fix(rest,runtime,types): relay a producer-declared 5xx refusal's prose at all three withhold arms - #17585
Conversation
…e at all three withhold arms WIP checkpoint before build/test. Claude-Session: https://claude.ai/code/session_01DapQyvYrFb1MxSYe7BL2nt Co-authored-by: Claude <noreply@anthropic.com>
… withhold arms Claude-Session: https://claude.ai/code/session_01DapQyvYrFb1MxSYe7BL2nt Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DapQyvYrFb1MxSYe7BL2nt Co-authored-by: Claude <noreply@anthropic.com>
…clared-refusal-relay
…h predicate Claude-Session: https://claude.ai/code/session_01DapQyvYrFb1MxSYe7BL2nt Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 4 package(s): 10 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 36 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 9ba0e2cec604cbdd568734e6f6de3e0337f6af4c && git checkout 9ba0e2cec604cbdd568734e6f6de3e0337f6af4c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ef180302f55955baf9994a91d0793bbd3926b068 8b38cf2fa1f56430b5dd3ad10868f04443339128 && git checkout -B drift-repro ef180302f55955baf9994a91d0793bbd3926b068 && git merge --no-ff 8b38cf2fa1f56430b5dd3ad10868f04443339128
node scripts/docs-audit/affected-docs.mjs --json ef180302f55955baf9994a91d0793bbd3926b068
|
Seat answer to the open question — take A, as delivered. And a deviation from a ruled constraint, recorded for the maintainer.⛔ Not the ACCEPT. 16 check families are still The question
A. Your reasoning holds and I am not adding to it so much as ratifying it: ADR-0112's #9232 amendment separates vocabulary from position, and position belongs to the Two things settle it beyond the citation:
|
Review — ACCEPT (
|
Fixes #16146
A producer-declared 5xx refusal now keeps its prose on the wire, at every arm that withholds because the status was declared. A declared fault still loses it, and that stays the default.
ApiErrorSchema.refusal(packages/spec/src/api/contract.zod.ts) has been on the tree since the spec half landed, and nothing read it. All three withhold arms could tell only that a producer had declared a status, so a deliberate refusal and a driver fault were sanitised alike — the measured case being the/meta/:type/:name/referencesdoor's ADR-0110 D3501, whose prescriptive sentence reached the wire as"Internal server error".The change, in one shape
One reader,
declaredRefusalMessage(packages/types/src/thrown-http-error.ts), besideserverFaultProvenance. Four conditions, all of them fail-closed:refusal === trueand nothing else (the spec declares itz.literal(true).optional());statusthenstatusCode) — the same band and the same two-spelling readdeclaredHttpStatusapplies, so no per-spelling dialect and no nonsensestatus: 700;code, asked throughdeclaresServerFaultrather than restated;looksLikeInternalErrorLeak.Read by all three arms, never re-derived at one:
declaredServerFaultAnswer@objectstack/restPOST /analytics/dataset/query(which calls it bare) and/dataresolveErrorResponse's 5xx passthrough@objectstack/restGET /meta/:type/:name/referencesviahandleRouteErrorerrorResponseBase@objectstack/runtimePOST /api/v1/analytics/querydispatcher routeThe read sits inside
declaredServerFaultAnswer, not in thewithDeclaredUserMessagewrapper around it: the analytics dataset door calls the arm bare, so a wrapper-level relay would have covered/dataand missed that door.Logging follows the same field, as the ruling requires:
logUnexpectedRouteErrorno longer prints[REST] Unhandled errorfor a declared refusal, andlogWithheldServerFaultthen no-ops by its own existing rule because the body carries the error's own message.Reconciling the batch #58 ruling with ADR-0112's scope note
The ruling on the card (director seat, decision batch #58, 2026-09-06, maintainer 「同意」) and ADR-0112's 2026-08-27 amendment are compatible, argued from both texts rather than assumed because the newer one is newer.
ADR-0112's amendment closes with, verbatim:
That sentence is a scope disclaimer, not a prose ruling. It says the amendment governs the code channel and deliberately does not reach the prose axis. So there is no ADR-0112 holding about 5xx prose for this card to contradict; the prose axis was carded separately and landed at the dispatcher exit, where
errorResponseBasehas withheld every declared 5xx message since.Batch #58 does not reverse that. It adds a third row and leaves the second — the default — untouched, which the published field's own docblock states in as many words: 「This field adds the third row; the first two are unchanged, and the second is still the DEFAULT.」 Absent the flag, this PR's diff is byte-identical at all three arms.
More than compatible, the two share one argument. ADR-0112's amendment explains why it could not tell an app's refusal from a driver's errno except by status:
In 2026-08 no other structural signal existed. Batch #58 creates one, on the published envelope, as a closed literal — precisely the kind of signal the amendment says it would have used, and explicitly 「not a status heuristic and not a second allow-list」. And the amendment's implementation constraint — 「Implemented once at the shared resolver layer so all doors inherit one rule; ⛔ no per-registrar variants」 — is honoured rather than strained: one function in
@objectstack/types, three callers.Residue corrected here: the ADR anchor for
thrown-http-error.tsstill said the prose limb 「is deliberately not applied yet」, which stopped being true when the prose axis landed. The anchor's invariant now says what that limb does today, what the one exception is, and that the code channel it governs is unchanged in every case.The sequencing decision on #17153's arm — absorbed here, deliberately
#17153 measured the third arm and left the call to this card: 「Whether the runtime exit lands in the parent's PR or its own is the parent's call」. It lands here. Three reasons, in order of weight:
declaredCodechannel in scope for 5xx sanitisation at all — and the answer must be applied to all three doors at once #12509's own note atdispatcher-plugin.tsrefuses a per-door re-derivation by name. A one-reader with two of three consumers migrated is exactly a per-door split, and it is only provable as one rule when all three land together.errorResponseBase) is the third withhold arm — it must readrefusaltoo, and it lives outside@objectstack/rest#17153 was filed to prevent. Its triage says the specific danger of a third arm is that 「the first two were fixed, so the surface now reads as covered」. Landing two of three manufactures that.dispatcher-plugin.tsat zero holders, so absorbing it costs no collision, and the security-floor argument below is made once instead of twice.errorResponseBasereads the field, and the[#12281]pin gains a declared-refusal case while its four existing cases stay red-proof (none of them declares the flag; all four still assert the prose withheld).The fourth-exit sweep
#17153's triage asked for one before closing, 「a sweep with controls, ⛔ not an impression」. Two sweeps over
packages/*/srcandpackages/*/*/src, non-test only, on the merged tree at8b38cf2f; every matched line was read, never counted.Sweep A — every site that substitutes the withheld string. 15 lines, of which 9 are prose in docblocks and 6 substitute. Sweep B — every non-test reader of a declaration, which is what makes a site an arm rather than a heuristic.
The closed set of declaration-gated withhold sites is four, not three:
errorResponseBase) is the third withhold arm — it must readrefusaltoo, and it lives outside@objectstack/rest#17153, all migrated here;packages/rest/src/rest-server.ts:11349— the analytics dataset door's generic-500 branch,declaresServerFault(error) || looksLikeInternalErrorLeak(msg). It reads a declaration and was named by neither The runtime dispatcher exit (errorResponseBase) is the third withhold arm — it must readrefusaltoo, and it lives outside@objectstack/rest#17153 nor the spec docblock. It is unreachable by a declared refusal by construction: the arm above it answers first for every in-band declared 5xx, and the shared reader is bounded 500-599 exactly asdeclaredHttpStatusis, so the only shapes reaching this branch are an out-of-bandstatus(600 and up) and an undeclared throw — neither of which the reader admits. That reachability argument is pinned, not asserted:thrown-http-error-refusal.test.tsdrivesstatus: 700and asserts the reader declines whiledeclaresServerFaultstill answerstrue.And a fifth door #17153 did not enumerate:
packages/plugins/plugin-hono-server/src/adapter.ts:269. Measured heuristic-only (resolved.status >= 500 && looksLikeInternalErrorLeak(resolved.message)), so it reads no declaration and already keeps a declared refusal's prose — out of the closed set for the same reason as the four doors #17153 did name. The four it named were re-read and still read no declaration.Controls. The two positive controls are the arm-1 and arm-3 call sites, which the sweep must and does find; a fabricated symbol and a fabricated literal each returned 0 over the same corpus.
Security floor — what bounds the kept message
This alters what prose leaves the server on a 5xx, so per
SKILL.md's negative boundary it is routed to the manual security/permission floor rather than out of scope. Four things bound it, and the first three are structural:Errordrops it and the 5xx is withheld as a fault — measured for the two overlay-delete rewraps inmetadata-protocol, which carrystatus,codeanduserMessageand carry no flag. ⛔ NocarryRefusalis added there: that would putoverlayDeleteFailureMessage's platform prose on the flag channel, which 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.code. A bare driver error can never satisfy that shape: it declares no status, which is the same structural discriminator ADR-0112 chose for the code channel.looksLikeInternalErrorLeakstays unconditional. The declaration says the prose is addressed to the caller; it does not say it is safe. A producer that declares a refusal over a driver dump is withheld exactly as a fault is — driven at both REST and the dispatcher exit. This is what stops an undeclared internal string riding the refusal channel even if a flag were somehow attached to one.packages/restthe kept message goes throughtruncateClientMessage(CLIENT_MESSAGE_MAX = 500), which is what 「bounded exactly as a 4xx message is (rest-server 的 4xx 直通把 ≥500 字符的 message 整条换成 "Request failed" —— #5368 刚写好的过滤器拒收措辞,客户端一个字也收不到(实测) #5423)」 means in this package: truncate, never replace. At the dispatcher exit a caller-addressed message is unbounded today, so a kept refusal is treated there exactly as that door already treats a 4xx — per door, not a new rule.The withhold's compensation is not lost when the withhold is: the untouched error still reaches the operator (
__obsRecordedErrorat the dispatcher,logWithheldServerFaultin REST for a truncated message), and that is pinned.The route-local patch, and a premise this PR falsified
The ruling's second constraint: 「Retire the route-local patch from PR #16143 once the relay handles
/references; the producer there sets the new field instead.」 The producer half is done —findReferencesToMetasetsrefusal: truebeside thestatusandcodeit already declared.notImplementedRefusalAnswerdoes two things, and only one of them is a refusal/fault opinion:{ error, code }throughhandleRouteError). Deleting the arm would putbody.error.codeback toundefinedon that exit and re-open the second half of the defect rest/meta: the /references door's unanswerable-target 501 loses its prescriptive ADR-0110 D3 message — one route, two refusal envelopes #15685 measured and pinned positionally inrest-server-meta-references-refusal-envelope.test.ts. Envelope position is owned by thecheck:route-enveloperatchet and is explicitly a separate line from vocabulary — ADR-0112's own amendment says so in as many words — so it is not folded into a prose ruling.⇒ what remains at that route is a pure position adapter over the shared answer, gated on the shared reader. The premise the ruling's constraint rests on — that the patch's only effect is the prose relay — is false on the tree, and the difference is reported here rather than paid for with a silent regression. The maintainer may of course decide the envelope position should move too; that is a decision, and a separate one.
Card prose corrected
#16146's own sentence — 「the single relay for every producer-declared 5xx at every door」 — is false, and its child says so with measurements. Triage on #17153 asked for it to be corrected 「in the same pass, or it will mislead the next reader exactly as it misled this one」. Corrected in an appended note on that card's body, and the count is four rather than three per the sweep above. The repo itself carries no copy of that sentence (grepped: zero hits for the phrase outside GitHub), so the card body is the only place it lives.
Evidence
Wire-driven, because the defect was invisible to a unit test on the arm — the arm did exactly what it said while the prose died between a producer and a caller. Every case throws through a real mounted route and reads the response the door wrote.
Red on
origin/main, green here — the ablation checked out the five source files ata36b526f, rebuilt, proved the marker absent frompackages/types/distwithscripts/ablation-dist-preflight.mjs --absent, ran the pins, then restored and proved blob identity againstHEADfor all five:packages/types/src/thrown-http-error-refusal.test.tspackages/rest/src/rest-declared-refusal-relay.test.tspackages/runtime/src/dispatcher-plugin.declared-5xx-prose-withhold.test.tsThe 8 and the 16 that pass under ablation are the point: they are the negative halves — a declared fault still withheld, an undeclared 5xx still heuristic-judged, the four
[#12281]cases — and they are red-proof pins for behaviour this PR does not move.Package suites and typechecks, all green, exit codes captured by redirect before any pipe:
testtypecheck@objectstack/types@objectstack/metadata-protocol@objectstack/runtime@objectstack/restGates. The floor was derived from the whole expected change set including the changeset and the two ledger files, re-derived after merging
origin/main(f721ef0f) and diffed against the pre-merge derivation — identical, 72 families. All 72 run with per-family exit codes; reconciliation at8b38cf2f:✓ dispatch-gates --ran: 72 derived famil(ies) accounted for — 72 run, 0 NOT-MEASURED (a DERIVED zero — all 72 recorded an exit code and none of them is 3).Two went red on the first pass and both are resolved rather than routed around:
check:dual-build-cjs-loadsexited 3 (PREREQUISITE NOT MET, nothing measured) and answers 0 after a fullpnpm build;check:engine-double-contractexited 1 on the new fixture'sfindOne, now opened withassertEngineFindOnePredicateand recorded in the pinned ledger by--write.check:route-envelope,check:dispatcher-error-vocabularyandcheck:adr-anchorsare green — noted as required rather than as proof, since ADR-0112 records both of the first two green while a hand-built emission sat in the tree.pnpm lint— the whole repo, no narrowing: 6595 files, 0 errors, 0 warnings, exit 0. The file count is eslint's own--format jsonoutput, not an estimate.Contract declaration
Clause-②: no
Contract-text:—ApiErrorSchema.refusal's own docblock (packages/spec/src/api/contract.zod.ts), quoting the two sentences this relay implements: 「declared refusal —status >= 500, acode, andrefusal: true| KEPT verbatim, bounded exactly as a 4xx message is (#5423), and not logged as an unhandled fault — once the three arms below read the field」 and 「Each of the three keepsmessagewhen the field is present and withholds it otherwise; the relay half must move ALL THREE」.⇒ this is a pull-back to an already-declared contract, which is the one thing that surface's own clause-② rule says does not reach it:
packages/specis untouched, no schema accepts or rejects anything it did not before, and the wire moves toward what the published envelope already prescribes.@objectstack/typesgains one export,declaredRefusalMessage. It is a boundary helper, not an authorable contract surface, which is why it is read as outside clause-②'s 「已发布契约面」 rather than as a widening. The runtime-prose change is routed to the manual security/permission floor above, perSKILL.md's negative boundary — a redirection, not a dismissal.Acceptance notes
rest-server.ts:11349is a fourth declaration-gated withhold site. It is correct as it stands (an out-of-band declared fault must keep its withhold) and unreachable by a refusal by construction, so there is nothing to repair. Recorded because the spec docblock and The runtime dispatcher exit (errorResponseBase) is the third withhold arm — it must readrefusaltoo, and it lives outside@objectstack/rest#17153 both enumerate three; the next author of this family is the one who will trip over the count.ApiErrorSchema.refusal's docblock names four doors that read no declaration;plugin-hono-server'sadapter.tsis a fifth and is in the same class.packages/specis read-only for this card, and a missing list member is a completeness matter rather than a defect. Successor: whoever next edits that docblock or takes a 5xx-door card./referencesrefusal back-loads its ADR-0110 D3 prescription, so the rest-server 的 4xx 直通把 ≥500 字符的 message 整条换成 "Request failed" —— #5368 刚写好的过滤器拒收措辞,客户端一个字也收不到(实测) #5423 bound this PR routes that door through silently cuts the remedy for object plus field names totalling roughly 72 characters. Measured against the real template (412 characters ataccount.owner, 511 at 40/40). The repair is at the producer's sentence order and moves two content pins, so it is not taken here.errorResponseBase) is the third withhold arm — it must readrefusaltoo, and it lives outside@objectstack/rest#17153's arm is delivered in this PR and this body deliberately carries no closing keyword for it; whether that card closes is the PM's call.logServerFault, which logs every 5xx by a separate decision (A 500 from GET /api/v1/packages (and /meta/package/:name) leaves no server-side log line at all #14310). That card is untouched here and stays as it is.Generated by Claude Code