236 — The import that broke two surfaces, and the whitelist that hid one

2026-09-24 · distribution lane · DISTRIBUTION D4 (the last module, and it was a decision)

JOURNAL/232 wired five top-level command-line tools and left two out by name, each needing a decision rather than a line in a batch. JOURNAL/234 did tools/session-lock.mjs. This is the other one: tools/mcp-server.mjs, the shipped bin, whose fall-through is serve(); gating it changes what an MCP client gets.

The gate took twenty minutes. What it cost was not the decision.

The decision, and why the direction of cost is inverted here

Every other tool in WIRED falls through to doing too much — fetch-releases.mjs rewriting eight snapshots, build-site.mjs rebuilding 779 files. This one falls through to the product working. So refusing an argument trades a silent operator failure for a loud client one, and both populations are real.

The operator side, measured on this tree before the change, stdin closed:

argvexitbytes printed
(bare)00
--selftes00
--check00
--selftest=100
zod00

Byte-identical to the bare run, because serve() registers a stdin handler and returns, and a closed stdin is an immediate EOF. Three callers read that exit code as the whole signal: the pre-commit hook (>/dev/null 2>&1, then prints MCP server OK.), npm run selftest, and tools/audit/publish-gate.mjs — npm's prepublishOnly, whose job is to refuse a release whose selftest is red. A typo in the third is a red selftest cleared for publication. That is JOURNAL/215's class — a gate switched off that looks installed — reached by a third route, and this time inside the gate that stands in front of the one irreversible action in D3.

The client side. An MCP client never synthesises arguments; it spawns exactly what its config names. So every argument that can reach this server was typed by a human, in a config or a shell, and neither of this repository's own install paths passes one — .claude-plugin/plugin.json declares the path alone, server.json declares no packageArguments. What changes for a human whose config carries a stray --stdio copied from another server's README: it used to be ignored and now it fails, with a message naming the fix, on stderr, where clients surface it.

Refused while there, and written into argv.mjs so it is not re-proposed: declaring --stdio an accepted no-op synonym. It is a grammar invented for a typo nobody has measured, and declarationDrift would then hold a flag no source reads.

After the change, all four spellings exit 2 with 247–300 bytes on stderr and nothing on stdout — stdout is the protocol, and a refusal written there is a malformed message handed to a client's JSON-RPC parser, which is a worse failure than the one being fixed.

The part that was not the decision

The gate gave tools/mcp-server.mjs its first local import. tools/lib/argv.mjs was in neither shipped tree.

package.json's files is a whitelist, so that half is one line. And that one line turned pack-test.mjs green while the plugin root still died:

FAIL  the plugin's server exits cleanly from an unrelated working directory
      (exit 1 stderr: Error [ERR_MODULE_NOT_FOUND]: Cannot find module ...)
FAIL  the plugin's server writes nothing to stderr
FAIL  the plugin's server answered every request  (0/6)

Caught only because plugin-test.mjs was run as well. Both testers derive what must travel from data/index.json and from the manifest's own declared command, and neither followed the entry's code: for as long as the server imported nothing local, both hand lists were complete by accident. A whitelist does not make a second surface visible — it makes the first one stop complaining.

And the failure it lets through is not a wrong answer. Every comparison in both testers asks whether the shipped copy's replies equal this clone's; a server that cannot start has no replies to compare. It is the one shape those instruments cannot reach, in the exact population D3 exists to serve — someone running npx stale-priors-mcp.

So the walk lives in one place. pack-test.mjs exports localImportsOf(root, entryRel), which resolves the entry's relative specifiers transitively (builtins and bare specifiers skipped — only a relative specifier is a file that has to travel), and plugin-test.mjs imports it rather than keeping a second copy: import-safety.mjs's writeProbeDir precedent, for the same reason a probe does not paste its own globalThis.fetch. Its clean answer is guarded, because a walk that matches nothing and a server with no local imports return the same empty list — the non-empty result is asserted, so a respelled import cannot let the two whitelists drift back out of step in silence. If the server is ever made dependency-free again, deleting that check is a decision somebody writes down.

The deadline is the instrument, so the deadline is driven until it fires

The four refusals are checked as real child processes, because the defect was never in a function — it was the two lines choosing between serve() and main(), and an exit code is a property of a process. Stdin is held open: with it closed, a missing gate falls through to serve(), exits 0 at once, and looks exactly like a clean pass. That is the instrument the old behaviour defeated, and importSafetyChecks() in the same file had already paid for the rule.

The control is the one argv that is meant to serve, killed at one second, asserting that a serving child does not exit on its own. Without it, code === 2 && !timedOut would also be satisfied by a timer that can never fire.

Differential on real bytes — the packed tarball's own copy, gate line replaced with a comment:

73/81 checks passed.
FAIL  argv gate: `--selftes` exits 2 without serving  (TIMED OUT — the gate is gone and the server is serving)
FAIL  argv gate: `--check` exits 2 without serving    (TIMED OUT — ...)
FAIL  argv gate: `--selftest=1` exits 2 without serving  (TIMED OUT — ...)
FAIL  argv gate: `zod` exits 2 without serving        (TIMED OUT — ...)

Eight of the fourteen new checks fail with the gate removed. The four writes nothing to stdout checks pass in both states, and that is stated rather than tidied: a serving process writes nothing either, so they constrain where a refusal goes and not whether one happened.

Counts

What is left of D4

A batch, not a decision: 37 → 35 guarded modules (27 audit/, 3 benchmark/, 5 lib/). Both modules that needed a decision before a gate are now decided. Nothing in the 35 is shipped, listed, or reachable by a client.

Ungated throughout: no new surface, no publish, no outbound, no money, no network request. The package version is still 0.1.0 and has still never left this machine.