
Tamper-Evident Audit Logging for Agentic AI: A Node.js Implementation Pattern
On this page
Introduction
The one part of an agentic system you cannot retrofit after an incident
Six months after an agent moves money, issues a refund, or rejects a candidate, someone will ask why — and by then the prompts have changed, the model version has been retired by the vendor, and the policy file has three commits stacked on top of the one that was live at the time. The only artifact that survives is whatever got written down at the moment of the decision. This post is about designing that artifact properly: a Node.js implementation pattern for tamper-evident audit logging in agentic systems, covering canonical record serialization, an HMAC-keyed hash chain, external checkpoint anchoring, and a verifier you can run in CI — mapped onto the EU AI Act's record-keeping and transparency obligations, with the confirmed requirements kept separate from the ones still in motion.
Everything else in an agent stack can be swapped. A different model is a config change; a rewritten tool schema is an afternoon. Reconstructing a March decision is impossible unless you designed for it in March. That asymmetry is the argument for treating tamper-evident audit logging as infrastructure rather than instrumentation.
EU AI Act obligations follow the decision, not the architecture
A HackerNoon analysis of how the EU AI Act regulates agentic AI frames the regulation as a set of constraints that will reshape agent architecture. That framing is half right, and the half that's wrong matters if you're the one building the thing.
Position: most agentic systems are not regulated because they are agents. They are regulated because of what they decide, for whom, and in which domain. The consolidated text of Regulation (EU) 2024/1689 does not define "agentic AI" as far as I can find. Obligations attach to a system's intended purpose and its Article 6 risk classification, not to whether it runs a planning loop. An autonomous refund bot for a retailer sits outside Annex III. A resumé-screening agent does not, however simple its control flow happens to be.
What follows from that: if your agent lands in a high-risk use case, Article 12 record-keeping and Article 14 oversight duties apply, and both have to be demonstrable. If it doesn't, you get Article 50 transparency duties from 2 August 2026 (tell people they're talking to a machine) and no logging obligation at all — but you still can't explain a decision when a customer asks. The engineering artifact is the same either way. This post builds it.
Two caveats up front, because they change scope:
- Confirmed: Article 113 sets the application dates. Prohibitions applied from 2 February 2025; general-purpose AI obligations from 2 August 2025; general application, including Annex III high-risk systems and Article 50 transparency, from 2 August 2026; Annex I product-safety systems from 2 August 2027.
- Unconfirmed, and worth checking against EUR-Lex before you set a budget: amendment proposals have circulated that would push parts of the high-risk timeline later. At the time of writing this is a proposal, not adopted law. If high-risk obligations slip a year, retention and oversight duties slip with them. The schema work does not.
What the EU AI Act Actually Requires From an Audit Log
Article 12 record-keeping requirements read plainly
Article 12 is short, and for engineers it's unusually concrete. High-risk AI systems must technically allow for the automatic recording of events (logs) over the lifetime of the system.
The purposes the text names are the useful part. Logs must support identifying situations that may result in the system presenting a risk, identifying substantial modifications, post-market monitoring under Article 72, and the deployer's monitoring of operation under Article 26(5).
Note what Article 12 does not do. It doesn't prescribe a format. It doesn't require a hash chain, WORM storage, JSONL, or an external timestamp authority. It doesn't specify field names. Anyone telling you the Act mandates Merkle trees is selling something. The Act mandates that the capability exists and that it serves those four purposes. The mechanism is your problem.
Article 12(3) adds a specific floor for biometric identification systems listed in Annex III point 1(a): record the start and end time of each use, the reference database checked against, the input data that produced a match, and the identity of the natural persons involved in verifying the result. That's much sharper than the general clause, and it's the only place in Article 12 where field-level content is spelled out.
Articles 13, 14, 19, and 26 in engineering terms
| Article | Who it binds | What it actually asks for | What that means in code |
|---|---|---|---|
| 13 | Providers | Instructions for use: intended purpose, known limitations, accuracy/robustness metrics, human oversight measures, and technical measures that help the deployer interpret outputs | Ship a versioned doc that the runtime references by hash, so you can prove which instructions were in force |
| 14 | Providers (design), deployers (execution) | Human oversight that is effective: understand limits, resist automation bias, interpret output, and override, stop, or reverse the decision | An approval event with an actor id, a timestamp, and what they saw — not a boolean in a UI |
| 19 | Providers | Keep automatically generated logs for at least six months, unless other Union or national law says otherwise | Retention policy is a floor, not a target; the carve-out for data protection law is explicit |
| 26 | Deployers | Keep logs under their control for at least six months, and assign competent humans to oversight | Your customer is on the hook too; give them an export path |
Article 14 is the one that quietly breaks most agent frameworks. "Human oversight" in a system making 400 tool calls an hour can't mean somebody watching a dashboard. It has to mean specific, recorded interventions at specific decision points, and if the intervention isn't in the log with a named human and a timestamp, you can't demonstrate it happened. For biometric identification, Article 14(5) goes further: no action on the basis of an identification unless at least two natural persons verify it.
Read the two six-month floors against Article 12's "lifetime of the system" and you get an odd pair. The capability has to exist for the system's lifetime; the retention is a minimum of six months. Those aren't the same number, and engineers routinely conflate them.
Risk classification decides which obligations apply
Annex III lists the high-risk categories that catch most enterprise agent deployments: biometrics, critical infrastructure, education and vocational training, employment and worker management, access to essential private and public services (creditworthiness, insurance pricing, emergency dispatch), law enforcement, migration and border control, and administration of justice.
Article 6(3) carves out a narrow derogation. A system in an Annex III area isn't high-risk if it performs a narrow procedural task, improves the result of previously completed human work, detects decision-making patterns without replacing human assessment, or performs a preparatory task. Article 6(4) requires you to document that assessment and register the system. In practice, that documentation is the first artifact an auditor asks for, and it's the one most teams haven't written.
The Article 6(3) derogation is not a loophole you can assert privately. The assessment has to be documented, and the burden sits with you if a market surveillance authority disagrees. Treat "we're probably not high-risk" as an untested assumption until the written assessment exists.
The Gap Between Having Logs and Having Evidence
Why application logs fail as audit evidence
I've watched four separate teams discover during an incident review that their "full audit trail" amounted to a set of strings in a Loki index. The failure modes repeat:
- Rotation. Aggregators drop indices on a 7-, 14-, or 30-day schedule. Evidence for a decision made eight weeks ago is gone.
- Mutability. Database rows can be
UPDATEd. Lines in a bucket can be overwritten. Nothing in the pipeline objects. - No ordering proof. Timestamps come from application clocks across a fleet. Two entries with the same millisecond have no defined order, and one host with a skewed clock quietly rewrites causality.
- Missing actors. The line says
tool=refund.create amount=42. It doesn't say which agent version, on behalf of which user, under which policy, with which approval. - Free-text messages.
"Refund processed for customer"is a sentence, not a record. You can't diff it, and you can't prove it wasn't edited.
The problem isn't volume. It's that none of this was designed to survive an adversarial reader.
The threat model that matters for an audit log
Be specific about who you're defending against, because it determines how much machinery you need:
- The insider with database access. Can
UPDATErows. Motivated by keeping a promotion or avoiding blame. - The compromised agent process. Code execution inside the runtime lets an attacker write whatever it wants to the local file, including a plausible history.
- The log pipeline operator. Controls the collector. Can drop a subset of lines before they reach cold storage.
- Retroactive edit or truncation. The quietest attack: delete the last 40 entries before an incident review so the record ends where it ends.
The design below assumes the writer is hostile. That's the only assumption that makes an audit log worth anything. Your application code isn't the trust boundary; key custody is.
The Hash-Chain Pattern in Plain Terms
Per-entry hash over canonical bytes, the previous hash, and a monotonic sequence number
Each record gets a hash over three things: the canonical serialization of the record itself, the hash of the previous entry, and a strictly increasing sequence number. The first entry's prevHash is 64 zeros.
Edit entry 3 and its hash changes, which breaks entry 4's prevHash, which breaks entry 5's — a single-byte edit invalidates everything downstream. Reordering breaks the back-links. Inserting requires forging the entire tail. That's the property you want: local edits become global failures.
Why a plain SHA-256 chain is insufficient
Three attacks survive a chain with no key and no anchor:
- Truncation. Delete the last 40 lines. What remains is a valid chain of 160 entries, and a verifier that only walks forward reports success. This is the most common real-world attack, and the one people forget.
- Replay. Restore last month's file wholesale. Every hash checks out.
- Recomputation. If the attacker has the code, they have the algorithm. Edit entry 3, then re-hash entries 3 through 200 in a loop. The result is internally consistent. A chain without a secret is a checksum, not a signature.
The chain proves internal consistency. It says nothing about whether this is the real history.
Adding an HMAC whose key lives in a separate signing boundary
Add an HMAC-SHA256 over the entry hash, keyed with a secret that never touches the agent runtime's filesystem or environment in production. The agent holds a reference — a KMS key id, a socket to a signing sidecar — not the bytes.
What that buys and what it doesn't:
- It buys: an attacker with write access to the log file can't produce a self-consistent forged history without also getting the key. Recomputing the chain fails because every
macis wrong. - It doesn't buy: protection from a writer who has the key. If the signing boundary is a sidecar that signs anything the agent asks, an attacker who compromised the agent just asks it to sign a forged chain. The signing boundary has to enforce a policy — monotonic sequence, one chain head, no signing behind the current head — or it's decoration.
Designing the Event Schema Before Writing Code
Required fields for each audit record
Get this wrong and no amount of cryptography saves you. Schema first, code second.
| Field | Why it exists | Notes |
|---|---|---|
traceId, spanId | Reconstruct one decision end to end | W3C Trace Context format, propagated into model calls |
actor | Who acted | {type: "agent"|"human"|"service", id, version} |
onBehalfOf | Delegated authority | The user or tenant, plus the scope they granted |
model.id, model.version | Pin the exact model | Vendor alias is not a version; record the dated snapshot |
policyHash | Prove which rules were in force | Hash of the policy file or prompt template, not its text |
tool.name, tool.argsDigest | What was attempted | Digest, not raw arguments — arguments carry personal data |
decision | The judgement | allow / deny / escalate, from a closed enumeration |
outcome | What actually happened | Separate from decision; they diverge on retries and failures |
approval.ref | Link to the human | Points at an approval event recorded as its own entry |
Two of these carry more weight than they look like. policyHash is what lets you say "the version that denied this application had the old screening threshold" without keeping every prompt ever written. approval.ref is what turns Article 14 from a claim into a record.
Canonical JSON serialization as a correctness requirement
Hashing JSON is a trap unless you fix the byte representation. {"a":1,"b":2} and {"b":2,"a":1} are the same object and different bytes. Canonicalization isn't a stylistic preference; it's the difference between a verifier that works and one that fails on a runtime upgrade.
The rules that matter, and where they bite:
- Key ordering. Sort by UTF-16 code unit order (what
Array.prototype.sort()does on strings) so the ordering is specifiable and not engine-dependent. - Numbers. Use ECMAScript's shortest round-trip formatting — what
JSON.stringifyemits for finite doubles. Never put integers above 2^53 or nanosecond timestamps in a JSON number; they lose precision silently. Use strings. - Unicode. Normalize to NFC before hashing. Otherwise an argument containing
écomposed vs decomposed hashes differently, and the chain "breaks" for a reason nobody can find. - Undefined. Drop
undefinedkeys deliberately rather than lettingJSON.stringifydo it at the wrong layer, and throw onNaN/Infinity/BigIntinstead of coercing.
RFC 8785 specifies the JSON Canonicalization Scheme. My code below is a small subset of it, enough for records you control. If you need full interoperability with other verifiers, use a JCS library rather than my forty lines.
What not to log in an audit record
Minimization isn't just GDPR hygiene. It's how you get to keep logs for six months instead of six days.
- Never log secrets or bearer tokens. Not the prompt, not tool credentials, not the raw user message if it might contain a card number.
- Hash personal data you only need to correlate. A salted hash of the subject id lets you join records without letting a reader learn who the subject was.
- Record digests of arguments, not arguments.
sha256(args)plus an allowlisted set of non-sensitive scalar fields covers most disputes. - Record the policy hash, not the policy. The policy lives in version control with its own retention story.
Implementing the Append-Only Chain in Node.js
Single-writer discipline with O_APPEND and fsync
One process writes to a given chain file. That's a correctness requirement, not a performance note. Two processes appending concurrently read different in-memory heads, produce two entries claiming the same prevHash, and the chain forks invisibly.
Enforce it with a lock file carrying a fencing token: a monotonically increasing integer stored alongside the lock. The signing boundary rejects any sequence number lower than the highest it has seen from that chain id. Without fencing, a paused zombie process can wake up and append stale entries that are cryptographically valid but logically in the past.
O_APPEND (the "a" flag) guarantees the kernel positions each write at the current end of file, which handles write interleaving at the syscall level. It doesn't handle concurrent logic, and it doesn't make anything durable. That's fsync.
The write path step by step
- Take the next sequence number from the in-memory head. Never from disk, never reused after a failure.
- Canonicalize the record together with
seq,recordedAt,prevHash. sha256those bytes →hash.hmac-sha256(key, hash)→mac.- Append one line: the canonical envelope plus
\n, in a single write, looping on partial writes. fsyncthe file handle. Only after this returns do you update the in-memory head.- Return
{seq, hash}so the caller can attach it to the span.
Step 6 is where the ordering matters. Update the in-memory head first, and a crash before fsync completes leaves the next entry claiming a predecessor that doesn't exist on disk. Update it last, and a crash costs you an unrecorded decision instead of a broken chain.
The append path in code: audit-chain.js
import { createHash, createHmac } from "node:crypto";
const GENESIS = "0".repeat(64);
function normalizeKeys(obj) {
const out = {};
for (const [k, v] of Object.entries(obj)) {
if (v === undefined) continue;
out[k.normalize("NFC")] = v;
}
return out;
}
export function canonicalize(value) {
if (value === null) return "null";
switch (typeof value) {
case "boolean":
return value ? "true" : "false";
case "number":
if (!Number.isFinite(value)) {
throw new TypeError("canonicalize: non-finite number");
}
return Object.is(value, -0) ? "0" : JSON.stringify(value);
case "string":
return JSON.stringify(value.normalize("NFC"));
case "object": {
if (Array.isArray(value)) {
return "[" + value.map((v) => canonicalize(v ?? null)).join(",") + "]";
}
const obj = normalizeKeys(value);
const keys = Object.keys(obj).sort();
return (
"{" +
keys.map((k) => JSON.stringify(k) + ":" + canonicalize(obj[k])).join(",") +
"}"
);
}
default:
throw new TypeError("canonicalize: unsupported type " + typeof value);
}
}
const sha256 = (s) => createHash("sha256").update(s, "utf8").digest("hex");
const mac256 = (key, s) => createHmac("sha256", key).update(s, "utf8").digest("hex");
// The exact bytes that get hashed. Verifier recomputes this from the stored line.
export function entryBody({ seq, recordedAt, prevHash, record }) {
return canonicalize({ seq, recordedAt, prevHash, record });
}
export class AuditChain {
#fh;
#key;
#clock;
#seq = 0;
#head = GENESIS;
#tail = Promise.resolve();
constructor({ fh, key, clock }) {
this.#fh = fh;
this.#key = key;
this.#clock = clock;
}
static async open({ path, key, clock = () => new Date().toISOString() }) {
if (!Buffer.isBuffer(key) || key.length < 32) {
throw new Error("AuditChain: need a >= 32 byte HMAC key");
}
await mkdir(dirname(path), { recursive: true });
const fh = await open(path, "a"); // O_APPEND
const chain = new AuditChain({ fh, key, clock });
await chain.#recover(path);
return chain;
}
get head() {
return this.#head;
}
get seq() {
return this.#seq;
}
// Walk the existing file so a restart continues the chain instead of forking it.
async #recover(path) {
const raw = await readFile(path, "utf8").catch(() => "");
const lines = raw.split("
").filter((l) => l.length > 0);
for (const line of lines) {
const parsed = JSON.parse(line); // throws loudly on a corrupt file
this.#seq = parsed.seq;
this.#head = parsed.hash;
}
}
append(record) {
// Serialize appends inside the process; the file lock handles other processes.
const run = this.#tail.then(() => this.#appendOne(record));
this.#tail = run.then(
() => undefined,
() => undefined,
);
return run;
}
async #appendOne(record) {
const seq = this.#seq + 1;
const recordedAt = this.#clock();
const prevHash = this.#head;
const body = entryBody({ seq, recordedAt, prevHash, record });
const hash = sha256(body);
const mac = mac256(this.#key, hash);
const line =
canonicalize({ seq, recordedAt, prevHash, entry: record, hash, mac }) + "
";
const buf = Buffer.from(line, "utf8");
let offset = 0;
while (offset < buf.length) {
const { bytesWritten } = await this.#fh.write(buf, offset, buf.length - offset);
if (bytesWritten <= 0) throw new Error("audit append stalled");
offset += bytesWritten;
}
await this.#fh.sync(); // durability point
// Only now is it safe to advance the in-memory head.
this.#seq = seq;
this.#head = hash;
return { seq, hash };
}
async close() {
await this.#tail;
await this.#fh.close();
}
}The verifier in code: verify-chain.js
import { createHash, createHmac } from "node:crypto";
const GENESIS = "0".repeat(64);
const sha256 = (s) => createHash("sha256").update(s, "utf8").digest("hex");
const mac256 = (key, s) => createHmac("sha256", key).update(s, "utf8").digest("hex");
export async function verifyChain(path, key, { expectHead } = {}) {
const raw = await readFile(path, "utf8");
const lines = raw.split("
");
const problems = [];
const trailing = lines.pop(); // "" when the file ends with a newline
if (trailing !== "") {
problems.push({ line: lines.length + 1, code: "PARTIAL_LINE" });
}
let prevHash = GENESIS;
let expectedSeq = 1;
lines.forEach((line, i) => {
const at = i + 1;
let parsed;
try {
parsed = JSON.parse(line);
} catch {
problems.push({ line: at, code: "UNPARSEABLE" });
return;
}
const { seq, recordedAt, prevHash: claimedPrev, entry, hash, mac } = parsed;
if (seq !== expectedSeq) {
problems.push({ line: at, code: "SEQ_GAP", detail: expectedSeq + " -> " + seq });
}
if (claimedPrev !== prevHash) {
problems.push({ line: at, code: "PREV_HASH_MISMATCH" });
}
const body = entryBody({ seq, recordedAt, prevHash: claimedPrev, record: entry });
if (sha256(body) !== hash) {
problems.push({ line: at, code: "HASH_MISMATCH" });
}
if (mac256(key, hash) !== mac) {
problems.push({ line: at, code: "MAC_MISMATCH" });
}
prevHash = hash;
expectedSeq = seq + 1;
});
if (expectHead && expectHead !== prevHash) {
problems.push({
line: 0,
code: "HEAD_MISMATCH",
detail: expectHead.slice(0, 12) + " != " + prevHash.slice(0, 12),
});
}
return { ok: problems.length === 0, entries: lines.length, head: prevHash, problems };
}
if (import.meta.url === "file://" + process.argv[1]) {
const args = process.argv.slice(2);
const path = args[0];
const headIdx = args.indexOf("--expect-head");
const expectHead = headIdx === -1 ? undefined : args[headIdx + 1];
const key = Buffer.from(process.env.AUDIT_HMAC_KEY ?? "", "base64");
const result = await verifyChain(path, key, { expectHead });
for (const p of result.problems) {
console.error("line " + p.line + ": " + p.code + (p.detail ? " " + p.detail : ""));
}
console.log(
(result.ok ? "OK" : "FAIL") +
" entries=" + result.entries +
" head=" + result.head.slice(0, 12) + "...",
);
process.exit(result.ok ? 0 : 1);
}Here's a clean run against a five-entry chain, then a tampered one. Hashes are abbreviated to twelve hex characters.
$ AUDIT_HMAC_KEY="$KEY" node -e '
import("./audit-chain.js").then(async ({ AuditChain }) => {
const key = Buffer.from(process.env.AUDIT_HMAC_KEY, "base64");
const chain = await AuditChain.open({ path: "var/audit.jsonl", key });
for (const decision of ["allow", "allow", "allow", "escalate", "allow"]) {
await chain.append({ traceId: "4bf92f3577b34da6a3ce929d0e0e4736", decision });
}
await chain.close();
});'
$ AUDIT_HMAC_KEY="$KEY" node verify-chain.js var/audit.jsonl
OK entries=5 head=9d1e4c0b7a3f...
$ sed -i '3s/"decision":"allow"/"decision":"deny"/' var/audit.jsonl
$ AUDIT_HMAC_KEY="$KEY" node verify-chain.js var/audit.jsonl
line 3: HASH_MISMATCH
line 3: MAC_MISMATCH
FAIL entries=5 head=9d1e4c0b7a3f...
Two things in that output. The edit got caught twice, by two independent mechanisms (hash and HMAC), which is what you want — a verifier with one check is one bug away from silent failure. And the reported head is unchanged, because the verifier walks the declared hashes. That's deliberate: it tells you the file's own story is intact apart from the payload, which is exactly the fingerprint of a field-level edit.
Anchoring and Key Custody
Periodic checkpoints to an external store
Every N entries, publish the current head hash somewhere the agent runtime can't rewrite. In increasing order of paranoia:
- An object in S3 with Object Lock in compliance mode, written by a different IAM principal than the one the agent uses.
- A row in a separate account's database with an append-only grant.
- A transparency log with an inclusion-proof API. The Certificate Transparency design, specified in RFC 9162, is the mature pattern.
A published head hash buys you truncation detection, which is the failure a plain chain can't see. If a verifier walks a 160-entry file and computes head 4e77b1d2, and your checkpoint from last Tuesday says 9d1e4c0b, the file was truncated or wholly replaced. Without the checkpoint, a shortened file verifies perfectly.
Separating the signing service from the agent runtime
The HMAC key should live in KMS or a dedicated signing sidecar with a policy. The agent sends {chainId, seq, hash} and gets back a MAC. That boundary is where you enforce:
- Monotonic sequence per chain id. Reject a
seqlower than the highest already signed. This is what kills the zombie-writer fork. - One head per chain. The signer tracks the last
hashit signed for this chain and rejects anything that doesn't chain from it. A rogue process can't start a parallel branch. - Rotation without breaking verification. Key ids go in the envelope (
kid), and after rotation the signer holds both keys: new entries use the new key, verification of old entries uses the retired one. Deleting a retired key destroys your ability to verify history, so retired keys move to a slower, more restricted store, not the bin. - The recovery problem. Lose the key with no escrow and every MAC in the chain becomes unverifiable — six months of "evidence" turns into a set of hashes nobody can authenticate. Decide escrow policy before the first entry, and write down who can invoke it.
Honest limits of anchoring
Anchoring proves the chain existed at a point in time and hasn't been truncated since. It says nothing about whether the recorded decision was lawful, correct, or sane. A perfectly anchored chain of an agent that discriminated against every applicant is a perfectly anchored record of wrongdoing.
Don't let a valid chain become an argument. "The log verifies" answers the integrity question only. Substantive review — was the policy right, was the override justified — is a separate process that the log makes possible, not one it performs.
Wiring the Audit Chain Into an Agent Loop
Propagating trace context across model calls, tool invocations, and retries
One decision often spans a model call, two tool invocations, a retry, and a human approval. All of them need the same traceId, or reconstruction turns into forensic archaeology.
Use W3C Trace Context — traceparent — instead of inventing an id format, because every observability vendor already speaks it. The traceId stays constant across the decision; a new spanId per step. Emit the span id into the audit entry and into your tracing backend so a log line and a trace can be joined by id rather than by timestamp.
The retry case is where naive implementations break. A retried tool call has to produce a second audit entry with a new span id and outcome: "retried", not overwrite the first. The first attempt happened. The record has to say so.
Recording human approval as a first-class event
An approval.ref pointing at nothing is worse than no field at all, because it implies a record that doesn't exist. The approval itself has to be an entry in the chain:
await chain.append({
traceId,
spanId: newSpanId(),
actor: { type: "human", id: "u_2291", role: "compliance-reviewer" },
action: "approval.granted",
subject: { kind: "audit_entry", hash: pendingHash },
approval: { scope: "refund.create", maxAmount: 500, expiresAt: "2026-11-02T09:00:00Z" },
decision: "allow",
});
That's the difference between Article 14 as a design claim and Article 14 as a demonstrable fact. The reviewer's identity, the scope they granted, and the entry they approved are all bound into the hash chain. Nobody can backdate it, and nobody can widen the scope afterwards.
Failure modes and recovery
| Failure | What you observe | Recovery |
|---|---|---|
Crash between append and fsync | Line present in page cache, absent after power loss, or absent entirely | Never reuse the sequence number; on restart, rescan and continue from the last durable entry |
| Partial write | Last line has no trailing newline | Verifier flags PARTIAL_LINE; writer truncates to the last complete line while holding the lock, and appends a chain.repaired entry so the repair is itself recorded |
| Duplicate sequence number | Two entries with the same seq, different prevHash | Caused by two writers. The signer's monotonic check should have rejected the second. If it didn't, your signing boundary is too permissive |
| Log rotation | New file starts at seq: 1 and GENESIS | Carry prevHash across files and record a chain.rotated entry in both, or the rotation looks identical to a truncation |
| Clock skew | Out-of-order recordedAt across hosts | Order on seq, never on time. Treat recordedAt as advisory metadata, not ordering evidence |
The last row is the one I'd underline. Any verifier that sorts by timestamp instead of sequence has a plausible-looking bug that destroys evidentiary value, and it only shows up when you have hosts with drifting clocks — which is to say, always.
Retention, Deletion, and the GDPR Collision
Reconciling the six-month retention floor with erasure
Articles 19 and 26(6) both say six months, and both carry the qualifier unless provided otherwise in applicable Union or national law, in particular on personal data protection. That qualifier is where retention meets erasure.
The collision is real: a data subject invokes Article 17 GDPR and asks you to erase their data, while Article 19 asks you to keep six months of logs. They aren't actually in direct conflict, because Article 17(3) carves out processing necessary for compliance with a legal obligation and for the establishment, exercise, or defence of legal claims.
A practical approach to retention and erasure
- Hash the personal data at write time. A subject identifier stored as
sha256(subject_id || salt)with the salt held in the signing boundary is pseudonymized, not anonymized. Under GDPR Recital 26 and the CJEU's Breyer reasoning, if you can re-identify with additional data you hold, it's still personal data. That's fine; pseudonymized data with a documented purpose is defensible. - Keep the evidentiary skeleton. Sequence number, timestamps, hashes, decision codes, model version, and approval actor role survive erasure. The chain stays verifiable; the human becomes a keyed digest.
- Make erasure an audit event of its own. Append
subject.erasedwith the subject hash and the legal basis. Now the erasure is provable too. - Document the balancing test. One page: what you retain, why, which Article 17(3) ground applies, who signed off, when it gets reviewed. This is the document a supervisory authority asks for, and it takes an hour to write before an incident and a week after.
- Run a DPIA where Article 35 applies. An oversight log for a high-risk system is a strong candidate.
Testing the Log Like an Attacker
A table of tamper attempts against the verifier
I ran each of these against the implementation above. "Detected" means the verifier exited non-zero.
| Attempt | What the verifier reports | Detected without anchor? |
|---|---|---|
| Edit one field in entry 3 | HASH_MISMATCH, MAC_MISMATCH at line 3 | Yes |
| Delete entry 3 of 5 | PREV_HASH_MISMATCH at line 3, SEQ_GAP | Yes |
| Truncate last two entries | All checks pass | No — needs --expect-head |
| Swap entries 2 and 4 | PREV_HASH_MISMATCH from line 2 onward | Yes |
| Forge an entry without the HMAC key | MAC_MISMATCH on the forged entry and every entry after it | Yes |
| Recompute the whole chain without the key | MAC_MISMATCH on every line | Yes |
| Rewrite the file from a self-consistent copy signed earlier | All checks pass, head matches an old checkpoint | No unless you track the latest checkpoint, not just any checkpoint |
| Rotate to a fresh file | Looks identical to truncation | No without a chain.rotated record |
The two rows that fail without an anchor are the ones that matter most in practice. An insider who wants history rewritten won't edit a field. They'll delete the tail or restore an old file. If your verification story stops at "the chain walks cleanly," you've defended against the least likely attacker.
Running the verifier in CI and on a schedule
Verification is a monitoring check, not a one-time forensic tool. Two triggers: every integration-test run against a fixture chain, and hourly against production, comparing to the newest checkpoint.
#!/usr/bin/env bash
set -euo pipefail
HEAD=$(jq -r '.head' var/checkpoint.json)
AUDIT_HMAC_KEY="$AUDIT_HMAC_KEY" \
node verify-chain.js var/audit.jsonl --expect-head "$HEAD"
$ ./scripts/audit-verify.sh
OK entries=5 head=9d1e4c0b7a3f...
$ head -n 4 var/audit.jsonl > /tmp/t.jsonl && mv /tmp/t.jsonl var/audit.jsonl
$ ./scripts/audit-verify.sh
line 0: HEAD_MISMATCH 9d1e4c0b7a3f... != 4e77b1d2c811...
FAIL entries=4 head=4e77b1d2c811...
Exit code 1 fails the CI job and pages on the scheduled run. The alert text is the useful part. MAC_MISMATCH means someone without the key tried to write history; HEAD_MISMATCH means the file no longer matches the last anchored state; SEQ_GAP with a valid MAC means your writer coordination is broken and you have a correctness bug, not an attack.
What This Pattern Does Not Solve
Open and unconfirmed items
Things I won't state as settled, and what would settle them:
- Harmonised standards status. CEN-CENELEC JTC 21 has been producing standards intended to give presumption of conformity. Which ones are cited in the Official Journal, and on what date, changes your documentation burden materially. Check the Commission's AI Act pages rather than a vendor whitepaper.
- Amendment timelines. Proposals have been discussed to delay high-risk obligations. If adopted, dates move; if not, they don't. Either way, Article 12's capability requirement and the design work here don't change.
- Whether your system is high-risk at all. That's your Article 6(3)/6(4) assessment, and it's an argument you make in writing, not a fact I can establish from outside. Untested until a market surveillance authority or a customer's procurement team challenges it.
- Timestamp authority. I didn't add RFC 3161 timestamping or an external notary. For most deployments the S3 Object Lock checkpoint is proportionate. If you need to prove a specific entry existed before a specific wall-clock time to a third party, you probably do need a TSA.
Closing position
The chain is the cheap part. Forty lines of canonicalization, forty of verification, a cron job — a day of work. The expensive parts need other people's signatures: key custody (who can sign, where the escrow lives, what happens on loss) and schema discipline (agreement on what a decision record contains, enforced at the type level so nobody adds a field that silently breaks canonicalization).
Teams that get this right treat the audit log as a product with a schema, a version, and an owner. Teams that get it wrong write a hash function, feel secure, and discover during the incident review that the log stopped at the truncation point and nobody was checking the head.
A tamper-evident log only answers one question — has this record been altered or truncated since it was written — but it's the question every later review depends on. Start with the schema and the checkpoint. Those two are worth more than the cryptography around them.
Further Reading
- Regulation (EU) 2024/1689 (AI Act), consolidated text on EUR-Lex — the primary source; read Articles 6, 12, 13, 14, 19, 26, 72, 113 and Annex III directly.
- European Commission — AI Act policy page — the place to check harmonised standards citations and amendment status.
- Regulation (EU) 2016/679 (GDPR), consolidated text on EUR-Lex — Articles 5, 17, 26, 30, and 35 for the retention and erasure section.
- RFC 8785 — JSON Canonicalization Scheme — the specification my
canonicalizefunction approximates. - RFC 9162 — Certificate Transparency v2 — the reference design for append-only logs with inclusion proofs.
- W3C Trace Context — the
traceparentformat used for decision reconstruction. - AWS S3 Object Lock documentation — the practical WORM option for checkpoints.
- Node.js
cryptomodule documentation —createHash,createHmac, and key object handling. - How the EU AI Act Regulates Agentic AI (HackerNoon) — the secondary analysis that prompted this post; prefer the EUR-Lex text for anything load-bearing.
More posts

Shipping AI Agents Under the EU AI Act: A Developer Checklist for Risk Tiers and Audit Trails
A developer-focused walkthrough of how the EU AI Act's risk tiers translate into concrete engineering obligations — model documentation, audit trails, human-in-the-loop gates, and incident reporting — when shipping agentic AI features.

What the Cyber Resilience Act Actually Requires From a Release Pipeline
Map the EU Cyber Resilience Act’s vulnerability disclosure, security update, and SBOM requirements onto CI/CD pipelines and dependency-management tools.

Auditing Model Routing and Prompt Caching Before You Trust the Cost Chart
A hands-on audit framework for verifying that an LLM gateway's model routing and prompt caching actually cut token spend before you trust the vendor's cost chart.
Loading comments…