# Feature flags

> Feature flags — per-subject / per-tenant targeting, deterministic % rollouts, kill-switch, declarative rpc gating + client UI gating.



---

<!-- source: en/plugins/flags.md -->
## Feature flags

_Feature flags — per-subject / per-tenant targeting, deterministic % rollouts, kill-switch, declarative rpc gating + client UI gating._

`@voltro/plugin-flags` is feature flags done the framework way: flags as code (or runtime-toggleable), evaluated per caller with **targeting + deterministic % rollout**, gating rpc calls declaratively and the UI via a hook.

## Wiring

```ts
// app.config.ts
import { flagsPlugin } from '@voltro/plugin-flags'

export default {
  type: 'api' as const,
  name: 'api',
  plugins: [
    flagsPlugin({
      flags: {
        betaExport: false,                                            // kill-switch
        newDashboard: { rollout: 25, description: 'Gradual rollout' }, // 25% of subjects
        aiSummaries: { targeting: [{ metadata: { plan: 'pro' } }] },   // pro plans only
      },
      // Optional: fail a gated rpc with typed `FlagDisabled` BEFORE the handler runs.
      gatedBy: { 'reports.export': 'betaExport' },
    }),
  ],
}
```

A flag is a bare boolean (kill-switch) or `{ enabled?, rollout?, targeting?, rolloutBy?, variants?, offVariant?, schedule?, description? }`.

## Evaluation order

`enabled:false` (off for everyone) → **schedule** (time-box + ramp, see below) → **targeting** (OR-of-rules; each rule ANDs `subjectIds` / `tenantIds` / `subjectTypes` / `metadata`) → **% rollout** (0–100, deterministic FNV-1a bucket on the subject — or tenant via `rolloutBy:'tenant'` — so a caller stays in/out consistently across processes).

## Multivariate / variant flags

Beyond boolean on/off, a flag can carry a set of **variants** — named string / number / boolean / JSON values the flag resolves ONE of per subject. Allocation is deterministic and weighted: a subject stays in the same variant across calls and processes (an FNV-1a hash of `(flag:variant, id)`, independent of the % rollout bucket). Omit `weight` for an even split; `[{weight:3}, {weight:1}]` is a 75% / 25% split.

```ts
flagsPlugin({
  flags: {
    checkoutButton: {
      variants: [
        { name: 'control', value: 'Buy now' },
        { name: 'urgent',  value: 'Buy now — 2 left!', weight: 2 }, // 2× exposure
      ],
    },
    pricingConfig: {
      // JSON / number variants, not just strings
      variants: [
        { name: 'a', value: { plan: 'pro', seats: 5 } },
        { name: 'b', value: { plan: 'pro', seats: 10 } },
      ],
    },
  },
})
```

Resolve the served variant in the browser with `useVariant` / `useVariants`:

```tsx
import { useVariant } from '@voltro/plugin-flags/web'

const v = useVariant('checkoutButton')   // { name, value, enabled } | undefined
return <button>{String(v?.value ?? 'Buy now')}</button>
```

Server-side, `evaluateVariant(key, value, subject)` returns `{ name, value, enabled }`. A boolean flag surfaces a synthetic `{ name: 'on' | 'off', value, enabled }`, so every flag has a uniform variant shape. Set `offVariant: '<name>'` to make the boolean resolution (`isFlagEnabled` / `flags.evaluate`) treat one variant as "off".

## Scheduled / time-boxed rollouts

A flag can carry a `schedule`, evaluated against the current time:

```ts
flagsPlugin({
  flags: {
    // Time-boxed: off before activateAt, on inside, off at/after deactivateAt.
    blackFridayBanner: {
      schedule: { activateAt: '2026-11-27T00:00:00Z', deactivateAt: '2026-11-28T00:00:00Z' },
    },
    // Ramping rollout: exposure grows 0% → 100% linearly across the window.
    gradualLaunch: {
      schedule: { ramp: { from: 0, to: 100, startAt: '2026-07-01T00:00:00Z', endAt: '2026-07-08T00:00:00Z' } },
    },
  },
})
```

Instants are epoch millis or ISO strings. A `ramp`'s interpolated percentage REPLACES the static `rollout` while active (before `startAt` ⇒ `from`%, after `endAt` ⇒ `to`%), so exposure increases smoothly over the window while every subject's in/out decision stays deterministic.

## Audit trail of kill-switch flips

On the `postgres` tier every `/toggle` flip is recorded to a durable, append-only `_voltro_feature_flag_audit` table (who, when, flag, old→new state). Read the trail — newest-first — via the inspect endpoint:

```http
GET /_voltro/inspect/plugins/flags/audit           # all flips, newest-first
GET /_voltro/inspect/plugins/flags/audit?flag=beta # one flag's history
```

The `POST /toggle` body accepts an optional `actor` (the acting admin id) that is stored on the audit row. On the `memory` tier there is no durable audit (the endpoint returns an empty trail with a note).

## Typed flags — `defineFlag()`

A flag's VALUE had no type. `FlagVariant.value` is the `FlagVariantValue` union (`string | number | boolean | null | array | object`), so `{ name: 'big', value: 'lots' }` on a flag every reader treats as a number typechecked, and the mistake showed up at the call site as `NaN`.

`defineFlag()` gives a flag a value **Schema**, and the flag is browser-safe by construction — ONE declaration, imported by `app.config.ts` and by the component that reads it.

```ts
// apps/api/lib/flags.ts
import { Schema } from 'effect'
import { defineFlag } from '@voltro/plugin-flags'

export const checkoutButton = defineFlag({
  key: 'checkout.button',
  value: Schema.Literal('blue', 'green'),
  default: 'blue',
  variants: [{ name: 'control', value: 'blue' }, { name: 'green', value: 'green' }],
})

export const pageSize = defineFlag({
  key: 'search.pageSize',
  value: Schema.Number,
  default: 20,
// default: 'twenty',   ← Type 'string' is not assignable to type 'number'
})
```

Register them with `flagsPlugin({ typedFlags: [checkoutButton, pageSize] })`. Keys share one namespace with `flags: { … }`; declaring a key in both is refused at construction.

Read them typed on either side:

```ts
import { flagValue, flagVariant } from '@voltro/plugin-flags'
const size: number = flagValue(ctx, pageSize)          // server
const arm = flagVariant(ctx, checkoutButton)           // the served variant NAME, or null
```

```tsx
import { useFlagValue } from '@voltro/plugin-flags/web'
const colour = useFlagValue(checkoutButton)            // 'blue' | 'green'
```

### Which half a Schema reaches, precisely

**Authored** values — `default`, every `variants[].value` — are checked by `tsc`. That is the headline and it is enforced by tests that fail `typecheck` if those lines ever start compiling.

**Runtime** values cannot be. A postgres-tier override row, or a dashboard edit, is JSON long after `tsc` ran. So the same Schema is the runtime gate: an override whose variant values do not decode is **refused whole**, the code-declared definition stands, and the refusal is logged and shown in the dashboard panel. Not partially applied — dropping the one bad arm re-normalises the weights of the rest, silently reallocating every subject.

Declaration also DECODES the authored default, which catches what a type cannot: `Schema.Int`'s TypeScript type is `number`, so `default: 20.5` typechecks and is a value the flag could never legally serve.

## Dead-flag detection

Every flag system accumulates flags nobody removes. `GET /_voltro/inspect/plugins/flags/list` carries a lifecycle report (rendered by the dashboard panel) built on two independent axes — and only some of it is a proof.

**`shape`** is decided from the DEFINITION alone. No observation, no window:

| shape | meaning |
|---|---|
| `constantOn` | `rollout` 100, no targeting, no variants, no live schedule — resolves `true` for everyone, forever |
| `constantOff` | `enabled: false`, or `rollout: 0` |
| `expired` | a `schedule.deactivateAt` that has passed — it can never be on again |
| `notYetActive` | a future `activateAt` — pending, not dead |
| `conditional` | genuinely selects between callers |

**`usage`** is decided from observed evaluations, and exactly one state is a proof:

| usage | meaning |
|---|---|
| `evaluated` | read server-side inside the threshold |
| `stale` | reads EXIST in the window and the newest is older than the threshold. **Provable** |
| `neverObserved` | no server-side read at all. Consistent with "dead" AND with "declared last Tuesday" — reported, never asserted, **never a removal candidate** |
| `untracked` | nothing is recording |

`removalCandidate` is set only by a proof: a constant/expired shape, or `stale`.

### What it cannot see

Shipped in the payload and rendered in the panel, not buried here:

- **Reachability is not decided.** "Not evaluated since \<date>" is a measurement; "this code path is dead" is not decidable in general. A Black-Friday flag, a flag behind an admin route nobody visited this month, and a flag whose last call site was deleted are indistinguishable.
- **Only SERVER-side reads count** — `isFlagEnabled` / `requireFlag` / `flagValue` / a `gatedBy` interception. `useFlag()` in the browser reads from the bulk set the server already sent, so the key never arrives as a named read. Bulk deliveries are recorded separately and never counted as use: one `useFlags()` poll evaluates the whole registry and would otherwise mark every flag in the app alive forever.
- **The window is finite**, bounded by the observation table's retention. A flag last read BEFORE the window has no observation at all and reads `neverObserved` — exactly what a flag declared this morning reads.
- On the day you turn tracking on, nothing has been observed, so every flag is `neverObserved` and nothing is proposed for removal. It is that SPLIT that prevents the day-one "everything is dead" report, not a coverage gate on top of it: such a gate is unreachable, because the observation that dates a stale flag is itself inside the window.

### Tunables

```ts
flagsPlugin({
  store: 'postgres',
  usage: {
    track: true,            // default: on with store:'postgres' (there is nowhere to write on memory)
    flushIntervalMs: 300_000,   // the report resolves to a DAY, so a tighter interval buys nothing
    staleAfterDays: 30,
    retentionDays: 90,      // also VOLTRO_FLAG_USAGE_TTL_HOURS; the report's observation ceiling
  },
})
```

Observations land in `_voltro_feature_flag_usage`, one row per (flag, UTC day, source), retention-swept. `track: true` on the `memory` tier is refused at construction rather than silently observing nothing, and `staleAfterDays > retentionDays` is refused because staleness could then never be proven.

## A flag can carry an experiment

`defineExperiment` (`@voltro/runtime`) maintains standing A/B results as **live IVM aggregates recomputed per-write from CDC deltas** — real-time uplift with no batch pipeline. A flag can name one:

```ts
export const checkoutButton = defineFlag({
  key: 'checkout.button',
  value: Schema.Literal('blue', 'green'),
  default: 'blue',
  variants: [{ name: 'control', value: 'blue' }, { name: 'green', value: 'green' }],
  experiment: 'checkout-colour',
})

// apps/api/experiments/checkoutColour.experiment.ts
export default defineExperiment({
  name: 'checkout-colour',
  on: { table: 'orders' },
  variantFrom: 'checkoutArm',                        // ← the arm the FLAG served
  variants: [{ name: 'control' }, { name: 'green' }],
  metric: { kind: 'conversionRate', column: 'completed' },
})
```

Persist the served arm on the row you want to measure:

```ts
await ctx.store.insert('orders', { …, checkoutArm: flagVariant(ctx, checkoutButton) })
```

**Why `variantFrom` and not just a name.** The flag assigns by hashing FNV-1a over `${key}:variant`; an experiment in `subject` mode hashes over the EXPERIMENT name. Both are stable and uniform, and they are INDEPENDENT — roughly half the subjects served `green` land in the experiment's `control` arm. The uplift would be live, precise, and measuring a split nobody experienced. One assignment, persisted, read by the experiment.

`flagsPlugin({ typedFlags, experiments })` **refuses to construct** when the link is wrong: a missing experiment, an experiment still in `subject` mode, an experiment-side holdout (carved AT assignment, which this experiment does not do), or arm names that do not match. Each of those is otherwise a wrong number rather than an error — a name mismatch shows up as a permanently-empty arm beside a permanently-full one, which reads as "the treatment has no effect".

## Three ways to use a flag

**1. Declarative gate** — `gatedBy: { '<rpcTag>': '<flag>' }` (exact tag or `/regex/`). An off flag fails the call with typed `FlagDisabled` before the handler runs (merged into every procedure's wire-error union → typed on the client).

**2. In-handler guard:**

```ts
import { requireFlag, isFlagEnabled } from '@voltro/plugin-flags'

export default (input, ctx) => Effect.gen(function* () {
  yield* requireFlag(ctx, 'aiSummaries')      // fails FlagDisabled if off for the caller
  // …or branch: if (isFlagEnabled(ctx, 'newDashboard')) { … }
})
```

**3. Client UI gating** — the `flags.evaluate` query returns the caller's resolved flag set:

```tsx
import { useFlags, useFlag } from '@voltro/plugin-flags/web'

const flags = useFlags()                 // { newDashboard: true, … }
if (useFlag('newDashboard')) return <NewDashboard />
```

## Dashboard panel

Both the local devtools dashboard and the cloud dashboard ship a **Flags** panel (api apps) — a live list of every resolved flag with its rollout % + a **kill-switch toggle**. Toggling flips the flag in the live registry immediately (gated on the `canToggleFlag` capability in cloud). Backed by the plugin's `/_voltro/inspect/plugins/flags/{list,toggle}` endpoints — the plugin declares `inspect:read` (the `list` read) **and** `inspect:write` (the state-mutating `toggle` POST).

## Store

`'memory'` (config-as-code, default) — flags live in `app.config`. `'postgres'` overlays runtime-toggleable overrides on the config baseline (the override wins, so toggling off in the DB beats the code default).

## Permissions

`rpc:intercept:{mutation,query,action}` only when `gatedBy` is set (otherwise none — the `flags.evaluate` route + guards need no interceptor).
