Skip to content

fix(hono): answer a raw-mount escaped throw with the declared ADR-0112 envelope - #17643

Merged
os-sales merged 2 commits into
mainfrom
claude/issue-17411-raw-mount-declared-envelope
Sep 11, 2026
Merged

fix(hono): answer a raw-mount escaped throw with the declared ADR-0112 envelope#17643
os-sales merged 2 commits into
mainfrom
claude/issue-17411-raw-mount-declared-envelope

Conversation

@os-sales

@os-sales os-sales commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

Fixes #17411

Clause-②: no

Declared by the dispatching domain:cli PM seat (session session_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-201 requires 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 is INTERNAL_ERROR, already on the published error-code ledger; no new code is introduced, which would be yes unconditionally.

A route mounted through IHttpServer.getRawApp() funnels through neither the Hono adapter's wrap() 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, no success, no code, and the thrown value's own declared status / code discarded.

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 in packages/spec/src/contracts/http-server.ts:

  • :290-295 — the getMountedRoutes() contract: raw-handle mounts "are outside this table by construction, and so are use middleware and the setFallbackHandler seam: this answers 'what routes did I register', not 'what paths might respond'." Scoped to route introspection, in the contract's own words.
  • :344-359 — the getRawApp() docblock: "THE deliberate framework-specific escape hatch on this otherwise framework-agnostic contract", declared because four consumers were each declaring it locally, with any as 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 (the errors.zod envelope), 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:

  • getRawApp appears in exactly one ADR (docs/adr/0076, OQ#10), which scopes the hatch to framework coupling: "all remaining Hono coupling is confined to the getRawApp() escape hatch (metadata HMR, cloud-connection/marketplace routes, static/SPA + CORS + Server-Timing), whose consumers feature-detect and degrade". Coupling, not wire shape.
  • ADR-0112's only declared non-door emission is the cloud-connection-plugin.ts RFC 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.
  • Zero hits for getRawApp in content/docs/ or skills/: 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 same declaredEnvelopeForThrow gate wrap() opted into in #16545. /raw/* and a direct-mount route now answer one shape for the same throw, the ValidationError-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 own getRawApp().onError(...) replaces the seam — the hatch working as designed. This is the reasoning the http_requests_total seam already rests on (#9650): the transport is the one layer every inbound request converges on, whatever registered the handler, and that docblock already names getRawApp mounts 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 answers INTERNAL_ERROR_MESSAGE instead. The code and the status, which are what a client branches on, agree with wrap() exactly, and both arms carry no cause in the body (#16545's pinned invariant).

Hono's own declared-Response limb is preserved. Hono's default handler honours a thrown value carrying its own Response (HTTPException) before falling back to text('Internal Server Error', 500). That limb is kept verbatim: an HTTPException is a framework-native refusal the producer declared, and overriding it would be this card's own defect with the roles reversed. Measured: zero HTTPException producers anywhere in packages/, 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.status is contracted (http-server.ts:166) as the status "of the response as sent" — with http_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/main today and the card's branch-relative baseline no longer exists. Both rows below are from this tree, one HonoHttpServer, the raw pair mounted the way marketplace-install-local-plugin.ts mounts, the wrapped pair through the ordinary IHttpServer verb.

Before, at 7d350a46 (the merge base, post-#16545):

/raw/envelope       500  text/plain; charset=UTF-8   Internal Server Error
/raw/plain          500  text/plain; charset=UTF-8   Internal Server Error
/wrapped/envelope   503  application/json  {"success":false,"error":{"code":"SERVICE_UNAVAILABLE","message":"The authorization store could not be read."}}
/wrapped/plain      500  application/json  {"success":false,"error":{"code":"INTERNAL_ERROR","message":"No response from handler"}}

The two raw doors were byte-identical — the reading that split this card out of #16545.

After:

/raw/envelope       503  application/json  {"success":false,"error":{"code":"SERVICE_UNAVAILABLE","message":"The authorization store could not be read."}}
/raw/plain          500  application/json  {"success":false,"error":{"code":"INTERNAL_ERROR","message":"Internal server error"}}
/wrapped/envelope   503  application/json  (unchanged)
/wrapped/plain      500  application/json  (unchanged, byte-for-byte)

The declared status and code are honoured, the two raw doors are no longer byte-identical, and neither is Hono's default answer any more. Every refusal assertion pins code + 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.ts restored into the worktree only:

marker counts BEFORE : installErrorEnvelopeSeam 3 · onError( 2 · reportTransportEscape 2 · blob 72e3321c
marker counts AFTER  : installErrorEnvelopeSeam 0 · onError( 0 · reportTransportEscape 0 · blob a7beaf1e
ABLATED_PIN_EXIT=1 — Test Files 1 failed (1) · Tests 13 failed | 4 passed (17)
RESTORE OK blob=72e3321c (git diff HEAD empty)

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 HTTPException preservation pin, and the "still observes 500 for a throw that declared no envelope" pin. The restore leg is proved by blob hash and an empty git 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 --ran carrying an exit code per line. Re-derived after git fetch origin main: identical floor, 62 families.

  • 62 derived · 59 run green · 3 NOT MEASURED · 0 UNRUN.
  • The 3 are check:dual-build-cjs-loads, check:lean-entry-closure, check:type-check-debt — each exits 3 and prints PREREQUISITE NOT MET because 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-repo pnpm build, which is CI's run.
  • Both gates flagged as touching this package's shape were read by their hit lines, not a count: check-plugin-teardown-shape.mjs and check-registry-log-declared.mjs, plus their --self-test siblings — all four exit 0.
  • check:route-envelope (which carries plugin-hono-server/src/adapter.ts in 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) and test both 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

  1. A throw-to-envelope mapper exists to reuse — confirmed, declaredEnvelopeForThrow. ⚠️ Partially falsified in its warning: no export and no move were needed. The new seam is a private method of the same module, so the module-private function is reachable directly. The rule has exactly one definition, still unexported.
  2. No Hono onError exists today — confirmed; the only .onError( hits in packages/ are unrelated observability/pubsub callbacks.
  3. One raw app instance, so one seam covers all consumers — confirmed structurally and by test. getRawApp() returns this.app, the single Hono created in the constructor, and hono-plugin.ts's six getRawApp() call sites all read that one instance. Pinned for both mounting styles: a direct getRawApp().get(...) registration and a sub-app merged through mount() (Hono's route()), because the console SPA and the plugin compose that way.
  4. Whatever is built must agree with the wrapped path — honoured by construction: the seam calls the same function, so there is no second ladder. Pinned by twin assertions on the same server for the status/statusCode spellings, the ValidationError shape limb, the unregistered-code fallback, the non-ADR-0112-status fallback, and the 5xx leak withhold.

Acceptance notes

  • Recorded blast radius, not a carve-out: the seam is the transport's, so a throw escaping a use() middleware also stops answering text/plain. That population is outside getMountedRoutes() by the same contract clause as a raw mount, and it is pinned rather than left to be discovered.
  • Noted, not filed — a future raw consumer that throws a Hono-native HTTPException gets a non-envelope body (Hono's own getResponse()), because that limb is deliberately preserved. Deriving an ADR-0112 code from an HTTPException's status would be a second rule the wrapped path does not have, so it is out of scope here. Not filed: there are zero HTTPException producers in packages/ today, so this is un-exercised drift rather than a reproducible defect, and no queued card or PR touches it. Successor: none.
  • Noted, not filed — an observer still cannot see the response body for a request that ended in a throw, only its status. Pre-existing, unchanged by this diff, and no contract claims otherwise.
  • packages/spec/** was read and never edited. The 25 getRawApp() 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/main moved to 76c9fab3 while 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

…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>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 11, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-hono-server, touching 6 documentable anchor(s).

4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx (via INTERNAL_ERROR (literal, a string literal in installErrorEnvelopeSeam))
  • content/docs/api/error-catalog.mdx (via INTERNAL_ERROR (literal, a string literal in installErrorEnvelopeSeam))
  • content/docs/plugins/development.mdx (via INTERNAL_ERROR (literal, a string literal in installErrorEnvelopeSeam))
  • content/docs/protocol/kernel/error-handling.mdx (via INTERNAL_ERROR (literal, a string literal in installErrorEnvelopeSeam))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-0.mdx (via INTERNAL_ERROR (literal, a string literal in installErrorEnvelopeSeam))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 7 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 76c9fab30ca406b7b1f06b8ca3db286af9f2bf8bpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 75b97146a55877c32cd4310396fbf3cea14c8e67 — the merge of head 7b3c366ebe210036ca328c0159b684cde24078a1 into base 76c9fab30ca406b7b1f06b8ca3db286af9f2bf8b, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 76c9fab30ca406b7b1f06b8ca3db286af9f2bf8b → pass the list as
args.docs, on the commit named under Which tree this was computed on.

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

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

raw Hono mounts (getRawApp()) answer an escaped throw as 500 text/plain "Internal Server Error" — no ADR-0112 envelope, declared status/code discarded

2 participants