Repository navigation
Description
The gap
buzz-audit implements a real per-community SHA-256 hash chain, but "tamper-evident" is currently a property of the schema, not of operations:
-
verify_chainhas no operational caller. It's invoked only from tests. The conformance notes (crates/buzz-test-client/tests/conformance_multitenant.rs:2686) describe audit reads as "operator-internal (consumed bybuzz-admin)" — butbuzz-admindeclares thebuzz-auditdependency and never uses it. Nothing deployed can actually verify a chain. -
The verifier accepts truncated chains:
- Prefix truncation:
verify_chain(c, 1, N)seedsexpected_prev = None, so the first row is never checked to be genesis —DELETE ... WHERE seq <= kleaves a suffix that verifies clean. - Unanchored segments:
verify_chain(c, 5, 10)never compares row 5'sprev_hashagainst row 4's stored hash; the left edge of a mid-chain range is unchecked. - Tail truncation: nothing records what the walk covered vs. what should exist, so deleting the newest rows is invisible to a walk over stored rows (the head is defined by what's stored).
- Interior deletions surface as a generic
ChainViolation, misdiagnosed as mutation rather than deletion.
- Prefix truncation:
So the strongest thing the docs claim — a tamper-evident audit chain — isn't checkable by anything an operator can run, and several deletion classes verify clean.
Proposed approach (no schema or wire changes)
- A pure
verify_entries(shared chain walk with an explicit left edge — genesis or an anchor hash — plus seq contiguity, prev-hash linkage, per-entry hash recompute), unit-testable without Postgres. verify_chainkeeps its signature but becomes anchored (from_seq > 1requires thefrom_seq-1entry to exist and be linked).- A new
verify_full_chainreturning{entries_verified, head_seq, head_hash}; passing a previously-recorded head as--expect-headcloses the tail-truncation hole. - New
AuditErrorvariants (MissingGenesis,MissingAnchor,SequenceGap,TruncatedTail), carrying seq values only (same error-text sanitization fence as today). - A
buzz-admin audit verify [--expect-head N]command reading Postgres directly on the same plane as the rest ofbuzz-admin— deliberately not a new HTTP endpoint, since the conformance notes treat the absence of an audit wire surface as an isolation property and CONTRIBUTING discourages new endpoint-specific JSON APIs.
No migrations, no /api/* routes, no wire-format changes, no new dependencies.
Status
Implemented and tested: six new Postgres-free verify_entries_* unit tests (each builds a real chain and mutilates it — prefix deletion, re-rooted genesis, interior deletion, content tamper, bad/skipped anchors), four #[ignore] Postgres integration tests, clippy-clean on both crates, wired into just test-unit.
Per CONTRIBUTING's "for significant changes, open an issue first to discuss the approach," I'm holding the PR pending your nod rather than dropping ~600 lines of new security-surface code on you unsolicited. Two things I'd want your read on first:
- Is the
buzz-adminoperator-command surface (rather than any wire endpoint) the shape you want? - The externally-recorded-head
--expect-headapproach for tail-truncation detection — acceptable, or would you prefer the head anchor persisted in-repo?
Happy to open the PR immediately if the direction sounds right.
Activity
SaravananJaichandar commentedon Jul 24, 2026
Ran into the same class of gap when we shipped a standalone reference verifier for our own agent audit chain. The "verify_chain has no operational caller" bit is particularly subtle. Tests exercise the function directly, so the "nobody runs it in prod" property never fails a test.
Two things that closed it for us:
Ship the verifier as a standalone CLI a third party runs, not a library method or admin task. Reads a signed dump manifest, returns exit 0/1/2. Removes the "operator vs auditor" trust asymmetry. The operator is not the one telling you their chain is valid.
Seq contiguity as an explicit named pass. Prefix and suffix truncation both surface as gaps in the seq column. The verifier enumerates entries in seq order and asserts each entry's prev_hash equals the previous entry's entry_hash AND that seq is contiguous. Missing seqs at either end fail the same way tampered entries do, no special handling required.
The subtler thing we found: once the standalone CLI existed, it caught a real race in our own append path. Two concurrent writers computed prev_hash against the same committed snapshot and produced two entries with the same prev_hash. Detectable because entry N+1's prev_hash did not match entry N's entry_hash. Fixed in the append path with BEGIN IMMEDIATE. Would not have surfaced without the verifier running as a real process in prod.
Point being: the reference verifier is the primitive that makes the chain trustworthy, but ONLY if it runs somewhere other than tests.
SeanGearin commentedon Jul 24, 2026
Exactly matches what we found. Two of your points are already in a branch we've held pending this issue: the verifier ships as a standalone
buzz-admin audit verifya third party runs (exit 0/1/2 over a signed manifest, so the operator isn't the one vouching for their own chain), and truncation is caught structurally — but you've named the cleaner framing: seq-contiguity as an explicit pass, so prefix and suffix truncation fail the same way tampered entries do, no special-casing. I'll adopt that naming. The concurrent-writer race you hit (two entries sharing a prev_hash, fixed with BEGIN IMMEDIATE) is a great catch — worth a note in the issue as a second thing the verifier-in-prod surfaces. Will link the PR here once it's up.fix(audit): hash created_at at the precision Postgres stores (#2638)
feat(audit): detect chain truncation and add `buzz-admin audit verify`
fix(audit): hash created_at at the precision Postgres stores (block#2638
fix(audit): hash created_at at the precision Postgres stores (block#2638
StevenMih commentedon Nov 9, 2026
The truncation case is the one worth fixing first. A chain that verifies from any prefix can't tell you it's been cut, and a DB writer that recomputes the keyless chain can rewrite it; both are the same gap.
The usual answer is a signed checkpoint over the head: a small statement (chain head hash, entry count, time) signed by the relay or community key on a cadence, optionally registered with an outside witness.
verify_chainthen checks the chain against the latest checkpoint rather than against itself, and a shorter chain fails. #4173's field-boundary collision is a separate bug but the same PR would be the place for it - domain-separate the fields incompute_hashbefore signing anything over the result.This is the direction #3228 points at (verifiable audit export: the export manifest's final chain hash, signed, optionally witnessed). I'll open that PR against the export path rather than the chain itself so the chain format doesn't change. Design note and test data from our self-hosted relays will come with it. No date yet; I'd rather post it working.