fix(d48): the mcp structured error leaves the JSON-RPC framing channel (ub-og3) - #439
Merged
Conversation
added 6 commits
August 6, 2026 15:28
… channel `unblock mcp` renders every failure through the generic CLI exit boundary, whose json/robot arm writes the structured payload to STDOUT. On the MCP stdio transport stdout IS the JSON-RPC framing channel, so a client parsing frames meets an unparseable line exactly where a frame belongs. The likeliest trigger is the most mundane one: a committed .mcp.json whose cwd and discovery tier both miss, where the workspace open fails BEFORE the startup binding line is emitted, so stderr is empty and the only bytes the operator gets are on the one stream that cannot carry them. Mint D48: a subcommand whose stdout is a protocol channel reports its structured error on stderr. The CHANNEL moves; the payload and every 0-8 exit code stay put. The rule is stated over the property, not over the command name. SUPERSEDES the D38 "FR-11 interaction (scoped)" clause, which affirmatively ruled that FR-11's always-valid-JSON-on-stdout rule binds the unsignalled Err path; that row carries the reciprocal note. NFR-14 gains the carve-out, and so do its README and spine restatements. D-range D1..D47 to D1..D48 at every live site PROCESS section 3 enumerates. Four residuals are NAMED rather than implied closed: ub-kp7, ub-b1a, ub-c5o and ub-5v5. `unblock mcp --help` is ruled out of scope explicitly, since the server never starts on that path and the framing channel is never live. Spec only: no code, no tests, no contract byte moves. Refs ub-og3.
Implements D48. `Command::stdout_role()` is an EXHAUSTIVE two-valued classification over the parsed subcommand with no wildcard arm, so a future subcommand fails to compile until its author classifies it; the feature-gated `update` variant carries the same cfg rather than falling into a catch-all. The role is read where the parsed command is still in scope and passed at the single call site of the exit boundary. `into_exit` splits into a thin real-stream wrapper and a sink-injected core returning the raw exit byte. BOTH sinks are injected, not only stderr: the stdout sink is what makes "no other command's bytes move" assertable in process. The machine-format arm then writes the FULL structured document — code, message, retryable and hint — to stderr for a protocol-owning command and to stdout for every other. Tests: the two shipped cells that asserted the defect are INVERTED while still pinning the exit code, the never-swallowed clause and the D47 coupling; seven end-to-end cells cover every reachable failure route, each asserting the POSITIVE stderr landing and not only the frame-free negative. The shared harness is hardened where it failed us: the stream guard now requires a top-level `jsonrpc` member instead of mere JSON validity (which is why the blob passed it), the spawn helper scrubs UNBLOCK_OUTPUT_FORMAT, and the drain handles are retained and joined so an inverted empty-stdout assertion cannot go green on a lost race. Four self-tests drive the guards themselves. Mints scripts/checks/d48-stdout-channel-claims.sh and enumerates it as a required step in ci-cd 2.1, PROCESS section 3 and the CI workflow. It exits non-zero until the four residual issues land in the git record, which is the Track step's duty. 17 named killing mutations applied individually and all 17 observed red, including the swapped-sink mutation, which compiles and survives every in-process cell and is caught only by the spawning end-to-end cells. Refs ub-og3.
The D48 mutation proof killed all 17 named mutations, then invented more and found five survivors, every one in the shared harness ORACLE layer — the one layer with no self-test of its own. Each was a docstring asserting something no cell pinned: - the framing guard's three membership tests could not be told apart: its self-test inputs were rejected by either shape clause alone, so dropping the `code` or the `retryable` clause survived; - the stderr oracle claimed to locate the payload "by SHAPE, never by position", but taking the first parseable line survived — it passes today only because the startup binding line happens not to be JSON; - the same oracle would match a DEGRADED document with the `retryable` clause removed; - and `write_payload` losing its trailing newline survived, which is not just an unpinned claim: the payload loses its terminator on stderr AND on stdout for the six report commands, so a host reading the child line-wise sees it only at EOF. The byte-equality cell could not see it, since it compares two equally-mutated buffers. Nine further mutations were invented and measured. One found that the fail-silent-race fix itself was unpinned — deleting the drain join survived — now closed by a cell that reads the synchronisation (the count of unjoined handles) rather than the race outcome, which would be flaky by construction. One survivor is REPORTED, not papered over: dropping the oracle's per-line trim still survives, because `str::lines` already strips the carriage return and serde accepts JSON whitespace, so the only discriminating input is non-JSON Unicode whitespace that neither writer can emit. No cell was added for an input the product cannot produce, and no docstring claims otherwise. Also extends the ci-cd class (a) enumeration, which still stopped at D47. Refs ub-og3.
The Verify quality gate returned six must-fixes. One was a real test defect, the other five were the class this change has been caught by at every gate: prose or a docstring claiming more than is true. THE TEST DEFECT. The self-test that claims to pin `wait_for` joining its retained drain threads did not pin it, and said in its own docstring that it "reads the SYNCHRONISATION, not its outcome". It read `drains.len()`, which `join_drains` empties with `drain(..)` whether or not it joins — so replacing `handle.join()` with `drop(handle)` left the count at zero and the whole crate green. Measured: that mutation survived `cargo test -p unblock-cli` with exit 0 and zero failures. This was the SECOND escape of the same pin; the previous round closed "delete the call" and never covered "gut the body". The fixture now writes from a delayed grandchild a second AFTER the child exits, so an unjoined drain provably has not read those bytes yet — an observation the mutation cannot reproduce, unlike a side effect it emptied just as well. Three mutations now die: join replaced by drop, the call deleted, and the join gated on a successful exit (the fixture exits 1, which the previous one did not). THE FIVE PROSE DEFECTS. A claim that one cell was the only in-process guard when three are; stdout purity claimed for "the WHOLE command" and "EVERY path" when `--help` is carved out by decision and a shipped test asserts it; a derived count of cells and commands, wrong in both halves, replaced by a measured enumeration; and roughly twenty stale file:line anchors in the decision row, including one that pointed at a different function entirely. Every anchor in that row was re-resolved and read back, not only those the gate sampled. The gate script also had a row satisfiable by a doc comment rather than by the production line it claimed to pin; it now anchors the real line, and both mutations against that line block the gate. Discloses, at Miguel's ruling, that the relocation moves an unbounded attacker-controlled message to a MORE DURABLE sink: measured 5,000,000 bytes in, 5,000,422 bytes on stderr, stdout zero. D48 amplifies nothing — those bytes went to stdout before — but under child-per-client the host captures that stderr into logs that persist. The clip stays out of scope and is tracked, at raised priority, as ub-b1a. Refs ub-og3.
An independent read-only recheck confirmed all six Verify-gate must-fixes closed, and then found that the repair commit had written three new sentences that are false — the same class it was repairing, one round later. - A cell was described as "the only cell in the crate that covers `agents` at all". It is not: `init_agents.rs` covers that command in five places and `help_snapshots.rs` in one. The exclusivity is over this list's own predicate — an error payload on stdout — not over the command, and the sentence now says so. This is the same exclusivity defect the round was written to retire, inside the paragraph that retired it. - The rewritten drain cell claimed its predecessor "read the count and nothing else". The predecessor did also assert the late bytes; under a fixture whose child exited immediately that assertion was VACUOUS, not absent. Vacuous and absent are different failures and the distinction is the whole lesson here, so both the cell and its harness twin now state it. - The gate script justified its deliberate row-numbering gap by "shifting fifteen of them". Thirteen rows follow the gap. The count is replaced by "every row that follows it" rather than corrected — a derived count in prose has now rotted in this repository often enough that the enumeration, or no number at all, is the only stable form. Refs ub-og3.
The Track step's two duties, in the same commit as the work (PROCESS sections 6 and 8). The re-export is what turns the D48 gate script from red to green: it required the four residual issues this decision names — ub-kp7, ub-b1a, ub-c5o and ub-5v5 — to actually exist in the git-backed record, and until now they lived only in the local database. That is the check doing exactly what it was minted for, and it went green for the right reason rather than by being weakened. The run-report records what a future session would otherwise have to rediscover: that the defect was five routes wide rather than the one its issue reported and that the likeliest one leaves stderr completely empty; that a pre-initialize ping is answered, which corrected an error the orchestrator had already propagated into three artifacts; that every gate in this task failed on the same class, prose claiming more than is true, and that each repair round wrote new prose carrying new claims; and that a pin must observe something its mutation cannot fabricate, which the first drain-join fix did not. Refs ub-og3.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes the stdout framing-channel defect
ub-og3as decision D48.The defect
unblock mcprendered every failure through the generic CLI exit boundary, whose machine-format arm writes theStructuredErrorpayload to stdout. On the MCP stdio transport stdout is the JSON-RPC framing channel, so a client parsing frames met an unparseable line exactly where a frame belongs — the server's own diagnostic corrupting the channel it was diagnosing.The issue reported one route. Measurement found five, and the likeliest is the most mundane: a committed
.mcp.jsonwhose cwd and discovery tier both miss. The workspace open then fails before the startup binding line is emitted, so stdout carries the blob and stderr is completely empty — the operator gets nothing on the one stream that could have carried it.The fix
The exit boundary learns that a command owns stdout as a protocol channel. The classification is an exhaustive two-valued match over the parsed subcommand with no wildcard arm, so a future subcommand fails to compile until its author classifies it. The full structured document moves to stderr — not a degraded one-liner, because the
hintmember is exactly what the schema-mismatch case carries.Exit codes do not move. A failed handshake keeps 1; pre-run-loop failures keep their own codes. Only the channel changes.
Stated over the property, not the command name, so a future protocol-owning command inherits the rule instead of re-deriving it.
Measured, before and after
NOT_INITIALIZED--dirat a non-workspaceCONFIG_ERRORUNBLOCK_OUTPUT_FORMATCONFIG_ERRORDATABASE_ERRORinitializefirst frame (notification)INTERNAL_ERRORinitializefirst frame (request)INTERNAL_ERRORUnchanged:
pingbeforeinitializeis still answered and the handshake proceeds; clean EOF still exits 0;versionandmigratestill write their payloads to stdout.Gates — read this before merging
Both gates failed and neither was closed by a clean re-pass. This is stated rather than buried.
Essentially every finding across all three rounds was one class: prose or a docstring claiming more than is true. The mechanism was correct from the first attempt.
Mutation evidence
17 named mutations applied individually — all 17 killed, including the swapped-sink mutation, which compiles, leaves every in-process cell green, and is caught only by the spawning end-to-end cells. That prediction was verified rather than trusted.
The mutation pass then invented more and found five survivors, all in the shared harness oracle layer — the one layer with no self-test of its own. Among them: dropping the payload's trailing newline, and the fix for a fail-silent race being itself unpinned. That race pin escaped twice: the first repair observed the handle vector being emptied, which the mutation reproduced exactly. The working version observes a grandchild writing a second after the child exits — something the mutation cannot fabricate.
Residuals — named, not implied closed
ub-kp7— a first frame that is neitherinitializenorpingstill kills the server.ub-b1a— the relocated message still embeds an unboundedDebugrendering of client bytes. Raised to high priority: measured 5,000,000 bytes in → 5,000,422 on stderr. D48 amplifies nothing (those bytes went to stdout before) but moves them to a sink that persists under child-per-client.ub-c5o—output::emit_reportstill writes to stdout with no classification (latent; no live path reaches it).ub-5v5— no response-size cap; reasoned from source, never reproduced, and stated in that register.Also in this PR
Mints
scripts/checks/d48-stdout-channel-claims.sh. It was red until the Track re-export, because it requires the four residual issues to exist in the git-backed record — the gate doing exactly what it was minted for, going green for the right reason rather than by being weakened.unblock mcp --helpis ruled out of scope in the decision text: the server never starts on that path, so the framing channel is never live.CI mirror green locally: fmt, clippy
-D warnings,cargo test --workspace, insta, doc-lint, check-layering, knowledge-lint,--no-default-features, and all six check scripts.