ADR-0064 One derivation per list, and the check that joins two of them
#Context
ADR-0063 closed a gap and named the check that would have caught it: *every scene on the CLI roster is themed or excluded by name with a reason.* It also said the naming was itself a habit — the third time in this session a record had ended by naming a check instead of writing one — and that the first two were written in the pass that followed. This is that pass.
Writing it turned up the reason the gap was possible, which is not the missing check.
#The lists could not be read
packages/budget/src/cli.js holds the scene roster and dispatches a command at module scope. packages/budget/tools/make-themed-fixtures.js holds the themed set's two lists and writes 170 files at module scope. Neither can be imported. Every consumer that wanted to know what those lists held had to pull the arrays out of JavaScript with a regular expression.
That is not hypothetical and it cost two wrong figures in two consecutive passes. A naive quote scan reading MUST_FAIL counted a contraction inside a comment as a list entry, and this session's own must-fail figure was wrong until it was checked against receipts/themed.json. A second scan, written to be more careful, silently dropped every entry that shared a line with another and reported nine scene names where there were fourteen.
tools/roster.js already exists for exactly this, and ADR-0053 states the rule it was extracted under: a fact with two derivations is a fact that can disagree with itself. The scene roster and the themed lists had one derivation each and no way to reach it, which is the same defect wearing the opposite face — not two readings that can disagree, but one reading nobody can have.
Four consumers had grown around that one array, and moving it found all four at once. check-status-figures.js sliced cli.js between const SCENES = [ and the next ]; and counted { file: ' inside it — with the array gone the slice found nothing and it reported zero scenes against a roster of fifty-four. cascade-audit.js did the same between const SCENES = [ and async function run(, and was the more careful of the two: it threw rather than reporting zero, which is the difference between a check that fails and a check that lies. Both now import the module. A second derivation is the one that goes quietly wrong, and these went wrong only because the first derivation moved.
#Decision
Both lists move into data modules that their tools import, and the check imports the same modules rather than parsing anything.
packages/budget/src/scenes.js— the engine's roster, moved out ofcli.jsverbatim with its comments. Nothing in the array changed.packages/budget/tools/themed-scenes.js— the themed set'sSCENESandRETHEMED, plusTHEMED_EXCLUSIONS, moved out of the generator.tools/check-themed-coverage.js— imports all three and joins them.
tools/check-status-figures.js and packages/budget/tools/cascade-audit.js are amended to import src/scenes.js as well, so the roster now has one derivation and four readers.
pnpm themed:check joins the two lists in both directions, and both directions earn their place:
*Every scene the engine measures is in the themed set, or excluded by name with a reason.* This is the direction the gap opened in — a scene arrives, nobody adds it, and the themed set quietly covers a smaller roster than it claims.
*Every scene in the themed set is on the engine's roster, or excluded by name with a reason.* Nobody was watching this direction at all, and it holds two scenes: 05-overlay and 06-overlay-violation are themed and have never been on the unthemed roster. Both are deliberate and both are asserted by name in the themed CI job — one step confirms the legal overlay passes in every theme it is authored for and prints its scrim Scope against each ceiling, another confirms the violation fails on emission and nothing else. Until now that intent lived in two workflow steps and nowhere a check could reach.
#The exclusions are data with reasons, and four assertions guard them
An exclusion list is where a check goes to stop being one. THEMED_EXCLUSIONS carries a sentence per entry, and the check refuses:
- an exclusion naming a file that does not exist — how a list keeps the reason for a decision that was reversed, reading as coverage rather than as the stale entry it is (ADR-0041);
- an exclusion naming a scene that is not actually excluded — present in the other list anyway;
- a reason that is a word rather than an argument, measured as a length floor, because "legacy" is an exclusion nobody argued for;
- either list repeating a scene, which neither join can see: a scene named twice is still covered and the counts still agree, so a list that can repeat itself is a list whose length means nothing.
And one positive assertion beside them, because the two joins are between two lists and would both pass on a repository where the generator had never run: every themed fixture on disk comes from a listed scene, and every listed scene generated one. 170 fixtures from 55 scenes.
#Rejected options
Guard the two files' module-scope side effects instead of moving the data. Smaller: wrap cli.js's dispatch and the generator's write loop in an import.meta.url === argv[1] test and import them directly. Rejected because it makes a data question depend on an execution subtlety — the next reader has to know why importing a CLI is safe — and because the data has three consumers now and belongs where three consumers can see it. tools/roster.js set the precedent and it is the right one.
Parse the lists more carefully rather than importing them. What the two wrong figures were produced by, twice, with the second attempt being the careful one. A regular expression over source is a second derivation of a fact, and the whole of ADR-0053 is that a fact with two derivations can disagree with itself. The second attempt failing differently from the first is the argument, not an anecdote.
Check only the direction the gap opened in. Half the work and it would have looked complete. Rejected because the other direction found the two scenes nobody had joined to anything, and because a coverage check that only looks one way answers "is the themed set behind?" while leaving "does the themed set contain something nothing else knows about?" to whoever notices.
Put the two overlay scenes on the unthemed roster instead of excluding them. Considered seriously and refused for lack of an argument rather than for a reason against it. They have never been on that roster — checked with git log -S, they were never dropped — and adding them would mean two new unthemed baselines on two platforms in a pass whose subject is a check. What they are for is the per-theme comparison, and the exclusion says so. If they belong on the unthemed roster, that is a decision with its own evidence and its own pass.
#Consequences
The battery is 23 checks, up from 22, and playbook §10's enforcement table is 27 rows. battery:check refused the new entry until CI ran it, which is the assertion ADR-0056 built for this exact case working on its first real use.
cli.js is 365 lines shorter and holds no data. The array moved verbatim; pnpm budget was run against it before anything else in this pass. Two other tools were broken by the move and are fixed here — the status-figures check and the cascade audit, which between them are the evidence that the parsing was a habit rather than an isolated shortcut.
All five assertions were observed red on the breakage each exists to catch before being trusted green (ADR-0041): a roster scene removed from the themed list, an invented scene added to it, an exclusion pointed at a missing file, a reason shortened to a word, and a roster entry duplicated. Each reported by name; each restored.
A gap this record does not close. The same argument applies to tools/battery.js's own lists and to make-themed-fixtures.js's MUST_FAIL — the list whose comment caused the first wrong figure is still inside cli.js, still unreachable, and still read by nothing but the CLI itself. It is smaller than the one closed here because only one consumer wants it, and it is named rather than done.
#Measured
- Two figures wrong in two consecutive passes from parsing these lists: a contraction read as an entry, and nine scene names found where there are thirteen — *disk, this pass*.
cli.js: 365 lines of data moved tosrc/scenes.js, array unchanged,pnpm budgetgreen against it. Four consumers had parsed that array; all four now import it.themed:check: 8 assertions, 54 roster scenes joined to 55 themed scenes both ways, 3 exclusions each carrying a reason of at least 60 characters (shortest 320), 170 themed fixtures on disk from 55 scenes.- All 5 breakage cases observed red and restored green.
- Battery 22 → 23 checks; playbook §10 26 → 27 rows.
pnpm battery— the verdict this pass is measured by.