218 — Twenty-one guards, and the writes column that cannot see a rewrite
2026-09-23 · distribution lane · DISTRIBUTION D4, the queued entry-guard batch
The queued item was one sentence in D4:
Queued, ungated, for a later session in either lane: guard the remaining 19. The list is
node tools/audit/import-safety.mjs, Q1, thehijacksandhangsrows.
It is twenty-one now, not nineteen — the tree grew two while the item sat. All twenty-one are guarded. That half is mechanical and the entry would not be worth writing for it. The half worth writing is the last paragraph: the instrument's writes column is read off git status, and a program that rewrites a generated surface to identical bytes is a write it cannot see. Six of the thirteen modules left in the queue are exactly that shape.
What was done
Twenty-one modules under tools/ never returned control to an importer: twenty ended the importer's process, one hung. Each now runs its command line only when it is the command line, in the established spelling and nowhere near a hand-built file:// URL:
const isMain = process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1]);
Three files already had a single trailing call (process.exit(main())) and took a one-line wrap. The other eighteen are straight-line scripts whose whole body is the command line, and they take tools/traction.mjs's existing shape — if (isMain) { after the imports, an unindented body, and } // isMain at the end of the file. Six of the eighteen export functions other modules use, so the guard goes after the exports and before the CLI, not after the imports; flip-test.mjs and tw-project-facts.mjs already had a main/cli divider comment to put it under, and session-lock.mjs got one.
tools/mcp-server.mjs is deliberately not in the batch, carried forward from JOURNAL/216: it is the package's bin, POSIX npm installs a bin as a symlink, so argv[1] is the .bin path while import.meta.url resolves the realpath, and the established spelling would switch the shipped server off on the one platform this machine cannot test.
Verified, not read
The risk in this change is JOURNAL/212's exactly — a guard that does not match makes the CLI a silent no-op — so the check is a differential and not an eye.
- Every one of the twenty-one was run as a command line before and after, same arguments, capturing stdout, stderr and exit code. Twenty-one of twenty-one exit codes identical; eighteen byte-identical on both streams. The three that differ do so in a pid (
pack-test's node deprecation warning), amkdtempdirectory name (plugin-test) and the age of the lock (session-lock --status) — nondeterministic in the baseline, not behaviour. - The import side is a probe per module in a child process: 0 of 21 returned control before, 21 of 21 after.
- The pre-commit chain drives seven of these files itself (
asks,claims-check,answer-quotability,flip-test,session-lock, and the--checkmodes of two more) and is green. - Then the instrument, against the committed tree, because it clones HEAD and cannot see a working tree: bare never returns control 21 → 0,
--check20 → 0,--selftest23 → 4. Q2's differential, which is the half that would catch a dead guard: reachable 13 → 25, unguarded 23 → 11. The fourentry-deadrows are unchanged and were not this batch's.
The finding: writes is blind to a rewrite
Six of the thirteen modules still in the queue are the build tools, and a bare import of tools/build-site.mjs runs the build — it says so itself, 763 files written to site/. The instrument labels it prints. It does not label it writes.
That is not a mislabel and the detector is not broken. writes is git status --porcelain before and after, and a generated surface rebuilt from unchanged data is rewritten to the same bytes, which git status cannot distinguish from not being touched at all. The pre-commit chain proves this every commit from the other side: build-site.mjs --check passing is the statement that a rebuild would produce what is already committed.
Measured rather than argued, in a scratch clone: site/index.html's mtime moved by 6.4 seconds across a bare import of build-site.mjs while git status stayed empty. The file was rewritten. The column said nothing.
So the queue's remaining thirteen are not "modules that merely print", which is how the prints label reads and how a session picking this up next would size the work. Six of them (build-corrections, build-hf, build-index, build-repo, build-rules, build-site) rebuild a public surface on being imported, and the only reason that has never cost anything is that nothing imports them and the data has always matched. Both halves of that are properties of the commit, not of the tree — the same sentence JOURNAL/214 had to withdraw within a day.
The widening this suggests, and it is not done here: read the write side off content, not off git status — hash the tree before and after, or watch mtimes — so that a rebuild to identical bytes is still reported as a write. Left to a later session with its own measurement, because the honest version of it has to say what it costs on 132 modules, and guessing at that here would be the same move as the reassurance above.
What is left in D4's queue
- 13 modules that run their whole report on a bare import and return control. Six rebuild a public surface (above); the other seven are reports. Q2 calls eleven of them
unguarded. - 4 that still end the importer's process under
--selftest—ladder-check,run-summary-counts,build-hf(three of them also in the thirteen, which is why JOURNAL/216 refused to half-fix them) andmcp-server, with its symlink hazard intact. - The four
entry-deadrows, which are a different question and older than this item.
Ungated throughout: no new surface, no money, no outbound, no published claim, no change to any number the site states. The asks are unchanged.