Skip to content

Latest commit

 

History

History
440 lines (367 loc) · 26.2 KB

File metadata and controls

440 lines (367 loc) · 26.2 KB

Vaara Receipt Specification

Status: normative, stable. Version: vaara.receipt/v1. Canonical URL: https://github.com/vaaraio/vaara/blob/main/SPEC.md

This is the parent specification for a Vaara execution receipt: a signed, independently recomputable record that binds a decision about an agent action to the evidence it was made on, and optionally to one or more external timestamp anchors. Any system that emits or consumes Vaara receipts conforms to this document. Downstream specifications (a payment rail, a compliance regime, a framework integration) define profiles that pin to a version of this document and add only their own evidence schema; they do not redefine the envelope.

The receipt's trust is root-agnostic. The same record is verifiable with or without a hardware TEE and re-expressible as an IETF RATS EAR (AR4SI vector), whether rooted in a TPM 2.0 host, an AMD SEV-SNP confidential VM, or software alone. The signature and the optional external time anchor carry the evidence, not a single trust root.

The key words MUST, MUST NOT, REQUIRED, SHOULD, MAY are to be interpreted as in RFC 2119.

This document packages a format that already ships and is already recomputed by independent implementers. It invents nothing new. The executable conformance fixtures live at tests/vectors/x402_settlement_v0/ with a dependency-light checker (_check_independent.py) that imports only the standard library, cryptography, and rfc8785.

1. Canonicalization

All digests and all signed payloads in this specification are computed over the JSON Canonicalization Scheme (JCS, RFC 8785). The canonicalization label for the evidenceRef.canonicalization field (Section 3) is jcs-rfc8785. The values JCS and jcs-json-v1 are accepted aliases for the same algorithm; producers SHOULD emit jcs-rfc8785, consumers MUST accept all three.

A digest is written sha256: followed by the lowercase hex SHA-256 of the JCS-canonical bytes of the referenced object.

2. The receipt envelope

A receipt is a JSON object with these top-level members:

Field Type Required Meaning
version integer MUST Envelope version. 1 for this document.
alg string MUST Signature algorithm. ES256 in v1. A receipt that names anything else is rejected by the reference checkers.
backLink object MUST Binds this receipt to its attestation/predecessor: attestationDigest, attestationNonce.
decisionDerived object MUST The decision and the evidence it derives from. See Section 3.
issuerAsserted object MUST Issuer-asserted identity claims: iss, sub, iat, nonce, alg, secretVersion.
signature string MUST Detached signature, hex. For ES256, the 64-byte `r
timestampAnchors array MAY External time attestations over this receipt. See Section 4.

2.1 Signed payload

The signature is computed over the JCS-canonical bytes of the object containing exactly these members, in this set, with their receipt values:

("version", "alg", "backLink", "decisionDerived", "issuerAsserted")

signature and timestampAnchors are NOT part of the signed payload: a receipt can gain anchors after signing without invalidating the signature. A consumer MUST verify the signature by reconstructing this payload, canonicalizing it, and checking it against the public key under alg.

2.2 Post-quantum protection

There is no post-quantum alg value in v1. What ships is additive and lives next to the classical signature rather than replacing it: an execution record MAY carry a pqSignature sibling block (alg, keyid, sig) under a registered hybrid suite, ES256+ML-DSA-65 or RS256+ML-DSA-65, so a verifier that cannot do ML-DSA still verifies the classical signature and a verifier that can sees a stripped block as the downgrade it is. Vectors at tests/vectors/pq_hybrid_v0/, which needs dilithium_py (vaara[pq]) and skips without it. The signed handoff zip is separate again: it signs Ed25519 by default and ML-DSA-65 under the same extra.

3. Evidence binding (decisionDerived.evidenceRef)

decisionDerived carries the decision (decision, decidedAt, policyId, reason, riskScore, thresholdAllow, thresholdBlock) and one evidenceRef object that binds the decision to a recomputable evidence record:

Field Meaning
canonicalization The label from Section 1 (jcs-rfc8785 / JCS / jcs-json-v1).
digest sha256: of the JCS-canonical evidence record.
ref An advisory, profile-defined locator for the evidence record. Not an identifier: see below.
schema The schema id of the evidence record (profile-defined).

The binding is recomputable: given the receipt and the evidence record, a third party confirms sha256(JCS(evidence_record)) == evidenceRef.digest with no access to the issuer. This is the property independent implementers verify today.

digest is the binding; ref is advisory. Earlier revisions called ref an opaque locator, which implies it names exactly one record. It does not. A profile MAY assign the same ref to more than one evidence record, and profiles in use already do: where a single action settles to several parties, each party's record is a separate evidence record under one shared ref. Those records differ under digest because their contents differ.

A consumer therefore MUST NOT resolve an evidence record by ref alone, and MUST confirm sha256(JCS(evidence_record)) == evidenceRef.digest before treating the record as the one the receipt decided over. Resolving by ref alone admits a record that shares the locator but is not the record the issuer signed over, and no check in this document fails when it happens.

4. Timestamp anchors (timestampAnchors)

A timestamp anchor is an external attestation that this receipt existed no later than a stated time. Anchors are additive and optional. Each anchor binds the anchored digest = sha256: of the JCS-canonical signed payload (Section 2.1), so an anchor commits to the exact signed receipt without depending on later anchors.

{
  "method": "rfc3161",
  "anchoredDigest": "sha256:…",
  "token": "<method-specific time token>",
  "authority": "<optional human-readable authority id>"
}

Registered methods (the registry is open; a profile MAY register more):

method What it is Who can produce it
rfc3161 An RFC 3161 timestamp token from any Time-Stamping Authority. Self-hostable (e.g. OpenSSL ts); needs no third party.
rfc3161-eidas-qualified An RFC 3161 token from a qualified TSA under eIDAS. A qualified trust service provider. Adds legal / court-admissible weight; this is the only thing the qualification adds over rfc3161.
ledger A commitment of the anchored digest to a public ledger; the block time bounds existence. Self-producible; trust-minimized, no TSA.
scitt A transparency-log inclusion proof: the anchored digest is appended to a SCITT-compatible Merkle transparency log, and the entry carries the inclusion proof (sibling hashes) and the log's root hash at the time of append. The verifier recomputes the root from leaf + proof alone — no key, no operator to trust. The entry carries logId (base64 of the log identity digest), leafIndex, treeSize, inclusionProof (array of base64 sibling hashes), and rootHash (base64 Merkle root). Self-hostable via the in-process transparency log (producer: vaara.audit.scitt_anchor), or against a remote SCITT log (e.g. Sigstore Rekor).

A receipt MAY carry several anchors of different methods. The technical anchor (rfc3161, scitt) and the legal anchor (rfc3161-eidas-qualified) are independent: a producer can stand up its own time evidence and add qualified legal weight as a separate, swappable method. No single anchor method is load-bearing for the receipt's integrity, which rests on the Section 2.1 signature.

5. Profiles

A profile is a downstream specification that uses this envelope unchanged and defines only its own evidence record (the schema and contents behind evidenceRef), plus any join keys it needs. A profile MUST state the vaara.receipt/vN version it pins to and SHOULD ship recomputable vectors.

There is one binding mechanism, not one per plane. Each named profile (5.2-5.5) names an external artifact by content address and binds it through this envelope unchanged; they differ only in which artifact is hashed and the evidenceRef.ref label. Section 5.6 states that mechanism in schema-agnostic form: a single binding that does not depend on what is connected to it. The named profiles are instances of it, kept because a given ecosystem pins to a label it recognizes as its own.

5.1 Registry

Profile Evidence schema Pins to Vectors
x402 settlement binding x402.settlement.*/v0 vaara.receipt/v1 tests/vectors/x402_settlement_v0/
authorization decision vaara.authorization/v0 vaara.receipt/v1 tests/vectors/authorization_v0/, tests/vectors/contiguity_v0/
AP2 checkout binding vaara.authorization/v0 (names AP2 PEF frame_id) vaara.receipt/v1 tests/vectors/ap2_v0/
TAP request binding tap.request/v0 vaara.receipt/v1 tests/vectors/tap_v0/
generic external execution evidence vaara.authorization/v0 (names an external_execution_evidence slot) vaara.receipt/v1 tests/vectors/external_evidence_v0/

5.2 Profile example: x402 settlement binding

This profile binds an x402 payment settlement to a Vaara receipt across an action lifecycle, on a generic rail and on the Sui exact-payment rail. It adds:

  • A settlement record (schema = x402.settlement.<rail>/v0) whose JCS digest is the receipt's evidenceRef.digest.
  • A join key actionRef = sha256(JCS({agentId, actionType, scope, timestampMs, seq, terminal})), carried on the settlement, so an in-progress receipt (terminal: false) cannot be presented where the terminal one is required.

A third party recomputes three per-step verdicts (action-ref recomputes, settlement binding resolves, signature verifies) and one lifecycle verdict, with only the settlement and the receipt in hand. See _check_independent.py.

5.3 Profile example: authorization decision

This profile turns an enforcement decision into a receipt. A credential broker authorizes a tool call against a signed, attestation-bound grant with typed capability scopes; the gateway's verdict, allow or deny, is minted as a receipt instead of being discarded. The decision maps onto the envelope verdict vocabulary: an allowed call is allow, a refused call is block carrying the machine reason (capability_exceeded, binding_unknown, missing_credential, ...) as decisionDerived.reason. It adds:

  • An authorization record (schema = vaara.authorization/v0) whose JCS digest is the receipt's evidenceRef.digest. It binds toolName, tenantId, the grant by content address (grantFingerprint = sha256(JCS(signed grant))), the runtime argument commitment (argsCommitment = sha256(JCS(args))), the evaluated capabilities, and the verdict / reason.
  • The raw arguments never enter the record; only their commitment does, so the receipt is publishable while the arguments stay private. An auditor holding the arguments out of band recomputes the commitment and re-runs the verdict.
  • An optional coverage block names the observation boundary the decision was made under, inside the record and therefore under the signature. It binds the boundary (the chokepoint identity), the serverFingerprint (the exact capability surface in scope, manifest:sha256(JCS(tools)) or the command hash), and a scope literal stating that only calls routed through the chokepoint are observed. A tool reached on an out-of-band path is out of coverage. The block is absent when no boundary is asserted, leaving the record byte-identical to a coverage-free decision.
  • An optional completeness block scopes a sequence to that boundary, inside the record and therefore under the signature. It binds the boundaryId (the same boundary the coverage block names), a monotonic seq starting at 0 with no gaps by construction, and a runningCount equal to the total receipts issued under the boundary up to and including this one (runningCount = seq + 1). The block is absent when no sequence is asserted, leaving the record byte-identical to a completeness-free decision.
  • An optional sealing record finalizes the boundary: a terminal completeness block ({boundaryId, sealed: true, total: N}) that pins the boundary's final count independently of the per-record sequence. It is additive and emitted once the boundary is closed; a boundary that is never sealed verifies exactly as before, with the seal absent and the stream byte-identical. The seal may also carry maxClass, the highest action class the boundary authorized; it bounds a gap's worst case (see Section 5.3) and is itself optional.

A verdict is only as meaningful as what the issuer could see. allow over an unbounded surface and allow over a stated one are identical bytes with opposite meaning, so an absent refusal reads as fact only against a declared scope: "not refused within this boundary", never "not observed". The coverage block carries that boundary in the trace itself, so it is recomputable evidence rather than a separate trust root. The verdict stays a thin read over it. The chokepoint remains an observer of what passes through it, not a claim about what does not.

The deny case is the point. A refused call leaves a signed, content-addressed, portable proof of the non-action: a third party recomputes the verdict from the grant and the arguments and confirms the refusal, trusting only the issuer's public key. A third party recomputes five verdicts per case (grant fingerprint, argument commitment, capability verdict, evidence binding, signature) with only the grant, the arguments, the evidence, and the receipt in hand. See _check_independent.py.

Coverage states the boundary; completeness makes a gap inside it provable. With the per-boundary seq contiguous by construction and the runningCount signed into each record, a dropped receipt is a missing sequence number that any holder detects from the receipts alone: the highest running count names how many exist, so a short set is self-evidently incomplete and the absent seq is named. This needs no issuer access and no external witness. The tests/vectors/contiguity_v0/ vectors and the vaara verify-contiguity surface carry that check.

The per-record running count alone cannot tell a pure tail truncation (holding 0..k with nothing after) from a complete stream, since the latest held count is then k + 1 and reads as whole. The optional sealing record closes that gap: when a boundary is finalized, the holder expects max(seq + 1, runningCount, total) records, so a dropped tail shows as the missing range up to the sealed total. A boundary that is never sealed verifies exactly as before. One residual remains, and it is irreducible from the held set alone: a suffix drop that also suppresses the sealing record leaves nothing to detect. Closing that is the job of an rfc3161 anchor over the running count (Section 4), which attests that at time T, N receipts existed under the boundary. The layering is seq for order, the hash chain for tamper-evidence, the sealing record for a truncated tail, and the timestamp anchor for the seal-suppressed residual.

A gap proves that a record is absent but not what it would have authorized. When worst-case-governs is the reading, the seal's optional maxClass bounds it: it names the highest action class the boundary authorized, so a missing record could have authorized an action of at most that class. The verifier surfaces this as worstCaseClass, computed from the held set and the seal alone, with no issuer. The field is optional; absent it, a gap reports only that a record is missing.

Beyond bounding a gap at audit time, the sealed maxClass is consumable at enforcement time. A chain recipient gating its own next unattended action holds a policy set of action classes it will proceed under and permits iff the sealed worst-case class is a member of that set, failing closed when no class is sealed. This is a membership test, not an ordering: Section 5.3 computes no ordering over class labels, so the recipient asks "is the sealed class one I permit," never "is it at or below a ceiling." Because the seal bounds a gap's worst case at maxClass, a permitted class permits even when the boundary has a gap: the recipient consumes the committed bound and does not re-derive the chain or query a log. The bound is trustworthy under the honest issuer whose seal commits before any tail is trimmed; a seal that under-states the class is a reconciliation question against the issuer's log, not one this held-set-alone gate answers.

maxClass lives in the unsigned evidence block, so a recipient MUST NOT consume it raw. It rides under signature only through the binding: the seal's signed decisionDerived.evidenceRef.digest is sha256: + JCS(evidence), so recomputing that digest proves the class is the class that was signed. Before gating, a recipient MUST verify each receipt's signature and that its evidence recomputes to the signed digest; a seal whose binding fails is not trusted, contributes no class, and the gate fails closed. Without this, an agent loosens the gate by relabeling an irreversible action's class into a permitted one while the record signature, which never covered the evidence, still verifies. The conformance vectors are in tests/vectors/class_gate_v0/; the deny_relabeled case carries exactly this attack and the independent checker rejects it.

5.4 Profile example: AP2 checkout binding

This profile binds an AP2 checkout to the post-checkout agent actions a credential broker authorizes, so the actions taken after a payment settles carry the same recomputable, gap-evident record as the authorization decisions in 5.3. It reuses the vaara.authorization/v0 evidence record unchanged and adds a join to the AP2 Payment Evidence Frame (PEF, AP2 PR #274):

  • The AP2 checkout emits a PEF whose frame_id = sha256(JCS(frame)), with frame_id and signature excluded from the preimage, and whose receipt_hash = sha256(JCS(receipt)) content-addresses the wrapped Checkout Receipt. Canonicalization is urn:x402:canonicalisation:jcs-rfc8785-v1 (JCS / RFC 8785), the same as this envelope, so the address joins with no re-canonicalization.
  • Each post-checkout authorization receipt names the checkout it followed by content address: decisionDerived.evidenceRef.ref = ap2:checkout/<frame_id>, under the receipt signature. The AP2 task scope is the coverage.boundary (5.3), and the completeness block sequences the actions under it.

The identity of the checkout is the PEF frame_id, a content address the payment side already computes; the completeness of the actions taken under it is the vaara.authorization/v0 contiguity stream. A per-action hash says an action was recorded; the running count says none inside the AP2 task boundary was dropped. A third party recomputes the frame address, confirms every receipt names that checkout, resolves each evidence binding, verifies each signature, and re-runs the gap check, with only the PEF and the held receipts in hand. See tests/vectors/ap2_v0/_check_independent.py. AP2 can pin from the point the Checkout Receipt ends rather than define a new post-settlement primitive.

5.5 Profile example: TAP request binding

This profile binds a Visa Trusted Agent Protocol (TAP) request to the action a trusted agent takes under it, across the action lifecycle, so the post-authorization record is the same recomputable evidence as any other decision receipt. It adds a TAP request evidence record (schema = tap.request/v0) whose JCS digest is the receipt's evidenceRef.digest, and the join key actionRef = sha256(JCS({agentId, actionType, scope, timestampMs, seq, terminal})) carried on the request:

  • The trusted agent presents the TAP request to the relying party. The decision receipt names it by content address: decisionDerived.evidenceRef.digest = sha256(JCS(request)), decisionDerived.evidenceRef.ref = tap:request/<actionRef>, both under the receipt signature. Canonicalization is JCS / RFC 8785, the same as this envelope, so the address joins with no re-canonicalization.
  • The lifecycle lives in the join key. Because the action tuple covers terminal, the in-progress (terminal: false) request has a different actionRef than the final (terminal: true) one, and the in-progress receipt does not resolve against the terminal request. A mid-action receipt cannot be presented where the final one is required.

The verdict is recomputable offline. A third party recomputes the action ref, resolves the request binding, and verifies the signature with only the TAP request, the held receipts, and the issuer's public key, with the TAP service offline and no live verifier endpoint to trust. See tests/vectors/tap_v0/_check_independent.py. TAP can pin to vaara.receipt/v1 for the post-authorization record rather than define a new primitive.

5.6 Profile: generic external execution evidence

This is the schema-agnostic binding the named profiles above are instances of. It takes any external execution-evidence artifact, content-addresses it, and binds it through this envelope unchanged, with no field names that depend on what produced it. A verifier carrying an external_execution_evidence slot (linked_call_id / evidence_hash / evidence_type, the shape used by agentrust trace-spec #34 and cMCP #301) resolves that slot against a vaara.receipt/v1 authorization receipt as the recomputable producer:

  • evidence_hash = sha256(JCS(evidence_record)), equal to the receipt's decisionDerived.evidenceRef.digest, so the slot and the receipt name the same recomputable artifact (JCS / RFC 8785, no re-canonicalization).
  • linked_call_id is the call the receipt names: decisionDerived.evidenceRef.ref = mcp:call/<linked_call_id>, under the receipt signature.
  • evidence_type is the receipt's evidence schema (vaara.authorization/v0).

The trace is the coverage.boundary, and each receipt carries a signed completeness block (seq + runningCount), so the held set proves not only that each named call's evidence resolves but that none inside the boundary was dropped. A slot's evidence_hash alone proves a given record exists; the completeness block turns a silent drop into a named gap. The dropped vector withholds one record, slot and receipt both, and the signed running count still proves it existed.

A third party recomputes every verdict offline with only the held slots, the receipts, and the issuer's public key, with no live verifier endpoint to trust. See tests/vectors/external_evidence_v0/_check_independent.py. Any plane that emits execution evidence pins here by naming its artifact through this slot, rather than defining a new primitive or a profile of its own.

6. The ingest envelope (vaara.ingest/v0)

The profiles in Section 5 bind external evidence into a vaara.receipt/v1 decision: they carry a verdict, or a back-link, or both. Not every foreign record is a decision. An adjacent log line, an identity assertion, a denial, an invocation context establishes something narrower, and forcing it into a receipt or an authorization envelope would fabricate a verdict or a back-link the source never carried. The ingest envelope is the sink for exactly that case: it wraps any foreign record, content-addressed, and asserts nothing the source did not establish.

It is a sibling envelope to vaara.receipt/v1, not a profile of it, and reuses the Section 1 canonicalization and the Section 2.1 signing construction unchanged. The signed payload is:

  • schema = vaara.ingest/v0, version, alg.
  • sourceFormat, the recognized format of the foreign record (or unknown).
  • evidenceRef: digest = sha256(JCS(normalized_evidence)), canonicalization = JCS, schema = vaara.normalized-evidence/v0, and an optional non-authoritative ref locator.
  • ingestAsserted: iss / sub / iat / nonce / secretVersion / alg.
  • completeness: a per-stream seq and runningCount; a lone ingest is seq 1 of a one-record stream. Note the difference from Section 5.3: the ingest stream counts from 1, so runningCount equals seq here, where an authorization stream counts from 0 and runningCount is seq + 1. A contiguity checker written for one is wrong by one on the other.

signature is appended over the JCS encoding of that payload.

The normalized evidence object pinned by evidenceRef.digest carries the SEP-2828 fields the source establishes (sep2828), the context it carries that is not on its own a proof (advisory), and the honest gap report (missing): what a complete signed record still needs that this source does not supply. Because the object is bound by digest under the signature, editing the gap report, a proof field, or the source format breaks verification. The sink never launders a weak source into a strong-looking receipt; the missing list is the record admitting what it is not.

7. Conformance

An implementation conforms to vaara.receipt/v1 if, for every receipt it emits:

  1. The Section 2.1 signature verifies against the stated alg and key.
  2. evidenceRef.digest equals sha256(JCS(evidence_record)) for the referenced record, under one of the Section 1 canonicalization labels.
  3. Any timestampAnchors[].anchoredDigest equals the sha256: of the JCS signed payload of the same receipt.

The committed vectors plus _check_independent.py are the reference conformance suite; python tests/vectors/x402_settlement_v0/_check_independent.py exiting 0 is a passing run for the x402 profile. A vaara.ingest/v0 envelope conforms when the evidence object recomputes to evidenceRef.digest and the signature verifies, both reproducible with no Vaara import; python tests/vectors/ingest_v0/_check_independent.py exiting 0 is a passing run.

8. Versioning

The envelope version is the integer version field and the vaara.receipt/vN schema id. Additive, backward-compatible changes (new optional fields, new anchor methods, new profiles) do not bump N. A change to the signed-payload field set, the canonicalization, or the signature construction bumps N.