Skip to main content

Audit Viewer

Route: /audit on the dashboard. Backed by: GET /api/audit/rows, GET /api/audit/rows/{id}, GET /api/audit/verify, GET /api/audit/merkle/root, GET /api/audit/rows/{id}/proof.
Audit Viewer dashboard surface — append-only audit log with the derived Merkle root and per-row inclusion proofs
The Audit Viewer is the visual face of the tamper-evident audit chain. Every MCP tool call writes exactly one row, regardless of outcome — success, empty, partial, degraded, error, or refused. Each row carries a 32-byte chain_hash computed as sha256(prev_chain_hash || canonical(row)). On top of that chain the page derives an RFC-6962 Merkle root over every row and, for any row, an O(log n) inclusion proof — a short list of sibling hashes that the browser folds back up to that one root. Clicking Verify against root re-walks the hash chain across the whole log (the tamper tripwire) and recomputes each visible row’s inclusion proof locally.

What the surface shows

The top of the page carries the integrity summary — the spine of the surface:
  • rows in tree — how many audit rows the Merkle tree commits to.
  • merkle root — the 64-hex root over every row, computed this request. Click it to copy; archive it off-host so a later change is caught (see below).
  • integritynot verified this session until you run a verify pass, then proving · n/N, verified · n/N intact, or N rows edited after write (a failed proof / partial variant covers the rarer internal-inconsistency and unverifiable cases).
  • in view — how many rows are loaded (the most recent page); when the log is larger than the page, the surface discloses that verify covers the rows in view while the root commits to all of them.
Below it, the formula eyebrow H(rowₙ) = SHA256( dataₙ ‖ H(rowₙ₋₁) ) and the Verify against root action, then the row grid. Each row shows:
  • time — relative age plus an HH:MM clock.
  • row — the monotonic rowid (#N).
  • tool · caller — which MCP tool was called and the caller id.
  • costsmall, medium, large, or refused.
  • chain hash (truncated, cyan) — the row’s anchor in the chain. Refused rows are calm (a ban marker), never alarm-colored.
  • idx — the row’s 0-based leaf index in the tree (oldest = 0). Audit rows render newest-first but are indexed oldest-first; this column makes that mapping explicit.
  • proof — the row’s state in the current verify pass (proving, proven, edited, no proof, log grew, missing).
Selecting a row expands its detail in place (an accordion, not a separate drawer):
  • The inclusion proof ladder — the leaf hash, each sibling hash on its rung with the 0x00/0x01 fold notes, terminating in = root … · matches the root (or a divergence verdict). This is the actual proof the browser just recomputed.
  • The audit row chain link — leaf index, source, fingerprint, and chain_hash, all copyable.
  • The row payload — the structured columns hashed into the row. The original request text is never stored.
This is the same information schemabrain audit list --json exposes from the CLI — the dashboard adds the visual proof ladder and the prev-hash → row → root linkage.

Verify against root

The Verify against root button does a real check, not a cosmetic animation. It runs two complementary passes:
  1. Fetches the whole-log chain walk (GET /api/audit/verify?full=true) and the global root (GET /api/audit/merkle/root) once. The chain walk is the tamper tripwire — it covers every row, not just the visible ones, so its result drives the integrity verdict.
  2. For each visible row (oldest-first, the leaf order), if the chain walk flagged it the row reads edited; otherwise the browser fetches GET /api/audit/rows/{id}/proof and recomputes the root from the leaf hash and the audit path using the Web Crypto API (crypto.subtle), folding with the same RFC-6962 0x00 leaf / 0x01 node prefixes the server uses, and requires the result to equal the global root.
A row that folds to the root reads proven; every row the chain walk flagged is shown edited (the pass does not stop at the first), and the integrity stat counts them all — including edited rows outside the visible window. Because the browser recomputes the hashes itself, “verified” means the cryptography held — not that a timer finished.
What this proves, precisely. Every visible row reconciles to one self-consistent root that the sidecar computed now. It does not prove the underlying data is authentic — the browser is given each row’s leaf hash by the same sidecar that computed the root. The honest, external defence is archiving the root (next section).
The older server-side chain walk is still available: GET /api/audit/verify (and schemabrain audit verify --full from the CLI) re-walks the stored-vs-recomputed chain_hash for every row. It answers a slightly different question — “does each stored chain hash still match its recomputed value?” — and supports --since <hash-prefix> cursors for scoped forensic walks. See audit verify.
The root and the proofs are recomputed per request from the rows on disk. On stores with hundreds of thousands of rows this can take noticeable time. The chain walk covers the whole log (so an edited row outside the visible window still trips the verdict), while the per-row inclusion proofs are recomputed only for the rows in view — the surface discloses that when the log is larger than the page.

Tamper-evident, not tamper-proof

The chain and the derived root are detection, not prevention. Anyone who can write the SQLite file could rewrite the rows and recompute a coherent new chain_hash and Merkle root — so the root the page shows is never, on its own, proof the data is untouched. The real defence is cheap: copy the root and store it somewhere the audit file’s writer cannot reach (a different host, an object store, a ticket). A change to any earlier row produces a different root, so a later re-check against your archived value catches it. The page never claims more than this — it surfaces the root, the proofs, and the honest framing, and leaves the anchoring to you. See the audit chain mechanism for the cron-driven anchoring pattern.

Freshness

The list reflects the audit log as of page load (and as of the last Verify against root pass). It does not auto-push new rows — reload to pick up tool calls written since you opened the page. If the log happens to grow mid-verify, the surface marks the affected rows log advanced and invites a re-verify rather than reporting a false break. (The sidecar exposes a GET /api/audit/stream SSE endpoint for live row push; this surface does not subscribe to it yet.) This is an operator’s after-the-fact view, not a security-alerting channel. For sub-second tamper detection, drive it from a cron that compares an archived root against a fresh GET /api/audit/merkle/root.

What you cannot do here

The viewer is read-only and intentionally minimal:
  • No filtering by tool or status in the UI yet. Use schemabrain audit list --tool ... --status ... from the CLI.
  • No export. Use schemabrain audit list --json and pipe through jq.
  • No tampering reproduction. The “tamper, then re-verify” walkthrough lives in the audit chain doc.
These are deliberate. The dashboard helps an operator answer “does every row reconcile to one root, and what landed since I last looked?” — not run a forensic toolkit. The CLI remains the source of truth for deep investigation.

When a row is flagged

If a verify pass flags a row edited (the common case — the chain walk found its stored hash no longer matches its contents) or failed proof (the rarer case — the browser folded its leaf up the audit path and the result did not equal the root the sidecar reported), the claim is precise. Possible causes, in order of likelihood:
  1. A row was rewritten. The append-only triggers on mcp_audit reject normal UPDATE / DELETE, so the rewrite required dropping the triggers first or writing to the file outside SQLite. Either is a forensic signal worth investigating — and if you archived an earlier root, comparing it locates the change.
  2. A schema migration changed canonical(row) semantics without bumping fingerprint_version. This is a SchemaBrain bug — please file an issue with the row’s id, fingerprint_version, and the output of schemabrain audit verify --full.
  3. The file was restored from an inconsistent backup. A partial restore that mixed rows from two timelines will fail on every divergent row.
schemabrain audit verify --full enumerates every divergent row from the chain side; that is the right next step.

Tamper-evident audit chain

The mechanism the viewer reflects — append-only triggers, chain hash, the derived Merkle root, recovery semantics.

schemabrain audit verify

The CLI surface for the server-side chain re-walk, with --since cursor support.

Refusals

Refused rows live in the chain too; this is the envelope-facing view of the same data.

Observability

Where the audit row sits in the event-bus substrate.