feat(harness): capture and check deliberate divergences instead of hand-writing them - #151
Conversation
… git-tracked provenance record
A deliberate-lead row pins NEW's side as a hand-written literal in the corpus. That is affordable at
16 rows and unaffordable at the 57 rows B83's two-character fix breaks (measured 2026-07-29, against
the filed 54), so this adds the capture half: `--bless-divergences` runs the corpus and writes a
record for every LEAD row that does not have one. Comparison logic is deliberately NOT in this task.
Record shape — one flat stream of the harness's own length-prefixed sections, no second encoder and
specifically not JSON: every one of B79's four defects lived in a value encoder, and these payloads
(`%%`, backslashes, multi-line stderr with significant trailing newlines) are exactly the shapes
`emit_section`/`get_section` already handle byte-exactly.
Four sections per row, and the identity fields are PER-ROW on purpose. D3 makes blessing granular, so
a file-level hash could not describe the result of re-blessing one row — it would claim one
implementation identity for text captured under two. The header states this, because the next
reader's first instinct is to deduplicate it.
`newimpl` is `git hash-object --no-filters` of the implementation ACTUALLY IN USE (C1/R2): the
worktree hook, or the injected copy under PLC_CF_NEW. Hashing the committed blob instead would
describe the tracked hook while the recorded text came from an injected copy, making the tagging
inert exactly where it is load-bearing.
C5 (spec R4) needed a value the plan did not name: `mk_dir`'s umask-derived bytes are a DIRECTORY
mode, which the rig's existing `$FILEDEF` does not describe. A `$DIRDEF` probe is sampled the same
way and both fold to placeholders in the record only — folding inside `normalize_record` would have
moved all 16 pins and reddened the corpus for an unrelated reason.
P1: the argument parser lives BELOW the library boundary and reads `${1:-}`. The meta-suite sources
this file with no positional parameters, where a bare `$1` under `set -u` aborts the sourced library
and takes a CI-gating suite red at this commit.
C2/AC12: `.gitattributes` is created here, path-scoped. The repo had none, and `* text=auto` would
renormalize the whole tree as a side effect of one file needing a guarantee.
Evidence:
differential 85 passed, 0 failed, 0 skipped, 4 known-diverge, 16 deliberate-lead (unchanged; AC9)
per-line census zero delta against the pre-change baseline, 105 rows
bless 16 records against 16 discovered LEAD rows, names identical; re-run writes 0 and leaves the
file byte-identical
round-trip 16/16 sections read back through get_section; header reads as prose; absent row refused
meta 28/0 · contract 88/0/0/0 · test-hooks 101/0 · tasklist-view ALL PASS · validate OK · invariants 8/8
AC12 autocrlf checkout 13278 -> 13278 bytes, 0 CR, while the unpinned control file in the SAME
checkout moved 2630 -> 2735 (+105 for 105 lines)
AC11 (hash-source half) 20b4005 -> 5c77246f under PLC_CF_NEW, observation text byte-identical
The first AC11 probe was vacuous — its `sed` matched nothing, so the "changed" copy was byte-identical
and every line of the output looked like a pass. A `cmp -s` positive control in the same command
caught it. Recorded in the journal rather than quietly rerun.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rigp3VhvZdwJVUZKLLmfq2
…rison a count cannot make The capture half without a comparator is the wrong half to ship, so this adds the guard: re-run each deliberate-lead row, compare against its record, exit non-zero naming the row. A bare "mismatch occurred" is the filed complaint against `terraform fmt -check`'s exit 3; every finding here names its row. TWO VERDICTS, not one. AC11 requires that dirtying the hook invalidates every record, which needs the recorded implementation hash compared; AC4 requires that B83's patch reddens THE 12, which a hash-only comparison inflates to 16. `STALE` (captured under a different implementation) and `DRIFT` (the observation text moved) are reported independently, so behaviour and identity stay separable. Vacuous-run hard FAIL, no waiver: zero records, an orphan record, or a discovered row with no record. The set comparison is over NAMES in both directions, because AC15's copy-paste rename leaves the totals matching while one record describes a row that no longer exists. No constant is hardcoded -- the discovered side is accumulated from rows that actually reported LEAD, so a host that SKIPs marker/mk_samesecond compares 15 against 15. `checked N records` on stdout is the line AC7's CI-step assertion reads. A step that trusts only an exit code cannot tell "ran and found nothing wrong" from "never ran" -- the gofmt shape, and GitHub counting a skipped required check as passing. The record census walks the framed stream rather than grepping it: this harness's own observations embed framed sections, so a payload can legitimately contain a line shaped like a header, and a search-based census would count those. Demonstrations, run not argued (each rc=1 unless stated): AC2 unchanged tree -> rc=0, checked 16 records, 16 matched AC3a payload edited, prefix CORRECTED -> DRIFT, names marker/mk_dir, 15 matched 1 failed AC3b payload edited, prefix LEFT WRONG -> UNREADABLE RECORD branch, checked 5 of 16, 13 failed AC5a one record removed -> 1 missing, checked 15 records AC5b record file emptied -> VACUOUS RUN, checked 0 records, 17 failed AC15 a row renamed in the file -> count MATCHES at 16, yet 1 orphan + 1 missing AC11 inert hook change via PLC_CF_NEW -> 16 STALE, 0 DRIFT, 0 matched 16 failed MEASURED CORRECTION to AC3's wording, folded into the spec at T6: an uncorrected prefix does NOT make `get_section` refuse the EDITED row. A shortened payload under a too-large prefix over-reads, so the edited row reports DRIFT and its NEIGHBOUR reports UNREADABLE. Both branches are demonstrated and distinct; the attribution is off by one section, and nothing silently passes. Unchanged: differential 85/0/0/4 known-diverge/16 deliberate-lead, per-line census zero delta over 105 rows, meta 28/0, contract 88/0/0/0, test-hooks 101/0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rigp3VhvZdwJVUZKLLmfq2
…ires naming the row
D3, adopted from syrupy -- the only surveyed tool that gates "add a new expectation" and "touch an
existing expectation" separately. It maps onto B105's own Trigger text: the dangerous operation is
touching a record, because that is where a behaviour change can be laundered into a green.
`--bless-divergences [row...]`. A row with no record is written unconditionally. A row whose record
already matches is a no-op. A row whose record has MOVED is refused unless the invocation names it.
Deliberately not taken, noted in the code so the second path reads as a purchase: the cheaper
`[row...]` where omitting names means "bless everything" -- the rubber-stamp shape. Pure per-row was
declined too: one hook edit legitimately changes many rows identically, and N invocations add
friction without scrutiny.
C3/AC13 -- THE NAME CHECK RUNS BEFORE THE CORPUS. The failure mode is not a confusing message; it is
that an unmatched name falls through to the unconditional new-record branch, defeating the guard
while the output reads as a successful bless. Validating afterwards cannot undo the writes. Names are
checked against the record file's own census, because naming a row authorises touching an EXISTING
record; a name with no record is meaningless by construction.
A second check the plan did not name: a row can pass the up-front test and still be wrong, because
this run never discovered it as a lead (retired or renamed). Reported after the corpus rather than
left as a silent no-op.
Demonstrations:
AC13 'marker/mk_dirr' -> rc=2, names the bad argument, record file byte-identical
AC13b 'marker/mk_dir ' -> rc=2 on the stray space an exact-string match would wave through
AC6a changed impl, no row named -> 16 REFUSED, 0 written, file byte-identical
AC6b changed impl, one row named -> 1 REBLESS + 15 REFUSED, rc=1; a per-row hash dump shows
exactly one newimpl moved
extra naming a row that has a record but was not discovered -> reported, rc=1
extra --check-divergences with row arguments -> rc=2
Also fixed on the way: the argument-rejection message was passed as printf's FORMAT string and
begins with `--`, so bash read it as an option and printed its own usage instead of the error. An
error path, which is why only running it found it.
Unchanged: differential 85/0/0/4/16 with a zero-delta 105-row census, --check 16 matched 0 failed,
bless idempotent byte-for-byte, meta 28/0, contract 88/0/0/0, test-hooks 101/0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rigp3VhvZdwJVUZKLLmfq2
…g to one
T4's first run refuted the shape T1-T3 built. Under a verified one-line copy of B83's `%%` fix
(context-floor.sh:1038), the twelve affected rows fail their HAND-WRITTEN NEW literal first, so
diverge_case enters its failure branch and never reaches the record: 0 DRIFT, 4 STALE, and no
re-bless repairs them. AC4 was unsatisfiable, and the built shape was the pure sidecar D1 names and
rejects -- "leaves B83 exactly as unaffordable as today, so this PR would ship a mechanism that
cannot pay for the case that justified it". Reinterpreting a locked decision is not an unattended
call; the human approved the rebuild at the point of discovery.
D1's hybrid, as built now: NEW's expectation comes from the record when the row has one, from the
corpus literal when it does not. OLD's side is always hand-written -- it is content-addressed by a
pinned blob and the rig FATALs if that blob moves, so it stays the independent anchor.
Two defects the rebuild exposed, both measured:
record_has_row answered "does this row have a record" with get_section, conflating ABSENT with
UNREADABLE. Harmless in a sidecar; under the hybrid it is a SILENT PASS, because an unreadable
record falls back to the literal and can go green. Existence and readability are two calls now.
The hybrid makes the plain suite run depend on the record, colliding with D4a ("a result merged
into the suite's own pass/fail cannot tell the port broke from a record went stale"). Split by
question instead of by file: the plain run compares BEHAVIOUR only; identity drift is enforced by
--check-divergences alone. Measured -- an inert implementation change leaves the plain run at
85/0/0/4/16 rc=0 while --check reports 16 STALE rc=1.
T4 demonstration, run not argued:
1 --check under the patched hook -> 12 DRIFT, 16 STALE, rc=1
2 ONE --bless-divergences, names generated from the census -> 16 REBLESS, 0 REFUSED
3 --check under the patched hook -> 16 matched, 0 failed
(the 45 remaining reds are diff_case conversions -- B83's own work, out of scope by D5)
4 revert the hook -> the re-blessed file reddens again, 12 DRIFT / 16 STALE
4b the COMMITTED record file was never touched, so the repo checks green: rc=0, 16 matched
Meta coverage for the new branch, because nothing reached it: meta09j (a recorded row uses the
record, LEAD despite a literal deliberately set to fail) and meta09k (a record that no longer matches
-> FAIL naming the record as the source). Mutants m26 `28 passed, 2 failed` and m27 `29 passed,
1 failed` -- two rather than one, because "which source is consulted" and "is it compared" are
separate guards. The ids I first picked were already in use; the suite still reported 30 passed,
which is what duplicate ids do -- they make a failure unattributable rather than colliding.
The record file's header described the old semantics; corrected by delete-and-re-bless rather than a
hand edit, verified header-only (all 64 non-header sections byte-identical).
meta 30/0 · differential 85/0/0/4/16 with a zero-delta 105-row census · --check 16 matched 0 failed
· contract 88/0/0/0 · test-hooks 101/0 · tasklist-view ALL PASS · validate OK · invariants 8/8
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rigp3VhvZdwJVUZKLLmfq2
…on that it actually ran
D4a: a fifth application of the `|| rc=$?` idiom already used four times in this step -- not a new
step and not a new gate. Its own exit code and its own `::error::` per drifted row, because a result
folded into the differential's own pass/fail cannot tell "the port broke" from "a record went stale"
(the B96 shape this file paid for once). The plain differential asks the behaviour question; this
one asks identity and completeness.
THE RAN-IT ASSERTION reads the check's own stdout for `checked N records`, not whether a variable got
assigned. An rc that merely holds a value proves nothing: a subshell early-exit leaves it looking set
while the comparison never executed -- the bare-`tee` masking shape one layer over, and why `gofmt
-l` in CI can be falsely green for years. GitHub also counts a SKIPPED required check as passing, so
"the step reported no failure" is not evidence the step did anything.
Demonstrated against the SHIPPED BYTES: the block is extracted verbatim from this file (dedented,
nothing retyped) and run in a temp tree where the harness path holds a stub, so the tested text and
the shipped text are the same.
AC14 stub exits 0 immediately, block still assigns prc=0 -> rc=1, the assertion names it
control1 stub prints the line and exits 0 -> rc=0, ZERO errors
control2 stub prints the line, emits one RECFAIL, exits 1 -> rc=1, `::error::divergence
record: marker/mk_dir` -- the row is named, not a bare non-zero
real the same extracted block against the real harness -> rc=0, checked 16 records
control1 is the load-bearing one: without it the AC14 result is equally consistent with an assertion
that is simply always red.
AC8 verified by reading the changed file: `grep -n bless .github/workflows/validate.yml` returns
nothing. CI has no path to the mutate half, by construction rather than by convention.
The guard forces prc=90 only when prc was 0, so a check that both failed AND printed nothing keeps
its real exit code instead of having it overwritten by the guard's.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rigp3VhvZdwJVUZKLLmfq2
…our new ones
B104's instance half is closed: this harness now has three self-destructing label mechanisms
(`known`'s reality check, `diverge_case`'s agreement check, and the divergence records), demonstrated
red-then-green. Its repo-wide class is RE-FILED as B135 rather than closed with it. Closing both on
the strength of one call site would be the exact "claim wider than mechanism" pattern that entry's
own table is a record of, and B135's Trigger therefore requires the review-time rule to exist as a
durable artifact a reviewer consults -- not a sentence in a backlog entry.
Corrections landed rather than remembered:
B83 57 of 105 rows, not 54 of 101 -- and the SHAPE was wrong: 12 of them are already
diverge_case rows needing re-blessing, only 45 are conversion. Its "no cheaper shape
available" blocker is gone. Both superseded corrections kept verbatim above the original,
because a corrected number that erases its predecessor teaches nobody how far an estimate
can drift, and this one drifted twice in two days.
B105 closed as BUILT, with three of its own filed fields corrected: `observation digest` demoted
to a ride-along (all 8 surveyed tools store full text; a digest says a pin moved but not into
what), `fixture name + hash` dropped (a "fixture" here is a shell function -- nothing to
hash), and the identity fields per-row rather than per-file.
New filings, ids from scripts/backlog-toc.sh (129 entries, next free B139), never eyeballed:
B135 the review-time rule has no durable artifact -- split from B104
B136 habituation: every record is tagged to one implementation hash, so an inert hook change
reddens all of them (measured: 16 STALE / 0 DRIFT). D1's accepted risk, filed not remembered
B137 tasklist-first's nonce is repo-global while its seen-marker is per-session, so any party's
gate run re-arms every other session. Blocked FIVE times in this one phase against its own
"exactly one block per phase" contract
B138 a bad length prefix desyncs the record walk, so the corrupted row reports DRIFT and its
NEIGHBOUR reports UNREADABLE -- diagnosis quality, not a correctness hole
Spec §5.6 records three corrections MEASURED during execution: D1 was implemented as the sidecar D1
rejects and rebuilt as the hybrid (E1), AC3's branch attribution is off by one section (E2), and
AC4's "all 12" is exact but not exhaustive under two verdicts (E3). Retired wording kept verbatim.
ROADMAP: batch-map PR 5 flipped, recorded as NARROWED at the intent-gate, and B83's 45-row
conversion split out as PR 10.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rigp3VhvZdwJVUZKLLmfq2
…the pipe defect the fix reproduced Cross-family code review (codex, one lens: the expectation-source seam) returned two findings. Both were real. Applying the first reproduced a third. FINDING 2 (Important, fixed). `record_has_row` was `record_present`, which locates a header by SEARCHING. That predicate's own comment bounded it -- "a look-alike payload can only mis-attribute the DIAGNOSIS between two failing branches; it cannot turn either into a pass" -- and the comment was TRUE when written. T4 then promoted the same predicate to source selection without revisiting the bound, and in that role a payload line reading `observation <row>:<n>:` makes a row with NO record take the record branch. This harness's own observations embed framed sections, so that payload shape is legitimate. Fixed by making the predicate section-aware; `record_present` stays diagnosis-only and its comment now says the bound is conditional on that. New meta row meta09l, mutant m28 (`30 passed, 1 failed`). This is the class B135 -- filed in this same PR -- is about, committed inside the PR that files it. THE FIX REPRODUCED B102. First version: `record_row_names | LC_ALL=C grep -qxF "$1"`. `grep -q` exits at the first match, SIGPIPEs the still-producing left side, and `set -o pipefail` reports the whole pipeline as failed -- so a row that HAS a record was told it did not and fell back to its literal. B102's changelog entry describes this verbatim for the close-gate path matcher: "the search stopped at the first hit, the writer died of a broken pipe, and the pipeline's failure status was read as no match." WHAT STAYED GREEN WHILE IT WAS BROKEN, which is the part worth keeping: meta 31/0, differential 85/0/0/4/16, exit 0 -- because the literals the rows fell back to still matched. `--check` went from `16 matched` to `1 matched`, a number nothing asserted. So a COVERAGE ARM was added: the check now fails when the number of rows that actually consulted a record disagrees with the number of records, no waiver, same grounds as the vacuous-run check -- "the checker quietly stopped consulting the artefact it exists to consult" is the vacuous run one level in. Re-introducing the SIGPIPE version now gives rc=1 and `16 record(s) exist but only 1 row(s) actually read one`. Both sibling call sites (`is_named`, the up-front name validation) were rewritten pipe-free too. They do not fail at today's sizes; a latent instance of a defect this repo has shipped twice is not made safe by being small. FINDING 1 (filed Critical, folded into B138 as Important with the mechanism confirmed). `dd bs=1 count=N` returns 0 on a short read, so `get_section` never verifies it consumed the declared bytes. Mechanism confirmed by the reviewer's own probe. The attached claim -- a check can go green against an invalid record -- holds only for a missing FINAL newline, where the compared bytes are correct anyway; a truncated payload compares shorter and reddens. Not fixed here for the reason B138 was deferred in the first place: `get_section` is read by the whole corpus and two suites, and changing a CI-gating oracle's core reader inside a provenance phase mixes two risks. B138 now states both halves as one root cause -- the reader trusts the header instead of verifying against it -- and its Exit requires both demonstrations. meta 31/0 · differential 85/0/0/4/16, zero-delta 105-row census · --check 16 matched 0 failed · bless idempotent byte-for-byte · contract 88/0/0/0 · test-hooks 101/0 · tasklist-view ALL PASS · validate OK · invariants 8/8 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rigp3VhvZdwJVUZKLLmfq2
Layer 1 — Golden path, real outputThe whole point is a mutate/check pair, so here is the pair, on a clean tree. Capture (adding is unconditional; a re-run is a no-op): Check: What a record looks like — this is the product, so it has to be readable:
Layer 2 — Negative paths, because these are the productEvery one of these is
The rename row is the one worth pausing on: the count matches and the check still fails. A count Layer 3 — Before / after
Layer 4 — Cost(N/A — no paid API in the shipped path.) Nothing here calls a metered service; the check is local Layer 5 — PerformanceNo stated go/no-go target for this phase. Measured on this machine so the next reader has a baseline:
+1s for the whole check. Both modes re-run the same corpus; the record comparison is 16 reads of Known cost, stated rather than hidden: Layer 6 — FindingsS1 (blocks merge): none open. S2 (shipped with a follow-up filed):
S3 (captured):
Corrections landed rather than filed: B83's count (57 of 105, not 54 of 101) and its shape (12 Review round 1 — cross-family (codex), one lens: the expectation-source seam. 2 findings, both
And then applying the fix reproduced B102. Layer 7 — Close gateLayer 8 — Reviewer asks
Layer 9 — What's nextLOCKED, on the batch map:
Not scheduled: B135, B136, B137, B138 — all filed with Triggers, none of them blocking. |
CI evidence — and the first cross-platform verification of the records
The interesting part is not that it passed. The records were blessed on macOS and verified on Locally this machine reports The paragraph that used to end this comment claimed the no-formatter path was "handled in code Measured with a one-line mutant forcing Still not covered by a suite, and this time the sentence is a limitation rather than a defect in |
… red on any host without a fractional stat formatter
AC5 says to exclude skipped rows from BOTH sides. Only the discovered side was excluded, so on a host
where `frac_row` SKIPs `marker/mk_samesecond` the run finds 15 rows against 16 records and the
completeness arm calls the sixteenth an orphan. `--check-divergences` therefore FAILED on that entire
class of host for no reason but a legitimate skip.
Latent here: this machine probes `bsd`, CI probes `gnu`, and both have the formatter. It would have
surfaced as an unexplained red on a contributor's laptop, attributed to their change.
Found by refusing to ship a sentence. The CI-evidence comment on the PR said the path was "handled in
code but reasoned, not measured" -- accurate, and not the bar, because the sentence would have
described a defect as a limitation. Measured with a one-line mutant forcing FRACTIONAL_STAT="":
before 84 passed, 0 failed, 2 skipped, 4 known-diverge, 15 deliberate-lead
RECFAIL <orphan records> ... marker/mk_samesecond (sub-second) rc=1
after 1 record(s) set aside -- this host cannot run their rows
checked 15 records / 15 matched, 0 failed rc=0
this host, unchanged checked 16 records / 16 matched rc=0
Two things the fix had to get right, both measured rather than assumed:
The record is keyed by the name `diverge_case` RECEIVES -- with the `(sub-second)` suffix -- while
the skip line prints the bare `$1`. Recording the bare name would have matched nothing and the
exclusion would silently have done nothing, which is the same shape as the defect it fixes.
The set-aside message counts RECORDS, not skipped rows. The first version said "2 records set
aside" when only 1 record existed: most skipped rows are diff_case rows that never had a record.
Accepted gap, stated: no suite covers the no-formatter path. Every host available to this project has
a fractional formatter, so a faithful fixture cannot be built here; the guard is a documented mutant.
The durable answer is a FRACTIONAL_STAT test double, which is the shape the meta-suite already uses
to drive the probe.
Also logged in the journal's Plan deviations: the milestone-close retention drain is SKIPPED, because
B132 makes the branch un-pushable while the PR is still open to review changes and B114 forbids
draining the CHANGELOG at all.
meta 31/0 · differential 85/0/0/4/16, zero-delta 105-row census · contract 88/0/0/0 ·
test-hooks 101/0 · validate OK · invariants 8/8
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rigp3VhvZdwJVUZKLLmfq2
CI went red once, on a row this PR does not touch — same-SHA re-run is greenRecording it rather than quietly re-running, because "it passed the second time" is exactly the The red, run 30516182278, commit Note the attribution line: Same SHA, re-run, no tree change: pass (3m0s). So the row is load-flaky on a shared runner. Filed as B139, and the reason it earns an entry rather than a shrug: phase B139's Exit explicitly forbids fixing it with a retry: a retry converts a measurable flake into an |
TL;DR
1. What was done
The differential harness compares two implementations of
context-floor.shrow by row. Most rows must agree. A handful are deliberate leads — places where a phase intentionally made the new implementation behave differently — and those rows pin both sides to literals that the harness's own header insists must be captured from a real run, never reasoned out, because "a hand-derived expectation and a captured one are byte-identical in review".Sixteen such rows are still hand-writable. Fifty-seven are not, and fifty-seven is what a single known one-line fix (B83) produces — measured, on the current corpus, not estimated.
This PR adds the missing half: the new side of a deliberate lead can now be captured into a git-tracked record and checked on every CI run.
Use cases
"I intentionally changed how the hook behaves and the harness went red."
Run the check to see which rows moved and how, then capture the new expectation for exactly those rows by naming them:
Adding a record for a row that has none is unconditional. Touching a record you already have requires naming that row, because touching an existing expectation is the operation that can quietly turn a behaviour change green. A name that matches nothing is a hard error before anything runs — a typo silently taking the "add a new one" path would defeat the guard while the output read like success.
"Did anyone hand-edit an expectation instead of measuring it?"
CI answers this on every push, as its own invocation with its own exit code, naming any row that drifted. It has no path to the capture half by construction, so CI can never make the records agree with whatever the code currently does.
"Is this record still describing the implementation I'm running?"
Two separate verdicts are reported, never merged:
DRIFT(the recorded text moved) andSTALE(the record was captured from a different implementation). The plain suite run asks only the first, so "the port broke" stays distinguishable from "a record is out of date".Files
skills/project-lifecycle/hooks/test-context-floor-differential.sh— the record format, both modes, the touch-existing gate, and the hybrid comparison (a row's NEW-side expectation comes from its record when it has one, from the corpus literal when it does not; OLD's side is always hand-written)..claude/divergence-records.txt— 16 records, one per deliberate-lead row, with a prose header a reviewer reads before the first record..gitattributes— new file, path-scoped. The record format is length-prefixed, so a contributor whose git normalises line endings would break every stored byte count with nothing complaining until the check ran.skills/project-lifecycle/hooks/test-differential-harness.sh— two new meta rows covering the record branch, plus their mutants in the file's mutation table..github/workflows/validate.yml— the check, inside the existing step, with an assertion that it actually ran.CHANGELOG.md,docs/ledger — corrections and four new filings.2. Why this approach
The record is the expectation, not a copy of one. This is the load-bearing decision and the first implementation got it wrong. A pure sidecar — pins stay hand-written, the record only attests — was built across the first three tasks and then refuted by a run: under the B83 patch the twelve affected rows fail their hand-written literal first, so the comparison never reaches the record and no amount of re-capturing repairs them. That shape was the one the design document had already rejected in writing. It was rebuilt as the hybrid, with the human's approval at the point of discovery, because reinterpreting a locked decision is not an unattended call.
The generalisable form, which is worth more than the fix: a mechanism justified by a use case is not done until it has been run against that use case.
Old's side stays hand-written on purpose. The reference implementation is content-addressed by a pinned blob and the harness refuses to run if that blob moves, so it costs nothing to keep and it is the independent anchor the check compares against. Capturing both sides would leave the check nothing to disagree with.
Two verdicts rather than one. Requiring that a dirtied hook invalidate every record needs the implementation identity compared; keeping the 12-row text signal legible needs it not to be. Reporting both separately satisfies both and — more usefully — keeps did behaviour move and did identity move answerable independently.
Vacuous runs are a hard failure with no waiver, matching pytest's dedicated exit code for "no tests collected", Jest's fail-on-no-tests default, and this repo's own empty-corpus rule. Zero records, a record for a row that no longer exists, or a discovered row with no record all fail. The name comparison runs in both directions, because a copy-paste rename leaves the totals matching while one record describes a row that is gone.
3. Requirements satisfied
Every acceptance criterion is demonstrated with raw output in the PR comment, never prose.
checked 16 records, 16 matched, rc=0--blessgrep -n bless .github/workflows/validate.ymlreturns nothing85 passed, 0 failed, 0 skipped, 4 known-diverge, 16 deliberate-lead, and a zero-delta 105-row per-line census20b40056→5c77246f; 16 STALE / 0 DRIFT.gitattributes, proven by a normalising checkoutchecked 16 recordswith 1 orphan + 1 missingThree acceptance criteria were corrected by measurement rather than reworded to pass (spec §5.6): the sidecar-to-hybrid rebuild, AC3's branch attribution being off by one section, and AC4's "all 12" being exact but not exhaustive. Retired wording is kept verbatim.
Deliberately not here: B83's two-character fix and the 45 rows that need real conversion (split out as batch-map PR 10); a close-gate invariant for record freshness (rejected in the design, with the gap stated — the local pre-push layer cannot see a stale record, so red arrives from CI);
user-story.mdand dual-track smoke (internal test-rig tooling, no adopter-invoked surface — logged asSKIP:in the journal's Plan deviations).