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 8 commits into
Conversation
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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 is the right layer for the server-authoritative half. SEP-2817 can stay with client-asserted input context, SEP-2787 can stay with request attestation, and this SEP can carry what the enforcement point decided and what happened afterward. The bit I would make especially crisp before implementers copy the shape is the verifier contract, not only the emitter shape. A proxy can emit a decision/outcome pair, but the durable value comes from an independent verifier being able to reconstruct the same answer from committed records alone. From that side, I would want the SEP to make a few cases boring and testable: which decision record applies to this call instance; whether an outcome is required, absent, or intentionally absent; how superseded decisions are ordered; how conflicting decisions are rejected; and whether the fallback binding without SEP-2787 is still instance-bound enough to catch replay or substitution. The The conformance artifact I would find most useful as a downstream evidence consumer is a plain SEP-owned fixture set: observed request or attestation input, decision record, optional outcome record, expected pairing result, and negative cases for substituted backlink, duplicate decision, stale superseded decision, missing required outcome, and fallback-binding replay. That keeps Vaara or any other proxy as strong implementation input, while making the standard testable by independent consumers that only read the wire records and do not share the emitter's code path. |
|
@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 verifier-first direction is the right one. One downstream data point: Assay has now landed a small independent consumer verifier for SEP-2787 + server execution-record fixtures: Rul1an/assay#1462, with edge-case coverage in #1463. It does not emit records, proxy MCP, or establish issuer trust; it only reads committed fixtures and reports whether the attestation, decision, and optional outcome pair up. That is the verifier role I meant in my earlier comment. That exercise makes one contract detail worth making boring before the next revision lands: the join model. The draft mostly reads as decision and outcome sharing the same request/attestation My preference would be to spell this as machine-testable fields rather than prose: request/attestation instance binding, decision-to-outcome pairing, supersession ordering and tie-breaks, and absent-outcome semantics for Assay can consume SEP-owned fixtures as an independent downstream verifier once they exist. That gives the SEP a second implementation path that only reads the wire records and does not share Vaara's emitter code. |
|
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. |
|
That fixture set would be useful. Small precision on the implementation wording: Assay is an independent consumer/verifier for fixture pairing, not a full SEP-2828 issuer or trust implementation. It verifies the request/decision/outcome linkage from committed records and deliberately does not emit records, proxy MCP, establish issuer trust, or claim runtime truth. That is still the useful second path for this SEP: a downstream consumer that only reads the wire artifacts. Agent-guard looks like a strong independent implementation data point for the runtime shape, while Assay is the narrower verifier-consumer path. From Assay's side, the most useful first SEP-owned fixture set would be:
|
|
@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 |
|
Thanks for landing these. I pointed Assay's current consumer verifier at the committed Check-A vectors to see where it actually lands. The two positive shapes come through clean: the valid allow plus executed pairing, and the decision-only escalate. Both substitution cases Assay can evaluate today fail exactly where they should, the swapped attestation backLink and the swapped pairing nonce, so the instance binding is doing its job. Two of them Assay can't judge yet, and I'd rather be honest about why than paper over it. The fallback request-envelope binding needs an attestation input that Assay v0 still assumes is there, and the equal-decidedAt supersession case needs multi-decision ordering that Assay doesn't model yet. That's a real piece of work on our side, not just a re-run. So it lands right on the boundary we talked about. Assay can consume the shared-instance-binding fixtures today as an independent reader, and once the explicit outcome-to-decision-digest Check B lands I'll re-run against it. The supersession side follows when Assay actually models ordering, rather than pretending this is already just another fixture run. |
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. |
|
Thanks for landing Check B. This is the shape I was hoping for: Check A anchors the call instance, and Check B answers the separate question of which decision the outcome actually resolves to. I pulled the current fixture suite and ran Assay over it after adding the Check B consumer check on our side. The two positive cases still come through clean, and all three single-decision negatives Assay can evaluate today fail exactly where they should, the substituted attestation backLink, the substituted pairing nonce, and the new substituted decision under a shared attestation, which our Two are still genuine work on our side rather than a re-run, and I'd rather say so plainly. The fallback request-envelope binding needs a no-attestation input path Assay doesn't have yet, and supersession needs multi-decision ordering we don't model. The upside is your fixtures now give both of those a concrete target to build against. One small thing from a clean checkout, in case it bites the next person running these independently. |
|
You're right, and thanks for running it from a clean checkout. That's exactly the case the vectors exist to serve. The keys directory was caught by a broad ignore rule, so the public key and the HMAC secret never got committed, and the checker bailed before the first case. Fixed: From a fresh clone the checker now runs 7/7 with no Vaara import, so the substituted-decision Check B negative that your On the two Assay can't judge yet, the fallback envelope binding and the supersession ordering: agreed, those are real work rather than a re-run, and they sit on your side, not in the fixtures. The fixtures carry both as concrete targets for when you get to them. Nothing owed back from me there. |
|
Appreciate the quick turnaround. 7/7 from a clean clone with no Vaara import is exactly the property these vectors should have, and reproducing the Check B negative from the committed material alone is the whole point of an independent consumer, so that's a real step up. Agreed on the split too. The fallback envelope binding and supersession ordering are work on our side, not anything I'm asking from the fixtures right now. We've scoped the no-attestation input path and will pick both up when we get to them. Thanks for making the suite cleanly runnable. |
|
Quick status, and a process question. The anti-backdating mechanism raised earlier in this thread (a trusted timestamp over the chain head, so a later signing-key compromise cannot produce a backdated alternate chain) is implemented in the reference. It uses an RFC 3161 token over the record-chain head, pinned to an eIDAS-qualified TSA so it is recognised EU-wide, and it verifies offline with the standard library plus the TSA certificate. v0.59.0 also exposes it in the one-command Article 12 regulator export, so the property is reachable end to end. The SEP scope is unchanged: server-authoritative decision and outcome records paired by |
|
The v0.60 I would keep the proof boundary explicit, though. A keyless conformance check is not issuer trust, not signature verification, not time-anchor verification, and not runtime truth. It answers “is this a well-formed SEP-2828-shaped record, and do the recomputable bindings match?” The signer-key path and the time-anchor path remain separate checks. For sponsorship/review, the clean route from my side would be:
That keeps the proposal implementation-backed without making the standard Vaara-backed. It also gives maintainers a concrete thing to review: the verifier contract and fixture surface, rather than a broad compliance claim. |
|
v0.60.0 ships a conformance check for the record format in this SEP. It is keyless. A party that holds neither the signing key nor the request attestation can still tell whether a record is well formed. Pass the attestation and it also checks the back-link, still without a key. The signature check stays where it needs the key. For this SEP, the point is that the format is checkable by someone who runs none of the producer's software. The conformance vectors ship with a standalone checker that imports no Vaara code, so a second implementation reproduces every verdict offline. That is what lets the format, not any one implementation, be the thing a verifier trusts. |
|
This is the right direction, but I would keep the trust boundary tight. For review, I would draw the line this way:
|
|
+1 to keeping the normative authority in the SEP rather than in any single reference implementation. A reference implementation like |
|
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. |
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.