A multi-tenant feature-flag and A/B experimentation platform. Flag evaluation happens in-process — the SDK holds the whole ruleset in memory and adds zero network I/O to the host application. Rulesets are pushed over SSE with monotonic versioning, bucketing is deterministic and identical across languages, and the analysis engine uses always-valid sequential testing rather than fixed-horizon p-values you peek at.
Design reasoning for the non-obvious parts is in docs/DESIGN-DECISIONS.md.
- Always-valid sequential testing (mSPRT) and chi-square sample-ratio-mismatch detection. Across 1,000 simulated A/A experiments checked at 39 interim points, the false-positive rate held at 1.0% where naive fixed-horizon peeking produced 27.4%. → reproduce
- In-process SDK evaluation at 241–827 ns/eval (200 flags × 5 rules, zero network I/O), with identical bucketing across TypeScript, Go, and Java enforced by a 500-case conformance fixture gated in CI. → reproduce
- 4,305 concurrent SSE connections at ~18 KB heap each, held by a sharded connection registry with bounded per-connection buffers and slow-consumer eviction — zero evictions under load. → method and limits
- Evaluation latency cut ~2,000 ns → 716 ns by profiling out three hot-path allocations: a per-hash encoder allocation, a per-rule set allocation, and a linear variation lookup. None of them was the hash.
Every figure above is reproducible with a single command and stated with its test conditions. Where a measurement has limits — the connection ceiling is the load generator's, not the server's — those are recorded alongside it rather than omitted.
- In-process evaluation. A flag is checked hundreds of times per request. It cannot make a network call. Everything in the architecture follows from this.
- Cross-language determinism. The TypeScript, Go, and Java SDKs must bucket a user identically. One spec (spec/BUCKETING.md), one shared conformance fixture, run in CI against every SDK.
- SSE fan-out under slow consumers. Thousands of long-lived connections behind a sharded registry; each connection gets a bounded buffer, and on overflow the connection is dropped and forced to resync rather than blocking the broadcaster.
- Version ordering. Every environment has a monotonic version. SDKs reject any payload older than what they hold, so out-of-order delivery cannot corrupt state. Cache invalidation is by version bump, never by delete — a stale read is old but valid, never missing.
- Payload filtering by key type. Client-side keys ship in browser bundles. A rule like
email endsWith @competitor.commust never reach a client SDK. One rule tree, two serialization paths. - The peeking problem. A fixed-horizon t-test checked continuously produces false positives far above 5%. mSPRT does not. Both are implemented, and 1000 simulated A/A tests measure the difference.
apps/
console/ Next.js admin dashboard — flags, rules, experiments, audit
control-plane/ Fastify + TypeScript — admin CRUD, auth, audit, rule validation
data-plane/ Go — snapshot serving and SSE fan-out
packages/
core/ Shared wire types; the contract between every component
sdk-js/ TypeScript SDK
sdks/
go/ Go SDK
java/ Java SDK
spec/ Normative bucketing spec + cross-language conformance fixtures
infra/ Dev datastores, plus a full containerized stack
The control/data split is deliberate: the control plane is low-traffic and high-complexity, the data plane is high-traffic and low-complexity. Different scaling profiles, different languages, different deployment cadence.
Node 22+, Go 1.23+, Docker, and a JDK for the Java SDK.
npm installnpm run infra:upnpm run buildOr run every service in containers — see DEPLOY.md for the environment it needs:
docker compose -f infra/docker-compose.prod.yml up -dReproduce either of these yourself; both are deterministic.
Evaluation latency (npm run bench) — node 24.17, win32 x64, 200 flags × 5 rules, 2M iterations, in-process, no network I/O:
| Path | ns/op |
|---|---|
| miss all rules → default | 241 |
| match rule + rollout | 716 |
isEnabled |
712 |
| varying flag key across 200 flags | 827 |
The first benchmark read ~2000 ns/op. Three hot-path allocations were responsible — TextEncoder.encode allocating per hash, a Set allocated per rule for cycle detection, and a linear variations.find() with a closure per evaluation. None of it was the hash.
The peeking problem (npm run aa:simulate) — 1000 simulated A/A experiments, 20k users/arm, 10% baseline, α=0.05, checked every 500 users:
| Strategy | False positive rate |
|---|---|
| Fixed horizon, one look at the end | 5.1% (nominal 5%) |
| Fixed horizon, peeking continuously | 27.4% |
| mSPRT, peeking continuously | 1.0% |
Both arms draw from the same distribution, so every rejection is a false positive. The middle row is what most teams actually do.
SSE fan-out (see LOADTEST.md) — i5-8600K (6 cores), Windows 11, load generator co-located with the server:
| Measurement | Result |
|---|---|
| Concurrent SSE connections held | 4,305 |
| Heap per connection | ~18.1 KB |
| Slow-consumer evictions under load | 0 |
| Propagation p50, single connection | 1.6 ms |
| Broadcast fan-out to 1,000 subscribers | 117 µs |
Heap per connection came out at 18,184 / 18,105 / 18,178 bytes at 500 / 2,000 / 4,305 connections — that consistency is why it is the number worth quoting. The 4,305 ceiling is the load generator's limit, not the server's: 695 of 5,000 connections failed on the client during the ramp while the server logged no errors and reported all 4,305 connected. Latency above a few hundred connections is inflated by co-location and by Windows' ~15.6 ms timer granularity, so treat it as an upper bound rather than a server property.
Done — 202 tests with all services up, plus the Go suite under -race and a Java conformance run. Every service is containerized and the stack runs end to end from infra/docker-compose.prod.yml.
- Deterministic bucketing — MurmurHash3 in TypeScript, Go, and Java, each validated against published smhasher vectors rather than only against our own fixture, and all three gated in CI against the same 500 cases. The fixture deliberately carries multi-byte UTF-8, astral-plane characters, and every tail length, because those are what separate a correct port from one that merely agrees on ASCII.
- Rule evaluation — nested AND/OR/NOT, reusable segments, flag prerequisites, percentage rollouts. Both recursive structures are cycle-guarded; a malformed ruleset fails closed instead of overflowing the stack inside a customer's request path.
- In-process SDK evaluation — never throws, never performs I/O, degrades to the caller's fallback when no ruleset has loaded.
- Payload filtering by key type — a rule containing any server-only node is dropped whole rather than rewritten, because stripping a node out of an AND makes it more permissive.
- Statistics — Welch's t-test, two-proportion z-test, mSPRT, SRM detection, MDE calculator.
- SSE fan-out — sharded registry, bounded per-connection buffers, slow-consumer eviction. 117 µs to broadcast to 1000 subscribers.
- Snapshot store — monotonic versions, bounded history for
Last-Event-IDresumption, ETag conditional GET. - Control plane — Postgres schema and migration runner, hashed API keys with indexed-prefix lookup, a ruleset compiler that rejects invalid publishes rather than shipping them, publish transaction with per-environment version locking, append-only audit log, and an SDK snapshot endpoint that picks the payload from the authenticated key kind.
- Exposure pipeline — SDK-side adaptive sampling and hard-bounded queues, ingesting into ClickHouse. Aggregations count
uniqExact(dedupe_key)rather than rows, so at-least-once redelivery cannot inflate them regardless of whether a background merge has collapsed the duplicates yet. - Console — a Next.js admin dashboard wired to the control plane: flag list with optimistic toggles, a recursive rule builder bound to real data, publish, audit log, and an experiment results view plotting confidence intervals over time. Ruleset updates arrive live over SSE with the same monotonic version guard the SDKs apply, and the connection state is shown rather than hidden — a console that has silently stopped updating looks identical to one where nothing changed. The admin token never reaches the browser: every call goes through a server-side route handler.
- Publish authentication — the data-plane ingress is gated by a service token compared in constant time, and fails closed: no token configured disables the endpoint rather than leaving it open.
Not started — a conversion-event table (exposures supply the denominator; the numerator still has to come from somewhere), and multi-node data-plane fan-out (the control plane pushes to exactly one DATA_PLANE_URL, so a second replica would never receive updates).
Integration tests run against a real Postgres and skip cleanly when one is not reachable, so npm test stays green without Docker.
npm run build # all workspaces
npm test # 202 tests (25 need infra:up; they skip cleanly without it)
npm run conformance # 500-case fixture, TypeScript SDK
npm run bench # evaluation latency
npm run aa:simulate # the A/A false-positive measurement
npm run infra:up # Postgres (host port 5433), Redis, ClickHouseThe Go and Java SDKs check the same fixture. CI runs all three.
cd apps/data-plane && go test ./... -racecd sdks/go && go test ./... -run Conformancecd sdks/java && javac -encoding UTF-8 -d out $(find src test -name '*.java') && java -cp out com.flagship.sdk.ConformanceTest