Skip to content

fix(runtime): the declarative-endpoint fallback reads the body after the match and the policies - #24285

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-24252-endpoint-fallback-body-after-match
Oct 18, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-24252-endpoint-fallback-body-after-match

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #24252

Clause-②: no

Contract basis for the line above: only the order of refusals moves (the 404 and the 401 now answer ahead of the body's 400). No request that was refused before is accepted, and no export moves.

What was wrong

On the standalone server (HonoServerPlugin + the dispatcher plugin), the declarative-endpoint fallback (setFallbackHandler in packages/runtime/src/dispatcher-plugin.ts) built runAppEndpointStep's request with body: req.body, before the step matched an endpoint or ran its policy chain. The transport's req.body is an accessor that throws its 400 INVALID_REQUEST refusal for a body it could not parse, records the refusal, and answers the request with it whatever the handler then writes (packages/plugins/plugin-hono-server/src/adapter.ts, runHandler). So:

  • a POST to /api/v1/apps/ns/... that no declaration owns, with an unparseable JSON body, answered 400 where the same request with a readable body answers 404 ENDPOINT_NOT_FOUND, signed in or not;
  • an anonymous POST to an authRequired: true endpoint with an unparseable body answered 400 where 401 UNAUTHENTICATED is owed. The card listed this case as inferred; it is now measured (below).

What changes

  • packages/runtime/src/dispatcher-plugin.ts: the fallback hands the step deferRequestBody(req) (the runtime(dispatcher): on the standalone server an anonymous request whose JSON body cannot be parsed is answered 400 INVALID_REQUEST ahead of the domain's 401 floor, because the mounts read req.body before dispatch (20 sites) #24151 pair, packages/runtime/src/deferred-request-body.ts) instead of req.body.
  • packages/runtime/src/api-endpoint-step.ts: the step reads it with readDispatchedBody(request.body) after applyEndpointPolicies has admitted the caller (rate limit, auth gate, authRequired), just before inputMapping. That is the step's one body read, and nothing above it touches the body. A body that was not deferred (a direct caller, a unit test) passes through unchanged, and with no mapping declared the request still rides on by reference.
  • The "scoping test FIRST" property is unchanged: a path outside the /apps/ carve-out still returns before any resolution, and a miss still writes nothing.
  • New real-socket pins: packages/runtime/src/dispatcher-plugin.endpoint-fallback-body-after-match.integration.test.ts.
  • .changeset/24252-endpoint-fallback-body-after-match.md: patch for @objectstack/runtime.

The deferred-body pair fits this seam without change: the step has exactly one body read, and it sits after the policy chain. The pair's guard also applies here: a future read of the deferred body before the resolve throws UnresolvedRequestBodyRead instead of quietly running the transport's refusal early.

Measurements (dispatch assumptions A1 to A5)

Tree: origin/main 53ef75243 (BASE), fix at d8dbf8b1d, head f23824394.

  • A1, holds. On BASE: setFallbackHandler at :2151, runAppEndpointStep({ at :2243, body: req.body at :2291, built after resolveRequestScope and the multi-tenant decline and before any match.

  • A2, holds. In code, the adapter's get body() throws bodyReading.refusal and sets refusedBodyRead; after the handler, a recorded refusal is the answer. On a socket, the before-readings below show it.

  • A3, holds. Both functions exist. runAppEndpointStep reads the body once, at applyInputMapping(match.endpoint, request.body), after the policy deny return. Neither the matcher nor applyEndpointPolicies receives the body.

  • A4, measured. Anonymous caller, authRequired: true endpoint, unparseable body: 400 INVALID_REQUEST before the fix, 401 UNAUTHENTICATED after.

  • A5, no read before the match, in this repository. resolveRequestScope runs prepareResolverHints (path and route params only), the host's KernelResolver, and resolveExecutionContext (no body reference). After the fix, the single-tenant socket pins answer 404 for an unmatched path, and that composition runs resolveRequestScope before the match. A throwaway probe (not committed) on the multi-tenant composition, with the existing multi-tenant test's stub resolver, read the same at the fixed head and with the fallback's req.body put back:

    request (unparseable body) fallback reads req.body fixed
    unplaced (no environment header), declared path 404, matcher asked 0 times 404, matcher asked 0 times
    unknown environment, declared path 404 404
    placed, unmatched path 400 INVALID_REQUEST 404 ENDPOINT_NOT_FOUND
    placed, authRequired: false path (admitted) 400 INVALID_REQUEST 400 INVALID_REQUEST

    The decline branch returns before the step's argument is built, so it never read the body. The real KernelResolver is host-supplied (the cloud distribution) and has no implementation in this repository: whether it reads context.request.body is NOT MEASURED here.

Tests

New pins, on a real socket (LiteKernel + HonoServerPlugin on port 0 + the dispatcher plugin, with stubbed auth, metadata.matchEndpoint and automation slots). Refusal-shaped assertions check status and error.code. The transport refusal is told apart from a domain INVALID_REQUEST by its message, as #24151's pins do.

case BASE 53ef75243 fix d8dbf8b1d
anonymous, unmatched path, unparseable body: 404 ENDPOINT_NOT_FOUND as for a readable body, matcher asked red: 400 green
signed-in, unmatched path, unparseable body: 404, as above red: 400 green
anonymous, authRequired: true, unparseable body: 401 UNAUTHENTICATED as for a readable body, target never runs red: 400 green
control: signed-in, authRequired: true, unparseable body: transport 400 refusal, target never runs green green
control: anonymous, authRequired: false, unparseable body: transport 400 refusal, target never runs green green
control: signed-in, authRequired: true, readable body: 200, flow receives params: { days: 30 } green green

Existing suites, all green at the fix, in one run (11 files, 231 tests, VERDICT command-exit 0): the new file, dispatcher-plugin.endpoint-fallback.integration.test.ts, api-endpoint-step.test.ts, #24151's dispatcher-body-after-anonymous-floor.hono.integration.test.ts and deferred-request-body.test.ts, dispatcher-plugin.multi-tenant-endpoint.integration.test.ts, dispatcher-plugin.endpoint-principal-less-deny.integration.test.ts, dispatcher-plugin.streaming-fallback.test.ts, dispatcher-plugin.fallback-absence-warn.test.ts, endpoint-flow-dispatch-status.test.ts, endpoint-flow-elevated-door.test.ts.

Full package: pnpm --filter @objectstack/runtime test at 50f9caa35 (same code as the head; later commits touch only the changeset): 417 files passed, 6553 passed and 19 skipped, VERDICT command-exit 0. pnpm --filter @objectstack/runtime typecheck: exit 0. tsc --listFiles -p tsconfig.test.json lists the new test file, with no error TS lines naming it. check:test-typecheck OK, ledger unchanged.

Ablation (one time, both legs, on d8dbf8b1d)

Both subjects are imported relatively (./dispatcher-plugin.js, which vitest transforms from src/), so no dist/ rebuild applies. Mutation and restore went through scripts/ablation-replace.mjs, which uses a must-hit anchor and verifies blob hashes, with a trap restore as well.

  • Leg A, fallback: body: deferRequestBody(req), changed to body: req.body, (anchor 1 to 0, blob 718ef188ae82 to b872160649cf). Red, the expected direction: the three 404 / 404 / 401 pins fail (expected 400 to be 404, expected 400 to be 401), and the three controls stay green. Restored: blob equals HEAD 718ef188ae82, git diff HEAD empty.
  • Leg B, step: readDispatchedBody(request.body) changed to request.body (blob 88d109cebdcf to 22f289504261). Red, the expected direction: the three controls fail with 500 (the unresolved deferred body throws), and so do two existing fallback cases whose flow reads a body (runs a flow through the automation slot ..., [#22147] hands the flow executor the guest ...). The 404 / 401 pins stay green. Restored: blob equals HEAD 88d109cebdcf, git diff HEAD empty.

Gates (local, at head 8b5e3b7f4)

  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, run with no paths (change set from merge base 93b4acb57), derives 64 commands. All 64 ran, each exit code captured before any pipe, and all exited 0. --ran verdict: "64 derived famil(ies) accounted for — 64 run, 0 NOT-MEASURED (a DERIVED zero — all 64 recorded an exit code and none of them is 3)". Among them: check:route-envelope, check:slot-lookup ("ratchet holds: 101 unswept site(s) in 25 file(s), none new"), check-system-context-census (OK), check:nul-bytes (OK), check:test-source-alias (OK) and check:cross-package-test-inputs (OK).
  • Five roster families keep their roster under a directory this diff touches. They ran in addition and all exited 0: check-changeset-fixed, check:authz-resolver, check:error-code-casing, check:filter-alias-parity and check:route-ledger-census.
  • eslint: pnpm exec eslint --no-inline-config over the three changed source files exited 0 with no output. The repo-wide pnpm lint is CI's.
  • On the pre-merge head f23824394, two commands first exited 3 (prerequisite not met). check-plugin-teardown-shape --self-test needed its pinned fixture commit 621a4876, which this shallow clone lacked; after fetching it, the re-run exited 0. check:dual-build-cjs-loads needed unbuilt workspace packages; on 8b5e3b7f4 it measured that 111 require entry points across 69 packages load. The merge of origin/main 93b4acb57 touched only scripts/pm/dispatch-gates*.
  • Left to CI, not run locally: the repo vitest project (test:repo; no file in this diff is in it), the workspace type-check lanes, Build Core, the Test Core shards and dogfood. check-platform-checklist --changed-from is not owed, because no exported symbol is renamed or removed.

Diff: 4 files, +246 / -4 (dispatcher-plugin.ts +9/-1, api-endpoint-step.ts +20/-3, new pin file +201, changeset +16).

Acceptance notes

  • EndpointExecutionRequest.body's doc line in packages/runtime/src/endpoint-executor.ts ("The fallback seam populates it exactly as a route does") still holds for what the executor receives, which is the body the step read. The deferral is documented where it happens: in AppEndpointExecutionInput.request and at the step's read. endpoint-executor.ts is outside the claimed file surface, so it was left alone. Carrier: none.
  • The host-supplied KernelResolver sees context.request before the match. No implementation in this repository reads the body; the cloud one is NOT MEASURED here. Carrier: none.
  • dispatcher-plugin.ts's route-handler mount (body: req.body near :305) reads the body after its own route.auth decision, so it is in the right order and not this defect.

Generated by Claude Code

…the match and the policies

The fallback built runAppEndpointStep's request with `body: req.body`
before the step matched an endpoint or ran its policy chain. The standard
transport's `req.body` throws its 400 INVALID_REQUEST refusal when a body
it could not parse is read, and answers the request with it, so an
unmatched path answered 400 where 404 is owed, and an anonymous caller of
an authRequired endpoint answered 400 where 401 is owed.

The fallback now hands the step deferRequestBody(req), and the step reads
it with readDispatchedBody once the policy chain admitted the caller,
just before inputMapping. An admitted caller with an unparseable body
still gets the transport's refusal; a readable body reaches the target.

Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T
Co-authored-by: Claude <noreply@anthropic.com>
…'s body read order

Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m 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 3 documentable anchor(s).

2 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/plugins/packages.mdx (via createDispatcherPlugin (symbol, a top-level function))
What this run could not see
  • 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 93b4acb57ec23c4e94a7047133ce612f89a13838 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 93b4acb57ec23c4e94a7047133ce612f89a13838

⚠️ 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 93b4acb57ec23c4e94a7047133ce612f89a13838 → 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 · domain:cli seat 1 (seat post #6024) · zhuangjianguo · session session_01SYEJqDL3NXxrJRBVqaMu9T · 2026-10-18T08:12Z · on head 8b5e3b7f4 · #24252 (claim 6246697624, report 6247464704). The seat's own review at the default tier (Clause-②: no, no packages/spec path, not governed), read against GitHub, not the report.

  1. Shape: draft against main. The first line is Fixes #24252, and no other closing keyword is in the body (full-body scan). Clause-②: no is line-initial and bare, with its basis in prose. Assignee zhuangjianguo. 4 files, +246 / −4. check-governed-merges --pr 24285: NOT governed, 250 changed lines.
  2. Premise, measured: the fallback's body: req.body (dispatcher-plugin.ts:2291 on 0d34066ef) was read by the seat before dispatch. The diff replaces it with deferRequestBody(req), and the step reads readDispatchedBody(request.body) after applyEndpointPolicies and before applyInputMapping. Spot read at head: endpoint-policy.ts reads no request body, so nothing touches the deferred value before the read. A body that was not deferred is returned unchanged (readDispatchedBody). With no inputMapping, mappedBody.value !== request.body, so the executor gets { ...request, body: <read body> }, never the deferred proxy. The pin's admitted readable-body control (200, params: { days: 30 }) covers that path.
  3. Gates: the report's local union of 64 derived families is all exit 0, and the --ran reconciliation shows 0 NOT-MEASURED. CI on this head at this stamp: 26 completed, all success or skipped, and 11 in_progress (Test Core 1–6/6, Lint · CI, release & engine gates, Lint · repo & tooling gates, two scoped lint jobs, Type Check · consumer gates). No failure. ⛔ Not ready until every check on the head is green.
  4. Deviations accepted: the main merge before the PR (touched scripts/pm/ only); the throwaway A5 multi-tenant probe, uncommitted; the bare Clause-② line inside the changeset too; no dist leg for the ablation, since both subjects resolve to src under vitest; the model-free commit trailers. The refusal pins assert code and status (ENDPOINT_NOT_FOUND/404, UNAUTHENTICATED/401, INVALID_REQUEST/400). The ablation went red in both directions and was restored to the HEAD blob.
  5. Out of scope: both findings are carrier: none, recorded in the PR's Acceptance notes (EndpointExecutionRequest.body's docline still holds for what the executor receives; the cloud KernelResolver sees context.request before the match and is NOT MEASURED here). Nothing to file.
  6. Landing: ready plus auto-merge through the queue once every check on the current head is success (or a roster skip). Then read origin/main and the queue branch for the merge, and close runtime(dispatcher): the declarative-endpoint fallback reads req.body while building the step input, before any endpoint is matched or authRequired applies, so an unparseable body answers 400 where the same request answers 404 (and, inferred, 401) #24252 with pm:dispatched removed.

Generated by Claude Code

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/m tests tooling

Projects

None yet

2 participants