Audit Events
WitWiki keeps a hash-chained, append-only audit log of every consequential action in an org. Entries are generated server-side — no agent or client cooperation is required or trusted — and chained with SHA-256, so an edited, deleted or reordered entry is detectable. Entries removed from the end of the chain are not: see the limit noted below. The system is shipped and live in production. See index for other engineering deep-dives.
Schema
Migration 0012_audit_log.sql defines the audit_log table:
CREATE TABLE audit_log (
id BIGSERIAL PRIMARY KEY,
org_id UUID NOT NULL,
actor_type TEXT NOT NULL CHECK (actor_type IN ('user', 'agent', 'system')),
actor_id TEXT NOT NULL,
action TEXT NOT NULL, -- e.g. 'page.upsert', 'source.upload', 'key.create'
resource TEXT NOT NULL, -- e.g. page path, source ID, key ID
prev_hash TEXT NOT NULL DEFAULT '',
entry_hash TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
idx_audit_log_org_created on (org_id, id DESC) backs fast per-org,
newest-first listing.
What the hash chain does and does not prove
Each entry stores the SHA-256 hex of its constituent fields chained with the previous entry's hash:
entry_hash = SHA256( prev_hash || action || resource || actor_id || created_at )
The first entry per org has an empty-string prev_hash. audit.PostgresAuditor
(api/internal/audit/audit.go) appends entries atomically: it reads the current
tail's entry_hash, computes the new hash in the application layer, and inserts
in one statement so the chain cannot fork under concurrent writes.
computeHash is deliberately stable — its field ordering and format string must
never change, because altering it would invalidate every existing chain.
Server-generated events (no agent cooperation)
Audit entries are produced at the service layer, not the MCP handler layer.
wiki.Service.UpsertPage() and wiki.Service.DeletePage() (api/internal/wiki/wiki.go)
each append a log entry after a successful write, so every page write or delete
is recorded regardless of whether the calling agent ever invokes
wiki_appendLog manually.
Because the service already logs, the MCP handlers (api/internal/mcp/mcp.go)
intentionally do not call AppendLog again after wiki_updatePage /
wiki_deletePage — a second call would produce duplicate entries. (See commit
010f588, which removed the duplicates.)
Coverage was extended across org CRUD in commit 3db8a8e, so org-level mutations
(invites, key creation, etc.) also land in the log.
Verification endpoints
Two HTTP endpoints expose the log (api/internal/httpapi/audit.go), both
owner-gated via the owner middleware:
GET /api/audit?limit=N— returns the org's entries, newest-first.GET /api/audit/verify— re-walks the entire chain for the org, recomputing eachentry_hashfrom its stored fields, and confirming that each entry'sprev_hashmatches the previous entry'sentry_hash. ReturnsVerifyResult{ ok };ok=falsecarries the offending entry id and a reason.
The limit, stated because it used to be overstated
Until 2026-09-02, Verify recomputed each row's hash from that
row's own stored prev_hash and never compared it against the previous
row's entry_hash. Deleting an entry therefore left every surviving row
self-consistent and the check returned ok=true, while this page claimed any
deletion was detectable. Probed live: middle deletion, first-row deletion and
reordering all passed. The link check now catches all three.
Truncation is still invisible. Deleting entries from the end of the chain
leaves the remainder valid, and so does deleting the tail and appending
afterwards, because Log rebuilds from whatever tail it finds. No
self-contained chain can prove otherwise — that needs an anchor the same actor
cannot rewrite, such as a head hash published elsewhere. So describe this log
by the three cases it actually catches — edits, deletions and reordering — and
never with a word that implies nothing can be removed from it.
The auditor is optional on the handler (SetAuditor); if nil, the list/verify
endpoints return empty/clean results rather than erroring.
The 2026-06-08 hash-chain repair
A latent bug broke verification for every entry (commit eeed13b,
fix(audit): repair hash chain broken by ns/µs timestamp truncation).
time.Now() produces nanosecond precision, but Postgres TIMESTAMPTZ
stores only microsecond precision. The original code hashed the full
nanosecond timestamp on write, while Verify re-read the microsecond-truncated
value back from the database — so the recomputed hash never matched the stored
one and the whole chain failed verification.
The fix truncates the timestamp to microseconds before both hashing and
insertion, keeping Log and Verify in agreement:
now := time.Now().UTC().Truncate(time.Microsecond)
entryHash := computeHash(prevHash, action, resource, actorID, now)
Related
- index — engineering deep-dives index