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

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

The optional limit applies after filtering by flag: ?flag=beta&limit=20 returns the latest 20 changes to that flag, even when other flags have newer entries.

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

On the durable tier, the override and its audit entry commit in one transaction. Concurrent toggles lock the same override row before reading its previous state, including when the override is first created. Only the enabled state changes; targeting and rollout remain intact. The local registry is reloaded from durable state after the commit. Overlapping reloads discard superseded responses, so a slow older read cannot overwrite the newer state. If persistence or its confirmation fails, the endpoint returns 503 and attempts to reconcile the registry. A 503 does not prove that the write was rolled back: refresh the flag state and audit history before retrying. An unavailable store refuses the change; it does not fall back to a memory-only toggle.

After an unconfirmed toggle, the dashboard blocks further toggles until a fresh, successful flag snapshot arrives. It requests a refresh when the host supports it and refreshes the audit trail as well. If the refresh fails, controls remain blocked; retry the read rather than repeating the write.

The dashboard validates flag lists, lifecycle evidence, audit history and toggle replies before using them. Malformed reads show an explicit error and hide only the affected section; flag controls remain available when only the audit history fails. A provider-backed refresh reloads the observation without repeating the write. A malformed toggle reply is an unconfirmed change: further toggles remain blocked until a fresh valid flag snapshot arrives.

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

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

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

Both dashboards select the effective plugin instance before reading or toggling flags. The evaluated values still belong to the app-wide registry in the serving process: another flags installation does not create an isolated flag namespace. List observations carry process scope; audit observations identify the configured shared store, or report that the audit store is unavailable. Failed reads hide stale controls and lifecycle findings. A toggle reports refusal instead of implying a successful change.

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

Kill-switch history

Both dashboards show the latest 100 entries from GET /_voltro/inspect/plugins/flags/audit, including previous/current state, actor and timestamp. Filter within the returned window by flag key. The history requires the PostgreSQL flag store; an unavailable store is shown explicitly. Cloud limits this audit read to organization owners and admins on the server as well as in the UI. Experiment results are available in the separate Experiments view.

Dashboard toggle feedback

The shared flag panel serializes toggle submissions, including rapid clicks on different flags. While a request is pending, other toggles are disabled. A rejected request leaves the displayed state unchanged, shows its error, and allows a deliberate retry. Successful responses update the visible state; local DevTools also requests a fresh snapshot immediately. Read-only viewers cannot submit a toggle.

Flag toggles from a selected replica observation target that replica explicitly in both dashboard adapters. A combined selection cannot issue a toggle; open one replica observation first. In-memory flags change only in that process. PostgreSQL-backed overrides additionally use the plugin’s existing persistence and propagation path.