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:
| 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 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-blinded | rfc3161, 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-blinded | rfc3161-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
| 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/ |
| release condition | vaara.release-condition/v0 (consumes vaara.authorization/v0) | vaara.receipt/v1 | tests/vectors/release_condition_v0/ |
| attribute attestation | vaara.attribute-attestation/v0 | vaara.receipt/v1 | tests/vectors/attribute_attestation_v0/ |
| hidden-value attribute attestation | vaara.attribute-attestation-zk/v0 (proved by vaara.attribute-predicate/v0) | vaara.receipt/v1 | tests/vectors/attribute_attestation_zk_v0/ |
| engine decision | vaara.trail-decision/v0 | vaara.receipt/v1 | tests/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'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.
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'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.
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)), 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.
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 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.
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'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.
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) carryingholds(amount as a decimal string, asset, network, payee),requires, and an inclusivenotAfter. The signature is overJCS(condition without "signature"), the same rule the receipt envelope uses, so it needs no new cryptography. - A
requiresblock that is matched exactly, never approximately:actionDigest(theargsCommitmentof the authorised call),grantFingerprint(the authorization that governed it),receiptIssuer,receiptKeyFingerprint(sha256over the SubjectPublicKeyInfo DER of the one key whose receipts count),decision, andevidenceSchema. - A decision (
vaara.release-decision/v0) naming theconditionDigestit 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:
| state | meaning |
|---|---|
released | the authorised action is proved |
held | the evidence is sound and insufficient, or none has been presented |
expired | the window closed |
refused | the 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:
| standing | meaning |
|---|---|
undeclared | nothing is claimed about where the value came from |
operator_declared | the party being judged supplied it |
measured | the issuer observed it directly |
protocol_defined | the 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:
- draw a fresh blind, uniform over the scalar field, and compute the commitment
- sign the document containing the commitment
- hand the value and the blind to the holder
- 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:
| predicate | prover shows | verifier target |
|---|---|---|
at_least | value - lower is in range | C - lower*G |
at_most | upper - value is in range | upper*G - C |
in_range | both, in that order | both, 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:
| Field | Meaning |
|---|---|
schema | vaara.trail-decision/v0 |
recordId, actionId | The trail record and the action it decided. |
eventType | decision_made or action_blocked. |
agentId, toolName, tenantId | As recorded. tenantId is "" when unset. |
decision, reason | The trail's words: allow, escalate or deny, and its reason. |
riskScore | Decimal string. |
decidedAt | ISO 8601 UTC, milliseconds. |
recordHash | sha256: and the trail record's own hash. |
previousHash | sha256: and the hash of the record before it. An empty genesis link is written as the SHA-256 of the empty string. |
decisionDetail, approver, humanDisposed | Present 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 (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.
7. Conformance
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.
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.