Decision Receipt
The signed, hash-chained audit record written on every mint and every consume. Per-entity chain. Verifiable offline.
Every decision the gateway makes — allow, deny, require_approval — produces a Decision Receipt. Receipts are:
- Canonical — serialized with JCS, so the hash is stable across runtimes
- Hash-chained — each receipt's
prev_receipt_hashequals the SHA-256 of the previous receipt's canonical bytes - Merkle-rooted — every 1024 receipts the chain emits a merkle root for external anchoring
- Per-entity — one chain per
(organization_id, entity_id), not a global log - Verifiable offline —
verifyReceiptChain()from@veto/spend-capsule-protocolreads your NDJSON export and returns{ ok: true }or the index where the chain broke
Shape
{
"version": "veto.receipt/1",
"receipt_id": "rcp_01hy2z...",
"entity_id": "ent_acme_llc",
"agent_id": "agent_finance_bot",
"workflow_id": "wf_01hy2z...",
"capsule_id": "cap_01hy2z...",
"tool": "meow.pay",
"decision": "allow",
"reason_code": "consumed",
"reason_detail": "MCP tools/call; upstream=mock",
"args_hash": "sha256:...",
"result_hash": "sha256:...",
"policy_hash": "ac3f...",
"counterparty_hash": "sha256:...",
"rail": "ach",
"amount": { "currency": "USD", "amount": "500.00" },
"issued_at": "2026-04-21T14:03:24Z",
"prev_receipt_hash": "sha256:...",
"merkle_root": "sha256:..."
}Genesis
The first receipt for an entity has:
prev_receipt_hash = sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855That's SHA-256 of the empty byte string — the canonical genesis anchor. Both the TypeScript and Python verifiers check this explicitly, so a chain that starts from any other prev_hash fails immediately.
The onboarding submit flow seals this genesis receipt automatically — it's the first record in your entity's audit trail, and it binds to the policy pack sha256 you chose at onboarding time.
Chain verification
import { verifyReceiptChain } from "@veto/spend-capsule-protocol";
const rows = await fetch("http://localhost:3005/v1/receipts/ent_acme_llc")
.then(r => r.json());
const result = verifyReceiptChain(rows.receipts.map(r => r.payload));
if (result.ok) {
console.log(`chain clean · ${rows.receipts.length} receipts`);
} else {
console.error(`chain broken at index ${result.breakAt}: ${result.reason}`);
}verifyReceiptChain enforces:
- Structural validity (schema)
- Version match (
veto.receipt/1) - Hash-link continuity (every
prev_receipt_hashmatches the predecessor) - Monotonic
issued_at(cannot travel backward) - Merkle root progression at every 1024-receipt anchor
Export
# NDJSON export for audit archive:
curl http://localhost:3005/v1/receipts/ent_acme_llc \
| jq -c '.receipts[].payload' > receipts-$(date +%s).ndjson
# Verify offline with the protocol package:
node -e 'const {verifyReceiptChain}=require("@veto/spend-capsule-protocol");
const rs = require("fs").readFileSync("receipts.ndjson","utf8")
.trim().split("\n").map(JSON.parse);
console.log(verifyReceiptChain(rs))'What binds into the hash
Every field in the receipt payload is included in the canonical bytes that get hashed. That means any mutation — even a whitespace edit in reason_detail — breaks the chain at that row and every row after. Auditors detect tampering instantly.
What does NOT bind into the hash
- The storage row's
stored_at(wall-clock when the SQLite write happened) - The HTTP response envelope wrapper
- Server-side indices (
chain_index)
These exist for operational ergonomics but are derived, not authoritative.