SaaS Pro Max
Sign inStart free
Documentation menu

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#

TypeScript
{ 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 / neq compare scalars after a string coercion, so 1 and "1" match.
  • in takes a list (or a comma-separated string). If the property is itself an array, the condition matches when the two intersect.
  • contains is a case-insensitive substring on a string, and membership on an array.
  • gt / lt / gte / lte compare numerically when both sides look numeric and lexicographically otherwise — which is what makes ISO timestamps work.
  • exists is true when the field is present and not null; value: false inverts 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.

  1. Archived → false. (evaluateFlags drops archived flags from the result entirely; they no longer exist as far as an SDK is concerned.)
  2. No configuration for the environment → false.
  3. enabled: false for the environment → false.
  4. A variant override for this identity in this environment → that value.
  5. 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.
  6. 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#

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

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

JSON
// 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/plain with the key inside the body. A plain text body is not a preflighted request, so the SDK never pays for an OPTIONS round trip.
  • Node — content-type: application/json with Authorization: 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:

TableKeyRead by
flag_evaluations_dailyapp, environment, UTC day, flag, resulting valueevery evaluation chart and sparkline
flag_evaluations_minuteapp, environment, flag, UTC minutethe list's live dot and last-hour count
flag_evaluation_steps_dailyapp, environment, UTC day, flag, deciding step, resulting valuethe 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#

TypeScript
// 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");
TypeScript
// 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 } })) {
  // ...
}
TSX
// 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 restore intent. Saving an archived flag still restores it, recorded as restored.
  • 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.

  • save accepts payload and an optional reason.
  • toggle and archive answer (to a JSON request) with undo: { auditId, revision }. Their reversals send them back as undoOf and expectedRevision: toggle with the previous value, and restore (key, optional reason, flags.write and a passkey verified in the last 30 minutes, which the archive just proved). The route checks undoOf with assertUndoOf (same organization, same member, same flag, the expected action, at most 10 minutes old, not undone yet) and records it as metadata.undoOf. expectedRevision makes the reversal compare-and-set: if anyone changed the flag in between, it answers 409 instead of overwriting their change.

Exported functions#

src/lib/flags/queries.ts is the query layer other workstreams reuse (W13's Management API in particular):

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