On August 2, 2026, Article 12 of the EU AI Act enters full enforcement. Every organization deploying a high-risk AI system in the EU has to maintain automatic per-event logs of what the system did and why, retain them for at least six months, and surface them on demand to the supervisory authority. Fines reach โฌ15M or 3% of global turnover. The obligation lands on the deployer, not the AI vendor.
A lot of teams are about to need a "decision log" for their LLM features, and "trust our database" is not the right answer when an auditor or opposing counsel asks how a specific decision was reached on a specific date.
The good news: the technical pattern that solves this is small and old. Hash-chained logs were used for receipt timestamping in the 90s, certificate transparency uses them, git uses them, blockchains use them. The whole thing fits in about 150 lines of TypeScript. This post walks through that 150 lines, what to be careful about, and where it stops being a moat.
I built Praxa around this pattern. The open-source verifier is @piposlabs/praxa-verify on npm. You can see the whole idea running in your browser, no signup, at praxa.piposlab.com/playground: five sample AI decisions in a chain, edit a field, watch the verifier flag the tamper.
What "tamper-evident" actually means
There are three properties we want from the log:
- Append-only. You can add new entries but not change old ones.
- Order-preserving. The order of entries cannot be quietly rearranged.
- Detectable tamper. If anyone edits or removes a past entry, the next reader can prove it.
We do not need entries to be encrypted, or unreadable. We do not need the database itself to be unmodifiable. We just need a way for someone with the log to detect that it was changed.
The hash chain gets us all three with one rule.
The 100-line core
Each event in the chain hashes to a digest. Each entry stores the digest of the previous entry. Editing an old entry changes its digest, which changes the digest stored on the next entry, which changes the next, and so on. A single bit-flip in event #5 invalidates the chain from #5 forward.
import { createHash } from "node:crypto";
interface ChainEntry {
index: number;
occurredAt: string;
agentId: string;
event: Record<string, unknown>;
prevHash: string;
hash: string;
}
function canonicalize(value: unknown): string {
if (value === null || typeof value !== "object") return JSON.stringify(value);
if (Array.isArray(value)) return "[" + value.map(canonicalize).join(",") + "]";
const obj = value as Record<string, unknown>;
const keys = Object.keys(obj).filter((k) => obj[k] !== undefined).sort();
return "{" + keys.map((k) => JSON.stringify(k) + ":" + canonicalize(obj[k])).join(",") + "}";
}
function digestOf(body: object, prevHash: string): string {
return createHash("sha256")
.update(canonicalize(body))
.update("|")
.update(prevHash)
.digest("hex");
}
export function append(prev: ChainEntry | null, entry: Omit<ChainEntry, "index" | "prevHash" | "hash">): ChainEntry {
const index = prev ? prev.index + 1 : 0;
const prevHash = prev?.hash ?? "0".repeat(64);
const body = { index, occurredAt: entry.occurredAt, agentId: entry.agentId, event: entry.event };
const hash = digestOf(body, prevHash);
return { ...body, prevHash, hash };
}
export function verify(entries: ChainEntry[]): { ok: true } | { ok: false; firstBadIndex: number } {
let expectedPrev = "0".repeat(64);
for (let i = 0; i < entries.length; i++) {
const e = entries[i];
if (e.index !== i) return { ok: false, firstBadIndex: i };
if (e.prevHash !== expectedPrev) return { ok: false, firstBadIndex: i };
const recomputed = digestOf(
{ index: e.index, occurredAt: e.occurredAt, agentId: e.agentId, event: e.event },
e.prevHash,
);
if (e.hash !== recomputed) return { ok: false, firstBadIndex: i };
expectedPrev = e.hash;
}
return { ok: true };
}
That is the whole pattern. SHA-256 (boring on purpose, every auditor recognizes it), one digest per entry, the digest mixes in the previous one. Adding an entry is O(1). Verifying the full chain is O(n) on the number of entries.
canonicalize() is more important than the hash
The most common way I see this pattern broken in production: someone uses JSON.stringify on the event payload, then changes one key order somewhere downstream (maybe a different driver or a different language), and now the verifier disagrees with the producer about the digest of the same logical event. The chain looks tampered when it isn't.
canonicalize() solves this by being deterministic about three things:
- Key order. Always alphabetical.
-
Undefined. Dropped. (
nullis kept.) - Arrays. Insertion order. We do not sort arrays, sorting them would lose meaningful order in lists like "messages in a conversation".
Notice we do not canonicalize via CBOR or another binary format. We could. CBOR's determinism is slightly tighter. But for an audit log, the canonical form has to be inspectable by a human if there is ever a fight. "Open the canonical bytes" should be reasonable for the auditor. JSON wins on legibility, CBOR wins on byte-tightness, and we are not in the byte-tight regime.
Ship the verifier as a separate package
Here is the part that turns this from a technical curiosity into a useful product: the auditor does not have to trust you to verify your own log.
We ship @piposlabs/praxa-verify as its own npm package. MIT-licensed, zero dependencies, about 150 lines of TypeScript. The auditor exports the chain from the dashboard and runs:
npx @piposlabs/praxa-verify chain.json
If the chain is intact, the verifier exits 0 with a one-line summary. If a tamper is present, it exits 1 and prints the first bad index plus what was different.
There are two ways this can go wrong:
- The verifier and the producer disagree about
canonicalize(). Then a clean chain looks tampered, or worse, a tampered chain looks clean. - The verifier is updated and the producer is not (or vice versa).
The fix is one paragraph of CI: a known-answer test that lives in both packages and fails the build if the two disagree.
// In both packages: tests/known-answers.test.ts
import { canonicalize, append } from "../src/chain";
const FIXED_EVENT = { score: 0.71, agentId: "demo", input: { name: "x" } };
const FIXED_CANONICAL = '{"agentId":"demo","input":{"name":"x"},"score":0.71}';
const FIXED_DIGEST_AT_INDEX_0 = "f6b3d09e3a...";
test("canonicalize is stable", () => {
expect(canonicalize(FIXED_EVENT)).toBe(FIXED_CANONICAL);
});
test("digest at index 0 matches the known answer", () => {
const e = append(null, { occurredAt: "2026-05-01T00:00:00.000Z", agentId: "demo", event: FIXED_EVENT });
expect(e.hash).toBe(FIXED_DIGEST_AT_INDEX_0);
});
Now if either the producer or the verifier drifts, that test fails before either ships. The locked-in answer is the moat. The hash chain on its own is a 100-line standard pattern; the locked-in canonicalization across two packages is what makes the evidence defensible.
The playground: Web Crypto in the browser
The same chain runs in the browser via the Web Crypto API. That is the magic behind the no-signup playground at praxa.piposlab.com/playground: visitors run the verifier locally on five seeded AI hiring decisions, tamper any field, and see the chain break in real time.
The browser version is even shorter because Web Crypto handles the hash:
const encoder = new TextEncoder();
async function digestOfBrowser(body: object, prevHash: string): Promise<string> {
const bytes = encoder.encode(canonicalize(body) + "|" + prevHash);
const buffer = await crypto.subtle.digest("SHA-256", bytes);
return Array.from(new Uint8Array(buffer))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
Async because Web Crypto is async. Otherwise identical to the Node version. Same canonicalize(). Same locked-in known-answer test. The verifier you run in npx and the verifier the visitor's browser runs against the sample chain are the same function with the same locked answer.
Why this matters for distribution: the playground is doing real cryptographic verification, not a fake animation. A skeptical reader can open DevTools and watch it happen. That is what an honest demo looks like for this kind of product, and it is also the cheapest possible marketing surface (one static page, no server, no analytics, no signup).
What about TSA / blockchain anchoring
I get this question every time I talk to a security-minded engineer about hash chains:
"But if the operator runs the producer AND the verifier, they could swap both to the new tampered version. The chain alone does not prove freshness."
True. The chain proves nobody can modify a past entry without the next reader noticing, given the next reader has at least one untampered hash from the past. It does not, on its own, prove anything about WHEN an entry was added.
There are two stronger constructions:
RFC 3161 timestamp authority anchoring. Once a day (or on every entry, if you really want), the latest chain head is signed by a third-party Time Stamp Authority. Now the auditor can verify both "this chain has not been retroactively modified" and "the head as of date X existed at least by date X." Cheap. Defensible. The catch: it requires an external paid service like DigiCert or Sectigo and a small amount of network plumbing.
Public ledger anchoring. Same idea but to a public chain like Ethereum or a Certificate Transparency log. Stronger because no single TSA can be coerced. Adds blockchain gas to the equation.
For most deployers in 2026, the chain alone + an RFC 3161 anchor once a day is enough. The public-ledger version is mostly procurement theatre at the moment: I have not seen an auditor actually ask for it on a non-tier-1-financial-services deployer. Praxa has TSA anchoring on the Business tier and skips public-ledger anchoring entirely (until a customer asks).
What this is not
The hash chain alone is not:
- A bias audit. NYC LL144 needs an annual third-party bias audit, which is a different artifact entirely. Hash chains do not make a system fair; they make it auditable.
- Encryption. Anyone with read access to the chain reads every event. If your events contain PII, you still need GDPR Article 5(1)(e) data minimization and Article 32 encryption-at-rest. Praxa logs are encrypted-at-rest by Postgres + KMS, but that is orthogonal to the tamper-evidence.
- An access control system. Who can append, who can read, and who can export the chain are separate questions. Get those wrong and your beautiful hash chain ends up worthless because anyone could have appended a fake entry yesterday.
The honest sales pitch
The pattern in this post is fully reproducible. AuditKit ships the same idea as MIT open source. VeritasChain is an open standard. The hash-chain crypto is a 100-line pattern that any senior engineer can write in an afternoon, and there is no moat there.
The product layer on top is the moat:
- A three-line SDK call so the engineer wrapping a model call does not have to think about hashes.
- The locked-in known-answer test across the producer and the verifier so the evidence is defensible.
- Evidence packs mapped to Article 12 ยง1(a)-(c) and Article 26 deployer obligations.
- A no-signup playground that lets a skeptical engineer try the whole idea in 5 seconds.
- A free tier that covers 10,000 decisions a month, so a small deployer can actually use this for their first six months of compliance without paying.
If you build this yourself, you will end up with a hash chain. The hash chain is the easy part. The packaging is the hard part.
Try the playground
Five seconds, no signup, you tamper an audit chain in your browser and the verifier flags it:
praxa.piposlab.com/playground
And if you want to verify our chain serialization is honest, the OSS verifier is on npm:
npx @piposlabs/praxa-verify chain.json
Source on GitHub: github.com/bufaale/praxa (MIT).
Honest disclosure: I am the founder of Praxa, the hosted product on top of this open-source verifier. No paying customers yet, this post is part of the launch. Feedback from anyone deploying AI in the EU, or anyone who has worked through an AI Act audit, is welcome in the comments.
Top comments (0)