Verifiable, not viewable

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.

Wire one trace See the four steps
SHA-256 chain·Per-org sequence·Offline replay
One chain entry · sequence 17493
{
  "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.

01 payloadDigest — sha256(canonicalJson(traceView))

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));
}
02 chainHash — sha256(prevHash | digest | sequence | timestamp)

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
}
03 replay — recompute every chainHash in sequence order

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 };
}
04 verdict — verify-result shape

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.

01 Chain holds

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.

02 Chain holds

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.

03 Chain holds

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.

ArticleObligationAudit Trail artefact
Article 13Deployer 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 14Human 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.

TopicTypical AI-governance platformAdjudon
StorageAudit log lives only in the vendor's database.Chain lives in our database and in your downloadable export bundle.
Access to evidenceProducing evidence requires logging into the vendor.Replaying the chain needs only the export and the published algorithm.
Export formatA PDF or CSV screenshot of the dashboard.A self-contained JSON bundle with every hash and sequence number.
Vendor failureIf 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.