Skip to content

runtime: errorResponseBase never surfaces userMessage, so the dispatcher exit has no author-facing text channel — the one the 5xx prose withhold assumes as its compensation #13241

Description

@os-trump

Found while implementing #12281 (draft PR #13240), which made errorResponseBase withhold the message of every declared 5xx. Out of scope there: that card is fenced to the message ternary, and this is a different field on the envelope.

The gap

The 2026-08-27 maintainer ruling on #12509 (option D), propagated to #12281, says:

errorResponseBase adopts the structural withhold for every declared 5xx message, aligning to /data's rule; the author-facing text channel is userMessage (#9934), never the raw message.

That sentence names userMessage as the channel a producer uses when its refusal text is genuinely addressed to a human — the compensation for the withheld prose. At this exit that channel does not exist.

Measured on origin/main at a81aa9dd:

  • packages/runtime/src/dispatcher-plugin.tserrorResponseBase calls buildApiError({ message, httpStatus, code, details, extra }). userMessage appears nowhere in the function. It resolves the thrown error through resolveThrownHttpError, whose result does carry userMessage, and then does not read that field.
  • packages/runtime/src/http-dispatcher.tserrorFromThrown, the caught-path sibling, does carry it: ...(thrown.userMessage !== undefined ? { userMessage: thrown.userMessage } : {}).
  • packages/rest/src/error-response.ts — the /data door carries it too, via boundedDeclaredUserMessage.
  • packages/spec/src/api/contract.zod.tsApiErrorSchema.userMessage is a declared optional field, so the envelope already has the slot.

So of the boundaries that answer with an ADR-0112 error envelope, the dispatcher's throw-transparent exit is the only one that drops the field. buildApiError already supports it through extra, which is how declaredCode reaches the wire from this same function — so the omission is not a structural obstacle.

Why it matters more after #12281

Before #12281, a producer that declared a 5xx without a code kept its raw prose at this door, so author text reached the caller by accident, through the diagnostic channel. #12281 closes that — correctly, per the ruling. The result is that a declared-5xx producer at this exit now has no way to address the caller: the diagnostic channel is withheld and the author channel is not plumbed.

⚠️ This is a latent seam, not a live regression. The #12281 measurement established that the population of declared 5xx reaching this door is empty today, so nothing currently loses text. The consequence is for the next producer, not for today's tree.

Not established here

Region

packages/runtime/src/dispatcher-plugin.ts (errorResponseBase) · possibly packages/runtime/src/error-envelope.ts (buildApiError) if the shared shape is preferred.

⚠️ packages/runtime/src/http-dispatcher.ts is #7898's on-hold trigger file. It is named above only as the sibling that already carries the field — a repair should not need to touch it.

Refs

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions