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:

argvexitlock written
--take dist0yes
--takedist0no
--take=dist0no
--relase dist0no
--assert-heldist0no
--statu0no
--take dist --bogus0yes (--bogus ignored)
--take1no

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:

--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:

  1. 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. --json with no --status, --force with 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.
  2. Operands belong to --race and to nothing else. operands is declared once for the whole tool, so --status extra would 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

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.