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

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

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:

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:

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 startAtfrom%, after endAtto%), 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:

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.

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

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
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 countisFlagEnabled / 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

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:

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:

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 gategatedBy: { '<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:

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:

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