Skip to content

fix(hono): createHonoApp's two catch arms answer a declared ADR-0112 envelope as declared, not as 500 (#23365) - #23530

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-23365-hono-declared-envelope
Oct 14, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-23365-hono-declared-envelope

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #23365
Clause-②: yes (widening): one added export of @objectstack/plugin-hono-server, declaredEnvelopeForThrow, which was module-private

What this does

createHonoApp (@objectstack/hono) has two catch arms: the ${authMount}/* mount's and the ${prefix}/* catch-all's around HttpDispatcher.dispatch(). Both answered errorJson(c, err.message || 'Internal Server Error', err.statusCode || 500), reading statusCode alone. A throw that declared status and a registered code therefore lost both on this host and answered 500 INTERNAL_ERROR.

Both arms now go through one helper, thrownJson. It asks declaredEnvelopeForThrow, the recogniser #16545 landed for @objectstack/plugin-hono-server's direct-mount routes, and renders the envelope it returns. When the recogniser answers undefined, the arm's old line runs unchanged. No second recogniser is written. The recogniser was module-private, so it is now exported from @objectstack/plugin-hono-server (its src/index.ts already does export * from './adapter', so adapter.ts is the only file that changes there). That export is why line 2 reads Clause-②: yes, against the claim's no; the dispatch order names this exception. The seat re-grades.

Readings on the wire (real createHonoApp over the real dispatcher and protocol, in-memory SQLite)

Taken at 167cebe824 (fix) against 84672ff876 (pins only, adapter unchanged). Both are on this branch, and both sit on origin/main 869a381dc9.

Row (producer) Before After
GET / PATCH / DELETE /data/task/no_such_row (core recordNotFoundError, via the protocol) 500 INTERNAL_ERROR "Record no_such_row not found in task" 404 RECORD_NOT_FOUND
PATCH with a stale If-Match (protocol ConcurrentUpdateError) 500 INTERNAL_ERROR 409 CONCURRENT_UPDATE, row unchanged
DELETE with a stale If-Match 500 INTERNAL_ERROR 409 CONCURRENT_UPDATE, row kept
PATCH / DELETE with If-Match: "" (protocol MalformedVersionTokenError) 500 INTERNAL_ERROR 400 VALIDATION_FAILED, row untouched
The ADR-0069 gate's session read fails (AuthzStoreUnavailableError('auth_gate'), raised by enforceAuthGate above dispatch()'s try) 500 INTERNAL_ERROR 503 SERVICE_UNAVAILABLE, no cause in the body
Auth mount: the auth service's handleRequest throws AuthzStoreUnavailableError 500 INTERNAL_ERROR 503 SERVICE_UNAVAILABLE, no cause in the body
Control: a bare Error (with a cause) on either arm 500 INTERNAL_ERROR, keys code and message unchanged
Control: a throw that declares statusCode: 503 and no code (callData's shape) 503 SERVICE_UNAVAILABLE unchanged

Mechanism hypotheses, as measured

  • (a) The two arms. Confirmed on 869a381dc9. Both lines were index.ts:782 (auth) and :831 (catch-all) and read statusCode only.
  • (b) The recogniser. declaredEnvelopeForThrow reads resolveThrownHttpError. Its declaredStatus comes from .status, then .statusCode, then the 400 that a validation-shaped throw declares. It requires that status to be a key of HttpStatusErrorCodeMap, and the producer's own .code to parse as ErrorCode. It renders { success: false, error: { code, message, details?, userMessage? } }, and on 5xx it applies looksLikeInternalErrorLeak to the message. @objectstack/hono could not reach it through any existing export, so the export was added.
  • (c) Declared throws. All five measured; see the table.
  • (d) The 409 body. It does not carry currentVersion or currentRecord. The after-fix body is {"success":false,"error":{"code":"CONCURRENT_UPDATE","message":"Record task/ID was modified by another user (current version T1, expected T0)"}}. The recogniser forwards only the details that a producer declares (non-string code, issues, validation fields). ConcurrentUpdateError keeps the two fields as own properties, and only RestServer's classification arm reads them (packages/types/src/data-error-classification.ts, the CONCURRENT_UPDATE arm). Per the ruling, the recogniser is not widened here. This is reported to the seat as a finding.
  • (e) The auth-mount arm. No in-repo producer was found that reaches it with a declared envelope. better-call 1.4.0's router (the one better-auth's handler runs) turns a handler throw into a Response: an APIError becomes its own Response, and anything else becomes a bare 500. The dispatcher's handleAuthRequest catches the auth service's throw itself. The arm takes the same rendering for uniformity: one rule, not two. Its pin uses a host-supplied auth service whose handleRequest throws.

"No cause in the body"

Read as: the body of a non-envelope throw gains nothing. It gets no cause, no details and no userMessage; it carries code and message only. Each control row asserts exactly that, with a cause whose text must not appear in the body. The old fallback line is unchanged byte for byte, so a bare Error's own message is still in its 500 body, as it was before. packages/adapters/hono/src/hono.test.ts already pins that (returns error on dispatch exception), and this PR leaves that pin alone. Withholding the message of an undeclared 500 on this host would be a separate behaviour change; see the acceptance notes.

Tests

  • New: packages/qa/http-conformance/src/hono-declared-envelope.conformance.test.ts, 13 rows. Before the fix: 9 failed, 4 passed (the four controls). After: 13 passed.
  • @objectstack/http-conformance: 13 files, 137 tests passed. typecheck (with check:test-typecheck) is green, and tsc --listFiles -p tsconfig.test.json counts 13 of 13 test files, the new one included.
  • @objectstack/hono: 7 files, 154 tests passed; typecheck green.
  • @objectstack/plugin-hono-server: 29 files, 336 tests passed; typecheck green.

Ablation (one leg per arm, scripts/ablation-replace.mjs in wrap mode plus an outer trap … EXIT INT TERM restore)

In http-conformance's vitest.config.ts, the subject @objectstack/hono is aliased to packages/adapters/hono/src/index.ts, so a mutation of the source reaches the run without a build.

  • Catch-all arm reverted to the old line. The anchor hit once, x1 to x0; the blob went 3529a20128d4 to 51939c434b24. Result: 8 failed, 5 passed. All 8 catch-all declared pins went red. The controls and the two auth-arm rows stayed green.
  • Auth arm reverted to the old line. The anchor hit once; the blob went 3529a20128d4 to 4314a9ba898d. Result: 1 failed, 12 passed. Only the auth-arm declared pin went red, and its bare-Error control stayed green.
  • Restore was proven for each leg: the blob equals HEAD (3529a20128d4) and git diff HEAD is empty.

Gates

  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 66 commands at 287fd99bda. 65 exited 0. pnpm check:dual-build-cjs-loads exited 3 (PREREQUISITE NOT MET: it reads the whole tree's dist/), which counts as NOT MEASURED locally; CI runs it. --ran reports 66 derived, 65 run, 1 NOT-MEASURED, 0 UNRUN.
  • Lint, narrowed: eslint --no-inline-config over the diff's three .ts files exited 0, linting 3 files with 0 errors and 0 warnings. Positive control: a stdin file in packages/adapters/hono/src/ importing a root namespace from @objectstack/spec exits 1 (no-restricted-imports). Repo-wide pnpm lint is CI's run.

Changeset

.changeset/23365-hono-declared-envelope.md, minor for both packages. @objectstack/plugin-hono-server is minor because Clause-②: yes (an added export) takes at least minor. @objectstack/hono is minor to match #16545's level: that change applied the same rule to the direct-mount door and shipped as minor (cefe068702). The wording follows that changeset's sections.

Acceptance notes

  • On this host, an undeclared 500 still carries the thrown message verbatim, with no looksLikeInternalErrorLeak filter. It was already the case before this change, and it is unchanged here. Noted only; not filed.
  • For a declared 5xx, the recogniser keeps the message unless looksLikeInternalErrorLeak fires. The dispatcher's own exit withholds the message of every declared 5xx (commit 0783d7b). The two Hono doors agree with each other by construction, but not with that exit. Noted only.
  • main gained 989aa483fd and 0be162c75d after the base. Neither touches packages/adapters/hono, plugin-hono-server, http-conformance, the dispatcher or @objectstack/types, and git merge-tree is clean, so the branch was not merged; the merge queue rebuilds on main.

Generated by Claude Code

…envelope through #16545's recogniser (#23365)

Both arms read statusCode alone, so a throw declaring status and a registered
code (a missing record's 404, the /data version refusals' 409 and 400, an
authorization-store outage's 503) answered 500 INTERNAL_ERROR. They now ask
declaredEnvelopeForThrow, exported from @objectstack/plugin-hono-server for
this, and keep their unchanged answer for every other throw.

Claude-Session: https://claude.ai/code/session_01Q9PZ5Pzqcus4woVvTZwWNU
Co-authored-by: Claude <noreply@anthropic.com>
…ndering and its export (#23365)

Claude-Session: https://claude.ai/code/session_01Q9PZ5Pzqcus4woVvTZwWNU
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 14, 2026
@github-actions

github-actions Bot commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/releases/v17/17-4.mdx (via createHonoApp (symbol, a top-level function))
  • content/docs/releases/v17/17-5.mdx (via createHonoApp (symbol, a top-level function))

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
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 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; 97 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.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 8 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 989aa483fd769d2849784c7552211be042d256b2 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 60ddf2cb968abac15a6484ed365f15251308b4d0 — the merge of head 73e740fecec8df1c15f99703b26cbad94ce14528 into base 989aa483fd769d2849784c7552211be042d256b2, 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 60ddf2cb968abac15a6484ed365f15251308b4d0 && git checkout 60ddf2cb968abac15a6484ed365f15251308b4d0
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 989aa483fd769d2849784c7552211be042d256b2 73e740fecec8df1c15f99703b26cbad94ce14528 && git checkout -B drift-repro 989aa483fd769d2849784c7552211be042d256b2 && git merge --no-ff 73e740fecec8df1c15f99703b26cbad94ce14528

node scripts/docs-audit/affected-docs.mjs --json 989aa483fd769d2849784c7552211be042d256b2

⚠️ 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 989aa483fd769d2849784c7552211be042d256b2 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…ssified driver fault's declared 500 code included (#23365)

Claude-Session: https://claude.ai/code/session_01Q9PZ5Pzqcus4woVvTZwWNU
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

✅ ACCEPT: PR #23530 at 73e740fece (#23365). It lands after this seat's TIER_DEFAULT record and an all-green head · 2026-10-14T19:05Z

domain:cli seat 2 (#22648) · sunhaolin · session session_01Q9PZ5Pzqcus4woVvTZwWNU · holder of claim 6174741572. Report 6176061093. Thread-read: 6176061093.

Checklist, read against GitHub and origin/main, not against the report:

  • PR shape: draft, base main, title fix(hono):, assignee sunhaolin. Line 1 is Fixes #23365, the body's only closing keyword. Line 2 is Clause-②: yes (widening): one added export of @objectstack/plugin-hono-server, declaredEnvelopeForThrow.
  • Scope: 4 files, +369 / −3, inside the claim's surface, including the named exception:
    • createHonoApp's two catch arms;
    • the export keyword and a docblock section on declaredEnvelopeForThrow, with no logic change (src/index.ts already re-exports ./adapter);
    • one new hono-* conformance file;
    • one changeset.
    • check-governed-merges --pr 23530: NOT governed. Merge-tree is clean against origin/main 989aa483fd.
  • The fix, read in the diff. Both arms call one helper, thrownJson.
  • Wire readings, before at 84672ff876 and after at 167cebe824: on a real createHonoApp over the real dispatcher and protocol.
    • Before, every declared row answered 500 INTERNAL_ERROR.
    • After:
      • a missing record answers 404 RECORD_NOT_FOUND on GET, PATCH and DELETE;
      • a stale If-Match answers 409 CONCURRENT_UPDATE, with the row unchanged;
      • If-Match: "" answers 400 VALIDATION_FAILED;
      • AuthzStoreUnavailableError answers 503, on both arms;
      • a bare Error still answers 500 INTERNAL_ERROR with code and message only;
      • a statusCode-only throw keeps its status.
    • The 13-row pin file went 9 red before and 13 green after.
    • Two ablations, one per arm, each restored to HEAD:
      • the catch-all leg turns its 8 declared pins red and nothing else;
      • the auth leg turns its one declared pin red, and its control stays green.
  • Changeset prose, sentence by sentence: @objectstack/hono minor and @objectstack/plugin-hono-server minor, fix(hono):, Clause-②: yes (widening).
  • Gates:
    • Local, at 73e740fece: the dev ran 66 derived gates: 66 run, 0 unmeasured. The full suites of @objectstack/http-conformance (137 tests), @objectstack/hono (154) and @objectstack/plugin-hono-server (336) passed, with their typechecks. Lint was taken as the proven narrowing with a positive control; CI's Lint · ESLint is success.
    • Gate evidence: the check-runs pinned to 73e740fece, read at this stamp. There are 39 runs: 32 success, 6 skipped, 1 still running, and none failed.
      • Every skip is on the expected-skip roster: Dogfood Verify CLI, Temporal Conformance, Console Pin Gate, Build Docs, Dogfood Regression Gate (${{ matrix.shard }}/3), and Packed-tarball smoke.
      • Landing reads them again.
    • Earlier head: the red aggregates on 287fd99bda were sub-jobs cancelled by the next push, not measured reds.
  • Docs drift: none owed by this PR beyond hono: a 409 CONCURRENT_UPDATE rendered by #16545's recogniser (createHonoApp, the direct-mount adapter) carries no currentVersion or currentRecord, which the wire contract and objectui's conflict bar read #23531's contract gap.

Accepted deviations:

  • Clause-②: no → yes (widening). This is the claim's named exception: reusing the recogniser needed its export. This seat re-grades it, and its TIER_DEFAULT record follows as its own comment.
  • The PR body's Gates bullet cites the earlier 287fd99bda union (65 run, 1 unmeasured), and the body lacks the DATABASE_ERROR sentence. The changeset, which publishes, carries that sentence. The current union is 66/66 at 73e740fece, per the report. Left as is.
  • "No cause in the body" read as: a non-envelope throw gains nothing. It gets no cause, details or userMessage. A bare Error's own message stays, byte-identical to before. Whether that message itself should be withheld is hono: createHonoApp's catch arms ship an undeclared 500's thrown message unfiltered, so a driver diagnostic (no such table: ghost) reaches the client; looksLikeInternalErrorLeak is never asked #23532's question, not this PR's.
  • The auth-arm pin uses a host-supplied auth service that throws. No in-repo producer reaches that arm with a declared envelope (hypothesis (e)), so it takes the same rule for uniformity: one rule, not two.
  • Lint narrowing with a positive control, and two scratch probe files deleted before commit.

Out-of-scope findings, one line each:

Landing: this seat's TIER_DEFAULT record on this head, then pr_ready + automerge_enable through the relay once every check on the current head is success or a roster skip.


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: TIER_DEFAULT
Head-sha: 73e740fecec8df1c15f99703b26cbad94ce14528
Local-runs: none

The face is a Clause-②: yes declaration alone: one added export of a published package. There is no published schema (packages/spec/src/**) and no governed path in the file list (check-governed-merges --pr 23530: NOT governed). So the tier is TIER_DEFAULT, and the record is the lane seat's own. Read from the diff, the card and its thread, and the head's check-runs.

① Derived judgments

② Semver level

③ Boundary flags

Implemented-by: claude/issue-23365-hono-declared-envelope
Reviewed-by: session_01Q9PZ5Pzqcus4woVvTZwWNU

VERDICT: PASS


Generated by Claude Code

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/m tests tooling

Projects

None yet

2 participants