Skip to content

fix(hono)!: createHonoApp reads a dispatched body by plugin-hono-server's rule, so a text/plain or undeclared body is refused when a domain reads it - #24071

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-24042-hono-undeclared-body
Oct 17, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-24042-hono-undeclared-body

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #24042

Clause-②: no (narrowing)

What

createHonoApp (@objectstack/hono) now reads a dispatched body by the rule @objectstack/plugin-hono-server applies (readRequestBody, packages/plugins/plugin-hono-server/src/request-body.ts), media type by media type. Every refusal still rides the refuse-on-read value PR #24005 introduced for #23914, so a refusal is answered only when a dispatched domain reads the body, after the dispatcher's gates. One file changes in src: readDispatchBody, its docblock, and module-private copies of the twin's messages and of declaresBody beside RequestBodyRefusal. Nothing new is exported.

body (POST / PUT / PATCH on the catch-all; every method but GET / HEAD on the /auth/* fallback) base head
declares application/json (case-insensitive includes) parsed; blank is {}; unparseable is refused on read unchanged
multipart/form-data or application/x-www-form-urlencoded {} parsed from a CLONE by Hono's parseBody (the function c.req.parseBody() runs on the twin); a form that does not parse is refused on read, with the twin's form message
any other media type, or none, when the request declares a body (RFC 9112 section 6.3: Content-Length other than 0, or Transfer-Encoding) parsed as JSON when it parsed (step 4 honoured), {} otherwise refused on read, with the twin's message ("The request body was sent as text/plain, which this server does not read into a request body. ..."), naming the media type only when it is a plain token pair
no body declared {} (or parsed) {}

The JSON-sent-as-text/plain case (#23914's step 4) is the "other media type" row: refused, not parsed. content/docs/api/wire-format.mdx already names Content-Type: application/json as required (:11, :594); no sentence there becomes false, so it is untouched.

Measured at the door: the real dispatcher and data domain through createHonoApp

A temporary file in packages/qa/http-conformance (the hono-body-refusal.conformance.test.ts boot: a real LiteKernel, ObjectQL, sqlite-wasm, the real HttpDispatcher and /data domain), each body sent with Content-Length as a client sends it. The base column is the same file with the base reader put back through scripts/ablation-replace.mjs (blob a4b335ae969d to d060fe2070b0, restored, git diff HEAD empty). The file was deleted after both runs and is not in the diff.

request base reader head
step 4: POST /data/task/query {"where": {"status": "churned"}} as text/plain 200, records (parsed and honoured) 400 INVALID_REQUEST, "sent as text/plain"
POST /data/task/query hello as text/plain 200, records ({}, the empty query) 400 INVALID_REQUEST
PATCH /data/task/ID {"name":"HACKED2"} as text/plain 200, the name changed 400 INVALID_REQUEST, the row unchanged
POST /data/task/query urlencoded where=... 200, records ({}, the empty query) 400 VALIDATION_FAILED (query.where is a string)
POST /data/task urlencoded name=from_form&status=active 201, a row with no name 201, a row named from_form
PATCH /data/task/ID urlencoded name=HACKED 200, no field changed 200, the name changed

The three form rows are the parsed fields reaching the domain, which is what the self-hosted door already hands it (see Acceptance notes).

PM mechanism assumptions, measured at base b1d5569d9

  1. No first-party caller sends a JSON body without application/json, or a body with no Content-Type, to the hosted door. Holds.
    • This repo has no production createHonoApp host: every importer is a test (packages/adapters/hono, packages/qa/http-conformance), docs or a comment. packages/cli mounts none.
    • packages/client: this.fetch defaults Content-Type: application/json (index.ts:7450); the one direct fetchImpl with a body is the presigned upload, which goes to the storage surface, not through dispatch() (the dispatcher has no /storage domain).
    • A windowed scan of 307 request-body sites in 1309 files of examples/, packages/qa/dogfood, scripts/, packages/cli/src and packages/client/src: every platform poster declares JSON; the rest are GitHub API calls, flow-node configs or response shapes.
    • The console at the pin f8267df93aba (.objectui-sha): approvalsApi.call, CloudConnectionPanel.getJson, FormPage's apiFetch, useRecordApprovals's fetchJson, DatasourceResourcePage's api, plugin-chatbot's call and serverActionHandler all set application/json, and none of their body-carrying calls passes headers that would replace it. ApiDataSource sends text/plain only for a string body, and its three console instances are GET doors. No sendBeacon. UploadProvider's XHR PUTs to the presigned raw door. data-objectstack's uploadFile / uploadFiles post multipart to /api/data/OBJECT/upload, but nothing calls them at the pin and no server route serves that path.
    • NOT MEASURED: the cloud repository, the main createHonoApp host, is not in this container.
  2. Parsing a form from a clone leaves the forwarded request unread. Holds: the raw-request form row asserts bodyUsed === false at dispatch and the exact bytes on the request; a new row pins the cache path (an upstream c.req.parseBody(), then the catch-all hands the parsed form from Hono's cache).
  3. What domains do with a form body now. Measured on the data domain (table above). Every domain that reads body (data, meta, packages, actions, analytics, automation, keys, ai, notifications, security, share-links, permission-set-clone, catalog-activation, mcp) now receives a form's fields where it received {}, as it does on plugin-hono-server. The forwarders (/approvals/act, /webhooks/redeliver, the inbound automation hooks) never read body, and the legacy auth domain never reads its _body. No first-party caller posts a form to a body-reading domain (assumption 1); the one first-party form post, /approvals/act, is a forwarder.
  4. dispatch() reads no body before its gates. Holds. dispatch() holds body in the handler's argument literal only (http-dispatcher.ts :2890, :2989) and its gates take the path and headers. Measured on the real dispatcher and the real plugin-webhooks member, hosted door against the self-hosted mount over a socket, with a temporary copy of hono-webhooks-redeliver.conformance.test.ts (deleted after): anonymous text/plain garbage, anonymous application/octet-stream and anonymous no-Content-Type bodies all answer 401 UNAUTHENTICATED at both doors; signed-in text/plain and no-Content-Type garbage answer the member's 400 "Request body must be JSON." at both; a signed-in valid JSON body sent as text/plain for an unknown delivery answers 404 RESOURCE_NOT_FOUND at both. Status, headers and body bytes equal on every row.

Tests

All runs are under scripts/pm/os-verify-lock.sh on head f0c5d3bdc, each exit code captured before any pipe.

  • pnpm --filter @objectstack/hono exec vitest run --maxWorkers=2: 9 files, 207 passed.
  • pnpm --filter @objectstack/hono typecheck: exit 0 (tsc --noEmit and -p tsconfig.test.json; both edited test files are in that program by --listFiles).
  • pnpm --filter @objectstack/plugin-hono-server exec vitest run --maxWorkers=2: 30 files, 386 passed.
  • pnpm --filter @objectstack/http-conformance test: 14 files, 150 passed. hono-webhooks-redeliver.conformance.test.ts (its not JSON row included), hono-approvals-act.conformance.test.ts (the form POST redeeming a live token through createHonoApp) and hono-body-refusal.conformance.test.ts are unchanged and green: 3 files, 17 passed in a verbose run.
  • Closure first: pnpm --workspace-concurrency=2 --filter '@objectstack/hono...' --filter '@objectstack/plugin-hono-server...' --filter '@objectstack/http-conformance...' build, exit 0.

Pins:

  • hono-body-refusal.test.ts (43 cases). The parity case drives BOTH doors over one 21-row table and holds status and body equal: for a refusal the same code and message, for a read body the same value. Rows: valid, truncated, { not json, another case spelling, blank and whitespace-only JSON; a urlencoded form, a malformed urlencoded form (read leniently by Hono's parser on both doors, so both answer 200 with the same fields), a multipart form, and a multipart form that does not parse; step 4 as text/plain and as text/plain;charset=UTF-8, plain text, a body with no Content-Type, application/octet-stream, a +json suffix type, a media type that is not a token pair, and a body framed by Transfer-Encoding; and three no-body rows. New reader rows: step 4, plain text, octet-stream, no Content-Type, the /auth/* fallback and a malformed multipart form, each refused 400 though the domain answered its own 500. A new forwarder row: a text/plain garbage body reaches the forwarder, which answers its own refusal with every byte read. The CONTROL form row now expects the parsed fields.
  • hono-catch-all-raw-request.test.ts: as hono: createHonoApp still hands dispatch() {} for a non-JSON body that does not declare JSON, and parses a JSON body sent as text/plain; the two transports answer #23914's step 4 differently #24042 mandates, the rows JSON sent as text/plain (:101), invalid JSON that does not declare JSON (:106) and plain text (:109) move to the new answers, and so does a form body (:108), whose answer the form row of the rule changes. They move out of the table (which compares against the old reader line) into a new describe, each sent with Content-Length: the three are handed on as a body whose read throws status 400 / INVALID_REQUEST, a no-Content-Type row is added beside them, and the form is handed on parsed ({ token: 'def', x: '1' }); every one still asserts bodyUsed === false and the exact bytes on the forwarded request. One more row pins the form cache path. Every other row of the file is unchanged and green.

Red at base, green at head, and one ablation per row group

Each leg through node scripts/ablation-replace.mjs in WRAP mode (anchor hit once, the write proven on disk by the anchor count and the blob, the restore proven: blob equal to HEAD's a4b335ae969d, git diff HEAD empty). The suite imports ./index and resolves @objectstack/plugin-hono-server to its source (vitest.config.ts), so no rebuild sits between a mutation and its run. Run: pnpm --filter @objectstack/hono exec vitest run --maxWorkers=2 src/hono-body-refusal.test.ts src/hono-catch-all-raw-request.test.ts (63 cases).

leg mutation blob result
base readDispatchBody replaced by the base body a4b335ae969d to d060fe2070b0 25 failed (every new row), 38 passed
ablation A the "other media type" branch puts back the content-type-blind parse a4b335ae969d to 9858d8bde495 17 failed: exactly the undeclared-body rows of both files; every form row green
ablation B the form branch hands {} again a4b335ae969d to ae5e64d9423c 8 failed: exactly the form rows (parity urlencoded, malformed urlencoded, multipart, malformed multipart; the CONTROL form row; the reader's malformed-form row; the raw-request form row and its cache row)
head none a4b335ae969d 63 passed

17 plus 8 is the 25 that are red at base.

Gates

  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack on f0c5d3bdc (no paths, merge base b1d5569d9): 64 commands. In the first sweep, over the closure build, 63 exited 0 and pnpm check:dual-build-cjs-loads exited 3, PREREQUISITE NOT MET (it reads every package's dist/). After pnpm turbo run build --filter='!@objectstack/docs' --concurrency=2 (exit 0, 75 tasks) it exited 0: "110 published require entry point(s) across 68 package(s) load". Reconciliation with that reading: "64 derived, 64 run, 0 NOT-MEASURED, 0 UNRUN".
  • pnpm exec eslint --no-inline-config over the three changed .ts files: exit 0, no findings. The repo-wide run is CI's Lint & Repo Gates.
  • Control-byte scan over the four changed files: no hits.
  • @objectstack/hono's built dist/index.js (CJS, require("hono/utils/body")) and dist/index.mjs both load and export createHonoApp.

Acceptance notes

  • Form bodies now reach the domains on this door. A form posted to a domain that reads body was {} here and is its parsed fields now, as on plugin-hono-server: on the data domain a form query is 400 VALIDATION_FAILED where it read every row, a form create writes the form's fields where it wrote an empty row, and a form PATCH applies where it changed nothing (table above). The rule is the seat's; no first-party caller posts such a form. text/plain, like the two form types, is a media type a browser sends cross-origin without a preflight, and a JSON body sent that way is no longer honoured here.
  • Seat ruling B (6226688716, supersedes 6226633450). The pending .changeset/23914-hono-body-refusal.md bullet "A body that does not declare JSON is read as before: parsed when it is JSON, {} when it is not." is superseded by this PR's changeset. It is left in place because the finding: random changeset filenames collide silently across parallel agents — a round overwrote a sibling PR's minor changeset and every gate stayed green #17712 guard gates any edit of another PR's pending changeset on a person's confirmation. carrier: the release owner, at changeset version time: drop that bullet from the compiled @objectstack/hono entry.
  • Presence by framing headers. As on the twin, a body is present only when the request declares Content-Length other than 0 or Transfer-Encoding. An in-process Request built without either reads as no body on both doors, and so would a runtime that delivered a body with neither header. Carrier: none.
  • Message copies. The twin's NOT_EMPTY, namedMediaType, malformedFormMessage, unreadMediaTypeMessage and declaresBody are mirrored module-private, beside the two messages PR fix(plugin-hono-server,hono,cloud-connection)!: refuse an unparseable request body with 400 INVALID_REQUEST instead of reading it as {} #24005 mirrored; the parity table holds the two doors' answers equal for every row.
  • The /auth/* arm. The only in-repo reader on that arm, the legacy auth domain, never reads its _body, so no answer changes there today.
  • Not merged with origin/main. git merge-tree against origin/main b6805ac0f is clean; of the five incoming commits, one touches packages/runtime/src/domains (the read-only refusal on POST /packages) and none touches this reader or its tests.
  • Not run locally: test:repo (CI's Test Core).

Contract review

Clause-②: no (narrowing): the seat serves the CONTRACT_REVIEW_TIER review before the queue. One changeset, .changeset/24042-hono-undeclared-body-refusal.md, minor for @objectstack/hono under the launch-window convention for breaking changes, with the Clause-② line, the adr-0087 HTML-comment marker not-required (no-migration-prescription) with the other categories closed on facts (as #23914's carry), FROM / TO per row, and the integrator's fix: send Content-Type: application/json.


Generated by Claude Code

…r's rule, media type by media type

A JSON body sent as text/plain, and any other body that declares itself
without declaring JSON or a form, is refused with 400 INVALID_REQUEST when
the dispatched domain reads it, through the refuse-on-read value. A form is
parsed from a clone, so the forwarded request stays unread. No body is {}.

Claude-Session: https://claude.ai/code/session_018t6a5rvqPRvEDMCVVW9kYT
Co-authored-by: Claude <noreply@anthropic.com>
…e the raw-request rows the new rule answers

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

github-actions Bot commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/hono, touching 10 documentable anchor(s).

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

  • content/docs/api/client-sdk.mdx (via data.query (sdk, the route ledger binds it to POST /api/v1/data/:object/query, selected by route anchor /data/:object/query; the route ledger binds it to POST /data/:object/query))
  • content/docs/api/data-api.mdx (via /data/:object/query (route, a path literal in a comment on a changed line))
  • content/docs/api/wire-format.mdx (via /data/:object/query (route, a path literal in a comment on a changed line))
  • content/docs/data-modeling/queries.mdx (via /data/:object/query (route, a path literal in a comment on a changed line))
  • content/docs/kernel/runtime-services/data-service.mdx (via data.query (sdk, the route ledger binds it to POST /api/v1/data/:object/query, selected by route anchor /data/:object/query; the route ledger binds it to POST /data/:object/query), /data/:object/query (route, a path literal in a comment on a changed line))
  • content/docs/protocol/objectql/query-syntax.mdx (via /data/:object/query (route, a path literal in a comment on a changed line))

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

  • content/docs/releases/v17/17-5.mdx (via /data/:object/query (route, a path literal in a comment on a changed line))
  • content/docs/releases/v17/17-6.mdx (via /data/:object/query (route, a path literal in a comment 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
  • 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 — 5 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 8133cc78052c95b6efd59487b64fe78f69860759 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 8133cc78052c95b6efd59487b64fe78f69860759

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

…and the 24042 sentence that pointed at it

Claude-Session: https://claude.ai/code/session_018t6a5rvqPRvEDMCVVW9kYT
Co-authored-by: Claude <noreply@anthropic.com>
…4042 sentence that supersedes its bullet

The #17712 guard refuses an edit of a pending changeset this PR did not add.

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

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: c71a087e64086358935cae6c6d4f9f004ee85c6b
Local-runs: none
Scope: packages/adapters/hono/src/index.ts, packages/adapters/hono/src/hono-body-refusal.test.ts, packages/adapters/hono/src/hono-catch-all-raw-request.test.ts + packages/plugins/plugin-hono-server/src/request-body.ts, packages/plugins/plugin-hono-server/src/request-body-refusal.test.ts, packages/runtime/src/http-dispatcher.ts, packages/runtime/src/domains/approvals.ts, packages/runtime/src/domains/auth.ts, packages/runtime/src/domains/automation-hooks.ts, packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts, packages/qa/http-conformance/src/hono-webhooks-redeliver.conformance.test.ts, packages/qa/http-conformance/src/hono-approvals-act.conformance.test.ts, packages/adapters/hono/package.json, packages/adapters/hono/vitest.config.ts, tsup.config.ts, .changeset/config.json, .changeset/23914-hono-body-refusal.md, content/docs/api/wire-format.mdx, scripts/check-changeset-no-major.mjs, scripts/check-adr-0087-registration.mjs, scripts/check-empty-changeset.mjs, scripts/pm/check-governed-merges.mjs, and hono 4.13.12 (the lockfile's resolution for this package) dist/utils/body.js and dist/request.js. The changeset .changeset/24042-hono-undeclared-body-refusal.md is the prose face, judged in ②.

Net diff against the merge base b1d5569d9: 4 files, 396 insertions, 122 deletions; 1 added, 3 modified. The tree of c71a087e6 is the tree of f0c5d3bdc (47da01c42…), so the two intermediate commits are history only; .changeset/23914-hono-body-refusal.md is byte-identical to the merge base (an empty diff). No packages/spec path, no governed path (the seven register rows in check-governed-merges.mjs:1177-1183, none hit), head repo is the base repo, 518 changed lines. Every hold-off of the claim 6225935648 holds: nothing under plugin-hono-server, cloud-connection, plugin-auth, packages/spec, http-dispatcher.ts or runtime/src/domains is in the file list.

① Derived judgments

1. readDispatchBody is readRequestBody, branch for branch (hono/src/index.ts:374-406 against plugin-hono-server/src/request-body.ts:133-167). Right.

  • Declared JSON (includes('application/json') on the lowered header, so a charset suffix and another case spelling match): text, blank is {}, unparseable is refused with MALFORMED_JSON_BODY_MESSAGE, unreadable with UNREADABLE_BODY_MESSAGE. Unchanged from fix(plugin-hono-server,hono,cloud-connection)!: refuse an unparseable request body with 400 INVALID_REQUEST instead of reading it as {} #24005 except that the three statements now sit inside the JSON branch; the clone-or-cache choice (raw.bodyUsed ? c.req.text() : raw.clone().text()) is the one fix(plugin-hono-server,hono,cloud-connection)!: refuse an unparseable request body with 400 INVALID_REQUEST instead of reading it as {} #24005 landed.
  • A form (mediaType is the lowered header before the first ;, trimmed; multipart/form-data or application/x-www-form-urlencoded): parseBody(raw.clone()), or c.req.parseBody() when something upstream already consumed the body. These are one function: in hono 4.13.12 HonoRequest.parseBody(options) is parseBody(this, options) (dist/request.js:78-80), and parseBody takes a raw Request as well as a HonoRequest, reads Content-Type off whichever headers it has, lowercases and splits on ; exactly as the adapter does, and parseFormData reads arrayBuffer() on either (dist/utils/body.js). Default options on both doors (all: false), so a repeated name is its last value, as the raw-request form row pins. A throw out of formData() (a multipart body whose boundary does not match, or no boundary at all) is refused with malformedFormMessage(mediaType) on both doors; a malformed urlencoded body does not throw on either, so both answer 200 with the same fields (parity row, labelled as such).
  • Any other media type, or none: nothing is read; declaresBody(c) decides between the refusal (unreadMediaTypeMessage) and {}. The forwarded request is untouched on this path.

2. The mirrored prose is the twin's, word for word (index.ts:463-500 against request-body.ts:100-131). Compared: NOT_EMPTY, MALFORMED_JSON_BODY_MESSAGE, UNREADABLE_BODY_MESSAGE, namedMediaType (the same 64-plus-64 token-pair regex, with no Content-Type for the empty string, with an unrecognised Content-Type otherwise, so the only client-controlled fragment in any message stays a token pair), malformedFormMessage, unreadMediaTypeMessage, and declaresBody (Content-Length present and not blank: Number(length) !== 0; else Transfer-Encoding present). Identical. Right.

3. Every refusal rides the refuse-on-read value and nothing else moved (index.ts:408-441, :999-1016, :1055-1073, :723). Right. DispatchBody, readable, the refusedOnRead Proxy (every trap sets read and throws) and both arms are outside the diff: the catch-all reads for POST, PUT and PATCH only and dispatches GET, HEAD and DELETE with undefined; the /auth/* fallback reads for every method but GET and HEAD and hands those {}; after dispatch() or handleAuth() returns, refusalRead() wins over the domain's answer, and in the catch arm read?.refusalRead() ?? err; thrownJson renders through declaredEnvelopeForThrow, so the envelope is the declared two-key ADR-0112 one with the ledger code INVALID_REQUEST. So the three new refusals are answered only when a dispatched domain reads the body, after dispatch()'s gates: body is held in two handler-argument literals only (http-dispatcher.ts:2890, :2989), and resolveRequestScope, the ADR-0069 gate and membership run first (:2905-2925). Measured by the dev on the real plugin-webhooks member, hosted against the self-hosted mount over a socket: anonymous text/plain, octet-stream and no-Content-Type bodies answer 401 at both doors, signed-in garbage the member's own 400, equal bytes on six rows. Consistent with the code read.

4. The accept-set changes the diff implies, each named.

  • A body that declares neither JSON nor a form, when the request declares a body by its framing headers: FROM parsed as JSON when it parsed (Malformed JSON request body is silently parsed as {}: a truncated POST /data/:object/query answers 200 with the whole table, and a truncated PATCH answers 200 with an empty update #23914's step 4 honoured) and {} otherwise, TO refused when read, 400 INVALID_REQUEST. The card's two rows (:106, :109 of the base raw-request table) and step 4 (:101). Right, and the card's Done-when.
  • A +json suffix type (application/merge-patch+json) is inside that row: it was parsed as JSON at base and is refused now, as on the twin. Right; the changeset names it.
  • A form: FROM {} (the base raw-request row :108), TO its parsed fields; a form that does not parse, TO the refusal. Right: it is the twin's rule, which the card's Done-when names ("the way plugin-hono-server does") and the claim's file surface spells out ("multipart/form-data and application/x-www-form-urlencoded parsed from a clone"). What a domain then does with the fields is the domain's: measured on the data domain, a form query is 400 VALIDATION_FAILED where it read every row, a form create writes the form's fields where it wrote an empty row, a form PATCH applies where it changed nothing. No silent widening in any cell.
  • No body declared, any media type: {}. Unchanged, except the one corner in ②.
  • Declared JSON: unchanged (fix(plugin-hono-server,hono,cloud-connection)!: refuse an unparseable request body with 400 INVALID_REQUEST instead of reading it as {} #24005). GET, HEAD and DELETE on the catch-all, GET and HEAD on /auth/*: unchanged.

5. A forwarding domain still reads its own bytes. Right. Both reading branches read a clone, the third reads nothing, so c.req.raw reaches dispatch() unread in every case: pinned per moved row with bodyUsed === false and the exact bytes, and the cache path (an upstream c.req.parseBody()) pinned separately. /approvals/act reads context.request only and answers a consumed body as a logged 500 (approvals.ts:137-144); the inbound hooks the same (automation-hooks.ts:151-158); /webhooks/redeliver checks the session and then request.json() through its facade (webhook-outbox-plugin.ts:97-99, :548-558); the legacy auth domain's _body is unread by design (auth.ts:81-88), so no answer changes on the /auth/* arm today. The two conformance pins that drive these doors through the real createHonoApp are outside the diff and still describe this head: hono-webhooks-redeliver.conformance.test.ts sends every row as application/json (redeliver, :198-205), so its not JSON row (:240) is a declared-JSON case this PR leaves as #24005 left it; hono-approvals-act.conformance.test.ts:152 posts a real application/x-www-form-urlencoded form, now parsed from a clone while the member reads the unread original. The dev ran both files green (17 cases); CI's Test Core (3/6) is the gate (③).

6. Published surface. Nothing changes. index.ts exports the same seven statements (:29, :35, :42, :51, :68, :278, :559); RequestBodyRefusal, DispatchBody, readable, refusedOnRead, the messages and declaresBody are module-private; package.json is untouched (exports carries "." only). The new import hono/utils/body is hono's ./utils/* export, present on every 4.x the peer range ^4.12.8 admits, and tsup's default leaves dependencies and peerDependencies external (root tsup.config.ts, no noExternal), which is the require("hono/utils/body") the dev read in the built CJS; Build Core, TypeScript Type Check and Lint and Repo Gates (check:dts-closure, check:published-files, check:dual-build-cjs-loads inside it) are green on the head. Right.

7. Pins: every removed assertion is re-pinned with its new answer, and no pin loosens. Right.

  • hono-body-refusal.test.ts: the parity case is one 21-row table driven through BOTH doors (the hosted catch-all in front of a reader that answers a throw out of its read with its own 500; a real HonoHttpServer route, resolved to source by the vitest alias, answering req.body), with toEqual on the whole body and toBe on the status: six declared-JSON rows, four form rows, eight other-media-type rows (step 4 twice, plain text, no Content-Type built from bytes so undici stamps nothing, octet-stream, +json, a non-token-pair type, Transfer-Encoding framing), three no-body rows. The four new reader rows, the /auth/* row and the malformed-multipart row each assert the refusal though the domain answered its own 500 and that nothing was read; the new forwarder row asserts the forwarder's own bytes and answer for a text/plain garbage body; the CONTROL form row expects the parsed fields. expectRefused still asserts the message never contains the body.
  • hono-catch-all-raw-request.test.ts: the four rows that left the reference table (:101, :106, :108, :109) are each re-pinned in the new describe with the unread-request half kept (bodyUsed === false, exact bytes) and the body half moved to the new answer (readRefusal reads the handed-on value through JSON.stringify and asserts status 400, INVALID_REQUEST and the media-type message; the form row asserts { token: 'def', x: '1' }); a no-Content-Type row and the cache-path row are added. Every remaining reference row, the no Content-Type at all string row included (undici types it text/plain;charset=UTF-8, no framing header, so both the reference line and the new reader answer {}), is unchanged.
  • The explicit Content-Length on in-process rows is what the twin's own suite does (request-body-refusal.test.ts:100); the row "an in-process text/plain body with no framing headers" pins that both doors read it as no body, so the framing is declared, not hidden.
  • Red at base, green at head, and two ablations that partition the 25 red cases 17 plus 8 by row group, each restore proven by blob: reported; consistent with the diff.

8. Legitimate callers. None refused in what can be read from here. packages/client defaults Content-Type: application/json; the console at the pinned objectui sha f8267df93aba declares JSON on every body-carrying call the dev enumerated, which is the set the #24005 records (6221858698 item 7, 6222965551 item 4) verified at the same pin; data-objectstack's multipart uploadFile / uploadFiles have no caller at the pin and no server route. The cloud host is NOT MEASURED (③ item 7).

② Semver level

One changeset, @objectstack/hono: minor. The package is in the lockstep fixed group (72 packages, .changeset/config.json), so minor is the launch-window spelling for a breaking change (scripts/check-changeset-no-major.mjs, header). Clause-②: no (narrowing) is on the PR body (:3) and in the changeset (:7); the adr-0087 HTML-comment marker reads not-required (no-migration-prescription), a CATEGORIES member, with the other categories closed on facts, and the diff bears them out: the package publishes, no ADR-0087 id is named or touched, nothing exported is added, removed or narrowed. The arm is right: nothing widens and no error code or ledger row is added; what narrows is the hosted door's accept set, to the self-hosted door's. Check Changeset is green on this head (twice, 117248406802 and 117249189173); the one Check Changeset failure in this PR's history is on the superseded head 63282466e, where ruling A's edit of .changeset/23914-hono-body-refusal.md tripped the foreign-changeset rule of check-empty-changeset.mjs (a PR may not modify a changeset it did not add). Ruling B (6226688716) restored that file byte for byte, which the merge-base diff confirms; its superseded bullet (:25) is named for the release owner in the PR's Acceptance notes.

Sentences against the diff:

  • The three FROM and TO bullets match the three branches of ① item 1; the quoted text/plain and multipart messages are unreadMediaTypeMessage('text/plain') and malformedFormMessage('multipart/form-data') verbatim; "when the request declares a body (Content-Length other than 0, or Transfer-Encoding)" is declaresBody; "the last value of a repeated name" is parseBody's default; "sent with no Content-Type" is namedMediaType(''). Right.
  • "The domain acts on nothing past its read of the body, and the dispatcher's own gates still answer first": ① item 3. "Each answer is the status and body @objectstack/plugin-hono-server gives the same request": the parity table. Right.
  • The sentence "This replaces the earlier createHonoApp entry's statement that a body that does not declare JSON is read as before" is what ruling B keeps, and it is the only correction an upgrading reader of the compiled entry gets until the release owner drops the superseded bullet. Right under the ruling.
  • "The catch-all dispatches GET, HEAD and DELETE with no body, and the /auth/* fallback hands GET and HEAD {}": :1055-1057, :999-1000. Right.
  • Two over-wide sentences, both non-blocking wording: the opening "handed dispatch() {} for any body that did not parse" describes the door before Malformed JSON request body is silently parsed as {}: a truncated POST /data/:object/query answers 200 with the whole table, and a truncated PATCH answers 200 with an empty update #23914, while on this PR's own base a declared-JSON body that does not parse was already refused; the FROM column of every bullet below it is exact, and the 23914 entry it compiles beside carries that case. And "A request that declares no body is dispatched as {}, whatever its media type" has one corner: a multipart/form-data request with no body and no boundary reaches the form branch before declaresBody is consulted, formData() throws, and it is refused as a form that does not parse, on both doors; bullet three already covers it, and the twin's own changeset states the same unchanged clause.
  • No doc sentence becomes false: content/docs/api/wire-format.mdx:11 and :594 already name application/json as required. The Docs Drift Check comment lists six pages by a path literal in a comment on a changed line, not by anything the diff changed in their subject; advisory, nothing falsified.

③ Boundary flags

  1. The open question (report 6226599529): the unreleased 23914 bullet this PR makes false. Ruled: 6226633450 and then 6226688716, which replaces it and is what the head carries (② above). Closed.
  2. Report deviation 1: the form row (:108) moved beside the three mandated rows, and two rows added. Accepted: the form's answer changes under the rule the claim specified, and both halves of each moved row are kept (① item 7).
  3. Report deviation 2: in-process rows send Content-Length. Accepted; the twin's suite does the same, and the no-framing row makes the choice visible.
  4. Report deviations 4, 5 and 9 and the second report's deviations 2 to 4: temporary conformance files written and deleted (the diff holds 4 files, none under packages/qa); origin/main not merged (merge-tree clean; CI ran on the merge ref into 8133cc780, which the Docs Drift Check names); ruling A executed and then undone by a restore from the merge base rather than a force-push (the tree equality and the empty diff on that file are verified); the worktree re-created and removed. Accepted.
  5. Report deviation 7 (attribution): the four commits end with the model-free trailer pair and no model identifier appears in the PR, the changeset or a comment. Accepted.
  6. Second report's deviation 1: the dev did not PATCH the PR body and named the Acceptance-notes replacement for the seat. The PR body now carries the "Seat ruling B" bullet in that form. Closed.
  7. Report deviation 8 and PR assumption 1 "NOT MEASURED: the cloud repository": ESCALATED to the seat. The hosted door's main host lives in objectstack-ai/cloud, which is not in this container and could not be read for this record either. The narrowing reaches a cloud caller only if it posts a JSON body without application/json, or a form, to a body-reading domain of this door; every first-party caller readable from here declares JSON. The seat carries a grep of cloud's callers before the queue, or files it; this record does not block on an absent reading.
  8. Out-of-scope finding (carrier none): presence by framing headers. Accepted as the twin's rule the card mandated. Named for the seat beside item 7: on a runtime that delivers a request body with neither Content-Length nor Transfer-Encoding, an undeclared body reads as no body on both doors, which is the pre-PR {} for that framing only; not reproduced at any public door here, and whether the cloud host's runtime preserves the framing headers belongs to the same measurement.
  9. Out-of-scope finding: objectui's uploadFile / uploadFiles post multipart to a path no route serves, with no caller at the pin. Accepted as noted, not filed: dead code with zero pull.
  10. Second report's out-of-scope finding, the superseded 23914 bullet, carrier the release owner at changeset version time. Accepted per ruling B.
  11. PR Acceptance note 1 (forms now reach the domains; text/plain and the two form types need no preflight). Accepted: the self-hosted door has handed forms to the same domains all along, a cross-origin simple form POST already reached a writing domain here as {} (a write), and the domain's own validation answers the fields (measured, ① item 4). No new class of exposure; the twin's accept set is the ruling.
  12. PR Acceptance notes 4 and 5: the mirrored copies (verified word for word, ① item 2) and the /auth/* arm having no in-repo reader (auth.ts:88). Accepted.
  13. The claim's TIER_DEFAULT run with the (narrowing) face owing this review before the queue: this record is that review.
  14. Check-runs on c71a087e64086358935cae6c6d4f9f004ee85c6b, read at 2026-10-17T00:08:35Z after polling the check-runs API every three minutes from the first read, 47 runs, none in progress, none failed. Success (39): Test Core (1/6) to (6/6) with (3/6), the shard that carries @objectstack/http-conformance, run 117249006552, the last to conclude, and the Test Core aggregate 117254059311; Build Core 117249006523; Lint & Repo Gates 117249666993; TypeScript Type Check 117249850082; Governed Surface Queue Guard 117248406764; Check Changeset twice (117248406802, 117249189173); Check PR Size, Check Documentation Links, Flag docs affected by code changes, the four claim guards twice each, Auto Label, the Dogfood Regression Gate aggregate, filter, every Lint · and Type Check · sub-job. Skipped by design (8): the dogfood matrix shards, Dogfood Verify CLI, Temporal Conformance, Build Docs, Console Pin Gate, Packed-tarball smoke, and the second Auto Label and Check PR Size runs. The five required contexts are all success. The Check Changeset failure on 63282466e (job 117244722801) is the superseded head's, not this one's (② above).

Implemented-by: claude/issue-24042-hono-undeclared-body
Reviewed-by: session_018t6a5rvqPRvEDMCVVW9kYT

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 17, 2026 00:11
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 17, 2026 00:12
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

ACCEPT · #24042 · domain:cli seat 2 · os-tesla · session session_018t6a5rvqPRvEDMCVVW9kYT · 2026-10-17T00:12Z · reviewed head c71a087e64086358935cae6c6d4f9f004ee85c6b, against GitHub and the branch, not against the report's prose. Holder of claim 6225935648; reports 6226599529 and 6226838963 on #24042.

  1. Shape.
    • Draft, base main, assignee os-tesla. Line 1 is Fixes #24042; Clause-②: no (narrowing) is in the body and the changeset.
    • 4 files, +396 / −122.
    • check-governed-merges --pr 24071: NOT governed; 518 changed lines.
    • git merge-tree is clean against origin/main.
    • The head's tree equals f0c5d3bdc's. .changeset/23914-hono-body-refusal.md is byte-identical to the merge base (ruling B, 6226688716).
  2. The change, read by this seat against the claim's surface.
  3. Review of record: CONTRACT_REVIEW_TIER (the narrowing face), 6227110262 on this head: PASS, Local-runs: none, scope the three code paths plus 20 cited files, Hono's own body.js and request.js included.
  4. Changeset: one, @objectstack/hono minor (the launch-window spelling of a breaking change). It carries FROM / TO per row, the integrator's fix (send Content-Type: application/json) and the ADR-0087 marker not-required (no-migration-prescription). The review names two sentences as over-wide wording, not blocking.
  5. Gates, pinned to the head. Check-runs on c71a087e6, read by the review at 2026-10-17T00:08Z: 47 runs, 39 success, 8 skipped, none failed, none running.
    • The successes include Test Core 1–6/6 (3/6 carries @objectstack/http-conformance), Build Core, TypeScript Type Check, Lint & Repo Gates, Check Changeset and Governed Surface Queue Guard.
    • The skips are roster names.
    • The dev's local reading: 64 derived, 64 run, 0 NOT-MEASURED. @objectstack/hono 207 passed, plugin-hono-server 386, http-conformance 150.
    • Base reader: 25 of 63 rows red. Each of the two ablations reddens only its own 17 and 8 rows.
    • Door measurements on the real dispatcher, base against head: a text/plain query is 200 at base and 400 at head; a text/plain PATCH is applied at base and 400 at head with the row unchanged; forms read their fields. The webhooks member answers equally on both doors.
  6. Accepted deviations:
    • the form row moved beside the three mandated rows;
    • Content-Length on the in-process rows;
    • the temporary probe files, deleted;
    • no main merge;
    • ruling A's commit, undone by restore rather than force-push;
    • no PR-body PATCH by the dev. The seat wrote the ruling-B note.

Escalated by the review, carried by this seat:

  • Callers in objectstack-ai/cloud, not measured. The review's ③ item 7: the hosted door's main host is in objectstack-ai/cloud. That repository is not readable by this account (list_repos answers none), so a grep of its callers is NOT MEASURED. Every first-party caller readable here declares JSON. The carrier: is the cloud .objectstack-sha bump that the next tag opens, which reads this BREAKING changeset and its fix.
  • Bodies with no framing headers. ③ item 8: a runtime that delivers a body with neither Content-Length nor Transfer-Encoding reads it as no body on both doors. Not reproduced at any public door; same carrier.

Landing: pr_ready + automerge_enable went through the relay at 2026-10-17T00:12Z, before this record, because the record's first post was refused on a stamp and the landing stroke was not held for it. This record is the ACCEPT that landing rests on, posted in the same window. The card closes on the merge (Fixes).


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 17, 2026
Merged via the queue into main with commit 76d9dca Oct 17, 2026
48 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-24042-hono-undeclared-body branch October 17, 2026 00:30
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