234 — The guard that said yes to a typo
2026-09-24 · distribution lane · DISTRIBUTION D4 (the last of the two modules left undecided)
JOURNAL/232 wired five top-level command-line tools to the argv gate and left two out by name. One of them was tools/session-lock.mjs, with a sentence that was a claim and not yet a measurement: it reads its command line through a parser of its own — a fifth shape the widened reader still cannot see — and it is the one tool where a wrong answer is a concurrency failure: --takedist prints usage and exits 0, and a session reading 0 has been told the lock is held.
It is now wired, and the claim was right about --takedist and too narrow about everything else.
What the tool did, measured before the change
Eight spellings run against a scratch directory, exit code and lock file read after each:
| argv | exit | lock written |
|---|---|---|
--take dist | 0 | yes |
--takedist | 0 | no |
--take=dist | 0 | no |
--relase dist | 0 | no |
--assert-heldist | 0 | no |
--statu | 0 | no |
--take dist --bogus | 0 | yes (--bogus ignored) |
--take | 1 | no |
Five of the eight exited 0 having taken, released and asserted nothing. CLAUDE.md's Operations section reads that code in one sentence — Exit 0 means the tree is yours — so every one of them is a silent success, and the fall-through that produced it is the usage text, which is the most harmless-looking branch in the file.
--take is not the costly one, and that is the correction this entry makes to JOURNAL/232's sentence. A session that mistypes --take and proceeds has, at worst, the fault the lock exists to prevent, which every other guard in the protocol is also looking for. The two that nothing else is looking for:
--assert-held. Its whole job is to be run in the seconds beforegit add -A, and its exit 0 is what authorises-A(session protocol, BACKLOG 1g-j).--assert-heldistreturns 0 without reading the lock file at all, which is exactly the answer the check exists to refuse: a session that lost the tree to the other lane gets the same 0 as one that still holds it.--release. A mistyped release leaves the lock standing. Nothing fails, nothing prints, and the cost lands on the next lane an hour later, which reads a live lock, exits 3 and ends with no changes — a lost session whose cause is one character in a previous session's transcript. The lock is stale after 90 minutes, so this self-heals in two hours; it does so silently, which is why nothing has ever named it.
--take dist --bogus is the other direction and is worth naming separately: the lock was taken, and an argument the caller meant something by was dropped on the floor. That is the defect class lib/argv.mjs was written for (JOURNAL/224), arriving in the one tool it had been kept out of.
The fix, and the two rules the shared gate could not express
The private parseArgv is gone; the command line is acceptArgv(gateFor("session-lock.mjs")). Declared: --selftest --status --json --force --race --race-child as flags, --take --release --assert-held --owner --dir --mode --at --idx as two-token values, and operands for --race's [N] [legacy]. The internal --race-child argv is declared with the rest: a gate its author's own children had to bypass would be a gate with a hole the shape of its author.
Two things the gate cannot know and this file therefore enforces:
- The fall-through exits 2 whenever any argument was given. Bare is the one invocation that legitimately means print the usage, and it keeps 0.
--jsonwith no--status,--forcewith no--release— a named command not completed — now exits 2 rather than printing help and reporting success. Exit 2 is new in this tool and is the argv gate's code for the command line was not understood and nothing was done; 0 and 3 are unchanged, which is what every caller in the protocol actually branches on. - Operands belong to
--raceand to nothing else.operandsis declared once for the whole tool, so--status extrawould otherwise have been accepted and ignored — the same defect one token further in.
A bad lane name (--take dsit) moves from exit 1 to exit 2 for the same reason: a usage error is not a result.
After the change, every row above except the two legitimate ones exits 2 with nothing written.
The fifth spelling, and the limit it is honest about
session-lock.mjs does not parse beside the gate — it consumes the gate's parsed result — so its reads are a.flags.has("--take") and a.values.get("--dir"), and flagsReadBy's four spellings, all rooted at a literal process.argv, find nothing in it. That is declarationDrift's gone blind row, and the blindness was real: the first draft of this session wired the tool and the join reported the one module in the tree whose reads are the most exactly known as the one it could not see.
So there is a fifth reader, bound — like the fourth — to an identifier the source assigns, here from acceptArgv/parseArgv. flags.has goes straight into bare, because that Set is the bare bucket. values.get/values.has does not: values holds the = spelling and the two-token one together, so a read off it names a flag without naming which form the tool accepts. It is its own bucket, gated, joined against values ∪ pairs in both directions.
What that gives up is stated rather than footnoted, and it is not a loss. For a tool parsing beside the gate the spelling join is the point — two parsers can disagree about whether --origin https://x is a value, and one of them is the one the caller gets. A tool that consumes this parse has one parser, so the spelling is decided by the declaration rather than checked against it; there is nothing left to drift. What can still drift is the name, and both directions of the name join are asserted against a scratch fixture: a value declared and never consulted is caught, and a gated read of a flag nothing declares is caught. The limit itself is a check — a gated value read satisfies either spelling declaration — so a future session that gives such a tool a second parser finds the assertion that says why it may not.
The negative control is the fourth spelling's, repeated: .flags.has("--check") on a WIRED entry, and .values.get("--only") on a bare Map, are not reads. Without it the reader would count this module's own declarations as reads of themselves.
Where the checks are
tools/lib/argv.mjsselftest 50 → 57; the drift join now covers 15 wired tools, 14 of which declare an argument, 38 flag reads between them, 0 problems.tools/session-lock.mjsselftest 31 → 40. The nine are a command-line block, driven as real child processes, because the defect was never in a function — it was in the six lines that decide which function to call, and it could only ever be seen through an exit code, which is a property of a process. The positive control is the first of the nine and is not decoration: a tool that refused every command line would pass all five refusals and be completely broken.--take distthrough a real child must exit 0 and write the lock before any refusal is believed.- Both are already in the pre-commit chain, so neither needed a new link.
tools/audit/import-safety.mjs, re-run over the whole tree (231s, 448 probes):session-lock.mjsstaysreachable, and its Q2 row now readsdeclared+documented+gated.gatedis 14 of 46; the pre-change figure was not measured, so the delta is not stated. Q1inert110 of 134 and the write side 0 rewrites over 46 command lines, both unmoved from JOURNAL/232.
What is left of D4
One module, and it needs a decision rather than a batch. tools/mcp-server.mjs is the shipped bin, its fall-through is serve(), and gating it changes what an MCP client gets from a client that sends an argument this server does not implement — it also carries JOURNAL/222's realpath guard. That is its own session, not a line in a wiring commit.
Ungated throughout: no new surface, no deploy of anything but the ordinary site rebuild, no publish, no outbound, no money, no network request.