Skip to content

Add scoped authorization receipt security guidance#2867

Open
dinpd wants to merge 3 commits into
modelcontextprotocol:mainfrom
dinpd:docs/scoped-authorization-receipts
Open

Add scoped authorization receipt security guidance#2867
dinpd wants to merge 3 commits into
modelcontextprotocol:mainfrom
dinpd:docs/scoped-authorization-receipts

Conversation

@dinpd

@dinpd dinpd commented Jun 5, 2026

Copy link
Copy Markdown

Summary

  • Adds a non-normative security best-practice section for scoped authorization receipts on high-risk tool calls.
  • Separates transport authorization, runtime/action authorization, and provider business authorization.
  • Includes an example receipt shape plus replay, scope-drift, and retry-suppression acceptance criteria.

Notes

This follows the discussion in #2852. The text intentionally does not define a normative receipt schema, require a specific issuer, or replace provider-side business authorization.

Verification

  • git diff --check

I could not run the repository npm-based docs checks locally because npm ci repeatedly failed with npm's Exit handler never called! error in this environment.

@dinpd dinpd requested a review from a team as a code owner June 5, 2026 18:47
@github-actions github-actions Bot added the documentation Improvements or additions to documentation label Jun 5, 2026
@dinpd dinpd force-pushed the docs/scoped-authorization-receipts branch from 4f360c4 to d482dc2 Compare June 5, 2026 19:15
@dinpd

dinpd commented Jun 5, 2026

Copy link
Copy Markdown
Author

Folded in the latest issue-thread feedback by naming the success/timeout retry case explicitly: provider completes or reserves the mutation, the client times out before recording the outcome, and a retry with the same action/receipt ID plus canonical digest returns the prior outcome/completed receipt or an already_consumed state without rerunning the mutating handler. Also separated out_of_scope from already_consumed in the acceptance criteria.

@rpelevin

rpelevin commented Jun 5, 2026

Copy link
Copy Markdown

This captures the invariant I was trying to preserve in the issue thread.

The load-bearing pieces are now explicit:

  • transport authorization, runtime action authorization, and provider business authorization stay separate;
  • the receipt is bound to a stable action id plus canonical request digest;
  • provider business logic runs only after freshness, audience/tool, resource/context, and digest checks pass;
  • success/timeout retry returns the prior outcome or completed receipt instead of rerunning the mutating handler;
  • out_of_scope and already_consumed stay separate failure modes;
  • provider idempotency keys are linked to the action/receipt id, not treated as the only verifier-readable boundary.

I would keep the scope exactly this narrow. The section gives implementers concrete acceptance criteria without turning MCP core into a normative receipt schema.

@dinpd

dinpd commented Jun 5, 2026

Copy link
Copy Markdown
Author

Thanks, this is exactly the boundary I was trying to preserve as well.

I’ll keep the PR scoped to implementation guidance and acceptance criteria, without introducing a normative receipt schema into MCP core.

@Rul1an

Rul1an commented Jun 7, 2026

Copy link
Copy Markdown

Reads well, and keeping it non-normative with concrete acceptance criteria is the right call. Two security considerations I would add before merge, both guidance rather than schema so they stay in scope.

The load-bearing one is receipt authenticity. The provider verification list (fresh, intended-for, bound-to) is right, but the text never says how the provider knows the receipt is genuine. Since it travels in tool arguments or request metadata on a client-submitted call, a client can present its own issuer: gateway / state: admitted. Without integrity protection the pattern reduces to a client self-asserting that it was admitted. One sentence that the provider must verify the receipt was actually issued by the trusted authority (an issuer signature or other integrity protection it can check independently of the client) before any freshness or scope check carries weight would close this, without mandating a format.

Smaller: request_digest as a deployment-defined canonical representation is fine when one party owns both ends, but the premise is a gateway and provider operated separately. If they canonicalize differently the digests diverge and it surfaces as out_of_scope when it is really a hashing mismatch. Worth a note that issuer and verifier agree out of band on the exact canonicalization and projection, and that a mismatch fails closed.

We hit both implementing something close to this in assay, and the authenticity one was the difference between a receipt that authorizes and one that merely describes.

@dinpd

dinpd commented Jun 8, 2026

Copy link
Copy Markdown
Author

Thanks, agreed on both points. I pushed an update that keeps this as guidance rather than schema:

  • states that the provider must verify the receipt was issued by a trusted authority and protected from client modification before freshness/scope checks carry security weight;
  • notes that issuer and verifier need to agree out of band on the exact canonicalization/projection for request_digest, and that digest mismatch fails closed before business logic runs.

Verification: git diff --check.

@Rul1an

Rul1an commented Jun 8, 2026

Copy link
Copy Markdown

That reads right to me. The authenticity sentence is the load-bearing one: once the provider verifies the receipt was issued by the trusted authority and protected from client modification before freshness or scope checks count, the receipt authorizes rather than merely describes. And failing closed on a digest or canonicalization mismatch before business logic runs is the safe default. Scope stays exactly where it should, guidance and acceptance criteria, no normative schema. Nothing else from me; this closes both points.

@rpelevin

rpelevin commented Jun 9, 2026

Copy link
Copy Markdown

Coming over from #2852, I think the SEP-2828 mapping is now at the point where it would strengthen this PR as a short non-normative note, without turning this into a schema requirement.

The narrow shape I would add is:

  • SEP-2828 is one concrete signed-record example, not the required MCP receipt schema;
  • the conformance target is the verifier obligation set: canonical request binding, same-call-instance binding, outcome-to-decision linkage, and issuer/audience/scope/freshness checks where the verifier has the issuer key;
  • the fixture suite should include positive decision/outcome pairs, substituted decision/outcome negatives, no-2787 fallback-binding match and replay/substitution cases, and the equal-decidedAt supersession case with an explicit ambiguous / non-conformant verdict when there is no sequence or revision field;
  • runtime truth, issuer trust policy, and single-use / already-consumed semantics stay outside the signed record.

The two edge fixtures from #2852 seem especially worth naming because they prevent the example from resting on hidden producer behavior:

  1. No-2787 fallback: the observed tools/call envelope, including the server nonce / binding material under _meta, is fixture input. A verifier recomputes sha256(jcs(envelope)) and compares it to the decision back-link; replay with changed args produces a different digest and fails.
  2. Equal decidedAt: if two decision records under the same call binding have the same decision time and no explicit ordering field, the verifier should not pick a winner from file order, arrival order, or lexicographic nonce. Treat it as ambiguous / non-conformant unless a sequence or revision field exists.

That keeps the PR in its current lane: implementation guidance and acceptance criteria. The useful thing implementers copy is not a mandatory receipt format; it is the boundary that a signed record is durable evidence of what was admitted and what ran, while the authorization gate remains the thing that decides execution and consumes authority.

@Rul1an

Rul1an commented Jun 9, 2026

Copy link
Copy Markdown

@rpelevin this note shape is right, and I would land it close to as written. The obligation set as the conformance target, SEP-2828 as one illustrative shape rather than the required format, and the evidence-not-gate boundary are the things implementers actually need.

One precision on the no-2787 fallback, since it is the case most likely to drift in the wild. The fixture takes the observed tools/call envelope with the binding material under _meta and recomputes sha256(jcs(envelope)). The risk is that _meta is open-ended: in a real deployment it also carries progress tokens, trace context, and other SEP blocks, and a gateway and a provider can legitimately see slightly different _meta because transport adds or strips fields. If the preimage is the whole envelope, those differences change the digest and you are back to scope-drift-that-is-really-a-hashing-bug, on exactly the path that has no attestation structure to lean on. So I would have the note name the preimage: a fixed set of params plus a named binding block under _meta, not all of _meta. Then a second implementation recomputes the same digest from the same named fields, which is the property the fallback exists to provide. The 2787 path already has this because the attestation is a defined structure; the fallback has to state it.

On our side, the no-2787 fallback and the equal-decidedAt case are the two the Assay consumer could not reproduce from committed evidence before. Once we extend it to the no-attestation input path we will run the fallback match and replay cases and the ambiguous-supersession case against the committed corpus and report what reproduces. The ambiguous verdict is the easy half, a conformant reader should refuse to pick a winner and we can show it does; the fallback is the one that needs the named preimage above to be reproducible at all.

@rpelevin

rpelevin commented Jun 9, 2026

Copy link
Copy Markdown

Yes, I agree with that precision. The fallback should name a projection, not hash the whole observed _meta.

For the no-2787 path, I would make the preimage something like:

  • the canonical tools/call params that are meant to bind to the decision;
  • a named binding block under _meta, for example _meta.authorization_binding;
  • the exact canonicalization/projection version used for that fallback fixture.

And explicitly exclude transport-local or observation-local _meta material from the digest, such as progress tokens, trace context, UI hints, unrelated SEP blocks, or fields a gateway/provider can legitimately add or strip.

The tests I would add around that are:

  1. gateway and provider views with different non-binding _meta fields still produce the same fallback digest.
  2. changing the named binding block changes the digest and breaks the decision back-link.
  3. changing bound params changes the digest and breaks the decision back-link.
  4. missing or malformed named binding block fails closed instead of falling back to whole _meta.
  5. the 2787 path continues to use the attestation structure directly, so this projection rule applies only to the no-attestation fallback.

That keeps the fallback reproducible without making _meta itself the authority boundary. The verifier obligation becomes: reconstruct the same named projection from the same named fields, then compare the digest. If the projection cannot be reconstructed, the fallback case is not conformant.

@Rul1an

Rul1an commented Jun 9, 2026

Copy link
Copy Markdown

@rpelevin yes, and the fail-closed rule is the one I would underline: a missing or malformed binding block fails the fallback case rather than quietly hashing the whole _meta. That is the property that stops an unobserved or stripped binding from silently widening the digest surface.

One addition and one wording nudge.

The addition: make the fallback projection version self-describing in the record, not only known per fixture. The verifier has to reconstruct the same named projection, so it needs to know which projection rule produced this digest without inferring it. If the decision record names the fallback projection it used (a small field on the back-link), a verifier picks the right reconstruction deterministically, and a later projection revision becomes an explicit version rather than a silent reinterpretation.

The wording nudge: state the binding as an allowlist, not a denylist. The preimage is exactly the named params plus the named binding block, and everything else under _meta is excluded by construction. The exclusion list (progress tokens, trace context, UI hints, unrelated SEP blocks) is good as illustration, but a denylist can never be complete, and the moment a gateway adds a field nobody enumerated a deny-everything-listed rule drifts again. Your first test already pins the right behavior: different non-binding _meta on the two sides must produce the same digest, which only holds if the rule is inclusion.

On our side this is the exact fixture shape Assay's consumer will verify against once we extend it to the no-attestation path, so the named-projection-plus-version contract is what we will reconstruct from committed bytes and report on.

@rpelevin

rpelevin commented Jun 9, 2026

Copy link
Copy Markdown

Yes, agreed. I would make those two points explicit in the note/fixture contract.

For the no-2787 fallback, the digest back-link should carry something like fallback_projection: "tools_call_params_plus_meta_authorization_binding_v1" or an equivalent named projection id.

That gives the verifier three stable facts:

  • which params are included;
  • which named _meta binding block is included;
  • which projection/canonicalization version produced the digest.

Then the rule can be written as an inclusion rule:

  • include the canonical bound tools/call params;
  • include only the named binding block under _meta;
  • exclude all other _meta fields by construction.

That is stronger than an exclusion list. Progress tokens, trace context, UI hints, or later SEP blocks can still be mentioned as examples, but they do not need to be enumerated for safety because they are outside the allowlisted projection.

Tests I would add:

  1. the decision record names the fallback projection version used for the back-link.
  2. a verifier reconstructs the digest only from the named params plus the named _meta binding block.
  3. additional non-binding _meta fields on either side do not change the digest.
  4. changing the projection id/version without recomputing the digest fails verification.
  5. missing, unknown, or malformed projection id fails closed rather than falling back to whole _meta.
  6. missing or malformed binding block fails closed rather than widening the digest surface.

That makes the fallback self-describing enough for a second implementation to reproduce, while keeping the boundary narrow: the named projection is the preimage, not the observed envelope and not all of _meta.

@dinpd

dinpd commented Jun 9, 2026

Copy link
Copy Markdown
Author

Thanks, this thread makes sense. I pushed a short non-normative "Signed Record Examples" note that keeps SEP-2828 illustrative rather than required.

The added note:

  • treats the conformance target as verifier obligations, not a mandated receipt schema;
  • names decision/outcome linkage, canonical request binding, same-call-instance binding, and issuer/audience/scope/freshness/integrity checks;
  • keeps runtime truth, issuer trust policy, and single-use / already-consumed state outside the signed record;
  • calls out positive and negative fixture expectations, including equal-timestamp ordering cases where the verifier should return an ambiguous or invalid result if there is no explicit sequence or revision field;
  • specifies the no-SEP-2787 fallback as a named inclusion projection rather than hashing the whole observed _meta.

For the fallback path, the note says the decision back-link should name a projection version such as tools_call_params_plus_meta_authorization_binding_v1; the digest preimage is the canonical bound tools/call params plus only the named _meta.authorization_binding block. Other _meta material is excluded by construction, and missing, unknown, or malformed projection identifiers or binding blocks fail closed.

Verification: git diff --check.

@rpelevin

rpelevin commented Jun 9, 2026

Copy link
Copy Markdown

Thanks, this lands cleanly. Keeping SEP-2828 illustrative and defining the compatibility target as verifier obligations feels like the right balance.

The fallback section also has the important boundary now: named inclusion projection, projection version on the back-link, and fail-closed behavior rather than hashing the whole observed _meta.

The only fixture set I would keep close to this note is the small reproducibility triad:

  1. same bound tools/call params plus same _meta.authorization_binding, with different non-binding _meta, still verifies.
  2. changed bound params or changed _meta.authorization_binding fails the digest/back-link check.
  3. missing, unknown, or malformed projection id/binding block fails closed rather than widening to all _meta.

That gives a second implementation a very concrete thing to reproduce from committed bytes without turning this into a mandated receipt schema.

This reads like the right non-normative landing shape to me.

@Rul1an

Rul1an commented Jun 9, 2026

Copy link
Copy Markdown

This is the right landing shape, and the fallback section now has the property that matters: a named inclusion projection with the projection version on the back-link, failing closed on a missing or malformed projection id or binding block rather than hashing the whole observed _meta.

On the reproducibility triad: Assay's consumer now implements exactly that and reproduces all three from committed fixtures rather than prose. Same bound params plus the same _meta.authorization_binding with different non-binding _meta verifies to the same digest; changing the bound params or the binding block breaks the back-link; a missing, invalid, or malformed binding block (or _meta) fails closed instead of widening to the whole envelope. The projection id is part of the digest preimage, so changing the projection version without recomputing also fails verification. That is the named-projection-plus-version contract from this thread, in code, reproducible by a second implementation from committed bytes.

One alignment note for when the canonical id settles: Assay currently binds a versioned id (assay.fallback_projection.v0) rather than the descriptive one sketched here (tools_call_params_plus_meta_authorization_binding_v1). The mechanism is identical and the version is bound in the preimage, so Assay will track whatever id the note pins via an explicit version bump rather than a silent reinterpretation.

The boundary stays where it was: this reproduces Assay's committed fixtures. Independently running an external corpus still depends on that corpus committing the named-projection input, so I am not claiming external interop yet, only that the named inclusion projection and the triad are reproducible from committed bytes.

@vaaraio

vaaraio commented Jun 9, 2026

Copy link
Copy Markdown

The SEP-2828 reference verifier now implements this shape, if it is useful to have a second thing to reproduce against:

  • inclusion projection: the preimage is the canonical tools/call params plus the named _meta.authorization_binding block, everything else under _meta excluded by construction;
  • the decision record names the version on the back-link (fallbackProjection: tools_call_params_plus_meta_authorization_binding_v1), so reconstruction is deterministic from the signed record rather than the observed envelope;
  • fail-closed on a missing, unknown, or malformed projection id or binding block, rather than widening to the whole _meta.

The committed vectors cover the triad above: gateway and provider views with different non-binding _meta produce the same digest, changed bound params or a changed binding block break the back-link, and a missing or unknown projection id fails closed. A Vaara-free checker reproduces every verdict from the committed bytes, so a second implementation has concrete fixtures to read.

One wire note: the back-link field is camelCase (fallbackProjection) to match the record's existing attestationDigest and attestationNonce, not the snake_case in the prose. Worth pinning one casing in the example so implementations agree.

@Rul1an the fixtures can line up with whatever Assay reconstructs once it covers the no-attestation path.

@Rul1an

Rul1an commented Jun 9, 2026

Copy link
Copy Markdown

Good to see a reference verifier carrying this shape, and I think it puts the one remaining question in sharp relief. Before anyone reads two implementations as interoperable, the agreement has to be against a contract the note owns, not against a single producer's committed vectors. A second implementation reproducing a first one's repo only shows those two agree, it does not show the projection is well defined. So the thing worth pinning in the note is the projection itself as the illustrative shape: the exact field name and casing on the back-link (camelCase fallbackProjection reads fine, it matches attestationDigest and attestationNonce), the named params and the binding block that go into the preimage, and the part that actually bites, whether the projection id lives inside the preimage or only as a selector on the back-link.

That last point is not hypothetical, and it is the useful evidence here. Two good-faith implementations already differ on it. Ours binds the projection id inside the digest preimage, the reference names it on the back-link and hashes params plus binding only. Both are reasonable, and neither reproduces the other until the note says which bytes are hashed. That is exactly the canonicalisation detail that surfaces later as out_of_scope when it is really a preimage mismatch, so it belongs in the note rather than in each implementation's private choice.

Which is really the line this thread and #2828 already settled, applied one level deeper. The conformance target is the SEP's verifier obligations and SEP-owned fixtures, with SEP-2828 and any reference verifier as illustrative producers rather than the definition. Assay reproduces that obligation set from its own committed fixtures today, and it will reproduce the SEP's fixtures once the projection bytes are pinned. I would much rather have both implementations conform to the note than reconstruct each other's repositories, since conforming to a shared contract is what keeps the format producer-agnostic, which is the boundary a few of us were keen to preserve.

@vaaraio

vaaraio commented Jun 9, 2026

Copy link
Copy Markdown

A quick correction on where the reference verifier lands, because I think we already agree on the point you put in sharp relief.

The reference binds the projection id inside the digest preimage, not only as a selector on the back-link. The preimage is a JSON object hashed under JCS, and the id is one of its members. For the committed fallback_envelope_binding fixture the canonical preimage bytes are:

{"arguments":{"limit":10,"table":"employees"},"authorizationBinding":{"nonce":"server-chosen-nonce-001"},"name":"query_table","projection":"tools_call_params_plus_meta_authorization_binding_v1"}

with digest sha256:aeac6a08f83dafe8569a1a195e2874b863a7e3c7d751b6c368dbc3850fd549bb.

So the id is in the hashed bytes. The fallbackProjection field on the back-link is the selector that tells a verifier which projection to reconstruct, and the same id string then appears in the preimage, so changing the version without recomputing breaks the digest. That is the property you described for Assay, so on the question of whether the id lives inside the preimage or only on the back-link, both implementations are on the same side: inside the preimage.

What actually differs is the id string and the exact byte layout, and I agree those belong in the note, not in either repo. Ours is tools_call_params_plus_meta_authorization_binding_v1, Assay's is assay.fallback_projection.v0. The mechanism is identical.

A concrete thing the note could pin, so a third implementation reproduces from the note rather than from a repo: the preimage is a JSON object, JCS-canonicalized, with exactly these members:

  • name and arguments: the canonical tools/call params
  • authorizationBinding: the contents of the _meta.authorization_binding block
  • projection: the projection version id

The digest is sha256 over the JCS encoding. The back-link carries fallbackProjection set to the same id, camelCase to match attestationDigest and attestationNonce.

One casing detail worth pinning while we are here: the wire _meta block is authorization_binding (snake_case, as the server emits it), but the preimage member and the back-link field are camelCase. Stating that rename explicitly keeps implementations on the same exact bytes.

If the note pins the id and this member list, the reference vectors line up against the note by an explicit version bump, and I would expect Assay to track it the same way. Conformance is then against the note's shape, not against either of our repos, which is the producer-agnostic boundary this thread has been protecting.

@Rul1an

Rul1an commented Jun 9, 2026

Copy link
Copy Markdown

You are right, and I recomputed it before saying so: the JCS preimage you committed hashes to aeac6a08…f549bb, with projection as a member of the hashed object. So I had the reference wrong on that point. Both implementations bind the id inside the preimage, not only as a back-link selector, and it is good to have it settled from the bytes.

The useful part is what that convergence shows. Two verifiers, written independently, already agree on the load-bearing property that the id is hashed, and they still do not reproduce each other's digest, because the member names and the id string differ. That is the whole argument for the note owning the bytes rather than either repo. When two good-faith implementations agree on the principle and still diverge on the preimage, the preimage is underspecified, and the place to fix that is the note, not a third implementation reading one of our corpora.

So the contract worth pinning is the projection itself, written so a third implementation reconstructs it from the note alone. The preimage is a JSON object, JCS-canonicalized and hashed with sha256, whose members are the canonical tools/call params, the named binding block, and the projection id. Your casing point belongs in it: the wire _meta.authorization_binding becomes the authorizationBinding member and fallbackProjection on the back-link, since that rename is exactly where two implementations silently drift apart. I would add one precision next to it. The params member is exactly name and arguments with all other _meta excluded, because the binding already rides as its own member, and if _meta leaks back into params the digests diverge again on the one path that has no attestation structure to fall back on. Pin those plus the id string and the reference vectors and Assay's both line up against the note by a version bump rather than against each other.

One thing the note still does not state, and it sits a layer below reconstruction. Pinning the bytes makes the fallback reproducible, it does not make it equivalent to the attestation path. The 2787 path binds the call instance through a signed structure, so the instance is cryptographically pinned. The fallback has no signed request attestation, so its per-call binding is the server-chosen nonce inside the binding block, which a verifier cannot check for freshness or uniqueness. Reconstructing the fallback digest proves the decision binds this named projection of the observed request, it does not prove the instance was unique, fresh, or non-replayable. Worth one sentence in the note so the fallback is not read as the attestation path's equal, with replay of a byte-identical call staying the authorization layer's job, as the single-use section already says.

On reproducing it, the thing I would rather build toward is a small set of SEP-owned fixture vectors carrying the pinned projection, so the reference, Assay, and a third implementation each verify against the note's bytes instead of against one producer's repo. Assay's consumer reproduces the triad and the ambiguous-supersession case from committed fixtures today, and once the note pins the member list and id I will reconstruct against the note's vectors and report what reproduces. Both implementations conforming to the note is the producer-agnostic boundary this thread set out to protect, and it is a stronger result than either of us reproducing the other.

@vaaraio

vaaraio commented Jun 9, 2026

Copy link
Copy Markdown

Agreed on all of it, and the convergence is the result worth keeping: two verifiers written independently agree the id is hashed inside the preimage, and the only things they disagree on are the member names and the id string. That is the case for the note owning the bytes rather than either repo.

To pin it concretely, the preimage is a JSON object, JCS-canonicalized, hashed with sha256, whose members are:

  • name and arguments: the canonical tools/call params, and only those two. All other _meta is excluded, because the binding rides as its own member and any _meta leakage back into params diverges the digest on the one path with no attestation structure to fall back on.
  • authorizationBinding: the contents of the wire _meta.authorization_binding block. The rename from snake_case on the wire to camelCase in the preimage is stated explicitly, since that is exactly where two implementations drift silently.
  • projection: the projection version id.

The back-link carries fallbackProjection set to that same id, camelCase to match attestationDigest and attestationNonce. That is the reference's shape today, and I read it as identical to Assay's modulo the id string, so both line up against the note by a version bump rather than against each other.

Your layer-below point belongs in the note, and I would keep it to the sentence you framed: pinning the bytes makes the fallback reproducible, not equivalent to the attestation path. The fallback has no signed request attestation, so its per-call binding is the server-chosen nonce, which a verifier cannot check for freshness or uniqueness. Reconstructing the digest proves the decision binds this named projection of the observed request; replay of a byte-identical call stays the authorization layer's job, as the single-use section already says.

On the fixtures, I would rather the note own them too. The reference's fallback_envelope_binding and the ambiguous equal-decidedAt case are committed and keyless to verify; I can contribute those as a starting set for SEP-owned vectors, rewritten to carry the pinned projection so the reference, Assay, and a third implementation each verify against the note's bytes instead of one producer's corpus. Once the member list and id are pinned I will move the reference's vectors to track the note and report what reproduces.

@rpelevin

rpelevin commented Jun 9, 2026

Copy link
Copy Markdown

Yes, this is the right convergence point. I would have the note own the projection contract, not just describe the principle.

The concrete contract I would want a third implementation to reproduce from the note alone is:

  • projection id: one pinned id string for this fallback fixture, carried on fallbackProjection and also inside the hashed preimage;
  • preimage: JCS-canonicalized JSON object with exactly name, arguments, authorizationBinding, and projection;
  • name and arguments: the canonical tools/call params only;
  • authorizationBinding: the contents of wire _meta.authorization_binding, with the snake_case-to-camelCase rename stated explicitly;
  • digest: sha256 over the JCS bytes;
  • all other _meta fields excluded by construction.

I would also keep your fallback-vs-attestation sentence close to the fixture: this proves the decision binds the named projection of the observed request; it does not prove freshness, uniqueness, or non-replayability of a byte-identical call. That remains the authorization/single-use layer's job.

The SEP-owned vectors could be very small:

  1. positive fallback_envelope_binding with preimage bytes, digest, and fallbackProjection.
  2. gateway/provider views with different non-binding _meta still produce the same digest.
  3. changed arguments or changed _meta.authorization_binding fails the back-link check.
  4. changed projection id or back-link/preimage id mismatch fails.
  5. missing/unknown/malformed projection or binding block fails closed.
  6. equal decidedAt under the same call binding with no sequence/revision is ambiguous/non-conformant.

That gives the reference verifier, Assay, and a third implementation the same producer-agnostic target: the note's bytes and verdicts, not each other's repositories. It also keeps the PR in its current lane: non-normative implementation guidance and conformance fixtures, not a required receipt schema.

@Rul1an

Rul1an commented Jun 9, 2026

Copy link
Copy Markdown

This convergence is the result the thread was working toward, and rpelevin's contract block is the thing I would land in the note close to as written: the pinned member list, the snake-to-camel rename stated explicitly, the digest rule, and the fallback-vs-attestation sentence kept next to the fixture. On the one open slot, the id string, I would pin the descriptive one. A note-owned contract should not carry any implementation's namespace, ours included, and tools_call_params_plus_meta_authorization_binding_v1 describes the bytes rather than naming a producer. Assay will retire assay.fallback_projection.v0 for the note's id by an explicit version bump once it lands.

On the vectors, the seed contribution is welcome, and I would pair it with one acceptance rule so the set is owned by the note in substance and not only in location: a contributed vector becomes SEP-owned once an independent implementation reproduces every verdict from the note's bytes alone, without reading the contributing repository. Contribution and acceptance staying separate is what makes the vectors a contract rather than one producer's corpus relocated. Assay's consumer will run exactly that cross-check once the pinned vectors land, against the note's bytes only, and I will report what reproduces and what does not, including any verdict we fail to reproduce.

One small sequencing point in favor of starting where vaaraio suggested: the fallback and equal-decidedAt cases are the right opening subset precisely because they are keyless. Any implementation can verify them from committed bytes without issuer keys or trust decisions, so disagreement there is always a contract bug, never a key-handling difference. The signed decision/outcome pairs are worth adding right after, and they bring one more thing for the note to pin: the fixture's test public key travels with the vectors, so verification stays self-contained while real issuer trust policy stays outside the record, where this thread already put it.

@rpelevin

rpelevin commented Jun 9, 2026

Copy link
Copy Markdown

Yes, I agree with that acceptance boundary.

For the id, I would pin tools_call_params_plus_meta_authorization_binding_v1. It names the projection, not the producer, and it matches the note-owned posture.

For the fixture corpus, I would make the ownership rule explicit:

  • a vector can be contributed by an implementation;
  • it becomes SEP-owned only after at least one independent implementation reproduces the expected verdict from the note text and fixture bytes alone;
  • the reproducer should not read the contributing repo's verifier code or private corpus;
  • any failure to reproduce should be reported as either a contract ambiguity or an implementation bug, not papered over by adjusting the fixture privately.

That gives the vectors the right force: they are not reference-output snapshots; they are executable examples of the note's normative bytes.

I also agree with starting with keyless cases. The first set can be:

  1. positive fallback_envelope_binding with preimage bytes, digest, and fallbackProjection.
  2. equal-decidedAt ambiguous supersession case.
  3. one negative vector where _meta leakage into params changes the digest.

Then add signed decision/outcome pairs with a fixture test public key carried in the vector bundle. The test key should prove the fixture is self-contained, not imply real issuer trust. Real issuer policy stays outside the record and outside the fixture acceptance rule.

That would make the note portable in the useful way: a third implementation can reproduce the fallback bytes, understand the fallback-vs-attestation limit, and know when a contributed vector has become a shared conformance target.

@dinpd

dinpd commented Jun 9, 2026

Copy link
Copy Markdown
Author

Thanks, this is useful, but I think this is now crossing into follow-up fixture/conformance work rather than this security guidance PR.

For this PR, I want to keep the note non-normative and focused on the verifier obligations: receipt authenticity, freshness, audience/scope binding, canonical request binding, retry suppression, and fail-closed behavior before provider business logic runs.

I agree with the core fallback boundary: the fallback projection should be named, should use an inclusion rule, and should not hash all of _meta. The current note captures that: the fallback names a projection version, includes the canonical tools/call params plus the named _meta.authorization_binding block, excludes other _meta fields by construction, and fails closed on missing or malformed projection identifiers or binding blocks.

Exact fixture vectors, projection member names, fixture ownership rules, and SEP-owned acceptance policy seem better handled in a separate follow-up PR or fixture discussion. Otherwise this docs guidance starts becoming an implicit receipt schema, which is the boundary this PR has been trying to avoid.

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

Labels

documentation Improvements or additions to documentation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants