Skip to content

fix(plugin-hono-server, hono)!: a request body over the server's limit is answered 413 PAYLOAD_TOO_LARGE and is not read past the limit - #33172

Merged
objectstack-fleet[bot] merged 10 commits into
mainfrom
claude/project-thread-qv9odd
Nov 14, 2026
Merged

objectstack-fleet[bot] merged 10 commits into
mainfrom
claude/project-thread-qv9odd

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 13, 2026 •

Copy link
Copy Markdown
Contributor

Requested by Tesla · project thread

Clause-②: yes (narrowing) — a JSON, form or other body over 10 MiB that both HTTP doors read today is answered 413 PAYLOAD_TOO_LARGE, unread past the limit, unless an operator raises the limit. The published surface also widens: HonoPluginOptions.maxRequestBodyBytes, createHonoApp's maxRequestBodyBytes option, HonoHttpServer.setMaxRequestBodyBytes() / getMaxRequestBodyBytes(), the OS_HTTP_MAX_BODY_BYTES environment variable, and the body-limit exports of @objectstack/plugin-hono-server.
Metadata-protocol: none
Skills-docs: follow-up owed — content/docs/api/error-catalog.mdx (the VALIDATION_ERROR callout) and content/docs/protocol/kernel/error-handling.mdx (the 413 row) list the causes of 413 PAYLOAD_TOO_LARGE and do not yet name a request body over the server's limit, nor maxRequestBodyBytes / OS_HTTP_MAX_BODY_BYTES; no file under skills/** teaches a request-body limit.

Refs #32004

维护者速读

  • 改了什么(BREAKING,收窄):两个 HTTP 入口(自托管的 plugin-hono-server 和 createHonoApp)现在共用同一个请求体上限,默认 10 MiB。超过上限的请求回 413 PAYLOAD_TOO_LARGE:声明了长度的不读,没声明长度的读到上限就停。运维可以用 maxRequestBodyBytes 或 OS_HTTP_MAX_BODY_BYTES 调整。
  • 为什么改:按你 11-13 在 security#2 席位线程里选的方案 A,平台默认一个可配置的上限,上传入口保留各自更大的上限。
  • 风险与回滚:patch 级,不改元数据协议,但属于收窄(BREAKING):超过 10 MiB 的 JSON 或表单请求以前能读进来,现在回 413,运维可调大上限。三个存储上传入口(预签名直传、分片、上传链接)仍按 max_upload_mb 判断;上传链接的多文件表单按它自己的表单上限判断。packages/spec 只在错误码台账里补了一行来源记录。回滚就是还原本 PR。
  • 先看:packages/plugins/plugin-hono-server/src/body-limit.ts(守卫本体,重点看 guardRequest 和 installRequestBodyLimit 的 413 覆盖)、request-body.ts 里 readRequestBody 的新开头、adapter.ts 里 rawBody 改为读原始请求体、storage-routes.ts 里 uploadLinkFormBody。
  • 两分钟验证:pnpm --filter @objectstack/hono exec vitest run src/hono-body-refusal.test.ts,预期 63 个用例全部通过。
  • 需要你做的:无,席位落地。

What changes

The rule. Both Hono doors hold one request-body limit, 10 MiB unless an operator sets another. A request whose declared Content-Length is over the limit is not read. A body with no declared length is counted while it is read, and reading stops once it passes the limit. Either way, the request is answered 413 with the registered PAYLOAD_TOO_LARGE code, whatever the reader answered. Exactly the limit is read as before. A route that never reads the body reads nothing.

  • plugin-hono-server/src/body-limit.ts (new): the guard, installed as the first middleware on each door's app. It wraps the request's body, so every reader goes through it: the doors' own JSON and form pre-reads, raw-app routes that read c.req, and requests forwarded whole to the auth service or a domain. It also holds the option resolution, the RequestBodyTooLarge refusal and the 413 envelope. 413 is not in HttpStatusErrorCodeMap, so both doors render this envelope themselves rather than through declaredEnvelopeForThrow.
  • request-body.ts: a declared length over the limit makes req.body refuse with 413 on read, on the same read-time path as the existing 400 refusals. A JSON or form body with no declared length is read up to the limit and kept. If it passes the limit and no byte door goes on to read it whole, its source is cancelled at the end of the request, as a read refused through the guard is.
  • adapter.ts:
    • rawBody() reads the original body, so a byte door keeps its own bound. It is repeatable: a second call answers the same bytes as the first.
    • The constructor installs the guard.
    • setMaxRequestBodyBytes / getMaxRequestBodyBytes let a host change the limit.
    • The error seam answers a refused read as 413 without reporting a transport escape.
    • A route that was refused req.body for size and then reads rawBody() takes the read over under its own bound, so that refusal no longer stands as the answer. Every other refusal still does.
  • hono-plugin.ts: HonoPluginOptions.maxRequestBodyBytes.
  • adapters/hono/src/index.ts:
    • createHonoApp installs the same guard first and takes maxRequestBodyBytes.
    • readDispatchBody refuses an over-limit body as too large.
    • thrownJson renders the 413.
  • The option: maxRequestBodyBytes (bytes) on the plugin, the adapter and createHonoApp; else OS_HTTP_MAX_BODY_BYTES; else 10 MiB. A value that is not a positive whole number of bytes throws where it is configured. The retired stack key server.bodyLimit stays retired; no packages/spec key is added.
  • service-storage/src/storage-routes.ts: the upload link's several-file form is bounded by that door's own form limit (each file within max_upload_mb, times the files the link takes, plus 64 KiB of framing), not by the request-body limit. When the transport refuses req.body for size, the door reads the form through rawBody(), judges the bytes against its own limit and parses the form itself. A declared length over the door's limit is still refused before anything is read. The one-file body on the same door is unchanged.
  • packages/spec/src/api/error-code-ledger.zod.ts: one provenance row, @objectstack/plugin-hono-server → PAYLOAD_TOO_LARGE, already a registered code (check:error-code-provenance). Nothing else in packages/spec.
  • scripts/check-route-envelope.mjs: body-limit.ts declared as a conformant response module.

Tests

  • adapters/hono/src/hono-body-refusal.test.ts, describe "a request body over the transport limit is answered 413 and not read past the limit, on both doors". The client body is a stream that counts what the server pulled. Twenty cases:
    • Five readers, each in two framings (10 cases). The readers are: a hosted domain reading the parsed body, a hosted domain reading the forwarded request, the hosted auth fallback, a self-hosted req.body route and a self-hosted raw-app route. With a declared length over the limit each answers 413 with 0 bytes pulled. With no declared length each answers 413 with at most the limit plus two chunks pulled.
    • Exactly the limit, declared and not, is read as before on both doors.
    • An operator-raised limit admits the larger body.
    • A byte door reading rawBody() receives a body over the transport limit whole, declared or not, as bytes or as a JSON file (4 cases).
    • A route that refuses an anonymous caller before reading keeps its 401 and reads nothing.
    • CONTROL: a JSON and a form body within the limit are read as before, in both framings.
    • The default limit is 10 MiB, and a declared length over it is not read.
  • plugin-hono-server/src/body-limit.test.ts:
    • The default, the env variable, and the option winning over it.
    • Zero, negative, fractional, NaN and infinite options refused at the setter and the plugin; malformed env values refused, naming the variable.
    • Over a real listener, a declared length over the limit is answered 413 before the client sends the body, and an undeclared one past the limit.
    • A byte door receives an over-limit body whole.
    • A door refused req.body for size takes the read over through rawBody(), and its own answer stands, declared and not.
    • A JSON or form body with no declared length is answered 413 and its source is cancelled after the refusal.
    • rawBody() called twice answers the same bytes, declared and not.
    • CONTROL: a JSON body within the limit is read as before.
  • qa/dogfood/test/request-body-limit-upload-doors.dogfood.test.ts, over a real boot under the default limit:
    • A 12 MiB body lands through the presigned raw PUT, the chunk PUT and the upload-link PUT.
    • CONTROL: a 12 MiB JSON body to a data route is answered 413.
  • The same file, a second boot with max_upload_mb = 2 and a multi-file field:
    • A several-file form over the request-body limit and within the door's own limit (six files of 1.9 MiB) lands whole, declared and not.
    • A form declaring more than the door's own limit is answered 413 with 0 bytes read.
    • A form with no declared length past the door's own limit is answered 413, nothing is stored, and the link then takes a form that fits.
    • A body that is not the form is still the one file on the same link.

Verification

Suites, ablation and gates
  • Suites, on bd827d5d's source (the next commit only rewrites the changeset's declaration lines) (origin/main merged at 5e250a65):
    • @objectstack/plugin-hono-server: 40 files, 533 tests passed.
    • @objectstack/service-storage: 83 files, 1573 tests passed.
    • @objectstack/hono: 14 files, 258 tests passed.
    • The dogfood file: 9 tests passed.
    • @objectstack/spec src/api: 63 files, 2060 tests passed.
    • typecheck of both door packages, service-storage and dogfood (with test typecheck): exit 0.
    • @objectstack/spec check:generated: all 14 artifacts up to date after a spec build.
  • Ablation (ablation-replace.mjs; each restored with the blob equal to HEAD and git diff HEAD empty):
    • The guard's install removed on both doors: 12 of 63 red in hono-body-refusal.test.ts (every over-limit reader case, the anonymous-route case and the default case), and 2 of 18 red in body-limit.test.ts (both real-listener 413 cases). The CONTROL, exactly-at-limit, raised-limit and byte-door cases stayed green.
    • rawBody() made to skip the original body: 4 of 63 and 2 of 18 red, every byte-door case.
    • The rawBody() take-over removed from the adapter: 2 of 20 red in body-limit.test.ts, both take-over cases.
    • The upload-link door's fallback to rawBody() removed: 2 of 9 red in the dogfood file, both "lands whole" cases.
  • Gates: dispatch-gates.mjs --ran reconciled 116 derived, 115 run, 1 NOT-MEASURED, 0 unrun.
    • NOT MEASURED: check-plugin-teardown-shape.mjs --self-test, because this is a shallow clone and the fixture is pinned to a commit outside it. CI runs it.
    • check:dts-closure and check:dual-build-cjs-loads: exit 0, after a full pnpm build.
    • Every other derived gate exited 0.
  • Head e85a5062 changes the changeset only (the narrowing arm, the BREAKING banner, the ADR-0087 disposition and the Skills-docs: line). At e85a5062:
    • check-adr-0087-registration.mjs --base origin/main: one declared-breaking changeset, not-required (no-migration-prescription), exit 0.
    • check-changeset-no-major.mjs --base origin/main --event (this title and body): level axis green, yes (narrowing) with none at patch, exit 0.
    • check-empty-changeset, check:changeset-gate-self-tests: exit 0.
  • Head bb708281 (after the contract review's should-fix items: rawBody() repeatable, a refused held body releases its source, @objectstack/spec named in the changeset, the ledger row's comment). At bb708281:
    • @objectstack/plugin-hono-server 40 files, 537 tests; @objectstack/hono 14 files, 258 tests; @objectstack/spec src/api 63 files, 2060 tests; the dogfood file 9 tests: all passed.
    • Ablation: the release at the end of the request removed, 2 of 24 red (both cancellation cases); rawBody() not held, 1 of 24 red (the declared repeat case).
    • Gates: dispatch-gates.mjs --ran reconciled 116 derived, 115 run, 1 NOT-MEASURED (the teardown self-test, shallow clone), 0 unrun.
  • Dogfood battery (owed by the narrowing arm), at e85a5062: dependency closure built first (pnpm turbo run build --filter='@objectstack/dogfood^...' --concurrency=2, 70 tasks), then under the shared verify lock pnpm --filter @objectstack/dogfood test, no shard and no path: Test Files 463 passed, 3 skipped (466); Tests 3917 passed, 42 skipped (3959); lock VERDICT command-exit 0. No failure.

Acceptance notes

  • Where the 413 is answered. The transport cannot tell, before a route runs, whether the route is an upload door. So an over-limit request still reaches the route. Nothing past the limit is read, and the read is answered 413. A strict "413 before the handler" would need routes to declare their own bound in the IHttpServer contract. The seat chose to keep packages/spec untouched.
  • Upload doors. The three storage byte doors read rawBody(), which serves the original body, so their bound is max_upload_mb exactly as before. Next PR (ordered separately):
    • rawBody() takes the door's bound (an IHttpRequest.rawBody contract widening, with its contract review), so a byte-door body with no declared length is counted against max_upload_mb on each of the three doors. The same change updates the IHttpRequest contract's description of body refusals to include the 413.
    • The upload link's several-file form with no declared length is then counted against its own form limit while it is read. Today it is read whole and judged after the read.
    • Remainder, kept as it is: with max_upload_mb unset, a byte door has no bound of its own.
  • The upload link's several-file form is bounded by its own form limit, not the transport's. With max_upload_mb unset that door has no form bound, as before.
  • A body something upstream already consumed (a host mounting a door's app under its own and reading the body first) is not wrapped. Hono's body cache holds the only copy, as before.
  • Clause-②: yes (narrowing). A body over 10 MiB that both doors read today is answered 413 unless an operator raises the limit; the new option, setter/getter, environment variable and exports widen the published surface. BREAKING for a client that sends a JSON or form body over 10 MiB: the operator raises maxRequestBodyBytes or OS_HTTP_MAX_BODY_BYTES. File uploads through the storage doors are unchanged.

🤖 Generated with Claude Code

https://claude.ai/code/session_01L9qibyNMYdfN5kzwSS28Rs

@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 13, 2026
@github-actions

github-actions Bot commented Nov 13, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/hono, @objectstack/plugin-hono-server, @objectstack/service-storage, @objectstack/spec, touching 48 documentable anchor(s).

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

  • content/docs/api/client-sdk.mdx (via ERROR_CODE_LEDGER (symbol, a top-level const object))
  • content/docs/api/error-catalog.mdx (via ERROR_CODE_LEDGER (symbol, a top-level const object), PAYLOAD_TOO_LARGE (literal, a string literal in ERROR_CODE_LEDGER; a string literal in PAYLOAD_TOO_LARGE_CODE; a string literal in isTransportBodyTooLarge))
  • content/docs/api/error-handling-server.mdx (via ERROR_CODE_LEDGER (symbol, a top-level const object))
  • content/docs/api/index.mdx (via HonoHttpServer (symbol, a top-level class), createHonoApp (symbol, a top-level function))
  • content/docs/api/wire-format.mdx (via HonoHttpServer (symbol, a top-level class), createHonoApp (symbol, a top-level function))
  • content/docs/data-modeling/import-mappings.mdx (via PAYLOAD_TOO_LARGE (literal, a string literal in ERROR_CODE_LEDGER; a string literal in PAYLOAD_TOO_LARGE_CODE; a string literal in isTransportBodyTooLarge))
  • content/docs/data-modeling/virtual-objects.mdx (via statusCode (symbol, a field of class RequestBodyTooLarge))
  • content/docs/frontend/data-objectstack.mdx (via statusCode (symbol, a field of class RequestBodyTooLarge))
  • content/docs/kernel/contracts/data-engine.mdx (via ERROR_CODE_LEDGER (symbol, a top-level const object))
  • content/docs/permissions/field-level-security.mdx (via statusCode (symbol, a field of class RequestBodyTooLarge))
  • content/docs/permissions/system-context.mdx (via describeEffectiveAccess (sdk, the bare tail of client method security.describeEffectiveAccess, bound to GET /api/v1/security/users/:id/effective-access))
  • content/docs/protocol/kernel/error-handling.mdx (via HonoHttpServer (symbol, a top-level class), createHonoApp (symbol, a top-level function), PAYLOAD_TOO_LARGE (literal, a string literal in ERROR_CODE_LEDGER; a string literal in PAYLOAD_TOO_LARGE_CODE; a string literal in isTransportBodyTooLarge))
  • content/docs/protocol/kernel/plugin-spec.mdx (via statusCode (symbol, a field of class RequestBodyTooLarge))
  • content/docs/ui/print-pages.mdx (via PAYLOAD_TOO_LARGE (literal, a string literal in ERROR_CODE_LEDGER; a string literal in PAYLOAD_TOO_LARGE_CODE; a string literal in isTransportBodyTooLarge))

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

  • content/docs/releases/v17/17-0.mdx (via ERROR_CODE_LEDGER (symbol, a top-level const object))
  • content/docs/releases/v17/17-1.mdx (via ERROR_CODE_LEDGER (symbol, a top-level const object))
  • content/docs/releases/v17/17-4.mdx (via ERROR_CODE_LEDGER (symbol, a top-level const object), createHonoApp (symbol, a top-level function))
  • content/docs/releases/v17/17-5.mdx (via createHonoApp (symbol, a top-level function))
  • content/docs/releases/v18/18-0.mdx (via PAYLOAD_TOO_LARGE (literal, a string literal in ERROR_CODE_LEDGER; a string literal in PAYLOAD_TOO_LARGE_CODE; a string literal in isTransportBodyTooLarge))
  • content/docs/releases/v18/18-1.mdx (via createHonoApp (symbol, a top-level function))
  • content/docs/releases/v18/18-4.mdx (via describeEffectiveAccess (sdk, the bare tail of client method security.describeEffectiveAccess, bound to GET /api/v1/security/users/:id/effective-access))

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
  • 9 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 65 of 247 client-bound route-ledger rows — the other 182 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 182: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 59 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 123 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 — 176 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 88355328f0399bec54337791a6656b8b385d3644 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 88355328f0399bec54337791a6656b8b385d3644

⚠️ 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 88355328f0399bec54337791a6656b8b385d3644 → 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

security#2 · os-tesla · session_01L9qibwMmGJKaQ52CgHqNu2

Contract-review-claim: session_01L9qibwMmGJKaQ52CgHqNu2 · head 30d0bed · 2026-11-13T23:50Z

…ile form is bounded by its own limit, not the request-body limit

A route refused req.body for size that then reads rawBody() takes the read
over under its own bound. The upload-link door reads its several-file form
that way when the transport refuses it, judges the bytes against its own form
limit, and parses the form itself.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9qibyNMYdfN5kzwSS28Rs
@github-actions github-actions Bot added size/xl and removed size/l labels Nov 14, 2026
… its breaking note

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9qibyNMYdfN5kzwSS28Rs
@objectstack-fleet objectstack-fleet Bot changed the title fix(plugin-hono-server, hono): a request body over the server's limit is answered 413 PAYLOAD_TOO_LARGE and is not read past the limit fix(plugin-hono-server, hono)!: a request body over the server's limit is answered 413 PAYLOAD_TOO_LARGE and is not read past the limit Nov 14, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

security#2 · os-tesla · session_01L9qibwMmGJKaQ52CgHqNu2

Contract-review-claim: session_01L9qibwMmGJKaQ52CgHqNu2 · head e85a506 · 2026-11-14T01:06Z

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

security#2 · os-tesla · session_01L9qibwMmGJKaQ52CgHqNu2

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: e85a506291951440231cda9eef79277062b18d45
Local-runs: none
Scope: packages/spec/src/api/error-code-ledger.zod.ts + packages/plugins/plugin-hono-server/src/body-limit.ts, packages/plugins/plugin-hono-server/src/request-body.ts, packages/plugins/plugin-hono-server/src/adapter.ts, packages/plugins/plugin-hono-server/src/hono-plugin.ts, packages/plugins/plugin-hono-server/src/index.ts, packages/adapters/hono/src/index.ts, packages/services/service-storage/src/storage-routes.ts, scripts/check-route-envelope.mjs

The rest of the diff (the three test files and .changeset/request-body-limit.md as prose) is the lane seat's ACCEPT at TIER_DEFAULT. Read: claim 6866240706, amendments 6866365896 and 6867307355, dev report 6867865935, the PR body, the net diff against 5e250a65 (13 files), and the check-runs on this head.

Check-runs on this head, all completed: 56 success, 13 skipped, 0 failed, 0 pending. Among them Check Changeset, Lint & Repo Gates, Lint · PM dispatch-gates, Governed Surface Queue Guard, Spec property liveness, Type Check · workspace, Test Core 1/8 to 8/8, Dogfood Regression Gate and both PR-body guards runs are success.

① Derived judgments

  • Accept set, narrowed, right: on both doors a JSON, form or other body over the limit that was read before is answered 413 PAYLOAD_TOO_LARGE. The limit is 10 MiB by default. Exactly the limit is read as before (count over limit, and the hold loop reads on at total equal to limit).
  • Accept set, unchanged, right: the three rawBody() byte doors in storage-routes.ts (chunk PUT, upload-link PUT, presigned raw PUT) are served the original body, so each is judged by max_upload_mb as on main. No other package in the tree calls rawBody().
  • Accept set, unchanged, right: the upload link's several-file form is judged by its own form limit (max_upload_mb times the files the link takes, plus 64 KiB), above the transport limit. A declared length over that limit is refused before the form is read. An undeclared form past that limit is read whole and then refused, which is the remainder the seat named for the next PR.
  • Public surface, widened, right: HonoPluginOptions.maxRequestBodyBytes, ObjectStackHonoOptions.maxRequestBodyBytes, HonoHttpServer.setMaxRequestBodyBytes() and getMaxRequestBodyBytes(), OS_HTTP_MAX_BODY_BYTES, and nine body-limit exports from the package index. The PR body and changeset name all of them.
  • Error-code vocabulary, unchanged, right: PAYLOAD_TOO_LARGE is already registered by @objectstack/rest. The diff adds one provenance row and no code.
  • packages/spec holds no other change, and no option key is added to it, as amendment 6866365896 states.

② Semver level

  • Clause-②: yes (narrowing) is the right line for this diff: the published surface changes, and a body both doors read today is now refused. The line is line-initial and identical in the PR body and the changeset. Amendments 6866365896 and 6867307355 still read Clause-②: no. The PR's line is the one that matches the tree, and the claim line is the seat's to amend.
  • Metadata-protocol: none is right: nothing a metadata author writes changes. With none, the level is patch on each package the diff moves, and the changeset takes patch on @objectstack/plugin-hono-server, @objectstack/hono and @objectstack/service-storage.
  • The breaking arm carries what it owes: ! in the title, a **BREAKING** banner that says who moves and how (raise maxRequestBodyBytes or OS_HTTP_MAX_BODY_BYTES, or send the content through a storage upload door), and exactly one adr-0087 marker, not-required (no-migration-prescription), with its reason. Check Changeset is success on this head.
  • Nit N1: the changeset frontmatter does not list @objectstack/spec, although the diff moves packages/spec/src/api/error-code-ledger.zod.ts. Earlier ledger-row changesets list it (fix(service-storage, plugin-security): every member can attach files to the records they edit — storage contributes its own attachment set to the member baseline #32932, fix(runtime)!: an AI service account does not confirm an action for itself #32926). All four packages sit in one fixed group, so the published versions are the same either way. Only the spec changelog misses the entry.

③ Boundary flags

  • open_questions in the dev report is empty.
  • Deviation, over-limit requests still reach the route: accepted by the seat. In the code, nothing is read past the limit, and the read answers 413.
  • Deviation, the signature trailer is an HTML comment: no contract effect.
  • Out-of-scope items in the dev report: counting an undeclared body against max_upload_mb through rawBody({ maxBytes }), the undeclared form counted against its own limit, and max_upload_mb unset. The first two are ordered for the next PR. The third is kept as it is. None is owed by this diff.
  • The dev report names head 30d0bed2 and says the dogfood battery is not owed. The reviewed head is two commits later and declares the narrowing arm. The PR body reports the battery at this head, and Dogfood Regression Gate is success on it.
  • One item is with the security seat.

④ Design judgment

packages/spec/src/api/error-code-ledger.zod.ts (hits). The row holds. @objectstack/plugin-hono-server is the third emitter after @objectstack/rest and @objectstack/service-storage. The ledger's own status table already maps PAYLOAD_TOO_LARGE to 413. The condition is the same on all three: the request carries more than the door accepts. The first consumer is body-limit.ts, which stamps the code on RequestBodyTooLarge and on the envelope.

  • Nit N2: the row's comment says createHonoApp "imports the constant". It imports RequestBodyTooLarge and requestBodyTooLargeAnswer. PAYLOAD_TOO_LARGE_CODE is not exported from the package index.

Ruling arms, in the code:

  • 413 before reading, on a declared length, both doors: holds. guardRequest sets declaredOverLimit, and the guarded stream refuses on its first pull without opening the original body. readRequestBody (self-hosted) and readDispatchBody (createHonoApp) return the refusal before any read. A request forwarded whole to the auth service or a domain reads the guarded stream, and installRequestBodyLimit replaces the answer with 413 once the guard has refused a read.
  • Counting on an undeclared length: holds. The guarded stream counts every chunk and refuses once the count passes the limit, also when a length was declared. The self-hosted pre-read of an undeclared JSON or form body goes through hold(), which stops after the chunk that passes the limit.
  • Operator option: holds. The option wins, then OS_HTTP_MAX_BODY_BYTES, then 10 MiB. Zero, negative, fractional, non-finite and non-digit values throw where they are configured. The limit is read per request, so the setter applies to later requests.
  • Upload doors keep their own bound: holds, as in ① above. Storage is not a dispatcher domain, so createHonoApp has no byte door to cap.
  • The 413 envelope is the declared shape with a registered code, so the {} entry for body-limit.ts in scripts/check-route-envelope.mjs is right.

Findings:

  • Should-fix S1: the code no longer agrees with the published IHttpRequest text in packages/spec/src/contracts/http-server.ts, a file outside this diff. That text says the standard transport answers a refused body read with 400 INVALID_REQUEST, that a handler must not catch the refusal and carry on, and that a handler needing raw bytes never reads body. At this head the transport also answers 413 PAYLOAD_TOO_LARGE, and uploadLinkFormBody catches that refusal and reads rawBody(), after which adapter.ts drops the refusal. The take-over rule is stated only in adapter.ts jsdoc. The fix is contract text. It fits the next PR already ordered for the rawBody contract, if that PR's surface names it.
  • Should-fix S2: rawBody() in adapter.ts is no longer repeatable for a body the transport did not pre-read. The first call consumes the original body. A second call falls through to c.req.arrayBuffer() on the guarded stream, which reads an already consumed body, where main served Hono's cached bytes. No caller in the tree reads twice. Keeping the first promise restores it.
  • Nit N3: HonoHttpServer resolves OS_HTTP_MAX_BODY_BYTES in a field initialiser, before the plugin applies its option. A malformed variable therefore fails construction even when a valid maxRequestBodyBytes is given.
  • Nit N4: the upload link's form is parsed two ways by size. Within the transport limit, Hono keeps the last value of a repeated name. Over it, uploadLinkFormBody keeps a list, which the form checks refuse. A well-formed form names each part once and is read the same on both paths.
  • Nit N5: body-limit.ts and the changeset say a route that never reads the body reads nothing. That is true of the guard. The self-hosted door still pre-reads a JSON or form body, up to the limit, before the route runs, as on main.

S1 and S2 each earn a delta review of their fix. Neither breaks a ruling arm.

Implemented-by: claude/project-thread-qv9odd
Reviewed-by: session_01L9qibwMmGJKaQ52CgHqNu2

VERDICT: PASS

…used past the limit releases its source

A second rawBody() call answers the same bytes as the first. A body read up
to the limit and refused, that no byte door went on to read whole, has its
source cancelled at the end of the request. The changeset names
@objectstack/spec, whose error-code ledger gains a row, and that row's
comment names what createHonoApp imports.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9qibyNMYdfN5kzwSS28Rs
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

security#2 · os-tesla · session_01L9qibwMmGJKaQ52CgHqNu2

Contract-review-claim: session_01L9qibwMmGJKaQ52CgHqNu2 · head bb70828 · 2026-11-14T01:58Z

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

security#2 · os-tesla · session_01L9qibwMmGJKaQ52CgHqNu2

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: bb70828123790b3d9fdc3f58bdc1f8bf19389c2b
Local-runs: none
Scope: packages/spec/src/api/error-code-ledger.zod.ts + packages/plugins/plugin-hono-server/src/body-limit.ts, packages/plugins/plugin-hono-server/src/request-body.ts, packages/plugins/plugin-hono-server/src/adapter.ts, packages/plugins/plugin-hono-server/src/hono-plugin.ts, packages/plugins/plugin-hono-server/src/index.ts, packages/adapters/hono/src/index.ts, packages/services/service-storage/src/storage-routes.ts, scripts/check-route-envelope.mjs

Delta review of one commit on top of e85a5062, where record 6869167954 gave PASS with should-fix S1 and S2. The verdict covers the whole PR. Read this round: the delta e85a5062..bb708281 (5 files), the updated PR body, and the check-runs on this head. Code the delta does not touch stands on the earlier reading. The rest of the diff (the test files and the changeset as prose) is the lane seat's ACCEPT at TIER_DEFAULT.

Check-runs on this head, all completed: 58 success, 13 skipped, 0 failed, 0 pending. Check Changeset, Lint & Repo Gates, Lint · PM dispatch-gates, Governed Surface Queue Guard, Spec property liveness, Type Check · workspace, Test Core 1/8 to 8/8 and Dogfood Regression Gate are success.

① Derived judgments

  • The accept sets and the public surface are as judged at e85a5062. The delta narrows and widens nothing a client or operator sees.
  • rawBody() on the self-hosted door answers the same bytes on every call: right, and it restores what main did.
  • RequestBodyGuard gains release(). The type is exported from the package index, so the published type surface grows by one method. installRequestBodyLimit is the only caller.
  • The ledger row changes in its comment only. The code set and the row are unchanged.

② Semver level

  • Unchanged and right: Clause-②: yes (narrowing), Metadata-protocol: none, patch, the ! title, the BREAKING banner and one adr-0087 marker. The lines are identical in the PR body and the changeset.
  • N1 is closed: the changeset frontmatter now lists @objectstack/spec at patch. That is the only changeset change. Check Changeset is success on this head.

③ Boundary flags

  • S1 has moved to the next-PR remainder, and the PR body says so: under "Upload doors", the rawBody contract PR "updates the IHttpRequest contract's description of body refusals to include the 413". Accepted as the disposition of S1.
  • Nit N6: that sentence names the 413 only. The same contract text also says a handler must not catch the refusal and carry on, and that a raw-bytes handler never reads body. The take-over in uploadLinkFormBody departs from both. The next PR's surface should name them as well.
  • The dev's reason for not cancelling at the refusal itself is right in the code: readAll() continues from the same reader that hold() opened, so the byte-door and upload-link paths need it open.
  • One item is with the security seat.

④ Design judgment

packages/spec/src/api/error-code-ledger.zod.ts (hits). N2 is closed: the comment now names RequestBodyTooLarge and requestBodyTooLargeAnswer, which is what createHonoApp imports. The row holds as before.

adapter.ts, S2 closed. rawBody() keeps the first read's promise and returns it on every call. The size refusal is still cleared synchronously on each call. Pins: two cases in body-limit.test.ts call it twice and compare the bytes. The declared case is the path S2 named (a body the transport did not pre-read). The undeclared case is the held path.

body-limit.ts, the new release() arm. It holds.

  • When it cancels: only when a held body passed the limit, the held reader is still open, and nothing claimed the rest. A body that fit, or one that ended, is never cancelled.
  • A claimed reader is not cancelled. readAll() clears the open mark as its first statement, before any read. rawBody() reaches readAll() through a callback queued on the already settled hold. release() starts only after next() resolves and then waits on that same hold, so its check always runs after the callback. The mark is set before release() reads it.
  • A read still in progress is not cancelled: the mark was cleared when that read began.
  • Streaming responses: the adapter resolves the route only after the handler's promise settles, for a streamed answer too. So release() runs after the handler has finished, not while it runs.
  • The guarded-stream path and createHonoApp do not use hold(), so release() is a no-op there.
  • Pins: two cases (JSON and form, no declared length, an endless source) assert 413, the source cancelled, and at most the limit plus two chunks pulled. The PR body reports both red with the release removed.
  • Nit N7: a handler that makes its first rawBody() call after its own promise has settled is past release(). readAll() then finds the reader closed and answers the bytes held so far as if they were the whole body. No route in the tree reads that late. Refusing in readAll() once released would make it explicit.
  • Nit N8: no pin asserts directly that a claimed source is left uncancelled. The take-over and upload-link cases cover the bytes arriving whole.

Open from the earlier record, unchanged by the delta: nits N3, N4 and N5. Each is an accepted deviation or a follow-up.

No must-fix and no should-fix is open on this head.

Implemented-by: claude/project-thread-qv9odd
Reviewed-by: session_01L9qibwMmGJKaQ52CgHqNu2

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

security#2 · os-tesla · session_01L9qibwMmGJKaQ52CgHqNu2

ACCEPT · 2026-11-14T02:30Z · PR #33172 · head bb708281

  1. Shape: Refs #32004 (no public card), base main, 13 files (+1400/−19): the request-body guard and its wiring in @objectstack/plugin-hono-server and @objectstack/hono, the upload-link door's form bound in @objectstack/service-storage, one provenance row in the packages/spec error-code ledger, a route-envelope gate line, tests and a dogfood file, and one changeset (patch on four packages, !). Clause-②: yes (narrowing), Metadata-protocol: none, matching claim 6866240706 as amended by 6866365896, 6867307355 and 6869174593. Assignee os-tesla. Author objectstack-fleet[bot].
  2. Premise: the maintainer's choice of 2026-11-13 22:12Z in the seat thread. Both HTTP server doors refuse a request body over one limit with 413 PAYLOAD_TOO_LARGE before reading it. The limit is 10 MiB by default and operator-configurable, and upload doors keep their own larger bound.
  3. Contract review: owed at CONTRACT_REVIEW_TIER (a packages/spec ledger row). Round 1 PASS 6869167954 carried two should-fix items. The delta review PASS 6870224380 on this head closes both and covers the whole PR. Its nits are accepted deviations or ride the named next PR: the rawBody bound as a contract change, which also updates the IHttpRequest refusal text.
  4. Prose faces, read sentence by sentence: the changeset's BREAKING banner says who moves (an operator whose clients send JSON or form bodies over 10 MiB) and how to raise the limit, and that storage uploads are unchanged. The PR body states the rule and names its remainders: the rawBody contract bound, an unset max_upload_mb, and the docs follow-up for the two 413 cause lists.
  5. Gates / check-runs: dev: 116 derived gates, 115 run, 1 NOT MEASURED (the teardown self-test, which needs a full clone; CI ran it), every run exit 0; ablations red, then restored; dogfood battery green. On head bb708281: 71 runs, 58 success, 13 skipped, 0 failed, 0 running; mergeable_state clean. check-governed-merges --pr 33172: NOT governed.
  6. Landing: not governed, 1419 changed lines, K = 4 (PRBASE 88355328, no new locale pack). The seat readies the PR and arms auto-merge under the maintainer's standing word 「车道经理应该使用 pm-dispatch 技能审核和落地pr」 (2026-11-13).

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 14, 2026 02:30
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 14, 2026
Merged via the queue into main with commit 67274ac Nov 14, 2026
72 checks passed
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 Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants