Skip to content

item 6b: the outcome table and header-body validation - #8

Merged
arpanghoshal merged 2 commits into
mainfrom
item-6b-mcp-outcome
Sep 3, 2026
Merged

arpanghoshal merged 2 commits into
mainfrom
item-6b-mcp-outcome

Conversation

@arpanghoshal

Copy link
Copy Markdown
Member

Second of four stages for item 6. SPEC-v0.2 §6.8, §6.4, §6.2, §6.6 — acceptance tests T20 and T24.

This is the safety core, and it is pure functions on purpose: the tables can be read against the spec line by line with no HTTP server in the way. No sockets in this PR. 6c gives them one.

outcome.py — §6.8 and nothing else

The asymmetry is the product. Only two observations assert the remote did nothing:

  • the connection was never established, so no request byte can have been written;
  • the peer said, in band and before dispatch, that it refused.

Everything after the first byte is AMBIGUOUS — including -32603, including a tool's isError, and including a resultType the gateway doesn't recognize. That last one is deliberately against the client rule: the revision tells clients to read an absent resultType as complete for compatibility with older servers, and the gateway refuses those servers at the version check, so on a response it accepts, absence means malformed — and a malformed answer about a consequential action is an unknown outcome, not a success.

not_executed_on_error applies to a tool-level isError and to nothing else — not to a JSON-RPC error, not to a response that couldn't be parsed. The operator asserted something about errors, not about answers nobody can read.

One test asserts the shape rather than a row: exactly four observations in the whole table are FAILED, and eight named post-dispatch ones are all AMBIGUOUS. That's the inversion this product exists to prevent, pinned as an invariant.

mcp.py — §6.4

The headers are checked; the body is believed. Every body is parsed, every mirrored header validated against it, and interception is decided from the body's method and params.name. Taking the shortcut the headers exist for is precisely the hazard the mirroring rule was written to prevent.

Validation is conditional on the headers existing — required on 2026-07-28, absent-is-fine on the 2025 revisions — but a header that is present is validated on every revision, because a header disagreeing with the body is the hazard regardless of what the revision says about mirroring.

The =?base64?…?= sentinel is decoded before comparing, and one that won't decode is a refusal rather than a comparison against the wrapper. Integers compare numerically, or every well-formed request annotating a numeric parameter would be refused.

find_unrepresentable returns the JSON pointer to the first float anywhere in the arguments (§6.6). No rounding, no truncation, no coercion: 0.1 and 0.10 are the same money and different hashes.

Mutation table — 35 mutations, all red

# Group Result
M1–M3 the transport rows, incl. the inversion (post-send → FAILED) red
M4–M9 result rows: committed, isError, not_executed_on_error, unrecognized resultType, input_required red
M10–M11 the pre-dispatch set is closed; those codes are FAILED red
M12–M14 401/403 rows; relayed rows return the upstream's own response red
M15–M20 revisions, size limit, JSON, batch, JSON-RPC shape red
M21–M30 every header–body rule, the sentinel, numeric comparison, the legacy conditionality red
M31–M35 interception from the body; response bodies; float detection red

Four started green, and three were the same defect as 6a's — a guard subsumed by a later one that refuses identically:

  • the JSON-array refusal and the generic "not a JSON-RPC message" refusal share a status and code;
  • a malformed sentinel refusal and a value-mismatch refusal share a status and code.

Both are worth keeping — a batch should be refused for being a batch, and an undecodable sentinel is a header the gateway cannot validate rather than one that disagrees — so the tests now assert the messages, which are the only observable difference. The sentinel one matters beyond diagnostics: comparing an undecodable wrapper against the body would also accept a body whose value happened to equal the wrapper text.

  • the jsonrpc == "2.0" check had no test of its own at all. Every malformed body in the parametrized table was also caught by a later guard with the same code. Two tests now isolate it with bodies well-formed in every other respect.

The fourth was dead code: isinstance(arguments, bool) in find_unrepresentable guarded against nothing, because bools are ints and isinstance(True, float) is already False. Removed, and M34 now proves that a float check which did catch bools would be caught.

That's four items running where subsumed guards were the dominant finding. The pattern is worth naming: a guard that can only fire when a later guard would also fire is documentation, not defence — and only a mutation table tells you which one you wrote.

Verification

1034 tests pass (931 → 1034), mypy --strict src/, ruff check and ruff format --check clean. import ctrlrun still pulls in neither httpx nor ctrlrun.gateway — tests/test_packaging.py checks it in a subprocess.

Remaining: 6c server.py (T19, T21, T22, T23, T25, and T20/T24's end-to-end halves), 6d elicitation + legacy passthrough (T26, T30b).

SPEC-v0.2 §6.8, §6.4, §6.2, §6.6. Second of four stages for item 6, and the
safety core: pure functions, so the tables can be read against the spec line
by line without an HTTP server in the way.

outcome.py is §6.8 and nothing else. Its asymmetry is the product: only two
observations assert the remote did nothing — the connection was never
established, so no request byte can have been written, and the peer said in
band, before dispatch, that it refused. Everything after the first byte is
AMBIGUOUS, including -32603, including a tool's isError, and including a
resultType the gateway does not recognize. That last one is deliberately
against the client rule: the revision tells clients to read an absent
resultType as complete for compatibility with older servers, and the gateway
refuses those servers at the version check, so on a response it accepts,
absence means malformed.

not_executed_on_error applies to a tool-level isError and to nothing else —
not to a JSON-RPC error, and not to a response that could not be parsed. An
operator asserted something about errors, not about answers nobody can read.

mcp.py is §6.4: the headers are checked and the body is believed. Every body
is parsed, every mirrored header validated against it, and interception is
decided from the body's method and params.name. Validation is conditional on
the headers existing — required on 2026-07-28, optional on the 2025 revisions
— but a header that is present is validated on every revision, because a
header disagreeing with the body is the hazard §6.4 is about regardless of
what the revision says about mirroring.

The base64 sentinel is decoded before comparing, and one that will not decode
is a refusal rather than a comparison against the wrapper: an unvalidatable
header on a request about to be forwarded fails closed. Integers compare
numerically, or every well-formed request annotating a numeric parameter would
be refused.

find_unrepresentable returns the JSON pointer to the first float anywhere in
the arguments (§6.6). No rounding, no truncation, no coercion: 0.1 and 0.10
are the same money and different hashes.

T20's and T24's remaining halves — that the upstream's request count stays
zero, and that its own response reaches the client unchanged — need a socket,
and land with 6c.
Mutation-testing §6.4 found the pattern again, and one piece of genuinely dead
code.

The JSON-array refusal and the malformed-sentinel refusal are each subsumed by
a later guard returning the same status and code, so removing either changed
nothing the tests could see. Both are still worth having — a batch should be
refused for being a batch, and a sentinel that will not decode is a header the
gateway cannot validate rather than one that disagrees — so the messages are
what the tests now assert. Comparing an undecodable wrapper against the body
would refuse for the wrong reason *and* accept a body whose value happened to
equal the wrapper text.

The `jsonrpc == "2.0"` check had no test of its own at all: every malformed
body in the table was also caught by a later guard with the same code. Two
tests now isolate it with bodies well-formed in every other respect.

And `isinstance(arguments, bool)` in find_unrepresentable was dead: bools are
ints, not floats, so `isinstance(True, float)` is already False. Removed, with
a mutation proving a float check that *did* catch bools would be caught.
@arpanghoshal
arpanghoshal merged commit da2a26a into main Sep 3, 2026
3 checks passed
@arpanghoshal
arpanghoshal deleted the item-6b-mcp-outcome branch September 3, 2026 18:42
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