Skip to content

types: sendError's extra cannot carry userMessage either — the same Pick bars the second declared open channel from every nested-envelope route #12404

Description

@os-litant

Filed unassigned while implementing #11719. Out of scope there: that card's declared region is declaredCode, and its claim rulings fix the scope at that one field.

Measured on origin/main @ cdbd920

ApiErrorSchema (packages/spec/src/api/contract.zod.ts:87) declares userMessage beside declaredCode:

userMessage: z.string().optional().describe(…)

Its docblock states the contract in as many words — the producer-side opt-in channel for "this exact text is addressed to the END USER" (#9934; maintainer ruling 2026-08-19 on objectui#5210, option 1), where presence IS the marking and a consumer that sees the field renders it verbatim instead of substituting its generic string.

packages/types/src/response-envelope.ts types the shared nested-envelope writer's extra as a four-member Pick over ApiError:

extra?: Pick<ApiError, 'category' | 'httpStatus' | 'details' | 'requestId'>

userMessage is not a member, so no route answering the nested envelope can put it on the wire — it is a compile error to try. Measured directly, not inferred: a probe passing { userMessage: 'x' } is refused with TS2353 … 'userMessage' does not exist in type 'Pick<…>', on the same instrument that compiled a control line passing details clean.

Why it is the same class as #11719, and why it is a separate card

Identical shape — a channel ApiErrorSchema declares, that the ONE shared writer cannot emit — and the same one-directional silence: nothing invalid ships, so no gate and no conformance suite notices, while the producer's deliberate text is gone and a consumer told to read the field finds nothing.

It is separate because the two fields answer different rulings and have different producers. declaredCode is ADR-0112's demote (#9106 / #9232) and is produced by resolveThrownHttpError; userMessage is #9934's user-facing-text opt-in and is produced by declaredUserMessage, with a live sandbox-side reader in packages/runtime/src/sandbox/quickjs-runner.ts. #11719's claim rulings scope that card to declaredCode explicitly, so widening its PR to cover this would have been the scope breach those rulings exist to prevent.

packages/rest/src/error-response.ts:558 reads declaredUserMessage(error) for the flat /data door, so the same asymmetry #11719 records for declaredCode — flat door carries it, nested door cannot — applies here.

Not established here

  • Blast radius. Which nested-envelope routes can actually catch an error carrying a producer userMessage was not censused. The types: sendError's extra cannot carry declaredCode, so every nested-envelope route silently drops an unregistered producer's own code spelling #11719 census found that six of the seven modules on the shared pair build code/message as literals and never consult the shared resolver at all, so for those the field would be inert until they adopt it; packages/rest/src/package-routes.ts is the one that already holds a resolved ThrownHttpError (and therefore its userMessage) at the sendError call.
  • Whether the repair is widening the Pick again or something that stops this recurring per field. Three of ApiError's optional fields are now known to be declared-but-unemittable-from-the-nested-writer in sequence, which may argue for a different shape than a Pick extended one card at a time.
  • Severity not judged.

Region: packages/types/src/response-envelope.ts, sendError's extra parameter.


Generated by Claude Code

Metadata

Metadata

Assignees

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions