Date: 2026-08-06 Status: accepted (ratified 2026-08-06 — see Ratification)
Context
ADR-0046 measured what this project's
test net catches on the seven library modules the verdict is computed from, and published the score
with its absences. The largest absence was the one that mattered most: src/commands/check.ts,
where the verdict is assembled and the exit code chosen, was measured by nothing.
The numbers, re-derived rather than quoted:
- 8.70 % line coverage, 0 % function coverage on
dist/commands/check.js. - No file under
test/unit/importeddist/commands/at all. - The mutation pass could not reach it either: mutating a file no test imports yields 100 % survivors, which is noise and not a measurement, so it was excluded from the perimeter by construction rather than by choice.
That produced a sentence an assessor finds by crossing ADR-0046 with the source tree: we measured
what our net catches, everywhere except where the verdict is decided. It is also the region the 22
false positives of ADR-0045 lived in, and
docs/compliance/regulated-adoption.md now hands that fact to regulated buyers under its own
heading. Leaving it stated and unfixed would be the behaviour this project exists to refuse.
Why it was untestable, precisely. The verdict was not hidden, it was interleaved. A single
309-line async function read the mission, rendered each section, and incremented gaps,
strictGaps and hookFailed as a side effect of printing them. There was no seam: to assert that a
tampered seal reddens the gate, a test had to spawn the CLI and read text. test/smoke.js and
test/audit-corpus.js do exactly that and are worth keeping, but neither can fail on a single term
of the arithmetic, and neither runs in four milliseconds.
Decision
The verdict is a pure function of the mission on disk, in src/lib/verdict.ts. check.ts
renders it and exits on it, and decides nothing.
computeVerdict(mission, opts)reads, counts and returns. It prints nothing, never touchesprocess.exitCode, and runs no hook. It returns everything the renderer needs — the deliverable rows, the gated results, the corpus reading, the evidence breakdown, the seal, the unratified decisions — pluscleanandexitCode.Hooks stay in the command layer, their count crosses. They execute the operator's commands, which is a side effect and belongs where side effects live. Only
hookFailedreaches the verdict, which is what makes the arithmetic testable without spawning anything.One definition of
clean, exported.verdictFrom(gaps, strictGaps, hookFailed)is the whole rule. It is exported because the command cannot callcomputeVerdictlast: theafterhooks run once the report is rendered, so the final count is only known at the bottom of the command. The alternative was to re-write the condition there, which is how two copies of one rule start drifting apart.No second opinion.
check.tsmust never re-decide anything it renders. If the render and the verdict ever disagree, the duplicated logic is the defect, not the disagreement.Behaviour-preserving, and proven so rather than asserted. 24 golden outputs were captured before the change across four missions (runward's own,
init --example, a bare scaffold, and a sealed-then-tampered one) and six flag combinations, then compared byte for byte after. 24 of 24 identical, exit codes included.
Alternatives discarded
- Unit-test
checkCommanddirectly, by capturing stdout. Rejected: it pins the rendering, so every reworded message becomes a failing test, and a guard that reds on harmless edits gets switched off. It also would not have made the arithmetic reachable. - Move the rendering into the library instead. Rejected: the library would then own colours,
terminal width and message wording, and
--jsonwould have to un-render them. - Leave it and widen
test/audit-corpus.js. Rejected on cost and on precision. The corpus takes 15 s and answers "the mission was refused"; it cannot answer "the seal contributed the refusal". Both are needed, at different grains. - Extract further, into one module per section. Deferred, not rejected. The seam that mattered was verdict against render; splitting the verdict itself buys nothing today.
Consequences
dist/commands/check.jsgoes from 8.70 % / 0 % to 57.93 % lines / 100 % functions, anddist/lib/verdict.jslands at 97.79 % lines, 90 % branches, 100 % functions. Whole-project line coverage goes from 74.90 % to 79.70 %.- Said plainly, because the number flatters otherwise: the coverage now recorded against
check.jscomes from the new test driving the real CLI in a child process (init,check --freeze), which Node's coverage aggregates. It is not a unit test importingcheck.ts. The structural gain is that the verdict is now imported directly, and that is where the 97.79 % applies. src/lib/verdict.tsenters the perimeter of ADR-0046's next mutation pass, which was the point.- 17 new cases in
test/unit/verdict.test.js, each pinning a term in both directions: a fixture that only ever expects a refusal is satisfied by a function that refuses everything.
Ratification — 2026-08-06
Measured on the shipped build, every figure re-derived:
- Golden comparison: 24 outputs, 4 missions, 6 flag combinations, 24 of 24 byte-identical.
- Full net green: unit 342/342, smoke, OSCAL, audit corpus,
check --strict— all exit 0. - 12 hand-written mutants applied to
dist/lib/verdict.js, 11 killed by the new file:cleanforced true, each of the three terms deleted in turn,exitCodeforced to 0, the deliverable counter disabled, the seal ignored, unratified ADRs ignored, the conformance/evidence/drift sum ignored, the corpus contribution zeroed, and--freezemade to verify the seal it replaces. - The one survivor is argued, not assumed: removing the skip branch in
judgeGatedchanges nothing observable, because no shipped mission ever skips a gated deliverable. Measured on both (init --yesandinit --yes --example): five gated deliverables, five examined, zero skipped. The skip needs a deliverable with no expected rule and no violation, and ADR-0002 pins every phase floor above zero, so a stripped mapping raises(mapping)instead of falling silent. The branch is unreachable on any corpus runward ships, and a test asserts exactly that rather than manufacturing a mission to reach it.
Reevaluation trigger (mandatory, dated)
Trigger set on: 2026-11-05, together with the ADR-0046 pass, or at the first change that adds a term to the verdict.
The decision is wrong and must be revisited if any holds: check.ts regains a counter of its own;
verdictFrom acquires a second caller that reimplements it; the skip branch becomes reachable on a
shipped mission, which would mean a phase floor fell to zero or a mapping violation went missing; or
the golden comparison can no longer be reproduced because the render and the verdict have drifted.
References
- ADR-0046 — the measurement whose largest absence this closes, and the perimeter this module now joins.
- ADR-0045 — the 22 false positives, and why the seal, the corpus and the counter are terms of the verdict rather than decoration.
- ADR-0002 — the non-vacuity floors that make the skip branch unreachable.
- ADR-0030 — the machine-readable surface, built from the same object the renderer prints.
docs/compliance/known-defects.md— RWD-2026-0015, whose perimeter line this changes.