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.
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).
- integrity —
not verified this sessionuntil you run a verify pass, thenproving · n/N,verified · n/N intact, orN rows edited after write(afailed proof/partialvariant 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.
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.cost—small,medium,large, orrefused.chain hash(truncated, cyan) — the row’s anchor in the chain. Refused rows are calm (abanmarker), 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).
- The inclusion proof ladder — the leaf hash, each sibling hash on its rung with the
0x00/0x01fold 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, andchain_hash, all copyable. - The row payload — the structured columns hashed into the row. The original request text is never stored.
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:- 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. - For each visible row (oldest-first, the leaf order), if the chain walk flagged it the row reads
edited; otherwise the browser fetchesGET /api/audit/rows/{id}/proofand recomputes the root from the leaf hash and the audit path using the Web Crypto API (crypto.subtle), folding with the same RFC-69620x00leaf /0x01node prefixes the server uses, and requires the result to equal the global root.
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).
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.
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 newchain_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 rowslog 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 --jsonand pipe throughjq. - No tampering reproduction. The “tamper, then re-verify” walkthrough lives in the audit chain doc.
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:- A row was rewritten. The append-only triggers on
mcp_auditreject normalUPDATE/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. - A schema migration changed
canonical(row)semantics without bumpingfingerprint_version. This is a SchemaBrain bug — please file an issue with the row’sid,fingerprint_version, and the output ofschemabrain audit verify --full. - 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.
Related
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.