v3.5.6: the machinery that records what a run did #226

Merged
john merged 48 commits from cadence/v3.5.6 into main 2026-08-20 15:29:34 +00:00
Owner

Closes the v3.5.6 cycle: the machinery that records what a run did.

Three phases, 48 commits off main, manifest at 3.5.6, full suite 2464 pass /
0 fail / 1 skipped, self-verify ok with 0 problems.

  • Executor reports rotate to plan-<k>.<n>.md instead of overwriting, and
    /cad-execute refuses a phase whose derived status is already executed
    (#195)
  • A gate fire writes ADJUDICATION-<trigger>-<discriminator>.json: one entry
    per finding raised per voice, the claim and failure scenario byte-for-byte,
    full 40-character base/head SHAs, citations grounded at the head commit.
    trace append recounts the survivor figure from those rulings before a
    receipt reaches the trace
  • The risk gate stops deadlocking on an empty committed range, and reads that
    range with --no-ext-diff --no-textconv after its own gate caught that a
    diff driver in the reader's git config could present a risky range as empty
  • lease-check exempts a rotated report by grammar rather than byte equality,
    still refusing both risk diffs, another plan's report, a case variant and a
    nested path

Scope note: v3.5.6 was scoped as #139, #140, #145 and #195, and shipped #195.
Phases 2 and 3 were both found by running phase 1's acceptance and map to no
tracker issue. #139, #140 and #145 were never planned into a phase, so the
milestone's stated theme is untouched and those three stay open.

The audit could not catch that: the cycle seeded no requirement ids, so its
trace arm ran over zero requirements and returned PASS on an empty set while
the coverage arm carried the proof at 20/20 criteria. v3.5.7 seeds RDX-01,
BCH-01, CER-01 and IVW-01 at the open so the trace arm is live from the first
phase.

The v3.5.6 tag is not cut here. It belongs on the pulled base after this merge
confirms.

Closes the v3.5.6 cycle: the machinery that records what a run did. Three phases, 48 commits off main, manifest at 3.5.6, full suite 2464 pass / 0 fail / 1 skipped, self-verify ok with 0 problems. - Executor reports rotate to `plan-<k>.<n>.md` instead of overwriting, and `/cad-execute` refuses a phase whose derived status is already `executed` (#195) - A gate fire writes `ADJUDICATION-<trigger>-<discriminator>.json`: one entry per finding raised per voice, the claim and failure scenario byte-for-byte, full 40-character base/head SHAs, citations grounded at the head commit. `trace append` recounts the survivor figure from those rulings before a receipt reaches the trace - The risk gate stops deadlocking on an empty committed range, and reads that range with `--no-ext-diff --no-textconv` after its own gate caught that a `diff` driver in the reader's git config could present a risky range as empty - `lease-check` exempts a rotated report by grammar rather than byte equality, still refusing both risk diffs, another plan's report, a case variant and a nested path Scope note: v3.5.6 was scoped as #139, #140, #145 and #195, and shipped #195. Phases 2 and 3 were both found by running phase 1's acceptance and map to no tracker issue. #139, #140 and #145 were never planned into a phase, so the milestone's stated theme is untouched and those three stay open. The audit could not catch that: the cycle seeded no requirement ids, so its trace arm ran over zero requirements and returned PASS on an empty set while the coverage arm carried the proof at 20/20 criteria. v3.5.7 seeds RDX-01, BCH-01, CER-01 and IVW-01 at the open so the trace arm is live from the first phase. The v3.5.6 tag is not cut here. It belongs on the pulled base after this merge confirms.
john added 48 commits 2026-08-20 15:26:09 +00:00
Set PROJECT.md ### Active to the v3.5.6 milestone (#139, #140, #145, #195)
and record that v3.5.5's merge and tag both landed.

Phase 1 is #195: /cad-execute does not refuse a phase whose derived status
is executed, and reports/plan-<k>.md carries no run component, so a second
run's first task commit overwrites the first run's only per-task record.
Ordered first because every other item in the cycle costs a partial mutation
that /cad-audit reports and a re-run repairs; this one costs the evidence.
Four decisions, six acceptance criteria.

D-01 rejects the correlation id as the run key: #195 asserts it is already
unique per run, but correlationId returns ${phase}-${sha} from the newest
phase_start anchor (trace.mjs:212-229), so a re-run at unchanged HEAD - the run
that died before committing anything - mints the same string. Scoping is
rotation-on-write instead, which needs no run identifier and so survives the
worktree and /cad-task paths where no corr is derivable at all.

Also corrects the ROADMAP criteria block: it used a bold-bullet heading the
criteria-size seam does not read, so the phase declared zero criteria, and its
wording named only the executed status that D-04 widened to include complete.
The state step told the coordinator to stage the whole reports/ DIRECTORY and,
in the same breath, never to stage the transient plan-<k>-risk*.diff that lives
inside it. A directory stage takes everything in it, so the second instruction
could not be honoured by the first: an instruction that only forbids a filename
commits it. Name the mechanism that actually excludes it.
The suffix scan matched case-insensitively on the stated grounds that a
PLAN-1.1.MD left by hand IS plan-1.1.md on a case-folding filesystem, and
renaming onto it destroys a report. The base-name check applied that reasoning
to neither half of itself: entries.includes('plan-1.md') is exact, so a report
stored as PLAN-1.MD read as absent, the answer was rotate:false, and the caller
wrote the canonical spelling straight over the same file - the one outcome the
module exists to prevent.

`from` is now the entry's OWN spelling, since that is the name the rename has to
resolve on a case-sensitive filesystem, where the two names are two files.
An exact match still wins when both spellings are present.
The risk_surface fire on this plan's committed range adjudicated 2 survivors of
3 raised; both were fixed (b3e5ded, 9da7b15) and the one narrowed re-arm round
came back with no findings, so the settled list persisted here is empty - a
non-empty file would hard-halt every later land on an answered finding.
The prior run's record moves to plan-1.1.md byte-identical; the --rerun's
own record takes plan-1.md. Staged as the reports/ directory, since a
by-name stage would leave the rotated file untracked.
`git commit -- <pathspec>` commits only paths git already tracks, so the
worktree executor's report commit aborted on any untracked report - every
rotated plan-<k>.<n>.md, and a first run's plan-<k>.md on a path not yet in
history. Add a bounded `git add` of the same named paths, kept off the
enclosing reports/ directory so the flagged risk diff a blocking gate left
staged is still not swept in.
The statuses were matched against the whole arm, which includes the
sentence '`complete` is refused beside `executed` because ...'. That
sentence supplied the literal independently of the trigger, so narrowing
the trigger to `executed` alone left the assertion green. Split the arm
at '-> stop:' and assert against the trigger half.
A gate fire summarized itself: the trace kept `<n> survivors of <m> raised`
and the finding bodies were never written anywhere, so nobody could recount
the survivors and a refutation could not be checked against the code it
refuted. lib/adjudication-record.mjs states the shape that makes both
mechanical - one entry per finding RAISED per raising voice, carrying the
voice, its model, the severity as raised, and the claim and failure scenario
as the bytes the reviewer returned.

The payload carries BOTH sides on purpose: the reviewer's returned object
verbatim and the rulings over it, each ruling restating the two verbatim
fields so they can be COMPARED. The restatement is never stored - the entry
is copied from the returned side either way - it exists so a paraphrase is
refused before an entry is built, which is the tampering surface the record
exists to close. Pairing is checked twice over, by index and by text.

Convergence is DERIVED and marked on both entries, never used to merge them:
collapsing two voices makes "the raising voice" a list and a reviewer's
individual hit rate underivable. The record stores no count of its own -
`deriveCounts` recomputes over the stored entries, so a reader can recount a
record it did not write.

Refusals: a ruling outside survived | downgraded | refuted, a refuted entry
with no counter-evidence naming contradicting code, a survived entry with no
usable fix commit (blank and non-hexadecimal refused like an absent one - an
auditor runs `git show` on that value), an unruled finding, a ruling naming
no returned finding, and an unknown key at any level.
`planning.mjs adjudication` takes the orchestrator's composed payload as a
FILE, runs lib/adjudication-record.mjs over it, resolves the caller's range to
full 40-character ids and writes one record per fire beside its sibling
REVIEW-<trigger>-<discriminator>.md.

The row is declared first (D-12) so the prose that invokes it in a later task
has something for self-verify check 2 to resolve against. `--base` and `--head`
are both required for the reason the `risk-check run` row already states, and
`--round` exists because a capped blocking re-arm is a SECOND fire of the same
trigger on the same plan: without it round two's record would replace round
one's rulings. A second fire that forgot the flag is REFUSED rather than
overwritten, and `--trigger`/`--discriminator` reach a filename, so both are
validated against a conservative grammar and refused rather than sanitized.

The record carries the voice ROSTER as well as the entries: a blocking fire
that raised nothing has no entries at all, and a record that cannot say which
voices ran is not evidence that any did.
Before the record is written, each entry's cited `file` is asked for at the
resolved `head_id` and MARKED when it is not there. A marked entry is still
stored: the mark is the auditor's warning that the citation cannot be opened,
and dropping it would delete the very finding whose grounding is in question.

Nothing upstream checks either field - `FINDING_SCHEMA` bounds `file` only as a
non-empty string of at most 1024 characters and the reviewer contract calls
`line` best-effort - so this is where the auditor path gets bought rather than
demonstrated.

The probe first, and that is the load-bearing part: `git cat-file -e
<sha>:<path>` exits 128 both for a path absent at that commit and for "this is
not a repository", so one question whose answer cannot be about any path runs
ahead of the walk. A check that could not run at all is reported once on the
record and on the envelope and marks NOTHING - an unprovable citation set is
not a bad one.

The file-scoped redaction census moves 9 -> 10 sites, 3 -> 4 wrapped: the probe
arrived through `redactUrl` on the same EXP-01 rail every other git failure
detail here takes.
Step 5 deduped exact repeats and merged convergent findings BEFORE any ruling
existed, so the pipeline destroyed per-voice attribution before a record could
hold it. The adjudicator now grounds and rules EVERY raised finding per raising
voice, and the dedupe and the convergence merge that produce the survivor LIST
run on the ruled set.

The reason is stated in the clause: a merged finding has no raising voice, only
a list, and per-voice attribution is what makes a reviewer's individual hit rate
countable - the measurable form of this project's claim that its controls are
fallible machinery.

Nothing about what the gate acts on or what the user is shown changes: the
survivor list keeps its shape and its order, and triage-gate.md still presents
it as the same numbered multi-select. The weight row is re-pinned in this commit
from `weight.mjs --root .`, the surface having sat at its ceiling with zero
headroom.
Three edits, all about where the record comes from.

The arm scoping is widened rather than read around: the trace append and the
reported line stay the adjudicated arm's alone, and the RECORD is written on the
blocking arm too. Confining it to the adjudicated arm would record nothing at
all on a repository where `route.mjs resolve` returns `plan: blocking` and
`risk_surface: blocking`, and would exclude the sharpest case there is - a gate
that passed with everything killed. The advisory arm writes neither and reads as
unrecorded, with the reason stated: its reviewer writes the findings file and
closes its own bracket, so nothing is positioned to rule. The ruling enum stays
at exactly three values.

The orchestrator composes the payload, because it is the only actor holding both
the raised finding bodies and the ruling, as a FILE in this run's own scratch
directory and never hand-assembled with `echo` or a heredoc - the record's whole
content is verbatim reviewer text with arbitrary quoting.

The path, the discriminator grammar and the re-arm's `--round 2` are named where
the fire happens, since the fire site is the only actor that knows which round it
is on, along with the one clause that keeps the record out of `<plandir>/
reports/`. No `trace append` line is added or altered here: the receipt's new
flags are PLAN-2's. The weight row is re-pinned in this commit.
The blocking arm settles a fire at two points - `gate_pass` when nothing
blocker/high survives, `override` when the user clears a FAIL - and neither
carries a finding body. It now states that a blocking fire writes the same
adjudication record at that settle point and POINTS at review-triggers.md step 5
for the payload, the path and the discriminator rather than restating them: this
file is deliberately re-read at the gate step WITHOUT loading that one, so what
it owes a coordinator is the obligation plus the pointer, and a copy here is a
second statement that can drift.

The advisory arm writes no record and reads as unrecorded, for the reason step 5
now states. No fifth fenced outcome event name and no change to any existing
receipt line: GAT-04 still collects exactly four, and this file still carries its
same three receipt commands. The weight row is re-pinned in this commit.
`trace append --family outcome` gains `--survivors`, `--downgraded`,
`--refuted` and `--round` as structured non-negative integers, declared on the
row first so prose may name them. A malformed value on any of the four appends
NOTHING, the way `--raised` and `--turns` already refuse, and an absent flag
omits its key so a fire nobody counted stays distinguishable from one that
counted zero.

The three settled figures are then CHECKED rather than stored as typed: with
all three present the seam resolves the fire's own
`ADJUDICATION-<trigger>-<discriminator>.json` at the round the call names,
recounts its rulings through `deriveCounts`, and refuses a receipt that
disagrees with the record - naming the flag, the typed figure and the counted
one, with nothing appended. The writer and the recount resolve one filename by
one rule, so a settle that forgot its round can no longer pass against round
one's stale rulings. A fire with no record on disk omits the check: this is a
cross-check between two artifacts, never a requirement that one exist.
The three fenced receipts that SETTLE a fire - `adjudication` in
review-triggers.md, `gate_pass` and `override` in triage-gate.md - now carry
`--survivors`, `--downgraded`, `--refuted` and an optional `--round`. The
`rearm` line gains none of them: it marks a round opening, and a count there
would describe a fire still in flight.

Both files state the same rule in one clause each: the figures are the ones the
record seam DERIVED and returned on its envelope, never counted by hand off the
survivor list and never folded into `--detail`, and the seam recounts the
record's rulings against them so a disagreeing receipt is refused. `--round` is
omitted on an ordinary fire and names the round on a re-armed one, because a
settle that names none is checked against round one's stale rulings.

Both weight rows re-pinned in this commit from `weight.mjs --root .` - both sat
at their ceilings with zero headroom.
`/cad-report`'s `read_record` step now opens
`.planning/phases/<N>/ADJUDICATION-*.json` alongside the phase's other scoped
artifacts - the existing `REVIEW-*.md` glob cannot match a `.json` sibling, so
without the entry the Gates line has nothing to count.

The Gates line counts that record's rulings and compares the figure against the
`survivors`/`downgraded`/`refuted` on the same fire's `outcomes` event, naming a
disagreement rather than silently preferring one side: the trace is gitignored,
so custody rests on the committed record and comparing the two is the only thing
that makes a tampered one visible. A fire with no record reads as `unrecorded` -
earlier phases kept counters rather than finding bodies and the advisory arm
writes none, so there is nothing faithful to reconstruct and the report says so
instead of narrating a count it cannot recompute.

The Refuted line is byte-unchanged: it consumes SUMMARY deviations that
corrected a D-NN and has nothing to do with gate findings. report.md's weight
row re-pinned in the same commit.
Two assertions, one per artifact this plan changed.

The RECOUNT: counting a record's rulings through `deriveCounts` reproduces the
three figures its fire's outcome event carries, flipping ONE entry's ruling
makes the two disagree, and the disagreement is decidable from the record and
the event alone with no third source. The `raised` total is unchanged by that
flip, which is why a receipt carrying `--raised` alone could never have caught
it. Every record fixture is the SEAM's own output - the subcommand is spawned
against a scratch repository and what it wrote is read back - so the case cannot
pass against a shape the writer never emits.

The RENDER RULE, as a prose-agreement test over `workflows/report.md`: the Gates
line names the record and says the rulings are COUNTED and compared against the
event's three figures, a disagreement is NAMED, a fire with no record reads
unrecorded and synthesizes nothing, and the Refuted line still reads SUMMARY
deviations and carries no gate finding. Read by each line's own anchor, so a
rewrap stays green while a revert of any clause reddens.
The per-plan arm resolves through `regular`, which rejects a symlink because
every reader after it follows the link out of the tree. The glob arm returned
its hit directly, so a symlink wearing a matching record name was read as the
fire's own record - the one disposition the function declares in its own
comment and then took only on one of its two paths.
`scanDiff` coerced a non-string body to `''` and then took the same arm as a
zero-byte diff, so "there was no body to read at all" and "git read the range
and it held nothing" wrote byte-identical records: `checked: false,
inconclusive: true`. The second is a completed check that matched nothing, and
reporting it as unchecked deadlocked the blocking `risk_surface` gate -
`risk-check status` filters on `checked` before its fire predicate is ever
consulted, so `/cad-execute --rerun` over an already-satisfied phase refused
`risk-record-missing` and re-running the detector wrote the same refusal again.

The two arms are separated. A string body that trims to nothing answers
`checked: true, inconclusive: false, matches: []` plus a positive `empty: true`;
a non-string body keeps `checked: false, inconclusive: true` with `empty:
false`. The field rides every return, including the scanned one, so its absence
marks a record written before this split rather than a fresh honest `false`, and
`cmdRiskCheckRun` carries it onto both the trace record and the envelope.

Emptiness is decided from the BODY and never from `base_id === head_id`: a
revert pair spans two commits with a zero net diff, and an id compare would
leave that shape deadlocked one case over. `parseDiff`'s no-hunk arm is
untouched - a binary-only or gitlink range is a non-empty body the scanner could
not judge, and it stays `inconclusive: true`.
Task 1's `checked: true` already carries an empty range into `usable`, and
`fired` is false for `matches: []` with `inconclusive: false`, so the row
reaches `recorded` unaided. No fifth state name is added: `offending` is
`rows.filter((row) => row.state !== 'recorded')`, so a new name would be an
automatic `ok:false` with the row output looking correct, and
`risk-fire-missing`/`risk-record-missing` would then name a step that is not
missing.

The one reader change is the per-record object in the `outcome`/`risk_check`
loop, which now carries the empty flag and reads it `=== true` for the reason
that block already states about `checked` and `inconclusive`: an absent verdict
is not a passing one, and the 69 records already on this repository's trace were
written before the seam separated an empty range from an unread one. It rides
the reported `records` array so an auditor can see why a row is `recorded` with
nothing matched.

Neither the receipt vocabulary nor the `settles` join moves: an empty range is
not fired, so it needs no receipt, and the inconclusive-record rows stay green.
`isReportName(k, name)` answers, for one plan number and one directory-entry
name, whether that name is that plan's report - the canonical `plan-<k>.md` or a
rotated `plan-<k>.<n>.md` for the SAME `k`. It is what `cmdLeaseCheck`'s report
exemption has to ask now that an executor holds both names at once.

The grammar keeps exactly one statement: the rotated pattern source
`rotationTarget` already built inline is lifted to `rotatedSource(k)` and both
readers construct their RegExp from it, so a second copy anywhere is the defect
rather than a style point. The anchors move with it - the trailing `.md` and the
dot before the suffix are what keep `plan-11.md` from reading as plan 1's
rotation 1, and `[1-9][0-9]*` keeps out the suffixes the picker never mints.

The FLAG stays the caller's and the two callers differ deliberately: the rename
scan matches case-insensitively so it cannot destroy a report stored as
`PLAN-1.MD`, while the lease question is byte-exact, because the name this
module produces is always canonical and exempting a `PLAN-1.1.MD` no executor
wrote would widen a parallel-safety gate in the one direction it must not move.

The plan number goes through the module's own `planDigits`, so `08` throws
rather than answering about plan 8; a non-string NAME answers false rather than
throwing, because on this side fail-closed means "not exempt".
The exemption was one name by byte equality, which was correct while a plan had
exactly one report. Since rotation an executor holds `plan-<k>.md` and
`plan-<k>.<n>.md` at once - it renames the previous run's record aside before
its first write - so a re-run staging the rotated sibling during a task commit
was refused `undeclared-files`, blocking the executor for obeying its own
contract.

A staged path is now exempt when it sits DIRECTLY in that plan directory's
`reports/`, derived through the same repoRel/join construction the single name
used, AND its final component is a report name for THIS `k`, which is
`lib/report-rotation.mjs`'s answer rather than a second regex here.

Deliberately not a directory lease: `plan-<k>-risk.diff` and
`plan-<k>-risk-task-<n>.diff` live in that same directory, and a `risk_surface`
checkpoint leaves flagged changes staged on purpose, so a directory exemption
would let a blocking gate's own evidence ride into a task commit unnamed. A
further separator disqualifies a path, so a nested `reports/old/plan-1.1.md`
stays refused, as do another plan's report, `plan-11.md` under plan 1, and a
`PLAN-1.1.MD` no executor writes.

The `staged` count is untouched - it is taken before the exemption filter, so
widening the exemption moves no reported number - and the both-sides-of-a-rename
handling is left alone: rotation stages as an add plus a modify, not a rename
pair, so the destination needs its own exemption.
"Exactly one statement" was a property nothing held: a contributor could paste
the rotated-name pattern back into `cmdLeaseCheck` and every test stayed green,
leaving the picker that MINTS a rotated name and the gate that EXEMPTS one
holding two copies of the same grammar - which is how a name one produces
becomes a name the other refuses.

One census row, keyed on the pattern SOURCE rather than either exported name, so
a paste-back under a new name fails here too. The pattern is built from an
escaped string like every other row, for the reason the file's header states:
the rules are lexical and tree-wide, so text a rule matches must not appear
verbatim in this file or the census reddens on itself.
A checked-in `.gitattributes` `diff=<driver>` attribute binds to a
`diff.<driver>.command` or `.textconv` in the reader's own git config, so
`git diff <base> <head> --` can emit zero bytes for a file whose changed
line is a recursive delete. scanDiff then answers a COMPLETED empty check
with no matches, and risk-check status accepts that as a cleared range -
a false pass on the one gate that blocks at every stakes level.

No attacker is required: a textconv configured for pdf or docx in a
developer's own ~/.gitconfig produces it by accident. Both flags are
diff-generation switches only, so the empty/unreadable split is untouched.
docs: open v3.5.7, record the v3.5.6 close
All checks were successful
test / node-test (git, 22) (pull_request) Successful in 1m11s
test / node-test (git, 24) (pull_request) Successful in 29s
test / node-test (other, 22) (pull_request) Successful in 1m1s
test / node-test (other, 24) (pull_request) Successful in 1m1s
test / node-test (planning, 22) (pull_request) Successful in 1m33s
test / node-test (planning, 24) (pull_request) Successful in 1m35s
test / node-test (prose, 22) (pull_request) Successful in 35s
test / node-test (prose, 24) (pull_request) Successful in 35s
test / node-test (review, 22) (pull_request) Successful in 18s
test / node-test (review, 24) (pull_request) Successful in 17s
test / node-test (routing, 22) (pull_request) Successful in 37s
test / node-test (routing, 24) (pull_request) Successful in 35s
test / self-verify (pull_request) Successful in 16s
test / typecheck (pull_request) Successful in 19s
56ad7604a7
john merged commit ce3b7a28a5 into main 2026-08-20 15:29:34 +00:00
Sign in to join this conversation.
No description provided.