API · Feature flags

Feature flags wired turnkey with @voltro/plugin-flags — flags as code (kill-switch / % rollout / targeting), a declarative gatedBy map that fails typed FlagDisabled before the handler, an in-handler requireFlag guard, and flags.evaluate for useFlag() on the web. Memory store, zero infra.

Feature flags without a service. @voltro/plugin-flags declares flags as code and gates rpc calls two ways — declaratively (a gatedBy map fails FlagDisabled before the handler) and in-handler (requireFlag(ctx, key)). A flag is a bare boolean (kill-switch) or a rich definition with a deterministic % rollout and OR-of-rules targeting. The client gets the same evaluation via useFlag(). Memory store is zero-infra; postgres adds runtime toggles. Template id: api-feature-flags.

Scaffold

voltro create-project acme --api=api-feature-flags

Flags as code

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

flagsPlugin({
  flags: {
    newEditor: true,                                                   // kill-switch (on)
    betaExport: false,                                                 // kill-switch (off)
    proDashboard: { targeting: [{ metadata: { plan: 'pro' } }] },      // targeting
    gradualRollout: { rollout: 25 },                                   // deterministic % rollout
  },
  gatedBy: { 'notes.export': 'betaExport' },     // tag → flag
  store: 'memory',
})

Two ways to gate

  • Declarative — the gatedBy map points notes.export at betaExport. The framework fails typed FlagDisabled before the handler when the flag is off. No code in the handler.
  • In-handlernotes.create calls the Effect-native guard:
// notes.create.mutation.server.ts
import { requireFlag } from '@voltro/plugin-flags'

export default (input, ctx) => Effect.gen(function* () {
  yield* requireFlag(ctx, 'newEditor')        // fails typed FlagDisabled when off
  const store = yield* EffectStore
  return yield* store.insert('notes', { title: input.title, body: input.body })
})

Declare error: FlagDisabled (from the browser-safe @voltro/plugin-flags/errors) on the procedure so the client decodes it typed.

Try it

# newEditor ON → requireFlag passes → created:
curl -s localhost:4000/_voltro/inspect/invoke -H 'content-type: application/json' \
  -d '{"tag":"notes.create","input":{"tenantId":"acme","title":"hi","body":"x"}}'
# betaExport OFF → gatedBy blocks → typed FlagDisabled:
curl -s localhost:4000/_voltro/inspect/invoke -H 'content-type: application/json' \
  -d '{"tag":"notes.export","input":{}}'
# → { ok:false, error:{ _tag:"FlagDisabled", flag:"betaExport" } }

On the web side

import { useFlag } from '@voltro/plugin-flags/web'
const showBeta = useFlag('betaExport')        // same targeting + rollout as the server
{showBeta && <BetaExportButton />}

Runtime toggles

store: 'postgres' overlays runtime-toggleable flags (_voltro_feature_flags) on the code baseline — flip a kill-switch without a deploy. The dashboard ships a Flags panel for it.

Anti-patterns

  • Trusting useFlag for enforcement. It's a UI affordance; the server guard (gatedBy / requireFlag) is the real gate. Never gate a write on the client alone.
  • A % rollout keyed on a non-stable id. Rollout buckets deterministically per subject (or tenant via rolloutBy: 'tenant') — an anonymous/idless caller buckets on 'anon', so don't expect per-call variation.