Every user-facing surface in privacytracker is gated by the focus system: a layered resolver that takes the user’s chosen audience and goals and produces a per-flag value. Server code reads it with 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 from GET /api/feature-flags through lib/use-flag-bundle.ts — one shared, cached fetch per page load however many components ask for a flag.
There used to be a fifth module, lib/feature-flags-hooks.ts, exporting useFlag / useFocus over useSyncExternalStore. It was deleted, not deprecated. Those hooks resolved against an in-memory context that nothing primes in the browser — and since every page became a static client shell there is no per-request server render left that could. Each call fell through to HARD_DEFAULTS[key], so the component ignored the user’s focus and their overrides while typechecking perfectly. If you find that import in a branch, it predates the removal.
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 prefixed flag. so they grep cleanly and won’t collide with existing app_settings keys.
Examples:
Two reserved top-level keys (no surface):
  • 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 in app_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

Overrides are explicit — an absent row means “inherit the computed default from audience + goals”. This is what lets dev options show derivation (“on · because 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:
User overrides are always the final word.

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:
  1. Schema check — CREATE TABLE IF NOT EXISTS feature_flag_overrides plus any new app_settings indices.
  2. user_intent → audience+goals — read the legacy key, write flag.focus.audience + flag.focus.goal.*, drop user_intent.
  3. notification_prefs absorb — read the JSON blob, write each notification type as flag.notifications.types.* rows, drop the blob.
  4. Callout rename — drop override rows for old keys without carrying their values across.
  5. Quarantine check — scan feature_flag_overrides for rows whose flag_key isn’t in the registry’s FlagKey union, mark them quarantined = 1. Conversely, rehabilitate previously-quarantined rows whose keys are now known.
  6. Focus goal rename — move flag.focus.goal.understand onto flag.focus.goal.monitor and flag.focus.goal.declutter onto flag.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.
Each step is idempotent. Failures abort the migration and surface an error UI with the failing step name. Up to 3 retries; after that, the error screen offers a “Reset DB” escape hatch.

Kill-switch

Collapses every flag to its hard default. Use this if a release misbehaves; flipping it back on re-engages the rule engine without a code rollback.
  • Annotations (annotations table) 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.