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.
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.
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.
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.
| Entity | Owned/scoped by | Uniqueness constraint | Notes |
|---|---|---|---|
| tenant | — (root) | tenant_id (global) | The top of the hierarchy; billing and isolation boundary. |
| project | tenant | (tenant_id, project_slug) | A product or team grouping inside a tenant; shares no config with other projects by default. |
| environment | project | (project_id, env_slug) | production/staging/dev — same flag_definition, independently configured per environment. |
| flag_definition | project | (project_id, flag_key) | The stable identity: key, type, description. Does not vary per environment. |
| variation | flag_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_config | environment + 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. |
| rule | flag_environment_config | (flag_environment_config_id, order_index) | One ordered targeting entry — deny/allow/segment/percentage — with order_index enforcing deterministic precedence. |
| segment | project | (project_id, segment_key) | A reusable, named match-condition set referenced by rules across many flags. |
| segment_membership | segment | (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_config | environment | (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_version | environment | (environment_id, version_number) monotonic | The version counter itself — strictly increasing per environment, never reused, never decremented. |
| approval | published_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_event | tenant | (tenant_id, event_id) + append-only | Who changed what, when, with what diff — scoped so one tenant's compliance review never surfaces another's activity. |
| outbox_event | flag_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_credentials | project + 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. |
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.
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.
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.
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.
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.
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.
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.
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.
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.
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 & path | Purpose | Auth scope required |
|---|---|---|
| POST /v1/projects/{project}/environments/{env}/flags | Create a new flag_definition + initial flag_environment_config | Admin API token, write scope |
| PATCH /v1/projects/{project}/environments/{env}/flags/{key} | Edit rules, default values, or status — writes a pending, unpublished change | Admin API token, write scope |
| POST /v1/projects/{project}/environments/{env}/publish | Promote the pending change set to a new immutable config_version, subject to approval gating | Admin API token, publish scope (separate from write) |
| GET /v1/projects/{project}/environments/{env}/sdk/config | Fetch the current published_config snapshot — what a server SDK polls | Server SDK key, namespace-scoped |
| POST /v1/projects/{project}/environments/{env}/evaluate | Remote evaluation for callers that can't embed an SDK — evaluates server-side and returns only the result | Server SDK key, namespace-scoped |
| POST /v1/projects/{project}/environments/{env}/events | Non-blocking exposure/telemetry ingestion — fire-and-forget from the caller's perspective | Server or client SDK key, write-events scope only |
// 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.
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.
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.
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.
SDK Trust Boundaries: Backend vs. Browser
A backend SDK and a browser SDK solve the same evaluation problem under very different trust assumptions.
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.
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.
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.
| Signal | What it measures | Why it matters |
|---|---|---|
| Publish latency | Time from commit (control-plane write) to snapshot availability for fetch | A 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 it | The 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 fraction | Share of the fleet running a config_version older than the latest published | A rising fraction during normal operation (not during a planned rollout) usually means a distribution or connectivity regression, not just normal propagation lag. |
| Config version skew | The spread between the oldest and newest config_version currently active across the fleet | A wide skew means some requests are being evaluated against meaningfully different rules than others, which matters for debugging an inconsistent-looking rollout. |
| Evaluation reason counts | Tally of evaluations by reason (DENY_MATCH, PERCENTAGE_MATCH, DEFAULT, etc.), per flag | A 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 rate | Share of SDK instances that fail to load a valid snapshot at startup | A 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 churn | Reconnect rate on the push/pub-sub notification channel | High 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 rate | Product error rate segmented by evaluation reason/variation, correlated with a specific config_version | This 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.
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 |
|---|---|
| I1 | Tenant isolation holds at every layer config passes through — DB, cache, CDN, logs, audit — not just the first auth check. |
| I2 | Stable bucketing: identical versioned algorithm and canonical inputs produce identical results on every instance. |
| I3 | Exactly one winning rule/reason is visible per evaluation; invalid or missing context has defined behavior; prerequisites are cycle-safe. |
| I4 | Monotonic SDK activation: a newer version is never replaced by an older one, regardless of delivery order. |
| I5 | Availability despite control-plane outages: a warmed SDK keeps evaluating from its last valid snapshot. |
| I6 | Bounded staleness applies only under stated conditions — connected instances have a measured SLO; offline instances have no guarantee. |
| I7 | Flags are not an authorization boundary — protected operations need an independent permission check. |
| I8 | Privacy by design: attribute minimization, tenant-scoped audit, no unnecessary PII in targeting or telemetry. |
| I9 | Every publish is versioned, immutable, and auditable — rollback publishes a new version, never rewrites history. |
| I10 | Telemetry is non-blocking — evaluation never waits on, or fails because of, the telemetry pipeline. |
| I11 | Cache and distribution keys are namespace-safe, never collide across tenants sharing a flag key. |
| I12 | Invalid configuration (cycles, bad weights, unsupported operators) is rejected at publish time, before reaching any SDK. |