Measuring Flag Debt with a Staleness Score

This how-to is part of Managing Flag Deprecation and Cleanup. “We should clean up old flags” is a good intention that never gets prioritized because debt is invisible. This how-to makes it visible: a staleness score computed from a flag’s age, last change, evaluation skew, and code references, so you can rank flags for cleanup, target the worst offenders, and track total flag debt as a number that goes down.

The reason flag debt festers is that no individual flag ever feels urgent — each stale toggle costs you a few extra branches, a slightly harder-to-reason-about rollout, one more entry in the dashboard — and none of that shows up on a burndown chart. A score changes the economics: it converts a diffuse, deferrable liability into a ranked list with a number attached, which is the form work has to take before anyone will schedule it. Aim for a score in the range 0 to 1 per flag so it reads like a probability of “this flag is dead,” and keep the inputs cheap enough to recompute nightly — the whole approach only works if the number stays fresh without a human babysitting it.

Four signals compose a staleness score Flag age, time since last change, evaluation skew toward a single variant, and the number of code references combine into a single staleness score that ranks flags for cleanup. age time since last change evaluation skew code references staleness score weighted sum ranked list cleanup queue
Each signal on its own is noisy; combined into one score they rank flags so the cleanup queue targets the genuinely dead ones first.

Prerequisites

Staleness-score prerequisites Flag timestamps, per-flag evaluation metrics, and a code-reference count. Timestamps created + modified Eval metrics per-flag skew Code references grep / index
Evaluation skew is the highest-signal input — a flag that always resolves to one variant is doing nothing but adding a branch.

Step-by-Step Procedure

Step 1 — Gather the four signals per flag

Collect age, days since last change, evaluation skew (how one-sided the variant split is), and the count of code references for each flag. Age and last-change both come from flag-management metadata — most SDKs and control planes stamp a created and modified timestamp on every flag, and if yours does not, an audit log gives you the same thing. Skew comes from your evaluation telemetry: over a trailing window (28 days is a reasonable default), what fraction of resolutions returned the single most-common variant. Code references come from a repo grep or, better, a pre-built symbol index — grepping every repo on every run is slow and misses monorepo edges, so cache the count and refresh it on merge to keep the nightly job fast. Pull all four for a flag in one pass; a flag you cannot get all four signals for is itself a signal that something is misconfigured.

# signals.py — the four inputs
def signals(flag) -> dict:
    return {
        "age_days": days_since(flag.created),
        "since_change_days": days_since(flag.modified),
        "skew": max(flag.variant_share.values()),   # 1.0 = always one variant
        "refs": count_code_references(flag.key),
    }

Step 2 — Normalize and weight into a score

Scale each signal to 0–1 and combine with weights that reflect how strongly each predicts a dead flag — skew and time-since-change usually dominate. Normalization matters as much as the weights: an unbounded raw value (a flag 900 days old, a flag referenced 40 times) would otherwise swamp everything else, so each input is clamped with min(...) to a ceiling past which “more” tells you nothing. The ceilings encode judgment — 365 days for age, 180 days without a change, 5 references as “well-embedded” — and they are the first thing to adjust for your codebase, not the weights. Note the skew term maps a 50/50 split to 0 and a fully one-sided split to 1, so an even experiment contributes nothing to staleness no matter how old it is; that is deliberate, because a flag people are still actively split-testing is not debt. Keep the weights summing to 1 so the output stays in [0, 1] and stays comparable across runs.

# score.py — weighted staleness score in [0, 1]
W = {"age": 0.15, "since_change": 0.35, "skew": 0.4, "refs": 0.1}
def staleness(s: dict) -> float:
    age = min(s["age_days"] / 365, 1)
    stale = min(s["since_change_days"] / 180, 1)
    skew = (s["skew"] - 0.5) / 0.5            # 0.5 split -> 0, one-sided -> 1
    refs = 1 - min(s["refs"] / 5, 1)          # fewer refs -> more stale
    return W["age"]*age + W["since_change"]*stale + W["skew"]*skew + W["refs"]*refs

Step 3 — Rank and route to owners

Sort flags by score and route the worst to their owners as cleanup candidates, so the work lands with the people who can safely remove each flag. Routing to an owner is what turns a report nobody reads into work someone does — a ticket assigned to the team that shipped the flag carries the context needed to delete it safely, whereas a central “please clean up” list gets ignored. Cap the batch (the example takes the top 20) so you create a steady, absorbable trickle of cleanup tickets rather than a 300-item dump that demoralizes everyone and gets closed en masse as “won’t fix.” Stamp the score onto the ticket so the owner can see why the flag surfaced, and re-run the ranking after each cleanup cycle so newly-promoted flags take the place of the ones just removed.

# route.py — top staleness -> owner queue
ranked = sorted(all_flags, key=lambda f: staleness(signals(f)), reverse=True)
for f in ranked[:20]:
    open_cleanup_ticket(owner=f.owner, flag=f.key, score=round(staleness(signals(f)), 2))

Step 4 — Track total debt over time

Sum the scores into a single fleet-wide debt number and chart it, so cleanup effort shows up as a downward trend and new debt is visible as it accrues. A sum rather than an average is the right aggregate here: an average hides fleet growth, because adding ten pristine new flags drags the mean down and makes debt look like it fell when it actually rose. The total, by contrast, moves up when you accumulate debt and down only when you retire it, which is the behavior you want a chart to reward. Emit it on a fixed cadence — weekly is enough to see the trend without chasing daily noise — and consider splitting the total by team so each group can see its own line, which turns a shared abstraction into a scoreboard people act on. When the line trends up for several weeks running, that is your signal to tighten the deprecation and cleanup process, not to fiddle with the score.

# debt.py — one number to track
total_debt = sum(staleness(signals(f)) for f in all_flags)
emit_metric("flag_debt_total", total_debt)   # chart this weekly
The four scoring steps Gather the four signals per flag, normalize and weight them into a score, rank and route to owners, and track total debt over time. 1 Gather four signals 2 Score weighted 3 Rank + route to owners 4 Track debt trend down
Turning debt into one tracked number (step 4) is what makes cleanup a measurable goal instead of a perpetual good intention.

Verification

Sanity-check the ranking against known-dead flags: a flag you know is stale (old, one-sided, few references) should score near the top, and a freshly-changed, evenly-split flag near the bottom. This is calibration, not just a passing test — pick five or six flags whose real status you already know cold, score them, and check the ordering matches your gut before you trust the number to route anyone’s work. If a flag you know is dead lands in the middle of the pack, one signal is fighting the others (often a stray dynamic reference inflating the ref count), and that tells you where to look. Keep these assertions in CI so a future weight change that quietly breaks the ranking fails loudly instead of silently mis-prioritizing cleanup for months.

# score.test.py
assert staleness(signals(known_dead_flag)) > 0.8   # old, skewed, unreferenced
assert staleness(signals(active_experiment)) < 0.3 # recent, ~50/50, many refs
Score matches intuition A known-dead flag scores near the top of the staleness range while an active experiment scores near the bottom. active 0.2 dead 0.85 0 1
If the known-dead flag does not score high, the weights are wrong — tune them against a handful of flags whose status you already know.

Gotchas & Edge Cases

Three staleness-score edge cases A permanent operational flag scoring high but not for removal, low-traffic flags with unreliable skew, and dynamic references the grep misses. Permanent flags kill switches score high but stay Low traffic skew unreliable weight it less Dynamic refs grep misses them don't auto-delete
The permanent-flag case is why the score ranks rather than deletes — a kill switch is stale-looking by design but must never be removed.

Troubleshooting & FAQ

The score flags things that turn out to still be in use. Why?

Usually the code-reference count missed a dynamic or indirect reference, so a live flag looked unreferenced. Treat the score as a prioritized review queue, not a delete list — a human confirms each removal. Improve the reference detection over time, but never let the score delete on its own.

What weights should I use?

Start with skew and time-since-change carrying the most weight, since a flag that never changes and always resolves the same way is the clearest dead-flag signal. Then tune against a set of flags whose real status you know (verification), adjusting until the ranking matches your judgment.

How is the staleness score different from just finding stale flags?

Binary “stale or not” thresholds miss the nuance and produce a flat list you cannot prioritize. A continuous score ranks flags so you attack the worst first, and summing it gives a single debt number you can track down over time — turning cleanup from a one-off purge into a managed, measurable process.

How often should I recompute the score?

Nightly is a good default, with the weekly total charted for trend. The signals barely move day to day, so a nightly job keeps the ranking current without churn, and it runs cheaply if you cache the code-reference count and refresh it on merge rather than grepping every repo on every run. The only thing that needs to be timely is the total-debt metric you chart, and weekly resolution is enough to see whether cleanup is winning.

Should a permanent flag be excluded from the debt total, or just from cleanup routing?

Exclude it from both. A kill switch or long-lived operational toggle scores high by design — old, one-sided, lightly referenced — so leaving it in the total inflates a number you can never bring down, which teaches people to ignore the chart. Tag permanent flags explicitly and filter them out before summing, so the debt total only reflects flags that are genuinely removable.

Can I use the score to gate new flag creation?

Not directly, and you should not try to. The score measures an existing flag’s staleness, not the wisdom of adding a new one, and a hard cap on flag count tends to push teams into reusing one flag for two purposes — which is worse debt than an extra toggle. Use the per-team debt trend as a soft signal instead: a team whose line keeps climbing owes cleanup before it opens new rollouts, enforced socially rather than by a gate that blocks a legitimate launch.