ADR 0030 — Audit verb, sanitised error details, and complete error-code logging
- Status: Accepted — extends ADR 0029 (audit completeness), ADR 0026 (error model), ADR 0022; keeps the core generic per ADR 0002.
- Date: 2026-06-18
Context
After ADR 0029 a consuming
project moved its HTTP audit onto createAuditHook, then reported the remaining
edges of running one audit pipeline across HTTP and tools:
- Tool events have no verb. A tool
RequestEventcarriesmethod: "TOOL", so a single “audit only writes” filter cannot tell a read from a write — forcing a second, hand-rolled tool audit (afterToolCall, whereendpoint.methodis available) besidecreateAuditHook. setRequestError({ details })forced a type round-trip. A consumer’s domain error extendsAppError, whosedetailsisRecord<string, unknown>; passingappErr.detailsintosetRequestError’sJsonValue-typeddetails(added in 0.11.0) failed to typecheck, so the consumer laundered it withJSON.parse(JSON.stringify(appErr.details)).errorDetailwas HTTP-only and unsanitised. A failed tool call carriederrorCode/errorMessagebut not the structured detail an HTTP failure did, and the HTTPerrorDetailwas emitted raw (a potential secret leak).- The access log dropped the error code when
onErrorreturns its own Response. 0.10.0 renderederrorCodeonly on the framework-default error path; a project with a customonError(its own envelope) saw← 400with no code.
Decision
RequestEvent.httpMethod— the operation’s contract verb, set on tool events (from theendpointthe hook receives), so one filter spans both surfaces:(event.httpMethod ?? event.method) !== 'GET'. Omitted on HTTP events, wheremethodalready is the verb. The raw verb is exposed, not a derivedisMutationflag — the core reports the fact, the app decides what a mutation is (ADR 0002). This lets a project fold its tool audit into the singlecreateAuditHook.setRequestErroracceptsdetails: unknown, sanitised intoerrorDetailat emit. The error handler passes its detail raw — anAppError.details, aZodError’s issues, anything — andcreateAuditHookruns it throughsanitizePayload(the same masking/capping aspayload) before it lands onRequestEvent.errorDetail. The consumer’s manualJSON.parse(JSON.stringify())round-trip is exactly that sanitisation, now built in — no pre-laundering, anderrorDetailcan no longer leak a secret.errorDetailon tool events — emitted from a failedToolResult.details(sanitised), symmetric with the HTTP path.- The access log renders
errorCodeon every error path — including when a customonErrorreturns its own Response, via a side-effect-freeerrorCode(err)extractor (no re-normalise, no double log).
Alternatives considered
isMutation: booleanon the tool event instead of the verb. Rejected — baking the read/write judgement into the core is a domain call; the verb is the raw fact, and the app derives the rest.- Narrow
AppError.details(and the error helpers /ErrorEnvelope.details) toJsonValue— the consumer’s literal request. Rejected: it ripples into every boundary that builds anAppErrorfrom untyped network data (the typed client parsing an error envelope,implementRemotereconstructing a remote error) — those legitimately holdunknown/Record<string, unknown>, so the narrowing is a breaking change that pushes casts to the boundaries. AcceptingunknownatsetRequestErrorand sanitising at emit removes the round-trip additively, without touching the error model, and adds secret-masking on top.
Consequences
- Additive — no breaking change. New
RequestEvent.httpMethod, toolerrorDetail, a widersetRequestErrorinput, a sanitisederrorDetail, and an access-log fix. Ships in 0.12.0. - A project can run a single
createAuditHookacross HTTP and tools with one read/write filter and symmetric, sanitised structured error detail — and pass anAppError’sdetailsstraight tosetRequestError.
Declined (recorded so it is not re-proposed each cycle)
- An optional
resolveIdentityphase before validation — the consumer’s highest-priority ask, its fourth request. Its concrete case resolves aprojectIdvia a DB lookup; running that before body validation re-opens the fail-fast / DoS hole the current order closes (a malformed request would hit the database). The need (“attribute a pre-handler failure to an actor”) is real but only valid for cheap (token / header) identity, and must not be designed on a DB-lookup case. The consumer’s duplication is solvable in their app — cache thebotId → projectIdlookup, or carryprojectIdin the auth token soonErrorreads it from a header for free. A framework phase waits for a second, independent consumer with cheap identity.