Skip to content

The runtime dispatcher exit (errorResponseBase) is the third withhold arm — it must read refusal too, and it lives outside @objectstack/rest #17153

Description

@os-bill

Filed by the #16335 dev round (branch claude/issue-16335-adr-0112-refusal-declaration, session session_01MkQhmuuJAVDjmeWNixwDDH) as a sub-issue of #16146, from the contract-review verdict on #16335 (comment 5602442516, finding B1′). Every reading below was taken on origin/main 06d38fb92, not copied from the verdict.

The finding

#16146 describes declaredServerFaultAnswer (packages/rest/src/error-response.ts:574) as "the single relay for every producer-declared 5xx at every door", and the spec half (#16335, PR #17090) went one better and named two withhold arms in @objectstack/rest. Neither count is the closed set. A third arm withholds a declared 5xx's prose, and it lives in @objectstack/runtime:

// packages/runtime/src/dispatcher-plugin.ts:718-721  (errorResponseBase, :639)
const message =
    serverFaultProvenance(thrown) === 'declared' || (httpStatus >= 500 && looksLikeInternalErrorLeak(raw))
        ? INTERNAL_ERROR_MESSAGE
        : raw || 'Internal Server Error';

Its gate is serverFaultProvenance (packages/types/src/thrown-http-error.ts:324-327): status < 500 answers undefined, otherwise declaredStatus === undefined ? 'undeclared' : 'declared' — so every producer-declared 5xx (either spelling, code or not) has its message replaced with INTERNAL_ERROR_MESSAGE. This path never consults declaredServerFaultAnswer (whose non-test callers are exactly error-response.ts:1409 and rest-server.ts:11190). Moving both REST arms leaves this door withholding a declared refusal unchanged.

It is a production door, not a harness:

  • mounted by objectstack serve (packages/cli/src/commands/serve.ts:4042, kernel.use(createDispatcherPlugin({...}))) and by packages/plugins/plugin-dev/src/dev-plugin.ts:873;
  • it answers POST {prefix}/analytics/query (dispatcher-plugin.ts:1152) among the dispatcher's routes — rest-server.ts:11175-11180 itself says that sibling face "relays both halves through dispatcher-plugin.errorResponseBase";
  • it emits ErrorResponseSchema, which nests EnhancedApiErrorSchema — the envelope spec: ADR-0112 error envelope gains an explicit producer-side refusal declaration so a deliberate 5xx refusal can keep its caller-authored message (spec half of #16146) #16335 adds refusal to;
  • it is pinned: packages/runtime/src/dispatcher-plugin.declared-5xx-prose-withhold.test.ts:147 ([#12281] a DECLARED 5xx has its prose withheld at the dispatcher exit), cases {status: 503}, {status: 503, code: 'SERVICE_UNAVAILABLE'}, {status: 500}, {status: 504}, each asserting res.body.error.message is INTERNAL_ERROR_MESSAGE (:213).

The closed set of declaration-gated withhold arms on this tree is therefore three: declaredServerFaultAnswer, resolveErrorResponse's own 5xx passthrough arm (error-response.ts:2116-2123), and errorResponseBase. Every other 5xx door reads no declaration and withholds by the leak heuristic alone — HttpDispatcher.error() (http-dispatcher.ts:1041), endpointErrorAnswer (endpoint-executor.ts:287), package-routes.ts:178 sendThrownError, the hono auth door (adapters/hono/src/index.ts:630) — so they already keep a declared refusal's prose unless the heuristic fires and are NOT in this card's scope.

What this card asks

When the relay half lands, errorResponseBase must read refusal: true and keep message verbatim (bounded as a 4xx message is), exactly as the two REST arms will; and the [#12281] pin must gain a declared-refusal case that expects the prose KEPT while its four existing cases stay red-proof (no refusal → withheld).

Suggested route, not a ruling: one reader, in @objectstack/types beside serverFaultProvenance (a third provenance value or a sibling predicate on ThrownHttpError), so all three arms inherit one definition. dispatcher-plugin.ts:691-696 already argues "one rule, every door inherits" (#12509) and refuses a per-door re-derivation; a refusal read re-derived at each arm is the divergence that comment says this family was repaired for twice. Whether the runtime exit lands in the parent's PR or its own is the parent's call — this card exists so the runtime exit is not dropped when the parent's "move BOTH arms" is read as the whole job.

Notes for the parent's relay (#16146), measured on the same tree

  1. packages/metadata-protocol/src/protocol.ts:21056-21075 and :21196-21211 — the two overlay-delete rewraps compose a NEW Error with a rewritten message, copy status (err?.status ?? 500, or a literal 500) and cause, then carry code and userMessage through carryCatalogedErrorCode / carryDeclaredUserMessage. Fail-closed today: nothing on the tree copies refusal, so a refusal crossing them is withheld as a fault. ⛔ Do not add a carryRefusal there — it would put overlayDeleteFailureMessage's platform prose on the flag channel, which is the promotion the contract: a hook refusal has no way to mark its message user-facing — the console's 403 substitution (ruled in #3821) needs a producer-side opt-in channel #9934 note on ApiErrorSchema.userMessage refused.
  2. rest-server.ts:1377 (and PR rest/meta: the /references door answers both 501 refusals in one ADR-0112 envelope #16143's body) say the /references refusal "reaches the wire through handleRouteErrordeclaredServerFaultAnswer". Measured: that throw spells status (protocol.ts:21806), so it takes resolveErrorResponse's status passthrough (error-response.ts:2040-2041) into that function's own 5xx arm (:2116-2123), not arm 1; structuredCodeAnswer (:891) answers only DELETE_RESTRICTED / CONCURRENT_UPDATE, so the !declaresServerBand guard at :2015-2019 is not what routes it. A statusCode-spelled 5xx falls to mapDataError and arm 1 (:2032-2034). The two REST arms compose the same bytes, so the driven pin cannot tell them apart; the comment should name arm 2.
  3. The analytics dataset door (rest-server.ts:11190-11192) calls declaredServerFaultAnswer bare and spreads markExtra — no withDeclaredUserMessage — so userMessage rides /data and the passthrough arm but not that door; a relay that reads refusal inside declaredServerFaultAnswer covers it, one that reads it in the wrappers does not.

Dedupe

REST /search is proxy-refused in this container, so: GET .../issues/16146/sub_issues[] (200); the newest 100 open issues (created on or after 2026-09-08T17:21Z) grepped locally for errorResponseBase|dispatcher exit|dispatcher-plugin|serverFaultProvenance|withhold arm|declaredServerFaultAnswer|INTERNAL_ERROR_MESSAGE → one hit, #16937 (the 429 details fences in error-handling.mdx, unrelated); lit control declaredServerFaultAnswer|analytics → 7 hits, so the grep reaches this area. No open duplicate.


Generated by Claude Code


Generated by Claude Code

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions