fix(plugin-hono-server): render an escaped throw's declared ADR-0112 envelope instead of a bare 500 - #17412
Conversation
…envelope
`HonoHttpServer.wrap()` is the seam every direct-mount route passes, and it
answered every escaped throw as `500 { code: 'INTERNAL_ERROR', message: 'No
response from handler' }` with the thrown value discarded. A producer that had
declared its refusal lost both halves of the declaration on the way out.
An escaped throw carrying BOTH a declared ADR-0112 status (a key of
`HttpStatusErrorCodeMap`) AND a code registered in `ErrorCode` is now answered
as that envelope, with `details` and `userMessage` forwarded. Status and code
are read through `resolveThrownHttpError` -- the rule the REST registrar and the
dispatcher already share -- so the doors agree by construction, not by a second
ladder. The 5xx disclosure filter (`looksLikeInternalErrorLeak`) applies from
this seam's first day.
Everything else is unchanged and pinned: a non-envelope throw, a partial
declaration, an unregistered code, an undeclared status, a handler that wrote
nothing, and a handler that wrote then threw. The `notFound` fallback seam still
answers `Fallback handler failed`. No error code is minted and no ledger row is
added.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DapQyvYrFb1MxSYe7BL2nt
📓 Docs Drift CheckThis PR changes 1 package(s): 6 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 — 7 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 0e1784e580b099c1b4f25884b10bee31ed3a560b && git checkout 0e1784e580b099c1b4f25884b10bee31ed3a560b
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 572c33a2c43ea42de9b18e802d939cf68d01d24c 2b995cb8dc6f3051e8d41008c7e95889c005c960 && git checkout -B drift-repro 572c33a2c43ea42de9b18e802d939cf68d01d24c && git merge --no-ff 2b995cb8dc6f3051e8d41008c7e95889c005c960
node scripts/docs-audit/affected-docs.mjs --json 572c33a2c43ea42de9b18e802d939cf68d01d24c
|
Fixes #16545
Implements item 2 of the #15999 ruling — the
domain:clishared half.Clause-②: no
No error code is minted and no row is added to
ERROR_CODE_LEDGER/StandardErrorCode. The diff adds no exported symbol: the gate is a module-local function inadapter.ts, and the only public change is what an already-declared envelope renders as — pulling behaviour back to a declared contract, not widening an accept set or a public surface.The measurement that decided where this lands
The card said the fix goes in "the adapter (or the registrar wrapper it already funnels through — measure which one every direct-mount route actually passes)". Measured, and the answer moved the diff:
packages/adapters/hono/src/index.ts(createHonoApp)new Hono()and mounts only the dispatcher catch-all, discovery and the auth namespace. It carries no direct-mount route at all, and@objectstack/honohas no in-repo consumer (#4117, stated verbatim inhono-dispatcher-result-response.conformance.test.ts).HonoHttpServer.wrap()—packages/plugins/plugin-hono-server/src/adapter.tsget/post/put/delete/patcheach registerthis.app.VERB(path, this.wrap(handler)), andIHttpServeris howservice-datasource,packages/restand the dispatcher bridge all mount.packages/adapters/hono/src/; the funnel is one package over. Re-derived per-ref at my own base (e1eee43b) across 16 of 16 open PRs / 580 (PR,file) rows: 0 holders onpackages/plugins/plugin-hono-server/src/,packages/qa/http-conformance/src/,packages/types/orpackages/services/service-datasource/src/— the only rows any open PR has under those trees areCHANGELOG.mdandpackage.json, all from the standing Version Packages PR #17076. Positive controls fired (191packages/spec/rows, 4packages/runtime/, 35packages/plugins/, 14packages/qa/); a fabricatedpackages/NOSUCHPKG/returned 0.The raw Hono mount is OUT of scope — and it is NOT repaired by this PR
Binding, from the
domain:cliseat (comment 5570159125, restated in 5575064522):A route mounted through
getRawApp()funnels through neitherwrap()nor any registrar wrapper. This change leaves those doors exactly as they are today, and they will look repaired from the outside. Measured on this branch, with the fix in place:The two raw answers are byte-identical — which is exactly why the seat ruled that this card's own pin would be vacuous there:
The door population is larger than the one file the census named — nine
getRawApp()call sites acrosscloud-connection,metadata,plugin-auth,plugin-approvalsand the CLI console. Filed as its own card with that measurement: #17411 (not addressed here).What changed
An escaped throw is rendered as its declared envelope when both conditions hold, read off
resolveThrownHttpError— the one rule the REST registrar and the dispatcher already share, so this seam agrees with the other doors by construction rather than by a second ladder:HttpStatusErrorCodeMap(ADR-0112's own status list);codeis a member ofErrorCode(StandardErrorCode∪ERROR_CODE_LEDGER).The producer's
detailsanduserMessagechannels are forwarded.declaredCodedeliberately is not: under this gate the producer's spelling is the registered member incode, sodemotedDeclaredCodereturnsundefinedby construction.The 5xx disclosure filter (
looksLikeInternalErrorLeak, #3867 / #8086) applies from this seam's first day — this change makeswrap()a door that emits a thrown message for the first time, and without the filter a driver dump on a declared 5xx would newly reach the client where the old bare 500 disclosed nothing.The escaped-throw diagnosis (#5848) still fires exactly once at
error, and now names the answer that was really sent instead of hard-coding a claim that became false for the requests this card repairs.Both directions are pinned, and the ablation proves the pins can fail
packages/plugins/plugin-hono-server/src/handler-throw-declared-envelope.test.ts(19 cases) andpackages/services/service-datasource/src/__tests__/admin-routes-authz-outage-envelope.test.ts(5 cases, the card's named end-to-end probe:GET /api/v1/datasourceswith atenancyservice registered through a throwing factory, asserting503 SERVICE_UNAVAILABLEon the wire).Ablation —
adapter.tsrestored to the merge basee1eee43b,dist/rebuilt on the ablated tree, both suites re-run:The 11 + 3 that stay green under ablation are the regression half by construction — the fallback arms, which are supposed to pass on the unfixed tree and are what goes red if the gate is ever widened.
Acceptance notes
packages/services/**was driven read-only.admin-routes.tsis another lane's surface and is not modified; the new file there is a test that uses it as a driver, exactly as the dispatch's ZONE 3 allows.resolveThrownHttpErrortreats the validation SHAPE as a declaration (err.name === 'ValidationError'⇒400/VALIDATION_FAILED), so a bareValidationErrorescaping a direct-mount handler now answers 400 where it answered a bare 500. Carving that limb out at this door would mean disagreeing with the very function this seam delegates to — the two-door divergence The direct-mount REST package door answers 500 INTERNAL_ERROR for coded 4xx errors the dispatcher twin maps correctly #8016 removed.notFoundfallback seam is deliberately opted out and still answersFallback handler failed: a fallback that threw is a broken consumer, not a refusal it declared.capturedResponseis visible precisely so a declared envelope cannot overwrite a response the handler already produced.IHttpServeradapters disagree about escaped throws, and did before this card.NodeHttpServer(packages/qa/http-conformance) reads onlyerr.statusCode, answers that status, and writes a non-envelope{ error: "MESSAGE" }body — its own suite pins418 {"error":"kaboom"}. This PR widens the gap on the Hono side. It is not filed because no cross-adapter conformance case asserts throw parity today, so nothing is failing and no PR is carrying that file; the successor to raw Hono mounts (getRawApp()) answer an escaped throw as500 text/plain "Internal Server Error"— no ADR-0112 envelope, declaredstatus/codediscarded #17411 is the natural place to decide the port's answer once. Cross-adapter conformance is green as-is (89/89).packages/adapters/hono/src/index.ts— the package the card and the claim both name — has no in-repo consumer and its two dispatcher catch-alls relayerr.statusCodeinto a non-envelopeerrorJson. Same class as the row above and the same reason for not filing: nobody is carrying that file and nothing is red. Carrier for a future decision: whoever takes raw Hono mounts (getRawApp()) answer an escaped throw as500 text/plain "Internal Server Error"— no ADR-0112 envelope, declaredstatus/codediscarded #17411.packages/adapters/**is inside [Decision] Fail-close isAuthGateAllowlisted's "no path ⇒ exempt" default (Option 2 of #7432) #7898's H17 on-hold trigger index. This diff does not touchpackages/adapters/**at all — the measurement moved it out — so there is no collision to widen.Verification
dispatch-gates --commandsderived families--rancarrying exit codescheck:dual-build-cjs-loads,check:lean-entry-closure,check:type-check-debt— each printedPREREQUISITE NOT METand exited 3; all three need a whole-repopnpm buildclosure, which is CI's runpnpm linteslint . --no-inline-config, not a narrowing@objectstack/plugin-hono-servertest@objectstack/service-datasourcetest@objectstack/http-conformancetestcheck:test-typecheckOK, 0 files / 0 errors / 0 pinned signaturesMeasured at
2b995cb8.Generated by Claude Code