How We Built a Tamper-Evident Ledger for AI Decisions
A short engineering walkthrough of the hash chain, canonical serialisation, and advisory locks that make Precipiqs decision log forensically credible.
A log that a customer could have modified after the fact is not evidence. It is a narrative. That distinction matters more than the engineering complexity of fixing it, so we fixed it.
This post walks through the three pieces that make Precipiq's decision log credible under forensic scrutiny: the hash chain, the canonical identity function, and the serialisation guard that prevents a corrupted write from invalidating the chain.
The shape of the problem
Every AI decision is a row in a Postgres table. Rows have:
id(UUID, server-generated)org_id(FK to organizations)agent_id,action_type(caller-supplied strings)inputs,outputs,alternatives(JSONB)confidence,human_in_loop,timestampprev_hash— the hash of the previous decision for this orghash— a hash over{id, org_id, agent_id, action_type, inputs, outputs, timestamp, prev_hash}
A naive implementation has two classes of failure. First, if you just do
SHA256(json.dumps(dict_of_fields)) you will produce different hashes
on different machines because Python dict iteration order used to vary
and because JSON number encoding (1.0 vs 1) drifts across libraries.
Second, under concurrent writes, two inserts can both read the same
last_hash value, both insert rows pointing at the same predecessor, and
fork the chain.
The canonical identity function
Our hash input is built from a plain Python dict but serialised with
json.dumps(payload, sort_keys=True, separators=(",", ":")). That
combination gives us:
- Deterministic key order (alphabetic).
- No whitespace drift between writer and verifier.
- No
Decimalvsfloatambiguity — amounts are normalised to strings upstream, so the serialiser never has to choose a numeric encoding.
That determinism is the floor. Without it, the same inputs hash differently on different machines and verification fails for reasons that have nothing to do with tampering.
The advisory-lock guard
Concurrent inserts are serialised per-org with a PostgreSQL advisory lock:
SELECT pg_advisory_xact_lock(:org_hash);
SELECT hash FROM ai_decisions WHERE org_id = :org ORDER BY created_at DESC LIMIT 1;
-- compute new hash, insert, commit
The lock is acquired inside the same transaction as the insert, so it releases automatically when the transaction commits or rolls back. Two concurrent writers for the same org queue up behind each other; writers for different orgs proceed in parallel.
This costs us a few milliseconds per insert under contention. In exchange, we get the invariant that a verifier walking the chain can always rebuild it in exactly the order it was written.
The verification walk
Verification is just the inverse of the write. Walk from the genesis
row (the one whose prev_hash is 64 zeros), hash each row the same
way the writer did, compare against the stored hash, and move on to
the row whose prev_hash equals the just-verified hash.
If any row's recomputed hash doesn't match its stored value, we stop
and return the first broken link. That's what /decisions/chain/verify
emits — a boolean plus an optional UUID pointing at the break.
What the chain doesn't do
A few things worth being honest about:
- It doesn't prevent tampering. An attacker with DB write access can rewrite rows. The chain ensures we notice — they would have to rewrite every subsequent row's hash too, and the genesis hash is pinned in our backup pipeline.
- It doesn't make the log admissible as evidence. That's a question for your attorney and the relevant jurisdiction. Having a cryptographically verifiable record strengthens your position; it doesn't automatically win the argument.
- It doesn't answer "what should the AI have done?" We capture what the AI did, not what it should have done. Policy adherence is a separate product layer.
Where we want to go next
The immediate roadmap is signed export bundles — a customer who needs to hand the log to a regulator shouldn't have to trust our infra. We sign every bundle with a per-org RSA key so the regulator can verify authenticity without talking to us.
Beyond that: multi-party signing (a second signature from an insurer or counterparty), Merkle-tree batching (faster verification for very long chains), and optional external anchor (one hash per day committed to a public ledger). These are all "once we have the customers who need them" features.
The floor — deterministic hashes, advisory-locked inserts, per-org chains — is what we need to stand up today. That's what we built.