Repository navigation
Tamper-evident audit log (prev-hash chain)? #1261
Description
Context
Reading docs/ARCHITECTURE.md and docs/security-boundaries.md: audit_log
records every mutation ("Every mutation, addressable by at DESC"), and the
schema's own comments describe it as "a separate, append-only trail" — though
nothing in the schema enforces that (the only triggers in the migrations guard
the FTS tables). The integrity story rests on the architecture: "the markdown
wiki is the source of truth, and SQLite is the derived index", with the wiki
owned by git2 and raw segments immutable. The isolation boundary map is
unusually rigorous; what I couldn't find is a mechanism that detects
post-hoc edits to the audit trail itself. If I missed one in the docs, this
question answers itself — please point me at it.
The counter-argument first
The obvious pushback is the architecture's own: SQLite is a derived index, so
tampering with derived rows is detectable by rebuilding, and git history
already gives the wiki tamper-evidence for free. I think that's correct for
page mutations, and I'm not proposing to replace that. Three cases it doesn't
cover, as far as I can tell:
- Rebuild-detection is passive. It only fires if someone actually
rebuilds and diffs. A hash chain turns verification into a cheap
sequential read — verify recomputing the chain from genesis — so tamper
detection can run routinely instead of "when someone already suspects". - Not everything is reconstructible from the wiki.
audit_logcovers
every mutation, including ones with no wiki artifact (handoffs, feedback,
reclaim, tombstones, run leases), and the wiki git repo ownswiki/while
raw/lives outside it. A rebuild from the wiki validates pages, not
those. (I did find thelog_ledgerbehindreclaim-ledger-versions— row
10c — but that tracks page versions with git as the backstop; the non-page
mutations listed above have no integrity anchor of their own.) - Local git history is rewriteable. The wiki
.gitlives in the data
dir, and nothing inside the machine distinguishes a rewritten history
from the original. A chain whose head can be compared against an older
export (or simply noted in a backup) yields a divergence signal git alone
won't produce.
Honest scope
Boundary 5d says it outright: component checks "do not claim
descriptor-relative protection against a concurrently malicious same-user
process". A chain stored in the same SQLite is no different — this is
tamper-evident, not tamper-proof. The threat it addresses is the
team-trust one on a shared server: a trusted operator (or a botched
restore/migration) edits or drops rows and nothing notices. If that threat is
out of scope by design, closing as wontfix is a completely reasonable answer
and I won't argue.
What this looks like in practice
This isn't hypothetical plumbing. A small local decision-layer project I
maintain has exactly this verify pass, run on this machine today. Intact
ledger — chain recomputed from genesis:
$ verify --ledger pilot.json
entries: 10
chain: intact (prev-hash + entry_hash recomputed from genesis)
verify: PASS
Same ledger, one field of one sealed entry rewritten by hand (a recorded
decision flipped, nothing else touched):
$ verify --ledger ledger-tampered.json
entries: 10
- seq 0: entry_hash mismatch (entry modified after sealing)
verify: FAIL
Exit code 0 vs 1, and the divergence report points at the exact row.
Scale, on the same box: the same verify pass over a synthetic 10,000-entry
sealed ledger — recomputing every hash from genesis — takes ~45-50 ms and
still pinpoints the hand-edited row at its exact seq, and the chain
mechanics add ~4 µs to each written row over a plain JSON write (~0.5 µs).
Microseconds either way — cheap enough to run routinely, which is the whole
point of case 1. One laptop, benchmark available on request.
Shape (discussion, not implementation)
- Each
audit_logrow storesprev_row_hash;
row_hash = H(prev_row_hash || canonical row bytes); the genesis row is
anchored at migration time. - One extra hash computed inside the existing single-writer transaction; no
behavior change on the write path. - A
verifypass recomputes the chain from genesis and reports the first
divergence — a standard method, nothing novel is being proposed.
For context: that same small project converged on exactly this pattern, and
the verify output above is its real one — which is why I'm asking here rather
than assuming it transfers. ai-memory's rule that every invariant traces to a
prior-art bug is the right bar for deciding whether this earns a slot, and
"it doesn't" may well be the answer. If there's interest, I'm glad to draft
docs/design-tamper-evident-audit.md in the house format ("Status: design
proposal, not implemented") — including the awkward migration question:
existing rows can't be chained retroactively, so a trust-on-first-use anchor
at upgrade time would be an explicit decision the doc should record, not an
accident.
Activity
akitaonrails commentedon Oct 14, 2026
Thanks for this. The analysis is fair: "append-only" lives only in comments today, and nothing enforces it. Before it can earn a slot, two problems need a design:
move-projectupdatesaudit_log.workspace_idfor the moved project (ops.rs), which would break a naive previous-hash chain. A chain would need either to exclude mutable columns from the hash or to record the move as its own chained event.I'm labeling this
rfc. If you'd like to pursue it, a short design note answering those two points is the next step, aimed at 3.1 or later.