Skip to content

fix(runtime): refuse a falsy-body PUT /meta/:type/:name instead of serving it as a read - #8849

Merged
qq9340100 merged 1 commit into
mainfrom
claude/issue-8842-falsy-body-put-served-as-read
Aug 15, 2026
Merged

fix(runtime): refuse a falsy-body PUT /meta/:type/:name instead of serving it as a read#8849
qq9340100 merged 1 commit into
mainfrom
claude/issue-8842-falsy-body-put-served-as-read

Conversation

@qq9340100

Copy link
Copy Markdown
Collaborator

Fixes#8842

The defect

packages/runtime/src/domains/meta.ts opened its metadata save branch with if (method === 'PUT' && body). The && body conjunct was not a guard — it was a hole. Every path inside that block returns (including the terminal 501), so a falsy body did not merely skip the write: execution continued past the whole save block into the read try below, which resolved the type and answered the ordinary metadata read.

A caller who asked to write received what looks like a successful read. No status, header or field distinguished it from a real write acknowledgement — the shape "Absence must be loud" exists to prevent (AGENTS.md, Route and surface ownership §3).

Not a security card. Triage's correction is carried through: the manage_metadata gate was skipped, but that is not a privilege bypass. The request was answered by the read path, which runs the same ADR-0106 mask a plain GET runs, and nothing was written. Skipping a write gate on a request that performs no write grants nothing. The defect is the lie, not a privilege.

Reachability — driven, not asserted

The card's own open question, and the thing that decides whether this is a defect or dead code. The host that mounts this dispatcher path is the Hono adapter's catch-all (packages/adapters/hono/src/index.ts, the only adapter in the repo; packages/runtime/src/dispatcher-plugin.ts registers no /meta route). It builds the body as:

body = await c.req.json().catch(() => ({}))

The .catch covers a parse failure — it does not cover a successful parse of a falsy JSON value. Driven against a real Hono app using that exact expression:

payloadresulting bodyfalsy?
nullnullyes
falsefalseyes
00yes
""""yes
empty body{}no
unparseable{}no

So an ordinary client sending content-type: application/json with a payload of null reaches the branch with a falsy body. Reachable, and the premise holds.

The fix

The branch keys off the method alone, and a nullish body folds to {}. This is what packages/rest's PUT /meta/:type/:name already does (const body = req.body ?? {}, then into the save unconditionally), so the per-type schema refuses downstream with 422 INVALID_METADATA — the behaviour #8818 measured end to end on the REST door. Two doors onto one saveMetaItem disagreeing about what a bodyless metadata write means was the actual defect; they now give one answer, from one authority. No new bespoke error path was added here.

What callers see instead of a spurious read:

  • holding manage_metadata422 INVALID_METADATA, with the structured issues the Studio form reads;
  • not holding it — 403 PERMISSION_DENIED from the capability gate, which now runs on this request at all.

Pinned in both directions, each predicted before running

packages/runtime/src/domains/meta-put-falsy-body.test.ts.

Refusal — all five falsy payloads plus the compound-name form: the ADR-0112 envelope (statusandcode), that the falsy body was folded to {} at the writer, that nothing was written, and — the load-bearing one — that the read spy was never called, since "was this write served as a read?" is exactly the question that answers.

Over-refusal guard — a PUT carrying a body still saves, the body still reaches the writer verbatim, and a GET is still served as a read. A pin asserting only the new refusal would be satisfied by breaking every metadata write, so this half is not optional company.

Reverse verification, predicted then observed: against unmodified origin/main the seven refusal cases went red and every failure was expected 200 — the read being served, getMetaItem called once, saveMetaItem never — while the three over-refusal guards passed unchanged. Direction as predicted, no inversion.

Verification

Run at 6ebb03ad9, the final commit:

  • pnpm --filter @objectstack/runtime test161 files, 2426 tests, all passing (the 7 new pins green; no existing test changed behaviour)
  • pnpm --filter @objectstack/runtime typecheck — clean
  • gate union re-derived against the actual changed paths via node scripts/pm/dispatch-gates.mjs, all passing: check:nul-bytes, check:route-envelope, check:cross-package-test-inputs, check:query-options-erasure, check:type-check-coverage

Out-of-scope finding filed

#8848DELETE, PATCH and POST on the same path are also answered as reads (measured 200 + the item document, read spy called, nothing written). Same surface-lies class reached by a different trigger: the read try carries no method guard, and the domain registers no methods restriction. Left alone here deliberately — the fix shape is a decision (refuse with 405 vs implement the verbs), not a one-liner, and it is outside this card's region.


Generated by Claude Code

…rving it as a read (#8842)
The metadata save branch opened `if (method === 'PUT' && body)`. The `&& body`
conjunct was not a guard but a hole: every path inside the block returns
(including the terminal 501), so a falsy body fell through to the read `try`
below and was answered with the ordinary metadata read. A write verb came back
looking like a successful read, and the `manage_metadata` gate — the first thing
the save branch does — was skipped entirely for such a request.
Reachable from an ordinary client, measured rather than read: the Hono adapter's
catch-all builds the body as `await c.req.json().catch(() => ({}))`, whose catch
covers a parse failure but not a successful parse of a falsy JSON value. Driven
against a real Hono app, payloads of `null`, `false`, `0` and `""` all arrive
falsy; only unparseable input lands on the `{}` fallback.
The branch now keys off the method alone and folds a nullish body to `{}`,
matching what packages/rest's `PUT /meta/:type/:name` already does
(`req.body ?? {}`), so the per-type schema refuses downstream with
422 INVALID_METADATA. Two doors onto one saveMetaItem disagreeing about what a
bodyless metadata write means was the defect; the fix gives them one answer.
Pinned in both directions: the refusal (422 for an authorized caller, 403
PERMISSION_DENIED for one lacking the capability, and the read spy proving no
read was served) and the over-refusal guard (a PUT carrying a body still saves,
body reaching the writer verbatim; a GET is still a read).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NaS1PAHJcPfAA2acnV53Tn
@vercel

vercelBot commented Aug 15, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 15, 2026 9:34am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/runtime.

20 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx(via packages/runtime)
  • content/docs/api/index.mdx(via @objectstack/runtime)
  • content/docs/api/wire-format.mdx(via @objectstack/runtime)
  • content/docs/automation/hook-bodies.mdx(via @objectstack/runtime)
  • content/docs/concepts/metadata-lifecycle.mdx(via @objectstack/runtime)
  • content/docs/concepts/north-star.mdx(via packages/runtime)
  • content/docs/data-modeling/drivers.mdx(via @objectstack/runtime)
  • content/docs/deployment/index.mdx(via @objectstack/runtime)
  • content/docs/deployment/production-readiness.mdx(via @objectstack/runtime)
  • content/docs/deployment/single-project-mode.mdx(via @objectstack/runtime)
  • content/docs/deployment/vercel.mdx(via @objectstack/runtime)
  • content/docs/getting-started/your-first-project.mdx(via @objectstack/runtime)
  • content/docs/kernel/cluster.mdx(via @objectstack/runtime)
  • content/docs/permissions/authentication.mdx(via @objectstack/runtime)
  • content/docs/permissions/authorization.mdx(via packages/runtime)
  • content/docs/permissions/system-context.mdx(via packages/runtime)
  • content/docs/plugins/packages.mdx(via @objectstack/runtime)
  • content/docs/protocol/kernel/http-protocol.mdx(via @objectstack/runtime)
  • content/docs/protocol/kernel/index.mdx(via @objectstack/runtime)
  • content/docs/protocol/kernel/lifecycle.mdx(via @objectstack/runtime)

2 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx(via @objectstack/runtime)
  • content/docs/releases/v17.mdx(via @objectstack/runtime)

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.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 15, 2026
@qq9340100
qq9340100 marked this pull request as ready for review August 15, 2026 10:00
@qq9340100
qq9340100 added this pull request to the merge queueAug 15, 2026
Merged via the queue into main with commit 3d61924Aug 15, 2026
27 checks passed
@qq9340100
qq9340100 deleted the claude/issue-8842-falsy-body-put-served-as-read branch August 15, 2026 10:12
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[finding] http-dispatcher: a PUT /metadata/:type/:name with a falsy body falls through the save branch and is served as a READ

2 participants

@qq9340100@claude