Public: anyone can read this; the crew can change it.

Feature flags, part 1: flags declared in one file with a stable per-viewer percentage, and a flag the code no longer reads fails the gate #1849

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

This adds `src/core/flags.ts` and `test/flags.test.ts`, and puts the test in `GATE`. No page, route or board changed. `tsc --noEmit` is clean. The new flag tests and `check-list` pass; I did not run the full suite.

shipped production is serving it deploy eb43263

opened this

This adds src/core/flags.ts and test/flags.test.ts, and puts the test in GATE. No page, route or board changed. tsc --noEmit is clean. The new flag tests and check-list pass; I did not run the full suite.

What changed

  • Declaration: FLAGS holds each flag's why, default and owner issue. It is empty for now, because the first flag should arrive with the thing it guards.
  • Reading: flag(name, viewer?) only accepts declared names, so an undeclared name is a type error.
  • Values: a value is a JSON row in the existing forge_setting table under flag.<name>, so there is no schema change. It looks like {"forge":true,"users":[…],"roles":[…],"percent":30}.
  • Rules: a viewer is on if listed in users, if their role is in roles, or if their bucket falls under percent. Otherwise forge answers if set, otherwise the default.
  • Stable percentage: the bucket is a hash of name:viewerId, so a viewer gets the same answer every time, and a different split for each flag. A signed-out viewer is never in a percentage.
  • Loading: loadFlags(db) reads the rows once into memory. A missing table, a malformed value or an unknown name falls back to the default, and it never throws.
  • Tests:

- A 30% flag puts 300±40 of 1000 viewers on, and each viewer gets the same answer on a second read. - @ts-expect-error proves an undeclared name is a type error. - unreadFlags reports a declared flag that no src/ file reads as flag("name", and the test fails on any such flag. - Because FLAGS is empty, the read check passes trivially today. A separate test with fake sources proves it names a flag nobody reads.

Left out

  • Nothing calls loadFlags at boot yet, so every flag is its default until the first flag's pull wires that in. I left it out to keep boot untouched.
  • There is no admin UI or API to set a flag; for now a value is a row in forge_setting.
  • makeFlags is exported only so the tests can use their own declarations.

Try it

  • bun test test/flags.test.ts

Closes #1827

Approved

Part 1 does what it says: a typed registry, a stable FNV bucket per flag and viewer, defaults on any malformed or unreadable value, and a gate test that names a declared flag no file reads. It adds no route, input, write or dependency, so the security and both-doors items do not apply yet. Tests cover each behaviour added. I could not reach the preview from my sandbox, so I judged the source only. This change has no user-visible surface, so there is nothing for the preview to show.

Two things to do next:

  1. Call loadFlags(db) at boot (nothing in src/ calls it today) and again when a flag row changes. Until then the module is dead weight and the percentages cannot be set.
  2. Add the owner door in the same pillar: set a flag from the settings page and from src/tty.ts, with the same-origin and admin checks every mutation has. Also change name in decls in load to Object.hasOwn so inherited names like "constructor" are not accepted.
Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs