ADR 0043 — The framework records the failure; the project overrides it
Context
ADR 0042 taught the tool row to name the cause of a failure. Checking what
that left behind found the perch had simply moved rather than gone:
create.ts called setRequestEndpoint and never setRequestError, so the HTTP
row’s error fields stayed empty unless the project wrote an onError hook and
remembered to record inside it.
A project that wired createAuditHook and nothing else was therefore getting
richer records from its MCP tools than from its HTTP API — the reverse of the
asymmetry ADR 0042 set out to remove, and created by that fix.
A second, quieter hole sat in the same function. respondError has two branches:
a custom hooks.onError returning its own Response, and the framework default.
A project that customises the envelope and forgets setRequestError recorded an
empty error on every audited failure, with nothing to indicate it.
Decision
respondError records the failure onto the request context itself. It is the one
place both branches pass through, and it already holds the raw err on both, so
this is one insertion rather than a restructure.
Precedence — the project wins. The write happens after onError has had its
turn and only when the context carries no error yet. A project that curates
setRequestError keeps its value; a project that wires nothing still gets a row.
setRequestError becomes an override, not the wiring.
The message follows ADR 0042’s rule, shared rather than re-implemented.
recordedErrorMessage in internal/errors.ts is now the single expression of
“the raw message goes in only where the envelope was scrubbed to
INTERNAL_SERVER_ERROR”, called by both the HTTP path and the tool audit. One
rule, one place, two transports — which was the point of ADR 0042 and would have
been quietly lost by writing the condition twice.
The response is untouched, and so is stderr. This writes to a server-side record. Two constraints hold it there, both pinned by tests:
- The envelope the caller receives is byte-identical on both branches.
- The custom-
onErrorbranch must not gain aconsole.errorline. That branch deliberately avoidsnormalizeError(which logs the raw cause) and uses the side-effect-freeerrorCode; the recording obeys the same discipline, taking the normalised error only when the default branch already computed one.
What this does not touch
The access log is unaffected, and that was checked rather than assumed:
collectExtraLogFields reads userId, serviceName, action and dimensions
from the context and never ctx.error. The completion line takes its code from
logDone as before, so filling the context changes nothing a log scraper sees.
That is the right split, not an accident worth preserving by luck: an audit row is a record the project keeps, while an access log is frequently shipped elsewhere. If the log line ever wants the cause, it should be an explicit choice with its own argument.
Consequences
- An audited HTTP failure names its cause with no wiring, matching the tool row.
- The custom-
onErrorbranch stops being a silent hole. setRequestErrorchanges meaning from “how you record an error” to “how you override what was recorded”. The guide says so; the behaviour is compatible either way, since a project already calling it keeps winning.- One more consumer of
recordedErrorMessage. If a third transport ever needs a record, it takes the rule from there rather than inventing a third variant.