Skip to content
SeeItFlow
System Design · 13 chapters · ~36 min read

Feature Flag System Architecture: Evaluation, Bucketing & Distribution

Complete Design established the shape: evaluation local and fast, configuration centrally governed and asynchronously distributed, tenant isolation enforced at every layer. This module goes one level deeper — the exact evaluation pseudocode, the deterministic bucketing algorithm, the publish pipeline's transactional guarantees, and the twelve correctness invariants a production feature flag platform has to hold.

Mangesh MandlikLast updated: How lessons are made
Chapter 01

The Namespace & Data Model

Every flag, rule, and published snapshot is scoped to a tenant → project → environment tuple. Nothing is ever stored or evaluated against a bare global flag key.

Swipe to pan
Namespace: Tenant → Project → EnvironmentEvery flag definition and override is scoped to this exact tuple — never a bare global keyTenant CedarProject: DashboardProject: Billingenv: productionflag = OFFenv: stagingflag = ON
Tenant → project → environment — the scoping tuple every flag definition and override lives inside

Flag definition

A flag key, a type (boolean, string, number, JSON), a default value, an ordered list of targeting rules, and a status (active/archived). Immutable identity, mutable configuration.

Targeting rule

An ordered entry: a match condition (segment membership, explicit user/tenant list, attribute predicate), a percentage rollout definition (variations + weights + salt), and the value or variation to serve when it matches.

Segment

A named, reusable set of match conditions — 'beta testers,' 'enterprise plan,' 'EU region' — referenced by many flags' targeting rules instead of duplicating the same predicate everywhere.

Chapter 02

The Full Domain Model

Fifteen entities carry this entire design. Every one of them is owned by exactly one tenant, directly or by inheritance, and every compound uniqueness constraint includes the namespace — not just the business key.

EntityOwned/scoped byUniqueness constraintNotes
tenant— (root)tenant_id (global)The top of the hierarchy; billing and isolation boundary.
projecttenant(tenant_id, project_slug)A product or team grouping inside a tenant; shares no config with other projects by default.
environmentproject(project_id, env_slug)production/staging/dev — same flag_definition, independently configured per environment.
flag_definitionproject(project_id, flag_key)The stable identity: key, type, description. Does not vary per environment.
variationflag_definition(flag_definition_id, variation_key)A named possible value (e.g. 'control', 'treatment-a') — the set every rule and rollout selects from.
flag_environment_configenvironment + flag_definition(environment_id, flag_definition_id)The actual per-environment configuration: status, offVariation, fallthroughVariation, ordered rules. This is what differs between staging and production for the same flag.
ruleflag_environment_config(flag_environment_config_id, order_index)One ordered targeting entry — deny/allow/segment/percentage — with order_index enforcing deterministic precedence.
segmentproject(project_id, segment_key)A reusable, named match-condition set referenced by rules across many flags.
segment_membershipsegment(segment_id, subject_id)Explicit membership rows for segments too small or ad hoc for a predicate — or a cached materialization of an externally computed segment.
published_configenvironment(environment_id, config_version)The immutable, signed snapshot artifact an SDK actually fetches — a point-in-time render of every flag_environment_config in the environment.
config_versionenvironment(environment_id, version_number) monotonicThe version counter itself — strictly increasing per environment, never reused, never decremented.
approvalpublished_config (pending)(pending_config_id, approver_id)A second-reviewer sign-off required before a production publish; blocks publish until satisfied for protected environments.
audit_eventtenant(tenant_id, event_id) + append-onlyWho changed what, when, with what diff — scoped so one tenant's compliance review never surfaces another's activity.
outbox_eventflag_environment_config (on write)(aggregate_id, sequence_number)Written in the same transaction as the config change it describes; consumed at least once, idempotently, to trigger snapshot building.
api_credentialsproject + environment(credential_id) + (project_id, environment_id, scope)A server SDK key, a client/browser key, or an admin API token — each scoped to one environment and one capability level, never project-wide by default.
Swipe to pan
Domain Model: Identity vs. Per-Environment Configurationflag_definition is shared across environments; flag_environment_config is what actually differstenantprojectenvironmentflag_definitionshared across envsflag_environment_configvaries per envrule[] (ordered)deny/allow/segment/%published_configconfig_version Nvariation[]possible values a ruleor rollout selects from
tenant → project → environment, with flag_definition shared across environments and flag_environment_config varying per environment

Why flag_definition and flag_environment_config are separate tables

A flag's identity (key, type, the set of possible variations) is shared across every environment — the same flag shouldn't silently change type between staging and production. Its configuration (is it on, what rules apply, what's the default) is environment-specific and expected to differ: a flag can be 100% on in staging and 1% in production simultaneously. Collapsing these into one row per flag would force every environment to share configuration, which defeats the purpose of having separate environments at all.

config_version is the only thing an SDK actually trusts

An SDK never reasons about individual flag_environment_config rows changing — it only ever activates a complete published_config identified by a single monotonic config_version. This is what makes monotonic activation (Chapter 08) checkable with one integer comparison instead of reconciling a set of independent row-level timestamps.

Chapter 03

Tenant Isolation, Enforced at Every Layer

Isolation that only exists in one SQL WHERE clause isn't isolation — it has to hold at every layer config passes through.

Swipe to pan
Tenant Isolation: Every Layer, Not Just the First CheckA single missed layer (e.g. a shared cache key) can leak config across tenantsAuth checkDB query scopeCache keyCDN pathAudit logTenant identity comes from a verified credential — never a client-supplied tenant ID
Auth check, DB query scope, cache key, CDN path, and audit log — isolation has to hold at every one

Authorization at the control plane

Every administrative request's tenant scope comes from the verified credential, never a client-supplied tenant ID or project slug in the URL alone. A credential scoped to Tenant Birch requesting Tenant Cedar's configuration gets a 403, server-side, before any row is read.

Cache and CDN keys include the full namespace

A snapshot cache key or CDN path built from flag key alone (without tenant/project/environment) will silently serve one tenant's configuration to another's SDK the moment two tenants happen to share a flag key — a subtle, easy-to-miss isolation bug that has nothing to do with the authorization layer being correct.

Audit trail scoped per tenant

Every mutation — who changed what, when, with what diff — is itself namespace-scoped, so a tenant's compliance review of their own audit history never surfaces another tenant's activity.

Chapter 04

The Evaluation Algorithm

Evaluation is a pure function: given a local snapshot, a flag key, a context, and a safe default, it returns a value and a reason — no network call, no shared mutable state, no side effects.

textIllustrative evaluation pseudocode — not a real SeeItFlow or vendor API
function evaluate(namespace, flagKey, context, safeDefault):
  snapshot = localSnapshotCache.get(namespace)
  if snapshot is None or snapshot.isExpiredPastHardLimit():
    return (safeDefault, reason="NO_VALID_SNAPSHOT")

  flag = snapshot.flags.get(flagKey)
  if flag is None:
    return (safeDefault, reason="FLAG_NOT_FOUND")
  if flag.status == "disabled":
    return (flag.offVariation, reason="FLAG_DISABLED")

  for prereq in flag.prerequisites:
    if prereqCycleDetected(flag, prereq):
      return (safeDefault, reason="PREREQUISITE_CYCLE")
    prereqResult = evaluate(namespace, prereq.flagKey, context, prereq.safeDefault)
    if prereqResult.value != prereq.requiredValue:
      return (flag.offVariation, reason="PREREQUISITE_NOT_MET")

  # rulesInOrder is pre-sorted once, when the control plane builds the snapshot —
  # grouped deny, then allow, then segment, then percentage — so this single
  # pass enforces precedence by construction, not by hoping authors ordered rules correctly.
  for rule in flag.rulesInOrder:
    if rule.type == "deny" and rule.matches(context):
      return (flag.offVariation, reason="DENY_MATCH:" + rule.id)
    if rule.type == "allow" and rule.matches(context):
      return (rule.value, reason="ALLOW_MATCH:" + rule.id)
    if rule.type == "segment" and rule.matches(context):
      return (rule.value, reason="SEGMENT_MATCH:" + rule.id)
    if rule.type == "percentage":
      bucket = stableHash(namespace, flagKey, rule.salt, context.bucketingKey) % 100000
      variation = rule.resolveBucket(bucket)
      if variation is not None:
        return (variation.value, reason="PERCENTAGE_MATCH:bucket=" + bucket)

  return (flag.fallthroughVariation, reason="DEFAULT")

offVariation and fallthroughVariation are different fields

A flag carries two distinct resting values, not one shared 'default': offVariation is what every targeted user gets while the flag is deliberately turned off, and fallthroughVariation is what an enabled flag returns when a user matches no targeting rule. Collapsing these into a single defaultValue field is a common modeling mistake — it means an operator can't turn a flag off to a safe value (say, the old code path) while separately choosing a different fallthrough for enabled-but-unmatched traffic (say, the new code path for everyone not yet in a rollout segment). Two fields, two independent operator decisions.

Exactly one winning reason

Every evaluation returns not just a value but a machine-readable reason string — which rule matched, or that none did. This is what lets a support engineer explain any outcome without guessing or re-deriving the logic by hand.

Prerequisites are cycle-safe

A flag can depend on another flag's value (a prerequisite). Before evaluating one, the engine checks for a cycle in the dependency graph and fails safe to the default rather than recursing forever.

Chapter 05

Deterministic Bucketing, in Detail

bucket = stableHash(stableInput) mod 100000. The entire correctness of a percentage rollout rests on that one line being deterministic and consistent across every language runtime and every instance.

Swipe to pan
Monotonic Expansion (10% → 20%)Raising the threshold only adds buckets — it never evicts an already-ON userold 10%+10% newuser42 · bucket 7,314user99 · bucket 61,520 — still OFF
Raising a rollout's threshold only ever adds buckets — it never evicts an already-ON user (illustrative: User 42 → bucket 7,314)

Canonical input serialization

The tuple hashed — tenant, project, environment, flag key, bucketing unit, subject ID, salt — must serialize to identical bytes regardless of which SDK language computes it. A Python SDK and a Java SDK that format the same logical tuple differently will silently disagree on bucket membership for the same user.

Salt controls reshuffling

The salt is what makes two different flags' rollouts for the same user statistically independent. Changing a flag's salt deliberately reshuffles its bucket membership — useful when a rollout needs a genuinely fresh random sample, but it breaks the monotonic-expansion guarantee for that flag, so it should be a rare, explicit, audited action, not a side effect of an unrelated edit.

Not consistent hashing

This is deterministic bucketing into a fixed-size space for percentage targeting, a different problem from consistent hashing's goal of minimizing key remapping when nodes join or leave a ring. Don't conflate the two in an interview — they solve different problems with superficially similar-sounding mechanics.

Chapter 06

The Publish Pipeline

Edit → approve → commit (with an outbox event, same transaction) → build an immutable snapshot → distribute. Every step exists to prevent a specific failure.

Swipe to pan
Publish Pipelineedit → approve → commit + outbox (same transaction) → build immutable snapshot → distributeAdmineditReviewerapproveCommit +outboxSnapshotbuilderDistribution
edit → approve → commit + outbox → build snapshot → distribute

Approval gate

A production-environment change requires a second reviewer before it can publish. Cheap insurance against both accidental and malicious changes, and the natural place to log who approved what and why.

Transactional outbox

The durable config write and the publish-event write happen in the same database transaction (see Outbox Pattern for the general mechanism), so a crash between 'save the edit' and 'notify downstream' can never silently drop a change the admin believes succeeded.

Immutable, versioned snapshots

A snapshot builder consumes the outbox event and produces a new version number — never mutates a previous one. Version 12 will always mean exactly what it meant the moment it was built; a further edit produces version 13.

Validation before distribution

The snapshot builder rejects a config with duplicate keys, invalid rollout weights, cyclic prerequisites, or unsupported rule operators before it is ever published — an SDK should never need to defend itself against a structurally broken snapshot.

Chapter 07

Control Plane APIs (Illustrative)

These are illustrative shapes to reason about the publish pipeline's edges — not real SeeItFlow or any vendor's actual endpoints.

Method & pathPurposeAuth scope required
POST /v1/projects/{project}/environments/{env}/flagsCreate a new flag_definition + initial flag_environment_configAdmin API token, write scope
PATCH /v1/projects/{project}/environments/{env}/flags/{key}Edit rules, default values, or status — writes a pending, unpublished changeAdmin API token, write scope
POST /v1/projects/{project}/environments/{env}/publishPromote the pending change set to a new immutable config_version, subject to approval gatingAdmin API token, publish scope (separate from write)
GET /v1/projects/{project}/environments/{env}/sdk/configFetch the current published_config snapshot — what a server SDK pollsServer SDK key, namespace-scoped
POST /v1/projects/{project}/environments/{env}/evaluateRemote evaluation for callers that can't embed an SDK — evaluates server-side and returns only the resultServer SDK key, namespace-scoped
POST /v1/projects/{project}/environments/{env}/eventsNon-blocking exposure/telemetry ingestion — fire-and-forget from the caller's perspectiveServer or client SDK key, write-events scope only
jsonIllustrative request/response — PATCH .../flags/checkout-redesign
// Request
{
  "status": "active",
  "rules": [
    { "type": "segment", "segmentKey": "beta-testers", "variation": "treatment" },
    { "type": "percentage", "salt": "ckr-2026-01", "weights": { "control": 90000, "treatment": 10000 } }
  ],
  "fallthroughVariation": "control"
}

// Response — 200 OK (pending, not yet published)
{
  "flagKey": "checkout-redesign",
  "pendingChangeId": "chg_8f1a2",
  "requiresApproval": true,
  "status": "PENDING_APPROVAL"
}

// Response — 403 (wrong environment scope)
{ "error": "CREDENTIAL_SCOPE_MISMATCH", "message": "This token is scoped to 'staging', not 'production'." }

Write and publish are separate scopes, deliberately

A credential that can PATCH a flag is not automatically allowed to POST /publish — separating 'propose a change' from 'make it live' is what makes the approval gate (Chapter 06) structurally enforceable rather than a convention engineers are trusted to follow.

Why /evaluate exists alongside local SDK evaluation

Not every caller can embed an SDK — a lightweight webhook handler or a language without a maintained SDK still needs a flag decision. Remote evaluation trades the data plane's latency and availability guarantees (Chapter 09) for convenience; it's explicitly the exception path, not the primary evaluation model this design optimizes for.

Chapter 08

Distribution: Poll, Push, and Monotonic Activation

SDKs don't query a live database. They fetch versioned, immutable snapshots from a distribution layer, and they never move backward.

Swipe to pan
Polling vs. Push, Used TogetherPush improves typical freshness; polling remains the safety net for missed notificationsPoll (ETag / version)bounded by interval, simple, reliablePush (notify)fast, but a hint — not guaranteed delivery"I was notified" means "go fetch the authoritative snapshot" — never the update itself
Polling (cheap, bounded by interval) plus push (fast, but only a hint) — used together, not as alternatives
Swipe to pan
Monotonic SDK ActivationA delayed v12 arrives after v13 is already active — the SDK refuses to go backwardv13 activedelayed delivery: v12REJECTEDolder than active version
A delayed older version arriving after a newer one is already active is rejected, never applied

Conditional polling

An ETag or version tag lets most polls return a cheap 304 Not Modified. Freshness is bounded by the poll interval for a healthy, connected instance — acceptable for most flags, not sufficient alone for an emergency kill switch.

Push as a notification, not a source of truth

A push/pub-sub channel meaningfully improves typical freshness, but delivery isn't guaranteed — treat a push as 'go check for a new version,' with polling retained as the safety net that catches a dropped notification.

Monotonic SDK activation

The SDK compares incoming version numbers against the one it's running and refuses to activate anything older — enforced by the SDK itself, because the delivery mechanism underneath it (retries, multiple relay paths, redelivery) cannot itself guarantee ordering.

Delta vs. full snapshot

An incremental delta is only valid relative to a known base version. If an SDK's current version doesn't match the delta's assumed base — because it missed an earlier update — it rejects the delta and falls back to fetching the full snapshot instead of risking silent corruption.

Chapter 09

Control Plane / Data Plane Separation

Administration changes rarely; evaluation happens constantly. Different latency budgets, different availability profiles, different systems.

Control plane

Authentication, RBAC, validation, the approval workflow, publish orchestration, and audit. Can tolerate seconds of latency per operation; correctness and auditability matter far more than raw throughput here.

Data plane

Distribution (CDN/relay), the SDK's local snapshot cache, and the in-process evaluator. Must tolerate huge evaluation volume with sub-millisecond latency and must keep functioning when the control plane is unreachable.

One-way, asynchronous coupling

The data plane depends on the control plane only through versioned, asynchronously distributed snapshots — never a live synchronous call from an SDK back into the control plane on the evaluation hot path.

Chapter 10

SDK Trust Boundaries: Backend vs. Browser

A backend SDK and a browser SDK solve the same evaluation problem under very different trust assumptions.

Swipe to pan
Backend SDK vs. Browser SDKA browser never receives raw targeting rules — only its own already-evaluated resultBackend SDKfull rule set, trusted environmentBrowser SDKreceives: { newDashboard: true } onlyShipping full targeting rules to a browser leaks business logic to anyone opening dev tools
A backend SDK holds the full rule set; a browser SDK receives only its own already-evaluated result

Backend SDK

Runs inside a trusted environment. Holds the full targeting rule set for its namespace in memory and evaluates locally for every incoming request or job.

Browser SDK

Runs in an untrusted environment. Receives a bootstrap payload containing only this specific session's already-evaluated flag values — never the underlying segment definitions, rollout percentages, or other tenants' rules, which would otherwise be visible to anyone opening developer tools.

Chapter 11

Telemetry & Privacy

Knowing what was evaluated, for whom, and why, matters for debugging rollouts — but it must never become a dependency the evaluation call waits on.

Non-blocking exposure events

Each evaluation can emit an exposure event (flag, variation, reason, anonymized/derived bucketing key) to an async telemetry pipeline — fire-and-forget from the evaluation call's perspective, batched and sent on a background interval.

Attribute minimization

Telemetry and targeting context both carry the minimum attributes needed, favoring derived or hashed identifiers over raw PII wherever a derived attribute suffices for correct bucketing or segment matching.

Chapter 12

Operational Signals

A feature flag platform's own health metrics are different from product metrics — they measure whether configuration is actually reaching and being correctly applied by the fleet, not whether a feature is performing well.

SignalWhat it measuresWhy it matters
Publish latencyTime from commit (control-plane write) to snapshot availability for fetchA slow publish pipeline delays every downstream freshness guarantee, including emergency kill switches.
Time-to-receipt at SDK (p50/p95/p99)Time from snapshot availability to an individual SDK instance actually activating itThe tail (p99), not the median, is what an incident runbook needs — a few slow stragglers can hold a bad config far longer than the 'typical' propagation time suggests.
Stale instance fractionShare of the fleet running a config_version older than the latest publishedA rising fraction during normal operation (not during a planned rollout) usually means a distribution or connectivity regression, not just normal propagation lag.
Config version skewThe spread between the oldest and newest config_version currently active across the fleetA wide skew means some requests are being evaluated against meaningfully different rules than others, which matters for debugging an inconsistent-looking rollout.
Evaluation reason countsTally of evaluations by reason (DENY_MATCH, PERCENTAGE_MATCH, DEFAULT, etc.), per flagA flag whose reason distribution suddenly shifts to mostly DEFAULT/NO_VALID_SNAPSHOT signals a targeting or snapshot-loading regression — but this metric has high cardinality (flag × reason × environment) and needs deliberate aggregation, not raw per-event storage.
SDK initialization failure rateShare of SDK instances that fail to load a valid snapshot at startupA cold-start fleet-wide failure (e.g. after a bad deploy) is a different, more urgent incident class than a single degraded instance.
Push connection churnReconnect rate on the push/pub-sub notification channelHigh churn degrades push to 'occasionally helpful' and means the system is leaning more than expected on the polling safety net — worth knowing before an emergency kill switch is needed.
Rollout error rateProduct error rate segmented by evaluation reason/variation, correlated with a specific config_versionThis is what actually answers 'did this rollout cause the error spike' — without the config_version correlation, it's just a generic error-rate graph.

Measured exposure vs. mere evaluation

An evaluation happening is not the same claim as a user genuinely being exposed to a variation's effect — a flag evaluated during a health check, a retry, or a background job inflates evaluation counts without representing real user exposure. Exposure metrics used for experiment analysis need to filter to evaluations that actually reached user-visible behavior, or they systematically misstate sample size.

Sampling bias in telemetry

If exposure telemetry is sampled or dropped under backpressure (Chapter 11), that sampling must be uniform across variations — a bug that disproportionately drops one variation's events will make a rollout look like it's underperforming when the difference is actually in the data pipeline, not the product.

Chapter 13

The Twelve Invariants

Every design decision in this module traces back to one of these. They're the checklist for whether a proposed change is actually safe.

#Invariant
I1Tenant isolation holds at every layer config passes through — DB, cache, CDN, logs, audit — not just the first auth check.
I2Stable bucketing: identical versioned algorithm and canonical inputs produce identical results on every instance.
I3Exactly one winning rule/reason is visible per evaluation; invalid or missing context has defined behavior; prerequisites are cycle-safe.
I4Monotonic SDK activation: a newer version is never replaced by an older one, regardless of delivery order.
I5Availability despite control-plane outages: a warmed SDK keeps evaluating from its last valid snapshot.
I6Bounded staleness applies only under stated conditions — connected instances have a measured SLO; offline instances have no guarantee.
I7Flags are not an authorization boundary — protected operations need an independent permission check.
I8Privacy by design: attribute minimization, tenant-scoped audit, no unnecessary PII in targeting or telemetry.
I9Every publish is versioned, immutable, and auditable — rollback publishes a new version, never rewrites history.
I10Telemetry is non-blocking — evaluation never waits on, or fails because of, the telemetry pipeline.
I11Cache and distribution keys are namespace-safe, never collide across tenants sharing a flag key.
I12Invalid configuration (cycles, bad weights, unsupported operators) is rejected at publish time, before reaching any SDK.