Audit hash chain
Every decision is linked into a per-organization hash chain you can recompute offline to prove nothing was tampered with.
Every state change to an approval emits an AuditEvent in the same database transaction
as the change itself — a status without its audit record (or the reverse) cannot exist. The
events are append-only by construction and by a database guard: there is no update or delete
path, ever.
Each event is linked into a per-organization hash chain, so you can recompute it offline from an export and prove it has not been altered — the evidence a SOC 2 or EU AI Act auditor asks for.
The chain
Each event carries seq, prevHash, hash, type, actor, data, and createdAt.
seqis per-org, gap-free, starting at1.hash = sha256(prevHash + canonicalize(data)), hex-encoded.- The genesis event (
seq = 1) usesprevHash =64 zeros ("0".repeat(64)). actoris recorded alongside the event but is not part of the hash.
canonicalize(data) is a deterministic JSON serialization (recursively sorted object keys,
no insignificant whitespace) so the bytes are identical regardless of key order.
Recompute offline
Export the chain with GET /v1/audit and recompute it with nothing but a
SHA-256 implementation:
import { createHash } from "node:crypto";
const GENESIS = "0".repeat(64);
const canonicalize = (v) =>
JSON.stringify(v, (_k, val) =>
val && typeof val === "object" && !Array.isArray(val)
? Object.fromEntries(Object.entries(val).sort(([a], [b]) => a.localeCompare(b)))
: val,
);
export function verify(events) {
let prev = GENESIS;
for (const [i, e] of events.entries()) {
const hash = createHash("sha256")
.update(prev + canonicalize(e.data))
.digest("hex");
if (e.seq !== i + 1 || e.prevHash !== prev || e.hash !== hash) {
return { intact: false, firstBrokenSeq: e.seq };
}
prev = e.hash;
}
return { intact: true, count: events.length };
}events is the exported array ordered by seq. Any tamper — a changed data, a reordered
row, a deleted event (a seq gap) — is pinpointed by firstBrokenSeq. Passing
?verify=true to the export returns the same { intact, ... } summary computed server-side.