Seal
Concepts

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.

  • seq is per-org, gap-free, starting at 1.
  • hash = sha256(prevHash + canonicalize(data)), hex-encoded.
  • The genesis event (seq = 1) uses prevHash = 64 zeros ("0".repeat(64)).
  • actor is 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:

verify.js
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.

On this page