resolveFlagFromDb(); client components read the same resolved values over GET /api/feature-flags (see Reading a flag from a client component).
This page is the ergonomic summary. The full inventory and rollout reasoning live on the privacytracker Plane board.
The module split
Next 16 will refuse to build if these get tangled. Don’t break the layering. The four modules that carry the resolver:
Plus two that exist only to make the Developer Options panel legible. Neither
affects resolution — nothing in the app branches on them:
Reading a flag from a client component
Client components never touch the resolver. They read resolved values fromGET /api/feature-flags through lib/use-flag-bundle.ts — one shared,
cached fetch per page load however many components ask for a flag.
Pick the hook by what a wrong first paint costs:
The split matters on the failure path, not the first paint:
AppChrome
already holds the whole tree until the bundle settles, but it renders
anyway when the bundle cannot be read (its own chrome flags fail open).
A fail-closed read there would hand the user a Settings page with every
card missing.
useFlagValues and useFlagValuesWithDefaults return raw values.
Use them for any flag that can be 'collapsed' — a boolean hook reads
'collapsed' as off, which silently hides a surface whose default is
“visible but not expanded”.tests/app/client-flag-reads.test.ts in the app repo fails the build if
the deleted hooks return, if a client module imports the resolver, or if a
tri-state flag is read through a boolean hook.
Flag-key convention
All keys are lowercase, dot-separated, and prefixedflag. so they grep cleanly and won’t collide with existing app_settings keys.
flag.focus.active— the user’s currently selected focus (enum, not boolean).flag.focus.overrides_count— derived counter, used by the UI to show a “N custom overrides” hint. Not manually writable.
Storage model
Two pieces of state.Active focus
Seven rows inapp_settings — audience, four goal booleans, a workflow, and a timestamp:
setActiveFocus() in lib/feature-flag-storage.ts writes all seven in one transaction. The primary goals were re-keyed: understand became monitor and declutter became cleanup. The old names survive only as migration inputs (step 6 below) — nothing reads or writes them at runtime.
flag.focus.workflow is inferred from audience + goals when the caller doesn’t pass one, and collapses to custom whenever the answer is ambiguous — which is every loved_one and guardian flow, since those need a handoff-vs-monitor answer the goal tiles don’t ask for. The workflow gates audit-bundle export: POST /api/export/audit-bundle returns 403 unless flag.settings.admin.export.audit_bundle resolves to on or workflowAllowsAuditBundle() (lib/focus-workflow.ts) sees other_handoff.
Per-flag override
goal.monitor”) and spot overrides (“off (custom) · would be on because goal.monitor”) without mutating any focus config.
'collapsed' is a third legal value for flags that model “visible but not expanded by default”. Seven flags are tri-state, and they are exactly the ones whose hard default is 'collapsed': the accessibility panel, the annotations sidebar, the risk-tier legend, the Advanced accordion in Developer Options, and three policy diagnostics (the run-log strip, its full trace, and the per-chunk notes). Clients render a tri-state flag unless it is 'off'. Every other flag is two-state, and clients read anything but 'on' as off.
Resolution order
Layered. Each step may override the previous:Dependencies
FLAG_DEPENDENCIES maps a flag to the one it depends on, such as the
policy tab’s buttons to the policy panel, or the dashboard’s age-rating
callout to the guardian age-rating master. The parent is resolved through
the whole chain first, its own override included, and when it is hidden
the dependent resolves 'off'. A user override on the dependent still
wins, since overrides apply last.
What counts as hidden follows how clients read the parent
(parentHidesDependents in lib/feature-flag-rules.ts):
A
'collapsed' tri-state parent is on screen, so whatever sits inside it
keeps its own value. Two dependencies have a tri-state parent: the
accessibility preference highlights depend on the accessibility panel,
and the run log’s full trace depends on the run-log strip. So the
Accessibility tab highlights the features a saved accessibility profile
asks for under every focus, and the full trace shows, closed, under the
strip.
A two-state parent at 'collapsed' still hides its dependents. Developer
Options can set any flag to 'collapsed', and a two-state flag set that
way is off on screen, so its dependents follow it.
Adding a flag
Three edits, in this order:1
Add the key to the union
In
lib/feature-flag-rules.ts, add it to the FlagKey union. Typos will fail at tsc.2
Add a HARD_DEFAULTS entry
Every key needs a hard default — that’s what the kill-switch falls back to.
3
Add rules only if behaviour differs
Only populate
AUDIENCE_RULES, GOAL_RULES, or ACCESSIBILITY_RULES when the value should differ from the hard default for a given input. Most flags don’t care about most inputs — keep the tables sparse.Migration
lib/migrations/v1_feature_flags.ts (MIGRATION_VERSION = 2) runs eagerly in instrumentation.ts, in 6 ordered steps:
- Schema check —
CREATE TABLE IF NOT EXISTS feature_flag_overridesplus any newapp_settingsindices. user_intent→ audience+goals — read the legacy key, writeflag.focus.audience+flag.focus.goal.*, dropuser_intent.notification_prefsabsorb — read the JSON blob, write each notification type asflag.notifications.types.*rows, drop the blob.- Callout rename — drop override rows for old keys without carrying their values across.
- Quarantine check — scan
feature_flag_overridesfor rows whoseflag_keyisn’t in the registry’sFlagKeyunion, mark themquarantined = 1. Conversely, rehabilitate previously-quarantined rows whose keys are now known. - Focus goal rename — move
flag.focus.goal.understandontoflag.focus.goal.monitorandflag.focus.goal.declutterontoflag.focus.goal.cleanup, then delete the old rows. An install from before the re-key keeps its focus; a new key that already holds a value wins.
Kill-switch
Related
- Annotations (
annotationstable) sit on top of the flag system. Private notes (visibility = 'private') are unconditionally excluded from audit-bundle exports at the SQL level — there is no force-include path. - Audit bundle export (
lib/audit-bundle.ts) honours visibility, focus state, and quarantined overrides.