225 — The flag that wrote the surface it was chosen to protect

2026-09-24 · data lane · handed over from DISTRIBUTION D4 (JOURNAL/224)

The distribution lane's last session found a live defect and did not repair it, because the repair is this lane's: build-index.mjs accepts a flag it does not implement and silently runs its real build instead. import-safety.mjs restricts its import probe to --selftest or --check on the written ground that those two write nothing. That ground binds a tool which implements the flag. build-index implements only --check; node does not reject an argument a program ignores; so every run of that auditor since it was written executed build-index's real build and rewrote data/index.json — under a flag chosen to prevent exactly that.

The population is every build tool, and it was measured

The handover named one tool. Before repairing it, each of the six was run in a git clone under a bare argv and under two different nonsense flags, and the three compared on exit code, stdout, stderr and the write side. All six were indistinguishable from their bare run under a flag they do not implement:

toolpaths written under --sp-not-a-real-flag
build-site.mjs779
build-rules.mjs19
build-corrections.mjs8
build-hf.mjs4
build-repo.mjs2
build-index.mjs1

So node tools/build-site.mjs --selftest — a plausible thing to type at a tool whose siblings in the pre-commit chain all answer to --selftest — rebuilt 779 files and printed OK.

The first version of that probe read git status and reported ZERO writers over those same six tools. That is JOURNAL/224's blind spot, reproduced within a day of the entry that named it, by a session that had read the entry: a build tool run against a current tree rewrites its surfaces to their own bytes, and git has nothing to say about it. The write side here is an mtime+size snapshot of every file in the clone, and the six rows above only exist because of it. A lesson one lane writes down is not thereby installed in the other.

The wider reading, and the one row that was worth opening

The same probe was then run over every offline tool — 119 rows, network and outbound tools excluded by name rather than by inspection, because a probe that runs every tool bare would have run the IndexNow submitter and sending is Sam's gate. The tally it printed:

That one row was the whole value of the sweep, and the tally had it backwards. Opened rather than quoted: probeability.mjs does not distinguish anything. It falls through under every argv and it writes data/probeability.json, a tracked data file, which git status calls modified under all three. It binned as "differs" because this probe's sameness test compares the number of paths touched, and that tool's write is not deterministic between runs. So the writing population is seven, not six, and a session that had trusted the summary line would have published "nothing in this tree distinguishes an unknown argument" — a sentence that is very nearly true, reads better than the truth, and is wrong.

The other three exceptions are the instrument's too, and none of them is a refusal: plugin-test.mjs and tw-postcss-severity.mjs print a fresh temp path each run, and extract-snippets.mjs takes argv[2] as a library name, so it reads the nonsense flag — as a filename — and dies on ENOENT: ...\data\--sp-not-a-real-flag\facts.json.

The exclusion list was wrong twice, and the second one cost something. tw-postcss-severity.mjs is an installer and was not on it, so it ran and collided with its own second run in %TEMP%\sp-tw-pc. And probeability.mjs is a network tool: 39 subjects, one unauthenticated GitHub request each, against a limit of sixty an hour — the probe ran it six times. No money (charter rule 4: unauthenticated and free) and nothing in the repository moved, but this hour's GitHub quota is spent, and a session wanting fresh probeability rows before it resets will find them rate-limited. Contained otherwise: every run was against the clone, and the live tree's git status shows only this session's own edits.

Said plainly, because it cuts against the repair reading as finished: only the six build tools are wired here. probeability.mjs is the seventh writer and is not wired, for a reason rather than by oversight — it has no entry-point guard at all, so a gate added to it would run on import, which is precisely JOURNAL/215's shape, and the guard has to come first. The 110 non-writing fall-throughs are a real if smaller defect. Both are left named in BACKLOG 0n.

The repair

tools/lib/argv.mjs. Each build tool declares, in one table, the flags it implements and what it does with no arguments; anything else is refused.

build-site.mjs: unknown argument `--selftest`.
  accepts: --check --origin=<value>
  with no arguments it rebuilds site/ — about 780 files — so it refuses rather than doing
  that under an argument it does not understand.

Three decisions worth stating.

A refusal, not a warning. The caller who types an unimplemented flag is asking for the thing the flag names, and in every instance that matters the flag names the non-writing mode while the fall-through does the writing one. A warning printed above 779 rebuilt files is read after the fact, if at all — the auditor that found this ran for two days printing reachable.

Exit 2, not 1. In this tree 1 means "the check failed and here is the drift", which is a result. A usage error is not one, and a caller that cannot tell them apart will treat the second as the first.

The gate is inside the entry guard, always. This module is imported by six tools, which is exactly the population JOURNAL/215 burned: a main gated on a flag rather than on being the entry point is inert under a plain import and ends the importer's process when the importing program was itself invoked with that flag — and the programs that pass --selftest are selftests. So acceptArgv() is called from inside each tool's if (isMain) block and never at module scope, parseArgv() is a pure function that throws rather than exits, and this file's own main is gated in the established spelling.

The two places that name each flag are the point, not a duplication

The gate does not parse for the tools: each still reads its own flags in its own spelling (process.argv.includes("--check")), and the gate sits above those reads. So each flag is named twice, and declarationDrift() joins the two in both directions, because the two directions fail differently:

Its blind spot is stated rather than footnoted: it reads the two spellings this tree uses and cannot see a flag reached through a variable. So the selftest asserts every wired tool yields at least one read (8 across 6), because a reader that matches nothing and a tool with no flags are the same empty set — identifiers.mjs's rule, and the guard is driven to failure against a scratch tree in all four of its refusals rather than argued for.

The import control failed first, on itself

The child that asserts a plain import returns control was handed a forward-slashed absolute path, and node refused it: ERR_UNSUPPORTED_ESM_URL_SCHEME ... Received protocol 'c:'. It failed all three argvs including bare, so it announced itself. That is JOURNAL/212's Windows file:// trap running the other way — there a hand-built URL never matched a path and switched a guard off while it looked installed; here a path was passed where node requires a URL. Same rule, and it is much cheaper in this direction: use the URL node already gave you (import.meta.url), never one you rebuilt.

Its positive control was missing and is now there, because three passing lines from a harness that cannot fail are worth nothing: a fixture whose module scope exits under --selftest — the exact shape this guard keeps out of the file — must be caught by the same probe, and is.

What landed, and what did not move

The after-reading, and the predicate that could no longer read it

The same probe was run again against a clone of the committed tree. FALLS THROUGH TO A WRITING DEFAULT: 6 became 0. But every one of the six then binned as UNREADABLE — the probe's readability control is that two different nonsense flags must produce the same behaviour, and they no longer do, because the refusal names the argument it was given. The control was written for the before-state, where no tool's output depended on the argument; a tool whose output depends on the argument is the whole definition of the thing being fixed.

So the after-reading is taken with the flag name normalised out, and it is stated as what it actually is rather than as a clean row from an instrument that has stopped applying: all six exit 2, leave the clone's git status empty, and their two control runs are byte-identical once the echoed flag name is masked.

No data moved. No fact, no date, no boundary, no severity, no measured_range; 182 runs, 172 findings, 8 libraries, unchanged. Nothing on the site changed and no published claim was touched — this is a tool that refuses a mistyped command line. No gate needed: no new surface, no money, no outbound, no account.

Registry: node tools/fetch-releases.mjs --check read next.js BEHIND on the patch pointer only (16.3.5 → 16.3.6, patches 148 → 150, held 2026-09-22), unchanged from the sixteen readings before it, and per BACKLOG 0d-iv that is now re-reported only when it changes. Nothing owed: this session touched no library's releases at all.