AC Catalog ← back to the board
How it works

A ledger of promises

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.

Start here: what a criterion is

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.

Status is computed, not typed in

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.

StatusWhat it meansComputed fromNow
brokenWe still promise it. A test that names it is failing. test run0
staleProof exists, but for an older wording of the promise. hash comparison0
specifiedWe promised it. Nothing shows whether the code does it. no test, no sign-off150
verifiedA test that names this criterion passed, against this exact wording. test run + hash match0
attestedA named person checked it by hand, against this exact wording. attestation + hash match0
retiredWe built it, and we no longer promise it. human + reason1
cancelledWe promised it, never built it, and dropped it. human + reason0

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.

Where criteria come from, and how they get proven

The full loop, from someone writing a PRD to a green board.

PRD BRANCH NEXT/V2 — TRUNK PRD drafted criteria written with it PRD merges the promise is official Extract requirements backend + frontend Slice builds it tests tagged with the id Lanes run, stamp unit · integration · UI Manual QA signs off what tests can't A later PRD re-words the promise not on trunk yet merged imported tagged evidence recorded attestation hash moves draft specified specified still specified verified broken attested stale re-prove against the new wording WHAT THE TEAM DOES WHAT THE CATALOG SAYS
Top row: what people do. Bottom row: what the catalog says as a result. Notice that building the code changes nothing — the criterion is still 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.

How a test proves a criterion

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.

The full lifecycle

Everything above, as one picture.

LIVE STATES — A DEFERRAL CAN ATTACH TO ANY OF THEM WITHOUT CHANGING IT specified verified broken attested stale cancelled retired test passes test fails fixed person signs off re-worded re-worded re-proven nothing shipped code shipped
Two endings, because “we don’t promise this any more” has two histories. 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.

Putting work off, without lying about 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

What can block something

Blockers are typed, and most of them are not other criteria:

KindMeansWakes the work up automatically
ac:another promise has to exist firstyes
issue:tracked work somewhere elseno
decision:a product question nobody has answeredno
external:a vendor, an upgrade, infrastructureno
capacitynothing is blocking it; we chose not tono

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.

The point of recording it: the work wakes itself up

BEFORE AFTER A TEST PROVES B B — specified A — specified B — verified A — specified deferral — blocked by B deferral — blockers clear READY TO PICK UP waiting on satisfied
A blocker you can compute against is a work queue; one you can’t is a comment. Neither criterion’s status changed because of the deferral — A is 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.

Three rules that keep it from rotting

Who is allowed to write what

The split that makes the board trustworthy.

ToolWritesWhy you can trust it
/ac-speccriteria, deferrals, retirement and cancellationa person reviews it as a diff in a PR
import-extract.mjscriteria, copied word-for-word from an extractmechanical; it refuses design docs
stamp.mjstest evidencecontains no AI — it cannot form an opinion
attest.mjsmanual-QA evidencerecords 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.

When it’s a criterion, and when it’s a standard

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:

The registry pattern, in detail

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.

Partial rollouts: skip a deny-list, never iterate an allow-list

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.

THE TRAP — ITERATE YOUR OWN LIST CORRECT — ITERATE THE REGISTRY, SKIP EXCEPTIONS REGISTRY (source of truth) REGISTRY (source of truth) field A field B field C field D — added today field A field B field C field D — added today checked checked checked never checked checked skipped — on the register checked checked for (id of MY_LIST) assert(id) for (id of REGISTRY) if (EXEMPT.has(id)) continue; assert(id) D is not in MY_LIST, so nothing reports it. The gate is green and the coverage is a lie. Absence from the register means “must comply”.
Same green build, opposite meaning. An allow-list makes not being listed mean “exempt”, so every item added after it is silently excused forever — and the list stops describing the remaining work. A deny-list makes not being listed mean “must comply”, so the register is a complete, shrinking inventory of the gap. The register may only get shorter: CI compares it against the branch you merged from and fails if it grew, so an exemption costs an argued line in a diff rather than a quiet append.

Two promises, not one

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.

What this pattern cannot see

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.

Why it lives in the repo

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.