Principle: Feature Toggle Taxonomy
Status: Adopted
Date: November 9, 2026 (UTC)
Epic: #1582
Card: #1593
Refs: ADR-0051, #1587, #1588, #1589, #1590, #4926, #1591, #1592
The Principle
Each feature flag in backend_v2/api/feature_flags.yaml should declare a category, and every entry does at this revision. The category says what the flag is for, how long it is expected to live, and which rules apply to it: whether a removal date is allowed, whether CI expects a test that covers both states, and whether a percentage rollout is allowed. Raxx uses the four toggle categories in Martin Fowler's article (release, experiment, ops, and permissioning), named permission in the registry, and a fifth category, business, that Wikipedia also describes.
In this document, Raptor is the backend API in backend_v2/, Console is the operator console in console/, and Antlers is the customer frontend in frontend/raxx-next/.
Where a rule is enforced by code, this document names the check. Where a rule is a convention, it says so.
1. Why We Categorize Toggles
Fowler's article groups toggles by purpose, and each group has a different expected lifetime (Hodgson). A release toggle lets code ship before its feature is ready and is expected to be removed after release. An experiment toggle lasts as long as the experiment needs data. An ops toggle lets an operator change production behavior, and some are kill switches that stay for the life of the system. A permissioning toggle gates a feature by user group and can last for years. Fowler also describes toggles as inventory with a carrying cost, and describes teams that add a removal task when a release toggle is introduced or that put expiration dates on their toggles (Hodgson). Wikipedia describes release and business toggles as the two main types, and it defines toggle debt as the dead code left behind after a toggle is permanently on ("Feature toggle").
The category lets a reader know a flag's expected lifetime and the CI rules that apply to it without reading the code that uses the flag.
2. The Five Categories
Category (category:) |
Expected lifetime | Removal date allowed | Test check | Percentage rollout |
|---|---|---|---|---|
release |
Generally a week or two (Fowler). Product-centric toggles may stay longer. | Yes. The category: release line must be explicit. |
Expected; CI warns when missing (section 6) | Yes, when surface is console, raptor, or multi |
experiment |
Hours or weeks (Fowler). Raxx keeps the flag for as long as the experiment needs data. | No | Expected; CI warns when missing (section 6) | Yes, when surface is console, raptor, or multi |
ops |
Most are short-lived (Fowler). Kill switches may stay for the life of the system. | No | Not checked | No |
permission |
Long-lived, up to multiple years (Fowler). | No | Not checked | No |
business |
Not defined by Fowler. Wikipedia describes a business toggle as kept for a usage that the older code does not serve. | No | Not checked | No |
Every registry entry declares a category. The Console treats a missing or invalid category as release. The lint requires an explicit category line only on entries that set decommission_after or rollout_percentage, and it rejects those entries when the line is missing or does not match the rule for the field.
release
A toggle that lets code merge and deploy before its feature is ready for users. It is removed after the feature ships.
console_single_pane(default ON,decommission_after: 2027-03-31): the Console single-pane shell. Its registry comment says the flag and the legacy navigation branch are removed by the date.mbt_engine(default OFF,raptor): the MBT Raxx-native paper-trading engine, dark in Phase 1 according to its registry comment.
experiment
A toggle that switches a variant on for comparison. It stays as long as the experiment needs data.
demo_founders_cta_variant(default OFF,antlers): the Founders three-month free call-to-action variant in the demo conversion overlay (#490). Antlers flags are evaluated at build time and stay binary (section 7). The platform does not collect results for experiment flags (section 9).
ops
A switch an operator uses to change production behavior. Some ops toggles are kill switches.
drift_killswitch_enforce(default OFF,console): turns on enforce mode for the drift kill switch, so promote and reject return HTTP 409 for drifted flags (#2013).
permission
A toggle that gates a capability by role or group.
live_mode_rbac_gate(default OFF,raptor): returns 403 for live-mode support actions unless the caller holds theraptor-support-liverole or presents a valid token (#1259). This flag is also on the percentage-rollout exclusion list (section 7).
business
A toggle that gates a business capability that the product keeps for as long as it needs it.
billing_dunning(default OFF,queue): the dunning grace period and the automatic downgrade to Free after a failed payment (#4503). A business flag cannot take a percentage, because its category is neitherreleasenorexperiment(section 7).
3. Toggle Lifecycle
A flag moves through four states: new, active, candidate for decommission, and removed.
New
When a pull request adds a flag, CI requires the following:
- The entry declares
surface, which names the part of the system that reads the flag (for exampleconsole,raptor, orantlers).scripts/ci/validate_flag_surfaces.pychecks the value against the allowed list. - The entry has at least one
referencesentry (an issue or PR number). Check G inscripts/lint-feature-flags.pyrequires this for net-new flags. - The entry declares
categoryon its own line if it setsdecommission_afterorrollout_percentage(checks I and K). - The PR tree contains a migration under
console/migrations/versions/that names the flag key within 500 characters ofconsole_flag_promotions(ADR-0051, Layer B1, checked byscripts/ci/check_flag_promotion_migrations.py). The checker reads the PR tree, so the migration can be any file in it. By convention, the migration is added in the same PR, and it follows the structure of the exampleconsole/migrations/versions/0330_promote_toggle_debt_digest.py.
The B1 migration queues a promotion row for the flag. It does not change the flag's value. The target app comes from the flag's declared surface (console/app/services/promotions.py).
The B1 checker also accepts the text NO_PROD_FLAG in the PR body as an escape hatch for CI-only flags. The flag-promotion-check step passes an empty PR body and does not set GITHUB_PR_BODY, so the escape hatch does not apply in CI. A new flag needs its migration row.
Active
The flag is ON in at least one environment. Its value changes in these ways:
- A flip on the Console flags page. The Console writes a
console.flag.flipaudit row for the environment of the session. On the production console, a flip upserts the console's prod row for the flag. - A promotion from staging to prod, in these steps:
1. A row enters the queue in state
pending. Today that happens through the flag's B1 promotion migration (above). The Release pane of the single-pane Console (the Release domain, served byGET /api/pane/release, code inconsole/app/services/release_pane.py) also has a Mark for promotion action, but it is offered only where the route accepts it (promotions.mark_promote_refused). The route returns 404 whileFLAG_CONSOLE_FLAG_PROMOTIONSis off and 409env_mismatchoutside a staging console session. On a console withCONSOLE_ENV=stagingit then returns 403promote-from-prod-onlyfor a prod target app (#1579). Every target app is a prod app, so neither the production nor the staging console accepts the request today, and the Release pane hides the action on both. 2. The soak period runs. Its length is the flag'ssoak_period_hours. 3. On the Flag Releases page (/console/flags/promotions) of the production console, the row shows Soak complete. The operator clicks Deploy. Deploy is rendered for superadmin sessions only, and it is disabled while a dependency of the flag is OFF in the target environment. - A drift resolution on the production console. Revert prod to declared value (Release pane) and Resolve drift (flags page) call
resolve-drift. Itsdbwinner writes the row's declared value to the prod app's Heroku config, and itscustomwinner writes the chosen value. - A direct write with the Release pane's Write Heroku config var… on the production console (#2016, activated by #4187).
A console with CONSOLE_ENV=staging refuses every write to a prod app with 403 promote-from-prod-only (#1579): deploy, reject, drift resolution, mark-promote and the direct write. It hides those controls instead of offering them.
The flags page shows the promotion state in each row's menu, as Pending or Soaking, or the raw state word for other states. A drifted flag offers Resolve drift when the drift modal is on. The flags page has no control that starts a promotion. Its disabled Toggle for Production item was removed because it could never be enabled.
Candidate for decommission
A flag becomes a candidate through either of two signals:
- Date. A Release toggle whose
decommission_afterdate has passed fails the lint on the next registry-touching PR (section 4). The toggle debt digest lists it (section 5). - Age. The Console flags page shows a
candidatebadge (#1587) on a Release toggle that is ON in prod and whose latest deployed promotion is more than 90 days old. The badge is a display heuristic. It runs at page render, adds no job or cron, and never marks a flag that has no deployed promotion row.
Ops, permission, and business entries have no removal date, and the age badge applies only to release toggles.
Removed
A reviewed pull request deletes the registry entry, the toggle code, and the tests that cover the flag's states. Nothing removes a flag automatically (section 9).
4. The decommission_after Contract
decommission_after is an optional field on a flag entry. It names the UTC calendar date by which the flag should be removed from the registry and its toggle code deleted:
console_single_pane:
default: true
category: release
decommission_after: 2027-03-31
What the lint checks
Check I in scripts/lint-feature-flags.py applies these rules:
- The field is allowed only when the entry says
category: releaseon its own line. Any other category, or an entry with nocategoryline, fails and names the flag. - The value is a calendar date in
YYYY-MM-DDform, either unquoted (2027-03-31, which YAML reads as a date) or quoted ("2027-03-31"). A value with a time, an impossible date such as2027-02-30, or any other format fails and names the flag. - An unquoted impossible date also makes
yaml.safe_loadfail for the whole registry. The lint reports it first, with the flag name and line, and stops before the other checks. The runtime readers then differ. The Console's lenient loader logs an error and serves an empty registry, so flags evaluated for a subject serve OFF. When the digest flag evaluates ON, the digest's strict read raises and sends nothing. With the registry failed, the YAML default reads as OFF, so the flag can evaluate ON only through its environment variable or a database row. The Raptor loader raises on eachis_enabledcall.
Who sets it
The date is a value in the registry. It is set and moved only in a reviewed edit of backend_v2/api/feature_flags.yaml, which is the edit the lint message asks for. The lint and the digest read the date. Neither one chooses it. The code applies no default grace period, so each Release toggle carries its own date. One registry entry sets the field at this revision: console_single_pane.
When it passes
Check J compares the date with the current UTC date. A date equal to today passes. The first failing day is the day after the date.
Where the overdue check runs
Check J runs in these pipelines:
flag-promotion-checkin.woodpecker/ci-pr.yaml, on pull requests that touch the registry or the Antlers deploy files.- The
validatestep of.woodpecker/deploy-flag-docs.yaml, on pull requests and on pushes todevelopandmainthat touch the registry,scripts/build-flag-docs.py, or that pipeline file.
The smoke suite (scripts/ci/run_smoke.sh) and the local gate (scripts/ci/run_local.sh) pass --skip-expiry. They still run checks H and I, but not J, so ordinary code pull requests do not fail on an overdue flag.
What a failure means
After the date passes, the next registry-touching pull request fails. It keeps failing until the flag is removed or its date is moved in a reviewed edit. The check reads no stored state, so deleting the field or the flag also clears it. The check blocks by design. Do not disable it, and do not pass --skip-expiry in a registry gate.
To check a registry change before pushing, run python3 scripts/lint-feature-flags.py from the repository root. Exit code 0 (pass) and exit code 3 (warnings only) both pass CI. Exit code 1 means a failure, and the output names the flag and the rule. Exit code 2 means the registry file was not found or could not be parsed, and CI treats it as a failure.
What the date does not do
The date does not change the flag's value. The Raptor runtime (backend_v2/api/feature_flags.py) does not read decommission_after. The Console reads it only for the flags page and the digest. The date does not stop the application from starting, and nothing removes the flag.
What the Console shows
The /console/flags page shows the date on each row (#4926). The date is muted while it is in the future, amber on the day itself ("due today"), and red after it, with the number of days overdue. A value the page cannot read as a date is marked invalid.
5. Sunset Notification
The toggle debt digest (#1589) sends at most one plain-text email a day to ops@raxx.app through Postmark, and only when a Release toggle is overdue. The sender is no-reply@raxx.app. The email lists each Release toggle whose decommission_after date is before the current UTC date. Each line gives the flag key, the date, and the number of days overdue. The email includes nothing else from the registry, such as descriptions or references.
Trigger
- The digest runs inside the Console. When the flag is ON, a scheduler thread starts with the Console process and checks every
TOGGLE_DEBT_DIGEST_TICK_Sseconds (default 900). - A check sends the email at or after 09:00 UTC when no
console.toggle_debt_digest.sentaudit row exists for the UTC date. A process that restarts after 09:00 UTC sends the day's digest once, on its next check. - When no flag is overdue, the digest sends no email and writes no audit row.
- The gate is
FLAG_TOGGLE_DEBT_DIGEST(registry keytoggle_debt_digest). The registry default is OFF. Turning the flag off stops the digest within one check. Turning it on starts the scheduler at the next process restart. - If the flag is ON and
POSTMARK_SERVER_TOKENis not set, the run is an error. It is not a silent skip. console/scripts/toggle_debt_digest.pyruns the same logic once for a manual run. The manual run skips the 09:00 UTC check.
Recipient and expected action
The recipient is ops@raxx.app. The email body states the expected action: for each flag, remove the flag and its toggle code, or move the date in a reviewed edit of backend_v2/api/feature_flags.yaml.
Failures
Each failure is logged at ERROR, and the scheduler retries on its next check. The Console has no Sentry initialization, so the ERROR log line is the alert. Enabling the digest in production requires a log-drain alert on those lines (see the docstring of the 0330_promote_toggle_debt_digest.py migration for the full list of operator prerequisites).
Concurrency and duplicates
The digest sends at most one email per UTC date in normal operation. Each run takes a per-run pg_try_advisory_xact_lock. A run that finds the lock held by another worker sends nothing and reports locked. A run also checks for the day's audit row before it sends.
A duplicate can occur only after a send succeeds and its audit row then fails to write. The process retries the row instead of sending again. A different worker process, or one started after a restart, does not know about the earlier send and can send once more. Every send without a row is logged at ERROR with its Postmark MessageID.
6. Test Discipline
A release or experiment flag should have a test that exercises both of its states. CI warns when a changed flag has no such test. Phase 1 is warning only (see Phases and dates below). The convention is one pytest test with two marks. The flag_state marker is registered in backend_v2/conftest.py:
@pytest.mark.flag_state("<flag_key>")
@pytest.mark.parametrize("flag_on", [True, False])
def test_<behavior>(flag_on, monkeypatch):
monkeypatch.setenv("FLAG_<FLAG_KEY>", "1" if flag_on else "0")
reload_flags()
# assert the ON behavior when flag_on, the OFF behavior otherwise
The reference example is test_aasa_route_honors_both_flag_states in backend_v2/tests/test_aasa_well_known_174.py, which covers the ios_aasa flag.
Which categories are checked
Only release and experiment are checked. An entry with no category line counts as release, which is the runtime default. ops, permission, and business are excluded.
What triggers the check
The flag-test-states-check step in .woodpecker/ci-pr.yaml runs on pull requests that touch backend_v2/api/feature_flags.yaml. It maps each changed flag entry to its category. A changed release or experiment entry with no matching test prints a warning. Comment-only edits, blank lines, and removed flags are ignored.
What the check reads
The check reads the marks and the parametrize values only. It does not confirm that the test body uses flag_on or sets the environment variable, and it does not resolve values imported from another module. Jest and Vitest tests under frontend/raxx-next/ are not recognized in Phase 1.
Phases and dates
- Phase 1, from November 9, 2026 (#1592, PR #4928). The check is warning only. The step is
failure: ignore, and the script exits 0 on findings. A missing test does not block the PR during Phase 1. - Grace period, through December 9, 2026. After that date, the script prints a notice that escalation is due. It still exits 0.
- Phase 2. The decision to escalate to a hard block, or to keep the check as a warning, is recorded on #1582 by December 9, 2026. A hard block would make missing coverage exit 1, remove
failure: ignorefrom the step in.woodpecker/ci-pr.yaml, and settle whether frontend tests count.
7. Percentage Rollout
A rollout_percentage field, an integer from 0 to 100, narrows a flag that is already ON. It never turns on a flag whose base value is OFF. The field was added in #1590 and PR #4941 (70e501c73), and its read-only display in #4926.
Evaluation
Both evaluators apply the same rule after the base value:
| Base value | rollout_percentage |
Subject | Result |
|---|---|---|---|
| OFF | Any | Any | OFF |
| ON | Absent, or 100 | Any, or none | ON |
| ON | 0 | Any | OFF |
| ON | 1 to 99 | None | OFF |
| ON | 1 to 99 | Present | ON when the bucket is less than the percentage |
The bucket is the first eight bytes of the SHA-256 digest of flag_key:subject, read as a big-endian integer, modulo 100. The Python built-in hash() is not used, because it is salted per process. The subject is the user ID when there is one and the session ID otherwise. A session ID changes with each session, so cross-session stickiness is out of scope. Both test suites read backend_v2/tests/fixtures/flag_rollout_bucket_vectors.json, so either implementation fails its tests if the two disagree.
The base value keeps each surface's existing precedence:
- Raptor (
backend_v2/api/feature_flags.py,is_enabled(name, subject=None)): theFLAG_<NAME>environment variable overrides the YAML default. There is no database layer. - Console (
console/app/services/flags.py,is_on(flag_key, env, subject)): the database row (whenFLAG_CONSOLE_FEATURE_FLAGS_DBis on) comes first, then the environment variable, then the YAML default. The base value is cached for 30 seconds per process. The bucket is computed after the cache read.
Each evaluator reads the registry once per process, so a new percentage takes effect with the next deploy. The kill switch differs by surface:
- Raptor:
FLAG_<NAME>is read once per process. A changed value takes effect at the next dyno start, and a Heroku config change restarts the dynos. - Console: the database row outranks the environment variable when
FLAG_CONSOLE_FEATURE_FLAGS_DBis on. The flip on/console/flagswrites the row whenever its route accepts the request (the route also returns 404 whileconsole_flag_promotionsis off). The row is read only whileFLAG_CONSOLE_FEATURE_FLAGS_DBis on, so the flip is a kill switch for a Console flag only under that condition.
Without a subject, a flag with a percentage from 0 to 99 evaluates OFF. A flag at 100, or with no field, keeps its base value. GET /api/flags has no subject, so a flag at 1 to 99 reports false there. Every value in that response stays a JSON boolean, because iOS decodes the map as a strict [String: Bool]. The rollout_percentage value is never part of the response.
A value that is not an integer from 0 to 100 (a boolean, a float, a string, or 150) evaluates OFF for every subject. The Raptor evaluator logs a warning once per load. The Console evaluator returns OFF without logging. The lint rejects the value in CI.
Failure behavior differs by surface. While the Console registry fails to load, flags evaluated for a subject serve OFF, and the flags page shows a banner (registry_load_failed()). Raptor has no fail-closed fallback. Its loader raises when the registry does not parse, so each is_enabled call raises. The lint rejects an unparsable registry in CI when the registry changes.
Where the field may be set
The lint (check K, scripts/lint-feature-flags.py) applies these rules:
- The entry declares
category: releaseorcategory: experiment, on its own line. - The entry declares
surface: console,raptor, ormulti, on its own line. Antlers flags are client-side and evaluated at build time, so they stay binary. - The value is an integer from 0 to 100.
Check L fails a flag that sets the field and is named in an Antlers build, either through NEXT_PUBLIC_FLAGS or through a FLAG_* or NEXT_PUBLIC_FLAG_* environment entry in .woodpecker/deploy-antlers-*.yaml. The guard reads the names only, not the values, so FLAG_X: "0" fails the same way FLAG_X: "1" does.
Every flag that sets the field prints a warning that asks the author to confirm the constraints below. The warning never fails the lint.
Not permitted
The text below is the signed-off wording. The business, legal and research review (BLR) signed it off on November 9, 2026 (UTC). It is copied verbatim from the module docstring of backend_v2/api/feature_flags.py (lines 81 to 90). The Console evaluator's docstring in console/app/services/flags.py repeats the same text.
rollout_percentage is a delivery control for new, optional capabilities. Do not set it on a flag that gates or changes:
1. a live-order path: code that creates, changes, cancels, routes or sizes an order on a user's real brokerage account, or decides whether a rule-triggered order fires;
2. a compliance or safety control, including the paper-first gate, order-safety guards, verification gates, geo-blocks and kill switches (the percentage narrows the flag, so it would switch the control off for everyone outside the bucket);
3. a disclosure, consent, notice or acknowledgment a user must see or give;
4. pricing, trial, billing or refund terms;
5. something the user's current plan or the Terms already promise.
A percentage on a flag whose registry comment names BLR, an attorney or a legal precondition counts as a public rollout of that flag.
BLR signed off on this wording on 2026-11-09 UTC (research disposition, not a legal opinion).
The summary below is for orientation only. The block above governs.
- Live-order paths: code that creates, changes, cancels, routes, or sizes an order on a user's real brokerage account, or that decides whether a rule-triggered order fires.
- Compliance or safety controls: the paper-first gate, order-safety guards, verification gates, geo-blocks, and kill switches.
- Required disclosures or consents: any disclosure, consent, notice, or acknowledgment a user must see or give.
- Pricing or billing terms: pricing, trial, billing, and refund terms.
- Promised entitlements: anything the user's current plan or the Terms already promise.
A percentage on a flag whose registry comment names BLR, an attorney, or a legal precondition counts as a public rollout of that flag. The lint does not check those comments. Review is manual.
Enforcement is layered. Check K blocks the field on every ops, permission, and business flag, because their categories are not release or experiment. Among release and experiment flags on the console, raptor, and multi surfaces, the lint blocks only the names in NEVER_PERCENTAGE. Every other flag on those surfaces that sets the field gets the confirmation warning, and review decides. A flag in an excluded class that is not in NEVER_PERCENTAGE is blocked only by its category or surface.
The list lives in NEVER_PERCENTAGE in scripts/lint-feature-flags.py. The lint fails any listed flag that sets the field. Some entries were added on QA review of PR #4941 and others on architect review of PR #4944. The code comment in NEVER_PERCENTAGE says that the entries added on review await BLR confirmation. Editing the list requires a recorded BLR decision.
Current state and changes
No registry entry sets rollout_percentage at this revision. A value changes only through a pull request on the registry. The Console displays the value read-only (#4926): a badge on the flag row and a caption for each environment. The Console has no database column and no edit control for the value.
8. Enforcement Points
| Check | Enforces | Runs in | Effect |
|---|---|---|---|
| Lint, full (A to L, including J) | Schema, duplicate keys (H), decommission_after (I, J), rollout (K, L) |
flag-promotion-check, on registry and Antlers deploy-file changes |
Fails the PR on error; warnings pass (exit 3) |
Lint, with --skip-expiry (every check except J) |
Same rules, without the overdue check | Smoke suite (scripts/ci/run_smoke.sh) and local gate (scripts/ci/run_local.sh) |
Fails the PR on error |
| Lint, full | Same as above | deploy-flag-docs validate, on registry, scripts/build-flag-docs.py, and pipeline changes |
Fails the PR on error |
| B1 migration check | New flag key has a console_flag_promotions migration row |
flag-promotion-check |
Fails the PR |
| Surface check | Every entry declares a valid surface. A present category must be one of the values in _ALLOWED_CATEGORIES |
flag-promotion-check (scripts/ci/validate_flag_surfaces.py) |
Fails the PR |
| Test-state check | release and experiment changes have an on and off test |
flag-test-states-check |
Warning only until the Phase 2 decision |
Check H, duplicate mapping keys at any depth, is never demoted. The --warn-only option of the lint does not demote H, I, J, K, or L.
9. Deliberately Not Adopted (Yet)
- Experiment analytics. The platform does not collect experiment results or calculate statistical significance. An
experimentflag records intent only. Event tracking is separate:demo_posthog_events, also in theexperimentcategory, sends PostHog demo funnel events, which are product analytics and not experiment results. Experiment tooling in PostHog is under evaluation in a draft (docs/architecture/posthog-raxx-adoption.md). - Autonomous flag removal. No code deletes a flag, edits the registry, or opens a pull request to remove a flag. The lint and the digest report overdue flags, and a reviewed change removes them.
- Per-flag Slack alerts for toggle debt. Per-flag Slack alerts were not adopted. Toggle debt reaches ops@ as the daily digest (section 5).
- Toggle-debt cron and dashboard tile (sub-card TT-2 of #1582). Dropped by the operator on May 10, 2026 (UTC), as recorded on #1582. Detection is the
decommission_aftercheck (#1588) and the digest (#1589). The display is the age badge (#1587). - User-attribute targeting by cohort, tier, or geography (#1591). Closed and not built. The per-user flag
beta_cohort_core_accessserves the beta cohort today. The trigger recorded on #1582 is to reopen #1591 only when Queue exposes tier, cohort, and country context (#1517, blocked) and a concrete flag needs it. Until then #1591 stays closed. - Runtime editing of a rollout percentage. The value changes only through a pull request. The Console has no edit path and no database column for it. Runtime editing needs its own ADR before anyone builds it.
- Percentage rollout for client-side (Antlers) flags. Antlers flags stay binary, because they are evaluated at build time.
- Percentage rollout on the excluded classes. Live-order paths, compliance or safety controls, required disclosures or consents, pricing or billing terms, and promised entitlements are not permitted (section 7).
10. References
Internal sources:
- ADR-0051, drift prevention layered controls (Layer B1 is the flag-promotion gate):
../adr/0051-drift-prevention-layered-controls.md - ADR-0098, flag operator UX and Pony-style internal docs:
../adr/0098-flag-operator-ux-and-pony-docs.md. Check G inscripts/lint-feature-flags.pyrequires at least onereferencesentry on a new flag. - Epic #1582, feature toggle taxonomy and decommissioning practices
-
1587, toggle category tagging and the age badge (shipped)
-
1588,
decommission_afterand the duplicate-key check (PR-A and PR-B) -
1589, toggle debt digest
-
1590 and PR #4941 (
70e501c73), percentage rollout (backend), and #4926, the read-only display on/console/flags -
1591, user-attribute targeting (closed, not adopted)
-
1592, flag test-state check (PR #4928)
External sources:
- Hodgson, Pete. "Feature Toggles (aka Feature Flags)." Martin Fowler's article, last updated October 9, 2017 (cited in text as Hodgson):
https://martinfowler.com/articles/feature-toggles.html - "Feature toggle." Wikipedia (cited in text as "Feature toggle"):
https://en.wikipedia.org/wiki/Feature_toggle
Works Cited
Hodgson, Pete. "Feature Toggles (aka Feature Flags)." martinfowler.com, 9 Oct. 2017, https://martinfowler.com/articles/feature-toggles.html.
"Feature toggle." Wikipedia, Wikimedia Foundation, https://en.wikipedia.org/wiki/Feature_toggle. Accessed 9 Nov. 2026.
Last reviewed November 10, 2026 (UTC). Update this page when the Phase 2 decision for the flag test-state check is recorded on #1582, when #1591 is reopened, or when a rule above changes in code.