← Blog

To verify an agent audit trail, recompute the hash chain. Grep is not verification.

I work at Auth0. A hash chain is tamper-evident: an edit shows up. A privileged operator with the key can still write a replacement chain. This page will not call that a certification.

Verification means: given an export plus a trusted starting checkpoint and expected latest tip, you can show that no row between those checkpoints was dropped, inserted, or rewritten. Without the expected tip, deleting records from the tail remains undetectable. The algorithm is old:

  1. Sort by sequence number. The first row's prev is a genesis sentinel.
  2. For each row, hash the canonical body (including prev) with SHA-256, or HMAC-SHA256 if you have a key.
  3. Compare to the stored hash. Then the next row's prev must equal that hash.
  4. On the first mismatch, fail and report the sequence number. Do not keep scanning as if the rest were fine.

Opening the JSON in an editor and reading it is review. It is not verification. A chat transcript is neither.


The naive approach (and when it is sufficient)

Append-only Postgres with no UPDATE/DELETE grants for the app role, plus object-lock on the export bucket, is enough when:

  • you trust the database administrators as much as you trust the investigation;
  • you only need “we have a row,” not “this row is the same bits we wrote at 14:03”;
  • the agent cannot reach the log table.

A 20-line verifier on top of that is still worth writing — it catches the silent UPDATE that someone ran from a laptop. Start prev at genesis only when the first row's seq is 0. If you were handed a suffix (in-memory retention dropped the prefix), start at that suffix's prev and expected seq, or concatenate the durable full chain first:

function verify(rows, resume = { seq: 0, prev: 'genesis' }) {
  let prev = resume.prev
  for (let i = 0; i < rows.length; i++) {
    const row = rows[i]
    if (row.seq !== resume.seq + i) return { ok: false, brokenAt: row.seq, reason: 'seq' }
    if (row.prev !== prev) return { ok: false, brokenAt: row.seq, reason: 'link' }
    const hash = sha256(canonical({ ...row, hash: undefined }))
    if (hash !== row.hash) return { ok: false, brokenAt: row.seq, reason: 'content' }
    prev = row.hash
  }
  return { ok: true, checked: rows.length }
}

Canonical JSON matters. If one exporter reorders keys, honest rows fail. Pick a key order and never change it. Hash inputs; do not put raw refund destinations in the export you email around.

Without a key, anyone can recompute a new chain from scratch after editing history. The break is visible only if you kept an earlier copy (or the chain tip in a second place). HMAC with a key held outside the log store raises the bar: forging a row needs the key. Losing the key means you cannot verify either. Copy the tip off-box if the investigation is real.


When nominee is a fit (and when it is not)

nominee.verifyReceipts() is that loop with the ledger's checkpoint: if in-memory retention dropped the prefix, it still verifies the visible window instead of demanding genesis. The standalone verifyReceipts(array) starts at genesis unless code passes an explicit resume: { seq, prev }. The CLI has no resume-checkpoint option: npx nominee-cli verify receipts.json always starts at genesis and therefore needs the full durable export. Dumping JSON.stringify(nominee.receipts) after the window has trimmed will look broken at the first retained row. Signed chains need NOMINEE_RECEIPT_KEY. The CLI proof also shows a doctored copy failing. That is the property: tamper-evident.

nominee.verifyReceipts()              // in-process window, including retained suffixes
await nominee.verifyDurableReceipts()  // full atomic stream, when a durable store is configured

// Use the exact same constant passed as receipts.stream at construction.
const receiptStream = 'support-agent-v1'
const receipts = await store.list(receiptStream)
if (receipts.length === 0) throw new Error(`no receipts in ${receiptStream}`)
writeFileSync('receipts.json', JSON.stringify(receipts))
// npx nominee-cli verify receipts.json
// ✓ 7 receipts intact

Skip nominee when your warehouse already verifies its own hash chain, when the agent only reads public non-sensitive data and you do not need an action trail, or when you wanted a GRC product to map controls to SOC reports. Receipts are primitives. Production mode wants a durable atomic store and delivery: 'strict'; that still is not a certification.

How to produce the trail (including denials) is a separate question: Your agent logs in as you. It shouldn't get to be you.

See it for yourself

A chain that verifies. A doctored copy that does not.

$ npx nominee-cli no signup
Star on GitHub nominee-cli verify → Read the docs →