ADR 0184 — Audit machinery, not audit policy
Decision
stitchkit/observability gains two pieces of machinery:
auditChanges— aRequestEventpredicate: dropGET,HEADandOPTIONS; keep everything else; keep401and403whatever the verb was. An unrecognised verb is kept. One predicate for HTTP, MCP and agent calls, because a tool call carries its contract verb inhttpMethod(→ ADR 0030).createSpooledSink— writes the row to a local append-only file before offering it to the store, marks it delivered once the store took it, and replays what a previous process left unmarked. At-least-once; the store must be idempotent on the record key (thespanIdby default).
Audit remains opt-in. createHandler and the tool mounts are unchanged: a
project that does not audit still never imports this entrypoint (→ ADR 0012).
Why the machinery
Measured across six consuming projects that do audit, the same two pieces had
been written six times and had diverged: one audited only tool calls, one
excluded /mcp, two disagreed on OPTIONS, and five lost the event whenever
the store was unreachable. Nobody decided any of that. They each started from the
one form the httpMethod doc gives — (event.httpMethod ?? event.method) !== 'GET' — which is right about GET and silent about the two other verbs that
change nothing.
That is the duplicated observability layer ADR 0012 exists to end, one level further in.
Rejected: audit on by default
A proposal (base, 2026-09-14) would have made a decision about audit mandatory:
createHandler and every tool mount accept either an observer or an explicit
refusal with a reason, and a server with neither fails to start. It came from a
real incident — an operator changed a setting, and three days later nobody could
name who, because that application had no sink and the bus log had rotated.
Rejected on the measurement it rested on. The proposal counted
createObservability across sixteen consumers, found six, and read the other ten
as forgetting. Re-measured on the eight consumers visible from the framework’s
own machine, the split is perfect and it is not forgetting: all four projects
without a sink call createHandler zero times. They have no HTTP surface to
audit. Counting the sink across all consumers answers “how many wire an audit”,
not “how many should have and did not” — the denominator does not contain what it
is read as containing.
Four of the sixteen live on another machine and were not measured here, so the original six-of-sixteen is neither confirmed nor refuted; only the local slice is.
Beyond the number: a mandatory decision moves policy into the core, which is the
line ADR 0012 draws. A required audit: false, reason: '…' becomes a line
pasted unread, which is a ritual rather than a journal — and a framework that
demands a declared position on each of its subsystems is unusable by the fifth
one.
The frozen task carries the defrost condition: a measurement that finds a project with an HTTP surface and no sink — forgetting rather than an absent subject — or a second unanswered “who changed this” in an application that has a server.
Limits
createSpooledSink is one process, one file. Two processes on one path replay
each other’s records: harmless against an idempotent store, wasteful always.
Exactly-once would need the spool and the store to share a transaction; they do
not, and an audit layer that claimed otherwise would be lying about the one thing
it exists to be trusted on.