jcp-gateway: a task's exchanges and an interim result's retry are decided as continuations of the call #336

Merged
dcharlot merged 2 commits from fix/jcp-gateway-gates-tasks-and-mrtr into main 2026-10-19 11:04:50 -04:00
Owner

What was open

MCP 2026-07-28 lets a gated call answer before its work is done. Both ways it does that bypassed the gateway:

  • Task handles (resultType: "task"). tasks/get, tasks/update and tasks/cancel passed through as discovery.
    • The task's outcome is the tool's real output. It reached the model unmetered and unsealed.
    • It was never admitted to the flow store, so a value read through a task could be sent on by a later call with nothing to fold it against.
    • Any task id was relayed, whoever it belonged to.
  • Interim results (resultType: "input_required"). The retry carries the client's answers in inputResponses, but only arguments were folded. An answer carrying a value from an untrusted read reached the server past the flow ceiling.

What this changes

  • Each task exchange is a continuation of the call.

    • It is decided against the grant of the call that opened the task, at the moment it is made, so a revoked, expired or spent grant refuses it.
    • It is metered, debited, and sealed over a payload that commits to the receipt it continues.
    • Its reply is admitted as untrusted data under the join of everything the client has sent the task.
    • The request forwarded upstream is rebuilt from the decided fields. Nothing else the client adds reaches the server.
  • A task closes once. The tasks/get that brings the outcome seals a zero-cost result frame (tool-result, data) over the content address of a closing record. The record names:

    • the opening receipt and every exchange's receipt;
    • the joules metered across them, marked Partial because the server works between exchanges, outside any span;
    • the digest of the outcome.

    The record and its receipt come back in _meta.

  • Unknown or closed task ids are refused. The refusal is sealed and never reaches the server.

  • Retries are flow-checked and chained. A retry's answers are flow-checked with its arguments. Its sealed payload commits to the receipt of the interim result it answers.

  • Server-minted handles are exempt only as exact echoes. A task id, a request key or a requestState skips the flow check only when it matches exactly what the gateway saw the server mint. Otherwise a UUID handle (36 bytes, against a 16-byte scan window) would taint every poll under a ceiling. A handle the client made up is checked.

  • server/discover carries the advertisement, as initialize does. It lists the task methods and continuations: 1.

  • Bounds. Open tasks and pending interim results are capped at 1024 each, oldest task dropped first.

  • SPEC. §6.1 states the rule in protocol-neutral terms, and §10 gains a row for it.

Known limits (stated in the code)

  • Open tasks are held in memory. After a restart, polls for earlier tasks are refused (fail closed).
  • The server's work between exchanges is not metered.
  • Server-pushed notifications/tasks fall outside the request/response relay.

Not in this PR

For every gated method, params fields other than the payload (_meta, unknown keys) are still forwarded unscanned. Task methods are now rebuilt and so are covered. tools/call, resources/read and prompts/get are not. That gap predates this PR and needs its own decision about which fields to forward.

Verification (build host)

  • cargo test -p jcp-gateway --lib: 47 pass, 12 of them new, all against scripted upstreams.
  • 19 mutations of the new logic, all killed. Each one disables a single piece: interception, refusal, carried label, answer split, answer scan, echo exemptions, continuation commitments, close, discover advertisement, request rebuild, key tracking, joule sum, round end, leg record, record addressing, eviction.
  • jcp-rs workspace tests and the jcp conformance suite.
  • Repo checks: tests-in-ci, spec-refs, orphan-sources, vanity-metrics, comparative-prose.

🤖 Generated with Claude Code

## What was open MCP 2026-07-28 lets a gated call answer before its work is done. Both ways it does that bypassed the gateway: - **Task handles** (`resultType: "task"`). `tasks/get`, `tasks/update` and `tasks/cancel` passed through as discovery. - The task's outcome is the tool's real output. It reached the model unmetered and unsealed. - It was never admitted to the flow store, so a value read through a task could be sent on by a later call with nothing to fold it against. - Any task id was relayed, whoever it belonged to. - **Interim results** (`resultType: "input_required"`). The retry carries the client's answers in `inputResponses`, but only `arguments` were folded. An answer carrying a value from an untrusted read reached the server past the flow ceiling. ## What this changes - **Each task exchange is a continuation of the call.** - It is decided against the grant of the call that opened the task, at the moment it is made, so a revoked, expired or spent grant refuses it. - It is metered, debited, and sealed over a payload that commits to the receipt it continues. - Its reply is admitted as untrusted data under the join of everything the client has sent the task. - The request forwarded upstream is rebuilt from the decided fields. Nothing else the client adds reaches the server. - **A task closes once.** The `tasks/get` that brings the outcome seals a zero-cost result frame (`tool-result`, `data`) over the content address of a closing record. The record names: - the opening receipt and every exchange's receipt; - the joules metered across them, marked `Partial` because the server works between exchanges, outside any span; - the digest of the outcome. The record and its receipt come back in `_meta`. - **Unknown or closed task ids are refused.** The refusal is sealed and never reaches the server. - **Retries are flow-checked and chained.** A retry's answers are flow-checked with its arguments. Its sealed payload commits to the receipt of the interim result it answers. - **Server-minted handles are exempt only as exact echoes.** A task id, a request key or a `requestState` skips the flow check only when it matches exactly what the gateway saw the server mint. Otherwise a UUID handle (36 bytes, against a 16-byte scan window) would taint every poll under a ceiling. A handle the client made up is checked. - **`server/discover` carries the advertisement**, as `initialize` does. It lists the task methods and `continuations: 1`. - **Bounds.** Open tasks and pending interim results are capped at 1024 each, oldest task dropped first. - **SPEC.** §6.1 states the rule in protocol-neutral terms, and §10 gains a row for it. ## Known limits (stated in the code) - Open tasks are held in memory. After a restart, polls for earlier tasks are refused (fail closed). - The server's work between exchanges is not metered. - Server-pushed `notifications/tasks` fall outside the request/response relay. ## Not in this PR For every gated method, `params` fields other than the payload (`_meta`, unknown keys) are still forwarded unscanned. Task methods are now rebuilt and so are covered. `tools/call`, `resources/read` and `prompts/get` are not. That gap predates this PR and needs its own decision about which fields to forward. ## Verification (build host) - `cargo test -p jcp-gateway --lib`: 47 pass, 12 of them new, all against scripted upstreams. - 19 mutations of the new logic, all killed. Each one disables a single piece: interception, refusal, carried label, answer split, answer scan, echo exemptions, continuation commitments, close, discover advertisement, request rebuild, key tracking, joule sum, round end, leg record, record addressing, eviction. - jcp-rs workspace tests and the jcp conformance suite. - Repo checks: tests-in-ci, spec-refs, orphan-sources, vanity-metrics, comparative-prose. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
jcp-gateway: a task's exchanges and an interim result's retry are decided as continuations of the call
Some checks failed
repository checks / lockfiles, tests in CI, workflow hygiene, spec and section references, public text (pull_request) Successful in 54s
standards workspaces build + test / which workspaces this change touches (pull_request) Successful in 7s
standards workspaces build + test / mesh-rs (mesh/mesh-rs, -p mesh-engine-gguf -p mesh-openai --features mesh-engine-gguf/gguf,mesh-openai/server, mesh-rs, linux-arm64) (pull_request) Successful in 1m24s
standards workspaces build + test / jouleclaw-rs (-nodebug, jouleclaw/jouleclaw-rs, -p jouleclaw-energy --features rapl,nvml, jouleclaw-rs, linux-arm64, -C target-feature=+fp16) (pull_request) Successful in 3m15s
standards workspaces build + test / joule-code-rs (joule-code/joule-code-rs, joule-code-rs, linux-arm64, true) (pull_request) Successful in 3m30s
standards workspaces build + test / jcp-rs (jcp-conformance, jcp/jcp-rs, -p jcp-energy --features nvml, jcp-rs, linux) (pull_request) Successful in 1m38s
standards workspaces build + test / joule-ui-rs (joule-ui/joule-ui-rs, joule-ui-rs, linux) (pull_request) Successful in 1m11s
standards workspaces build + test / joulehook-rs (joulehook/joulehook-rs, joulehook-rs, linux) (pull_request) Successful in 1m10s
standards workspaces build + test / macOS energy backends (pull_request) Failing after 5m58s
standards workspaces build + test / sandbox-rs (sandbox-conformance, sandbox/sandbox-rs, sandbox-rs, linux) (pull_request) Successful in 2m28s
standards workspaces build + test / jouledesk-rs (jouledesk/jouledesk-rs, jouledesk-rs, macos) (pull_request) Successful in 4m8s
fa83d4742e
MCP 2026-07-28 lets a gated call answer before its work is done, and both
ways it does so went around the gate.

A task handle (resultType "task"): the client fetches the outcome with
tasks/get, answers the server with tasks/update, stops it with
tasks/cancel. All three passed through as discovery. The outcome, which
is the tool's actual output, reached the model unmetered and unsealed and
was never admitted to the flow store, so a value read through a task could
be sent on by a later call with nothing to fold it against. And any task
id at all was relayed, whoever's it was.

An interim result (resultType "input_required"): the client repeats the
call with its answers in inputResponses. Only `arguments` were folded, so
an answer carrying a value from an untrusted read reached the server past
the flow ceiling.

Now:

- A task handle is bound to the grant of the call it came back from. Each
  tasks/* exchange naming it is decided against that grant at the moment
  it is made (revoked, expired or spent refuses it), metered, debited, and
  sealed over a payload that commits to the receipt it continues. Its
  reply is admitted as untrusted data under the join of everything the
  client has sent the task. The request sent upstream is rebuilt from the
  fields decided over, so nothing else the client adds reaches the server.
- The tasks/get that brings the outcome closes the task: one zero-cost
  result frame (tool-result, data) sealed over the content address of a
  closing record that names the opening receipt, every exchange's receipt,
  the joules metered across them and the outcome's digest. The record is
  Partial: the server works between exchanges, in no span the gateway
  reads.
- A tasks/* exchange naming a task this gateway did not open, or has
  closed, is refused, sealed, and never reaches the server.
- A retry's answers (inputResponses, requestState) are part of its sealed
  payload and are flow-checked with the arguments. The payload commits to
  the receipt of the interim result it answers.
- Handles the server minted (a task id, a request key, a requestState) are
  left out of the flow check only when they are an exact echo of what the
  gateway saw the server mint. A 36-byte UUID would otherwise taint every
  poll under a ceiling, since the scan window is 16 bytes. A handle the
  client made up is checked.
- server/discover carries the advertisement, as initialize does. The
  advertisement lists the task methods and `continuations: 1`.
- Open tasks and pending interim results are bounded (1024 each).

SPEC §6.1 states the rule in protocol-neutral terms. §10 gains a row
for it.

Known limits, stated in the code:
- Open tasks are kept in memory. After a restart, polls of earlier tasks
  are refused (fail closed).
- Work the server does between exchanges is not metered.
- Server-pushed notifications/tasks are outside the request/response
  relay.

Tests: 12 new, against scripted upstreams. All 19 mutations of the new
logic are killed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
jcp-gateway: the receipt-decoding test helper keeps its doc comment
All checks were successful
standards workspaces build + test / which workspaces this change touches (pull_request) Successful in 5s
repository checks / lockfiles, tests in CI, workflow hygiene, spec and section references, public text (pull_request) Successful in 40s
standards workspaces build + test / mesh-rs (mesh/mesh-rs, -p mesh-engine-gguf -p mesh-openai --features mesh-engine-gguf/gguf,mesh-openai/server, mesh-rs, linux-arm64) (pull_request) Successful in 1m2s
standards workspaces build + test / jouleclaw-rs (-nodebug, jouleclaw/jouleclaw-rs, -p jouleclaw-energy --features rapl,nvml, jouleclaw-rs, linux-arm64, -C target-feature=+fp16) (pull_request) Successful in 3m6s
standards workspaces build + test / joule-code-rs (joule-code/joule-code-rs, joule-code-rs, linux-arm64, true) (pull_request) Successful in 3m17s
standards workspaces build + test / macOS energy backends (pull_request) Successful in 6m58s
standards workspaces build + test / jouledesk-rs (jouledesk/jouledesk-rs, jouledesk-rs, macos) (pull_request) Successful in 2m32s
standards workspaces build + test / jcp-rs (jcp-conformance, jcp/jcp-rs, -p jcp-energy --features nvml, jcp-rs, linux) (pull_request) Successful in 1m41s
standards workspaces build + test / joule-ui-rs (joule-ui/joule-ui-rs, joule-ui-rs, linux) (pull_request) Successful in 1m13s
standards workspaces build + test / joulehook-rs (joulehook/joulehook-rs, joulehook-rs, linux) (pull_request) Successful in 1m20s
standards workspaces build + test / sandbox-rs (sandbox-conformance, sandbox/sandbox-rs, sandbox-rs, linux) (pull_request) Successful in 2m26s
66f36fdeec
The task tests were inserted between jcr1_payload's doc comment and the
function, so the comment documented the first constant after it and clippy
(-D warnings, macOS meter job) refused the empty line after it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Transaction-Science/open-standards!336
No description provided.