Skip to content

fix(plugin-hono-server): render an escaped throw's declared ADR-0112 envelope instead of a bare 500 - #17412

Merged
os-justin merged 1 commit into
mainfrom
claude/issue-16545-hono-envelope-render
Sep 10, 2026
Merged

fix(plugin-hono-server): render an escaped throw's declared ADR-0112 envelope instead of a bare 500#17412
os-justin merged 1 commit into
mainfrom
claude/issue-16545-hono-envelope-render

Conversation

@os-justin

Copy link
Copy Markdown
Collaborator

Fixes #16545

Implements item 2 of the #15999 ruling — the domain:cli shared half.

Shared half (domain:cli, hono adapter / registrar wrapper): an escaped throw carrying a declared ADR-0112 status + registered code is rendered by them, not as a bare 500 INTERNAL_ERROR "No response from handler". This changes what an escaped throw means for every direct-mount route; the PR pins that an escaped non-envelope throw still answers 500 with no cause in the body.

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 in adapter.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:

candidate is it the funnel?
packages/adapters/hono/src/index.ts (createHonoApp) No. It builds its own new Hono() and mounts only the dispatcher catch-all, discovery and the auth namespace. It carries no direct-mount route at all, and @objectstack/hono has no in-repo consumer (#4117, stated verbatim in hono-dispatcher-result-response.conformance.test.ts).
HonoHttpServer.wrap()packages/plugins/plugin-hono-server/src/adapter.ts Yes. get / post / put / delete / patch each register this.app.VERB(path, this.wrap(handler)), and IHttpServer is how service-datasource, packages/rest and the dispatcher bridge all mount.

⚠️ This is a declared breach of the claim's file surface, stated rather than taken silently. The claim named 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 on packages/plugins/plugin-hono-server/src/, packages/qa/http-conformance/src/, packages/types/ or packages/services/service-datasource/src/ — the only rows any open PR has under those trees are CHANGELOG.md and package.json, all from the standing Version Packages PR #17076. Positive controls fired (191 packages/spec/ rows, 4 packages/runtime/, 35 packages/plugins/, 14 packages/qa/); a fabricated packages/NOSUCHPKG/ returned 0.

The raw Hono mount is OUT of scope — and it is NOT repaired by this PR

Binding, from the domain:cli seat (comment 5570159125, restated in 5575064522):

raw Hono mount 在本卡范围之外,且必须在 PR 里写明,而不是默默略过。

A route mounted through getRawApp() funnels through neither wrap() 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:

/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",...}}
/wrapped/plain       -> 500  application/json   {"success":false,"error":{"code":"INTERNAL_ERROR","message":"No response from handler"}}

The two raw answers are byte-identical — which is exactly why the seat ruled that this card's own pin would be vacuous there:

本卡自带的那条 pin 在那扇门上是空的 —— 普查实测,raw mount 上「带信封的抛出」与「不带信封的抛出」今天答的是逐字节相同text/plain 500,所以「非信封抛出仍答 500 且 body 里没有 cause」这条 pin 在任何修复存在之前就已经绿了。⛔ 一条对着未修复的树就绿的 pin,正是本车道拒绝出货的东西。

The door population is larger than the one file the census named — nine getRawApp() call sites across cloud-connection, metadata, plugin-auth, plugin-approvals and 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:

  1. it declared a status, and that status is a key of HttpStatusErrorCodeMap (ADR-0112's own status list);
  2. its own code is a member of ErrorCode (StandardErrorCodeERROR_CODE_LEDGER).

The producer's details and userMessage channels are forwarded. declaredCode deliberately is not: under this gate the producer's spelling is the registered member in code, so demotedDeclaredCode returns undefined by construction.

The 5xx disclosure filter (looksLikeInternalErrorLeak, #3867 / #8086) applies from this seam's first day — this change makes wrap() 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) and packages/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/datasources with a tenancy service registered through a throwing factory, asserting 503 SERVICE_UNAVAILABLE on the wire).

Ablation — adapter.ts restored to the merge base e1eee43b, dist/ rebuilt on the ablated tree, both suites re-run:

PRE  src marker count: 3          HEAD_BLOB=a7beaf1e46a7f04482ed47e4a28375647979afe2
POST src marker count: 0          MUTATED_BLOB=f912cc49255a04ad1f6ebcba470cf6db81e548f0
ablation-dist-preflight --absent: ✓ marker absent from all 6 built files
  ABLATED RUN A (wrapper pin)      Tests  8 failed | 11 passed (19)
  ABLATED RUN B (end-to-end probe) Tests  2 failed |  3 passed  (5)
restore: whole-tree `git status --porcelain` empty; blob back to a7beaf1e; dist marker present in 2 built files

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

Verification

what result
dispatch-gates --commands derived families 62 derived · 59 exit 0 · 3 exit 3 (NOT MEASURED) · 0 UNRUN, reconciled with --ran carrying exit codes
the 3 NOT MEASURED check:dual-build-cjs-loads, check:lean-entry-closure, check:type-check-debt — each printed PREREQUISITE NOT MET and exited 3; all three need a whole-repo pnpm build closure, which is CI's run
pnpm lint exit 0 — the full repo-wide eslint . --no-inline-config, not a narrowing
@objectstack/plugin-hono-server test 24 files, 283 passed + 1 todo
@objectstack/service-datasource test 33 files, 681 passed
@objectstack/http-conformance test 6 files, 89 passed — the cross-adapter port suite
typecheck both packages exit 0; check:test-typecheck OK, 0 files / 0 errors / 0 pinned signatures

Measured at 2b995cb8.


Generated by Claude Code

…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
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/getting-started/examples.mdx (via /api/v1/packages (route, a path literal on a changed line))
  • content/docs/kernel/contracts/metadata-service.mdx (via /api/v1/packages (route, a path literal on a changed line))
  • content/docs/kernel/services-checklist.mdx (via /api/v1/packages (route, a path literal on a changed line))
  • content/docs/permissions/permission-sets.mdx (via /api/v1/packages (route, a path literal on a changed line))
  • content/docs/protocol/kernel/http-protocol.mdx (via /api/v1/packages (route, a path literal on a changed line))
  • content/docs/ui/apps.mdx (via /api/v1/packages (route, a path literal on a changed line))

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

  • content/docs/releases/v17/17-0.mdx (via /api/v1/packages (route, a path literal on a changed line))
  • content/docs/releases/v17/17-4.mdx (via /api/v1/packages (route, a path literal on a changed line))

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 572c33a2c43ea42de9b18e802d939cf68d01d24cpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 0e1784e580b099c1b4f25884b10bee31ed3a560b — the merge of head 2b995cb8dc6f3051e8d41008c7e95889c005c960 into base 572c33a2c43ea42de9b18e802d939cf68d01d24c, 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 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

⚠️ 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 572c33a2c43ea42de9b18e802d939cf68d01d24c → 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

2 participants