# Feature flags
Source: https://docs.privacytracker.privacykey.org/develop/feature-flags

The focus model — audience × goals × accessibility — and how it drives every user-facing surface.

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](#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](https://sites.plane.so/issues/39b6604351894f09a5e903acce37d265).

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

| Module | Role | Constraint |
|---|---|---|
| `lib/feature-flag-rules.ts` | Sparse rule tables: `HARD_DEFAULTS`, `AUDIENCE_RULES`, `GOAL_RULES`, `ACCESSIBILITY_RULES`, `FLAG_DEPENDENCIES`, `TOUR_STEPS` | Pure data + helpers. Server-safe. |
| `lib/feature-flags.ts` | Resolver (`resolveFlag`, `setResolverContext`), override mutators, cache accessors | Server-safe. **No React imports.** |
| `lib/feature-flags-server.ts` | `getResolverContextFromDb`, `resolveFlagFromDb` | `'server-only'`. |
| `lib/feature-flag-storage.ts` | SQLite reads/writes via better-sqlite3 | All synchronous. |

Plus two that exist only to make the Developer Options panel legible. Neither
affects resolution — nothing in the app branches on them:

| Module | Role |
|---|---|
| `lib/feature-flag-usage.ts` | Curated map of where each flag lives in the codebase and which route shows its effect. Powers the panel's hover-preview and its "Show me where" link. Deliberately partial — unlisted flags fall through to "no preview available". |
| `lib/feature-flag-wired.ts` | The set of flags actually consumed by component code today. The panel badges everything outside it "(no effect yet)", so a tester can tell a live toggle from an inert one. |

## 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.

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

| Hook | While loading / on failure | Use for |
|---|---|---|
| `useFlagBundle`, `useFlagValues`, `useResolvedFlag` | `null`, then fails **closed** | Gates where rendering the surface wrongly is the bug — label hints, tooltips, the social-share modal, `RequireFlagGate`. Hold render rather than paint and retract. |
| `useFlagValuesWithDefaults` | Seeds `HARD_DEFAULTS`, never `null`, keeps them on a failed read | Inline section gates (`{flagOn && <section/>}`) whose default is `on` and whose rules only subtract — most of Settings. |

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.

> **Note**
>
> `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.

```
flag.<surface>.<area>.<feature>
```

Examples:

```
flag.dashboard.hero.attention_state
flag.appgrid.filter.risk_buttons
flag.detail.timeline.wayback_rows
flag.detail.policy.ai_summary
flag.onboarding.step.ai_summaries
flag.settings.ai.debug_logging
flag.notifications.resume.wayback
flag.global.keyboard_shortcuts
```

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:

```
flag.focus.audience              = 'self' | 'loved_one' | 'guardian'
flag.focus.goal.monitor          = 'true' | 'false'
flag.focus.goal.cleanup          = 'true' | 'false'
flag.focus.goal.minimal          = 'true' | 'false'   (mutually exclusive with monitor/cleanup)
flag.focus.goal.accessibility    = 'true' | 'false'   (modifier — combines with any primary goal)
flag.focus.workflow              = 'self_monitor' | 'self_cleanup' | 'other_handoff' | 'other_monitor' | 'custom'
flag.focus.updated_at            = epoch milliseconds of the last write
```

`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

```sql
CREATE TABLE IF NOT EXISTS feature_flag_overrides (
  flag_key        TEXT    PRIMARY KEY,
  override_value  TEXT    NOT NULL,                          -- 'on' | 'off' | 'collapsed'
  set_at          INTEGER NOT NULL,
  set_by          TEXT    NOT NULL DEFAULT 'user',           -- 'user' | 'migration' | 'dev_preset' | 'restore'
  previous_focus  TEXT,                                       -- audience+goals snapshot at the time of override
  quarantined     INTEGER NOT NULL DEFAULT 0                  -- 1 if the flag_key is unknown to this app version
);
```

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:

```
resolveFlag(key):
  value ← HARD_DEFAULTS[key]
  value ← AUDIENCE_RULES[audience][key]      ?? value
  for goal in active goals:
    value ← GOAL_RULES[goal][key]            ?? value
  if accessibility modifier on:
    value ← ACCESSIBILITY_RULES[key]         ?? value
  value ← 'on' for two desktop-only flags inside the desktop app
  if FLAG_DEPENDENCIES[key] is hidden:
    value ← 'off'
  value ← user override                       ?? value   // final word
```

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

| Parent | Hides its dependents at | Leaves them alone at |
|---|---|---|
| Tri-state (hard default `'collapsed'`) | `'off'` | `'on'`, `'collapsed'` |
| Two-state (every other flag) | `'off'`, `'collapsed'` | `'on'` |

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:

**Step 1: Add the key to the union**

In `lib/feature-flag-rules.ts`, add it to the `FlagKey` union. Typos will fail at `tsc`.

**Step 2: Add a HARD_DEFAULTS entry**

Every key needs a hard default — that's what the kill-switch falls back to.

**Step 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

```
flag.devopts.feature_flag_system.enabled = off
```

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.

## Related

- **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.
