SEP: Server-Side Signed Execution Record for MCP Tool Calls#2828
SEP: Server-Side Signed Execution Record for MCP Tool Calls#2828vaaraio wants to merge 10 commits into
Conversation
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Co-Authored-By: Claude Opus 4.8 <[email protected]>
This comment was marked as spam.
This comment was marked as spam.
|
This split looks like the right place for the server-authoritative half. SEP-2817 should stay limited to client-asserted input-audit context: why the model/client invoked a tool, which model emitted the call, optional user intent, and the user-turn correlation. This draft picks up the other half: what the enforcement point actually decided before side effect, and what happened afterward. The load-bearing parts for me are:
That keeps human review out of the outcome enum. Escalate/refer is a decision with no outcome yet; execution either never happens or gets recorded later against the same attested call. The shape also preserves the boundary from SEP-2817: client-asserted intent helps explain the invocation, but the server/enforcement-point record proves what was allowed and what actually ran. |
|
Thanks for opening this separately. This split looks right to me:
The decision/outcome split also maps well to operational audit flows such as: user turn -> AI/MCP request -> server policy or approval decision -> execution/refusal/error -> audit/event record The main thing I would watch is keeping this SEP generic enough for MCP implementations that do not use SEP-2787 or a specific proxy implementation. Binding to a signed attestation instance is useful where available, but the fallback binding for deployments without SEP-2787 should remain clear. I'll review this with that boundary in mind. |
This comment was marked as spam.
This comment was marked as spam.
|
Thanks both. @hangum @rpelevin the scope split matches what I intended: 2817 keeps the client-asserted input context, this SEP carries the server-authoritative decision and outcome. @hangum on keeping it usable without SEP-2787: the backLink section already has the fallback. When no 2787 attestation exists, the record sets @rpelevin your read is the intended model. An On whether the decision enum should grow to carry deferral sub-states: I'd keep it at |
|
@XuebinMa the follow-up we discussed on 2817 is open here. The normative pairing rule is the one your decision/outcome framing shaped, and the instance-binding join is the version we settled on. If you still want to run the agent-guard implementation pass against it, this is the place. An independent implementation would help confirm the record shapes aren't tied to one proxy. |
|
@vaaraio happy to — here's the agent-guard implementation pass. It's a standalone Rust runtime (no proxy, no SEP-2787 dependency), so it's a useful test of whether these shapes hold outside a proxy deployment. The short version: the two-record split and the signed-approval lifecycle already exist independently; the instance-binding join and JCS canonicalization are where agent-guard would have to move to match this SEP. I'll be explicit about both. What already matches, arrived at independently
Where agent-guard would have to move to match this SEP (honest gaps, not claims)
Net: the decision/outcome + signed-approval shape reproduces in an independent, non-proxy runtime, which is some evidence it isn't tied to one implementation. The instance-binding join and JCS are the two things that make the records cross-verifiable, and both are SEP-level decisions agent-guard should follow rather than re-invent. Glad to review the decision-record field table directly if you want eyes on specific field names. |
This comment was marked as spam.
This comment was marked as spam.
|
@Rul1an agreed, the verifier contract is the part worth pinning before implementers copy the shape. The emitter side is easy to get superficially right and still produce records nobody can independently check. Given a decision record and its paired outcome record, a conformant verifier MUST:
I'll fold an explicit "Verifier" subsection into the next revision stating these as MUSTs, with one test vector per rule. @XuebinMa the agent-guard pass is the useful check here precisely because it verifies without being the emitter. If the instance-binding join holds outside a proxy, the contract is implementer-portable rather than tied to one deployment. On vocabulary: keeping the record server-authoritative and decoupled from any settlement or payment semantics is deliberate. The decision record states what the enforcement point decided before a side effect, and the outcome record states what was observed after. It does not assume the side effect is a payment or carry settlement state, which keeps it usable as regulatory evidence without importing a transaction model. |
This comment was marked as spam.
This comment was marked as spam.
|
Status update: the Markdown Format Check that was failing is resolved. The cause was small: table cell padding drifted after an author-line edit, so prettier flagged it. For anyone evaluating the record format itself: there are now two independent implementations, Rul1an/assay and XuebinMa/agent-guard, so the spec can be checked against more than the reference. I can add SEP-owned test fixtures next if that would help a reviewer confirm interop. |
This comment was marked as spam.
This comment was marked as spam.
|
@vaaraio @Rul1an the join model is the right thing to pin, and an honest note from the agent-guard side since it sits as a third data point. agent-guard today does not emit two separately-signed records joined by a backLink. It emits a single signed That's useful precisely as a contrast: a single-record implementation is a real point in the design space, and it tells you which of your two join models is more portable. The outcome-backLink-resolves-to-decision-digest model is the one that survives a runtime like agent-guard splitting its single receipt into two records later, because the decision digest is computable from the decision record alone — whereas "both share the same attestation backLink" silently assumes both records always exist and both always carry the attestation binding. For the SEP I'd make the outcome→decision-digest pairing the normative join, with the shared-attestation binding as the instance anchor underneath it. Two distinct fields, two distinct checks, as @Rul1an said. On vocabulary, to close that loop concretely: agent-guard's pre-execution verdict is And yes to SEP-owned fixtures. Once they exist agent-guard can run them from the emitter side — produce records and check they verify — which complements @Rul1an/Assay's consumer-side reading of committed fixtures. Between the two you get emit-and-verify coverage from independent code paths. The single-vs-two-record point above is the one place agent-guard's emitter output won't line up byte-for-byte yet, so it's worth a fixture that exercises a record pair explicitly. |
|
@XuebinMa @Rul1an the single-record contrast settles it. I'm pinning the join as two distinct checks and adopting your outcome-to-decision-digest as the normative pairing. Check A, instance binding (the anchor). Decision and outcome each bind the same call instance: the SEP-2787 attestation digest, or, with no 2787 attestation, a SHA-256 over the JCS-canonical request envelope plus a server nonce. Check B, pairing (normative). The outcome resolves to the decision record's content digest. Your portability argument is the deciding one: the decision digest is computable from the decision record alone, so a runtime that emits one record today and splits it into two later still pairs. "Both carry the same attestation backLink" assumes both records always exist and both always carry the binding, which agent-guard's single-receipt shape shows is not safe to assume. Honest status on the reference impl: it pairs on shared instance binding (Check A) today.
Fixtures are up: vaaraio/vaara#185, six cases under |
This comment was marked as spam.
This comment was marked as spam.
Bring the pairing rule in line with the implemented and conformance-tested behaviour: - outcomeDerived.decisionDigest: sha256 over the JCS-canonical full signed decision-record wire bytes the outcome was produced under. A conforming emitter MUST set it; pairing fails without it. - Pairing now states both checks. Check A (instance anchor) is the shared backLink; Check B (the normative pairing) is the outcome's decisionDigest equalling this decision's digest. Check A alone admits a different decision taken under the same attestation (an escalate and the verdict that supersedes it share the attestation); Check B pins which decision's content the outcome answers. - Supersession: when two decision records for one backLink carry the same decidedAt, the tie breaks on the lexicographically lowest issuerAsserted.nonce, so every verifier selects the same effective decision with no clock authority. - Test Vectors: the decision-and-outcome pairing suite is now published (tests/vectors/decision_pairing_v0/), driven by a standard-library-only walker with a per-case expected verdict, so an independent emitter or consumer can run it against its own implementation.
|
Check B is in the PR now. The pairing rule reads on two checks: Check A pins the call instance through the shared Supersession is resolved in the spec too: when two decision records share a The pairing conformance suite is published at @Rul1an, this is the Check B you were waiting on for Assay. The two cases your consumer could not judge before, the fallback and the supersession, now have fixtures. @XuebinMa, the digest binding is the content-versus-instance point from our thread, in wire form. Both of you can run the suite against your own side, since the walker carries the expected verdicts and takes no dependency on us. |
|
Strong +1, this is exactly the intent. The normative contract belongs in the SEP: the wire schema, the verifier obligations, the projectionDigest = sha256(projection) binding, and the conformance fixtures. The conformance vectors ship as fixtures anyone can run from a clean checkout. An independent developer reproduced the full suite from scratch with no shared code, which is the equal-footing interop you're describing: implementations conform to the SEP, not to each other. |
|
Thanks, that’s exactly the boundary I was hoping to preserve.
|
|
Implementation note: the reference implementation now reads the records from the adjacent proposals into this SEP's evidence model. |
Six committed cases under tests/vectors/decision_pairing_v0 with a stdlib-only independent walker (no Vaara imports): valid pair, decision- only escalate, substituted attestation backlink, substituted pairing nonce, equal-decidedAt supersession tie (open contract), and fallback request-envelope binding with replay. Pairing follows the shipped shared-attestation model (records_paired). Requested on modelcontextprotocol/modelcontextprotocol#2828. Co-Authored-By: Claude Opus 4.8 <[email protected]>
|
Status, for anyone arriving here from the project docs or weighing a sponsor. The scope is settled. SEP-2817 keeps client-asserted input context, SEP-2787 keeps request attestation, and this SEP carries the server-authoritative half: the decision record before the side effect, the outcome record after, paired and bound to the same call instance. The verifier contract is pinned, not only the way records are emitted. A conformant verifier runs two checks. Check A binds both records to the same call instance: the SEP-2787 attestation digest, or a request-envelope hash plus a server nonce when there is no 2787. Check B, the normative pairing, resolves the outcome to the signed decision record's digest. A receipt with no decision digest does not pair. The reference emitter is the Vaara MCP proxy, and JCS conformance vectors for the shared SEP-2787 surface are in #2789. Open items are mechanical, not design: the SEP is still Draft and seeking a sponsor, and the branch needs a rebase against main. The record shapes and the verifier contract are stable. What is missing is sponsor review. |
This comment was marked as spam.
This comment was marked as spam.
|
The fallback-binding concern is worth making normative, not implementation-adjacent. If there is no SEP-2787 attestation, hashing the whole observed tools/call envelope makes the binding depend on the observer. A gateway and provider can both be honest while carrying different reserved _meta sidecar values, which means the fallback digest stops being portable exactly in the path where it is the only anchor. I would define a named fallback projection and make fixtures own it:
Then the positive fixture is two honest views of the same call with different sidecar metadata and the same projection digest. The negative fixtures are changed arguments, changed binding, missing binding, and unknown binding id. That keeps the no-attestation fallback reproducible by an independent verifier without requiring retention or disclosure of the whole observed envelope, while still preserving an instance-binding hook rather than falling back to a purely content-addressed action description. Boundary: architecture and fixture-shape feedback only; no claim about using this project, running code, validating implementation behavior, or representing any MCP implementation. |
|
Both Rul1an and rpelevin have the spec text in hand and the observation is correct. The current wording at the fallback path is:
That preimage is observer-local. A gateway and a provider are both honest observers of the same call but carry different The fix is to replace "the {
"toolName": "<params.name>",
"arguments": <JCS-normalized params.arguments>,
"authBinding": <params._meta["authorization_binding"] if present, else absent>
}
The name "fallback projection" should appear as a defined term in the spec so conformance tests can target it explicitly. A JCS fixture for this shape is the right conformance artifact. I can add one to the existing vectors. |
|
That named fallback projection is the right spec-level fix. The fixture set I would want before calling the fallback portable is:
That gives implementors a stable preimage rule and gives verifiers a negative test suite, not just a prose instruction to ignore observer-local metadata. |
|
Items 1 through 6, mapped to the current corpus. Items 1 through 3 are now in the fixture set. observer_stable_a and observer_stable_b carry different _meta sidecars in the raw call (progressToken and traceId differ; the README shows the example values) and produce the same attestationDigest. neg_different_args isolates the arguments axis: same toolName and authBinding, different file argument, different digest. neg_different_auth_binding isolates the authBinding axis: same toolName and arguments, different policyId and scope, different digest. The independent checker verifies all three divergence properties: 11/11 pass. Item 4: basic_no_auth_binding is the explicitly defined unauthenticated fallback profile. When _meta carries no authorization_binding key, authBinding is absent from the projection and the digest covers {arguments, toolName} only. The README names this explicitly. Implementations that require authorization enforce that at the policy layer, not in the projection. Item 5 (unknown bindingId fail-closed): the current projection accepts any object for authBinding and hashes it. A spec definition of what constitutes a valid authBinding is needed first. The negative vector follows in the next update. Item 6: instance separation is via backLink.attestationNonce at the receipt layer, not in the projection preimage. The projection hash is content-binding only: two identical calls share a projection digest and are separated by the server-chosen nonce. Nonce vectors belong in the receipt corpus, not here. Corpus: https://github.com/vaaraio/vaara/tree/main/conformance/sep2828/fallback_projection_v0 |
This comment was marked as spam.
This comment was marked as spam.
|
This split makes sense. I would keep the corpus in three layers:
For item 5, I would not make unknown bindingId a projection-hash failure. The projection can hash it deterministically. The fail-closed check is that a verifier must reject an execution record that relies on an unknown binding as authorization evidence. That gives a clean negative fixture: digest computes, schema parses, but authorization resolution fails. That keeps fallback projection portable while making the policy binding non-ambient. |
|
The three-layer split above is the right structure. Projection layer: hash deterministically over {toolName, arguments, authBinding if present}. An unknown or malformed authBinding is still hashable. The projection does not fail-close on unknown binding. Receipt layer: nonce is instance-binding. Projection hash is content-binding. These are independent, which is why the projection corpus and the receipt corpus handle them separately. Authorization layer: a verifier that relies on authBinding as authorization evidence MUST fail closed when the binding does not resolve to a known policy or capability. Digest computes, schema parses, authorization resolution fails closed. The negative vector for this case belongs in an authorization corpus, not the projection corpus. The four normative additions belong in the named-projection definition once it lands in the prose:
On fixtures: the projection corpus is SEP-owned. Independently generated fixtures fit the existing structure (projection JSON, expected bytes, expected digest, stdlib-only checker). Authorization-layer negatives (unknown binding, malformed binding, policy resolution failure) slot into a separate authorization corpus once the valid-binding definition is in the text. Current projection corpus: https://github.com/vaaraio/vaara/tree/main/conformance/sep2828/fallback_projection_v0 |
|
Yes, that is the contract I would want implementations to test. The cross-corpus invariant I would add is: projection-valid does not imply authorization-valid. A verifier can accept the projection bytes, reproduce the digest, and still return an authorization failure if the binding reference is unknown, malformed, expired, out of scope, or not resolvable in the verifier's policy context. That gives three clean fixture families:
I would also keep the missing-binding profile named as unauthenticated content binding. That makes it useful for audit and replay without letting a valid digest be read as a grant. So the verifier rule becomes: projection digest is necessary evidence for what was requested, but never sufficient evidence that the request was authorized. |
…ned-execution-record # Conflicts: # docs/docs.json # docs/seps/index.mdx
|
The format this SEP describes is specified and stable on its own, independent of whether the proposal merges. The receipt envelope is Conformance is mechanical. Recompute the digests over the published vectors: the same stable fields produce the same projection digest, and changed arguments or a changed auth binding move it. The vectors, the canonicalization, and the reference runner live in the repository and are pinned at the release tag. Recomputing them is conformance to the published format, not co-authorship and not independent issuance of the format.
The proposal stays open as written. Build against the format or don't. It stands either way. |
This PR adds a Standards Track SEP (status: Draft, seeking a sponsor): a server-authoritative signed record of what a governing server or proxy decided about a tool call and what the call actually did.
It is the follow-up that SEP-2817 and Discussion #2704 defer to. SEP-2817 standardizes client-asserted input-audit context and states that server-side decision records are left to a later SEP; @hangum asked for this to be opened separately so SEP-2817 stays scoped. This is that SEP.
What it defines
Two records, signed by the enforcement point:
allow/block/escalateverdict and the risk basis behind it.executed/refused/erroredstatus and a commitment over the result.They are paired by
backLinkand bound to the originating SEP-2787 attestation instance, so a verifier can reconstruct what the agent was permitted to do, why, and what it actually did. Both use RFC 8785 (JCS) canonical JSON and the same detached-signature stack as SEP-2787, so a 2787 verifier needs no new cryptographic code. The trust surface is server-signed (issuerAsserted/receiptAsserted), kept distinct from 2787's call attestation and 2817's client-asserted input context.Why server-authoritative
A client can claim its intent and its arguments. It cannot credibly attest that a call was allowed, why it was allowed or blocked, or what the tool returned, because it does not own that logic and is not a neutral observer of its own behaviour. Article 12 logging for a deployment where the server enforces policy needs a record signed by the enforcement point, paired to the request it answers, that survives the client.
Reference implementation
The wire shape already ships in the Vaara MCP proxy. The outcome record is
vaara.attestation.receipt.ExecutionReceipt(shipping since v0.42); the decision record isvaara.attestation.decisionin the reference repo with round-trip, tamper, and pairing tests. The instance-binding join is a SHA-256 over the full SEP-2787 attestation wire bytes with the signature included, so a record cannot be replayed against a different instance of a byte-identical call. Verification is offline and standard-library only; JCS conformance vectors for the shared SEP-2787 surface are in #2789.Prior art reconciled in the SEP
SEP-2787 (tool call attestation), SEP-2817 (AI invocation audit context), SEP-414 (request
_meta), and the decision/outcome split in agent-guard (the instance-binding point was settled in discussion on 2026-05-30).AI assistance disclosure
This SEP was prepared with AI assistance for structure and wording. The proposal direction, the implementation behind it, and the final technical judgment were reviewed and edited by the author.