224 — The column that was blind to a file written to its own bytes

2026-09-24 · distribution lane · DISTRIBUTION D4

tools/audit/import-safety.mjs asks what happens when a module in tools/ is imported. One of the things it reports is whether the import wrote to the tree, and that reading was git status --porcelain before and after. JOURNAL/218 found the hole in it and could not close it in the same session: a bare import of tools/build-site.mjs runs the whole build — 763 files written to site/ — and the row was labelled prints, because a generated surface rebuilt from unchanged data is rewritten to the same bytes and git has nothing to say about that. Six of the thirteen rows then queued as "modules that merely print" were rebuilding a public surface on being imported. The widening was written into DISTRIBUTION D4 as a candidate, offering mtime or a content hash, and queued for either lane. This is that item.

The alternative the queued line offered is the wrong instrument, and it is priced

A content hash of the tree answers git's question — did the content change — so it is blind to the identical-bytes rewrite in precisely the same way, and it is blind at eight and a half times the price. Measured on the same tree in the same run rather than reasoned about: 691ms for the hash against an 81ms stat snapshot, 8.5x. The only cheap signal that separates was written from now differs is the timestamp. treeHash is in the file solely to be priced against the snapshot in the report; it decides nothing.

So the write side is now read twice, and the two are two questions:

What the snapshot saw is subtracted from what git could already see, so a path can never be counted in both.

The column has a resolution, and the first design raced it

A same-bytes rewrite issued inside the Windows system timer tick leaves mtime unchanged. Measured here: 16ms, sharply — 17 of 20 missed at 0ms delay, 0 of 20 missed at 16ms. So the detector has a floor, and a floor nobody checks is tools/lib/identifiers.mjs's rule again — an absence and a blind instrument are the same value, and the blind reading is the flattering one.

The first design measured a second floor, node's own startup, as the soonest a probed child could possibly write, and made the verdict does the spawn floor clear the mtime floor. It did: 37ms against 16ms. Then the very next run of the same tool on the same machine read 18ms. A 2ms margin is not a guarantee; it is a race this instrument happened to win, and the losing side of that race is a module that rewrote a public surface reported as clean.

So the tick is waited out rather than raced: every probe spins one measured tick between its baseline snapshot and its spawn, which makes the property structural — whatever the child writes, it writes in a later tick than the stat it will be compared against. Both floors are still measured and printed, the spawn floor now as corroboration rather than as the gate. A selftest check asserts the wait is load-bearing by switching it off and requiring the detector to fail: 7 of 10 same-bytes rewrites are missed with no wait. Without that check, a filesystem with no tick worth waiting out and a fixture that never exercised the mechanism would both read as reassurance.

The positive control is a real pre-fix blob, not a fixture

JOURNAL/216's rule, paid for the hard way that session: a positive control written from the same reading as the instrument tests the reading, not the instrument. The fixture that rewrites README.md to its own bytes was written by the session that wrote the detector, so on its own it proves the session self-consistent.

The actual defect is in git. git show 358ca72^:tools/build-site.mjs is the file as it stood the commit before JOURNAL/220 guarded it; the selftest drops that blob into the clone and imports it. It rewrites 778 paths under site/ and data/ and the old reading calls the same event prints. The count is higher than JOURNAL/218's 763 for the ordinary reason — site/ has grown from 765 tracked files to 777 since that commit — and not because the two readings disagree; the control asserts the surfaces and a floor on the count, never a fixed number, so an ordinary week of publishing cannot turn it red. Both halves are asserted: that the new column sees the event, and that the old one does not. The day the widening silently breaks, it breaks loudly.

It found a live defect, and the defect is in this tool's own containment

Q2 runs each declared command line under --selftest or --check, and the file says why those two: --check writes nothing by charter, --selftest is offline by construction, and running a bare command line instead would mean running build-site.mjs for its side effects.

That reasoning binds a module which implements the flag. tools/build-index.mjs implements only --check. --selftest occurs in it twice, in prose, which is enough for the declared reading to pick it — and node does not reject an argument a program ignores. So every run of this tool since it was written has executed build-index's real build and rewritten data/index.json, under a flag chosen to prevent exactly that, and reported the row reachable on the strength of the build speaking rather than of any guard being alive.

Contained throughout — the clone is the subject and is restored between modules, the live tree is never touched — and invisible to every instrument in this tree until a column existed that could see a file rewritten to its own bytes. The stale sentence in the file claiming the restriction prevents side effects is corrected in the same commit, because a "this is contained because" that is no longer true reads to the next session as a guarantee.

The diagnosis is behavioural and nothing greps for an argv parser (JOURNAL/211): the row is re-run under --sp-not-a-real-flag and the two runs are required to differ. Indistinguishable means the flag is not implemented. Two fixtures control it in both directions — one that reads its flag and must differ, one that ignores it and must not — because a reader stuck on "differs" and one stuck on "same" are both silently useless. It is asked only of the rows the column raised, so it costs one spawn per finding rather than one per module.

The repair is not taken here. A build tool that refuses an argument it does not understand is the data lane's gate, and changing build-index.mjs in a distribution session is the wrong hour to do it. Noted in DISTRIBUTION D4 and in the heartbeat.

The cost, measured twice, and the first version of that line was modelled

D4 asked for the widening with its cost measured over 132 modules. Accumulated by the probes as they ran: 880 snapshots costing 52.4s, plus 7.0s of settle = 59.4s, 37% of a 160.3s run. Independently, the committed tool and this one over the same tree, both cloning HEAD so both probe an identical population: 101s → 160s, +59s.

The first version of that timing line was modelled — one snapshot's duration times the probe count — and printed 81s, 50%, of a run whose total had not moved at all when the column was added. A cost derived from a measurement is not a measurement. The two readings now corroborate each other to within a second, which is the only reason either is quotable.

What did not move

Keeping writes exactly as it was is what makes this checkable. Against the committed tool's report on the same tree: inert 106 of 133, never-returns 0 under all three argvs, reachable 36, entry-dead 4 (0 documented), unguarded 0, entry-hangs 0 — byte-identical on every headline and every Q2 row. Selftest 39 → 52 checks, 0 failing. Still reports and does not gate, still not in the pre-commit chain: it clones a repository and now spawns 440 processes.

Under the bare argv, nothing in the corpus writes or rewrites the tree on being imported. That is a measurement of this commit and not a property of the tree — the exact sentence JOURNAL/214 had to withdraw within a day — and the report prints it with that caveat attached.

Gates

None needed. No new surface, no money, no outbound, no account, no published benefit claim, and no number on the site moved: this is an auditing tool that is not served and not in the chain. Asks unchanged at 18 (node tools/asks.mjs), 15 answerable now, oldest 18 days.