Vaara Receipt Specification

September 25, 2026 · View on GitHub

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 in IETF RATS EAR claims (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:

FieldTypeRequiredMeaning
versionintegerMUSTEnvelope version. 1 for this document.
algstringMUSTSignature algorithm. ES256 in v1. A receipt that names anything else is rejected by the reference checkers.
backLinkobjectMUSTBinds this receipt to its attestation/predecessor: attestationDigest, attestationNonce.
decisionDerivedobjectMUSTThe decision and the evidence it derives from. See Section 3.
issuerAssertedobjectMUSTIssuer-asserted identity claims: iss, sub, iat, nonce, alg, secretVersion.
signaturestringMUSTDetached signature, hex. For ES256, the 64-byte `r
timestampAnchorsarrayMAYExternal 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:

FieldMeaning
canonicalizationThe label from Section 1 (jcs-rfc8785 / JCS / jcs-json-v1).
digestsha256: of the JCS-canonical evidence record.
refAn advisory, profile-defined locator for the evidence record. Not an identifier: see below.
schemaThe 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):

methodWhat it isWho can produce it
rfc3161An RFC 3161 timestamp token from any Time-Stamping Authority.Self-hostable (e.g. OpenSSL ts); needs no third party.
rfc3161-eidas-qualifiedAn 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.
ledgerA commitment of the anchored digest to a public ledger; the block time bounds existence.Self-producible; trust-minimized, no TSA.
scittA Merkle-log inclusion proof. The anchored digest is appended as a leaf to an append-only log hashed as in RFC 6962, and the entry carries logId (base64 of the log identity digest), leafIndex, treeSize, inclusionProof (array of base64 sibling hashes) and rootHash (base64 Merkle root at the time of append). The verifier recomputes the root from leaf and proof. The identifier is historical: this is not registration with an IETF SCITT transparency service, and the entry is not a COSE receipt. rootHash is the log operator's own claim. It witnesses the receipt only when the verifier checks it against a tree head held independently of the receipt, directly at the same tree size or through an RFC 9162 consistency proof to a later head.Self-hostable: vaara receipt anchor-scitt appends to a file-backed log, anchor-scitt-head prints the head to publish, verify-scitt --head checks an anchor against a held head (producer: vaara.audit.scitt_anchor).
rfc3161-blindedrfc3161, with the authority shown a salted digest instead of the anchored digest. The entry additionally carries anchorSalt (64 lowercase hex characters, 32 bytes). See Section 4.1.Same as rfc3161.
rfc3161-eidas-qualified-blindedrfc3161-eidas-qualified, blinded as above. The qualified time is unaffected.Same as rfc3161-eidas-qualified.

4.1 Blinded anchors

An unblinded anchor sends the timestamping authority exactly the value the receipt then publishes as anchoredDigest. An authority keeps a request log, every entry in it sits behind a customer account, and a log that is sold, breached or produced under compulsion lets whoever holds it match its entries against any corpus of published receipts. That match reveals which receipts a named customer anchored and when, without breaking any signature.

A blinded anchor closes that match. The producer draws a fresh 32-byte salt, sends the authority

sha256( "vaara/anchor-blind/v1" || salt || anchoredDigest_bytes )

and carries the salt in the anchor entry as anchorSalt. anchoredDigest still names the Section 2.1 signed payload, so the receipt binding is unchanged.

A producer using a blinded method MUST draw the salt from a cryptographic random source and MUST NOT reuse a salt across anchors, since two anchors under one salt are linkable to each other. A verifier MUST recompute the imprint from anchoredDigest and anchorSalt and MUST reject a blinded anchor whose anchorSalt is absent or is not 32 bytes of hex. A verifier MUST reject an unblinded method that carries anchorSalt, because a verifier that ignores the member would read a blinded anchor as a plain one.

What this does and does not buy. It stops a party holding only the authority's log from matching that log against receipts it was not given. It does not make the anchor unlinkable to anyone holding the receipt: the salt travels with the receipt precisely so a holder can verify, and any holder can therefore recompute the imprint and find the log entry. It also does not hide the fact, timing or volume of anchoring from the authority itself, which sees the request as it happens.

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.

Section 5.7 is the one profile that runs the other way. It does not bind an artifact into a receipt; it names a receipt as the condition on which something external happens. It is listed here because it pins to the same envelope and ships recomputable vectors, not because it is another instance of the binding.

5.1 Registry

ProfileEvidence schemaPins toVectors
x402 settlement bindingx402.settlement.*/v0vaara.receipt/v1tests/vectors/x402_settlement_v0/
authorization decisionvaara.authorization/v0vaara.receipt/v1tests/vectors/authorization_v0/, tests/vectors/contiguity_v0/
AP2 checkout bindingvaara.authorization/v0 (names AP2 PEF frame_id)vaara.receipt/v1tests/vectors/ap2_v0/
TAP request bindingtap.request/v0vaara.receipt/v1tests/vectors/tap_v0/
generic external execution evidencevaara.authorization/v0 (names an external_execution_evidence slot)vaara.receipt/v1tests/vectors/external_evidence_v0/
release conditionvaara.release-condition/v0 (consumes vaara.authorization/v0)vaara.receipt/v1tests/vectors/release_condition_v0/
attribute attestationvaara.attribute-attestation/v0vaara.receipt/v1tests/vectors/attribute_attestation_v0/
hidden-value attribute attestationvaara.attribute-attestation-zk/v0 (proved by vaara.attribute-predicate/v0)vaara.receipt/v1tests/vectors/attribute_attestation_zk_v0/
engine decisionvaara.trail-decision/v0vaara.receipt/v1tests/vectors/trail_decision_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.

5.7 Profile: release condition (vaara.release-condition/v0)

Every profile above runs one direction: something external happens, and the receipt records it. Section 5.2 is the clearest case, where a payment gates access and the settlement lands inside a receipt. In this profile the receipt gates the payment.

A release condition is a signed, content-addressed statement made by whoever holds value: what is held, exactly what must be proved before it moves, and when the offer closes. Unlike the profiles above it does not sit behind an evidenceRef; it names a vaara.receipt/v1 receipt as its release trigger. It adds:

  • A condition document (schema = vaara.release-condition/v0) carrying holds (amount as a decimal string, asset, network, payee), requires, and an inclusive notAfter. The signature is over JCS(condition without "signature"), the same rule the receipt envelope uses, so it needs no new cryptography.
  • A requires block that is matched exactly, never approximately: actionDigest (the argsCommitment of the authorised call), grantFingerprint (the authorization that governed it), receiptIssuer, receiptKeyFingerprint (sha256 over the SubjectPublicKeyInfo DER of the one key whose receipts count), decision, and evidenceSchema.
  • A decision (vaara.release-decision/v0) naming the conditionDigest it was computed against, so a decision cannot be replayed against a re-issued condition.

The document holds no key belonging to a payer, signs no transaction, and reaches no chain or custodian. It answers one question about bytes, a settlement agent acts on the answer, and the verifier sits in the settlement path holding nothing.

Evaluation returns one of four states, each carrying a reason from a closed set:

statemeaning
releasedthe authorised action is proved
heldthe evidence is sound and insufficient, or none has been presented
expiredthe window closed
refusedthe presented artifact fails as evidence

A verifier that proved nothing MUST NOT read as green, and MUST NOT read as the same false as a genuine failure. held because no receipt arrived and refused because a receipt was tampered with are different facts, and one boolean for both discards the difference between "not yet" and "no". Implementations MUST partition the reason space so that each reason belongs to exactly one state. A third boolean beside a pass/fail does not satisfy this: the partition is what keeps the two negatives from collapsing.

The axis is soundness, then sufficiency. A broken condition signature, a receipt signed under a key the condition does not pin, a broken receipt signature, or evidence that does not resolve to the digest the receipt signed are all failures as evidence: refused. A missing receipt, a receipt for another action, another authorization, another issuer, or one that soundly proves a refusal are sound and insufficient: held. Checks MUST run soundness before the clock, so an expired window cannot swallow a tampering finding, and the clock before sufficiency, so a closed window is reported as the reason the value is not moving.

A third party recomputes every verdict from the condition, the receipt, the evidence and the two public keys, with no issuer access. See tests/vectors/release_condition_v0/_check_independent.py; the vaara release-check verb is the same evaluation at the command line.

5.8 Profile: attribute attestation (vaara.attribute-attestation/v0)

Section 5.7 asks what a receipt is worth when money is waiting. This one asks what a value is worth. An attribute attestation binds a subject to attribute values, states where each value came from, and says how long it holds.

Any signed record can assert an attribute. Whether the assertion is evidence depends entirely on its source, so every attribute MUST name its own, drawn from a closed and totally ordered set:

standingmeaning
undeclarednothing is claimed about where the value came from
operator_declaredthe party being judged supplied it
measuredthe issuer observed it directly
protocol_definedthe value is fixed by a specification and cannot differ

protocol_defined outranks measured because a value fixed by a specification cannot be wrong, while a measurement can come from a faulty sensor. undeclared is the floor and MUST NOT convert upward. A verifier that encounters a standing outside this set MUST treat the attestation as malformed and MUST NOT floor it to undeclared, because a verifier that silently downgrades what it does not recognise lets an issuer introduce a standing of its own.

A relying party states the floor it requires. Evaluation returns one of accepted, withheld, expired or refused, each carrying a reason from a closed set, with the reason space partitioned exactly as in Section 5.7. A value below the floor is sound evidence of a claim and no evidence of a fact: it withhelds, and it MUST NOT be reported the same way as a broken signature.

Checks run in the order soundness, clock, sufficiency. Soundness MUST precede the clock so an expired window cannot swallow a broken signature.

The signature is over the JCS encoding of the document with its own signature member removed, the same rule as Section 5.7 and the data-locality record, so a verifier that checks one checks all three with no new code. Attributes are emitted sorted by name so two issuers building the same statement produce the same bytes. Both ends of the validity window are inclusive.

What this is not. A vaara.attribute-attestation/v0 document is not a qualified electronic attestation of attributes under Regulation (EU) 910/2014 and MUST NOT be described as one, or as qualified, in any conforming implementation or its documentation. Those terms are tied to a supervised, audited entry on a Member State trusted list, and no cryptographic property substitutes for the listing. An attestation issued and signed by the party it describes proves integrity and never independence; implementations SHOULD surface that standing rather than omit it. A qualified timestamp anchor (Section 4) raises the confidence in when the attestation existed and changes nothing about the standing of its contents.

Vectors are in tests/vectors/attribute_attestation_v0/, whose checker also asserts that the reason-to-state mapping covers all four states and that the standing ladder is a total order.

5.9 Profile: hidden-value attribute attestation (vaara.attribute-attestation-zk/v0)

Section 5.8 asks what a value is worth. This one asks what an issuer has to keep in order to say it.

An attestation provider that vouches for an attribute has to hold the attribute. Anything held can be sold, subpoenaed, breached or repurposed, and a policy statement does not change what the holder is capable of. This profile commits to the value at issuance and hands the opening to the holder, so what remains on the issuer's side is a commitment and a signature over it.

One field changes from Section 5.8:

5.8:   {name, value,      source, sourceDetail}
5.9:   {name, commitment, source, sourceDetail}

source and sourceDetail stay in the clear and stay on the same closed, totally ordered ladder, with the same floor rule and the same prohibition on flooring a standing a verifier does not recognise. A relying party is entitled to judge how strongly a value was sourced, and is entitled to nothing further.

The issuance ritual

An issuer conforming to this profile MUST, for each attribute:

  1. draw a fresh blind, uniform over the scalar field, and compute the commitment
  2. sign the document containing the commitment
  3. hand the value and the blind to the holder
  4. retain neither

Step 4 is the property the profile exists for. A blind MUST NOT be reused across issuances: two commitments to the same value under the same blind are equal, and a relying party holding both learns that the values match.

Commitments and predicates

Commitments are Pedersen commitments C = v*G + r*H over NIST P-256. H is derived by hash-to-curve from a fixed public label, so its discrete logarithm to G is unknown by construction, there is no trusted setup, and any party recomputes H from the published label. Commitments are perfectly hiding and computationally binding.

A relying party asks whether a predicate holds over the hidden value. Three kinds are defined, and all three reduce to the same range argument over a shifted commitment, because Pedersen commitments add:

predicateprover showsverifier target
at_leastvalue - lower is in rangeC - lower*G
at_mostupper - value is in rangeupper*G - C
in_rangeboth, in that orderboth, in that order

The blind follows the shift: it stays as issued for the at_least direction and negates for the at_most direction. A witness outside the proved interval has no valid bit decomposition, so a predicate that does not hold has no proof, and a conforming prover MUST refuse to emit one rather than emit something that will not verify.

Each proof's Fiat-Shamir transcript MUST be seeded with the attestation digest, the attribute name, the JCS encoding of the predicate, and the direction, so a proof does not transfer to another document, another attribute or another threshold. A verifier MUST reject a proof whose envelope names an attestation digest, attribute or predicate other than the one being asked about, and MUST report that rejection separately from a proof that is bound and fails to verify.

Evaluation

Evaluation returns accepted, withheld, expired or refused, each carrying a reason from a closed set, partitioned as in Sections 5.7 and 5.8. Checks run in the order soundness, clock, sufficiency, and within sufficiency the presented proof is judged before the standing floor, so a forged proof is reported as forged rather than as merely weaker than what was asked for.

A presented proof that is absent and a presented proof that is invalid MUST NOT share a state. Nothing proved is not the same fact as something forged, and one boolean for both discards the difference between "not yet" and "no".

Limits, stated rather than implied

This is not selective disclosure. One signature covers every commitment in the document. A holder cannot present three attributes out of ten from a single signed credential; that requires a signature scheme built for it and is outside this profile. Per-attribute commitment covers the model above and nothing wider.

This is not qualified. A vaara.attribute-attestation-zk/v0 document is not a qualified electronic attestation of attributes under Regulation (EU) 910/2014 and MUST NOT be described as one, or as qualified, in any conforming implementation or its documentation. Those terms are tied to a supervised, audited entry on a Member State trusted list, and no cryptographic property substitutes for the listing.

The issuer is still trusted for the value at issuance. Hiding the value protects it from the relying party and from anyone the issuer might later sell to. It says nothing about whether the issuer committed to the truth. This is the same residual documented in docs/prove-what-an-ai-agent-did.md: a record proves what was recorded and does not prove that the recording was honest.

Discarding is structural, not physical. A conforming implementation removes the reason to retain a value, which is what a subpoena, a breach or a change of ownership reaches. It does not and cannot guarantee that no copy survives in process memory, in a backup, or in whatever produced the value upstream.

Values are bounded integers. Every committed value and every predicate bound MUST lie in [0, 2**32), which is the interval the range argument proves membership of. A value outside it MUST be refused at issuance. String attributes are not carried by this version; they would require a membership proof against a committed set.

Vectors are in tests/vectors/attribute_attestation_zk_v0/, whose checker rebuilds the curve arithmetic, the commitments and the range argument from the published parameters and imports no Vaara. It also asserts, before grading any case, that H recomputes from its label, that commitments are additively homomorphic, that the same value under two blinds gives two different commitments, and that a missing proof and a broken proof land in different states.

5.10 Profile: engine decision (vaara.trail-decision/v0)

The engine writes one of these receipts for every decision it records on its audit trail, beside the trail at receipts/<YYYY-MM-DD>/<recordId>.json. The file holds the envelope under receipt and the evidence record under evidence. The evidence record is the decision as the trail holds it:

FieldMeaning
schemavaara.trail-decision/v0
recordId, actionIdThe trail record and the action it decided.
eventTypedecision_made or action_blocked.
agentId, toolName, tenantIdAs recorded. tenantId is "" when unset.
decision, reasonThe trail's words: allow, escalate or deny, and its reason.
riskScoreDecimal string.
decidedAtISO 8601 UTC, milliseconds.
recordHashsha256: and the trail record's own hash.
previousHashsha256: and the hash of the record before it. An empty genesis link is written as the SHA-256 of the empty string.
decisionDetail, approver, humanDisposedPresent only when the trail record carries them.

The envelope writes the trail's deny as block. backLink.attestationDigest is previousHash, backLink.attestationNonce is recordId, and evidenceRef.ref is vaara:trail/<recordId>. The issuer public key sits beside the receipts as issuer-es256.pub.pem, and issuerAsserted.secretVersion names it as es256: plus the first 16 hex characters of SHA-256 over its DER SubjectPublicKeyInfo.

A verifier checks the signature and the evidence digest as in Sections 2.1 and 3. With the trail in hand it also looks up recordId and confirms the stored record hash matches recordHash. A receipt whose record is missing from the trail, or whose hash differs, fails. The evidence record carries no tool arguments, so a receipt can leave the machine without them.

Vectors are in tests/vectors/trail_decision_v0/: receipts written by the engine's own sink over a SQLite trail, tampered copies, the trail's record hashes, and expected.json with each file's verdict. The macOS app's verifier checks the same files.

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.