← Back to docs

Auditor's runbook · Verify the fasten audit chain offline

Audience. Bank auditors, compliance officers, and adopters who need to prove tamper-evidence independently of any UI or service the audited entity controls. Every recipe below runs against a snapshot of the audit database, not a live endpoint. For the plain-English "why this matters," see /why/owasp-llm-top-10/.

The membrane UI has a fast tail-verify indicator good enough for a live health signal. This runbook is what an auditor uses to prove tamper-evidence against a snapshot, without trusting any endpoint the audited entity controls.

Independence is the point. Every recipe runs against the audit database file directly. None call an endpoint the audited entity controls. The entity cannot choose what the auditor sees.

What this proves

Every fasten audit row carries prev_hash and hash, chained by SHA-256 over a versioned canonical form of the row. A verifier reads the rows in monotonic_seq order and confirms each row's prev_hash equals the previous row's hash. When the chain verifies clean, one of two things is true:

  1. Nothing has been tampered with since the rows were written; or
  2. The tampering party had access to a private hash-collision oracle for SHA-256, which they do not.

When it does not verify, first_break_at names the first offending monotonic_seq. Every row before that seq remains trustworthy; every row at or after must be re-ingested from the upstream source before it can be relied on.

Prerequisites

Recipes

Three recipes. Each is a collapsed card. Click to expand.

Recipe A Python one-liner · full historical verify expand ▾

The auditor's default. Reads the whole chain, no windowing.

python -c 'import fasten; from fasten.store.sqlite import SQLiteStore; \
  s=SQLiteStore("./membrane-audit.db"); \
  rows=s.query(limit=10_000_000); \
  r=fasten.verify_chain(rows); \
  print({"ok": r.ok, "rows": r.total_rows, "first_break_at": r.first_break_at, "reason": r.reason})'

Output on a clean chain:

{'ok': True, 'rows': 1284, 'first_break_at': None, 'reason': None}

Output when a row has been altered:

{'ok': False, 'rows': 1284, 'first_break_at': 512, 'reason': 'hash mismatch at seq 512'}
Recipe B Reusable script with exit codes expand ▾

For CI or an audit job. Exits 0 on clean, 2 on a break, 3 on any other failure (DB missing, permission, corrupt page).

#!/usr/bin/env python3
"""Offline verifier for a fasten audit DB. Exit 0 clean, 2 broken, 3 error."""
import argparse, sys
import fasten
from fasten.store.sqlite import SQLiteStore

p = argparse.ArgumentParser()
p.add_argument("--db", required=True, help="path to the audit sqlite file")
p.add_argument("--limit", type=int, default=10_000_000)
args = p.parse_args()

try:
    store = SQLiteStore(args.db)
    rows = store.query(limit=args.limit)
    result = fasten.verify_chain(rows)
except Exception as exc:
    print(f"error: {exc.__class__.__name__}: {exc}", file=sys.stderr)
    sys.exit(3)

print(f"rows checked: {result.total_rows}")
if result.ok:
    print("chain: CLEAN")
    sys.exit(0)
print(f"chain: BROKEN")
print(f"first_break_at: {result.first_break_at}")
print(f"reason: {result.reason}")
sys.exit(2)

Run it against a real audit snapshot:

python verify_chain.py --db ./snapshots/2026-07-09/membrane-audit.db
# rows checked: 1284
# chain: CLEAN
echo $?
# 0
Recipe C Schedule it as a CI job expand ▾

Once you have a scripted verifier, run it every night against the latest audit snapshot and fail the build on non-zero exit. Example GitHub Actions step:

jobs:
  verify:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: {python-version: "3.12"}
      - run: pip install fasten
      - name: Snapshot the audit DB from prod
        run: aws s3 cp s3://fasten-prod-audit/latest.db ./membrane-audit.db
      - name: Verify the chain
        run: python verify_chain.py --db ./membrane-audit.db

Snapshot to your own storage first so the verifier reads a file the audited entity cannot touch. A supervisor who receives the snapshot can rerun the same verifier locally.

If the chain is broken

The recovery path depends on where the break is and what caused it.

  1. Identify the seq. The verifier prints first_break_at. Rows with monotonic_seq less than this value remain trustworthy.
  2. Preserve the tampered snapshot. Copy the current DB to a read-only location before any recovery step. The tampered state is evidence.
  3. Re-ingest from the upstream source. Every fasten emitter is idempotent by (request_id, code, target), so replaying the source stream from first_break_at onward will re-seal the chain from a known-good prefix.
  4. Report. A chain break is a governance event in its own right. Emit a chain-integrity alert row into the recovered chain with the seq range that was replayed, so the audit trail carries its own remediation record.

Scope · what this catches, what it does not

ClassCaughtNotes
Row content modified in place yes hash no longer matches the canonical form; break at that seq
Row inserted between two existing rows yes the next row's prev_hash will not match the inserted row's hash
Row deleted from the middle yes the following row's prev_hash refers to a hash no longer present
Tail rows truncated partial chain still verifies as a shorter prefix; compare row count against a witnessed baseline
Entire DB replaced with a rewritten chain partial a self-consistent rewrite verifies clean; defeat by publishing periodic Merkle roots to a third party (see below)

Beyond a linear chain · Merkle roots and transparency logs

A linear chain proves that no one has tampered with the rows the chain names. It does not prove that no one has replaced the whole DB with a fresh, self-consistent one. That threat is defeated by publishing a Merkle root of the audit window to a party outside the audited entity. The same construction Certificate Transparency uses.

fasten's signed evidence-pack export ships an RFC 6962 Merkle root and per-row inclusion proofs, so an auditor can hold a single 32-byte commitment and verify any individual row's presence without downloading the whole log. The linear chain in this runbook is the substrate under that. The evidence-pack API is the layer above.

Questions or a stuck verifier? Open an issue at github.com/nerdapplabs/fasten/issues or reach us via nerdapplabs.com.