This site uses one functional cookie to keep feature rollouts consistent for you. Nothing is set until you choose. See the privacy notice.
Dev notes
A feature-flag management console — per-environment targeting rules, sticky percentage rollouts, a kill switch, and an audit log. You describe a user at the top and every flag card shows, live, what that user gets and why. The whole thing is built around one pure function: a deterministic engine that, given the same flag, environment, and user, always returns the same decision — and can explain why.
The core of a flag system is one question asked millions of times: does this user get this feature right now? Everything else — the console, the API, the audit log — is presentation around that answer. So the answer came first, as a single pure function:
evaluateFlag(flag, environment, context) => { value, reason }Nothing in the engine touches the network, the clock, or global state. The same (flag, environment, context) always produces the same result. That is what lets the same decision hold on the server, on the client, and in a test — and it is what makes a percentage rollout sticky for a given user instead of reshuffling on every refresh.
Percentage rollouts hash the user into a stable bucket in [0, 100). The seed is `${flagKey}:${userKey}`, run through FNV-1a and normalized. A 25% rollout serves everyone whose bucket is under 25; raising it to 40% only adds the 25–40 band, so users already exposed stay exposed. The rollout grows monotonically — no one flickers out as it widens.
Keying on both the flag key and the user key decorrelates flags: being in the 25% of one flag tells you nothing about your bucket in another.
FNV-1a alone leaves sequential keys (user-1, user-2, …) clustered, which would hand a rollout to a lopsided slice instead of an even one. A murmur3-style avalanche finalizer — a handful of shift/xor/multiply rounds — scatters the bits so single-character input differences spread across the whole range. The distribution comes out flat, and the cost is a few integer ops per evaluation.
Every evaluation walks the same ladder, and the first rung that applies wins:
OFF. The emergency "off now" path has to beat everything else.in, equals, contains, startsWith, …). Reason RULE_MATCH carries the matched rule index.FALLTHROUGH); a weighted fallthrough buckets the context (FALLTHROUGH_ROLLOUT, carrying the bucket).Every path returns a reason alongside the value, and a pure display helper turns it into plain English — "Matched targeting rule: routes paid plans to the fast support queue," or "Percentage rollout — landed in bucket 34.2." That is what powers the evaluation playground: type a user key and attributes, pick an environment, and see every flag resolve live and why. "Why did this user get the old checkout?" is the question you actually debug, and the engine answers it directly instead of leaving you to reconstruct it.
The UI is a thin client of the engine: an environment switcher, a card per flag with a status pill (Off, Targeted, Partial, Fully on) derived from pure helpers, a kill switch, a rollout slider, and an audit log. Status labels and exposure percentages are computed by testable pure functions kept out of the components, so the derived numbers can be asserted without rendering anything.
Every mutation — toggling the kill switch, changing a rollout weight — records an audit entry with a human-readable summary. For flags, "who changed this and when" is first-class, not an afterthought.
The console can toggle flags and set rollout weights; targeting rules are currently seeded rather than editable in the UI. A clause builder that writes back through the same PATCH API would close the loop without the engine changing at all.
Flags that depend on other flags — "only evaluate the new checkout if the redesigned cart is on" — are a natural extension of the precedence ladder, evaluated recursively inside the same pure function.
The store started in-memory, reseeding on every server restart — fine for a demo, and the claim in the write-up was that swapping it for a persistent one would not touch the engine or the components. That claim is now being cashed in. The four /api/flags routes became thin proxies over a small BFF layer that prefers a live backend (the same portfolio_api that backs the referral links) and falls back to the in-memory seed when the service is unreachable — so the console reads and writes shared, persistent data once the backend is deployed, and still works, looking identical, when it is not. The engine and the components did not change, which is exactly the property the original design was betting on.
The BFF is careful about what it hides. Reads fall back to the seed on any failure, because a readable console beats an error page. Writes only fall back on a genuine connection failure — a real 401 or 404 from the API is propagated, not masked, so a signed-out visitor gets an honest "sign in to change flags" instead of a silent local edit that never persists. And every payload crossing the boundary is validated against the same Zod schemas the console uses, so a drifting API surfaces as a clear error instead of quietly bad UI.
Once the store was real, the console had to stop pretending it was a toy. The old "demo data" line became an honest status strip: a Backed by a live API badge, a plain sentence that the flags live in portfolio_api and are evaluated by a deterministic engine, and a live resets in ~2h 14m countdown so a visitor knows any change they make is temporary. The countdown is a pure function over the current time and the fixed six-hour UTC cadence — the exact schedule the reset cron runs on — so it is unit-tested without a clock.
Signed in or not turned out to be the wrong question. This console is doing two jobs at once: most of it is a playground meant to be touched by whoever wanders in, and one part of it is a live kill switch. One rule across both makes either the playground useless or the kill switch reckless. So there are three rungs — open to everyone, signed-in visitors, and site owner only — and the page is grouped by them, with a badge saying which rung you can reach and a line on each locked card saying why. Nobody should have to click a dead switch to discover the rule.
The top rung is the same verified-email allowlist the research ask box uses: the address has to be one I named in config, and the provider has to have verified it, since an unverified claim is just something typed at signup. Unset means nobody rather than everybody, because only one of those two failure modes is loud enough to notice. The server distinguishes the two refusals too — 401 when you are signed out, 403 when you are signed in and it still is not yours. Collapsing them sends someone to a login screen that cannot help them.
Deciding the rung was harder than it looks, because the API serves a different set of flags than the local seed does and carries no access field of its own. Deriving the rung from the flag record gave two different answers on the two sides: the console inferred every API flag as open while the route enforced from seed data. Keying the map on the flag key — the one thing both sides always have — is what makes the page and the server agree, and one module now answers the question for both.
The open rung then hit a wall worth recording. The API authorizes every write on a token, so an anonymous change could only ever reach the in-memory store — and since reads come from the API, it sprang straight back on the next fetch. A tier that silently reverts is the exact bug I had just finished fixing, so it does not ship that way: the server carries a token of its own for that rung, the way the public operator demo already writes without a user. It is deliberately not reused above that rung. Attributing an admin's kill switch to the server would make the allowlist pointless and the audit log a fiction.
The other half was subtler and had been quietly broken in production. The route forwarded whatever bearer token arrived in the request's own Authorization header — but the console never sends one, so the write reached the API with no credential and came back 401. React Query dutifully rolled the optimistic update back, which on screen looked like the toggle flipping itself straight back to enabled. The token is now resolved server-side from the session, which fixes the bug and closes the hole underneath it: a token read off the request is whatever the caller decided to put there, which made the audit log's actor a suggestion rather than a fact.
A console that only toggles hypothetical flags is still a toy. The last step was to put a real, visible page behind one: the /tcg/pocket Pokémon TCG Pocket experience is now gated by a pocket-tcg flag, evaluated for the actual visitor.
For a rollout to be sticky it needs a stable per-visitor key, so middleware sets a first-party visitor_id cookie once — anonymous, a year long, forwarded on the same request it is minted on so the first render already sees it. A server component reads that key, runs the same pure engine against the persisted flag, and renders the on or off branch directly. Because the decision happens on the server, there is no flash of the wrong state — the visitor never sees the page appear and then vanish.
It fails open: if the flag exists nowhere, the feature stays on, so a config gap can never hide something that otherwise works. Seeded fully on, nothing is hidden today — but flip the kill switch or dial the rollout down in the console and real visitors lose access, each stuck to their own bucket. That is the whole point: the console now demonstrably changes what a real person sees.
A bug caught this the honest way. Drag a rollout to 100% on and the card's verdict would still read OFF — bucket 90, disabled. The slider said one thing, the verdict said another, and both were drawn from the same screen.
The cause was a read-your-writes race. The playground was POSTing to an evaluate endpoint that read the flag config from the server store — but a rollout drag is applied optimistically on the client, so the slider jumps to 100% instantly while the write is still in flight. The re-evaluation raced that write and read the old config, so it bucketed the user against 25% on and returned OFF. The verdict was not wrong about the config it saw; it was just looking at a config the user had already moved past.
The fix was to stop reading from a place that could lag. The console now evaluates every card in the browser, through the same pure evaluateAllFlags engine, against the exact flags it is rendering — the way a real flag SDK evaluates locally after fetching configs. There is no round-trip to race and no async gap to flash through: the verdict is a pure function of what you are looking at, so it can never disagree with the switch above it. Writes still go to the API; only the decision came home to the engine.
Throughout, one rule held: no flag decision lives in a component or a network round-trip. The moment resolution logic leaks out of the pure engine, you lose determinism — and determinism is the whole reason to trust a rollout at all.
Update — August 10, 2026
The console started as a list of flags with their config. That reads like the data model rather than the question anyone actually has, which is always what does this specific person see? It was reworked around a live test-user bar: describe a user, and every flag card shows what they get and why, updating as you change them.
Structuring it that way turned explainability from a feature into a property. The engine already returned a reason with each decision, so showing the reason beside the value cost nothing — but only once the interface was organised around the evaluation rather than around the flag.
The transparency strip is the part I would defend hardest. Most of these flags are demo data with a deterministic engine behind them; one is real and changing it needs a sign-in. A console that looked identical either way would be quietly dishonest, so it says which is which on the page rather than in a footnote.