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.
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.
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. |
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.
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.
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.
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.
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.
| 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/ |
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'sevidenceRef.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.
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'sevidenceRef.digest. It bindstoolName,tenantId, the grant by content address (grantFingerprint=sha256(JCS(signed grant))), the runtime argument commitment (argsCommitment=sha256(JCS(args))), the evaluatedcapabilities, and theverdict/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
coverageblock names the observation boundary the decision was made under, inside the record and therefore under the signature. It binds theboundary(the chokepoint identity), theserverFingerprint(the exact capability surface in scope,manifest:sha256(JCS(tools))or the command hash), and ascopeliteral 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
completenessblock scopes a sequence to that boundary, inside the record and therefore under the signature. It binds theboundaryId(the same boundary thecoverageblock names), a monotonicseqstarting at 0 with no gaps by construction, and arunningCountequal 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 carrymaxClass, 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.
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)), withframe_idandsignatureexcluded from the preimage, and whosereceipt_hash=sha256(JCS(receipt))content-addresses the wrapped Checkout Receipt. Canonicalization isurn: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 thecoverage.boundary(5.3), and thecompletenessblock 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.
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 differentactionRefthan 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.
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'sdecisionDerived.evidenceRef.digest, so the slot and the receipt name the same recomputable artifact (JCS / RFC 8785, no re-canonicalization).linked_call_idis the call the receipt names:decisionDerived.evidenceRef.ref=mcp:call/<linked_call_id>, under the receipt signature.evidence_typeis 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.
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 (orunknown).evidenceRef:digest=sha256(JCS(normalized_evidence)),canonicalization=JCS,schema=vaara.normalized-evidence/v0, and an optional non-authoritativereflocator.ingestAsserted:iss/sub/iat/nonce/secretVersion/alg.completeness: a per-streamseqandrunningCount; a lone ingest isseq 1of a one-record stream. Note the difference from Section 5.3: the ingest stream counts from 1, sorunningCountequalsseqhere, where an authorization stream counts from 0 andrunningCountisseq + 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.
An implementation conforms to vaara.receipt/v1 if, for every receipt it emits:
- The Section 2.1 signature verifies against the stated
algand key. evidenceRef.digestequalssha256(JCS(evidence_record))for the referenced record, under one of the Section 1 canonicalization labels.- Any
timestampAnchors[].anchoredDigestequals thesha256: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.
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.