--- title: "ADR 0010 — The audit log is witnessed, not chained" type: decision tags: [signpost, adr, decision] proposed_by: tok_bc09b340db294c709763789154d8113d model: kmem-docs-publisher extracted_at: "2026-10-08T22:38:55.765Z" --- # ADR 0010 — The audit log is witnessed, not chained **Status** Accepted — amended 2026-08-18: `auditIntegrity` and `auditDigest` now take a required `ReadScope`, see "Amendment" below · **Date** 2026-08-14 · **Supersedes** nothing · **Relates to** ADR 0005 (write serialisation), ADR 0009 (permission model), PRD §3.4, security requirements S2 and S5, defects `#64`, `#109` ## Context `audit_log` is the record security requirement S5 requires of every mutation, and PRD 5.2 requires of every authenticated Quick Connect request. `OPERATIONS.md` tells an operator to read it after an incident. It has no integrity mechanism: a row can be altered or deleted and nothing notices. The prompt for this decision was the demo on `rush-skills/link-prototype`, which chains its ledger. Every row carries `sha256(prev_hash ‖ row)`, `/activity` recomputes the chain from the start and names the first entry that does not verify. It works there, and the question is whether to copy it. Three facts about this codebase constrain the answer. **A chain needs a total order over appends, and there is not one.** The demo gets its order free from a single Durable Object: one thread, every append serialised through it. Signpost writes `audit_log` with plain D1 from any isolate. ADR 0005 serialises **per note**, by optimistic concurrency on `notes.content_sha`, and deliberately never globally — a global writer was rejected there for the same reasons it would be rejected here. Chaining puts one writer in front of every mutation *and* every authenticated Quick Connect read, because `recordSessionUse` writes an audit row on the hot path of both doors. It also breaks the property that makes the current log trustworthy. `prepareAuditRow` exists so the audit insert can ride in the same `db.batch()` as the note, block, chunk and relation writes it records, so a rolled-back commit cannot leave behind a row claiming it happened (`services/commit.ts:778`). A chained append cannot be in that batch: its hash depends on a row another isolate may be writing at the same moment. **A chain stored in the table it protects is evidence against accident, not against an adversary.** Anybody who can `UPDATE audit_log` can recompute every hash from the row they changed onward. The demo's own verifier does not reach even that bar: `verifyChain` (`src/store.ts:1141`) recomputes from `(at, kind, actor, detail)` and ignores both the stored `prev_hash` column and the row id, so it detects an altered interior row and not a wholesale rewrite, a truncated tail, or a reordering. Its commit message `bc12aaf` says so. **A chain would not have caught the only corruption this log has ever suffered.** Defect `#64` replaced `actor` with `[redacted]` on every human write for three days; 101 rows on the production instance are unattributable for ever. The redactor ran **before** the `INSERT`. A chain would have sealed the corrupted value and verified clean. The class of failure this codebase actually produces is a write-time bug, and no chain detects one. One mechanism from the study notes does not survive contact with the code either: a monotonic-id gap check. `newAuditId()` mints `aud_` plus a random UUID, so there are no gaps to count. Sequential ids need the same global writer as the chain. ## Decision **Do not chain. Witness instead, and count what a write-time bug corrupts.** Three parts. 1. **`auditIntegrity(ctx, window)`** — one query, four aggregates, no write. `entries`, `oldest`, `newest`, `actors`, and **`unattributed`**: rows whose `actor` is `[redacted]`. That last number is defect `#64`'s signature and is the thing that would have surfaced it on the first health check rather than three days later. It is on **every** `/api/audit` response rather than behind a flag, because the cheapest way to find out that a log has stopped recording who is to be told every time somebody looks at it. 2. **`auditDigest(ctx, window)`** — a SHA-256 over every row in the window, in `(at, id)` ascending order, `JSON.stringify`-encoded field by field. **The ordering is imposed at read time, so no ordering is needed at write time.** An operator records the digest somewhere `audit_log` cannot be written from, and any later modification, deletion or insertion inside that window changes it. That is strictly more than a chain in the same table gives — a chain cannot see a deletion its verifier does not look for — and it costs nothing on the write path. Capped at `AUDIT_DIGEST_MAX_ROWS` (10,000) with `complete: false` when the cap bites, because a witness that silently covers less than it claims is worse than none. 3. **`GET /api/audit`** — the door. `listAudit` had **no caller outside the test suite**: until this route existed, the only way to read the audit log was `npx wrangler d1 execute --remote`, which needs a Cloudflare credential rather than a Signpost one. A record nobody can read is not a record. Any signed-in member may read it, the same bar as `GET /api/tokens`. **If a chain is ever wanted, chain the digests, not the rows.** A daily cron already exists (`0 3 * * *`); hashing the day's rows into an append-only external sink and chaining those digests gives ordering at a coarse grain with one writer per day rather than one per request. That is the step the demo names as the one it skipped, and it is the only version of a chain that does not cost what part 1 of the context describes. It is **not** built here: the sink would be a new write outside `CommitService`, so it needs an I1 allowlist entry, and I1 has been defeated three times in this codebase by changes that looked exactly that reasonable. ## Consequences - **The witness only works if somebody writes it down.** A digest nobody recorded proves nothing. `OPERATIONS.md` carries the runbook; this is a procedure, not a mechanism, and saying otherwise would be the failure this ADR exists to avoid. - **Nothing detects tampering that happens between two digests.** The window between recordings is unwitnessed. A chain would not help — see the second fact above — but the gap is real and stated. - **`unattributed` is permanently non-zero on the production instance** (101 rows). It will read as a standing finding rather than an alarm, which is the honest report: those rows are not recoverable. - **The write path is untouched.** No new column, no new ordering, no change to `db.batch()`, no new failure mode under concurrency. That is the point. - **`audit_log` retention still trims by age**, so a digest over a window whose rows have aged out cannot be re-verified. Record digests for windows you intend to keep. ## What was rejected | Option | Why not | | --- | --- | | Copy the demo's row chain | Needs a global writer this system does not have and ADR 0005 rejected; breaks the audit-insert-in-the-commit-batch property; detects only accident; would not have caught `#64` | | Sequential integer ids and a gap check | Same global writer. `crypto.randomUUID()` ids have no gaps to count | | HMAC each row with `SIGNPOST_AUTH_SECRET` | Real tamper-evidence against an attacker without the secret, and it needs no ordering — but it is a column and a write-path change for a property the read-time digest already gives, and it puts the auth secret on the mutation path | | Put the integrity block on `/health` | `/health` is unauthenticated and CORS-open for monitors. Activity volume and actor counts are not public facts | ## Amendment, 2026-08-18 — the witness took no scope, and that was a disclosure channel This ADR designed `auditIntegrity(ctx, window)` and `auditDigest(ctx, window)` to take no `ReadScope` at all, on the reasoning in "What was rejected" and in the decision itself: an aggregate and a hash are not a row, so nothing here looked like note content. Adversarial review on 2026-08-18 (`#109`, `docs/DEFECTS.md` §14.1) found the reasoning wrong. `entries`, `oldest`, `newest` and `actors` move whenever *any* row is written, including a row about a note the caller cannot read at all — ADR 0009 made `private` and `sealed` real, routine postures a caller can be in, where this ADR predates that model and was written when every signed-in member read almost everything. A caller with no capability on a folder can bisect `since`/`until` against `entries` to learn the millisecond of a hidden write, difference `actors` to learn who, and watch `digest` change whenever a hidden note is edited — a change-notification channel on content invariant I7 exists to keep out of every response. **The fix.** Both functions now take the caller's `ReadScope`, required, and apply the same `auditVisibleTo` clause `listAudit` already used. `entries`/`oldest`/`newest`/`actors`/`digest` describe only the rows the caller may read. **What this ADR's design loses, and why it is not replaced.** Part 1 of the decision above says the `unattributed` count is "on every response... because the cheapest way to find out that a log has stopped recording who is to be told every time somebody looks at it" — instance-wide, deliberately. Scoped to a member, `unattributed` and the rest now report only what that member's slice contains, so a member whose readable notes happen to carry no corrupted row would see a clean signal even if `#64` recurred against notes they cannot read. **This is accepted, not fixed further, for one reason:** `ceilingForPerson("owner")` is `private`, the top of ADR 0009's ladder, so an owner's *ordinary* `ReadScope` already reads every row a purpose-built `system` scope would. The operator this ADR was written for is exactly the owner persona, and an owner calling `GET /api/audit` — with or without `?digest=true` — gets the same whole-log witness as before the amendment. Only a `member`'s view narrows, and narrowing it is the fix: a member's witness now proves what a member can actually see, not a superset they were never entitled to. No `SYSTEM_READERS` entry was added, on the same reasoning `read-scope-allowlist.ts` states before every exception: the question is not "is this read correct" but "why can this read not take the caller's own scope" — and here it can, and does.