ADR 0029 — Endpoint identity and domain dimensions on the audit event
- Status: Accepted — extends ADR 0012 (the observability module), ADR 0022 (stable endpoint identity), ADR 0021 (opaque passthrough) and keeps the core generic per ADR 0002.
- Date: 2026-06-18
Context
createAuditHook normalises every completed call into a RequestEvent and hands
it to the project’s sink. The HTTP event is built by an outer fetch wrapper
(createAuditHook.http) that reads the AsyncLocalStorage RequestContext
established by wrapInRequestContext — symmetric for success and failure, but
established before routing, so it never sees the matched route. Two gaps
followed, both reported by a consuming project:
- A — no
(serviceName, action)for HTTP. The tool path carriestoolName, but the HTTP event had onlypath. A sink whose audit table hasservice/actioncolumns had to parse them out of the URL — fragile. The stable identity already exists onMethodDef(serviceName/key, → ADR 0022) and reaches the lifecycle hooks, but not the ALS-based audit event. Because of this the consumer abandonedcreateAuditHookand hand-rolled an audit from theafterHandle+onErrorhooks (where the typed endpoint is available) — at the cost of the success/failure asymmetry those two halves create. - B — nowhere for domain dimensions.
RequestEventcarries generic identity (userId/authMethod/clientId) but no place for an app’s tenant / project / entity id, so the sink re-derived them from the path.
Decision
- A — the pipeline surfaces endpoint identity into the request context. When a
contract route matches,
createHandlercallssetRequestEndpoint(serviceName, action)before validation, so the audit event is attributed to its operation even on a pre-handler (400) failure.RequestEventgainsserviceName/action, populated on the HTTP path (from the context) and the tool path (from theendpointthe hook already receives) alike — from the contract, never parsed from the path. The write is a no-op without an active observability context, so a server that does not use observability pays nothing. - B — an opaque
dimensionsbag.RequestEventgainsdimensions?: Record<string, string>, filled bysetRequestDimensions(...). The core attaches no meaning to it (the ADR 0021 opaque-passthrough pattern): the app resolves a tenant / project / entity id cheaply fromctx.params/ headers inbeforeHandle(success) oronError(a pre-handler failure, which carriesctx.params/ctx.reqsince 0.10.0) and it lands on the event for the request, success or failure alike.
Alternatives considered
- Add
projectId/botId/entityIdfields. Rejected — domain modelling in a generic core (ADR 0002). The opaquedimensionsbag carries the same data without the core naming a domain, exactly like endpointmeta(ADR 0021). - Have
createAuditHookreturn lifecycle hooks that bind the endpoint, for the consumer to compose. Rejected — wiring burden, and easy to forget on theonErrorpath. The pipeline writing identity is zero-wiring and batteries- included, which is the point ofcreateAuditHook. - A dedicated pre-validation
resolveIdentityphase (a 5th lifecycle hook). Deferred — with (A) + (B) and the 0.10.0onErrorcontext (params/req), a failure is attributed inonError; a new lifecycle phase is not justified yet.
Consequences
- Additive — no breaking change (new optional
RequestEventfields and two new setters). Ships in 0.11.0. - A consuming project can drop a hand-rolled HTTP audit and adopt
createAuditHook— it now carries(serviceName, action)anddimensions, which also removes the success/error asymmetry of anafterHandle+onErrorsplit (one symmetric terminal event). createHandlernow imports the observability context setter (a no-op without an active context). Observability stays layered above the server (obs → server); there is no import cycle, and the browser-safe entrypoints are unaffected (server-only).