Feature flags
On this page
Owner: W9. Source: apps/web/src/lib/flags/**, apps/web/src/db/schema/flags.ts,
apps/web/src/app/api/v1/flags/**, apps/web/src/app/api/console/flags/**,
apps/web/src/app/(console)/o/[org]/apps/[app]/flags/**. Contract: PLAN §7.3, §15 W9.
Boolean and multivariate flags with percentage rollouts, property rules and per-environment overrides. One request returns every flag for one identity, and the browser, the server and the console all agree on the answer because they all use the same hash.
Module key flags. Permissions flags.read and flags.write.
The model#
A flag is one row in feature_flags, unique per (app_id, key).
| Column | Meaning |
|---|---|
key |
What the code asks for. ^[a-z0-9_-]{1,64}$; new is reserved by the console route. |
kind |
boolean (true/false) or multivariate (one of several variant keys). |
variants |
[{ key, weight }], weights totalling 100. Empty for a boolean flag. |
rules |
Ordered [{ id, description?, conditions, rollout_percent?, variant? }]. |
environments |
{ production: { enabled, rollout_percent, variant_overrides? }, staging: …, development: … } |
archived_at |
Set means the flag is no longer served and no longer returned. |
Two companion tables: flag_history (one row per change, with the diff, the
actor and the reason) and flag_evaluations_daily
(app_id, env_id, day, key, variant, count).
The jsonb documents use snake_case keys, matching the contract in the plan.
src/lib/flags/queries.ts is the only module that translates between that and
the camelCase in-memory model, and it accepts either spelling on read.
Conditions#
{ field: "property.plan", op: "eq", value: "pro" }
field reads property.<path> / trait.<path> from the properties bag (a bare
name means the same thing), or distinct_id / anonymous_id from the identity.
An exact key wins over a dotted path, so a property literally named "a.b" stays
reachable.
Operators: eq, neq, contains, in, gt, lt, gte, lte, exists.
There is deliberately no regex operator — rules are attacker-influenceable
data evaluated on a request path.
eq/neqcompare scalars after a string coercion, so1and"1"match.intakes a list (or a comma-separated string). If the property is itself an array, the condition matches when the two intersect.containsis a case-insensitive substring on a string, and membership on an array.gt/lt/gte/ltecompare numerically when both sides look numeric and lexicographically otherwise — which is what makes ISO timestamps work.existsis true when the field is present and not null;value: falseinverts it.
The shape is deliberately the same as W6's segment DSL
(src/lib/people/segments.ts), but the two are separate implementations: W6
compiles conditions to SQL over the persons table, this module evaluates them
in memory against a properties bag supplied by the SDK.
Evaluation order#
evaluateFlag in src/lib/flags/evaluate.ts is pure — no database, no clock —
and answers in this order. The first step that decides, wins.
- Archived →
false. (evaluateFlagsdrops archived flags from the result entirely; they no longer exist as far as an SDK is concerned.) - No configuration for the environment →
false. enabled: falsefor the environment →false.- A variant override for this identity in this environment → that value.
- Rules, in declared order. The first rule whose conditions all match is
the rule that decides:
- outside the rule's own
rollout_percent→false; - the rule forces a
variant→ that variant (multivariate only); - otherwise →
true, or a hashed variant for a multivariate flag. A matched rule ends the search even when its rollout excludes the identity. "This rule does not apply to you" and "you are targeted but not in the ramp" are different answers, and only the second should stop later rules firing.
- outside the rule's own
- The environment rollout. Inside →
true(or a hashed variant); outside →false.
A rule overrides the environment rollout rather than intersecting with it, so a flag at 0 % with an "internal team" rule serves the team and nobody else.
Missing identity#
With neither distinctId nor anonymousId there is nothing to hash, so there is
no bucket. Such a caller is served only by a rollout of exactly 100 (a
multivariate flag then gives the first declared variant, deterministically);
any partial rollout answers false with reason no_identity.
Bucketing#
identity = distinctId ?? anonymousId
bucket = murmurhash3_32(`${flagKey}:${identity}`) % 100 // 0..99
variantBucket = murmurhash3_32(`${flagKey}:variant:${identity}`) % 100 // 0..99
inRollout = bucket < rolloutPercent
murmurhash3_32 and rolloutBucket come from @saaspro/shared and are the
only implementation — the browser SDK, the Node SDK and this endpoint import
the same function, and the reference vectors are pinned in
packages/shared/tests/helpers.test.ts and again in
apps/web/tests/flags/evaluate.test.ts. Changing the hash changes who is in a
rollout, so it is frozen.
Because bucket < rolloutPercent, a rollout of 0 includes nobody and 100
includes everybody. Widening a rollout only ever adds identities: an identity at
bucket 12 is inside every rollout of 13 or more.
A multivariate variant is picked by walking cumulative weights over
variantBucket:
control 34 → buckets 0..33
annual-first 33 → buckets 34..66
monthly-first 33 → buckets 67..99
The second salt (key + ":variant") is what keeps the two decisions
independent: raising a rollout from 20 % to 60 % never reshuffles the variants of
the identities that were already in.
The evaluation endpoint#
POST /api/v1/flags/evaluate (PLAN §7.3).
// request
{ "key": "spm_pub_prod_…", "distinctId": "user_123", "anonymousId": "anon_…", "properties": { "plan": "pro" } }
// response
{ "flags": { "new-onboarding": true, "pricing-experiment": "annual-first" }, "evaluatedAt": "2026-09-03T10:00:00.000Z" }
Two transports, one handler:
- Browser —
content-type: text/plainwith the key inside the body. A plain text body is not a preflighted request, so the SDK never pays for anOPTIONSround trip. - Node —
content-type: application/jsonwithAuthorization: Bearer.
OPTIONS is answered anyway. Access-Control-Allow-Origin: * on this route
only: a public key is meant to be readable in a page. When the application lists
allowed_origins, the server checks the Origin header and answers 403 —
exact matches and *.example.com subdomain wildcards, never the bare apex. A
request with no Origin is server-to-server and is not an origin to check.
Refusals: unknown, revoked or wrong-environment key → 401; an access token
(spm_pat_ / spm_org_) → 403, because flags are evaluated with an application
public or secret key; body over 64 KB → 413; unparseable body → 400. Every response,
including refusals, carries the CORS headers.
A key evaluates flags in its own environment. The environment is part of the key, not of the request body, so a staging key can never read production's rollout.
Caching#
Flag definitions are cached in memory per application and environment for 10
seconds (src/lib/flags/cache.ts). A console mutation clears the entry in its
own process, so an operator flipping a switch sees it immediately; other
instances converge within the window. The browser SDK additionally caches the
response in localStorage (spm_flags) and the Node SDK for 30 seconds per
identity — see docs/sdk/browser.md and docs/sdk/node.md.
Evaluation counting#
Each evaluation is folded into one per-instance buffer and flushed every 30
seconds, plus a best-effort flush on beforeExit
(src/lib/flags/counters.ts). A flag evaluated a million times a day therefore
costs a few thousand row updates, not a million inserts. The buffer keeps three
tallies, each written to its own table:
| Table | Key | Read by |
|---|---|---|
flag_evaluations_daily | app, environment, UTC day, flag, resulting value | every evaluation chart and sparkline |
flag_evaluations_minute | app, environment, flag, UTC minute | the list's live dot and last-hour count |
flag_evaluation_steps_daily | app, environment, UTC day, flag, deciding step, resulting value | the audience waterfall's observed shares |
The deciding step is audience, rule:<ruleId>, override, default or
off, derived from the evaluation's reason by stepForEvaluation
(src/lib/flags/steps.ts); the editor's Try-it highlight uses the same mapping.
The flush writes the three tables in one short transaction (a 5-second statement timeout, rows in key order so concurrent instances cannot deadlock). If the transaction rolls back, all counts in that batch return to the buffer, merging with counts that arrived meanwhile. Requeued keys bypass fresh-traffic caps, so a full active buffer cannot discard previously accepted counts.
Each tally caps new keys from fresh traffic independently: 20,000 daily keys, 5,000 minute keys and 20,000 step keys. Existing buffered keys still accumulate counts at or above the cap; fresh keys beyond it are dropped from that tally. One flush runs per instance at a time and takes at most one cap's worth of keys per tally, leaving any excess buffered for later flushes. Active and in-flight keys together are therefore bounded by twice each tally's cap, even across repeated failures. A burst of distinct minutes cannot starve the daily counter.
These in-memory counters are best effort, not durable or exactly once: process exit can lose unflushed counts, and a lost commit acknowledgement can cause a committed batch to be retried and counted again. The retry guarantee applies to counts already admitted to a tally when its transaction rolls back.
Only the evaluation endpoint counts. SDKs that evaluate a downloaded snapshot locally (P12) never report back, which is why the console says "server evaluations".
The day is UTC: the counter is an operational signal shared by every environment, not a report in the application's reporting timezone.
The worker job flags.evaluations.compact runs daily and deletes daily and step
counts older than 90 days; flags.evaluations.minute.compact runs hourly and
deletes minute counts older than 48 hours.
SDK usage#
// Browser
import { spm } from "@saaspro/browser";
spm.init({ key: "spm_pub_prod_…" });
await spm.flags.load({ properties: { plan: "pro" } });
if (spm.flags.isEnabled("new-onboarding")) showNewFlow();
const variant = spm.flags.get("pricing-experiment", "control");
// Node
import { SaaSProMax } from "@saaspro/node";
const spm = new SaaSProMax({ secretKey: process.env.SPM_SECRET_KEY! });
if (await spm.flags.isEnabled("new-onboarding", { distinctId: user.id, properties: { plan: user.plan } })) {
// ...
}
// React
import { FeatureFlag, useFlag } from "@saaspro/react";
const variant = useFlag("pricing-experiment", "control");
<FeatureFlag flag="new-onboarding">{<NewOnboarding />}</FeatureFlag>
Both SDKs keep serving the last known values when the endpoint is unreachable, so a flag outage degrades to "whatever the user saw last", never to a crash.
The console#
/o/{org}/apps/{app}/flags
- List — key, name, kind, one switch per environment (production held to confirm, the others offer Undo), a 7-day evaluation sparkline for the selected environment, a breathing live dot when the server evaluated the flag in that environment within the last two minutes (its label carries the last hour's count), and the last change. Archived flags live behind their own filter, each with a Restore action. The empty state is the install guide, carrying the application's own key prefixes.
- Editor (
flags/new,flags/{key}) — general fields, the variants editor with live weight validation, per-environment rollout slider, the ordered rules builder (conditions, per-rule rollout, forced variant, reorder), the audience waterfall, and a test evaluation panel that runs the unsaved draft against an identity and shows the value, the reason, the bucket and the deciding rule. - Audience waterfall — with at least 100 server evaluations in the selected environment over 7 days, each step shows what it actually served and its share of all evaluations ("Server evaluations, last 7 days"); a rule that no longer exists folds into "Earlier rules". Below that, or while the draft differs from the saved flag, it shows the configured split and says so.
- Save — on an existing flag, Save opens Review changes: the rollout change per environment with production first, the definition parts that changed, and an optional reason (up to 500 characters) recorded in the flag history and the audit row. When production would serve something different (its rollout, or rules or variants while it is on) the save is held to confirm.
- History — every change with its diff, actor and reason.
- Archive — needs a reason and a passkey verified in the last 30 minutes.
The confirmation offers Undo, which is the
restoreintent. Saving an archived flag still restores it, recorded asrestored. - Linked experiment — the header names an experiment linked to the flag
(see
docs/integrations/experiments.md), and "Decided by" once its verdict was applied.
Mutations go through POST /api/console/flags?intent=save|toggle|archive|restore|preview
and the standard console chain (same origin → passkey session → membership and
permission → CSRF → zod → transaction → audit). Audit actions: flag.saved,
flag.toggled, flag.archived, flag.restored. preview changes nothing and
needs only flags.read.
saveacceptspayloadand an optionalreason.toggleandarchiveanswer (to a JSON request) withundo: { auditId, revision }. Their reversals send them back asundoOfandexpectedRevision:togglewith the previous value, andrestore(key, optionalreason,flags.writeand a passkey verified in the last 30 minutes, which the archive just proved). The route checksundoOfwithassertUndoOf(same organization, same member, same flag, the expected action, at most 10 minutes old, not undone yet) and records it asmetadata.undoOf.expectedRevisionmakes the reversal compare-and-set: if anyone changed the flag in between, it answers409instead of overwriting their change.
Exported functions#
src/lib/flags/queries.ts is the query layer other workstreams reuse (W13's
Management API in particular):
listFlags(scope, { includeArchived?, envId?, sparklineDays? }): Promise<FlagSummary[]>
getFlag(scope, key): Promise<FeatureFlag | null>
upsertFlag(scope, input, actorId, reason?): Promise<{ flag; created; change }> // validates + writes history
archiveFlag(scope, key, reason, actorId): Promise<FeatureFlag | null>
restoreFlag(scope, key, { reason?, expectedRevision?, actorId }): Promise<FeatureFlag | null> // throws FlagConflictError
toggleEnvironment(scope, key, environment, enabled, actorId, { expectedRevision? }?): Promise<FeatureFlag | null>
writeFlagEnvironments(client, scope, lockedFlag, environments, { actorId, reason }): Promise<{ flag; change }>
flagHistory(scope, key, limit?): Promise<FlagHistoryEntry[]>
evaluationStats(scope, key, { from, to, envId? }): Promise<EvaluationStats>
evaluationSparklines(scope, { days?, envId? }): Promise<Map<string, number[]>>
loadEvaluableFlags(appId): Promise<EvaluableFlag[]>
FlagScope = { orgId: string; appId: string } — every statement filters on
app_id, so a key from another tenant simply does not resolve.
Also exported: parseFlagInput / flagInputSchema / FlagValidationError
(src/lib/flags/schema.ts), evaluateFlag / evaluateFlags /
evaluateFlagsDetailed / rolloutBucket helpers (src/lib/flags/evaluate.ts),
cachedFlags / invalidateFlagCache (src/lib/flags/cache.ts), and
recordEvaluations / flushEvaluationCounts (src/lib/flags/counters.ts),
stepForEvaluation (src/lib/flags/steps.ts), and recentFlagActivity /
flagStepCounts (src/lib/flags/matrix.ts).
Seed#
pnpm db:seed --demo creates two flags on the bootstrap application:
new-onboarding (boolean, 50 % in production, always on for @saaspro.dev
addresses) and pricing-experiment (multivariate control / annual-first /
monthly-first). Creation is ON CONFLICT DO NOTHING, so re-seeding never
overwrites a rollout an operator has adjusted. --demo also writes the last
60 minutes of minute counts for new-onboarding relative to the wall clock (a
reseed refreshes the live dot), seven days of step counts for checkout-v2, and
links the finished onboarding experiment to new-onboarding.
What the assistant can do here#
Pilot reads flags and their rollouts, and can resolve what a given context would evaluate to, which is usually the real question behind "why is this user not seeing it?".
Creating or replacing a flag is a proposal: the assistant states the key, the environment, the current rollout and the one it wants to write, and waits. A write that lands in production is treated as destructive whatever the method, so it additionally needs a typed reason and a fresh passkey — the same bar the console's own production toggle sets. Deleting a flag is destructive for the same reason. "Roll checkout out to 10% in staging" is therefore one sentence and one approval, not a form.