Feature flags
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
ingest 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 a per-instance buffer keyed by
(app, environment, UTC day, flag, resulting value) and flushed with one upsert
every 30 seconds, plus a best-effort flush on beforeExit. A flag evaluated
a million times a day therefore costs a few thousand row updates, not a million
inserts. 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 counts older
than 90 days.
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 pill per environment (click to toggle with
flags.write), a 7-day evaluation sparkline for the selected environment, and the last change. Archived flags live behind their own tab. 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 switch and rollout slider, the ordered rules builder (conditions, per-rule rollout, forced variant, reorder), 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. - History — every change with its diff, actor and reason.
- Archive — needs a reason and a passkey verified in the last 10 minutes.
Saving an archived flag restores it, recorded as
restored.
Mutations go through POST /api/console/flags?intent=save|toggle|archive|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. preview changes nothing and needs only
flags.read.
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): Promise<{ flag; created; change }> // validates + writes history
archiveFlag(scope, key, reason, actorId): Promise<FeatureFlag | null>
toggleEnvironment(scope, key, environment, enabled, actorId): Promise<FeatureFlag | null>
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).
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.
What the assistant can do here
Ask 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.