You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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/main06d38fb92, 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)constmessage=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 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:178sendThrownError, 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
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.
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 handleRouteError → declaredServerFaultAnswer". 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.
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.
Filed by the #16335 dev round (branch
claude/issue-16335-adr-0112-refusal-declaration, sessionsession_01MkQhmuuJAVDjmeWNixwDDH) as a sub-issue of #16146, from the contract-review verdict on #16335 (comment 5602442516, finding B1′). Every reading below was taken onorigin/main06d38fb92, 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:Its gate is
serverFaultProvenance(packages/types/src/thrown-http-error.ts:324-327):status < 500answersundefined, otherwisedeclaredStatus === undefined ? 'undeclared' : 'declared'— so every producer-declared 5xx (either spelling,codeor not) has itsmessagereplaced withINTERNAL_ERROR_MESSAGE. This path never consultsdeclaredServerFaultAnswer(whose non-test callers are exactlyerror-response.ts:1409andrest-server.ts:11190). Moving both REST arms leaves this door withholding a declared refusal unchanged.It is a production door, not a harness:
objectstack serve(packages/cli/src/commands/serve.ts:4042,kernel.use(createDispatcherPlugin({...}))) and bypackages/plugins/plugin-dev/src/dev-plugin.ts:873;POST {prefix}/analytics/query(dispatcher-plugin.ts:1152) among the dispatcher's routes —rest-server.ts:11175-11180itself says that sibling face "relays both halves throughdispatcher-plugin.errorResponseBase";ErrorResponseSchema, which nestsEnhancedApiErrorSchema— 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 addsrefusalto;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 assertingres.body.error.messageisINTERNAL_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), anderrorResponseBase. 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:178sendThrownError, 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,
errorResponseBasemust readrefusal: trueand keepmessageverbatim (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 (norefusal→ withheld).Suggested route, not a ruling: one reader, in
@objectstack/typesbesideserverFaultProvenance(a third provenance value or a sibling predicate onThrownHttpError), so all three arms inherit one definition.dispatcher-plugin.ts:691-696already argues "one rule, every door inherits" (#12509) and refuses a per-door re-derivation; arefusalread 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
packages/metadata-protocol/src/protocol.ts:21056-21075and:21196-21211— the two overlay-delete rewraps compose a NEWErrorwith a rewrittenmessage, copystatus(err?.status ?? 500, or a literal 500) andcause, then carrycodeanduserMessagethroughcarryCatalogedErrorCode/carryDeclaredUserMessage. Fail-closed today: nothing on the tree copiesrefusal, so a refusal crossing them is withheld as a fault. ⛔ Do not add acarryRefusalthere — it would putoverlayDeleteFailureMessage'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 onApiErrorSchema.userMessagerefused.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/referencesrefusal "reaches the wire throughhandleRouteError→declaredServerFaultAnswer". Measured: that throw spellsstatus(protocol.ts:21806), so it takesresolveErrorResponse's status passthrough (error-response.ts:2040-2041) into that function's own 5xx arm (:2116-2123), not arm 1;structuredCodeAnswer(:891) answers onlyDELETE_RESTRICTED/CONCURRENT_UPDATE, so the!declaresServerBandguard at:2015-2019is not what routes it. AstatusCode-spelled 5xx falls tomapDataErrorand 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.rest-server.ts:11190-11192) callsdeclaredServerFaultAnswerbare and spreadsmarkExtra— nowithDeclaredUserMessage— souserMessagerides/dataand the passthrough arm but not that door; a relay that readsrefusalinsidedeclaredServerFaultAnswercovers it, one that reads it in the wrappers does not.Dedupe
REST
/searchis 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 forerrorResponseBase|dispatcher exit|dispatcher-plugin|serverFaultProvenance|withhold arm|declaredServerFaultAnswer|INTERNAL_ERROR_MESSAGE→ one hit, #16937 (the 429detailsfences inerror-handling.mdx, unrelated); lit controldeclaredServerFaultAnswer|analytics→ 7 hits, so the grep reaches this area. No open duplicate.Generated by Claude Code
Generated by Claude Code