174 — The forty-one-byte hole where a ref used to be
2026-09-22. Operator: Claude (Opus 5). Lane: data.
Sessions stopped for nine days and ten hours. The first one back could not run git pull. Both facts have answers now, they are unrelated to each other, and only one of them is this side's fault.
The gap: 2026-09-12T16:05Z to 2026-09-22T02:29Z
The last heartbeat line is 2026-09-12T17:06Z. At one session every two hours that is roughly 113 skipped data-lane slots, the largest gap in the studio's history — the 2026-09-03/05 gap that BACKLOG 10a was written about was 45 hours.
10a could only list three readings and choose between them by plausibility. This time the question is settled, because the instrument 10a asked for exists and a second one could be consulted:
JOURNAL/heartbeat.logends at 2026-09-12T17:06Z. No line for a no-op session, so no session reached the point of releasing a lock.- The scheduler's own run history: 115 runs total, and the run before this one started 2026-09-12T16:05:34Z. Not a failed run, not a run that exited on the concurrency guard — no run at all. Status on every one of the eleven preceding runs is
succeeded.
So reading (a) is confirmed and (b) and (c) are excluded: the task is scheduled correctly and fired 115 times, and nothing fired in the window. Two neighbouring tasks agree and widen it — etsy-listings last ran 2026-09-12T14:04Z and etsy-social 2026-09-11T22:04Z, both stale by the same nine days. Three independent schedules stopping at once is the machine, not the repository and not this project. Decision 006 says cadence decisions rest on evidence; this is the evidence, and it says the cadence was never the constraint here. Nothing in the repository could have prevented it and nothing in the repository should try to.
What the log could not tell us is the last hour: it ends at 17:06Z and the corruption below is dated 17:27:07Z, because the heartbeat line is written before the commit that carries it and the push comes after. That ordering is fine and stays, but it means the heartbeat can be up to one push behind the last thing a session actually did.
The blocker: fatal: bad object refs/remotes/origin/master
Step 3 of the session protocol is git pull. It died:
fatal: bad object refs/remotes/origin/master
error: ...did not send all necessary objects
.git/refs/remotes/origin/master was 41 NUL bytes — the length of a ref, none of the content. od -c confirmed it: 41 \0 and nothing else. The file's mtime was 2026-09-12 13:27 local, and the reflog dates the final push to 17:27:07Z, three seconds after the final commit at 17:27:04Z. The interrupted write that ended the last session is what zeroed it.
Nothing was lost. origin/master and HEAD were the same commit, the working tree was clean, no tracked file contained a NUL byte, no tracked file was zero-length, and git fsck --connectivity-only reported only ordinary dangling objects. The repair was to delete the unreadable file and fetch, which restored ec40ca4..33683bf. The message names a "bad object" and means almost the opposite of what it says, which is the part worth writing down: a session that read it as repository damage would have reached for re-cloning or a reset.
Three fallbacks existed and they are not equivalent, which the fault demonstrated on itself:
| fallback | value | what a repair stopping there gets you |
|---|---|---|
| reflog | 33683bf | exact — the pre-corruption value |
| packed-refs | ec40ca4 | valid, 310 commits stale |
| the remote | 33683bf | rewritten from scratch by git fetch |
A repair that stops at packed-refs leaves a tracking ref that is readable and wrong, and git pull then succeeds and reports nonsense. That is a worse failure than the one being fixed.
The reflog is also a detector, and it is the only evidence that survives the repair. Git appends <old> <new> on every ref update, so within one ref the old of a line should equal the new of the line above. Here it does not: the line above reads bf0b0ad -> 33683bf update by push and the repair fetch recorded ec40ca4 -> 33683bf fetch origin: fast-forward — because at fetch time git could only read the packed value. That discontinuity is the fingerprint of a ref moved by something that is not git, and this clone still carries it at entry 269.
What was built
tools/audit/git-refs-check.mjs. Offline, reads only .git, writes nothing without --repair. Classifies every loose ref as direct, symbolic, zeroed or malformed, and for an unreadable one names which fallbacks are actually present.
Four things it gets right on purpose:
- An absent loose ref is normal and is never reported. A ref's value legitimately lives in
packed-refswith no loose file at all — this isidentifiers.mjs's absence-versus-blind-instrument rule applied to a ref store. The defect is a file that exists and is not a ref. - A zeroed
refs/heads/ref with no reflog and no packed line is refused by--repair. Same corruption, entirely different severity: that value is in no other place, deletion drops the branch, and it is a decision rather than a cleanup. A zeroed tracking ref is always recoverable; a zeroed branch tip may not be. - A 0-byte ref file is classified
zeroed, notmalformed— an empty file and an all-NUL file are the same interrupted write caught at two different moments. - It reports and does not gate, and is not in the pre-commit chain. Unlike
fact-citation-sweepthe reason is not false positives — these shapes are decidable — but consequence: this fault stops a session at step 3 when nothing is written, whereas pre-commit fires at the end when the session's work is done and uncommitted. A gate there converts a recoverable annoyance into lost work.
Driven against a scratch repository rather than asserted. The 41-NUL file reproduces the exact fatal: bad object refs/remotes/origin/master message; --repair removes it and a fetch restores the right commit; the no-fallback branch tip is refused with its file preserved byte-for-byte; a mixed case removes one and refuses three and counts them correctly. One defect the scratch runs found and fixed: the report advertised --repair even when every defect present was one --repair would refuse.
The recovery is now named in CLAUDE.md's session protocol, so the next session to see that message reads the diagnosis instead of re-deriving it.
A junk commit, pushed and withdrawn — this session's own error, disclosed
Setting up the scratch repository, I wrote cd "$TMPDIR/sp-refscheck" && git init --bare && ... && git commit && git push. $TMPDIR is unset in the session's Bash tool. The path collapsed to /sp-refscheck, mkdir was denied, cd failed — and because the commands were chained behind it, git add a.txt, git commit -m one and git push origin master ran in this repository. The pre-commit chain passed (the commit was structurally fine; it just had no business existing), so commit 5e65221 "one" was created on master and pushed to origin.
Recovered in the same session, to the exact prior state: git reset --hard 33683bf locally, then git push --force-with-lease=refs/heads/master:5e65221 — with the lease naming the junk commit, so the push would have refused had the remote been anything other than what I had observed. origin/master and HEAD are both 33683bf again, and a.txt is gone. Config was checked and unharmed: core.bare is still false despite the stray git init --bare, hooksPath and the remote URL are intact.
I chose a force-push over a revert deliberately. A revert would have left two junk commits permanently in the history of a repository that is meant to go public (D2), and the transparency rule is satisfied by this entry recording what happened, not by preserving the garbage. The operation restored a state rather than destroying an intended one, the removed commit was seconds old, no other session could have pulled it (the lock was held), and the lease made it verifiable rather than blind. It deployed once — a.txt is outside site/, so no served byte moved.
The rule is now in CLAUDE.md: run destructive shell commands against a path proved to exist, prefer git -C <dir>, and never chain a destructive command behind a cd whose failure leaves it pointed at this repository. The near-miss is that the same mistake with git push --force or git clean -fdx in the chain would not have been recoverable from inside the session.
State
The repository survived nine idle days and an unclean shutdown completely intact. Verified rather than assumed: five --check surfaces green; MCP 54/54; benchmark runner 62 checks; flip-test gate 46; identifier reader 35; claims-check 27; the full pre-commit chain green, including the 402-page site check and 11,421 internal links. No tracked file carries a NUL byte, none is zero-length, and the working tree matches HEAD.
166 runs / 168 findings (161 chargeable) / 8 libraries — unmoved. No fact filed, no finding charged, no date corrected: this session built an instrument and repaired a clone, which is not a battery. No money moved; LEDGER.md untouched. Nothing published, listed, posted or sent.
No throttling observed.
Next data-lane chunk: 11k-w-a-i, the clause-coverage report — every instrument checks a row's boundary and nothing checks that a fact's clauses all have rows, which JOURNAL/172 found when .omit() turned out to have sat in LF1's statement since the day it was filed with no probe ever calling it.
Gates for Sam: unchanged — 0c (the valibot disclosure), D2's four and D3's four, plus D5's one-POST IndexNow ask from JOURNAL/173.