Skip to content

fix(runtime): refuse an unsupported verb on /metadata/:type/:name instead of serving it as a read (#8848) - #8871

Merged
qq9340100 merged 2 commits into
mainfrom
claude/issue-8848-metadata-verb-fallthrough
Aug 15, 2026
Merged

fix(runtime): refuse an unsupported verb on /metadata/:type/:name instead of serving it as a read (#8848)#8871
qq9340100 merged 2 commits into
mainfrom
claude/issue-8848-metadata-verb-fallthrough

Conversation

@claude

@claudeclaudeBot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Fixes#8848

The parts.length >= 2 block in the /meta domain carried exactly one method-sensitive branch — the PUT save landed by #8842 — and the read try that follows it had no method guard at all. Every other verb fell into it and was answered with the ordinary metadata read.

Reachability — measured first, because the card said it was not

The card was explicit that per-verb routing precedence in a fully composed host was not measured, and that premise_still_valid: false was a legitimate delivery. It was measured before anything was built. The premise holds.

Driven against a real createHonoApp app (the ${prefix}/* catch-all → dispatch() → domain registry → this handler), authenticated caller, /api/v1/meta/object/account, on origin/mainbefore the fix:

GET 200 getMetaItem x1 (control)
HEAD 200 getMetaItem x1 (control - see below)
OPTIONS 204 never reaches here (CORS short-circuits it)
PUT 403 manage_metadata (control, #8842's landed gate)
POST 200 getMetaItem x1
PATCH 200 getMetaItem x1
DELETE 200 getMetaItem x1, deleteMetaItem x0

createMetaDomain registers no methods restriction (DomainRoute.methods is optional, "Omit = all methods"), so domainRegistry.resolve(path, method) matches every verb.

The other composition, enumerated rather than assumed.RestServer.registerRoutes() registers GET, PUT and DELETE on /api/v1/meta/:type/:name — but notPOST or PATCH. So where REST is mounted alongside the catch-all (ADR-0076 D11: "REST-shadowed but still catch REST misses"), DELETE is shadowed by REST's real delete, while POST and PATCH stay REST misses and still land here. In a catch-all-only host — n() on Vercel, the documented embed shape — all three land here. Both statements are in the test file's header.

What changed

The block now answers 405 with Allow: GET, HEAD, PUT, aligning it with every other route in this file, which already guard their verb (the state and published reads use !method || method === 'GET'; _drafts and _migrate-stored guard theirs too). This block was the outlier.

DELETE was the sharpest case: 200 plus the item document is indistinguishable from a successful destructive call, and nothing was deleted. No status, header or field separated any of those answers from a real GET — the shape "Absence must be loud" exists to prevent (AGENTS.md, Route and surface ownership §3), and §4's "machine-readable surfaces must not lie" is why the refusal carries Allow rather than only a message.

This is not a privilege escalation, and is deliberately not described as one. These verbs were answered by the read path, which runs the ADR-0106 mask, so the caller received exactly what GET would return and nothing was written. A request that does not write does not escalate by skipping a write gate. What was wrong is that the answer lied about which operation happened.

HEAD is allowed, not refused. It is measured above as served today (200, getMetaItem called once, transport strips the body). That is correct HTTP for a readable resource, so refusing it would regress a working read verb rather than restore an invariant. The allowed set is spelled once in METADATA_ITEM_METHODS so the header and the message cannot drift apart.

Scope

⛔ This refuses the unsupported verbs; it does not implement them. A real metadata delete exists (protocol.deleteMetaItem) and REST already exposes DELETE /api/v1/meta/:type/:name, but mounting it on this transport expands the public surface and needs its own card. Per the ruling on the issue, only the invariant restoration is in scope here.

⚠️ One file outside the declared surface, and why

The card's surface was packages/runtime/src/domains/meta.ts + its tests. This PR also touches scripts/check-route-envelope.mjs — one ledger entry, moving meta.ts from handBuilt: 0 to handBuilt: 1 with the required note.

It is mechanically forced, not chosen. The 405 must be hand-built because it carries an Allow header and deps.error takes no headers — the same reason domains/mcp.ts hand-rolls its own 405, which the gate's own table already classifies that way. Measured before editing the gate:

✗ Route-envelope conformance (#3843)
packages/runtime/src/domains/meta.ts
handBuilt: found 1, declared 0

The gate treats an unclassified hand-built response as an error by design, so the fix cannot land without this line. The body still goes through the one builder (buildApiError) and the error code is derived from the status (METHOD_NOT_ALLOWED) rather than spelled, matching the other 405 sites.

Tests

packages/runtime/src/domains/meta-verb-fallthrough.test.ts — 12 cases, driving dispatch() (not handleMetadata()) for the routing-level cases so the domain-registry lookup is inside the pin.

  • Refusal half (6): each of DELETE / PATCH / POST asserts the full ADR-0112 envelope — statusanderror.code — plus the Allow header, plus getMetaItem never called, plus saveMetaItem / deleteMetaItem never called. Also the compound-name form, a caller with no authoring capability, and that the document no longer rides in the body.
  • Over-refusal half (6):GET still reads, HEAD still reads, an absent method still defaults to the read, PUT still saves, PUT's [finding] http-dispatcher: a PUT /metadata/:type/:name with a falsy body falls through the save branch and is served as a READ #8842 capability gate still answers first (an ordering pin — were the guard moved ahead of the save branch, this would become a 405), and the sibling routes are untouched.

Reverse verification, direction predicted in writing before running: plain red on the refusal half only, green on the over-refusal half, no inversion and no diagnostic-count increase, since the guard is a pure addition nothing else reads. Observed exactly that — with the guard reverted to origin/main, 6 failed / 6 passed, every failure expected 200 to be 405. The fix was committed first, so restoring was git checkout HEAD -- on the file; git status --porcelain confirmed byte-identity afterwards.

Verification

All at HEAD 5cb492fb5 (the merge commit; tree clean, nothing committed after the union ran).

pnpm --filter @objectstack/runtime test 164 files / 2452 tests passed
pnpm --filter @objectstack/runtime typecheck tsc --noEmit clean

Gate union re-derived from the actual changed paths with node scripts/pm/dispatch-gates.mjs, which surfaced four families beyond the dispatch list (all changeset-triggered) — every one run, all exit 0:

check:changeset-gate-self-tests check:cross-package-test-inputs
check:objectui-changeset check:route-envelope
check:query-options-erasure check:type-check-coverage
check-adr-0087-registration check-changeset-no-major
check-cross-package-test-inputs check-empty-changeset
check-nul-bytes check:type-check-debt

check:type-check-debt first refused to run ("24 workspace dependencies have no built type entry point") — recorded as not measured, not as a pass. The full closure was then built (turbo run build --filter='./packages/*' --filter='./packages/*/*', 70/70) and it was re-run to a real result: 33 ledger entries re-measured, 1926 raw errors, none above its recorded number. The one informational note (@objectstack/lint sits 1 below its ceiling) is pre-existing and untouched by this diff.

origin/main was merged before opening (bringing #8854 and #8857); the tree carries no deferred regen debt.


Generated by Claude Code

…tead of serving it as a read (#8848)
The `parts.length >= 2` block in the `/meta` domain carried exactly one
method-sensitive branch — the `PUT` save landed by #8842 — and the read
`try` that follows it had no method guard at all. Every other verb fell
into it and was answered with the ordinary metadata read.
Measured through a real composed host (`createHonoApp`'s catch-all →
`dispatch()` → domain registry → this handler), authenticated caller,
`/api/v1/meta/object/account`:
DELETE → 200, getMetaItem x1, deleteMetaItem x0
PATCH → 200, getMetaItem x1
POST → 200, getMetaItem x1
`DELETE` is the sharpest case: `200` plus the item document is
indistinguishable from a successful destructive call, and nothing was
deleted. This is the same defect class #8842 closed for the falsy-body
`PUT`, reached by a different door — a write verb answered as a read,
with no status, header or field telling the caller (AGENTS.md, Route &
surface ownership §3 "Absence must be loud").
Not a privilege escalation: the read path runs the ADR-0106 mask, so the
caller received exactly what `GET` would return and nothing was written.
What was wrong is that the answer lied about which operation happened.
The block now answers `405` with `Allow: GET, HEAD, PUT`, aligning it
with every other route in this file (which already guard their verb).
`HEAD` is in the allowed set because it is measured to be served today —
refusing it would regress a working read verb, not restore an invariant.
Scope: this REFUSES the verbs, it does not implement them. Mounting a
real metadata delete on this transport expands the public surface and
needs its own card.
`scripts/check-route-envelope.mjs` moves `meta.ts` to `handBuilt: 1`:
the gate mechanically requires classifying the hand-built response, and
the 405 must be hand-built because it carries an `Allow` header that
`deps.error` cannot express — the same reason `domains/mcp.ts` hand-rolls
its own 405.
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 12:30pm

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 12:46
@qq9340100
qq9340100 added this pull request to the merge queueAug 15, 2026
Merged via the queue into main with commit 4fc4a3cAug 15, 2026
27 checks passed
@qq9340100
qq9340100 deleted the claude/issue-8848-metadata-verb-fallthrough branch August 15, 2026 12:59
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

2 participants

@qq9340100@claude