Thanks to visit codestin.com
Credit goes to github.com

Skip to content

SEP: Server-Side Signed Execution Record for MCP Tool Calls#2828

Open
vaaraio wants to merge 10 commits into
modelcontextprotocol:mainfrom
vaaraio:sep-server-side-signed-execution-record
Open

SEP: Server-Side Signed Execution Record for MCP Tool Calls#2828
vaaraio wants to merge 10 commits into
modelcontextprotocol:mainfrom
vaaraio:sep-server-side-signed-execution-record

Conversation

@vaaraio

@vaaraio vaaraio commented May 31, 2026

Copy link
Copy Markdown

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:

  • Decision record, emitted before the side effect: the allow / block / escalate verdict and the risk basis behind it.
  • Outcome record, emitted after execution: the executed / refused / errored status and a commitment over the result.

They are paired by backLink and 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 is vaara.attestation.decision in 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.

@vaaraio vaaraio requested review from a team as code owners May 31, 2026 11:03
@chopmob-cloud

This comment was marked as spam.

@rpelevin

Copy link
Copy Markdown

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:

  • a decision record before the side effect, not only an outcome log after it;
  • an outcome record after execution, paired back to the same attested invocation;
  • backLink binding to the attestation instance, so the verifier is checking this exact call, not just a recomputable action description;
  • JCS/canonical signing so the record can be verified offline;
  • a clean distinction between allow/block/escalate decisions and executed/refused/errored outcomes.

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.

@hangum

hangum commented May 31, 2026

Copy link
Copy Markdown

Thanks for opening this separately.

This split looks right to me:

  • SEP-2817 stays focused on client-asserted input-audit context: why the AI invoked the tool, which model produced the invocation, optional user intent, and user-turn correlation.
  • This SEP can focus on the server/proxy-authoritative layer: what decision was made before execution and what outcome was observed after execution.

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.

@chopmob-cloud

This comment was marked as spam.

@vaaraio

vaaraio commented May 31, 2026

Copy link
Copy Markdown
Author

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 attestationDigest to a SHA-256 over the JCS-canonical request envelope the server observed (the tools/call params plus _meta), with a server-chosen per-call nonce, so the binding is still to the request instance and not a recomputable description. I'll lift that out of the field table into its own short "deployments without SEP-2787" paragraph so it isn't buried, and change the front-matter from "Requires: SEP-2787" to "Related", since 2787 strengthens the binding but isn't load-bearing.

@rpelevin your read is the intended model. An escalate is a decision with no outcome record yet. If a human later resolves it, that is a new decision record carrying the same backLink and a later decidedAt, and the record with the latest decidedAt wins (Pairing). Human review never enters the outcome enum, which stays executed/refused/errored.

On whether the decision enum should grow to carry deferral sub-states: I'd keep it at allow/block/escalate. A deployment with a real deferral lifecycle models that in its own policy layer and supersedes the escalate with a later decision record, rather than the core enum taking on domain-specific verdict classes. A small enum is what lets a generic verifier check any implementation.

@vaaraio

vaaraio commented May 31, 2026

Copy link
Copy Markdown
Author

@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.

@XuebinMa

XuebinMa commented Jun 1, 2026

Copy link
Copy Markdown

@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

  • Decision/outcome split. Pre-execution decision is GuardDecision { Allow, Deny, AskUser }, written before the side effect; the post-execution outcome (exit code, sandbox type) is written after. AskUser is your escalate.
  • Signed record covering the approval. ExecutionReceipt { receipt_version, agent_id, tool, policy_version, sandbox_type, decision, command_hash, timestamp, approval: Option<ApprovalProof>, signature }, Ed25519. When a human approves an AskUser, with_approval() attaches ApprovalProof { request_id, decided_by, decided_at } and re-signs so the signature covers the approval — the approval isn't a separate unsigned log line.
  • Escalate has no sibling outcome, and that's terminal-or-resumable, not an error. Confirms @rpelevin's and your reading from a second implementation. agent-guard keeps the deferral lifecycle off both enums, in a separate append-only ledger: ApprovalStatus { Pending, Approved, Denied, Expired }. So "decision written, outcome absent" is observable as a Pending ledger entry with no receipt yet; an Expired is the timed-out escalate that never produced an outcome. That's a concrete data point for the @chopmob-cloud rule_class question: the deferral sub-state lived cleanly outside the decision enum here, which argues for keeping the core enum at allow/block/escalate and modeling deferral lifecycle in the policy/ledger layer.

Where agent-guard would have to move to match this SEP (honest gaps, not claims)

  • Binding. Today ExecutionReceipt binds command_hash (SHA-256 of the request payload) plus an internal request_id. That's content binding — recomputable, can't distinguish retries — exactly the property you and @chopmob-cloud flagged. To pair against this exact call I'd adopt your backLink instance digest. And since agent-guard runs standalone, your no-SEP-2787 fallback (SHA-256 over the JCS-canonical observed request envelope + server nonce) is the binding it would actually use — so I'd second @hangum's point that the fallback needs to be first-class, not a footnote. It's the only binding a non-2787 deployment has.
  • Canonicalization. agent-guard's current signing payload is a delimiter-joined field concatenation, not RFC 8785. For a generic verifier to check records across implementations, JCS has to be the normative wire form; a bespoke per-implementation payload defeats the "2787 verifier needs no new code" goal. This is the change with the most reach, and I think it's the right call.

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.

@Rul1an

This comment was marked as spam.

@vaaraio

vaaraio commented Jun 1, 2026

Copy link
Copy Markdown
Author

@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:

  1. Recompute the JCS canonicalization and verify the signature against the declared issuer key, rejecting any record whose canonical form does not round-trip.
  2. Verify the backLink join: the outcome record's backLink resolves to the decision record's content digest, and both bind to the same attestation instance (the SEP-2787 attestation digest, or the request-envelope SHA-256 fallback when no SEP-2787 attestation exists).
  3. Reject an inconsistent pair: a terminal deny decision with an executed outcome, or a refer decision with no later record resolving it.
  4. Treat the records as data, not as authority. The verifier trusts the issuer key and the chain, not the proxy that emitted them.

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.

@Rul1an

This comment was marked as spam.

@vaaraio

vaaraio commented Jun 1, 2026

Copy link
Copy Markdown
Author

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. format and validate are both green now, and render-seps passes, so the proposal is ready for review with no outstanding CI.

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.

@Rul1an

This comment was marked as spam.

@XuebinMa

XuebinMa commented Jun 1, 2026

Copy link
Copy Markdown

@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 ExecutionReceipt after execution that carries the decision verdict and the outcome context together (decision field + command_hash + sandbox/exit context), with the human-approval sub-record (ApprovalProof) folded in and covered by the same signature. The approval-to-call join is by an internal request_id (a locally-generated UUID), which is content-independent but not instance-bound to any attestation — so it's neither of the two joins on the table: not "share the same attestation backLink," not "outcome.backLink resolves to decision content digest."

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 AskUser, which maps to the normative escalate. refer is only discussion-vintage vocabulary (it came up via the AskUser/REFER analogy earlier in the 2817 thread) and should not be a wire value — please map it to escalate explicitly in the enum note so implementers don't add a fourth.

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.

@vaaraio

vaaraio commented Jun 1, 2026

Copy link
Copy Markdown
Author

@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. records_paired compares the two records' attestation backLinks, not a decision content digest. The explicit decision-content-digest field on the outcome is Check B, and it lands in the next revision. I did not want to backfill the thread with a field the code does not carry yet.

refer is not a wire value. The enum stays allow/block/escalate, and the note will map both refer and agent-guard's AskUser to escalate.

Fixtures are up: vaaraio/vaara#185, six cases under tests/vectors/decision_pairing_v0 with a stdlib-only walker (no Vaara imports) that Assay can read on the consumer side and agent-guard can run on the emitter side. They encode Check A pairing as shipped. I will add the Check B explicit-digest case in the same revision that lands the field, plus the supersession tie-break, which is the one case I left marked open since equal decidedAt has no deterministic order in the impl yet. Proposed tie-break: lexicographic on the record nonce, lowest wins, so two verifiers never disagree.

@Rul1an

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.
@vaaraio

vaaraio commented Jun 2, 2026

Copy link
Copy Markdown
Author

Check B is in the PR now. The pairing rule reads on two checks: Check A pins the call instance through the shared backLink, and Check B, the normative one, pins content through outcomeDerived.decisionDigest, a sha256 over the full signed decision-record bytes the outcome was produced under. Check A alone admits a different decision taken under the same attestation, so an escalate and the human verdict that supersedes it both pass Check A; Check B is what says which decision the outcome actually answers. A receipt without decisionDigest does not pair.

Supersession is resolved in the spec too: when two decision records share a backLink and carry the same decidedAt, the effective one is the lowest issuerAsserted.nonce, so verifiers agree with no clock authority.

The pairing conformance suite is published at tests/vectors/decision_pairing_v0/. Seven cases, each carrying its expected verdict, driven by a standard-library-only walker that takes no Vaara import: a valid pair, two Check A substitution negatives, a Check B negative (a substituted decision under a shared attestation), the no-attestation fallback with a replay rejection, a decision-only escalate, and the supersession tie.

@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.

@vaaraio

vaaraio commented Jun 8, 2026

Copy link
Copy Markdown
Author

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. vaara verify-record is deliberately keyless and producer-agnostic. It checks any record that claims the format, including records Vaara never emitted, so the spec stays the definition and the tool is just one checker of it.

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.

@AgentGymLeader

Copy link
Copy Markdown

Thanks, that’s exactly the boundary I was hoping to preserve.

vaara verify-record is useful as implementation evidence, but the SEP should stay the source of truth for the wire schema, verifier obligations, and conformance fixtures. That keeps the format producer-agnostic and gives independent implementations a shared target without turning any one checker into the definition.

@localden localden added SEP draft SEP proposal with a sponsor. labels Jun 8, 2026
@localden localden added proposal SEP proposal without a sponsor. and removed draft SEP proposal with a sponsor. labels Jun 8, 2026
@vaaraio

vaaraio commented Jun 8, 2026

Copy link
Copy Markdown
Author

Implementation note: the reference implementation now reads the records from the adjacent proposals into this SEP's evidence model. vaara normalize takes a SEP-2643 authorization denial, a SEP-2787 tool-call attestation, or a SEP-2817 invocation audit context and reports which part of an execution record each one establishes and what is left unproven: a denial is a refused outcome, an attestation fixes the back-link a receipt pins, and invocation context is advisory input that is never treated as authorization evidence. Conformance vectors built from each proposal's own examples and a dependency-free checker that reproduces the mapping are in the repo for anyone who wants to check it (vaaraio/vaara#221).

vaaraio added a commit to vaaraio/vaara that referenced this pull request Jun 17, 2026
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]>
@vaaraio

vaaraio commented Jun 25, 2026

Copy link
Copy Markdown
Author

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.

@Rul1an

This comment was marked as spam.

@rpelevin

Copy link
Copy Markdown

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:

  1. include the tool name;
  2. include canonical arguments;
  3. include only the authorization-binding subobject from _meta, including whatever nonce or binding id the server uses for instance uniqueness;
  4. exclude observation-local _meta fields such as progress, trace, transport, and injected gateway ids;
  5. fail closed if the authorization-binding subobject is missing or references an unknown binding id.

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.

@vaaraio

vaaraio commented Jun 25, 2026

Copy link
Copy Markdown
Author

Both Rul1an and rpelevin have the spec text in hand and the observation is correct. The current wording at the fallback path is:

sha256:<hex> over the JCS-canonical encoding of the request envelope it observed (the tools/call params plus _meta)

That preimage is observer-local. A gateway and a provider are both honest observers of the same call but carry different _meta values: progress tokens, injected trace IDs, span context, correlation headers. Two conforming implementors hash to different digests for the same call instance, which breaks cross-verifier portability exactly when there is no SEP-2787 attestation to anchor on.

The fix is to replace "the tools/call params plus _meta" with a named fallback projection whose contents are stable across honest observers:

{
  "toolName":    "<params.name>",
  "arguments":   <JCS-normalized params.arguments>,
  "authBinding": <params._meta["authorization_binding"] if present, else absent>
}

attestationDigest in the fallback is then sha256:<hex> over the JCS-canonical encoding of this projection, not the full observed envelope. The attestationNonce (server-chosen per call) still carries instance-binding: the nonce separates two calls with byte-identical content; the projection hash separates the call from a different tool or different arguments.

authBinding is the authorization-relevant subobject from _meta that the governing server should record: scope, policy reference, capability grant. Progress tokens and trace-correlation artifacts are transport concerns and do not belong in the preimage.

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.

@rpelevin

Copy link
Copy Markdown

That named fallback projection is the right spec-level fix.

The fixture set I would want before calling the fallback portable is:

  1. two honest observer views with different transport or trace _meta and the same projection digest;
  2. changed arguments move the projection digest;
  3. changed authorization binding moves the projection digest;
  4. missing authorization binding fails closed unless the spec explicitly defines an unauthenticated fallback profile;
  5. unknown binding id fails closed;
  6. nonce changes separate byte-identical repeated calls without making transport _meta part of the digest.

That gives implementors a stable preimage rule and gives verifiers a negative test suite, not just a prose instruction to ignore observer-local metadata.

@vaaraio

vaaraio commented Jun 25, 2026

Copy link
Copy Markdown
Author

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
Checker: python3 _check_independent.py (stdlib only, no Vaara import)

@Rul1an

This comment was marked as spam.

@rpelevin

Copy link
Copy Markdown

This split makes sense. I would keep the corpus in three layers:

  1. projection conformance: same stable fields produce the same projection digest, and changed arguments or authBinding move the digest;
  2. receipt conformance: nonce separates byte-identical repeated calls without entering the projection preimage;
  3. authorization conformance: a present authBinding has to resolve to a known policy or capability binding before the decision can be treated as authorized.

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.

@vaaraio

vaaraio commented Jun 25, 2026

Copy link
Copy Markdown
Author

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:

  1. Field set fixed: toolName, arguments (JCS-normalized, RFC 8785), authBinding (absent when _meta carries no authorization_binding key).

  2. Unauthenticated profile: an absent authBinding produces a valid projection over {toolName, arguments}. That digest is content-binding only and is not authorization evidence. Deployments requiring authorization enforce that at the policy layer; the projection is valid either way.

  3. Content/instance split: projection hash is content-binding only. Instance-binding is via backLink.attestationNonce, server-chosen per call. An implementation MUST NOT treat a shared projection digest as evidence that two calls are the same instance.

  4. Unknown binding: the projection hashes whatever authBinding is present; the verifier fails closed at authorization resolution, not at projection recomputation.

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

@rpelevin

Copy link
Copy Markdown

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:

  1. projection fixtures prove stable canonical bytes and digest movement only;
  2. receipt fixtures prove instance separation through the nonce and backlink;
  3. authorization fixtures prove that a record can be structurally valid and still fail closed before it becomes executable authority.

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
@vaaraio

vaaraio commented Jun 28, 2026

Copy link
Copy Markdown
Author

The format this SEP describes is specified and stable on its own, independent of whether the proposal merges.

The receipt envelope is vaara.receipt/v1, normative in SPEC.md. A receipt records what a tool call did, under what authority, and when, with the time anchored to a trusted RFC 3161 or eIDAS-qualified timestamp so it is provable against a clock the issuer does not control. The decorator form is one line over an existing handler. The capability, credential, and anchoring layers sit on top of that and are optional.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

proposal SEP proposal without a sponsor. SEP

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

8 participants