Repository navigation
item 6b: the outcome table and header-body validation - #8
Merged
Merged
Conversation
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.
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.
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 elseThe asymmetry is the product. Only two observations assert the remote did nothing:
Everything after the first byte is
AMBIGUOUS— including-32603, including a tool'sisError, and including aresultTypethe gateway doesn't recognize. That last one is deliberately against the client rule: the revision tells clients to read an absentresultTypeas 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_errorapplies to a tool-levelisErrorand 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 allAMBIGUOUS. That's the inversion this product exists to prevent, pinned as an invariant.mcp.py— §6.4The 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
methodandparams.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_unrepresentablereturns the JSON pointer to the firstfloatanywhere in the arguments (§6.6). No rounding, no truncation, no coercion:0.1and0.10are the same money and different hashes.Mutation table — 35 mutations, all red
FAILED)isError,not_executed_on_error, unrecognizedresultType,input_requiredFAILEDFour started green, and three were the same defect as 6a's — a guard subsumed by a later one that refuses identically:
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.
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)infind_unrepresentableguarded against nothing, because bools are ints andisinstance(True, float)is alreadyFalse. 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 checkandruff format --checkclean.import ctrlrunstill pulls in neitherhttpxnorctrlrun.gateway—tests/test_packaging.pychecks 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).