Every thing the product is supposed to do, written down once — and, next to each one, machine-checked evidence about whether the code actually does it.
The unit everything else is built from.
A criterion is one promise the product makes, small enough that a single test can pass or fail on it. “A Dissolved Group never implies a member’s client status changed.” That’s one. It lives in its own file, has an id that never changes, and sticks around long after the ticket that introduced it is closed.
Two rules make the rest work.
One criterion, one outcome. A promise that bundles several things gets split into several criteria, one per outcome. Not because bundling reads badly, but because it hides missing proof: several tests can all pass while one of the bundled promises has no test at all, and the row still shows green. Split, and the unproven one sits visibly unproven. (Many tests proving one outcome is fine — that’s just edge cases. All of them must pass for it to count.)
The criterion doesn’t bend to the code. If they disagree, either the code changes or someone deliberately re-words the promise — and re-wording is recorded, which un-proves it until a test catches up.
The one idea the whole system rests on.
Nobody sets a criterion’s status. There is no field to set. A small function reads two things — is there a test that names this criterion, and did it pass? — and the status falls out. That’s deliberate: a status somebody types is a claim, and claims drift from reality the moment attention moves elsewhere.
| Status | What it means | Computed from | Now |
|---|---|---|---|
| broken | We still promise it. A test that names it is failing. | test run | 0 |
| stale | Proof exists, but for an older wording of the promise. | hash comparison | 0 |
| specified | We promised it. Nothing shows whether the code does it. | no test, no sign-off | 150 |
| verified | A test that names this criterion passed, against this exact wording. | test run + hash match | 0 |
| attested | A named person checked it by hand, against this exact wording. | attestation + hash match | 0 |
| retired | We built it, and we no longer promise it. | human + reason | 1 |
| cancelled | We promised it, never built it, and dropped it. | human + reason | 0 |
Two fields a human does write are deliberately powerless.
claimedImplemented is a developer saying “I built this” — useful, but
it’s a self-report, so it shows up as a note reading claimed, unproven rather than a
status. coverage.kind says what kind of proof is possible (an automated test,
a manual check, or none), never whether the promise is kept.
The full loop, from someone writing a PRD to a green board.
specified after the slice ships. Only running the tests moves it, because writing
code is a claim and a passing test is evidence. Notice too that the PRD must merge for its
promises to count: criteria have to be on trunk before slice branches fork off it, or there is
nothing for a test to name.Two mechanics, both boring on purpose.
First, the tag. A test claims a criterion by putting its id in the test title:
it('ENG-7040 / B7.2 — linking an existing member never creates a second edge', …)
It must be in the title, not a comment above it. Test reporters emit test names; they never see your comments. A tag in a comment reads fine to a human and is invisible to the machine, so the criterion shows as unproven no matter how well it’s tested.
Second, the content hash. Each criterion’s wording is hashed. When a test passes, the stamp records which hash it passed against. Re-word the promise and the hashes stop matching — so the criterion goes stale rather than staying green. That’s the mechanism that stops old proof quietly covering a new promise.
Everything above, as one picture.
cancelled means we never built it — nothing to remove.
retired means we did — there is probably code to delete. Both keep their record
and their reason forever, so “did we ever promise this?” stays answerable. And note
attested goes stale just like a test does: a manual sign-off pins the wording it
checked, so last quarter’s QA stops counting when the promise moves. The dashed box holds
the live states — deciding to postpone work on one of these doesn’t move it
anywhere, because postponing changes our plans, not what the code does. That’s a
deferral, and it rides alongside the status rather than replacing it.Why a postponed promise keeps its status.
Plenty of promises are real but not being worked on yet — blocked on another piece, waiting
on a product decision, or simply not this quarter. The tempting move is to add a
deferred status. That would be wrong, and the reason is worth understanding because it
explains the shape of several other decisions here.
Status answers one question: what is the relationship between this promise and the running code? A promise we’ve postponed has exactly the same relationship as one nobody has got to yet — unproven. What differs is our plans, which is a different kind of fact. And treating it as a status would destroy information: a criterion can be broken and postponed — you know it regressed, and you’ve decided not to fix it this cycle. One status field can’t hold both.
So a deferral is a record that rides alongside the status, not a replacement for it:
deferrals:
- deferredAt: 2026-08-07
deferredFrom: ENG-7599 # the work that would have built it
decidedBy: michael
blockedBy:
- ref: ac:ac_profile_servicing-k91mq4
note: needs the polymorphic servicing-team surface first
resolution: null # open; closes with an outcome, never deleted
Blockers are typed, and most of them are not other criteria:
| Kind | Means | Wakes the work up automatically |
|---|---|---|
| ac: | another promise has to exist first | yes |
| issue: | tracked work somewhere else | no |
| decision: | a product question nobody has answered | no |
| external: | a vendor, an upgrade, infrastructure | no |
| capacity | nothing is blocking it; we chose not to | no |
The other four kinds exist so nobody has to invent a criterion just to have something to point at. Force every reason into “blocked by another criterion” and the catalog fills up with promises nobody actually wants.
specified throughout. What changed is that the thing A was waiting for is now proven,
so A surfaces on the board under blockers clear without anyone re-reading the
deferral. Only ac: blockers can do this; the other kinds are honest reasons that
simply aren’t machine-checkable.The split that makes the board trustworthy.
| Tool | Writes | Why you can trust it |
|---|---|---|
| /ac-spec | criteria, deferrals, retirement and cancellation | a person reviews it as a diff in a PR |
| import-extract.mjs | criteria, copied word-for-word from an extract | mechanical; it refuses design docs |
| stamp.mjs | test evidence | contains no AI — it cannot form an opinion |
| attest.mjs | manual-QA evidence | records who, when, and which wording; never judges |
Whoever declares a promise must not be the one who certifies it’s kept. If the same actor did both, the board would be worth exactly what a self-report is worth — which is what it replaced.
Where this can still lie. The tag is the seam. A test tagged with a criterion’s id that asserts nothing at all will still stamp verified, and no script can tell. So the honest claim is narrower than “the catalog is correct”: it is the catalog reflects which tagged tests pass. Whether a test is any good stays a code-review problem.
The line that keeps the catalog from filling with engineering rules.
Some rules apply to everything — “no personal data in logs”, “every migration is idempotent”. It’s tempting to file those here. Usually that’s wrong, and the test is not how broad the rule is but whether you can enumerate what it covers:
docs/standards/, graded against each pull request.How one criterion covers eight hundred fields without eight hundred rows.
Some promises apply to a whole family of things: every native field, every purpose code, every personal-data field name. Writing one criterion per member would be thousands of near-identical rows, and — worse — adding a new field tomorrow would need someone to remember to add its row. The safety net would be opt-in, which is no safety net.
So instead: one criterion, and one test that walks the list. The list is a real thing in the code — a registry, a lookup table, an enum-keyed record — and the test reads it at runtime rather than copying it. Add a member and it is covered automatically, because the test never knew how many there were.
A criterion proven this way carries a conformance block, and the board marks it
registry-proven with a link to the list itself:
conformance: enumerates: PURPOSE_COMPAT_TRAITS # the list the test walks source: packages/modules/.../group.types.ts register: null # exceptions, if the rollout is partial
enumerates must name the table the test actually reads, not the
type that happens to name the family. SystemGroupType tells you there are eleven
purpose codes; PURPOSE_COMPAT_TRAITS holds the per-code attributes every verdict is
derived from. Only the second tells a reader where the behaviour is decided.
Rolling something out across eight hundred fields takes a while, so the gate needs a way to say “these ones don’t comply yet” without going red. There are two ways to write that, they look nearly identical in review, and one of them is a trap.
A rollout in progress is really making two different claims, and they need separate rows because one is true today and the other isn’t:
Merging them into one criterion forces a choice between sitting red for a year or wording the promise loosely enough that green stops meaning anything.
A registry-driven test is only as complete as the registry. A field that exists in the code but was never registered isn’t in the list, so the test never walks it and the gate stays green — the same blind spot as an allow-list, one level up. That needs a second check of a different shape: scan the code surface and diff it against the registry. Two criteria, because they fail independently — every registered item complies, and every item is registered.
One decision that buys several properties.
The criteria are files in git, next to the code. So the catalog on any branch describes what that branch promises — two branches disagreeing isn’t a conflict to resolve, it’s the record being accurate. Changing a promise shows up in code review like any other change. And because a deployment is just a commit, “what does qa-4 promise?” is answered by reading the catalog at the commit qa-4 is running. None of that works if the promises live in a ticket tracker.