Receipt specification, v0.2
This page is what https://agent-custody.dev/receipt/v0.2 resolves to: the predicateType carried by every receipt. It describes the three signed objects a verifier meets, field by field, and the checks a verifier must make. The formats are standard on purpose, so a verifier in any language needs nothing from this codebase.
Building blocks
| Envelope | DSSE. { payloadType, payload (base64), signatures: [{ keyid, sig (base64) }] }. The signature is Ed25519 over the pre-authentication encoding "DSSEv1 " + len(payloadType) + " " + payloadType + " " + len(payload) + " " + payload. |
| Statement | in-toto Statement v1, serialised as canonical JSON: keys sorted, no whitespace, undefined dropped. |
| keyid | Hex SHA-256 of the SPKI DER encoding of the Ed25519 public key. Signatures are matched to trusted keys by keyid. |
| Digest | Hex SHA-256 of the canonical JSON of a value. |
| Log | RFC 6962 hashing: `0x00 |
Provenance
Every field that describes the call carries a provenance label. This is the part a reader must not skip.
| label | meaning |
|---|---|
attested | signed by a key other than the issuer's, today the principal's delegation key |
observed | obtained by the issuer itself, outside the agent's control; only a gateway can produce this |
claimed | originates from the agent, the model, or the agent's own process, with no independent check |
The receipt bundle
A receipt on disk, receipts/<receiptId>.json, is a bundle of three parts.
{ "envelope": <DSSE over the receipt statement, signed by the issuer>,
"treeHead": <DSSE over a TreeHead, signed by the issuer or by the log>,
"inclusion": { "leafIndex": 0, "treeSize": 1, "hashes": [] } }The receipt statement
payloadType is application/vnd.in-toto+json. The statement is:
{ "_type": "https://in-toto.io/Statement/v1",
"subject": [{ "name": "tool-call:<tool>:<receiptId>", "digest": { "sha256": "<digest of request.args>" } }],
"predicateType": "https://agent-custody.dev/receipt/v0.2",
"predicate": { ... } }The predicate
| field | type | provenance | notes |
|---|---|---|---|
receiptId | UUID string | unique per receipt; also the file name | |
timestamp | ISO 8601 | when the issuer built the receipt | |
issuer.kind | "gateway" or "sdk" | the first thing a verifier reads. A gateway is out of process and enforces; an SDK is inside the agent's process and self-reports | |
issuer.keyid | keyid | must equal the keyid of the envelope's signature | |
issuer.version, issuer.framework? | strings | producer version; the host framework for SDK receipts | |
principal | { id, keyid, provenance: "attested" } or { id | null, provenance: "claimed" } | as stated | attested only when a delegation is present |
agent.id | string | attested with a delegation, else claimed | |
delegation? | { envelope, provenance: "attested" } | present on gateway receipts: the signed grant, see below | |
session | { id | null, toolUseId | null, provenance: "claimed" } | claimed | correlation ids from the host, never checked |
model | { id | null, provenance: "claimed" } | claimed | |
tool.name, tool.upstream? | string | observed on gateway receipts, claimed on SDK receipts | upstream names which of several upstreams served the call, when the gateway fronts more than one |
request | { args, argsDigest, provenance: "claimed" } | claimed | argsDigest is the digest of args and must equal the statement subject's digest |
facts | map of name to { tool, args, value, resultDigest, provenance: "observed" } | observed | what the gateway fetched itself before deciding; empty on SDK receipts |
consumed? | { factIds[], provenance: "observed" } | observed | gateway receipts only: the fact ids upstreams had declared they served, in _meta["agent-custody/facts"], on earlier calls in the session. What the agent had been shown before this call, as an upper bound on what it relied on. Added after v0.2 shipped, optional, so older receipts stay valid |
authorization? | { envelope, treeHead, inclusion } | present when the gateway committed the call to the log before forwarding it: the signed authorization statement, below, with the proof of its own log position. A verifier checks that it names this receipt and precedes it in the log | |
policy | { decision, reasons[], errors[], policyDigest, provenance } or null | observed on gateway receipts, claimed on SDK receipts | policyDigest is the SHA-256 of the Cedar policy text; null when no policy was evaluated |
execution | { status: "executed" | "failed", result, resultDigest, upstream? } or { status: "denied", reason } or { status: "error", error } or { status: "withheld", reason }, each with provenance | as stated | on a gateway receipt a denial means the call never reached the tool. upstream.envelope, when present, is the upstream's own signature over its result, see below |
The tree head
payloadType is application/vnd.agent-custody.treehead+json. The payload is { treeSize, rootHash, timestamp }. It is signed by the issuer's key when the issuer keeps the log itself, or by the log's key when the issuer appends to a log run by someone else. Which key signed it is what a verifier reports: a tree head signed by a party that is not the operator says the receipt was in a log the operator could not rewrite.
The upstream attestation
An upstream that holds a key may sign what it returned. It puts a DSSE envelope on its result's _meta["agent-custody/upstream-signature"] with payloadType application/vnd.agent-custody.upstream+json and payload { receiptId, tool, contentDigest }, where contentDigest is the digest of { content, isError } of the result, the part the agent receives, and receiptId is the id the gateway passed to the upstream in the call's _meta. The gateway embeds the envelope as execution.upstream. A verifier given the upstream's key checks the signature, the receipt id, the tool, and the digest, and then reports the execution as attested by that key rather than merely observed by the gateway.
Provider-native deliveries
Where the upstream is a wrapper around a provider that signs with a shared secret, execution.upstream may instead be { provider: "stripe-webhook" | "github-delivery", rawBody, signature, bind, deliveryId? }: the delivery exactly as received, its signature header, and a dot path into the body whose value must appear in the receipt's result. A verifier given the secret recomputes the HMAC (Stripe: over <t>.<rawBody> with the timestamp within tolerance of the receipt's; GitHub: over the raw body) and checks the binding. The report labels the execution attested (shared secret), weaker than a key-based attestation, since the secret is shared.
The authorization statement
For a tool the gateway treats as consequential, it signs and logs this before forwarding the call. payloadType is application/vnd.in-toto+json; the statement has the same subject as the receipt will and predicateType https://agent-custody.dev/authorization/v0.1. The predicate is the receipt's receiptId, timestamp, issuer, principal, agent, delegation, tool, request, facts, consumed, and policy, and nothing about the outcome, because there is none yet. The gateway writes it as receipts/<receiptId>.authorization.json and embeds it in the receipt as authorization, with the inclusion proof and tree head of its own leaf. If the log refuses it, the call is not forwarded and the receipt's execution is withheld.
The delegation grant
payloadType is application/vnd.agent-custody.delegation+json. The payload is { version: "0.1", principal, agent, scopes[], issuedAt, expiresAt }, signed by the principal. The gateway refuses to start outside the validity window and embeds the grant in every receipt, so a verifier re-checks it rather than trusting that the gateway did.
Verification
A verifier holds public keys only: issuer keys, principal keys, and log keys when a remote log is used. It needs no access to the issuer, the agent, or the upstream system. The checks, in order:
receipt signature (issuer key): the envelope verifies against a trusted issuer key.receipt payload type: the envelope'spayloadTypeand the statement'spredicateTypeare the values above.issuer kind is knownandissuer keyid matches signer.- On a gateway receipt:
gateway receipt carries a delegationandgateway receipt carries a policy decision. - With a delegation:
delegation signature (principal key),delegation binds principal and agent(the grant's principal and agent equal the receipt's, and the signing key is the receipt's attested principal keyid),delegation valid at receipt time, andexecuted tool within delegated scope(an executed or failed call names a tool in the grant's scopes). Without one:principal is claimed, not attested. request args digest: the digest ofrequest.argsequalsrequest.argsDigestand the subject digest.- With an upstream signature and an upstream key:
upstream signature (upstream key), the envelope verifies against a trusted upstream key and binds to this receipt id, this tool, and this result's content digest. With a provider delivery and the provider's secret:upstream signature (provider secret), the HMAC verifies, Stripe's timestamp is within tolerance, and the bound value appears in the result. - With a policy:
policy decision consistent with execution(allow never pairs with denied; deny always does) andno policy errors on an allow. tree head signatureagainst log keys then issuer keys, reporting which signed;tree head matches inclusion proof size;log inclusion proofrecomputes the root from the leaf hash of the canonical envelope and the proof.- Optionally, with a copy of the log,
log file root matches tree headrecomputes the root at the tree head's size.
A bundle passes only if every check passes. The report then lists each field with its provenance, so the reader knows what was proven and what was merely claimed.
Auditing across receipts
Given two signed tree heads and a consistency proof between their sizes, older is not larger than newer and newer log extends older log establish that nothing before the older head was rewritten. A log serves the proof at GET /consistency?old=M&new=N and its current signed head at GET /head.
The log API
A log reached over HTTP implements four routes. POST /append with { leaf } returns { inclusion, treeHead }, the tree head signed by the log's key, and may require a bearer token. GET /root?size=N returns { treeSize, rootHash }. GET /consistency?old=M&new=N returns { oldSize, newSize, hashes }. GET /head returns { treeHead }.
Reference implementation
@agent-custody/receipts on npm implements every object and check on this page. Verification is the pure function verifyBundle; auditing is auditExtends. The conformance vectors are generated by its test suite, and a second implementation agrees with the reference when it reproduces every verdict in them; the browser verifier on this site does.