Skip to content

fix(d48): the mcp structured error leaves the JSON-RPC framing channel (ub-og3) - #439

Merged
miguelramos merged 6 commits into
mainfrom
ub-og3-mcp-stdout-channel
Aug 7, 2026
Merged

fix(d48): the mcp structured error leaves the JSON-RPC framing channel (ub-og3)#439
miguelramos merged 6 commits into
mainfrom
ub-og3-mcp-stdout-channel

Conversation

@miguelramos

Copy link
Copy Markdown
Member

Closes the stdout framing-channel defect ub-og3 as decision D48.

The defect

unblock mcp rendered every failure through the generic CLI exit boundary, whose machine-format arm writes the StructuredError payload 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.json whose 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 hint member 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

Failure route stdout non-frame stderr exit
walk-up workspace miss 0 NOT_INITIALIZED 2
explicit --dir at a non-workspace 0 CONFIG_ERROR 7
rejected UNBLOCK_OUTPUT_FORMAT 0 CONFIG_ERROR 7
corrupt database 0 DATABASE_ERROR 2
non-initialize first frame (notification) 0 INTERNAL_ERROR 1
non-initialize first frame (request) 0 INTERNAL_ERROR 1

Unchanged: ping before initialize is still answered and the handshake proceeds; clean EOF still exits 0; version and migrate still 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.

  • Design Review: FAIL (15 must-fixes), FAIL again (4, two of them introduced by that round itself). Closed by an orchestrator-applied third pass under the PROCESS §5 escalation, with Miguel deciding.
  • Verify: FAIL (6 must-fixes). A full re-gate did not fit the session budget, so Miguel ruled a narrow independent read-only recheck instead. It confirmed all six closed and found three further false sentences written by the repair round, which were then verified against source and corrected by hand.
  • Track ran solo rather than as a team, for the same reason.

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 neither initialize nor ping still kills the server.
  • ub-b1a — the relocated message still embeds an unbounded Debug rendering 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-c5ooutput::emit_report still 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 --help is 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.

Miguel Ramos 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.
@miguelramos
miguelramos merged commit 9adc2cb into main Aug 7, 2026
23 checks passed
@miguelramos
miguelramos deleted the ub-og3-mcp-stdout-channel branch August 7, 2026 09:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant