fix(hono): answer a raw-mount escaped throw with the declared ADR-0112 envelope - #17643
Conversation
…2 envelope
A route mounted through `IHttpServer.getRawApp()` funnels through neither
`wrap()` nor any registrar wrapper, so its escaped throw reached Hono's own
default handler: `500 text/plain "Internal Server Error"`, with the thrown
value's declared `status`/`code` discarded. Install a transport error seam on
the raw handle that renders the SAME `declaredEnvelopeForThrow` gate `wrap()`
opted into, so `/raw/*` and a direct-mount route answer one shape.
The observation seam's rejected-request status now reads that same rule: it
defaulted to 500 because Hono's error path always sent 500, which stops being
true here, and `http_requests_total{status}` is armed off that seam.
Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c
Co-authored-by: Claude <noreply@anthropic.com>
… add the changeset A Hono handler may not return `void`, and only a body whose statement IS the `throw` infers `never` — so each door throws a value a factory hands back instead of calling a shared throwing helper. Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 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 75b97146a55877c32cd4310396fbf3cea14c8e67 && git checkout 75b97146a55877c32cd4310396fbf3cea14c8e67
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 76c9fab30ca406b7b1f06b8ca3db286af9f2bf8b 7b3c366ebe210036ca328c0159b684cde24078a1 && git checkout -B drift-repro 76c9fab30ca406b7b1f06b8ca3db286af9f2bf8b && git merge --no-ff 7b3c366ebe210036ca328c0159b684cde24078a1
node scripts/docs-audit/affected-docs.mjs --json 76c9fab30ca406b7b1f06b8ca3db286af9f2bf8b
|
Fixes #17411
Clause-②: no
Declared by the dispatching
domain:cliPM seat (sessionsession_01TSf4DV7ziu4V5j73e46b7c), matching the claim comment on #17411. Verified against the DIFF rather than the card: this change pulls raw-mount refusals back onto the already-declared ADR-0112 envelope —http-server.ts:190-201requires that envelope and forbids "an adapter-native error page" — so it widens no accept set and adds no public surface. The only error code the diff adds isINTERNAL_ERROR, already on the published error-code ledger; no new code is introduced, which would beyesunconditionally.A route mounted through
IHttpServer.getRawApp()funnels through neither the Hono adapter'swrap()nor any registrar wrapper, so an escaped throw was answered by Hono's own default handler:500 text/plain "Internal Server Error"— no ADR-0112 envelope, nosuccess, nocode, and the thrown value's own declaredstatus/codediscarded.The premise, verified from the contract text before any code
The ruling on this card rests on a falsifiable premise: that the
getRawApp()exemption covers framework-native mounting and ledger exemption, not the error answer. Lines read inpackages/spec/src/contracts/http-server.ts::290-295— thegetMountedRoutes()contract: raw-handle mounts "are outside this table by construction, and so areusemiddleware and thesetFallbackHandlerseam: this answers 'what routes did I register', not 'what paths might respond'." Scoped to route introspection, in the contract's own words.:344-359— thegetRawApp()docblock: "THE deliberate framework-specific escape hatch on this otherwise framework-agnostic contract", declared because four consumers were each declaring it locally, withanyas the return type "ON PURPOSE" so the contract keeps no framework dependency. A framework-handle accessor. Nothing about error shape.:190-201— Unmatched-request semantics (CONTRACT, spec/runtime: IHttpServer 契约补写 SSE 软扩展与 404/405 语义(OQ#10 尾巴) #3607 / ADR-0076 OQ#10): an unmatched path answers 404 "with the shared not-found error body (theerrors.zodenvelope), never an adapter-native error page." The same class of answer this card removes, already refused by name on this surface.Searched for the falsifier the card's prohibition names — a contract or ADR clause that exempts raw mounts from the error envelope — and found none:
getRawAppappears in exactly one ADR (docs/adr/0076, OQ#10), which scopes the hatch to framework coupling: "all remaining Hono coupling is confined to thegetRawApp()escape hatch (metadata HMR, cloud-connection/marketplace routes, static/SPA + CORS + Server-Timing), whose consumers feature-detect and degrade". Coupling, not wire shape.cloud-connection-plugin.tsRFC 8628 relay. That is an exemption from deriving the code through the shared pair, on a hand-built body that still carries the envelope — not an exemption from the envelope.getRawAppincontent/docs/orskills/: no published doc blesses the current answer.So the premise holds and the fix proceeds. This pulls behaviour back to an already-declared contract: no accept set widens, no public surface is added.
What changed
One seam, at the transport:
HonoHttpServer.installErrorEnvelopeSeam(), installed unconditionally from the constructor, renders an escaped throw through the samedeclaredEnvelopeForThrowgatewrap()opted into in #16545./raw/*and a direct-mount route now answer one shape for the same throw, theValidationError-shape-as-declaration limb included.The escape hatch is untouched: consumers still mount framework-natively, still stay outside
getMountedRoutes(), still need no adapter verb. A consumer that installs its owngetRawApp().onError(...)replaces the seam — the hatch working as designed. This is the reasoning thehttp_requests_totalseam already rests on (#9650): the transport is the one layer every inbound request converges on, whatever registered the handler, and that docblock already namesgetRawAppmounts as a first-class population.The fallback arm deliberately does not copy
wrap()'s literal"No response from handler". That sentence describes a handler that wrote nothing — a state this seam never observes — so copying it would put a false diagnosis on the wire. It answersINTERNAL_ERROR_MESSAGEinstead. Thecodeand thestatus, which are what a client branches on, agree withwrap()exactly, and both arms carry no cause in the body (#16545's pinned invariant).Hono's own declared-
Responselimb is preserved. Hono's default handler honours a thrown value carrying its ownResponse(HTTPException) before falling back totext('Internal Server Error', 500). That limb is kept verbatim: anHTTPExceptionis a framework-native refusal the producer declared, and overriding it would be this card's own defect with the roles reversed. Measured: zeroHTTPExceptionproducers anywhere inpackages/, so this preserves behaviour rather than adding any.One defect this change would otherwise have created, fixed in the same diff. The response-observation seam defaulted a rejected request's observed status to a hard-coded 500, under a comment explaining that Hono's error path always sent 500. That stops being true the moment a declared envelope is rendered, and
HttpResponseObservation.statusis contracted (http-server.ts:166) as the status "of the response as sent" — withhttp_requests_total{status}armed off that same seam, an operator would have alerted on a 500 the caller never received. It now reads the status off the same rule, and re-raises the throw untouched.Acceptance — the card's four-door matrix, re-taken
Re-measured myself rather than quoted: #16545 closed 2026-09-10 (PR #17412), so its fix is
origin/maintoday and the card's branch-relative baseline no longer exists. Both rows below are from this tree, oneHonoHttpServer, the raw pair mounted the waymarketplace-install-local-plugin.tsmounts, the wrapped pair through the ordinaryIHttpSerververb.Before, at
7d350a46(the merge base, post-#16545):The two raw doors were byte-identical — the reading that split this card out of #16545.
After:
The declared
statusandcodeare honoured, the two raw doors are no longer byte-identical, and neither is Hono's default answer any more. Every refusal assertion pinscode+status, never "it didn't 200".The ablation that makes the green evidence
The wrapped pair lives in the same fixture as the lit control, so a green proves the raw path changed rather than that the harness booted. Run from the committed state, the pre-fix
adapter.tsrestored into the worktree only:13 of 17 red on the unfixed tree. The 4 that stayed green are exactly the controls that must be green in both directions: the wrapped pair pinned byte-for-byte, the no-double-report pin, the
HTTPExceptionpreservation pin, and the "still observes 500 for a throw that declared no envelope" pin. The restore leg is proved by blob hash and an emptygit diff HEAD, not by an exit code.Gates
Derived on the final diff with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands(no paths passed — the script takes its own change set from the merge base), then reconciled with--rancarrying an exit code per line. Re-derived aftergit fetch origin main: identical floor, 62 families.check:dual-build-cjs-loads,check:lean-entry-closure,check:type-check-debt— each exits 3 and printsPREREQUISITE NOT METbecause it reads built output this worktree has none of. Each says of itself that this is neither a pass nor a failure. They need a whole-repopnpm build, which is CI's run.check-plugin-teardown-shape.mjsandcheck-registry-log-declared.mjs, plus their--self-testsiblings — all four exit 0.check:route-envelope(which carriesplugin-hono-server/src/adapter.tsin its own registry) exits 0.pnpm lint— the standing blind spot — was run in full, not narrowed:eslint . --no-inline-config, exit 0, zero findings. No narrowing argument is owed.pnpm --filter @objectstack/plugin-hono-server typecheck(three legs:tsc --noEmit, the typecheck project,check:test-typecheck) andtestboth exit 0 — 300 passed | 1 todo across 25 files.Every exit code was captured before any pipe (
cmd > log 2>&1; EXIT=$?).PM mechanical assumptions — verdicts
declaredEnvelopeForThrow.onErrorexists today — confirmed; the only.onError(hits inpackages/are unrelated observability/pubsub callbacks.getRawApp()returnsthis.app, the singleHonocreated in the constructor, andhono-plugin.ts's sixgetRawApp()call sites all read that one instance. Pinned for both mounting styles: a directgetRawApp().get(...)registration and a sub-app merged throughmount()(Hono'sroute()), because the console SPA and the plugin compose that way.status/statusCodespellings, theValidationErrorshape limb, the unregistered-code fallback, the non-ADR-0112-status fallback, and the 5xx leak withhold.Acceptance notes
use()middleware also stops answeringtext/plain. That population is outsidegetMountedRoutes()by the same contract clause as a raw mount, and it is pinned rather than left to be discovered.HTTPExceptiongets a non-envelope body (Hono's owngetResponse()), because that limb is deliberately preserved. Deriving an ADR-0112 code from anHTTPException's status would be a second rule the wrapped path does not have, so it is out of scope here. Not filed: there are zeroHTTPExceptionproducers inpackages/today, so this is un-exercised drift rather than a reproducible defect, and no queued card or PR touches it. Successor: none.packages/spec/**was read and never edited. The 25getRawApp()consumer files were not touched: this is one fix at the adapter.Landing
Draft. No governed surface in the diff (register read at this commit:
docs/adr/**·.claude/**·skills/**·AGENTS.md·CLAUDE.md).origin/mainmoved to76c9fab3while this ran; its new paths are disjoint from this diff (zero overlap, no shared package), so no merge was taken and the merge queue validates the merge commit.Generated by Claude Code
Generated by Claude Code