Skip to content

A flag that never got cleaned up

The cycle shipped. The signal reading was green. The retrospective said "done". Eight months later, the feature flag is still in the codebase — its two branches diverging quietly, its name long lost from the brief, the if (flag.is_on) call sites multiplying. The flag has become the architecture.

The artefact

Excerpt — pull request, "Add per-tenant rate limit override", November 2026

typescript
// services/api/middleware/rate-limit.ts

async function checkRateLimit(req: Request): Promise<void> {
  const tenant = req.tenant

  if (await flags.isEnabled('hebrew_name_grading_flow', tenant)) {
    // grader queue with hebrew normalisation enabled
    const baseLimit = config.rateLimits.gradingV2
    return checkAgainst(baseLimit, req)
  }

  if (await flags.isEnabled('grading_v3_redesign', tenant)) {
    const baseLimit = config.rateLimits.gradingV3
    return checkAgainst(baseLimit, req)
  }

  if (await flags.isEnabled('experimental_legacy_grader_path', tenant)) {
    const baseLimit = config.rateLimits.gradingLegacy
    return checkAgainst(baseLimit, req)
  }

  // default
  return checkAgainst(config.rateLimits.default, req)
}

PR comment: "Adding per-tenant override for the new endpoint. Following the existing flag pattern. The existing flags handle the right fallbacks."

The PR shipped. The reviewer approved. The new flag was added in the same shape as the three already in the file. The team's flag inventory grew from three to four.

What the three flags above represent:

  • hebrew_name_grading_flow — shipped 7 months ago. Enabled for all tenants. Never removed.
  • grading_v3_redesign — a redesign cycle that was killed at week 3. The flag was disabled but the code path remained because what if we want to revisit?
  • experimental_legacy_grader_path — added during a 2024 migration; the migration is complete; nobody remembers what it was for.

A new developer reading this file faces three live code paths and one dead one — but cannot tell which is which without git archaeology.

What's wrong?

Stop. Find three things wrong before reading the diagnosis.

Diagnosis (open when ready)

1. The flag for the hebrew_name_grading_flow feature outlived its cycle

The cycle's prediction was checked at month 1. The signal moved. The flag was rolled to 100%. At that moment, the flag should have been scheduled for removal in the next cycle. Instead, it stayed in the code "in case we need to roll back". Seven months later, the rollback is irreversible (the data layer assumes Hebrew normalisation in many other paths), but the flag is still there. The branches of the if have diverged silently.

The corpus rule from As We Build · Feature Flags: every flag has a cleanup story scheduled at the cycle's release brief. The cleanup story is the cycle that deletes the flag and any dead branch.

2. The grading_v3_redesign flag is the architecture of a cycle that was killed

The redesign was killed in week 3 — at amigos, the trio agreed the brief was wrong. But the flag had already been added in week 1, and the code path it gated was already drafted. The dev moved on. The flag stayed in the file as scaffolding.

The corpus rule: a killed cycle's flag is deleted as part of the kill. Killing an initiative means killing its scaffolding. If the flag stayed, the kill was not complete — the team is still paying interest on a dead decision.

3. The experimental_legacy_grader_path flag is the runbook the team forgot existed

It guards a code path that may or may not run. No one knows. There is no test for the is_on=true branch because no one has flipped it in years. There is no runbook for what to do if it fires unexpectedly. The flag has become the architecture's hidden ghost — the kind of thing that fires at 3am during an unrelated incident and pages the on-call to a codepath they have never seen.

The corpus rule: every active flag has a runbook for the unhappy state. If you cannot write the runbook for "this flag fires unexpectedly", the flag should be removed.

The fix

Three discrete actions, none heroic:

text
Cycle 2026-Q4 · cleanup
------------------------------------------------------------
Story 1 — Remove hebrew_name_grading_flow flag and dead branch.
  Owner: the TL
  Sized: 2 days
  DoR:   includes verification that no other service reads
         this flag.

Story 2 — Remove grading_v3_redesign flag and unreferenced code path.
  Owner: the senior dev
  Sized: 1 day
  Note:  this completes the kill of the 2026-Q2 initiative.

Story 3 — Investigate experimental_legacy_grader_path.
  Owner: the TL
  Sized: 1 day spike → ADR
  Output: either ADR-XXX deprecating with named risks, or
          PR deleting the flag and its code path. Not both.

The cycle ships with four fewer code paths than it started. The rate-limit file becomes:

typescript
async function checkRateLimit(req: Request): Promise<void> {
  return checkAgainst(config.rateLimits.default, req)
}

The new PR (the one that added the per-tenant override) now has a different question to answer: why does this case need an override? Is the answer per-tenant rate limits, or per-tenant configuration? That conversation produces an ADR. The architecture sharpens.

Where this comes from in the chain

This failure traces to Execution (Level 4) but compounds at Operation (Level 5). The flag was added correctly at L4; the cleanup story was never created. The structural fix is at L4 — every flag's release brief includes the cleanup story by default.

A senior practitioner spots this in 30 seconds: any flag whose age is over two cycles old without a roadmap to removal is a chain-level signal. The conversation is why is this still here? — not can we delete it?

Worth noticing: the new PR (the one starting this clinic) was correct in pattern and wrong in architecture. The pattern-match is what made it pass review. The reviewer's structural question — why do we have three flags here? — would have caught it. This is the kind of question senior review imports into junior cycles.

See also

200apps · How We Work · NWIRE