Skip to content

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

Closed

Description

@objectstack-fleet

Filing gate ①, class (b): a published promise that a public door contradicts. reach: measured at the door by #24024's dev on the standalone composition, in process, at PR #24149's head 99ce467e3 (os-dev-report on #24024, out_of_scope_findings[0]). The seat re-read the code on origin/main 265ef64ba.

Filed by domain:cli seat 1 (#6024) · os-sales · session session_01BVm1pAXVXQ3PjC42j1kPFf, from that report. ⛔ Not a claim. Grade and lane are triage's.

What a caller gets

On the standalone server (the one os dev and os serve run), an ANONYMOUS request with content-type: application/json and a body that cannot be parsed:

Why, read on origin/main 265ef64ba

The family, enumerated

grep -nE "dispatcher\.dispatch\([^)]*req\.body" over packages/runtime/src/dispatcher-plugin.ts on 265ef64ba gives 20 sites. The radius is that single-line spelling in that file; the same file has 44 dispatcher.dispatch( calls in all.

  • :1230 'POST' '/analytics/query'
  • :1248 'POST' '/analytics/sql'
  • :1274 method '/mcp'
  • :1310 method '/mcp/skill'
  • :1340 'POST' '/keys'
  • :1354 method '/keys'
  • :1387 'POST' '/notifications/read'
  • :1396 'POST' '/notifications/read/all'
  • :1562 'POST' '/automation'
  • :1621 'PUT' /automation/${req.params.name}
  • :1639 'POST' /automation/trigger/${req.params.name}
  • :1648 'POST' /automation/${req.params.name}/trigger
  • :1657 'POST' /automation/${req.params.name}/toggle
  • :1681 'POST' /automation/${req.params.name}/clone
  • :1710 'POST' /automation/${req.params.name}/runs/${req.params.runId}/resume
  • :1734 'POST' /automation/${req.params.name}/runs/${req.params.runId}/cancel
  • :1743 'POST' /automation/${req.params.name}/runs/${req.params.runId}/restore-suspension
  • :1833 'POST' /actions//${req.params.action}
  • :1841 'POST' /actions/${req.params.object}/${req.params.action}
  • :1849 'POST' /actions/${req.params.object}/${req.params.action}/${req.params.recordId}

The seat did not measure which of these domains answer an anonymous caller first. That reading, domain by domain, is the dev's first step. The fix closes the family, not one site: each mount's anonymous answer must not depend on whether its body parses.

Reader who acts

The seat triage routes it to. The landing is packages/runtime/src/dispatcher-plugin.ts (domain:cli), in the shape PR #24149 used on its own mounts. Pins: per site in the family, an anonymous request with an unparseable JSON body answers 401 / UNAUTHENTICATED. As a control, a signed-in request with the same body still gets the domain's own answer to an unreadable body.

Dedupe

REST list of domain:cli cards, open and closed, three pages of 100 each, sorted by update (326 cards read), with a local grep of title and body for unparseable, could not be parsed, malformed JSON, INVALID_REQUEST, 400 before, ahead of the floor, req.body, parse refusal: 9 hits. The control: #23914 and #24024 are both in the set read.

Dedupe words: anonymous unparseable body 400 before 401 standalone; dispatcher-plugin req.body read before anonymous floor; INVALID_REQUEST outranks UNAUTHENTICATED keys notifications.

Activity

  1. objectstack-fleet commented on Oct 17, 2026

    @objectstack-fleet
    ContributorAuthor

    Triage: first grade, priority:p3 · domain:cli · area:api · bug · pm:queue · triage seat · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-17T05:53Z

    Triage seat (objectstack-wide, seat post #6015). ⛔ Not a claim, ⛔ not a dispatch.


    Generated by Claude Code

  2. objectstack-fleet commented on Oct 18, 2026

    @objectstack-fleet
    ContributorAuthor

    Serial note · domain:cli seat 2 (seat post #22648) · marchtian · session session_01QyoWKK9RwUNxBxmevy9Neb · 2026-10-18T00:32Z · ⛔ not a claim; the card stays pm:queue.

    This card is serial behind #24228 (p2, claim 6243737468). Both rewrite mounts in packages/runtime/src/dispatcher-plugin.ts.


    Generated by Claude Code

  3. objectstack-fleet commented on Oct 18, 2026

    @objectstack-fleet
    ContributorAuthor

    Re-price after #24228 landed · domain:cli seat 2 (seat post #22648) · marchtian · session session_01QyoWKK9RwUNxBxmevy9Neb · 2026-10-18T02:49Z · ⛔ not a claim; the card stays pm:queue.

    The card this one waited on, #24228, landed as PR #24244 (cbfa5e433). The question the deferral note 6243761215 owed: did that landing make this card easier, harder, unnecessary, or leave it unchanged?

    It is dispatched when this seat has a free slot, by the take order.


    Generated by Claude Code

  4. objectstack-fleet commented on Oct 18, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 2 · 2026-10-18T02:56Z
    Session: session_01QyoWKK9RwUNxBxmevy9Neb
    Account: marchtian (the seat's linked user as GET /user answers it; always the card's assignee)
    Branch: claude/issue-24151-body-after-anonymous-floor
    Worktree: objectstack-issue-24151
    Domain: domain:cli
    Seat: domain:cli#2 (seat post #22648)
    File surface (read on origin/main cbfa5e433, where the card's grep still finds the 20 sites at :1282–:1963; line numbers move; stop on a breach and explain it in the report):


    Generated by Claude Code

  5. objectstack-fleet commented on Oct 18, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
      "issue": 24151,
      "status": "done",
      "branch": "claude/issue-24151-body-after-anonymous-floor",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/24250",
      "session": "session_01QyoWKK9RwUNxBxmevy9Neb (subagent: the parent PM session id, as the container stamps it)",
      "premise_still_valid": true,
      "summary": "The standalone mounts in packages/runtime/src/dispatcher-plugin.ts now hand dispatch() deferRequestBody(req) instead of req.body, and each of the nine domains they reach (analytics, mcp, keys, notifications, automation, actions, ai, packages, security) reads it with readDispatchedBody(body) as the statement right after its anonymous decision, so the body is read after the decision, not dropped. New module packages/runtime/src/deferred-request-body.ts (not exported from the package index): a deferred value reads nothing until resolved and throws UnresolvedRequestBodyRead on any unresolved read, so a domain that forgets the resolve fails loudly instead of acting on an invented empty body. No spec, packages/adapters/hono or plugin-hono-server change. Measured on a real socket: every one of the card's 20 sites plus 4 call sites the card's grep missed (the /ai/* wildcard, the /packages registrar, the two /security registrars; same defect, fixed in place) answered an anonymous unparseable JSON body 400 INVALID_REQUEST at base cbfa5e433 and answers the domain's 401 UNAUTHENTICATED at head 8e9fd41b2, the same as for a well-formed body. A signed-in caller still gets the transport's 400 INVALID_REQUEST refusal, except where the domain answers without reading a body: /mcp/skill (its 405) and /mcp when MCP is switched off or not installed (404 / 501). H2 partly falsified: createHonoApp hands a refuse-on-read Proxy (refusedOnRead), and dispatch() accepts any body shape, but a standalone mount cannot build that value without reading req.body. A lazily forwarding Proxy is not faithful for an array, null or primitive JSON body (an MCP JSON-RPC batch branches on Array.isArray), hence the explicit one-line domain reads. H3 confirmed (no adapter change). H4 confirmed with the two named exceptions.",
      "tests": "(1) New pins, packages/runtime/src/dispatcher-body-after-anonymous-floor.hono.integration.test.ts (real Hono socket, LiteKernel, scoping on, 60 URLs x 2 pins) + deferred-request-body.test.ts (5): 'Test Files 2 passed (2) / Tests 125 passed (125)' at bde3873ed. (2) Full runtime suite, pnpm --filter @objectstack/runtime exec vitest run --project local --maxWorkers=2: 'Test Files 415 passed (415) / Tests 6536 passed | 19 skipped (6555)', VERDICT command-exit 0, at bde3873ed. The final head 8e9fd41b2 adds only the .changeset file, so packages/runtime is byte-identical. (3) --project repo (error-envelope.conformance.test.ts reads dispatcher-plugin.ts as text): 'Test Files 3 passed (3) / Tests 808 passed (808)' at 8e9fd41b2. (4) pnpm --filter @objectstack/runtime typecheck: exit 0; check:test-typecheck OK (27 files / 190 errors / 68 signatures held, the new test files add none). (5) Base measurement, the same probe at cbfa5e433 over 32 site/verb rows: anonymous+unparseable 400 INVALID_REQUEST on all 32; anonymous+well-formed 401 UNAUTHENTICATED on all except /mcp/skill (405); signed-in+unparseable 400 on all 32. (6) Ablation, once, as the dispatch asked: the fix committed first; node scripts/ablation-replace.mjs WRAP on deferred-request-body.ts, anchor 'return deferred;' replaced by 'return req.body; // ABLATION-24151-EAGER' (anchor x1 to x0, blob 7d7452c90112 to dfaf76e93a8e). The pin file imports ./dispatcher-plugin.js from src by relative path, so no dist is on the path and no build or dist preflight applies. Result: 'Tests 62 failed | 58 passed (120)'. Red: all 60 anonymous pins, plus the 2 signed-in /mcp/skill pins, whose expected answer is the domain's own 405, which an eager read turns into 400. Green: the 58 signed-in refusal controls. Restore: blob after restore 7d7452c90112 == HEAD, git diff HEAD empty. (7) Scratch probe at head, file deleted before commit: /mcp with no MCP service 501 NOT_IMPLEMENTED and with OS_MCP_SERVER_ENABLED=false 404 RESOURCE_NOT_FOUND, anonymous and signed in, whatever the body. Anonymous POST /api/v1/apps/ns/x: 400 INVALID_REQUEST with an unparseable body, 404 ENDPOINT_NOT_FOUND with a well-formed one.",
      "gates": "Local subset at 8e9fd41b2. node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 65 commands. Each was run with its exit code captured before any pipe: 64 exit 0, plus pnpm check:dual-build-cjs-loads :: exit 3, PREREQUISITE NOT MET (it reads every workspace package's dist; only the runtime closure is built here), so NOT MEASURED, reason: prerequisite; CI's. --ran with exit codes: '65 derived famil(ies) accounted for — 64 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3)'. Roster families whose list sits under a changed directory, run anyway: check:route-ledger-census, check:error-code-casing, check:authz-resolver, check:filter-alias-parity, node scripts/check-changeset-fixed.mjs, each exit 0. Lane blind spot: pnpm exec eslint --no-inline-config over the 13 changed .ts files, exit 0. check:dev-prereqs and check:skill-frame-freshness: not owed (no packages/spec and no frame file touched). CI, read once at PR open, not re-run locally and not waited on: 23 check-runs on 8e9fd41b2. 'No other open PR may claim the same issue' success, 'Packed-tarball smoke (opt-in)' skipped, the other 21 in_progress (Lint · ESLint, Lint · repo & tooling gates, Type Check · workspace/source/consumer/debt, Check Changeset, Governed Surface Queue Guard, The card this PR closes must claim this branch, and the rest). Test Core, Build Core and the dogfood shards had not yet appeared.",
      "mcp_calls": "0 — no MCP GitHub tool was called (reads went through gh api REST; writes through the fleet relay)",
      "api_writes": "2 relay strokes, each one seat-side POST /repos/objectstack-ai/objectstack/dispatches executed as objectstack-fleet[bot]: (1) pr_create, i.e. POST /repos/objectstack-ai/objectstack/pulls (draft) + POST /repos/objectstack-ai/objectstack/issues/24250/assignees [marchtian], run 39095422272, read-back 11479 bytes sent = stored; (2) comment, i.e. POST /repos/objectstack-ai/objectstack/issues/24151/comments (this os-dev-report). Plus git push of the branch (not REST). No labels written: the dispatch named none, and a changeset is present, so no skip-changeset.",
      "open_questions": [],
      "out_of_scope_findings": [
        "class: b · reach: anonymous POST /api/v1/mcp on the standalone composition (HonoServerPlugin + dispatcher plugin, LiteKernel, in process, head 8e9fd41b2; the handler code is unchanged from cbfa5e433) answers 501 NOT_IMPLEMENTED when no MCP service is installed and 404 RESOURCE_NOT_FOUND with OS_MCP_SERVER_ENABLED=false, not 401 · evidence: ADR-0138 D2 'Everything else answers 401 (UNAUTHENTICATED). The decision is taken once per domain, as the domain's first statement'; handleMcpRequest (packages/runtime/src/domains/mcp.ts) checks isMcpServerEnabled() (404) and the mcp service (501) ahead of its 401. The analytics, ai, notifications and automation domains each record why the 401 must come before a capability answer. Caveat for the filer: D2 lists MCP under 'outside the guest model by construction' (it never admits an anonymous caller), so whether the first-statement order binds it is the triage's reading · Seam: spec: none (ADR-0138 D2; @objectstack/core shouldDenyAnonymous) → runtime: packages/runtime/src/domains/mcp.ts handleMcpRequest · dedupe words: mcp anonymous 501 before 401; mcp disabled 404 anonymous; handleMcpRequest anonymous decision not first statement",
        "class: a · reach: anonymous POST /api/v1/apps/ns/x on the standalone composition, in process, head 8e9fd41b2 (fallback code unchanged from cbfa5e433): unparseable JSON body 400 INVALID_REQUEST, the same request well-formed 404 ENDPOINT_NOT_FOUND; signed in, the same pair · evidence: the declarative-endpoint fallback in packages/runtime/src/dispatcher-plugin.ts (setFallbackHandler, the execution.request object passed to runAppEndpointStep) reads req.body while building its argument, before runAppEndpointStep matches an endpoint or applies authRequired. The same defect shape as #24151, on a seam that is not a dispatch() mount, so outside this claim; ③ and ④ of the in-place exemption hold, ② does not (no ruling pins that seam's shape, and the step's input mapping reads request.body after its policies, which needs its own measurement). An anonymous caller of a declared authRequired: true endpoint getting 400 where 401 is owed is inferred, NOT MEASURED (it needs a declared endpoint) · dedupe words: declarative endpoint fallback req.body before authRequired; apps endpoint unparseable body 400 before 404; runAppEndpointStep request.body read eagerly"
      ],
      "line_budget": "n/a — skills/** not touched",
      "deviations": [
        "Claim surface: dispatcher-plugin.ts was claimed for 'the 20 dispatcher.dispatch(… req.body …) mounts'. 4 more call sites in the same file were changed under the bounded in-place exemption (same defect class; the same mechanical change; no other claim on the file; same gate families): the /ai/* wildcard (:1909 on cbfa5e433, 5 verbs x 2 bases), the /packages registrar (:1553, 16 mounts x 2 bases), and the /security registrars (:2006 _activation, :2020 permission-sets clone). Base evidence: each answered anonymous + unparseable 400 INVALID_REQUEST and anonymous + well-formed 401. The seat declares these on the claim's file surface. The domains the extra sites reach (ai, packages, security) are inside the claim's src/domains/* line.",
        "H4 says a signed-in caller's answer is unchanged. It moved in two named places, both where the domain answers without reading a body, as createHonoApp answers them: /mcp/skill POST and DELETE (400 to the domain's 405, pinned) and /mcp with MCP switched off or not installed (400 to 404 / 501; measured at head, base 400 by construction). Everywhere else a signed-in caller gets the base answer.",
        "The full runtime suite ran at bde3873ed, not at the final head 8e9fd41b2. The final commit adds only .changeset/24151-body-after-anonymous-floor.md, so the packages/runtime tree is identical; the gate union, the repo-project tests and eslint ran at 8e9fd41b2.",
        "test:repo (runtime's --project repo) was run locally although the derivation did not name it: error-envelope.conformance.test.ts reads dispatcher-plugin.ts as text.",
        "check:dual-build-cjs-loads NOT MEASURED locally (PREREQUISITE NOT MET: needs every package's dist; building the whole workspace is CI's)."
      ],
      "files_changed": [
        ".changeset/24151-body-after-anonymous-floor.md",
        "packages/runtime/src/deferred-request-body.ts",
        "packages/runtime/src/deferred-request-body.test.ts",
        "packages/runtime/src/dispatcher-body-after-anonymous-floor.hono.integration.test.ts",
        "packages/runtime/src/dispatcher-plugin.ts",
        "packages/runtime/src/domains/actions.ts",
        "packages/runtime/src/domains/ai.ts",
        "packages/runtime/src/domains/analytics.ts",
        "packages/runtime/src/domains/automation.ts",
        "packages/runtime/src/domains/keys.ts",
        "packages/runtime/src/domains/mcp.ts",
        "packages/runtime/src/domains/notifications.ts",
        "packages/runtime/src/domains/packages.ts",
        "packages/runtime/src/domains/security.ts"
      ],
      "domains_measured": [
        {
          "site": ":1282 POST /analytics/query, :1300 POST /analytics/sql",
          "domain": "analytics",
          "anonymous_first": "yes (first statement)",
          "base": "400 INVALID_REQUEST",
          "head": "401 UNAUTHENTICATED"
        },
        {
          "site": ":1326 /mcp (POST, GET, DELETE)",
          "domain": "mcp",
          "anonymous_first": "no: the isMcpServerEnabled() 404 and the service-probe 501 come first (finding 1)",
          "base": "400 INVALID_REQUEST",
          "head": "401 UNAUTHENTICATED with MCP on and installed; 404 / 501 otherwise, body unread"
        },
        {
          "site": ":1362 /mcp/skill (POST, GET, DELETE)",
          "domain": "mcp/skill",
          "anonymous_first": "no anonymous decision: public by design",
          "base": "400 INVALID_REQUEST",
          "head": "405 METHOD_NOT_ALLOWED (its own answer to a non-GET)"
        },
        {
          "site": ":1395 POST /keys, :1409 /keys (GET, PUT, PATCH, DELETE)",
          "domain": "keys",
          "anonymous_first": "yes (first statement)",
          "base": "400 INVALID_REQUEST",
          "head": "401 UNAUTHENTICATED"
        },
        {
          "site": ":1447 POST /notifications/read, :1456 POST /notifications/read/all",
          "domain": "notifications",
          "anonymous_first": "yes (first statement)",
          "base": "400 INVALID_REQUEST",
          "head": "401 UNAUTHENTICATED"
        },
        {
          "site": ":1676 :1735 :1753 :1762 :1771 :1795 :1824 :1848 :1857 /automation (x9)",
          "domain": "automation",
          "anonymous_first": "yes (first statement)",
          "base": "400 INVALID_REQUEST",
          "head": "401 UNAUTHENTICATED"
        },
        {
          "site": ":1947 :1955 :1963 POST /actions (x3)",
          "domain": "actions",
          "anonymous_first": "yes (first statement)",
          "base": "400 INVALID_REQUEST",
          "head": "401 UNAUTHENTICATED"
        },
        {
          "site": ":1909 /ai/* wildcards (outside the card grep)",
          "domain": "ai",
          "anonymous_first": "yes (decided once, ahead of every capability answer; a route's auth: false opens it)",
          "base": "400 INVALID_REQUEST",
          "head": "401 UNAUTHENTICATED"
        },
        {
          "site": ":1553 /packages registrar, 16 mounts (outside the card grep)",
          "domain": "packages",
          "anonymous_first": "yes (first statement)",
          "base": "400 INVALID_REQUEST",
          "head": "401 UNAUTHENTICATED"
        },
        {
          "site": ":2006 POST /security/_activation/:type/:name, :2020 POST /security/permission-sets/:name/clone (outside the card grep)",
          "domain": "security",
          "anonymous_first": "yes (first statement)",
          "base": "400 INVALID_REQUEST",
          "head": "401 UNAUTHENTICATED"
        }
      ]
    }

    Generated by Claude Code

  6. objectstack-fleet commented on Oct 18, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT → PR #24250 comment 6245468236 · domain:cli seat 2 · session session_01QyoWKK9RwUNxBxmevy9Neb · 2026-10-18T04:04Z · claim revised for the four extra mounts; filed #24251 and #24252; landing waits on every check of head 8e9fd41b2.


    Generated by Claude Code

  7. objectstack-fleet commented on Oct 18, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed: PR #24250 → 8eccf519d (#24151, Fixes #24151) · domain:cli seat 2 · marchtian · session session_01QyoWKK9RwUNxBxmevy9Neb · 2026-10-18T04:47Z

    Reading Result
    PR #24250 object merged: true, merge commit 8eccf519d, merged 2026-10-18T04:46Z through the merge queue (enqueued 2026-10-18T04:24Z)
    origin/main 8eccf519d is an ancestor of origin/main, with a single parent (squash). The 14 files and +521 / −25 match what was reviewed at 8e9fd41b2
    queue branch no gh-readonly-queue/main/*pr-24250* head remains

    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area:apiThe API a customer can call, and integrations — REST, connectors, webhooks, jobsbugSomething isn't workingdomain:clipriority:p3

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions