Anyone can check it.
runback.cassette/v1
How every Runback record is built, so you, your auditor or anyone else can check one without us.
- 0run…
- 1llm…
- 2tool…
- 3llm…
- 4tool…
- 5run…
Checking…
Fetching /sample-cassette.json.
Three parts. One holds the evidence.
Every input the agent touched that could change between runs (context, tools, retrieval) is fixed in the record at execution. Replay the agent against those inputs, and any change in its decision shows.
runback.cassette/v1 is this spec's name and the schema string older records declare. A record built today declares runback.audit/v2. Every verifier below accepts both.
{
"$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": "2e91e3f5fc451d2c9841d9e3b6c68d78a9b2fca00fdff98989512704180a9189",
"replay": {
"cassette_digest": "4376f4529252da46ea7da2f5a0afb59f5cb3511b13459ef8f67cf848f7a02cf8",
"entry_count": 4,
"algorithm": "oracle-chain/sha256",
"note": "The run's oracle stream — every model response and tool output, hash-ch…"
},
"signed": true,
"signature": {
"alg": "Ed25519",
"value": "d6ab935b3ec9d96883ccb689d5589a50c985fa7109f1e321724a2623d417d7bce7f01a8…",
"pubkey": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEArDPmR+Gvu1HawnSvM6xjZao7BVPP…"
},
"verify": "Recompute the event chain h_i = SHA-256(h_{i-1} + canonical(event_i)) o…",
"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": "You are a careful lending agent. Follow policy.",
"messages": [
{
"role": "user",
"content": "Applicant #4471 requests a $12,000 personal loan."
}
],
"tools": [
{
"name": "check_credit_file",
"description": "Pull the applicant's credit file.",
"parameters": {
"type": "object",
"properties": {
"applicant_id": {
"type": "number"
}
}
}
}
],
"params": {
"temperature": 0
}
},
"response": {
"text": "Checking the credit file before deciding.",
"reasoning": null,
"finish_reason": "tool-call",
"tool_calls": [
{
"tool_call_id": "t1",
"tool_name": "check_credit_file",
"input": {
"applicant_id": 4471
}
}
]
},
"usage": {
"input_tokens": 428,
"output_tokens": 184,
"total_tokens": 612
},
"latency_ms": 1400,
"error": null,
"_hash": "8f985ca54090e255926fb55830ecf4ab4dc4f9f32b598e04f50e34aaf09745fb"
}
]
}Cut from the record /sample-cassette.json serves, by code: 1 of 6 events, strings past 72 characters ending in “…”. Every other value is the record's own.
Event fields.
Every event shares a base shape, then adds fields by type. Timestamps are ISO 8601 with milliseconds and a Z suffix by convention; runtime validation checks only for a non-empty string. These four types are the ones the checks read. The full list, including reasoning and env events, is in the event reference.
- base
schema_version, run_id, span_id, parent_span_id, seq, ts_start, ts_end, type. Every event has these.parent_span_idisnullfor the root run span;ts_endisnullwhile a step is in flight. - run
phase("start"|"end"),name, input, output, status, error, metadata. Two per run. The outcome is thephase: "end"one, checked againstrun.statusby theconsistentcheck. - llm
model: { 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. - tool
tool_name, tool_call_id, input, output, latency_ms, error. A blocked call addspolicy_block: { rule, detail }andpolicy_evaluated: { passed }.
Written the same way, every time.
Every hash is computed over a canonical JSON string, not the raw object, so key order can never change a digest. The algorithm is RFC 8785-equivalent, with one deliberate divergence from a naive implementation. The check at the top of this page runs exactly this, in your browser.
// 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.Change one byte, and it shows.
Each event is chained to the one before it. Change any event and its hash breaks, and every hash after it. The chain is append-only by construction, not by policy.
// Event chain — web/lib/audit.ts, packages/verify/index.jsh_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 eventsReplayable, not just readable.
The event chain proves the record was not edited. It does not prove the record is what the agent actually saw and did. That is a second, independent digest over the non-deterministic surface alone: every model call and tool call, 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: error ? {error} : response }
tool → { kind: "tool", key: sha256("tool:" + tool_name + ":" + canonical(input, projected)), output: error ? {error} : output }
env → { kind, key, output } directly (kind: now | date | random | uuid | fetch)
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.Who made it, provably.
The chain and both digests establish internal consistency only. 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 offlineHMAC-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 are what runback.dev/verify, @runback/verify and the API below run: the same function, three call sites.
- schemaThe record declares a format this verifier knows.
- consistentThe run summary agrees with the signed events. A summary that contradicts its own events is rejected however intact the chain is.
- chainEvery event's recomputed hash matches the one stored beside it.
- digestThe recomputed terminal hash matches the manifest's content digest.
- cassetteThe recomputed oracle-stream digest matches the manifest's replay digest.
- signatureUnsigned, no-key, invalid, valid-unpinned, or valid. Sound maths signed by a key this verifier does not pin is valid-unpinned — not a pass.
The first five are integrity — the record is internally sound. The sixth is origin. A record that passes integrity but not signature is unverified, never valid: the chain algorithm is published, so anyone can author a self-consistent record.
// Run by @runback/verify, /api/audit/verify and runback.dev/verify.
// schema · consistent · chain · digest · cassette are integrity;
// signature is origin. $schema ∈ { "runback.audit/v2", "runback.cassette/v1" }.
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 record.
Paste a record at runback.dev/verify, or load the real one at /sample-cassette.json.
The check runs on Runback's server and stores nothing. To keep the record off our infrastructure entirely, use the CLI.
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.
- 0 · validIntegrity holds and the signer is the pinned key.
- 2 · unverifiedSelf-consistent, but unsigned or signed by an unpinned key.
- 1 · invalidA check failed outright, or the input could not be parsed.
POST https://runback.dev/api/audit/verify
Content-Type: application/json
<record JSON, max 8 MiB>Unauthenticated by design: verifying a record must not need an account. 200 returns 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 or events.
- 413Over 8 MiB.
- 429Over 20 requests in 60 seconds from one IP. Retry-After is set.
Use the format.
- Receiving a recordVerify it at runback.dev/verify, POST it to /api/audit/verify, or run @runback/verify. No account and no Runback dependency to check integrity. Read the verdict, not just whether it parsed: unverified is not the same claim as valid.
- Producing the formatImplement the canonicalisation, the event chain and the oracle-stream digest exactly as above. All three are needed to pass every check, not just chain and digest. The reference implementations are @runback/verify on npm, and packages/replay with web/lib/audit.ts in the Runback source.
- Regulatory useDesigned against the record-keeping obligations in EU AI Act Article 12 and APRA CPS 230 incident documentation: automatic event logging, with traceability and integrity. Whether a deployment satisfies either is for your assessor to decide; no regulator certifies a file format. The format supplies the evidence that argument needs: manifest.generated_at, a signature that names the producer, and a chain any third party can re-derive without our software.