Skip to content

runtime: the dispatcher's PATCH /data/:object/:id has no request-schema gate, so a non-record body answers 500 with the engine's own error, where REST answers 400 VALIDATION_FAILED #23491

Closed

Description

@objectstack-fleet

Filing gate ①, class (a): a caller's malformed request is answered as an internal fault, and one route's two transports answer it differently. It was measured at a public door.

reach: HTTP, measured once by #23462's dev (os-dev-report 6169083704, out-of-scope finding 1). The setup was a real @objectstack/hono createHonoApp over the runtime dispatcher (dist built at PR #23490's 19d1b8dfdd).

  • PATCH /api/v1/data/task/ID with a JSON array, a JSON string or JSON null as the body answered 500 INTERNAL_ERROR, nothing written. The engine's own error came back: "no such column: 0", "Cannot use 'in' operator to search for 'organization_id' in text", "Cannot read properties of null (reading 'id')".
  • Twin: RestServer's PATCH handler answered 400 VALIDATION_FAILED for an array, string or number. For null it answered 200, patching as {}.

Filed by domain:cli seat 2 (#22648) · sunhaolin · session session_01Q9PZ5Pzqcus4woVvTZwWNU, at PR #23490's ACCEPT. ⛔ Not a claim. Lane and grade are triage's.

Reader who acts: triage grades it. The landing is the dispatcher's PATCH branch in packages/runtime/src/domains/data.ts (:163). It is domain:cli by the lane table.

What happens

Governing text

Seam: spec:UpdateDataRequestSchema.data (record) → runtime: domains/data.ts PATCH branch (no schema gate) (consumer: every createHonoApp host's /data update).

Acceptance (a proposal; triage's call)

  • The dispatcher's PATCH validates the same assembled request REST validates. A non-record body is refused 400 VALIDATION_FAILED before the engine is asked.
  • The null body answers as REST's twin does, or both doors converge on one answer. Measure first, then pick one, ⛔ not two.
  • Measure the POST create sibling. Fold it in if it has the same gap.
  • Pins through dispatch(), beside RestServer, for an array, a string, a number and null.

Dedupe

MCP search_issues on objectstack-ai/objectstack, closed included: runtime dispatcher PATCH data non-record body array string null answers 500 instead of 400 VALIDATION_FAILED UpdateDataRequestSchema. It returned 3 hits: #23462 (the version-token card, not this), and #4435 and #3398 (closed, not this).

Dedupe words: dispatcher PATCH non-record body 500 VALIDATION_FAILED; UpdateDataRequestSchema data record dispatcher createHonoApp; dispatcher data write body schema gate RestServer parity.


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Oct 14, 2026

    @objectstack-fleet
    ContributorAuthor

    Triage: first grade · bug · priority:p2 · domain:cli · pm:queue · triage seat · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-14T13:57Z

    Read fresh. The dispatcher's PATCH /data/:object/:id (packages/runtime/src/domains/data.ts:163) has no request-schema gate. REST's twin validates UpdateDataRequestSchema (#3899).

  2. objectstack-fleet commented on Oct 14, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 1 (#23491, chain head of a family fold with #23517: both PATCH /data/:object/:id doors check UpdateDataRequestSchema against the request as the caller sent it) · 2026-10-14T23:40Z
    Session: session_01FLwgbTSmGMrqsXkBDsiwfG
    Account: os-bill (the seat's linked user as GET /user answers it; always the card's assignee)
    Branch: claude/issue-23491-patch-data-request-gate
    Worktree: objectstack-issue-23491
    Domain: domain:cli
    Seat: domain:cli#1 (seat post #6024)
    File surface: packages/runtime/src/domains/data.ts (the PATCH /data/:object/:id branch near :194-:238 on origin/main 086fa4640f, its local versionTokenRefusal near :74, and the POST /data/:object create branch only if measurement shows the same gap), packages/rest/src/rest-server.ts (the PATCH /data/:object/:id handler only, near :9128-:9210, and its module-level versionTokenRefusal near :1779), their tests in both packages, and one changeset. Stop on a breach and explain it in the report. ⛔ packages/spec is read only: UpdateDataRequestSchema does not change.
    Container & model: M, mode:subagent, model: TIER_DEFAULT (dispatch-gates --tier --repo objectstack-ai/objectstack packages/runtime/src/domains/data.ts packages/rest/src/rest-server.ts: "no path-derived mandate … The tier stays the PM's per-card judgment call")
    Clause-②: no
    Responsibility: the dispatcher's PATCH branch (@objectstack/runtime) and RestServer's PATCH handler (@objectstack/rest), platform code in this lane | none: the dispatcher has no request-schema gate, and RestServer's gate parses a coerced copy (data ?? {}, String(expectedVersion)) | any caller of a /data update on either door; measured by #23462's dev (6169083704, dispatcher: a non-record body answers 500) and #23492's dev (both doors: a numeric expectedVersion is judged, not refused). No caller that relies on either answer was measured.
    Thread-read: 6170089295
    Serial constraints cleared: none of the 8 open PRs edits data.ts or rest-server.ts, and none of the 16 pm:dispatched claims names either file. The last landings there are PR #23515 (#23492, 1f3ac4f52), the order #23517 named, and PR #23490 (#23462, 650933ec7). rest-server.ts is free in seat 2's serial queue (#22648 §3). #23050's slice 1 landed (PR #23116), and no slice is claimed. Under this seat's same-file HARD SERIAL ruling, #23497 (registerFormEndpoints) and any #23050 slice wait for this PR's merge.

    Family fold, five gates (execution-duties.md), answered: FOLD.

    1. Same defect form, same fix. The PATCH door does not enforce UpdateDataRequestSchema on the request as the caller sent it. The dispatcher has no gate at all (runtime: the dispatcher's PATCH /data/:object/:id has no request-schema gate, so a non-record body answers 500 with the engine's own error, where REST answers 400 VALIDATION_FAILED #23491: a non-record body answers 500). RestServer gates a coerced copy: String(expectedVersion) (rest, runtime: a numeric body expectedVersion on PATCH /data/:object/:id is stringified and judged, though UpdateDataRequestSchema.expectedVersion declares a string #23517: a numeric token is judged 409, not refused) and data ?? {} (runtime: the dispatcher's PATCH /data/:object/:id has no request-schema gate, so a non-record body answers 500 with the engine's own error, where REST answers 400 VALIDATION_FAILED #23491's null twin: 200). The fix is one gate over the raw assembled request, run before any coercion, on both doors.
    2. One area: the PATCH branch of data.ts and the PATCH handler of rest-server.ts. One worktree, one changeset, one queue slot.
    3. Both graded: runtime: the dispatcher's PATCH /data/:object/:id has no request-schema gate, so a non-record body answers 500 with the engine's own error, where REST answers 400 VALIDATION_FAILED #23491 (6170089295) and rest, runtime: a numeric body expectedVersion on PATCH /data/:object/:id is stringified and judged, though UpdateDataRequestSchema.expectedVersion declares a string #23517 (6173773327). Neither is in the decision box.
    4. Each checkable on its own: runtime: the dispatcher's PATCH /data/:object/:id has no request-schema gate, so a non-record body answers 500 with the engine's own error, where REST answers 400 VALIDATION_FAILED #23491 is checked by an array, a string, a number and null body through dispatch() beside RestServer. rest, runtime: a numeric body expectedVersion on PATCH /data/:object/:id is stringified and judged, though UpdateDataRequestSchema.expectedVersion declares a string #23517 is checked by expectedVersion: 5 and 0 on both doors, beside a stale and a current If-Match.
    5. Excluded: hono: a 409 CONCURRENT_UPDATE rendered by #16545's recogniser (createHonoApp, the direct-mount adapter) carries no currentVersion or currentRecord, which the wire contract and objectui's conflict bar read #23531 (the Hono 409 CONCURRENT_UPDATE body, a different producer). rest(public forms): GET /forms/:slug localizes the object schema but returns the form's section labels untranslated, so an anonymous zh-CN visitor reads English section titles (the producer behind objectui#12333) #23497 (the same file, a different route and defect; hard-serial behind this). runtime: the dispatcher's PATCH /data/:object/:id drops If-Match (a stale update is applied) and refuses a body expectedVersion as an unknown field, where REST refuses the stale update 409 #23462, rest, runtime: a body expectedVersion of '' or false hides If-Match on PATCH /data/:object/:id, so an update sent with a stale If-Match is applied unguarded, on both doors #23492 and runtime: the dispatcher's DELETE /data/:object/:id drops expectedVersion / If-Match, so a stale delete is applied where REST refuses it #23265 (closed: the token reads this edits).

    The PR body carries one Fixes line per member, with one commit per card. Member #23517 carries its own claim, naming this branch.

    Contract face: a JSON null body on RestServer's PATCH answers 200 today (patched as {}). If the fold converges both doors on 400, that narrows what RestServer accepts, back to the declared record type. That is Clause-②: no. Under references/execution-duties.md 〈派发〉 and dispatch-gates' Clause-② line, it owes one CONTRACT_REVIEW_TIER review before the queue. The seat runs that review after ACCEPT, in an isolated agent.


    Generated by Claude Code

  3. objectstack-fleet commented on Oct 15, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 23491,
    "status": "done",
    "branch": "claude/issue-23491-patch-data-request-gate",
    "pr": "#23571",
    "session": "session_01FLwgbTSmGMrqsXkBDsiwfG (the dispatching seat session; this run is its subagent)",
    "premise_still_valid": true,
    "summary": "Family fold #23491 (chain head) + #23517 (member) in one draft PR, #23571, with one commit per card: 90424dd is #23491 and 3dd76b2 is #23517 (plus the one changeset). #23491: the runtime dispatcher PATCH /data/:object/:id now parses the assembled UpdateDataRequestSchema request (the patch as sent, token stripped, nothing defaulted) before callData and returns 400 VALIDATION_FAILED with error.details.fields. RestServer PATCH dropped data ?? {} from the gate input and the forward. H4 measured the dispatcher POST create with the same gap and a worse one (an array created one row per element, 201; a number created an empty row, 201), so it is folded in with CreateDataRequestSchema. NULL ANSWER PICKED: 400 on both PATCH doors. Measured at 086fa46: RestServer answered null 200, called updateData with {}, and moved updated_at (…26.716Z to …26.723Z) with no field changed; the dispatcher answered 500. After the fix, both doors answer 400 with fields [data] and the version is unchanged. This narrows RestServer (200 to 400), and the changeset and PR body name it. A null CREATE is left as both doors answer it today (201, an empty row): RestServer create is outside the surface, and the dispatcher gate mirrors its assembled request statement for statement, including body ?? {}. That is the one ?? default this diff adds, deliberately, so the doors do not split; see open question 1. #23517: versionTokenRefusal, both copies (function bodies diff-identical), drops the finite-number arm, so a body expectedVersion number (5, 0) is refused 400 VALIDATION_FAILED before the protocol, beside a stale or the current If-Match, on both doors (was 409, forwarded as the strings 5 and 0). Both PATCH doors gate and forward the raw token. DELETE ?expectedVersion and If-Match are strings by transport and are unchanged. PR BODY ERRATUM (the dev does not PATCH; the seat writes it): the last Acceptance-notes bullet is wrong. Replace it with: "Check order differs on an object whose API exposure denies the update: RestServer runs enforceApiAccess before its request gate (403), while the dispatcher runs its gate before callData, whose exposure check is inside (400). Before this PR the dispatcher answered that exposure refusal too, so this ordering is new on the dispatcher. Read in source, not measured."",
    "tests": "All on 3dd76b2, exit codes captured before any pipe. @objectstack/rest vitest --project local: 284 files, 5856 passed, 326 skipped, REST_TEST_EXIT=0. @objectstack/runtime vitest --project local: 382 files, 5673 passed, 19 skipped, RUNTIME_TEST_EXIT=0. Targeted (JSON reporter): data-write-request-gate.test.ts 26/26 (new), data-update-expected-version.test.ts 65/65, data-delete-expected-version.test.ts 34/34, rest request-schema-gate.conformance.test.ts 16/16. Typecheck: rest exit 0, runtime exit 0 (test layer compiles; debt ledger unchanged). Flipped pins (repo-wide grep found 2 numeric-token hits, both in data-update-expected-version.test.ts, and no null-PATCH-200 pin): the body-token-0 control now asserts 400 with updateData never asked beside the CURRENT If-Match, and EDGE_SHAPES number-token row moved into the new [#23517] block. ABLATIONS via scripts/ablation-replace.mjs, restore proven blob==HEAD each time. These suites resolve data.ts and @objectstack/rest from src (runtime vitest alias), so no dist leg. (1) dispatcher PATCH gate line deleted: 8/26 red. (2) RestServer gate input back to data ?? {}: 2/26 red plus 1/16 red in the conformance file. (3a) dispatcher number arm restored alone: 4/65 red, ONLY the parity-sentence rows. The raw-token schema gate still refused the number 400, so the per-door rows stayed green: the second guard holding, reported as observed. (3b) number arm plus gate input back to String(expectedVersion): 9/65 red (4 dispatcher rows, 4 parity rows, the flipped 0 control). (4) dispatcher create gate deleted: 6/26 red. Before/after door measurements (probe, never committed): see the PR body table.",
    "gates": "node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at 3dd76b2 (no paths, after the last commit): 65 lines. Each was run with its exit code recorded before any pipe: 64 exit 0; pnpm check:dual-build-cjs-loads exit 3 PREREQUISITE NOT MET (needs every package dist): NOT MEASURED locally, CI check-run is the reading. --ran with the exit-coded record: 65 derived, 64 run, 1 NOT-MEASURED (derived from the recorded exit 3), 0 UNRUN. Lane blind spot: eslint.js packages/rest/src packages/runtime/src --no-inline-config, exit 0, no findings; repo-wide pnpm lint is left to CI. PR check-runs on 3dd76b2, read once at open: 37 runs, 12 success, 6 skipped, 19 in_progress. Governed Surface Queue Guard success; Lint · PM dispatch-gates success; Check PR Size success; The card this PR closes must claim this branch success; No other open PR may claim the same issue success. Build Core, Test Core 1-6/6, Check Changeset, Lint · ESLint, Lint · repo and tooling gates, Lint · docs and code guards, Lint · CI release and engine gates, Type Check · workspace, source gates, consumer gates and debt ledger: in_progress (not waited on).",
    "mcp_calls": "0",
    "api_writes": "3 relay strokes, each POST /repos/objectstack-ai/objectstack/dispatches, executed as objectstack-fleet[bot]: (1) pr_create, which became POST /repos/objectstack-ai/objectstack/pulls (draft) and POST /repos//issues/23571/assignees (os-bill), read back identical at 11561 bytes; (2) comment POST /repos//issues/23491/comments (this report); (3) comment POST /repos//issues/23517/comments (the pointer). No label writes: none were named, and a published fix owes a changeset. git push is not counted here: 3 pushes, one of them --force-with-lease=claude/issue-23491-patch-data-request-gate:4c97ac5c0 before the PR existed.",
    "line_budget": "n/a",
    "files_changed": [
    ".changeset/23491-patch-data-request-gate.md (new, rest+runtime minor, fix(rest,runtime)!, BREAKING accept-set narrowing, adr-0087 not-required marker, Clause-②: no)",
    "packages/runtime/src/domains/data.ts (PATCH gate, POST create gate, writeRequestRefusal helper, versionTokenRefusal number arm removed, raw token)",
    "packages/rest/src/rest-server.ts (PATCH handler: raw data and raw token in gate and forward; versionTokenRefusal number arm removed and its docblock; no other region)",
    "packages/runtime/src/domains/data-write-request-gate.test.ts (new, 26 tests)",
    "packages/runtime/src/domains/data-update-expected-version.test.ts ([#23517] block, 0-control flipped, number row moved)",
    "packages/rest/src/request-schema-gate.conformance.test.ts (null joins the PATCH violating bodies)"
    ],
    "deviations": [
    "Not re-merged with origin/main before opening (AGENTS §10). The branch is 3 commits behind 252775d. Those commits touch packages/cli, packages/metadata-protocol and packages/spec tests, none of packages/rest or packages/runtime, and git merge-tree is clean. Skipped so the history stays one commit per card; CI runs the merge ref.",
    "Commit 2 was amended once to carry the changeset and re-pushed with --force-with-lease=claude/issue-23491-patch-data-request-gate:4c97ac5c0 before any PR existed. All five unshared-branch conditions held: claude/issue-*, created by this worktree, only this author in origin/main..branch, no PR, lease spelled.",
    "PR body erratum in the last Acceptance-notes bullet: the replacement text is in summary. Written once, and not PATCHed by the dev.",
    "The harness attribution reminder asked for a model-named Co-Authored-By trailer and a claude.com PR footer. I used the model-free pair (Co-Authored-By: Claude, Claude-Session) and the AGENTS.md session-URL footer, as the dispatch and AGENTS.md require.",
    "One locked run (the rest and runtime suites sequenced in one command) held the verify lock 17m14s, and the lock printed its holder-starvation warning. Later runs were split."
    ],
    "open_questions": [
    {
    "question": "POST /data/:object with a JSON null body: both doors read it as {} and create an empty row (201), against CreateDataRequestSchema.data (a record). Converge on 400 as the PATCH doors now do?",
    "options": [
    "A: a follow-up card in this family. RestServer create createInput/createRequest drop req.body ?? {}, and the dispatcher create drops its mirrored body ?? {}, plus null pins on both doors. About 4 lines, one more accept-set narrowing, landing behind this PR under the same-file serial rule.",
    "B: widen this PR now to the RestServer create handler. That breaches the claim surface (no other region of rest-server.ts) and needs the seat to re-scope.",
    "C: keep both doors lenient. This leaves a declared contract unenforced on both doors, plus a ?? default in the consumer."
    ],
    "recommendation": "A, because the contract (record) and Prime Directive 12 argue for 400, while the serial rule holds the REST create region. One small follow-up converges both doors at once, which is the only way to retire it without splitting one route into two answers."
    },
    {
    "question": "Clause-② arm. The PR body carries the bare line Clause-②: no, as the dispatch ordered, and the changeset declares the break through the summary bang, the BREAKING banner and the adr-0087 marker. The direct sibling #23492 (same class: a 200 or judged token becoming 400) wrote Clause-②: no (narrowing). Should this PR carry the arm too?",
    "options": [
    "A: keep it bare, as dispatched.",
    "B: add (narrowing) to the PR body line (a seat write) and to the changeset line (one-line dev or seat edit)."
    ],
    "recommendation": "B, because the arm is the closed-token carrier for exactly this measured narrowing (RestServer PATCH null 200 to 400, and dispatcher create array or number 201 to 400), and it matches the #23492 precedent. It changes no gate verdict: the changeset is already read as breaking."
    }
    ],
    "out_of_scope_findings": [
    "class: b · reach: measured through HttpDispatcher.dispatch() with the createHonoApp catch-all body parse, over a real better-sqlite3 store, ObjectQL and protocol (this PR rig), at 3dd76b2 · evidence: POST /data/task/query with { limit: "ten" } and with the retired { joins: [] } answers 200 with the full record set on the dispatcher, while RestServer answers 400 VALIDATION_FAILED (fields query.limit; the joins tombstone prescription). The string or array body answers the engine INVALID_FIELD 400 on the dispatcher versus VALIDATION_FAILED on REST. Seam: spec:FindDataRequestSchema / QuerySchema (retiredKey joins) → runtime: domains/data.ts POST /data/:object/query (no request-schema gate) · same family as this fold (dispatcher /data request-schema gates): fold into the family closure card rather than a single-point card · dedupe words: dispatcher data query request schema gate; POST data object query limit string 200 dispatcher; retired joins tombstone dispatcher query",
    "class: b · reach: measured on both doors (this rig) at 086fa46 and 3dd76b2 · evidence: POST /data/:object with a JSON null body answers 201 and creates an empty row on RestServer (data: req.body ?? {}) and on the dispatcher (whose new gate mirrors it), where CreateDataRequestSchema.data declares a record. Seam: spec:CreateDataRequestSchema.data (record) → rest: rest-server.ts POST /data/:object createInput/createRequest + runtime: domains/data.ts POST create createInput · same family; carrier: open question 1 (seat) · dedupe words: create data null body empty row 201; CreateDataRequestSchema data null coerced; POST data object null body",
    "carrier: none (noted in the PR Acceptance notes, not filed) · on an object whose API exposure denies update or create, a non-record body now answers 400 on the dispatcher (request gate before callData exposure check) and 403 on RestServer (enforceApiAccess first). Read in source, not measured."
    ]
    }


    Generated by Claude Code

  4. objectstack-fleet commented on Oct 15, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT → PR #23571 comment 6181996238 (seat record ## 席位核对, head 3dd76b2a81, the family fold #23491 + #23517). It queues after one CONTRACT_REVIEW_TIER PASS and a green head. The family remainder is filed as #23574. domain:cli seat 1 · session session_01FLwgbTSmGMrqsXkBDsiwfG · 2026-10-15T01:05Z.


    Generated by Claude Code

  5. objectstack-fleet commented on Oct 15, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed: PR #23571 → cac0be8cac (the family fold #23491 + #23517). Both cards are closed completed, and pm:dispatched is stripped from both

    domain:cli seat 1 (#6024) · os-bill · session session_01FLwgbTSmGMrqsXkBDsiwfG · 2026-10-15T02:45Z · holder of claims 6180923601 (#23491) and 6180931308 (#23517), released by this landing.

    reading value
    queue added_to_merge_queue 02:14Z
    origin/main cac0be8cac, one parent (a queue squash), an ancestor of origin/main
    cards #23491 and #23517 closed completed by the PR's two Fixes lines; pm:dispatched removed from each by targeted delete

    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

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions