Verifying a receipt
A verifier needs three things and no network access:
- the receipt bundle, one JSON file
- the issuer's public key: the gateway key, or the application key for SDK receipts
- the principal's public key, for gateway receipts, which carry a signed delegation
A fourth is optional: a copy of the log file, which lets the verifier confirm the receipt sits in a log whose root the verifier recomputed, not one the issuer merely asserted.
When the issuer logs to a remote log, the tree head is signed by the log's key rather than the issuer's. The verifier then needs that public key too, and the report says which key signed the tree head. That is the point of a remote log: a tree head signed by a party that is not the operator says the receipt was in a log the operator could not rewrite. A tree head signed by the issuer's own key says only that the issuer has not changed its story since.
From the command line
node src/cli.ts verify receipts/<id>.json \
--issuer-key keys/gateway.pub \
--principal-key keys/principal.pub \
--log-key keys/log.pub \ # only for receipts logged to a remote log
--upstream-key keys/upstream.pub \ # only when the upstream signed its result
--log log.jsonl # optionalExit code 0 when every check passes, 1 otherwise. Add --json for machine-readable output. --issuer-key and --principal-key can be repeated to accept rotated keys; signatures are matched by keyid. --gateway-key is an alias for --issuer-key. --principal-key may be omitted for SDK receipts, which carry no delegation.
A real report, produced by node scripts/demo.ts:
PASS receipt signature (issuer key) (keyid f2f53b689a84)
PASS receipt payload type
PASS issuer kind is known (gateway)
PASS issuer keyid matches signer
PASS gateway receipt carries a delegation
PASS gateway receipt carries a policy decision
PASS delegation signature (principal key) (signed by bfa1a505f55b)
PASS delegation binds principal and agent
PASS delegation valid at receipt time (2026-09-04T13:06:20.074Z .. 2026-09-04T14:06:21.074Z)
PASS executed tool within delegated scope (stripe.refund)
PASS request args digest
PASS policy decision consistent with execution (allow -> executed)
PASS no policy errors on an allow
PASS tree head signature
PASS tree head matches inclusion proof size
PASS log inclusion proof (leaf 0 of 1, root 4efaea879930)
PASS log file root matches tree head (recomputed 4efaea879930)
RESULT: VERIFIED
ISSUER: gateway, enforced outside the agent's process; the agent could neither skip nor forge this receipt
field provenance value
principal attested user_456
agent attested support-agent
model claimed claude-fable-5-1
tool observed stripe.refund
args claimed {"amount":50000,"customer_id":"cust_123"}
fact.customer observed {"email":"alex@example.com","id":"cust_123","verified":true}
policy observed allow [policy1] policy ba4e4461ffc4
execution observed executedWhat each check means
| check | what it establishes | what a failure usually means |
|---|---|---|
| receipt signature (issuer key) | the bundle's envelope was signed by a key you trust as an issuer and has not changed since | edited receipt, or an issuer key you do not trust |
| receipt payload type | the payload is an in-toto Statement with this project's predicate type | wrong file, or a different envelope replayed as a receipt |
| issuer kind is known | the receipt says whether a gateway or an SDK produced it; the detail shows which, and the framework | a predicate this verifier does not understand |
| issuer keyid matches signer | the receipt's own claim of who signed it matches the actual signature | mixed-up or forged predicate |
| gateway receipt carries a delegation | gateway receipts always embed the signed grant they enforced | an SDK receipt relabelled as gateway |
| gateway receipt carries a policy decision | gateway receipts always record the Cedar decision | same |
| delegation signature (principal key) | the embedded grant was signed by a key you trust as a principal | a grant the principal never issued |
| delegation binds principal and agent | the grant names the same principal and agent the receipt names, and the principal keyid matches | a valid grant for someone else, spliced in |
| delegation valid at receipt time | the receipt's timestamp is inside the grant's window | expired or not-yet-valid authority |
| executed tool within delegated scope | if the tool ran, the grant covered it. Denied calls pass this check by construction | a gateway that forwarded out of scope |
| principal is claimed, not attested | SDK receipts only: there is no delegation, so the principal is a config string and is labelled as such | an SDK receipt pretending to an attested principal |
| request args digest | the args in the predicate hash to the digest in the subject | edited arguments |
| policy decision consistent with execution | allow went with executed or failed; deny went with denied. Skipped when no policy was evaluated | an issuer that executed after a deny |
| no policy errors on an allow | an allow was not produced while Cedar reported errors | broken fail-closed behaviour |
| authorization signature (issuer key) | pre-commit receipts only: the embedded authorization statement was signed by the same issuer key as the receipt | an authorization from another gateway, or none the issuer signed |
| authorization names this call | the committed authorization names this receipt id, tool, argument digest, agent, and principal, and recorded an allow | an authorization spliced in from a different call |
| authorization tree head signature | the authorization's own tree head is signed by a trusted log or issuer key | a fabricated log position for the authorization |
| authorization log inclusion proof | the authorization statement is a leaf of that tree | an authorization that was never logged |
| authorization logged before execution | the authorization's leaf precedes the receipt's leaf, at a tree size no larger than the receipt's | evidence written after the side effect, dressed up as before |
| tree head signature | the tree head was signed by a trusted issuer key | forged log position |
| tree head matches inclusion proof size | the proof and the tree head describe the same tree | mismatched bundle parts |
| log inclusion proof | this exact envelope is a leaf of the tree with that root | receipt never logged, or logged then changed |
| log file root matches tree head | recomputing the root from your copy of the log at that size gives the same value | your log copy and the issuer's history diverge: deletion, reordering, or edit |
Gateway receipts run seventeen checks, eighteen with a log file, and five more when the tool was committed before it ran (precommit in the gateway config). SDK receipts run fewer, because there is no delegation to check, and the report says so on the principal is claimed line.
What a verified receipt lets you conclude
Read the ISSUER line first, then the provenance column. For the executed refund above, a gateway receipt, a verified result supports exactly this statement:
The gateway holding key
f2f5…observed that the agentsupport-agent, acting under a grant signed by the holder of principal keybfa1…foruser_456, requestedstripe.refundwith these arguments; the gateway itself looked up the customer and gotverified: true; policy with digestba4e…allowed it; the upstream server returned a result with this digest; and the gateway committed all of that to position 0 of a log whose root is4efa….
For an SDK receipt the statement is shorter: a process holding the application key reported this call, with these arguments and this result, and the record has not changed since. Nothing in it was checked outside that process. The report prints this under ISSUER so nobody has to remember it.
A gateway receipt does not support:
- that Stripe really executed the refund. The upstream result is
observed, not signed by Stripe. That is the first roadmap item. - that the side effect exists because the receipt does, unless the receipt carries an authorization. Without one, the call was forwarded first and logged second. With one, the five
authorizationchecks establish that the gateway had committed the call to the log before it went out, and awithheldexecution establishes that it never went out. - that the arguments were correct. They are
claimed: they are what the agent asked for, which is what a receipt should record. - that the model named in
modelproduced the call. No hosted provider signs model identity. - that the gateway operator is honest. The operator holds the gateway key. Against a dishonest operator you need a log copy taken out of their control, or a signer they do not control. See the threat model table in the README.
In the browser
agent-custody.dev/verify runs the same checks in the page over WebCrypto, nothing uploaded. It is a second implementation of this verifier, and it passes every published conformance vector.
Programmatic verification
import { readFileSync } from "node:fs";
import { loadPublicKey } from "../src/crypto.ts";
import { verifyBundle, formatReport } from "../src/verify.ts";
const bundle = JSON.parse(readFileSync("receipts/<id>.json", "utf8"));
const result = verifyBundle(bundle, {
issuerKeys: [loadPublicKey("keys/gateway.pub")], // gateway keys and SDK application keys
principalKeys: [loadPublicKey("keys/principal.pub")],
logKeys: [loadPublicKey("keys/log.pub")], // only for receipts logged to a remote log
upstreamKeys: [loadPublicKey("keys/upstream.pub")], // only when the upstream signed its result
logFile: "log.jsonl", // optional
});
result.ok // every check passed
result.checks // [{ name, ok, detail? }]
result.statement?.predicate // the decoded receipt, only when the signature verified
console.log(formatReport(result));verifyBundle is pure and synchronous. It reads the log file only when logFile is given.
Verifying without this codebase
The formats are standard on purpose, so a verifier in another language needs no code from here:
- Envelope: DSSE. Signature is Ed25519 over
"DSSEv1 " + len(payloadType) + " " + payloadType + " " + len(payload) + " " + payload. - Payload: in-toto Statement v1, canonical JSON with sorted keys and no whitespace.
- keyid: sha256 of the SPKI DER encoding of the public key, hex.
- Log: RFC 6962 hashing (
0x00prefix for leaves,0x01for nodes) and the RFC 9162 inclusion-proof algorithm. Leaves are the canonical JSON of the envelope. The log file is one JSON string per line.
Auditing a log copy
Take copies of log.jsonl on a schedule and keep them where the operator cannot write. A remote log serves GET /root?size=N so an auditor can compare a tree head with the log's own root without a copy; example 13 does this. Then for any receipt:
node src/cli.ts verify receipts/<id>.json --issuer-key ... --principal-key ... --log /audit/copies/log-2026-09-04.jsonlThe last check recomputes the root at the receipt's tree size from your copy. If the operator later deletes, reorders, or edits a line before that position, the recomputed root changes and the check fails.
Today a copy must be at least as long as the receipt's tree size. Consistency proofs between two tree heads, which would let you check that a newer log extends an older copy without holding the whole file, are on the roadmap.
Proving history was not rewritten
An inclusion proof says a receipt was in the log at one moment. It does not say the log still contains, unchanged, everything it contained earlier. That is what a consistency proof is for: given two tree heads, it proves the larger tree extends the smaller one, so nothing before the older head was rewritten. Keep the tree head from any receipt; it is the older head in every later audit.
node src/cli.ts audit --older receipts/<earlier>.json --newer receipts/<later>.json --log log.jsonl --issuer-key keys/gateway.pub
node src/cli.ts audit --older receipts/<earlier>.json --newer receipts/<later>.json --log-url https://log.example.com/ --log-key keys/log.pubBoth tree heads must be signed by a trusted key. With --log the proof is computed from a copy of the log; with --log-url it is fetched from the log's GET /consistency?old=M&new=N. Exit code 0 means the newer log extends the older one. A failure means either history was rewritten between the two heads or the proof belongs to other tree heads; example 14 shows a rewritten log failing this way while every individual receipt still verifies.
Programmatically, auditExtends(older.treeHead, newer.treeHead, proof, keys) returns the same checks. The proof algorithm is RFC 9162 section 2.1.4, so any log implementing it can answer, and any verifier implementing it can check.
The remote log also serves GET /head, its current tree head signed with the log's key, so an auditor can record heads on a schedule and later audit any two of them without holding a receipt for each.
Conformance vectors
vectors/ in the package holds fixed receipts, keys, logs, proofs, and the verdicts this verifier produces for them, generated by bun run vectors and checked by the test suite on every run. A verifier written elsewhere proves it agrees by reproducing every verdict. They are published at agent-custody.dev/receipt/vectors.
Attested execution
On a gateway receipt the execution is observed: the gateway saw what the upstream returned. An upstream that holds a key can do better and sign its result for the receipt being issued; the gateway embeds the signature, and a verifier given the upstream's public key checks it and reports the execution as attested by that key. The check binds the signature to the receipt id, the tool, and the digest of the result content, so a signature cannot be moved between receipts.
For upstream authors, signResult(result, key, receiptId, tool) from @agent-custody/receipts does the signing; the receipt id arrives in the call's _meta["agent-custody/receipt"]. The memory server in @agent-custody/state signs when started with --key, and the demo's fake upstream does too.
Provider-native deliveries
Real providers do not sign per receipt. Stripe signs webhooks and GitHub signs deliveries with an HMAC over the raw body under a shared secret, unbound to any receipt. An MCP server wrapping such a provider can attach the delivery that corresponds to the call, with attachProviderAttestation(result, { provider: "stripe-webhook", rawBody, signature, bind: "data.object.id" }), and the gateway embeds it as execution.upstream. A verifier given the secret recomputes the HMAC, checks Stripe's timestamp against the receipt's within five minutes, and checks that the value at bind in the delivery appears in the receipt's result, which is what ties a delivery to this call.
STRIPE_WEBHOOK_SECRET=whsec_... node src/cli.ts verify receipts/<id>.json --issuer-key keys/gateway.pub --principal-key keys/principal.pub --stripe-secret-env STRIPE_WEBHOOK_SECRETThe report then says attested (shared secret), deliberately distinct from attested: anyone holding the secret could forge a delivery, so this is the provider's word as far as the secret is trusted, not a signature only the provider could make. Without the secret the delivery is carried and not checked, and the report says so. The demo's fake Stripe attaches webhooks when started with --webhook-secret; the conformance vectors include a receipt verified with and without the secret.
Retention on the log
Receipts hold values: request arguments, results, facts. The log is append-only and hashed, so nothing can simply be deleted from it. prune is how retention reaches it without breaking a proof:
node src/cli.ts prune --log log.jsonl --before 2026-06-01T00:00:00Z --receipts receiptsEvery leaf whose receipt is older than the cutoff is replaced in the file by its leaf hash, and the receipt's bundle file is deleted. The Merkle tree is built from leaf hashes, so every root, every inclusion proof, and every consistency proof for the remaining leaves is unchanged, and --log verification of later receipts still passes. Someone who kept a pruned receipt's bundle can still prove it was in the log; nobody holding only the log can recover what it said. Run it on the same schedule as memory retention.