Veto/docs
Meow Gateway

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_hash equals 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 offlineverifyReceiptChain() from @veto/spend-capsule-protocol reads 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:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855

That'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_hash matches 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.