Skip to content

fix(runtime): on the standalone server, an anonymous request whose JSON body cannot be parsed gets the domain's 401, not the transport's 400 - #24250

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-24151-body-after-anonymous-floor
Oct 18, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-24151-body-after-anonymous-floor

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #24151

Clause-②: no

What

On the standalone server (os dev, os serve: HonoServerPlugin + the dispatcher plugin), every mount in packages/runtime/src/dispatcher-plugin.ts that handed dispatch() req.body now hands it deferRequestBody(req), and each domain reads the body with readDispatchedBody(body) once its anonymous decision (ADR-0138 D2) has admitted the caller. An anonymous caller's answer no longer depends on whether its JSON body parses; an admitted caller with an unparseable body still gets the transport's 400 INVALID_REQUEST.

  • packages/runtime/src/deferred-request-body.ts (new, module-private to the package, not exported from the index): the two halves. deferRequestBody(req) returns a value that holds the request and reads nothing; readDispatchedBody(body) reads req.body for a deferred value and returns any other value unchanged (what createHonoApp, tests and internal callers hand dispatch()). A deferred value read WITHOUT being resolved throws an UnresolvedRequestBodyRead error on every trap, so a domain that forgets the resolve fails loudly at its first read instead of acting on an invented empty body.
  • dispatcher-plugin.ts: 24 call sites, req.body becomes deferRequestBody(req) (the card's 20, plus 4 the card's grep missed, below). One note at the shared sendResult / errorResponse helpers says why.
  • Nine domains, one statement each, right after the anonymous decision: analytics, mcp, keys, notifications, automation (after the inbound-hook hand-off, which verifies raw bytes and never reads a parsed body), actions, ai, packages, security. ai decides per route: an admitted caller's body is read at the domain-wide decision, and an anonymous caller a route's auth: false admits has it read in the route loop.
  • Untouched: packages/spec (IHttpRequest's body contract), packages/adapters/hono, plugin-hono-server's adapter, PR fix(runtime): on the standalone server every /actions verb reaches the actions domain, so an anonymous caller gets 401, and no Allow lists a verb mounted only to refuse #24244's fallback path, the domains' logic beyond when they read the body.

H1: domain by domain (measured on a real socket, base cbfa5e433, head 8e9fd41b2)

Anonymous request, content-type: application/json, body {"name": "probe" (closing brace missing). "Anonymous first?" is read in the domain's source on cbfa5e433.

site(s) on cbfa5e433 domain anonymous decided first? base head
:1282 POST /analytics/query, :1300 POST /analytics/sql analytics yes, first statement 400 INVALID_REQUEST 401 UNAUTHENTICATED
:1326 /mcp (POST, GET, DELETE) mcp no: isMcpServerEnabled() 404 and the service probe 501 come first (finding below) 400 401 (MCP on and installed); 404 / 501 otherwise, body unread
:1362 /mcp/skill (POST, GET, DELETE) mcp/skill no anonymous decision: public by design ("Served PUBLIC like /discovery") 400 405 METHOD_NOT_ALLOWED, its own answer to any non-GET
:1395 POST /keys, :1409 /keys (GET, PUT, PATCH, DELETE) keys yes, first statement 400 401
:1447 POST /notifications/read, :1456 POST /notifications/read/all notifications yes, first statement 400 401
:1676–:1857, the nine /automation mounts automation yes, first statement 400 401
:1947, :1955, :1963, the three POST /actions/... shapes actions yes, first statement 400 401
:1909 the /ai/* wildcards (GET, POST, PUT, PATCH, DELETE), not in the card's grep ai yes, decided once ahead of every capability answer; a route's auth: false opens it 400 401
:1553 the /packages registrar (16 mounts), not in the card's grep packages yes, first statement 400 401
:2006 POST /security/_activation/:type/:name, :2020 POST /security/permission-sets/:name/clone, not in the card's grep security yes, first statement 400 401

At base the same anonymous requests with a well-formed body answered 401 everywhere (405 on /mcp/skill): only the parse decided the 400. Base was measured on the unscoped prefix; the scoped (/environments/:environmentId) mounts are the same registrar code, and head pins both prefixes.

H2, H3, H4

  • H2, partly falsified. createHonoApp answers 401 because readDispatchBody hands dispatch() either the parsed value or refusedOnRead(...): a Proxy over Object.create(null) whose every trap throws the refusal and records the read, which the arm then answers. Not a getter or a thunk. dispatch()'s body parameter is any and dispatch() never reads it (it passes it to domainRoute.handler), so the shape is accepted. But a standalone mount cannot build it: it can learn whether the body parsed only by reading req.body, the one door IHttpRequest declares, and that read IS the refusal. A lazily forwarding Proxy would have needed no domain change, but it is not faithful: Array.isArray, typeof and == null read the target, fixed at creation, so a JSON array (an MCP JSON-RPC batch; @objectstack/mcp passes parsedBody to the SDK, which branches on Array.isArray), null or a primitive would reach the domain as an object. So the domains read the body explicitly, one statement each.
  • H3, confirmed. The adapter's get body() throws the refusal and records refusedBodyRead; the adapter then answers that refusal whatever the handler did. Deferring the read into the domain needs no adapter change: all anonymous pins are green with adapter.ts untouched.
  • H4, confirmed with two named exceptions. A signed-in caller with the same body gets 400 INVALID_REQUEST, message "The request body could not be parsed as JSON …" (the transport's refusal, unchanged) on every site whose domain reads a body once it admits the caller. Exceptions, both answers the domain gives without reading a body, as on createHonoApp: /mcp/skill (its 405; base answered 400, measured) and /mcp where MCP is switched off or not installed (404 / 501; base answered 400 by construction, the mount reading the body before dispatch()).

The four mounts outside the card's grep (bounded in-place fix)

The card's grep dispatcher\.dispatch\([^)]*req\.body stops at the first ), so it missed the /ai/* wildcard (method.toUpperCase() precedes req.body) and the three registrars that spell the call over several lines (/packages, /security/_activation, /security/permission-sets/:name/clone). Same defect class, same mechanical change, same file, same gate families. Evidence, base cbfa5e433, anonymous: POST /ai/chat, PUT and PATCH /ai/conversations/c1, POST /packages, PATCH /packages/pkg_x, PATCH /packages/pkg_x/enable, POST /packages/pkg_x/publish, POST /packages/pkg_x/rollback, POST /security/_activation/permission_set/ps_x, POST /security/permission-sets/ps_x/clone: each 400 INVALID_REQUEST with the unparseable body, 401 UNAUTHENTICATED with the well-formed one. Head: 401 for both. Pinned below.

Two req.body reads remain in the file, both deliberately: the service-emitted route mount (the mount decides route.auth before it reads the body, so an anonymous answer never depends on it), and the declarative-endpoint fallback, which is not a dispatch() mount (see Acceptance notes).

Tests

  • packages/runtime/src/dispatcher-body-after-anonymous-floor.hono.integration.test.ts (new): real Hono socket, LiteKernel, both prefixes for the registrars enableProjectScoping mounts twice. 60 URLs, two pins each: anonymous + unparseable gets the domain's anonymous answer in the dispatcher's envelope, equal to the anonymous + well-formed answer; signed-in + unparseable gets the transport's refusal (or /mcp/skill's 405). 120 tests.
  • packages/runtime/src/deferred-request-body.test.ts (new): the value's own contract, 5 tests: nothing is read until it is resolved; resolving returns the exact value (object, array, null, string, number, boolean); the transport's refusal propagates as the same object; values it did not defer pass through untouched; an unresolved read throws UnresolvedRequestBodyRead on every trap and never reads the request.
  • pnpm --filter @objectstack/runtime exec vitest run --project local --maxWorkers=2: 415 files passed, 6536 tests passed, 19 skipped (run at bde3873ed; 8e9fd41b2 adds only the changeset).
  • --project repo (it reads dispatcher-plugin.ts as text): 3 files, 808 tests passed at 8e9fd41b2.
  • pnpm --filter @objectstack/runtime typecheck: exit 0; check:test-typecheck OK, the two new test files add no debt.

Ablation (one, as asked). deferRequestBody mutated to return req.body; (eager read again) through scripts/ablation-replace.mjs: anchor 1 to 0, blob 7d7452c90112 to dfaf76e93a8e. The test imports ./dispatcher-plugin.js from src by relative path, so no dist/ is on the path. Result: 62 failed, 58 passed of 120. Red: all 60 anonymous pins, and the two signed-in /mcp/skill pins, whose expected answer is the domain's own 405, which an eager read pre-empts with 400. Green: the 58 signed-in refusal controls. Restore proven: blob back to 7d7452c90112 equal to HEAD, git diff HEAD empty.

Gates (at 8e9fd41b2)

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack: 65 commands. 64 exit 0. 1 NOT MEASURED: pnpm check:dual-build-cjs-loads exit 3, PREREQUISITE NOT MET (it reads every package's dist/; only the runtime closure is built here). --ran with exit codes: "65 derived famil(ies) accounted for — 64 run, 1 NOT-MEASURED". Also, for the roster families whose list lives under a changed directory: check:route-ledger-census, check:error-code-casing, check:authz-resolver, check:filter-alias-parity, check-changeset-fixed.mjs, each exit 0. pnpm exec eslint --no-inline-config over the 13 changed .ts files: exit 0. The repo-wide pnpm lint, the type-check lanes, Test Core, Build Core and dogfood are CI's.

Acceptance notes

  • /mcp does not decide anonymous first (H1). handleMcpRequest answers 404 when OS_MCP_SERVER_ENABLED is off and 501 when no MCP service is installed, both ahead of its 401. Measured in process on the standalone composition: anonymous POST /api/v1/mcp answers 501 NOT_IMPLEMENTED with no MCP service and 404 RESOURCE_NOT_FOUND with MCP off, for a well-formed and an unparseable body alike. Not changed here: the dispatch order says a domain that does not decide anonymous first is a finding, not something to add a check for.
  • The declarative-endpoint fallback reads the body before its own decisions. The fallback seam builds execution.request.body from req.body before runAppEndpointStep matches an endpoint or applies authRequired. Measured: anonymous POST /api/v1/apps/ns/x answers 400 INVALID_REQUEST with an unparseable body and 404 ENDPOINT_NOT_FOUND with a well-formed one; signed in, the same. A declared authRequired: true endpoint was not driven, so whether an anonymous caller gets 400 where 401 is owed there is inferred, NOT MEASURED. Not a dispatch() mount, so outside this change.
  • /mcp/skill is public by design and reads no body, so its row pins its own 405 rather than 401.
  • A signed-in caller's answer moved only where the domain answers without reading a body (the two H4 exceptions); everywhere else it is byte-for-byte what base answered.

Generated by Claude Code

… domain reads it after its anonymous decision

WIP: helper, mount sites, domain reads, pins.

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/deployment/production-readiness.mdx (via createDispatcherPlugin (symbol, a top-level function))
  • content/docs/permissions/system-context.mdx (via handleAIRequest (symbol, a top-level function), handleActionsRequest (symbol, a top-level function), handleAnalyticsRequest (symbol, a top-level function), handleAutomationRequest (symbol, a top-level function), handleMcpRequest (symbol, a top-level function), handlePackagesRequest (symbol, a top-level function), handleSecurityRequest (symbol, a top-level function))
  • content/docs/plugins/packages.mdx (via createDispatcherPlugin (symbol, a top-level function))
What this run could not see
  • 1 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 — 26 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 f5ef054a72e743723101d7ff15120b35c7a19b68 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json f5ef054a72e743723101d7ff15120b35c7a19b68

⚠️ 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 f5ef054a72e743723101d7ff15120b35c7a19b68 → 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

席位核对 — ACCEPT · PR #24250 at 8e9fd41b2 (#24151)

domain:cli seat 2 · marchtian · session session_01QyoWKK9RwUNxBxmevy9Neb · 2026-10-18T04:04Z · holder of claim 6244946396 (revised in place 2026-10-18T04:01Z for the four extra mounts). The dev's report is comment 6245430651 on #24151. The seat reviewed against this PR, not the report's own account.

  1. Shape. Draft → main. The first line is Fixes #24151, the only closing keyword. Clause-②: no is line-initial, and the assignee is marchtian. 14 files, +521 / −25, all in packages/runtime, with one patch changeset. check-governed-merges.mjs --pr 24250: NOT governed. No packages/spec, packages/hono or plugin-hono-server change.
  2. The mechanism, read on the diff.
    • The new packages/runtime/src/deferred-request-body.ts is not exported from the package index. deferRequestBody(req) returns a Proxy whose every trap throws UnresolvedRequestBodyRead, so a domain that forgets to resolve the body fails loudly instead of acting on an invented empty one. readDispatchedBody(body) resolves it through a WeakMap, and returns any other value unchanged, so createHonoApp's refuse-on-read body still passes through.
    • Each of the nine domains reads the body with readDispatchedBody as the statement right after its anonymous decision (e.g. keys.ts: after the shouldDenyAnonymous arm). The body is read after the decision, not dropped, and ADR-0138 D2 is kept: no anonymous check is added in a mount.
    • dispatch() (http-dispatcher.ts) passes the body through untouched to its thin domain delegates.
    • H2 was partly falsified, with a reason: a standalone mount cannot build createHonoApp's refuse-on-read value without reading req.body, and a lazily forwarding Proxy is not faithful for an array JSON body (an MCP JSON-RPC batch branches on Array.isArray). Hence the explicit one-line read in each domain.
  3. The four mounts outside the card's grep (in-place, the claim revised): the /ai/* wildcard, the /packages registrar (16 mounts) and the two /security registrars. The card's grep stops at the first ), so it missed them. The four conditions hold: the same defect class, the same mechanical change, no other claim on the file, and the same gate families. Each was measured at base (anonymous + unparseable 400, well-formed 401) and is pinned at head.
  4. Evidence.
    • On a real Hono socket with scoping on: 60 URLs × 2 pins. At base, all 32 measured site/verb rows answered 400 to anonymous + unparseable. At head, anonymous gets the domain's 401 whatever the body; /mcp/skill, which is public, gets its own 405.
    • Signed-in controls: 58 still get the transport's 400. The two named exceptions are domains that answer without reading a body (/mcp/skill 405; /mcp switched off or not installed, 404 / 501), and those answers match createHonoApp.
    • Ablation: return req.body; in place of the deferred value gave "Tests 62 failed | 58 passed (120)": all 60 anonymous pins plus the two /mcp/skill signed-in pins red, the 58 controls green. The restore was proven by blob.
    • Runtime local: 415 files, 6536 passed. --project repo (which reads dispatcher-plugin.ts as text): 808 passed. Typecheck exit 0, ledger held. Gates: 65 derived, 64 run, all exit 0, and one NOT MEASURED (check:dual-build-cjs-loads, needs a whole-tree build; CI's). ESLint --no-inline-config exit 0 over the 13 files.
  5. domains_measured (the card's first step), as the PR body tables it:
  6. Clause-②: no, read. The triage named this an answer-order defect. An anonymous unparseable request moves from one refusal (400) to another (401). Signed-in /mcp/skill POST / DELETE move from 400 to the domain's own 405, the createHonoApp answer. Nothing is admitted that was refused.
    Changeset, read sentence by sentence: the before ({"name": "probe" → 400), the per-route 401 list including the four extra families, the /mcp and /mcp/skill sentences, "the body is read after the anonymous decision, not dropped", and "A createHonoApp host … is unchanged" all match the diff and the pins.
  7. Accepted deviations:
    • the four in-place mounts (item 3);
    • the two signed-in answer moves (item 6), named, pinned and matching createHonoApp;
    • the full suite at bde3873ed: the final head adds only the changeset, so packages/runtime is identical;
    • --project repo run locally beyond the derivation.
  8. Out of scope, filed:
  9. Landing. Ordinary queue landing once every check on 8e9fd41b2 is green or a roster skip. Fixes #24151 closes the card, and the seat strips pm:dispatched in the same act. runtime(mcp): on a createHonoApp host, HEAD /api/v1/mcp/skill answers 405 with Allow: GET while GET is served — the catch-all hands dispatch() the raw HEAD and handleMcpSkillRequest refuses every non-GET #24245 (serial behind this card, note on its thread) is re-read after it lands.

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 18, 2026 04:22
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 18, 2026 04:23
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 18, 2026
Merged via the queue into main with commit 8eccf51 Oct 18, 2026
41 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-24151-body-after-anonymous-floor branch October 18, 2026 04:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants