Skip to content

fix(plugin-hono-server,hono,cloud-connection)!: refuse an unparseable request body with 400 INVALID_REQUEST instead of reading it as {} - #24005

Merged
objectstack-fleet[bot] merged 10 commits into
mainfrom
claude/issue-23914-malformed-body-refusal
Oct 16, 2026
Merged

objectstack-fleet[bot] merged 10 commits into
mainfrom
claude/issue-23914-malformed-body-refusal

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Fixes #23914

Clause-②: no (narrowing)

What

A request body that an HTTP door cannot parse is now refused with 400 INVALID_REQUEST and the ADR-0112 envelope. It is no longer handed to the handler as {}. INVALID_REQUEST is an existing ERROR_CODE_LEDGER code; no code or ledger row is added. The message names the failure and never echoes the body.

package door what changed
@objectstack/plugin-hono-server HonoHttpServer.runHandler (every route registered through IHttpServer, plus the fallback seam): the objectstack dev / os serve door req.body is an accessor. Reading a body the adapter could not parse throws the refusal, and the adapter answers it whatever the handler made of the throw
@objectstack/hono createHonoApp's readDispatchBody, used by the ${prefix}/* catch-all and the /auth/* legacy fallback a body declared application/json that is not blank and does not parse is refused when the dispatched domain READS it, after the dispatcher's gates; a domain that forwards the request reads its own bytes and answers as before
@objectstack/cloud-connection its own Hono routes: /bind/start, /bind/poll, /install, and marketplace/install-local same rule, through a module-private readJsonRequestBody

Measured at the door

These are fresh objectstack dev --seed-admin showcase boots, base 2054a0888c against b9df5b1d3 (origin/main merged in; the same answers were read on 0660b5a16 before the merge). This door is plugin-hono-server, which is byte-identical at the current head 97d881a2e. Every body was sent from a file with curl --data-binary. The admin session came from POST /api/v1/auth/sign-in/email.

request base head
step 1, POST /data/showcase_account/query {"where": {"status": "churned"}} 200, 2 rows 200, 2 rows
step 2, the same with the closing brace missing 200, 14 of 14 rows 400 INVALID_REQUEST "The request body could not be parsed as JSON (it was sent as application/json). …"
step 3, { not json 200, 14 rows 400 INVALID_REQUEST, same message
step 4, the valid where-body sent as text/plain 200, 14 rows 400 INVALID_REQUEST "The request body was sent as text/plain, which this server does not read into a request body. …"
step 5, PATCH /data/showcase_account/ID {"name": "HACKED" 200, name unchanged, updated_at bumped 400 INVALID_REQUEST; name and updated_at unchanged
step 6, POST /data/showcase_account truncated, with name 400 VALIDATION_FAILED "Account Name is required" 400 INVALID_REQUEST, the parse refusal
CONTROL: empty body, application/json 200, 14 rows (empty query) 200, 14 rows
CONTROL: POST with no body and no Content-Type 200, 14 rows 200, 14 rows
CONTROL: valid PATCH / valid create / empty-body create 200 / 201 / 400 VALIDATION_FAILED 200 / 201 / 400 VALIDATION_FAILED
CONTROL: presign then raw PUT of a .txt file as text/plain, then complete 200 / 200 / 200 200 / 200 / 200
CONTROL: presign then raw PUT of a JSON-with-comments file as application/json, then complete 200 / 200 / 200 200 / 200 / 200
dispatcher-bridged doors (0660b5a16): PUT /meta/object/showcase_account, POST /keys, truncated (not run on base) 400 INVALID_REQUEST, the parse refusal
POST /cloud-connection/bind/poll truncated {"device_code": "dc_1" / {} (not run on base) 400 the parse refusal / 400 "device_code is required" (unchanged)
POST /marketplace/install-local truncated {"packageId": "com.example.crm" (not run on base) 400 INVALID_REQUEST, the parse refusal

On this boot the door is served by plugin-hono-server. createHonoApp (@objectstack/hono) is the embed door, and nothing in packages/cli mounts it. Both transports carried the swallow, so both change, as triage asked.

Why plugin-hono-server refuses when req.body is read, not before the handler runs

The PM's mechanism assumption 3 ("who else relies on {}") was measured, and it moved the design. One transport serves JSON doors and byte doors, and only the route knows which one it is:

  • The storage presign answers headers: { "content-type": "application/json" } for a .json file, so the platform's own upload flow PUTs a JSON-with-comments file (a tsconfig.json, say) as declared JSON that does not parse. The raw-PUT route reads req.rawBody() and stores it with 200 (measured on both boots above). objectui's UploadProvider sends Content-Type: mimeType on that PUT.
  • The dogfood suites PUT raw bytes as text/plain (attachments-authz-harness.ts, field-file-collection, and others).

Refusing before the handler would have turned both uploads into 400s. Raised on read, the refusal reaches exactly the handlers that would otherwise have acted on an invented {}, and it stops them at the read, so nothing after it runs and nothing is written. Body presence for the media types the adapter does not read is judged by the RFC 9112 framing headers (Content-Length other than 0, or Transfer-Encoding). Reading the bytes to find out would buffer an upload before storage's declared-length check refuses it. Reading Request.body cannot answer either, because @hono/node-server gives every non-GET/HEAD request a stream.

One rule on both transports: the reader refuses (seat ruling B)

createHonoApp follows the same rule. Its body is a dispatch() argument rather than a req property, so for a declared-JSON body that does not parse, readDispatchBody hands dispatch() a body value whose every read (a property, in, its keys, a spread, JSON.stringify) throws RequestBodyRefusal and records that it was read. Each arm (the ${prefix}/* catch-all and the /auth/* legacy fallback) then answers that refusal if, and only if, the body was read, whatever the domain answered. This is the same override plugin-hono-server applies after its req.body getter. There is no new export and no list of routes: whether a domain reads the body is answered by the domain reading it.

  • dispatch() itself never reads body. Its liveness short-circuit, scope resolution, the ADR-0069 auth gate and project membership read the path and headers, and body goes straight to the domain's handler. So the gates still answer first, as they do ahead of a route handler on plugin-hono-server.
  • A domain that forwards the request reads its own bytes and never touches body: /webhooks/redeliver, the inbound automation hooks, and the /approvals/act form POST. It answers exactly as at base, a malformed body included, after its own sign-in or signature check. hono-webhooks-redeliver.conformance.test.ts's not JSON row, which holds the hosted door byte-equal to the self-hosted mount, stays as pinned and is green.

b9df5b1d3 refused before dispatch() instead. That reddened exactly that row: the hosted door answered this adapter's message where the mount answers the member's "Request body must be JSON.". It also answered an anonymous caller 400 where the mount says 401. The seat's contract review ruled B.

Step 4 (text/plain): refuse, not parse

content/docs/api/wire-format.mdx documents the contract: "All requests and responses use application/json", and Content-Type: application/json is a required request header. Parsing text/plain as JSON would be a lenient second dialect. On plugin-hono-server it would also mean reading every text upload eagerly. So step 4 is refused there. createHonoApp was already content-type-blind (it parses a text/plain body as JSON when it parses), and that is unchanged. See Acceptance notes.

Which doors treat an empty body as {}, legitimately

All three keep it. A blank or whitespace-only body declared as JSON, and a request with no body whatever it declares, are still {}. The REST query handler documents the reliance ("An empty request body is not null: the HTTP adapter hands it over as {}, an empty query"). Bodiless action POSTs rely on it too (/bind/start, /automation/NAME/trigger). The empty-body controls above and in every suite pin it.

Sibling sweep (lane domain:cli)

The sweep covered .json().catch(, catch {} around a body parse, and return {} from a body reader, over packages/{cli,runtime,verify,types,rest,mcp,observability,client*,cloud-connection,create-objectstack,adapters/*,qa} plus plugin-hono-server and plugin-dev.

In scope (a door that takes a client body), changed here:

  • plugin-hono-server/src/adapter.ts runHandler: the c.req.json() / parseBody() pair.
  • adapters/hono/src/index.ts readDispatchBody: both {} fallbacks, which serve both arms.
  • cloud-connection/src/cloud-connection-plugin.ts: /bind/start, /bind/poll, /install.
  • cloud-connection/src/marketplace-install-local-plugin.ts: handleInstall.

Out, with the reason:

Not in this lane, so not changed here: the same swallow in plugin-auth, measured at the door. Truncated POST /api/v1/auth/admin/unlock-user {"userId": "usr_nobody" answers 400 "userId is required". That is reported to the seat. plugin-webhooks already refuses ("Request body must be JSON."). metadata's HMR reload reads a body the CLI's own watch loop posts.

Tests

All runs are under scripts/pm/os-verify-lock.sh, with each exit code captured before any pipe. The current head is 97d881a2e: b9df5b1d3 (which merges origin/main at adce19297) plus ruling B in @objectstack/hono, its pins, and the corrected hono changeset. plugin-hono-server and cloud-connection are byte-identical to b9df5b1d3, so their runs there stand.

  • Ruling B on 0cee7c3d9 (the code of 97d881a2e, whose last commit only rewrites the hono changeset):

    • pnpm --filter @objectstack/hono exec vitest run --maxWorkers=2: 9 files, 180 passed.
    • pnpm --filter @objectstack/http-conformance exec vitest run --maxWorkers=2: 14 files, 150 passed, including the redeliver row as pinned.
    • pnpm --filter @objectstack/hono typecheck and pnpm --filter @objectstack/http-conformance typecheck: exit 0.
  • @objectstack/http-conformance at the merge base adce19297 (a detached compare worktree, closure built there): 13 files, 144 passed. At b9df5b1d3 it was 1 failed, 143 passed: hono-webhooks-redeliver.conformance.test.ts line 273, row not JSON.

  • pnpm turbo run build --filter='!@objectstack/docs' --concurrency=2 on 97d881a2e: exit 0, 75 tasks.

  • After the merge (AGENTS.md §9/§10): pnpm install --frozen-lockfile exit 0; pnpm turbo run build --filter='!@objectstack/docs' --concurrency=2 exit 0, 75 tasks; pnpm --filter @objectstack/spec check:generated exit 0.

  • pnpm --filter @objectstack/plugin-hono-server typecheck on b9df5b1d3: exit 0, including check:test-typecheck OK. @objectstack/hono exit 0 and @objectstack/cloud-connection exit 0, each tsc --noEmit plus -p tsconfig.test.json.

  • On b9df5b1d3, pnpm --filter @objectstack/plugin-hono-server exec vitest run --maxWorkers=2: 30 files, 386 tests passed.

  • pnpm --filter @objectstack/hono exec vitest run --maxWorkers=2: 9 files, 176 passed.

  • pnpm --filter @objectstack/cloud-connection exec vitest run --maxWorkers=2: 46 files, 561 passed.

New pins:

  • plugin-hono-server/src/request-body-refusal.test.ts (20 cases):
    • each card body refused, with the envelope parsed against ApiErrorSchema / envelopeViolations, the handler never acting, and no echo;
    • the handler's own catch arm answering 500 loses to the refusal;
    • the fallback seam both ways;
    • a real socket for step 4;
    • CONTROL: valid, empty, form, valid PATCH, and three byte doors stored byte for byte.
  • adapters/hono/src/hono-body-refusal.test.ts (18 cases), over a fixture dispatcher with a READER domain (reads body inside a try whose catch answers its own 500) and a FORWARDER (reads the forwarded request):
    • the card bodies refused 400 by the reader, though it answered 500;
    • a read that escapes dispatch();
    • the /auth/* fallback, DELETE included;
    • the forwarder answering its own refusal with every byte;
    • CONTROL rows;
    • parity with HonoHttpServer.
  • packages/qa/http-conformance/src/hono-body-refusal.conformance.test.ts (6 cases), on the real dispatcher and data domain through createHonoApp:
    • steps 2, 3, 5 and 6 refused, with the store read back unchanged;
    • byte parity with a HonoHttpServer route;
    • order: an active ADR-0069 gate whose session read fails answers 503, not the body's 400;
    • CONTROL: a valid query filters, and an empty body is the empty query.
  • hono-catch-all-raw-request.test.ts gains the case that a declared-JSON body that does not parse still reaches a forwarding domain unread, byte for byte.
  • cloud-connection: four refusal cases plus an empty-body CONTROL in error-envelope.conformance.test.ts. In marketplace-install-local-install-modes.test.ts, a refusal with zero writes (no register, no sync, no import, no ledger entry), plus a CONTROL.
  • Fixture triage: hono-catch-all-raw-request.test.ts's ['invalid JSON', 'application/json', '{ not json', {}] row pinned the branch this removes, so it is replaced. The refusal moved to the new file, and the row now pins the undeclared spelling, which is unchanged.

Ablation (one per package; every suite resolves its subject from source, so no rebuild)

Each leg ran through node scripts/ablation-replace.mjs in WRAP mode, with the anchor hitting once, the write proven on disk, and the restore proven.

leg mutation blob result
plugin-hono-server the accessor's refusal branch becomes return {} a331a0f6ed81 to dee7fcca5045 10 failed: every refusal case, the fallback-reads case, socket step 4. 10 passed: every CONTROL
hono, swallow (on 0cee7c3d9) refusedOnRead(…) becomes readable({}) fb199e0ebefb to 9c5714c12488 hono: 10 failed (every reader refusal, the escaping read, /auth/*, parity), 8 passed. http-conformance: 4 failed (steps 2, 3, 5 and 6, parity), 9 passed (the order case, CONTROL, and every redeliver row)
hono, refuse before dispatch() (the b9df5b1d3 shape, on 0cee7c3d9) refusedOnRead(…) becomes an immediate throw fb199e0ebefb to 4f27b2629aad hono: 7 failed (the reader-ownership cases, /auth/*, the forwarder), 11 passed. http-conformance: 2 failed (the redeliver byte-parity row and the gate-order case), 11 passed
cloud-connection (on 2d952cae1, the file unchanged since) the ok: false parse refusal becomes { ok: true, body: {} } 61561f4f3e17 to 7c27524a66d0 4 failed: the four refusals. 41 passed, including both empty-body CONTROLs

The plugin-hono-server leg ran on 0660b5a16, and its file is byte-identical at the current head. Restore proof after each run: git diff HEAD is 0 bytes and git status --porcelain is 0 lines. Each file's git hash-object equals its HEAD: blob (a331a0f6ed81…, fb199e0ebefb…, 61561f4f3e17…), and the marker count is 0.

Gates

  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack on 97d881a2e (no paths, merge base adce19297): 66 families. All 66 ran and every one exited 0, including check:route-envelope, check:dispatcher-error-vocabulary, check:error-status-conformance, check:nul-bytes, check:published-files, check:dts-closure, check:test-source-alias, check:cross-package-test-inputs, check:adr-0087-registration, check:empty-changeset and check-changeset-no-major. --ran reconciliation with COMMAND :: exit CODE records: "66 derived, 66 run, 0 NOT-MEASURED, 0 UNRUN".
  • The derivation flags one stale input: scripts/check-route-envelope.mjs changed on main after the merge base (d132140eb, [maintainer] refactor(rest): split packages/rest/src/rest-server.ts (13,701 lines, 70 commits in 30 days) along its register*Endpoints seams, one move-only slice per PR, /meta after #22877 stage 2 #23050 slice 10). That change only moves declared counts from rest-server.ts to the new ui-routes.ts, and this diff touches neither. The branch is merge-clean against origin/main, so it is not merged (git merge-tree exit 0).
  • pnpm --filter @objectstack/http-conformance test (the suite that went red in CI's Test Core (3/6) on b9df5b1d3): base adce19297 13 files, 144 passed; head 14 files, 150 passed.
  • pnpm exec eslint --no-inline-config over the 12 changed .ts files: exit 0, no findings. The repo-wide run is CI's Lint & Repo Gates.
  • On 0660b5a16, the first sweep reddened check:route-envelope: the new cloud-connection helper built a response, so the gate wanted a PLUGIN_ROUTE_MODULES row. That script is outside this card's file surface, so the helper now answers a message and each declared route module builds its own 400 body.

Acceptance notes

  • createHonoApp reads every content type as JSON. This is pre-existing and unchanged. A JSON body sent as text/plain is parsed (step 4 is honoured there, not refused), and a non-JSON body that does not declare JSON is {}. The reader-refuses mechanism could carry that input too, but it would narrow a second input and is not this card's. Carrier: none.
  • Three copies of one message. The refusal text lives in plugin-hono-server/src/request-body.ts, adapters/hono/src/index.ts and cloud-connection/src/request-json-body.ts, each module-private. A shared export would enlarge a package's public surface, which this card's Clause-②: no (narrowing) does not declare. The hono parity case holds the two transports' answers equal.
  • @objectstack/http-conformance's NodeHttpServer keeps its catch { body = {} }. It is a private reference harness. The suites do drive a malformed body: hono-webhooks-redeliver's not JSON row and the new hono-body-refusal file, both through the two Hono doors. Neither drives one through NodeHttpServer. (The v1 sentence said no suite drives a malformed body at all, which was false.)
  • runtime/src/domains/meta.ts:1314's comment still quotes the catch-all's pre-runtime + hono: an exact /approvals/act dispatcher domain that forwards to the approvals service member, and a catch-all that stops consuming non-JSON bodies (segment 2 of ruling A on #22438) #22576 line, c.req.json().catch(() => ({})). Its subject (falsy JSON values arrive falsy) is still true.
  • Ordering. On both Hono transports the refusal comes from the body's reader, so every gate ahead of the reader answers first: the dispatcher's gates on createHonoApp, and a route's own checks on plugin-hono-server. On cloud-connection's /bind/start and /bind/poll, the body is read before the session check, as it always was. Neither refusal discloses anything.

Contract review

Clause-②: no (narrowing): the seat serves the CONTRACT_REVIEW_TIER review before the queue. Three changesets (.changeset/23914-*.md) are minor under the launch-window convention for breaking changes. Each carries the Clause-② line, an ADR-0087 marker of kind not-required (no-migration-prescription) with the other categories closed on facts, and the FROM / TO for an integrator.


Generated by Claude Code

… 400 INVALID_REQUEST instead of reading it as {}

WIP: transports only; tests follow.

Claude-Session: https://claude.ai/code/session_018t6a5rvqPRvEDMCVVW9kYT
Co-authored-by: Claude <noreply@anthropic.com>
…tes with 400 INVALID_REQUEST

Claude-Session: https://claude.ai/code/session_018t6a5rvqPRvEDMCVVW9kYT
Co-authored-by: Claude <noreply@anthropic.com>
…te doors it leaves alone

Claude-Session: https://claude.ai/code/session_018t6a5rvqPRvEDMCVVW9kYT
Co-authored-by: Claude <noreply@anthropic.com>
…zero writes on install-local

Claude-Session: https://claude.ai/code/session_018t6a5rvqPRvEDMCVVW9kYT
Co-authored-by: Claude <noreply@anthropic.com>
…here the route-envelope gate counts it

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 3 package(s): @objectstack/hono, @objectstack/cloud-connection, @objectstack/plugin-hono-server, touching 25 documentable anchor(s).

15 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/error-catalog.mdx (via INVALID_REQUEST (literal, a string literal in RequestBodyRefusal; a string literal in handleInstall; a string literal in start))
  • content/docs/api/index.mdx (via createHonoApp (symbol, a top-level function))
  • content/docs/api/metadata-api.mdx (via INVALID_REQUEST (literal, a string literal in RequestBodyRefusal; a string literal in handleInstall; a string literal in start))
  • content/docs/api/wire-format.mdx (via createHonoApp (symbol, a top-level function), /data/:object/query (route, a path literal in a comment on a changed line))
  • content/docs/automation/webhooks.mdx (via INVALID_REQUEST (literal, a string literal in RequestBodyRefusal; a string literal in handleInstall; a string literal in start))
  • content/docs/data-modeling/import-mappings.mdx (via INVALID_REQUEST (literal, a string literal in RequestBodyRefusal; a string literal in handleInstall; a string literal in start))
  • 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/permissions/authentication.mdx (via INVALID_REQUEST (literal, a string literal in RequestBodyRefusal; a string literal in handleInstall; a string literal in start))
  • content/docs/permissions/sso.mdx (via INVALID_REQUEST (literal, a string literal in RequestBodyRefusal; a string literal in handleInstall; a string literal in start))
  • content/docs/protocol/kernel/error-handling.mdx (via createHonoApp (symbol, a top-level function))
  • content/docs/protocol/objectql/query-syntax.mdx (via /data/:object/query (route, a path literal in a comment on a changed line))
  • content/docs/ui/forms.mdx (via INVALID_REQUEST (literal, a string literal in RequestBodyRefusal; a string literal in handleInstall; a string literal in start))

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

  • content/docs/releases/v17/17-0.mdx (via INVALID_REQUEST (literal, a string literal in RequestBodyRefusal; a string literal in handleInstall; a string literal in start))
  • 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), /data/:object/query (route, a path literal in a comment on a changed line))
  • content/docs/releases/v17/17-6.mdx (via INVALID_REQUEST (literal, a string literal in RequestBodyRefusal; a string literal in handleInstall; a string literal in start), /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
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 13 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 92fd343bcf986fbd8c7cbc9a3824d8096573f3d5 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 92fd343bcf986fbd8c7cbc9a3824d8096573f3d5

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Seat note: Test Core (3/6) is red on b9df5b1d30, and it is this PR's failure · domain:cli seat 2 · os-tesla · session session_018t6a5rvqPRvEDMCVVW9kYT · 2026-10-16T18:03Z


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: b9df5b1d303c13f0a00daa0a3710f3887c8c5649
Local-runs: none
Scope: packages/plugins/plugin-hono-server/src/adapter.ts, packages/plugins/plugin-hono-server/src/request-body.ts, packages/plugins/plugin-hono-server/src/request-body-refusal.test.ts, 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/cloud-connection/src/cloud-connection-plugin.ts, packages/cloud-connection/src/marketplace-install-local-plugin.ts, packages/cloud-connection/src/request-json-body.ts, packages/cloud-connection/src/error-envelope.conformance.test.ts, packages/cloud-connection/src/marketplace-install-local-install-modes.test.ts + packages/spec/src/api/error-code-ledger.zod.ts, packages/spec/src/contracts/http-server.ts, packages/runtime/src/dispatcher-plugin.ts, packages/runtime/src/domains/approvals.ts, packages/services/service-storage/src/storage-routes.ts, packages/rest/src/approvals-routes.ts, packages/rest/src/rest-server.ts, packages/plugins/plugin-approvals/src/approvals-plugin.ts, packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts, packages/triggers/trigger-api/src/plugin.ts, packages/plugins/plugin-auth/src/auth-plugin.ts, packages/metadata/src/routes/hmr-routes.ts, packages/client/src/index.ts, packages/qa/http-conformance/src/hono-webhooks-redeliver.conformance.test.ts, packages/qa/http-conformance/src/fallback-seam.conformance.test.ts, packages/qa/http-conformance/src/adapter.ts, packages/adapters/hono/vitest.config.ts, the three package.json files of the changed packages, .changeset/config.json, scripts/check-route-envelope.mjs, scripts/check-changeset-no-major.mjs, scripts/check-adr-0087-registration.mjs, content/docs/api/wire-format.mdx, .github/workflows/ci.yml, and in objectui at the pinned sha f8267df93aba: apps/console/src/services/approvalsApi.ts, packages/app-shell/src/console/cloud-connection/CloudConnectionPanel.tsx. The three .changeset/23914-*.md files are prose faces, judged in ②.

Net diff against the merge base adce19297: 14 files, 937 insertions, 41 deletions; 7 added, 7 modified. No packages/spec path and no governed path.

① Derived judgments

1. plugin-hono-server, HonoHttpServer (src/request-body.ts; adapter.ts:701-737 and :876-885). Right, with one over-claim noted in item 4.

  • Declared application/json, matched case-insensitively, not blank, does not parse: 400 INVALID_REQUEST, message "could not be parsed as JSON". Card steps 2, 3, 5 and 6. Right.
  • Declared JSON, blank or whitespace only: {}, as before (request-body.ts:145). The REST query door documents that reliance (rest-server.ts:8607). Right.
  • multipart/form-data and application/x-www-form-urlencoded: parsed as before (:156); a parse failure is refused. Right.
  • Any other media type, or none (text/plain, application/octet-stream, suffix types such as application/merge-patch+json), when the framing headers say a body is present (Content-Length other than 0, or Transfer-Encoding; declaresBody, :127-131): refused when req.body is read (:164-165). Card step 4. Right. The old code answered {} for every one of these without reading. A suffix type was already {} and is now loud; that is the right direction and inside the changeset's sentence.
  • No body, whatever the declared type: {}. Right; pinned over a real socket.
  • A route that never reads req.body is never refused. Verified: the refusal is thrown only by the body getter (adapter.ts:728-734); runHandler is the only builder of a handler request and both callers go through it (wrap at :641, the notFound seam at :1304); the use() middleware seam hands body: undefined by contract (:1487); rawBody() reads c.req.arrayBuffer() and never the getter. The storage byte doors read req.rawBody() first and reach req.body only when rawBody is absent (storage-routes.ts:1184-1189, :1546-1549), which it never is on this adapter. plugin-auth, the trigger-api inbound hooks (trigger-api/src/plugin.ts:190, c.req.text() at :94), the plugin-webhooks self-hosted mount, the approvals action page (approvals-plugin.ts:451-460) and the metadata HMR route (hmr-routes.ts:279-285) are raw-Hono mounts outside runHandler; untouched.
  • After c.req.text() on a declared-JSON body, rawBody() re-encodes from Hono's text cache. The old code called c.req.json() on the same bodies, so that is unchanged; the JSON-with-comments byte-door pin holds.

2. The transport's 400 wins (adapter.ts:876-885). Right. The getter records the refusal; once the handler settles, runHandler returns the refusal envelope with failed: false, ahead of the "a handler that wrote and then threw keeps what it wrote" rule and independent of renderDeclaredEnvelope, so the fallback seam gets it too. A handler's own catch sees an Error with status 400 and code INVALID_REQUEST; whatever it answers (its own 500, its own 200, a rethrow, a stream) is replaced. Pinned: the 500-catch door and both fallback shapes. No error log is written for it, which is right: a client's malformed body is not a handler defect. The set body setter clears a refusal; no in-repo site assigns req.body (0 hits).

3. Envelope. Right. RequestBodyRefusal carries status = 400 and code = 'INVALID_REQUEST'; INVALID_REQUEST is a ledger member (error-code-ledger.zod.ts:252) in the 400 family. declaredEnvelopeForThrow (adapter.ts:334-397) renders success: false with error.code and error.message; details and userMessage only when declared, and neither is. createHonoApp's thrownJson (hono/src/index.ts:606-608) renders through the same function. cloud-connection builds the same two-key body inline in each route module, which is where scripts/check-route-envelope.mjs:1062-1063 counts it. No new code, no ledger row, no spec edit. The messages are constants. The only client-controlled fragment is the media type, echoed only when it is an RFC token pair of at most 64 plus 64 characters (request-body.ts:110-115), never the body; the tests assert that no five-letter word of the body appears in the message.

4. The dispatcher bridge (runtime/src/dispatcher-plugin.ts). Right in effect, over-claimed in prose. The bridge reads req.body eagerly on behalf of every bridged domain: service RouteDefinitions (:303), /analytics/*, /mcp, /keys, /notifications/*, /automation/*, /actions/*, /packages/*, /security/* and authored API endpoints (:2064); 25 reads. So on this transport every bridged domain is a JSON-or-form door now, an authored endpoint that never looks at its body included. The PR's "the refusal reaches exactly the handlers that would otherwise have acted on {}" is wider than written. Nothing legitimate is lost: those domains could never read the bytes (execution.request carries no rawBody), and no in-repo caller sends such a body. The changeset's "sent to a route that reads req.body" covers it. Non-blocking.

5. createHonoApp (hono/src/index.ts:342-360, :880-883, :932-934). Two findings; the second is blocking.

  • Declared JSON, not blank, unparseable: RequestBodyRefusal is thrown before dispatch() on the catch-all (POST, PUT, PATCH) and before handleAuth() on the /auth/* legacy arm (every method but GET and HEAD). The read is from raw.clone(), so the forwarded request stays unread. Blank: {}. Not declared JSON: the content-type-blind parse, {} on failure, unchanged. Right for the two REST doors; the parity pin against HonoHttpServer holds for the card's bodies.
  • WRONG, and the red gate: the eager refusal pre-empts a forwarding domain's own refusal. /webhooks/redeliver is served by a member that reads its own bytes (webhook-outbox-plugin.ts:96-99, request.json()), and the repo pins that the hosted door (createHonoApp) and the self-hosted raw mount answer every declared refusal with equal status, code, headers and body bytes: packages/qa/http-conformance/src/hono-webhooks-redeliver.conformance.test.ts:231-275, the row 'not JSON' at :241, body {deliveryId: sent as application/json. On the merge base both doors answered 400 INVALID_REQUEST "Request body must be JSON." (webhook-outbox-plugin.ts:558). On this head the self-hosted mount still does, and the hosted door answers the transport's message ("The request body could not be parsed as JSON ..."), so the toEqual on the masked snapshots fails on the body bytes. Test Core (3/6) on this head is red; its annotation reads command (packages/qa/http-conformance) pnpm run test exited (1). The same 7-package shard was green on the PR base d132140e (run 38938076670, a timing-drift warning only). The PR's Acceptance note that "the conformance suites do not drive a malformed body through it" is false for this file. The inbound automation hooks (/automation/hooks/:flowName/:hookId; the member reads c.req.text() and verifies the signature) are the same class on the hosted door and are not pinned; the self-hosted trigger-api mount is untouched.
  • The content-type-blind parse for undeclared bodies stays (Acceptance note). For this card's scope that is right: the card's defect is silent widening, and on this door step 4 is parsed, so the filter is honoured and no row is widened. The two doors now answer step 4 differently (400 on plugin-hono-server, 200 filtered on createHonoApp). Escalated in ③.

6. cloud-connection (request-json-body.ts; cloud-connection-plugin.ts:321, :384, :554; marketplace-install-local-plugin.ts:1130). Right. c.req.json() is asked first (request-json-body.ts:52), so every input the four routes accepted is still accepted, a JSON body sent as text/plain included. On failure: declared JSON and non-blank is refused; blank, or not declared JSON, is {} as before (:55, :64). Each read sits where the old let body = {} sat: first thing on /bind/start and /bind/poll, after the session and CLOUD_UNCONFIGURED checks on /install, after admission on install-local; pinned with zero writes. The helper relies on Hono 4.13 filling the text cache from json() (:57-64); the dev measured it live at the door (/bind/poll truncated answered the parse refusal, not the "could not be read" arm), and both test fixtures encode the same behaviour. One assumption, measured once; non-blocking.

7. Legitimate callers. None found refused. packages/client: every body goes through fetch() with the default Content-Type: application/json (index.ts:7446; the scoped client's _fetch at :3619 delegates to it). The console at the pinned objectui sha f8267df93aba: approvalsApi.call (apps/console/src/services/approvalsApi.ts:191-198) and CloudConnectionPanel.getJson (packages/app-shell/src/console/cloud-connection/CloudConnectionPanel.tsx:104-107) set application/json; no sendBeacon, no XMLHttpRequest (80 files scanned). A scan of every in-repo poster at this head (packages, scripts, workflows, examples, dogfood, tests) found no JSON body sent without a content-type reference in its window. The dogfood text/plain PUTs go to the raw storage door only. A curl -d with no header is application/x-www-form-urlencoded and parses as before. packages/adapters/ holds only hono; there is no second adapter with the swallow.

8. Exports and .d.ts. Right: nothing published changes. request-body.ts is imported by adapter.ts and re-exported by nothing (plugin-hono-server/src/index.ts exports five modules, not this one); RequestBodyRefusal in @objectstack/hono is file-local; request-json-body.ts is not in cloud-connection/src/index.ts. All three exports maps carry the "." entry only, so none of the three files is addressable. IHttpRequest.body's TSDoc (packages/spec/src/contracts/http-server.ts:23-24) still reads "parsed, JSON or form data when applicable"; on this adapter it can now throw. Outside the hold-off; a docs follow-up, non-blocking.

9. The replaced fixture row (hono-catch-all-raw-request.test.ts:97-103). Right. The removed row ['invalid JSON', 'application/json', '{ not json', {}] pinned exactly the defect. Its inverse is pinned in hono-body-refusal.test.ts, same package, same layer. The new row (text/plain, { not json, {}) still agrees with the reference app's old line. Not a loosening.

② Semver level

Three minor changesets, one per package: @objectstack/plugin-hono-server, @objectstack/hono, @objectstack/cloud-connection. All three are 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). Each carries Clause-②: no (narrowing) and the adr-0087 HTML-comment marker not-required (no-migration-prescription) with the other categories closed on facts; the diff bears that out (each package publishes, no ADR-0087 id is touched, nothing exported is removed or narrowed). Check Changeset and Lint & Repo Gates are green on the head.

Sentences against the diff:

  • plugin-hono-server: the three FROM and TO bullets match readRequestBody; "no Content-Type at all" matches namedMediaType(''); the five Unchanged bullets match :145, :127-131, the byte-door pins and the fallback pin; the quoted messages are the constants word for word. Right.
  • hono: "POST, PUT or PATCH" matches :932; "the /auth/* legacy fallback that runs when no auth service is registered" matches :781-883; "GET, HEAD and DELETE are dispatched with no body" is true of the catch-all, but the /auth/* arm reads a DELETE body (:880-882), so that sentence is one method too wide for the fallback. Non-blocking wording. "The forwarded request still arrives unread, so ... read their own bytes as before" is true for undeclared bodies and false for a declared-JSON body that does not parse: such a body never reaches /webhooks/redeliver or the hooks now (① item 5). The changeset must say so once the seat decides the remedy.
  • cloud-connection: the four routes, "after its sign-in and capability checks and before anything is fetched, registered or written", the /bind/poll empty-body answer and "a body that does not declare JSON is read as before" all match the diff. Right.

③ Boundary flags

  1. createHonoApp keeps the content-type-blind parse (Acceptance note, carrier none). Accepted for this card: no silent widening on that door. Escalated to the seat: the two transports now answer step 4 differently, and content/docs/api/wire-format.mdx:11 and :594 name application/json as required; a follow-up card for the lenient dialect is owed under Prime Directive 12, not by this PR.
  2. Three module-private copies of the message (Acceptance note). Accepted; ① item 8. The hono parity case holds the two transports equal; the cloud-connection copy is asserted by regex only.
  3. @objectstack/http-conformance's NodeHttpServer keeps catch { body = {} } (Acceptance note). Accepted for the private harness. The note's second half, that the conformance suites do not drive a malformed body, is false: hono-webhooks-redeliver.conformance.test.ts:241 does, through createHonoApp, and it is the red check. Not accepted.
  4. runtime/src/domains/meta.ts:1314 comment. Accepted; a comment.
  5. Ordering (refusal before the auth gate on createHonoApp, before the session check on /bind/*). Accepted: the message is constant and discloses nothing; on /bind/* the body was read first before this PR too. One new effect deserves a sentence in the changeset: on the hosted /webhooks/redeliver door a malformed body is now refused before the anonymous check, while the self-hosted mount answers 401 first.
  6. PM assumption 3 falsified, the refusal moved to the read (deviation 1). Accepted; verified against storage-routes.ts:935 (the presign answers content-type: mimeType) and the two raw doors.
  7. plugin-auth swallow sites (out_of_scope_findings, class a): auth-plugin.ts:2735, :2870, :3423, admin-import-users.ts:493, register-sso-provider.ts:250, :444, :501, all raw-Hono c.req.json() sites outside this PR's seam. Confirmed at :2735 and :2870. Escalated to the seat for the plugin-auth lane's family card, as the dev asked.
  8. Deviations 2 to 8 (the main merge, minor not patch, AGENTS.md attribution over the harness reminder, test:repo and the CLI tier not run locally, the route-envelope refactor kept inside the surface). Accepted. The last one is why cloud-connection builds its 400 inline. The dev ran the three packages' suites and 66 gate families, and not @objectstack/http-conformance, which is the package CI reddened.
  9. Check-runs on b9df5b1d3, read at 2026-10-16T18:13Z, 39 runs: Test Core (3/6) failure, step "Run this shard's tests", first error line command (packages/qa/http-conformance) pnpm run test exited (1); Test Core (4/6) in progress at read; Test Core (1/6), (2/6), (5/6), (6/6), Build Core, Lint & Repo Gates, TypeScript Type Check, Governed Surface Queue Guard, Check Changeset, Check PR Size and every other reported context success; Console Pin Gate, Temporal Conformance, Build Docs, Packed-tarball smoke and the dogfood matrix skipped by design. The red signature is not the merge base's: the adce19297 shard runs were cancelled, and the PR base d132140e shard 3/6 was green with the same 7 packages.

Remedy, for the seat to choose, not this record: either the self-hosted plugin-webhooks mount (and the trigger-api hook mount, the same class) answers the transport's refusal with the same code and message, one wire spelling, with the conformance row re-pinned to those bytes; or createHonoApp leaves the declared-JSON parse to the domains that forward the raw request. The conformance row is a pin, not a fixture to delete.

Implemented-by: claude/issue-23914-malformed-body-refusal
Reviewed-by: session_018t6a5rvqPRvEDMCVVW9kYT

VERDICT: FAIL


Generated by Claude Code

…it, not before dispatch()

A domain that forwards the request (webhooks redeliver, inbound hooks,
approvals act) reads its own bytes and answers as before; the dispatcher's
gates run before the refusal, as on plugin-hono-server.

Claude-Session: https://claude.ai/code/session_018t6a5rvqPRvEDMCVVW9kYT
Co-authored-by: Claude <noreply@anthropic.com>
…est and the method rows stated as they are

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: 97d881a2e743bf4ab3ee4d5bf4ea7071942cc41c
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/adapter.ts, packages/plugins/plugin-hono-server/src/request-body.ts, packages/plugins/plugin-hono-server/src/request-body-refusal.test.ts, packages/cloud-connection/src/cloud-connection-plugin.ts, packages/cloud-connection/src/marketplace-install-local-plugin.ts, packages/cloud-connection/src/request-json-body.ts, packages/cloud-connection/src/error-envelope.conformance.test.ts, packages/cloud-connection/src/marketplace-install-local-install-modes.test.ts, packages/qa/http-conformance/src/hono-body-refusal.conformance.test.ts + packages/runtime/src/http-dispatcher.ts, packages/runtime/src/dispatcher-plugin.ts, packages/runtime/src/domains/auth.ts, packages/runtime/src/domains/data.ts, packages/runtime/src/domains/approvals.ts, packages/runtime/src/domains/automation-hooks.ts, packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts, packages/triggers/trigger-api/src/plugin.ts, packages/services/service-storage/src/storage-routes.ts, packages/metadata/src/routes/hmr-routes.ts, packages/client/src/index.ts, packages/spec/src/contracts/http-server.ts, packages/qa/http-conformance/src/hono-webhooks-redeliver.conformance.test.ts, packages/plugins/plugin-hono-server/src/index.ts, packages/cloud-connection/src/index.ts, the three package.json files of the changed packages, packages/services/service-mailbox/src/ingest/messages.fixture.ts, .objectui-sha, and in objectui at the pinned sha f8267df93aba: packages/core/src/adapters/ApiDataSource.ts, apps/console/src/services/approvalsApi.ts, apps/console/src/services/approvalRequestsDataSource.ts, packages/app-shell/src/console/cloud-connection/CloudConnectionPanel.tsx, packages/app-shell/src/console/connect/ConnectAgentWidget.tsx, packages/providers/src/UploadProvider.tsx. The three .changeset/23914-*.md files are prose faces, judged in ②.

Net diff against the merge base adce19297: 15 files, 1276 insertions, 51 deletions; 8 added, 7 modified. No packages/spec path and no governed path. plugin-hono-server and cloud-connection are byte-identical to b9df5b1d3; @objectstack/hono and the new http-conformance file carry ruling B. This record judges the whole diff again.

① Derived judgments

1. plugin-hono-server, HonoHttpServer (src/request-body.ts; adapter.ts:701-737, :876-885). Right.

  • Declared application/json (case-insensitive), not blank, does not parse: refused when req.body is read. Card steps 2, 3, 5, 6. Right.
  • Declared JSON, blank or whitespace only: {} (request-body.ts:145). No body at all, whatever it declares: {} (:164-166). Right; the REST query door relies on that.
  • multipart/form-data and application/x-www-form-urlencoded: parsed as before (:154-160); a parse failure is refused. Right.
  • Any other media type, or none, with a body by the framing headers (declaresBody, :127-131): refused when req.body is read. Card step 4. Right.
  • The read-time seam is exact. The refusal is thrown only by the body getter (adapter.ts:728-734). Every handler request is built by runHandler; the notFound fallback goes through it too (pinned both ways). rawBody() reads c.req.arrayBuffer() and never the getter. The storage raw doors read rawBody() first (storage-routes.ts:1184-1189, :1545-1548); pinned with three media types, JSON-with-comments as application/json included. The webhook, inbound-hook, HMR (hmr-routes.ts:279-285), approvals-page and plugin-auth routes are raw-Hono mounts outside runHandler; untouched.
  • The transport's 400 wins (adapter.ts:876-885): a handler's own 500 catch, its own 200, a rethrow are replaced. Pinned. Right.
  • One over-claim in the PR body, as on the last head: "the refusal reaches exactly the handlers that would otherwise have acted". The dispatcher bridge reads req.body eagerly for every bridged domain (dispatcher-plugin.ts:303; the same shape at :1230 to :2064). Its anonymous floor runs first (:286-293), and the dispatcher's ADR-0069 gate and membership check run after the read. The changeset's own sentence, "sent to a route that reads req.body", is exact. Non-blocking.

2. createHonoApp under ruling B (hono/src/index.ts:362-384, :395-424, :941-957, :997-1014). Right. This is the finding that failed the last head, re-derived here.

  • readDispatchBody hands each arm a DispatchBody: value and refusalRead(). For declared JSON, not blank, unparseable, value is a Proxy over a null-prototype object whose traps get, has, ownKeys, getOwnPropertyDescriptor, getPrototypeOf, set, defineProperty and deleteProperty all set read = true and throw RequestBodyRefusal (:395-424). So a property read, in, Object.keys, a spread, JSON.stringify, for...in, instanceof, hasOwnProperty, a Zod parse and a write all refuse and record. Not trapped: typeof ('object'), Array.isArray (false), truthiness, ===, ??, isExtensible. None of those yields content, so no code can act on the body without a recorded read. A spurious read is possible only by awaiting the value (a then lookup) or by util.inspect, which reads the empty target and records nothing; neither happens on the dispatch path, and the dispatcher logs no body.
  • dispatch() never reads body. It holds it in two object literals (http-dispatcher.ts:2890 liveness, :2989 domain route) and passes it positionally to the legacy branches. resolveRequestScope, enforceAuthGate and enforceProjectMembership take (context, cleanPath, method) only (:2905-2925). The foot catch answers a permission denial and rethrows everything else, with no log (:3093-3109). So the gates answer first. Pinned on the real dispatcher: an active ADR-0069 gate whose session read fails answers 503, not 400.
  • Both arms are the same shape. read is declared outside the try; after dispatch() or handleAuth() returns, refusalRead() wins over the domain's answer (:1008, :946); in the catch arm read?.refusalRead() ?? err (:1014, :957). thrownJson renders through declaredEnvelopeForThrow (:664-666), so the envelope is the declared one. Right.
  • The catch-all reads only for POST, PUT, PATCH (:997); GET, HEAD and DELETE are dispatched with undefined, as before. The /auth/* fallback reads for every method but GET and HEAD (:941) and hands those {}, as before. The fallback runs only when no auth service serves the request; with plugin-auth mounted, c.req.raw is forwarded unread to better-auth (:900-934), unchanged.
  • The forwarded request stays unread: raw.clone().text() when bodyUsed is false (:364). Forwarding domains answer exactly as at base. /approvals/act reads context.request only and refuses a consumed body (approvals.ts:130-161). The inbound hooks do the same (automation-hooks.ts:144-165; the member reads c.req.text(), trigger-api/src/plugin.ts:94). /webhooks/redeliver checks the session first and then reads request.json() through its facade (webhook-outbox-plugin.ts:99, :550-558), so the hosted door answers 401 for an anonymous caller and "Request body must be JSON." for a malformed body, as the mount does.
  • hono-webhooks-redeliver.conformance.test.ts is not in the diff (an empty git diff --stat against the base). Its not JSON row (:241) and its anonymous row (:240) stand as pinned, and Test Core (3/6) is green on this head (③). The red of b9df5b1d3 is gone.
  • No new export: RequestBodyRefusal, DispatchBody, refusedOnRead and readable are module-private; the file's export statements are the seven it had (:28, :34, :41, :50, :67, :277, :500). No route or prefix list anywhere. Right.
  • Undeclared bodies keep the content-type-blind parse and {} on failure (:371-372), pinned in the CONTROL rows. Step 4 is parsed on this door and refused on plugin-hono-server. Unchanged from the last head; escalated in ③.

3. cloud-connection (request-json-body.ts; cloud-connection-plugin.ts:321-324, :384-387, :554-557; marketplace-install-local-plugin.ts:1130-1133). Right. c.req.json() is asked first, so every input the four routes accepted is still accepted. On failure, declared JSON and not blank is refused; blank, or not declared JSON, is {} as before. Each read sits where the old let body = {} sat: first on /bind/start and /bind/poll, after the session and CLOUD_UNCONFIGURED checks on /install, after admission on install-local. Each route builds its own two-key 400 body, where check-route-envelope counts it. Pinned with zero writes on install-local.

4. Legitimate callers. None refused. The SDK sets Content-Type: application/json on every request (client/src/index.ts:7446; :4771 explicit). The console at the pin f8267df93aba: approvalsApi.call (:191-198), CloudConnectionPanel.getJson (:104-107) and ConnectAgentWidget (:144) all declare JSON. ApiDataSource sends text/plain only for a string body with no Content-Type (ApiDataSource.ts:205-219); its three console instances are GET doors with no body (approvalRequestsDataSource.ts:289, :299, :373). The UploadProvider XHR (UploadProvider.tsx:385) PUTs to the presigned raw door, which reads rawBody(). No sendBeacon in either repo. The one in-repo text/plain literal outside tests is an RFC 822 header inside a mailbox fixture message (messages.fixture.ts:24), not an HTTP header. No application/jsonl, x-ndjson or +json reader in packages. Webhook and hook senders reach raw mounts or forwarding domains.

5. Envelope. Right. RequestBodyRefusal carries status = 400 and code = 'INVALID_REQUEST', a ledger member; declaredEnvelopeForThrow renders success: false with error.code and error.message on both transports; cloud-connection builds the same two keys inline. No new code, no ledger row, no spec edit. The three copies of MALFORMED_JSON_BODY_MESSAGE and UNREADABLE_BODY_MESSAGE are identical word for word (compared). The only client-controlled fragment is the media type on plugin-hono-server, echoed only as a token pair of at most 64 plus 64 characters (request-body.ts:110-115); the tests assert no five-letter word of the body appears.

6. Published .d.ts and exports. Nothing changes. All three exports maps carry the "." entry only. plugin-hono-server/src/index.ts exports five modules, not request-body; cloud-connection/src/index.ts does not export request-json-body; @objectstack/hono exports nothing new. check:dts-closure and check:published-files are inside the green Lint and Repo Gates. IHttpRequest.body's TSDoc (spec/contracts/http-server.ts:23, "parsed, JSON or form data when applicable") still does not say the accessor can throw. Outside the hold-off; a docs follow-up, non-blocking.

7. Pins. All pin the new behaviour; none loosens an assertion.

  • request-body-refusal.test.ts: 20 cases, unchanged since b9df5b1d3; 8 refusals against ApiErrorSchema and envelopeViolations, 8 CONTROL rows including three byte doors, the fallback seam both ways, two real-socket cases.
  • hono-body-refusal.test.ts: 18 cases over a fixture dispatcher with a READER that catches and answers its own 500 and a FORWARDER that reads the forwarded request. 8 reader refusals (the catch-all, the escaping read, /auth/* with DELETE), 2 forwarder cases answering the member's own bytes with the adapter adding nothing, 6 CONTROL rows, 2 parity cases against the real HonoHttpServer source.
  • hono-body-refusal.conformance.test.ts: 6 cases on a real kernel, ObjectQL, sqlite-wasm, the real dispatcher and data domain through createHonoApp: steps 2, 3, 5, 6 refused with the store read back unchanged, parity, the gate-order case, CONTROL.
  • hono-catch-all-raw-request.test.ts: the removed row ['invalid JSON', 'application/json', '{ not json', {}] pinned exactly the defect; its inverse is pinned in the new file. The replacement row (undeclared garbage) still equals the reference line's {}. The new describe sends a declared-JSON garbage body to a fixture that holds body without reading it and asserts bodyUsed === false and the exact bytes, so the proxy is never touched and the 200 proves no refusal.
  • cloud-connection: 4 refusals plus an empty-body CONTROL in error-envelope.conformance.test.ts; a refusal with zero writes plus a CONTROL in the install-modes suite. Both fixtures model Hono's json() as the cached text() then JSON.parse; the dev measured the same at the live door.

8. Residuals, non-blocking. A domain that caught the read's throw and still wrote would be answered 400 after writing; none found (data.ts reads unguarded at :238-240, :298-302, :456, and the conformance pins prove no write on PATCH or create). A domain that read body on a detached path after answering would raise an unhandled rejection; zero detached-work sites in runtime/src/domains and http-dispatcher.ts. The real legacy auth domain never reads its _body (auth.ts:88-90), so the /auth/* arm's mechanism has no reader today; its answers do not change.

② Semver level

Three minor changesets, one per package. All three are in the lockstep fixed group, so minor is the launch-window spelling for a breaking change; each carries Clause-②: no (narrowing) and the adr-0087 HTML-comment marker not-required (no-migration-prescription) with the other categories closed on facts. The diff bears that out: each package publishes, no ADR-0087 id is touched, nothing exported is added, removed or narrowed. Check Changeset is green twice on this head (18:29Z and 19:09Z after the body edit); check:adr-0087-registration and check-changeset-no-major are inside the green Lint and Repo Gates.

Sentences against the diff:

  • hono (rewritten for ruling B): "refuses ... when the dispatched domain reads it" matches :395-424 and :1008. "POST, PUT or PATCH" matches :997. "the dispatcher's own gates still answer first" matches http-dispatcher.ts:2905-2925 and the 503 pin. "the same status and body @objectstack/plugin-hono-server answers" matches the two parity pins. "the /auth/* legacy fallback ... for every method it hands a body (all but GET and HEAD)" matches :941-942; the last head's DELETE wording is corrected. "An empty or whitespace-only JSON body is dispatched as {}" matches :371-372. "The request handed on beside the body still arrives unread, in every case" matches :364. "/webhooks/redeliver and the inbound automation hooks give their own refusal after their own sign-in or signature check, and the /approvals/act form POST reads its form" matches webhook-outbox-plugin.ts:550-558, automation-hooks.ts:144-165, approvals.ts:130-161; the last head's false forwarding sentence is gone. "The catch-all dispatches GET, HEAD and DELETE with no body, and the /auth/* fallback hands GET and HEAD {}" matches :997-999 and :942. One over-statement: the /auth/* sentence describes the arm, and the only in-repo reader on that arm reads nothing (① item 8); no integrator answer changes there. Non-blocking.
  • plugin-hono-server (unchanged): the three FROM and TO bullets match readRequestBody :140-166; "no Content-Type at all" matches namedMediaType(''); the five Unchanged bullets match :145, :127-131, the byte-door pins and the fallback pin; the quoted messages are the constants word for word. Right.
  • cloud-connection (unchanged): the four routes, "after its sign-in and capability checks and before anything is fetched, registered or written", the /bind/poll empty-body answer and "a body that does not declare JSON is read as before" all match the diff. Right.

③ Boundary flags

  1. The last record's blocking item: the eager refusal on createHonoApp pre-empting /webhooks/redeliver and reddening hono-webhooks-redeliver.conformance.test.ts:241. Answered on this head by ruling B (① item 2). The row is unchanged, the hosted door is byte-equal to the mount again on both the anonymous and not JSON rows, and Test Core (3/6) is green. Closed.
  2. The last record's item 5 ask, a changeset sentence about the hosted redeliver door refusing before the anonymous check: moot under ruling B, since the member's sign-in check now runs first. Closed.
  3. createHonoApp keeps the content-type-blind parse for undeclared bodies (PR Acceptance note, carrier none). Accepted for this card: no silent widening on that door, and step 4 is honoured there. Escalated to the seat again: the two transports answer step 4 differently (400 on plugin-hono-server, 200 filtered on createHonoApp) while content/docs/api/wire-format.mdx names application/json as required; a follow-up card is owed, not this PR.
  4. Three module-private copies of one message (Acceptance note). Accepted; the three are identical, and the two parity pins hold the Hono doors equal.
  5. @objectstack/http-conformance's NodeHttpServer keeps catch { body = {} } (Acceptance note). Accepted for the private harness. The note's second half was corrected in the rewritten PR body: the suites do drive a malformed body, through the two Hono doors, not through NodeHttpServer. The last record's objection is answered.
  6. runtime/src/domains/meta.ts:1314 comment. Accepted; a comment.
  7. Ordering (Acceptance note). Accepted. On createHonoApp the dispatcher's gates answer first, pinned. On plugin-hono-server the bridge's anonymous floor precedes its read (dispatcher-plugin.ts:286-303) and the dispatcher's ADR-0069 gate and membership check follow it (① item 1); the message is constant and discloses nothing. On /bind/* the body was read before the session check at base too.
  8. PM assumption 3 falsified, the refusal moved to the read (report deviation 1). Accepted; the presign and the two raw doors are verified.
  9. plugin-auth swallow sites (out_of_scope_findings, class a): auth-plugin.ts:2735, :2870, :3423, admin-import-users.ts:493, register-sso-provider.ts:250, :444, :501. Raw-Hono sites outside this PR's seam. Escalated to the seat for the plugin-auth lane's family card, as the dev asked.
  10. Addendum deviations: ruling B with no new export and no route list (verified); the hono changeset rewritten (verified in ②); the PR body applied by the seat, not the dev (the body on the PR is the v2 text with the ruling-B section); the ENOSPC incident and the detached compare worktree had no effect on the diff. First-report deviations 2 to 8 (the main merge, minor not patch, AGENTS.md attribution, test:repo and the CLI tier not run locally, the route-envelope refactor kept inside the surface) stand accepted; CI's Test Core now covers the repo project the dev did not run.
  11. The PR body still carries the "exactly the handlers" over-claim about plugin-hono-server (① item 1). Prose only; the changeset is exact. Non-blocking.
  12. Check-runs on 97d881a2e743bf4ab3ee4d5bf4ea7071942cc41c, read at 2026-10-16T19:17Z, 47 runs, none in progress, none failed. Success: Test Core (1/6) to (6/6) with (3/6) completed 18:56:17Z, the Test Core aggregate, Build Core, Lint and Repo Gates, TypeScript Type Check, every Lint · and Type Check · sub-job, Governed Surface Queue Guard, Check Changeset (twice), Check PR Size, Check Documentation Links, Flag docs affected by code changes, the four claim guards (twice), Auto Label, the Dogfood Regression Gate aggregate, filter. Skipped by design: the dogfood matrix shards, Dogfood Verify CLI, Temporal Conformance, Build Docs, Console Pin Gate, Packed-tarball smoke, and the 19:07Z re-runs of Auto Label and Check PR Size after the body edit. The Docs Drift Check comment is advisory and names no page this diff falsifies.

Implemented-by: claude/issue-23914-malformed-body-refusal
Reviewed-by: session_018t6a5rvqPRvEDMCVVW9kYT

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

ACCEPT · #23914 · domain:cli seat 2 · os-tesla · session session_018t6a5rvqPRvEDMCVVW9kYT · 2026-10-16T19:21Z · reviewed head 97d881a2e743bf4ab3ee4d5bf4ea7071942cc41c, against GitHub and the branch, not against the report's prose. Holder of claim 6219157652; report 6221346264 and its fix-round addendum on #23914.

  1. Shape.
    • Draft, base main, assignee os-tesla. Line 1 is Fixes #23914; Clause-②: no (narrowing) is in the body and in each changeset.
    • 15 files, +1276 / −51.
    • check-governed-merges --pr 24005: NOT governed (0 of 15 paths); 1327 changed lines, under the human-merge line.
    • git merge-tree is clean against origin/main fd0e72abc.
    • Every path is this lane's: plugin-hono-server, packages/adapters/hono, cloud-connection and packages/qa/http-conformance (the claim's sibling clause).
  2. The review chain.
    • 6221858698, at CONTRACT_REVIEW_TIER on b9df5b1d30, was FAIL. Its one blocker was the red Test Core (3/6) (seat note 6221672700): createHonoApp refused before dispatch() and pre-empted /webhooks/redeliver's own refusal.
    • The seat ruled B: on both transports the body's reader refuses it.
    • 6222965551, at CONTRACT_REVIEW_TIER on this head, re-derived the whole diff and is PASS. Its findings:
      • readDispatchBody hands dispatch() a value whose every content read throws RequestBodyRefusal and records the read, and both arms answer the refusal only when it was read;
      • dispatch() and its gates never read the body;
      • the forwarding domains read their own bytes, and the redeliver conformance row is unchanged and green;
      • there is no new export and no route list;
      • no legitimate caller is refused (the SDK, the console at the pin, raw byte doors, webhook and hook senders).
  3. Changesets: three minor (@objectstack/plugin-hono-server, @objectstack/hono, @objectstack/cloud-connection), marked BREAKING under the launch-window convention. Each carries Clause-②: no (narrowing) and an ADR-0087 not-required (no-migration-prescription) marker. The review read each sentence against the diff, including the rewritten hono one.
  4. Gates, pinned to the head. Check-runs on 97d881a2e7 read at this stamp, latest run per name: 40 names, 32 success, 8 skipped, 0 failure, nothing running.
    • The successes include Build Core, Test Core 1–6/6 (3/6 is the shard that was red), TypeScript Type Check, Lint & Repo Gates, Check Changeset and Governed Surface Queue Guard.
    • The 8 skips are roster names: Build Docs, Console Pin Gate, Dogfood Regression Gate (matrix), Dogfood Verify CLI, Packed-tarball smoke, Temporal Conformance, and the label-event re-runs of Auto Label and Check PR Size.
    • The dev's local subset: 66 of 66 derived gate commands exit 0. @objectstack/http-conformance gives 144 passed at the base, 1 failed at b9df5b1d3 and 150 passed at this head.
    • The ablations: swallowing the refusal reddens every refusal row; refusing before dispatch() (the b9df5b1d3 shape) reddens the forwarder, /auth/* and the redeliver parity row.
  5. The PR body was rewritten for this head by the seat (the dev's file, read in full before publishing). It corrects the false NodeHttpServer note.
    • One over-claim stays in its prose: "the refusal reaches exactly the handlers that would otherwise have acted on {}". On plugin-hono-server the dispatcher bridge reads req.body for every bridged domain (dispatcher-plugin.ts about :303), after the anonymous floor and before the ADR-0069 gate. No legitimate input is lost there, and the changeset's own sentence ("sent to a route that reads req.body") is exact.
  6. Accepted deviations:
    • PM assumption 3 was falsified, and the design moved with it (plugin-hono-server refuses at the read);
    • the in-lane siblings in cloud-connection were folded in under the sibling clause;
    • three module-private copies of the message, with no export;
    • minor, not patch;
    • the PR body was written by the seat, not the dev, because the dev may not edit it.

Follow-ups owed by this seat (filed in its next filing window; this window's three are #24023, #24024, #24025):

  • plugin-auth's own body-parse swallow (auth-plugin.ts:2735, :2870, :3423, admin-import-users.ts:493, register-sso-provider.ts:250, :444, :501): a family card for that lane.
  • createHonoApp still parses an undeclared body as JSON (card step 4 answers 400 on one transport and 200 filtered on the other), against wire-format.mdx's "Content-Type: application/json required".
  • IHttpRequest.body's TSDoc (packages/spec/src/contracts/http-server.ts:23) still reads "parsed"; on this adapter it can now throw (domain:spec).

Landing: pr_ready + automerge_enable through the relay, in this act. 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 16, 2026
Merged via the queue into main with commit 75ced06 Oct 16, 2026
48 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-23914-malformed-body-refusal branch October 16, 2026 19:53
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/xl tests tooling

Projects

None yet

2 participants