Raxx · internal docs

internal · gated

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.

experiment

A toggle that switches a variant on for comparison. It stays as long as the experiment needs data.

ops

A switch an operator uses to change production behavior. Some ops toggles are kill switches.

permission

A toggle that gates a capability by role or group.

business

A toggle that gates a business capability that the product keeps for as long as it needs it.


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 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 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:

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:

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:

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

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


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:

Each evaluator reads the registry once per process, so a new percentage takes effect with the next deploy. The kill switch differs by surface:

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:

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.

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)


10. References

Internal sources:

External sources:


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.