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 each entry_hash from its stored fields, and confirming that each entry's prev_hash matches the previous entry's entry_hash. Returns VerifyResult{ ok }; ok=false carries 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)
  • index — engineering deep-dives index