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