Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,22 @@ jobs:
# code and not our taxonomy. Positive-only by design: a negative sweep for retired framing is
# dodged by a rewrapped line. Specified in ci-cd-and-distribution.md §2.1 (the D47 sub-check).
- run: scripts/checks/d47-envelope-id-claims.sh
# D48 (`ub-og3`) required landings: the PROTOCOL-CHANNEL carve-out — `unblock mcp` reports its
# structured error on STDERR, because its stdout IS the JSON-RPC framing channel. Its teeth are
# executable (injected-sink cells, a spawning suite, two INVERTED lifecycle cells), but three of
# its obligations are unreachable from any test: the SINGLE-CALLER invariant its blast-radius
# argument rests on, the four residual ids the PRD row names, and a rendered document
# (`docs/roadmap.html`) that sits outside every lint corpus here. This step asserts the positive:
# the classification type, its classifier and its consumer (the two-file co-occurrence — either
# half alone is a fix that does nothing), the carve-out arm itself, the sink-injected core, the
# spawning regression file, the hardened framing guard and the format-env scrub, `ub-og3` plus
# all four residual ids in the tracker export, the rendered roadmap's v1.0.1 card, this gate's
# own wiring, and the live D-range at every file the count-free `docs/PROCESS.md` §3 enumeration
# names — plus the contract version standing UNMOVED, since D48 moves a stream and mints no
# `ErrorCode`. It also checks two RELATIONS no row can express: exactly one `into_exit` caller,
# and every `Command` variant classified (a `_` arm would stop the compiler asking). Positive-only
# by design. Specified in ci-cd-and-distribution.md §2.1 (the D48 sub-check).
- run: scripts/checks/d48-stdout-channel-claims.sh
# k1..k6: .knowledge structural lint — SEPARATE corpus from the 19-file normative lint (ci-cd §2.3)
- run: cargo xtask knowledge-lint
# Executable proof of the shared gate predicate — every rule arm, exits 0/1/2.
Expand Down
1 change: 1 addition & 0 deletions .knowledge/wiki/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ Descriptive only — never normative (PRD > spine > crate plans is unchanged).
- [2026-08-03-comments-forward-migration](runs/2026-08-03-comments-forward-migration.md) — Repairing the missing forward migration for the comments columns (decision D46) — a database no shipped binary could read, an issue whose own proposed fix provably could not work, five design-gate rounds, and a Verify gate that caught the repair's own error message lying about the state it names.
- [2026-08-04-label-diff-base-hydration](runs/2026-08-04-label-diff-base-hydration.md) — Hydrating the label diff base inside the update transaction (tracker ub-lp9.27) — a before-set that was always empty, so a label removal succeeded while removing nothing and an already-present label add died on a primary key; the production fix then passed every later gate untouched while four adversarial rounds kept finding prose, tests and docs claiming more than was true.
- [2026-08-06-envelope-id-reject](runs/2026-08-06-envelope-id-reject.md) — Answering an un-decodable envelope id instead of losing the frame (decision D47, tracker ub-cnv) — a request that vanished into the notification variant and hung its client, of which duplication was only the minority route; four binding rulings, two impossibilities escalated rather than absorbed, a delta-verify that failed inside the repair of its own findings, an implementation split across a session limit, and a mutation matrix measured by a lens the implementer never saw.
- [2026-08-07-mcp-stdout-framing-channel](runs/2026-08-07-mcp-stdout-framing-channel.md) — Moving the mcp structured error off the JSON-RPC framing channel (decision D48, tracker ub-og3) — a defect five times wider than its report, four residuals split out rather than absorbed, two design-gate failures and a Verify failure that were all the same class of defect, a mutation pass that found five unpinned claims nobody wrote down, and a race-fix that had to be pinned twice.

## Topics

Expand Down
133 changes: 133 additions & 0 deletions .knowledge/wiki/runs/2026-08-07-mcp-stdout-framing-channel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
---
name: 2026-08-07-mcp-stdout-framing-channel
description: Moving the mcp structured error off the JSON-RPC framing channel (decision D48, tracker ub-og3) — a defect five times wider than its report, four residuals split out rather than absorbed, two design-gate failures and a Verify failure that were all the same class of defect, a mutation pass that found five unpinned claims nobody wrote down, and a race-fix that had to be pinned twice.
type: run
date: 2026-08-07
branch: ub-og3-mcp-stdout-channel
pr: '-'
issues: [ub-og3, ub-kp7, ub-b1a, ub-c5o, ub-5v5, ub-gwe]
---

# Run — the mcp stdout framing channel (D48)

## Context

Task `ub-og3` — `unblock mcp` wrote its `StructuredError` payload onto STDOUT, which on the MCP
stdio transport is the JSON-RPC framing channel, so a client parsing frames met an unparseable line
exactly where a frame belongs. Taken as the active v1.0.1 patch-slot item off `main` at 7790db0
(post-D47). Full lifecycle in one session: Understand, Decide, Spec, design Review (two failed
rounds plus an orchestrator-applied third pass under a PROCESS section 5 escalation), Implement,
Verify (one failed round plus a narrow independent recheck), Track. Teams were hand-picked per
phase — four lenses plus a coordinator for Understand and for each gate, single implementers for
the writing phases, every one in an isolated worktree.

## What & why

The issue reported one reproduction: a non-`initialize` first frame killing the server with a
non-frame blob on stdout. The orchestrator re-measured before briefing anyone, and the defect was
wider in two directions at once.

WIDER IN ROUTES. The blob was not one path but a shared terminal renderer reached by every failure
of the command. The likeliest real-world hit was the most mundane and had never been suspected: a
committed `.mcp.json` whose cwd and discovery tier both miss, so the workspace open fails BEFORE the
startup binding line is emitted — stdout carries the blob and **stderr is completely empty**. A
corrupt database, a schema newer than the build and a rejected output-format environment variable
all land the same way.

NARROWER IN CLASS THAN ITS OWN TEXT. The issue asserted that a clean non-`initialize` REQUEST in
that position is answered normally. Measured: it is not. Later, at the design gate, the orchestrator's
own correction of that was itself found wrong — a pre-`initialize` `ping` IS answered and the server
lives on to a normal handshake, which rmcp implements deliberately because the MCP lifecycle
specification permits it. The false version had by then reached three artifacts.

Specs read rather than restated: PRD section 4 (D27, D31, D35, D38, D39, D40, D43, D46, D47), NFR-14
and FR-11 in PRD section 6, the design spine sections 2.3, 2.4 and 5b, the `unblock-cli` crate plan,
and PROCESS section 3 for the decision-identity question.

Decision D48 was minted rather than an addendum: PRD and spine did not merely omit the case, they
affirmatively RULED that FR-11's always-valid-JSON-**on-stdout** rule binds the unsignalled error
path. PROCESS section 3 sends a clause reversal falling out of a new decision to that decision's own
D-id.

## Outcome

LANDED, five commits: the doc-only spec cascade with the D48 row and the D-range bump at every live
site; the exit-boundary role seam; the harness oracle pins a mutation pass proved missing; the
drain-join pin; and three corrections to claims the repair round itself introduced.

FOUR RESIDUALS SPLIT OUT rather than absorbed, each named in the decision row: `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 when
the amplification was measured), `ub-c5o` (`output::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).

GATE VERDICTS. Design Review round 1: FAIL, 15 must-fixes and 10 should-fixes. Round 2: FAIL, 4
must-fixes, two of them introduced by round 2 itself — which is why it was closed by hand under a
PROCESS section 5 escalation rather than looped a third time. Verify round 1: FAIL, 6 must-fixes.
The narrow recheck that followed confirmed all six closed and found three further false sentences
written by the repair.

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, a prediction that was verified rather than trusted. The mutation pass 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.

MEASURED FINAL BEHAVIOUR, orchestrator-run against the built binary: all six reachable failure routes
put zero non-frame bytes on stdout and the full structured document on stderr, newline-terminated,
with exit codes unmoved (2, 7, 7, 2, 1, 1); `ping` still answered, clean EOF still exit 0, and
`version` and `migrate` still writing their payloads to stdout.

PROCESS DEVIATIONS, disclosed rather than absorbed: the design gate was closed by an
orchestrator-applied pass instead of a third team round (Miguel ruled it); the Verify gate was closed
by a narrow single-agent recheck plus orchestrator measurement instead of a full re-gate, because the
session budget would not carry one (Miguel ruled it); and Track ran solo rather than as a team, for
the same reason.

## Gotchas

- A pipeline masks the exit status: `cargo test --workspace | tail -25` reported exit 0 from `tail`
while cargo's own status was unknown. Re-run capturing the command's own status before believing a
green.
- Every isolated worktree builds its own `target/`. Thirteen worktrees reached ~72 GB inside
`.claude/worktrees/` and took the machine to 20 GiB free. Remove each worktree as soon as its patch
is absorbed, not at the end.
- A probe that runs `unblock init` in the wrong directory leaves a stray workspace that later
walk-up probes then FIND, silently invalidating them — here at `/private/tmp/.unblock`.
- The knowledge bash-guard fails closed on heredoc quoting: write commit messages to a file with the
Write tool and use `git commit -F`.
- The measured amplification is one-for-one and uncapped: a 5,000,000-byte method name produces
5,000,422 bytes on stderr. D48 amplifies nothing — those bytes went to stdout before — but under
the D31 child-per-client topology it moves them to a sink that PERSISTS.
- The self-test written to pin the drain join observed `drains.len()`, which `join_drains` empties
with `drain(..)` whether or not it joins — so the mutation reproduced the observation exactly. A
pin must observe something the mutation CANNOT fabricate; the working version uses a grandchild
that writes a second after the child exits.

## Glossary

No session-local ids appear in this report or in this run's issue comments: every gate finding was
relayed to Miguel and recorded in the tracker by its plain description plus a `file:line`, and the
mutation names used in the issue comments are the source edits themselves rather than codes.

| id | what it is (in words) | where it lives (file:line / doc § / issue id) |
|----|-----------------------|-----------------------------------------------|
| — | none coined | — |

## Links

- `ub-og3` — the stdout framing-channel defect this run closes.
- `ub-kp7` — a first frame that is neither `initialize` nor `ping` kills the server.
- `ub-b1a` — the unbounded `Debug` rendering of client bytes inside the relocated message.
- `ub-c5o` — `output::emit_report` writes to stdout with no notion of a stdout-owning command.
- `ub-5v5` — no response-size cap; a large frame may be written truncated.
- `ub-gwe` — make the codebase graph a stated first move for structural questions (raised in this
run, when it emerged the graph tooling had never been used and was never indexed).
- Key files: `/Users/ramosmig/Public/WS-Labs/unblock/crates/unblock-cli/src/exit.rs`,
`/Users/ramosmig/Public/WS-Labs/unblock/crates/unblock-cli/src/cli.rs`,
`/Users/ramosmig/Public/WS-Labs/unblock/crates/unblock-cli/tests/mcp_stdout_channel.rs`,
`/Users/ramosmig/Public/WS-Labs/unblock/crates/unblock-cli/tests/common/mod.rs`,
`/Users/ramosmig/Public/WS-Labs/unblock/scripts/checks/d48-stdout-channel-claims.sh`.
- Prior related run-report: `runs/2026-08-06-envelope-id-reject.md` (D47 — the decision whose
residual clause named this defect).
9 changes: 7 additions & 2 deletions .unblock/issues.jsonl

Large diffs are not rendered by default.

7 changes: 4 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ codes are stable, a breaking change → 2.0.0. (Original source for reference on

| Doc | Role |
|---|---|
| `docs/PRD.md` | Product truth — decisions (§4 D1..D47), FR/NFR, domain model, milestones. **APPROVED v1.1.** |
| `docs/PRD.md` | Product truth — decisions (§4 D1..D48), FR/NFR, domain model, milestones. **APPROVED v1.1.** |
| `docs/plans/01-design-spine.md` | **Authoritative interface contract** (types, `Storage` trait, `Session` API, MCP schemas, errors). |
| `docs/plans/implementation-plan.md` | Task DAG M0–M3 (T-ids) + acceptance criteria. |
| **unblock (MCP)** — tracker | **System of record for tasks** (dogfooded) — next-ready/status/deps live here, not in a doc. Git record: `.unblock/issues.jsonl`; wiring + tool surface: `.mcp.json` / `AGENTS.md` / `unblock://capabilities`. |
Expand Down Expand Up @@ -68,8 +68,9 @@ session** by default (template: `docs/plans/templates/drift-gap-report.md`).
discovery input** (injected by Claude Code as the project root; consumed by workspace discovery, never emitted) —
DISTINCT from the `UNBLOCK_` prefix family (D10), honored as a ROOT-probe tier below `--dir`/`UNBLOCK_DIR` (D39).
- Crates `unblock-*` are **workspace-internal (not published to crates.io)**; only the `unblock` binary ships (via `dist`).
- Config is **TOML**. Output: **structured to stdout, diagnostics to stderr** (NFR-14); output shapes are
snapshot-pinned (`insta`).
- Config is **TOML**. Output: **structured to stdout, diagnostics to stderr** (NFR-14) — **D48 carve-out: a
command whose stdout is a protocol channel (`unblock mcp`, once the server starts) reports on stderr instead,
full payload, same exit code (PRD §4 D48)**; output shapes are snapshot-pinned (`insta`).
- **No git operations, no git library linked, no network on any normal command path** (D13/NFR-6). Network only
on explicit `unblock update` (axoupdater runs the dist installer; SHA256-checksum-verified before swap). libsql `remote` feature is **off by default**.
- libsql is the source of truth; JSONL is an **optional** export/import (no 3-way merge, no locks) — model B (D5).
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,10 @@ Command-specific flags: `init` takes `--prefix <PREFIX>` and `--force`; `update`
**Exit codes** — a stable 0–8 contract: `0` ok · `1` internal · `2` workspace/db · `3` not-found/id ·
`4` validation · `5` dependency/cycle · `6` sync/path · `7` config · `8` io/json. The full
code → exit → retryable table is in the generated `AGENTS.md` and in `unblock://capabilities`.
Structured output goes to **stdout**; diagnostics go to **stderr** (NFR-14).
Structured output goes to **stdout**; diagnostics go to **stderr** (NFR-14). One carve-out: `unblock mcp` owns
stdout as the MCP JSON-RPC framing channel, so **once the server starts** it writes nothing there but frames —
its structured errors go to stderr too, whole and machine-readable (the exit code is the same either way).
`unblock mcp --help` never starts the server, so it keeps its usage text on stdout like every other subcommand.

## Self-update

Expand Down
72 changes: 72 additions & 0 deletions crates/unblock-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@
//! commands; the flag still forwards via `CliOverrides.output_format` so `--flag > env` holds inside
//! config's resolver (spine §5b / §1807; the no-workspace `version` path reads the env leniently in
//! `output::pick_cli_format`). See `docs/plans/crates/unblock-cli.md`.
//!
//! Since D48 this module also owns ONE channel fact: [`Command::stdout_role`] classifies each
//! subcommand by what its STDOUT *is* — its own report channel, or a wire-protocol framing channel —
//! which the exit boundary reads to pick the stream its structured error renders to.

use std::path::PathBuf;

Expand All @@ -17,6 +21,8 @@ use unblock_config::CliOverrides;
use unblock_model::OutputFormat;
use unblock_render::parse_format;

use crate::exit::StdoutRole;

/// The top-level `unblock` command (lifecycle/ops only — D3).
#[derive(Debug, Parser)]
#[command(
Expand Down Expand Up @@ -115,6 +121,33 @@ pub enum Command {
Update(UpdateArgs),
}

impl Command {
/// D48: what THIS subcommand's stdout is — its own report channel, or a protocol framing one.
///
/// Read at `lib.rs` while the parsed `Cli` is still in scope (`dispatch::dispatch` CONSUMES it,
/// so the fact cannot be recovered afterwards) and threaded into `exit::into_exit`, which is the
/// SINGLE terminal renderer for every `Err` a command returns.
///
/// **The match is EXHAUSTIVE with no `_` arm, and that is normative (PRD §4 D48 clause 2).** A
/// new subcommand must fail to COMPILE here until its author classifies it: that forces the
/// question. Nothing checks the answer — which is stated here rather than left to be discovered.
/// The `Update` arm is `cfg`-gated because the VARIANT is; a wildcard would build under
/// `--no-default-features` at the cost of silently classifying every future subcommand.
pub(crate) fn stdout_role(&self) -> StdoutRole {
match self {
// `unblock mcp` speaks MCP over stdio: fd 1 IS the JSON-RPC framing channel.
Self::Mcp(_) => StdoutRole::Protocol,
Self::Migrate(_)
| Self::Doctor(_)
| Self::Version(_)
| Self::Init(_)
| Self::Agents(_) => StdoutRole::Reports,
#[cfg(feature = "self-update")]
Self::Update(_) => StdoutRole::Reports,
}
}
}

/// `unblock mcp` — no v1 flags (the MCP surface is fixed; instructions are generated).
#[derive(Debug, Args)]
pub struct McpArgs {}
Expand Down Expand Up @@ -239,6 +272,45 @@ mod tests {
assert!(overrides.db.is_none());
}

/// **D48 — the ONLY unit-layer pin of the classifier.** Every subcommand declares what its
/// stdout IS, and `mcp` is the one whose stdout is a wire-protocol framing channel.
///
/// It kills a blanket classifier (one returning `Protocol` for everything); it does NOT kill a
/// flipped literal at the CALL SITE in `lib.rs`, which the classifier never sees — that one is
/// carried by the end-to-end suite, which is why D48 clause (7) requires both layers.
#[test]
fn each_subcommand_declares_its_stdout_role() {
use crate::exit::StdoutRole;

let role = |args: &[&str]| {
Cli::try_parse_from(args)
.unwrap_or_else(|e| panic!("parse {args:?}: {e}"))
.command
.stdout_role()
};

assert_eq!(
role(&["unblock", "mcp"]),
StdoutRole::Protocol,
"`unblock mcp` speaks MCP over stdio: fd 1 is the JSON-RPC framing channel (D48)"
);
for args in [
["unblock", "migrate"],
["unblock", "doctor"],
["unblock", "version"],
["unblock", "init"],
["unblock", "agents"],
] {
assert_eq!(
role(&args),
StdoutRole::Reports,
"{args:?} owns stdout as its OWN report channel and is byte-unchanged by D48"
);
}
#[cfg(feature = "self-update")]
assert_eq!(role(&["unblock", "update"]), StdoutRole::Reports);
}

#[test]
fn version_short_flag_parses() {
let cli =
Expand Down
Loading
Loading