The audit trail is the algorithm.
Every trace becomes one chain entry: prev hash, payload digest, sequence, timestamp. Anyone with the export and the algorithm replays verification offline.
{
"sequence": 17493,
"traceId": "trc_847b3f...",
"prevHash": "a4f2c8d1e6b9...3d7c5e2f9a1b",
"payloadDigest": "9e3f7c2a8d5b...6f4e1c9b2a8d",
"chainHash": "f7e2d9a4c1b6...8a3e7d2c9f1b",
"createdAt": "2026-05-03T10:14:22.317Z"
}Three hashes per entry: prevHash, payloadDigest, chainHash. The merkleRoot and eidasTimestamp fields are reserved for the Phase-1 roadmap; current chains use the three core hashes.Bytes in. Verdict out. Four steps between.
Every trace lands in the chain as one HashChainEntry: a sequence number, the previous hash, a SHA-256 of the trace payload, and a SHA-256 of those three concatenated. Our verify endpoint just replays the algorithm below. So does anyone with your export bundle. Below: four blocks, one verdict.
The payload digest is a SHA-256 of the trace's canonical JSON view. The view excludes embedding (too large to chain) and deletedAt (mutable — would break the chain on soft-delete). Canonicalisation guarantees identical traces always produce the same digest.
// 01 payloadDigest — backend/services/hashChainService.js
function tracePayloadDigest(trace) {
// Canonical view — excludes embedding (size) and deletedAt (mutable).
const view = {
traceId: trace.traceId,
organizationId: String(trace.organizationId),
agentId: trace.agentId,
inputContext: trace.inputContext ?? null,
outputDecision: trace.outputDecision ?? null,
status: trace.status,
timestamp: trace.timestamp.toISOString(),
confidenceScore: trace.confidenceScore ?? null,
// … plus 5 more fields: agentVersion, schemaVersion, rationale,
// humanOverride, adapter
};
return sha256Hex(canonicalize(view));
}The chain hash binds four values: the previous chain hash, the payload digest from step 01, the per-org sequence number, and the entry's timestamp. The sequence comes from atomic Mongo $inc on Organization.hashChainCounter — the single-writer-lock that prevents chain forking. GENESIS_HASH is sixty-four zeros.
// 02 chainHash — backend/services/hashChainService.js
async function appendTrace(orgId, trace) {
const payloadDigest = tracePayloadDigest(trace);
// Atomic single-writer-lock per org via Mongo $inc on hashChainCounter.
const updated = await Organization.findByIdAndUpdate(
orgId,
{ $inc: { hashChainCounter: 1 } },
{ new: true, projection: { hashChainCounter: 1, lastChainHash: 1 } }
).lean();
const sequence = updated.hashChainCounter;
const prevHash = updated.lastChainHash || GENESIS_HASH; // 64 zeros
const createdAt = new Date();
const chainHash = sha256Hex(
`${prevHash}|${payloadDigest}|${sequence}|${createdAt.toISOString()}`
);
// ... persist HashChainEntry, update Organization.lastChainHash
}Verification streams every entry by sequence, recomputes each chain hash with the same SHA-256 formula from step 02, and compares it to the stored value. Two checks per entry: the prev-hash matches the previous entry's chainHash, and the recomputed hash matches the stored one. Both must pass.
// 03 replay — backend/services/hashChainService.js
async function verifyChain(orgId, opts = {}) {
const cursor = HashChainEntry.find({ organizationId: orgId })
.sort({ sequence: 1 }).lean().cursor();
let prevHash = null;
let expectedSequence = null;
let lastValidSequence = 0;
for await (const entry of cursor) {
// Sequence-monotonicity gap detection: a failed insert leaves the
// counter incremented but no entry, so verify must catch the missing
// sequence number, not just the prev-hash chain.
if (expectedSequence === null) expectedSequence = entry.sequence;
else if (entry.sequence !== expectedSequence) {
return { verified: false, brokenAt: entry.sequence,
brokenReason: 'sequence-gap', lastValidSequence,
expectedSequence };
}
const expectedPrev = prevHash === null ? GENESIS_HASH : prevHash;
if (entry.prevHash !== expectedPrev) {
return { verified: false, brokenAt: entry.sequence,
brokenReason: 'prev-hash-mismatch', lastValidSequence };
}
const recomputed = sha256Hex(
`${entry.prevHash}|${entry.payloadDigest}|${entry.sequence}` +
`|${entry.createdAt.toISOString()}`
);
if (recomputed !== entry.chainHash) {
return { verified: false, brokenAt: entry.sequence,
brokenReason: 'chain-hash-mismatch', lastValidSequence };
}
prevHash = entry.chainHash;
lastValidSequence = entry.sequence;
expectedSequence += 1;
}
return { verified: true, lastValidSequence };
}The verify endpoint returns a verdict object: a boolean integrity flag, the total entries replayed, the last valid sequence, and — on failure — the first failing entry plus the reason. One of prev-hash-mismatch (chain link broken), chain-hash-mismatch (entry tampered with), or sequence-gap (a write failed mid-flight and left a missing entry). The verdict is what the regulator reads.
{
"verified": true,
"ok": true,
"totalChecked": 17493,
"lastValidSequence": 17493,
"brokenAtSequence": null,
"brokenReason": null,
"durationMs": 842,
"verifiedAt": "2026-05-03T10:14:22.317Z"
}Three guarantees that survive a hostile insider.
What survives a hostile insider on the Adjudon side? Three properties: one writer per org, no updateOne path through the audit code, and an export the customer can replay offline against the published algorithm. None of the three is a customer setting. All three are construction.
One writer per org
The next sequence number comes from atomic Mongo $inc on Organization.hashChainCounter. Two concurrent appends serialize on Mongo's document-level lock — neither sees the same predecessor. Chain forks become impossible by construction, not by coordination.
No updateOne path
Cardinal Rule #5: no updateOne against existing entries, ever. Soft-delete nullifies trace payload fields; the chain entry's payloadDigest was computed pre-erasure and stays stable. The constraint is enforced in code review and by the absence of any audit-shaped helper that touches a written entry.
Replayable offline
GET /api/v1/hash-chain/export returns the chain as a self-contained bundle. With the bundle and the four-step algorithm above, you replay verification on your own machine — no Adjudon network, no Adjudon dashboard, no Adjudon login. The chain survives our shutdown.
Article 13 + 14, replayable from the export.
Article 13 obligates deployers to expose how outputs are produced. The chain IS that exposure — per-trace input, output, status, confidence, and policy-match, all hashed into one verifiable sequence. Article 14 obligates human oversight; flagged-status decisions are permanently chain-anchored, and the Review Queue links every human action back to the trace by traceId.
| Article | Obligation | Audit Trail artefact |
|---|---|---|
| Article 13 | Deployer must expose how outputs are produced — input, decision, confidence, policy outcome. | Per-trace inputContext + outputDecision + status + confidenceScore + matchedPolicy — all hashed into the per-org sequence. Verify endpoint replays the chain on demand. |
| Article 14 | Human oversight on AI decisions must be feasible — flagged decisions reviewable, review actions auditable. | Trace's status='flagged' is permanently chain-anchored. Review Queue records reviewer decision (approve / reject / escalate) linked by traceId. Review action itself is audit-logged on the Operations chain. See docs.adjudon.com/concepts/audit-and-security. |
Their dashboard. Your replay.
Most AI-governance platforms write their audit logs to their own database, surface them through their own dashboard, and require their own login to produce evidence — making the vendor a single point of failure for your audit. Adjudon writes the chain on our infrastructure too, but the export bundle is yours to download, replay, and hand to an auditor.
| Topic | Typical AI-governance platform | Adjudon |
|---|---|---|
| Storage | Audit log lives only in the vendor's database. | Chain lives in our database and in your downloadable export bundle. |
| Access to evidence | Producing evidence requires logging into the vendor. | Replaying the chain needs only the export and the published algorithm. |
| Export format | A PDF or CSV screenshot of the dashboard. | A self-contained JSON bundle with every hash and sequence number. |
| Vendor failure | If the vendor disappears, the evidence is unverifiable. | If Adjudon disappears, your chain still verifies offline. |
Adjudon disappears tomorrow. Your chain still verifies.
You've seen four steps to a verdict, three guarantees that hold under attack, and a four-row comparison that makes the wedge concrete. Next: wire one trace, generate your own chain, export it, replay it offline. The first call lands at the engineer who wrote the chain — not at an SDR.