runback.cassette/v1
An open, tamper-evident format for AI agent audit records — the artifact an auditor, a regulator or another vendor should ask for, not a Runback account. Every decision is hash-chained and independently verifiable with any standard crypto library. Field types, error codes and byte-level detail, not just the shape — implement it in any language against this, then check your output against the real record below.
Naming note: runback.cassette/v1 is this spec's name and the schema string accepted from older records. A record built today declares $schema: "runback.audit/v2" — both strings are accepted by every verifier below.
What it solves.
AI agents make decisions that can't be rolled back — loans approved, refunds issued, triage routed. Logs record the outcome; they prove neither the reasoning nor the absence of tampering. The cassette captures every non-deterministic input the agent touched — context, tools, retrieval — in a hash-chained record sealed at execution. Change one event and verification fails. The record is re-executable: replay the agent against the captured inputs and the output must reproduce.
Structure.
A record is a JSON document with three top-level fields: manifest (digests, signature, metadata), run (a human-readable summary — name, status, timing), and events (the ordered, hash-chained event array — the signed source of truth the run summary is checked against, not the other way around). This is a trimmed excerpt — the full, real, byte-exact record is at /sample-cassette.json, generated by the same code path that builds a customer export.
{
"$schema": "runback.audit/v2",
"manifest": {
"run_id": "run_a3f1b90c",
"generated_at": "2026-06-14T02:47:13.950Z",
"event_count": 6,
"algorithm": "sha256-chain",
"content_digest": "8f2e1a9c4b7d3f60...",
"replay": {
"cassette_digest": "3c91f0a4d8b2e715...",
"entry_count": 2,
"algorithm": "oracle-chain/sha256",
"note": "Recomputing this over the events must equal it — that's the replay proof."
},
"signed": true,
"signature": {
"alg": "Ed25519",
"value": "9f3a1c7e0d5b...",
"pubkey": "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"
},
"verify": "npx @runback/verify sample-cassette.json",
"spec_url": "https://runback.dev/spec",
"verifier_url": "https://runback.dev/verify"
},
"run": {
"run_id": "run_a3f1b90c",
"name": "loan-approval-agent",
"status": "error",
"started_at": "2026-06-14T02:47:11.000Z",
"ended_at": "2026-06-14T02:47:13.950Z"
},
"events": [
{
"schema_version": 1,
"run_id": "run_a3f1b90c",
"span_id": "l1",
"parent_span_id": "r",
"seq": 1,
"ts_start": "2026-06-14T02:47:11.000Z",
"ts_end": "2026-06-14T02:47:12.400Z",
"type": "llm",
"model": { "provider": "openai", "model_id": "gpt-4o" },
"request": { "...": "system, messages[], tools[], params" },
"response": { "...": "text, tool_calls[], finish_reason" },
"usage": { "input_tokens": 428, "output_tokens": 184, "total_tokens": 612 },
"_hash": "a41f... ← SHA-256(prev_hash + canonical(this event, _hash excluded))"
}
]
}Event fields.
Every event shares a base shape, then adds fields by type. Timestamps are ISO 8601 (millisecond precision, Z suffix) by convention — not enforced by the schema's runtime validation, which only checks non-empty string.
baseschema_version, run_id, span_id, parent_span_id, seq, ts_start, ts_end, type — every event has these. parent_span_id is null for the root run span; ts_end is null while a step is in flight.
runphase ("start"|"end"), name, input, output, status, error, metadata. Two events per run — the failure or success is the phase: "end" one, checked against run.status by the consistent verifier check below.
llmmodel: { provider, model_id } (an object, not a bare string), request: { system, messages[], tools[], params }, response: { text, reasoning, finish_reason, tool_calls[] }, usage: { input_tokens, output_tokens, total_tokens }, latency_ms, error.
tooltool_name, tool_call_id, input, output, latency_ms, error. A blocked call adds policy_block: { rule, detail } and policy_evaluated: { passed }.
Before anything is hashed.
Every hash below is computed over a canonical JSON string, not the raw object — otherwise two byte-identical records could digest differently just from key order. The algorithm is RFC 8785-equivalent, with one deliberate divergence from a naive implementation.
// canonical(v) — RFC 8785-equivalent, applied before every hash:
if v === null || typeof v !== "object": return JSON.stringify(v)
if Array.isArray(v): return "[" + v.map(canonical).join(",") + "]"
else (object):
keys = Object.keys(v).sort() // native JS .sort() — UTF-16 code-unit order
emit each "key":canonical(value) IN THAT SORTED ORDER, comma-joined, wrapped in { }
— properties whose value is undefined are omitted (matches JSON.stringify)
— keys are serialised straight from the sorted array, never rebuilt into a
fresh {} — rebuilding re-triggers V8's own key order, which hoists
integer-like keys ("0","1","42") first regardless of sort. That single
line is the actual difference between this and a naive "sort + stringify".
// Numbers: ECMAScript Number::toString via JSON.stringify (RFC 8785 §3.2.2.3
// is defined against this). -0 serialises as 0.
// Strings: whatever JSON.stringify escapes — no extra escaping added.
// All hashes and signatures below are lowercase hex, never base64.Tamper evidence.
Each event is chained to the one before it. Changing any event breaks its hash and every hash after it — the chain is append-only by construction, not by policy.
// Event chain — web/lib/audit.ts, packages/verify/index.js
h_0 = "" // empty-string seed, not sha256("")
h_i = SHA256_hex( h_{i-1} + canonical(event_i) ) // event_i with its own _hash field removed
// events are hashed in array order (not re-sorted by seq for this chain)
content_digest = h_n // the terminal hash; sha256("") if zero eventsWhat makes it re-executable, not just readable.
The event chain proves the record wasn't edited. It doesn't prove the record IS what the agent actually saw and did — that's a second, independent digest over just the non-deterministic surface: every model call and tool call, content-addressed by its inputs.
// Oracle-stream / cassette digest — packages/replay/src/{cassette,digest}.ts
// A SEPARATE chain from the event chain above — it's what makes a record
// re-executable, not just internally consistent. Events are sorted by seq
// first; "run" and "reasoning" events don't enter it.
oracleEntryOf(event):
llm → { kind: "llm", key: sha256("llm:" + model_id + ":" + canonical(request, projected)), output: response ?? {error} }
tool → { kind: "tool", key: sha256("tool:" + tool_name + ":" + canonical(input, projected)), output: output ?? {error} }
env → { kind, key, output } directly (fetch/http/now/random/uuid/clock)
chainStep(prev, entry) = SHA256_hex( prev + canonical({ kind, key, output }) )
cassette_digest = final chainStep result, in seq order (sha256("") if no oracle entries)
entry_count = number of oracle entries (usually < event_count — run/reasoning excluded)
// Optional per-event "key_projection: { keep?, drop? }" strips volatile fields
// (nonces, request IDs, timestamps) from the input before it's hashed into the
// key, so two semantically-identical calls key identically. Applied identically
// by the SDK, the server, and the verifier — same function, three call sites.Tying a record to its producer.
The chain and both digests establish only internal consistency — the algorithm is public, so anyone can author a self-consistent record. The signature is what proves who made it.
// Signing — web/lib/audit.ts. Preference order: Ed25519, then HMAC-SHA256, then unsigned.
payload = content_digest + ":" + cassette_digest // UTF-8, colon-joined hex digests
Ed25519: crypto.sign(null, payload, privateKey) // null digest — Ed25519 doesn't pre-hash
signature hex-encoded; manifest.signature.pubkey carries the SPKI PEM
public key inline, so a record verifies fully offline
HMAC-SHA256: createHmac("sha256", key).update(payload).digest("hex")
proves custody of a shared secret, not identity — no non-repudiation
// A rotated-out key (*_PRIVATE_KEY_PREVIOUS) still verifies records it signed.
// The published Ed25519 key: GET /.well-known/runback-audit-key.pemThe six checks.
Any SHA-256 implementation can run all six. They're what runback.dev/verify, @runback/verify, and the API below all actually run — the same function, three call sites.
// Six checks, all run by @runback/verify, /api/audit/verify, and runback.dev/verify:
schema — record.$schema ∈ { "runback.audit/v2", "runback.cassette/v1" (legacy alias) }
consistent — manifest.run_id and the run summary agree with the signed events
(a summary that contradicts its own events is rejected however intact the chain is)
chain — recomputed h_i === events[i]._hash, for every i
digest — recomputed terminal hash === manifest.content_digest
cassette — recomputed oracle-stream digest === manifest.replay.cassette_digest
signature — one of: unsigned · no-key · invalid · valid-unpinned · valid
(Ed25519 sound but signed by a DIFFERENT key than this verifier
pins → "valid-unpinned": sound maths, unproven signer)
integrity = schema && consistent && chain && digest && cassette
valid = integrity && signature === "valid"
verdict = !integrity || signature === "invalid" ? "invalid"
: valid ? "valid"
: "unverified"
// The chain algorithm is published, so a self-consistent record can be
// authored by anyone. "unverified" exists so CI can tell "self-consistent"
// apart from "provably from the key I pinned" instead of conflating them.Verify a cassette.
Paste a record at runback.dev/verify, or load the real one at /sample-cassette.json. All six checks run client-side — the record never leaves your browser.
curl -O https://runback.dev/sample-cassette.json
npx @runback/verify sample-cassette.json [--json] [--key <hmac-key>]Zero runtime dependencies, pure node:crypto. Reads a file path or -/--stdin. --key is only for legacy HMAC-signed records — Ed25519 records carry their own public key and verify fully offline.
Exit codes — three, not two, because "self-consistent" and "provably genuine" are different claims and CI needs to tell them apart:
0VALIDintegrity holds and the signer is the pinned key2UNVERIFIEDself-consistent, but unsigned or signed by an unpinned key1INVALIDa check failed outright, or the input couldn't be parsedPOST https://runback.dev/api/audit/verify
Content-Type: application/json
<record JSON, max 8 MiB>Unauthenticated by design — verifying a record must not require an account. 200: the raw { valid, verdict, integrity, checks: { schema, consistent, chain, digest, cassette, signature }, consistencyFailures? } result — nothing wrapped or renamed.
400invalid JSON, or valid JSON missing manifest/events413over 8 MiB429over 20 requests/60s from one IP — Retry-After header setUse the format.
Verify it at runback.dev/verify, POST to /api/audit/verify, or run @runback/verify. No account, no Runback dependency to check integrity — and check the verdict field, not just "did it parse": unverified is not the same claim as valid.
Implement the canonicalization, the event chain, and the oracle-stream digest exactly as specified above — all three are required for a record to pass every check, not just chain+digest. The reference implementations are @runback/verify (npm) and packages/replay + web/lib/audit.ts in the Runback source.
Designed against the record-keeping obligations in EU AI Act Article 12 and APRA CPS 230 incident documentation — automatic event logging over the system's lifetime, with traceability and integrity. Whether a given deployment satisfies either is a determination for your assessor, not for us: no regulator certifies a file format. What the format supplies is the evidence that argument needs — manifest.generated_at, a signature that establishes the producer, and a chain any third party can re-derive without our software.