Skip to content

item 6c: the gateway in the execution path - #9

Merged
arpanghoshal merged 2 commits into
mainfrom
item-6c-gateway-server
Sep 3, 2026
Merged

arpanghoshal merged 2 commits into
mainfrom
item-6c-gateway-server

Conversation

@arpanghoshal

Copy link
Copy Markdown
Member

Third of four stages for item 6. SPEC-v0.2 §6.1, §6.3, §6.5–§6.8, §6.10 — acceptance tests T19, T21, T22, T23, T25, and the end-to-end halves of T20 and T24 that 6b could only do as pure functions.

An MCP client points here instead of at the tool server. tools/call becomes an Action; everything else is relayed. No agent changes.

The upstream in these tests is a real HTTP server on a real socket, because the guarantees are about what happens between two processes: that the upstream is never called for a refused request, that what it receives is byte-for-byte what was hashed, and that a lost response blocks the retry.

§6.8 doesn't get its own outcome model

The executor translates the table into v0.1 §5.5's own vocabulary — return for COMMITTED, raise NotExecuted for FAILED, raise anything else for AMBIGUOUS — and Control then applies the rules it has always applied. The gateway does not get a second way of deciding what happened, and a lost response over HTTP blocks a retry for exactly the reason a lost response in-process does.

§6.7's canonical forwarding is the other half: the request sent upstream is built from the action's canonical arguments, never copied from the received body. A gateway relaying the original bytes would be binding an approval to one document and executing another.

A fresh connection per intercepted call, because "never established" is the only claim in §6.8 that asserts non-execution, and a pooled connection closed while idle fails on write — indistinguishable from a request that arrived.

Two decisions I'd want you to look at

A 401 is classified from the status, before the body is parsed. A peer may answer 401 with any payload it likes, including a well-formed JSON-RPC result — my first version parsed that as a committed tool call. The status is the load-bearing part: the resource server validated the token before dispatch, so nothing reached the tool. The test caught it.

§6.10 leaves a case open, and I took the fail-closed reading. The spec says a denied request must not be re-asked until it expires. It doesn't say what happens when no approval exists and the effect key would refuse anyway. Asking a human to approve an action that could not run if they said yes wears the same approver down for the same reason — so the refusal the reservation would give is given now, before anyone is troubled by it. Marked # SPEC: §6.10. It only ever refuses more. This is also what makes the spec's stated expectation for T25's third call (-41004) come out right.

Mutation table — 30 mutations, all red

# Group Result
M1–M5 Origin, interception, principal (incl. no receipt/no events) red
M6–M8 action naming, float refusal, its evidence red
M9 canonical arguments reach the upstream red
M10–M12 the inversion, both directions: FAILED↔AMBIGUOUS red
M13–M17 401-before-body, verbatim relay, receipt _meta, not_executed_on_error red
M18–M23 the whole §6.10 code table red
M24–M26 refusals never forward; policy templates supply resource and effect key red
M27–M30 what the config refuses at startup red

One started green, and it's a consequence of the decision above. The except DuplicateEffect handler had no test at all: my new pre-approval check catches an approved duplicate before Control ever raises, so every T25 path went around it. An allowed action goes all the way to the reservation, which is where v0.1 §5.4 refuses it — that test now exists.

Worth naming as its own pattern, distinct from the subsumed guards of the last four items: adding an earlier check can orphan a later handler, and the tests that covered the behaviour keep passing because the outcome is unchanged. Only the mutation shows the handler is now unreachable from any test.

Also

  • ctrlrun gateway joins the CLI, and prints at startup the actions with no effect: template (§3.2) — a write with no effect key should be visible on the line that starts the process, not discovered in a receipt three weeks later.
  • CI installs .[dev,gateway] in both jobs. T30 still proves in a subprocess that import ctrlrun touches neither httpx nor ctrlrun.gateway, installed or not.
  • ANN401/N818 are ignored for src/ctrlrun/gateway/*.py with a written justification: the subject matter is untyped JSON off a socket, and the error names follow this codebase's convention (DuplicateEffect, AmbiguousEffect) rather than the suffix rule.

Verification

1082 tests pass (1034 → 1082), mypy --strict src/, ruff check and ruff format --check clean.

Remaining: 6d — elicitation/MRTR held reservations, legacy passthrough, SSE resumption stripping (T26, T30b).

SPEC-v0.2 §6.1, §6.3, §6.5-§6.8, §6.10. Third of four stages for item 6:
acceptance tests T19, T21, T22, T23, T25, and the end-to-end halves of T20 and
T24 that 6b could only do as pure functions.

The shape worth noticing is how §6.8 reaches the kernel. It is not a second
outcome model: the executor translates the table into v0.1 §5.5's own
vocabulary — return for COMMITTED, raise NotExecuted for FAILED, raise
anything else for AMBIGUOUS — and Control then applies the rules it has always
applied. The gateway does not get its own way of deciding what happened, and a
lost response over HTTP blocks a retry for exactly the reason a lost response
in-process does.

§6.7's canonical forwarding is the other half of that: the request the gateway
sends is built from the action's canonical arguments, never copied from the
received body, so what was hashed, reserved and recorded is byte-for-byte what
the upstream receives. A gateway relaying the original bytes would be binding
an approval to one document and executing another.

A fresh connection per intercepted call, because "the connection was never
established" is the only claim in §6.8 that asserts non-execution and a pooled
connection closed while idle fails on write, which is indistinguishable from a
request that arrived.

Two resolutions worth reading:

An HTTP 401 is classified before the body is parsed. A peer may answer 401
with any payload it likes, including a well-formed JSON-RPC result; the status
is the load-bearing part, because the resource server validated the token
before dispatch and nothing reached the tool.

§6.10 says a denied request must not be re-asked until it expires. The spec
leaves open what happens when no approval exists and the effect key would
refuse anyway; the fail-closed reading is to give that refusal now rather than
trouble a human with a decision that could not be acted on. Marked `# SPEC:`.
It only ever refuses more.

CI installs the gateway extra. T30 still proves in a subprocess that
`import ctrlrun` touches neither httpx nor ctrlrun.gateway, installed or not.
Mutation-testing §6.10 found it: the check that will not send a human a
decision the effect key would refuse anyway catches an *approved* duplicate
before Control ever raises, so the except-DuplicateEffect handler had no test.
An allowed action goes all the way to the reservation, which is where v0.1
§5.4 refuses it, and that is the mapping under test.
@arpanghoshal
arpanghoshal merged commit ea7e9f5 into main Sep 3, 2026
3 checks passed
@arpanghoshal
arpanghoshal deleted the item-6c-gateway-server branch September 3, 2026 19:01
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