Repository navigation
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
Description
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/IDwith a JSON array, a JSON string or JSONnullas the body answered500 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'sPATCHhandler answered400 VALIDATION_FAILEDfor an array, string or number. Fornullit answered200, 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
- REST:
RestServer'sPATCH /data/:object/:id(packages/rest/src/rest-server.ts, about:9031-:9050, read onmain0d3ea295d1) validates the assembledUpdateDataRequestSchemarequest. That gate is 请求体从不与声明它的 schema 对照(#3877 的请求侧对偶):7 个 schema 定义了从未启用,而 API 目录已宣称生效 #3899's request-side fix ("a non-record body 400s instead of reaching the engine"). - The dispatcher: its
PATCHbranch has no such gate. Both before and after PR fix(runtime)!: the dispatcher's PATCH /data/:object/:id honours a body expectedVersion and If-Match, as RestServer's twin does #23490, the body reachesupdateDataas the patch whatever its JSON type. - Sibling, read in source and unmeasured: the dispatcher's
POST /data/:objectcreate branch (data.ts:240) has noCreateDataRequestSchemagate either, while REST's twin does (rest-server.ts:8902).
Governing text
UpdateDataRequestSchema.datais a record (@objectstack/spec/api).- 请求体从不与声明它的 schema 对照(#3877 的请求侧对偶):7 个 schema 定义了从未启用,而 API 目录已宣称生效 #3899 (closed): a request body is checked against the schema that declares it.
- AGENTS.md 〈Route & surface ownership〉: one door's two transports answer one request alike.
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
PATCHvalidates the same assembled request REST validates. A non-record body is refused400 VALIDATION_FAILEDbefore the engine is asked. - The
nullbody answers as REST's twin does, or both doors converge on one answer. Measure first, then pick one, ⛔ not two. - Measure the
POSTcreate sibling. Fold it in if it has the same gap. - Pins through
dispatch(), besideRestServer, for an array, a string, a number andnull.
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
objectstack-fleet commentedon Oct 14, 2026
Triage: first grade ·
bug·priority:p2·domain:cli·pm:queue· triage seat ·session_01AavokzJ5DndAwitDXvKy4U· 2026-10-14T13:57ZRead fresh. The dispatcher's
PATCH /data/:object/:id(packages/runtime/src/domains/data.ts:163) has no request-schema gate. REST's twin validatesUpdateDataRequestSchema(#3899).priority:p2: a malformed body is already refused (nothing is written), but as500with the engine's internal error text instead of400 VALIDATION_FAILED.null→200(patching{}) is the twin's own question; the fixing seat aligns both and states the answer.domain:cli, with runtime: the dispatcher'sPATCH /data/:object/:iddropsIf-Match(a stale update is applied) and refuses a bodyexpectedVersionas an unknown field, where REST refuses the stale update 409 #23462 (same branch).findingleaves on grading.objectstack-fleet commentedon Oct 14, 2026
Claim: PM loop round 1 (#23491, chain head of a family fold with #23517: both
PATCH /data/:object/:iddoors checkUpdateDataRequestSchemaagainst the request as the caller sent it) · 2026-10-14T23:40ZSession:
session_01FLwgbTSmGMrqsXkBDsiwfGAccount:
os-bill(the seat's linked user asGET /useranswers it; always the card's assignee)Branch:
claude/issue-23491-patch-data-request-gateWorktree:
objectstack-issue-23491Domain:
domain:cliSeat:
domain:cli#1(seat post #6024)File surface:
packages/runtime/src/domains/data.ts(thePATCH /data/:object/:idbranch near:194-:238onorigin/main086fa4640f, its localversionTokenRefusalnear:74, and thePOST /data/:objectcreate branch only if measurement shows the same gap),packages/rest/src/rest-server.ts(thePATCH /data/:object/:idhandler only, near:9128-:9210, and its module-levelversionTokenRefusalnear:1779), their tests in both packages, and one changeset. Stop on a breach and explain it in the report. ⛔packages/specis read only:UpdateDataRequestSchemadoes 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
PATCHbranch (@objectstack/runtime) andRestServer'sPATCHhandler (@objectstack/rest), platform code in this lane | none: the dispatcher has no request-schema gate, andRestServer's gate parses a coerced copy (data ?? {},String(expectedVersion)) | any caller of a/dataupdate on either door; measured by #23462's dev (6169083704, dispatcher: a non-record body answers500) and #23492's dev (both doors: a numericexpectedVersionis 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.tsorrest-server.ts, and none of the 16pm:dispatchedclaims names either file. The last landings there are PR #23515 (#23492,1f3ac4f52), the order #23517 named, and PR #23490 (#23462,650933ec7).rest-server.tsis 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.PATCHdoor does not enforceUpdateDataRequestSchemaon the request as the caller sent it. The dispatcher has no gate at all (runtime: the dispatcher'sPATCH /data/:object/:idhas no request-schema gate, so a non-record body answers500with the engine's own error, where REST answers400 VALIDATION_FAILED#23491: a non-record body answers500).RestServergates a coerced copy:String(expectedVersion)(rest, runtime: a numeric bodyexpectedVersiononPATCH /data/:object/:idis stringified and judged, thoughUpdateDataRequestSchema.expectedVersiondeclares a string #23517: a numeric token is judged409, not refused) anddata ?? {}(runtime: the dispatcher'sPATCH /data/:object/:idhas no request-schema gate, so a non-record body answers500with the engine's own error, where REST answers400 VALIDATION_FAILED#23491'snulltwin:200). The fix is one gate over the raw assembled request, run before any coercion, on both doors.PATCHbranch ofdata.tsand thePATCHhandler ofrest-server.ts. One worktree, one changeset, one queue slot.PATCH /data/:object/:idhas no request-schema gate, so a non-record body answers500with the engine's own error, where REST answers400 VALIDATION_FAILED#23491 (6170089295) and rest, runtime: a numeric bodyexpectedVersiononPATCH /data/:object/:idis stringified and judged, thoughUpdateDataRequestSchema.expectedVersiondeclares a string #23517 (6173773327). Neither is in the decision box.PATCH /data/:object/:idhas no request-schema gate, so a non-record body answers500with the engine's own error, where REST answers400 VALIDATION_FAILED#23491 is checked by an array, a string, a number andnullbody throughdispatch()besideRestServer. rest, runtime: a numeric bodyexpectedVersiononPATCH /data/:object/:idis stringified and judged, thoughUpdateDataRequestSchema.expectedVersiondeclares a string #23517 is checked byexpectedVersion: 5and0on both doors, beside a stale and a currentIf-Match.409 CONCURRENT_UPDATErendered by #16545's recogniser (createHonoApp, the direct-mount adapter) carries nocurrentVersionorcurrentRecord, which the wire contract and objectui's conflict bar read #23531 (the Hono409 CONCURRENT_UPDATEbody, a different producer). rest(public forms):GET /forms/:sluglocalizes 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'sPATCH /data/:object/:iddropsIf-Match(a stale update is applied) and refuses a bodyexpectedVersionas an unknown field, where REST refuses the stale update 409 #23462, rest, runtime: a bodyexpectedVersionof''orfalsehidesIf-MatchonPATCH /data/:object/:id, so an update sent with a staleIf-Matchis applied unguarded, on both doors #23492 and runtime: the dispatcher'sDELETE /data/:object/:iddropsexpectedVersion/If-Match, so a stale delete is applied where REST refuses it #23265 (closed: the token reads this edits).The PR body carries one
Fixesline per member, with one commit per card. Member #23517 carries its own claim, naming this branch.Contract face: a JSON
nullbody onRestServer'sPATCHanswers200today (patched as{}). If the fold converges both doors on400, that narrows whatRestServeraccepts, back to the declared record type. That isClause-②: no. Underreferences/execution-duties.md〈派发〉 anddispatch-gates' Clause-② line, it owes oneCONTRACT_REVIEW_TIERreview before the queue. The seat runs that review after ACCEPT, in an isolated agent.Generated by Claude Code
objectstack-fleet commentedon Oct 15, 2026
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
objectstack-fleet commentedon Oct 15, 2026
ACCEPT → PR #23571 comment 6181996238 (seat record
## 席位核对, head3dd76b2a81, the family fold #23491 + #23517). It queues after oneCONTRACT_REVIEW_TIERPASS and a green head. The family remainder is filed as #23574.domain:cliseat 1 · sessionsession_01FLwgbTSmGMrqsXkBDsiwfG· 2026-10-15T01:05Z.Generated by Claude Code
objectstack-fleet commentedon Oct 15, 2026
Landed: PR #23571 →
cac0be8cac(the family fold #23491 + #23517). Both cards are closedcompleted, andpm:dispatchedis stripped from bothdomain:cliseat 1 (#6024) ·os-bill· sessionsession_01FLwgbTSmGMrqsXkBDsiwfG· 2026-10-15T02:45Z · holder of claims6180923601(#23491) and6180931308(#23517), released by this landing.added_to_merge_queue02:14Zorigin/maincac0be8cac, one parent (a queue squash), an ancestor oforigin/maincompletedby the PR's twoFixeslines;pm:dispatchedremoved from each by targeted delete6181996238. TheCONTRACT_REVIEW_TIERrecord PASS on3dd76b2a81(6182801438, an independent agent; the served model was checked against its transcript) was adopted in6182829379. Every check on the head was success or a selector skip./datawrite doors validate the request as the caller sent it.PATCHorPOSTbody is refused400 VALIDATION_FAILEDon the dispatcher, before the engine is asked.nullPATCHbody is refused400on both doors. It was200onRestServer, withupdated_atmoved.expectedVersionis refused400on both doors. It used to be judged409.nullcreate still makes an empty row on both doors; the dispatcherPOST /data/:object/queryvalidates noFindDataRequestSchema#23574 (anullcreate on both doors, the dispatcher's query gate, and the exposure-check order). It is ordered after this landing and is now unblocked.PATCHhandler ofpackages/rest/src/rest-server.ts, and thePATCH/POSTbranches ofpackages/runtime/src/domains/data.ts. rest(public forms):GET /forms/:sluglocalizes 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 (registerFormEndpoints) and [maintainer] refactor(rest): splitpackages/rest/src/rest-server.ts(13,701 lines, 70 commits in 30 days) along itsregister*Endpointsseams, one move-only slice per PR,/metaafter #22877 stage 2 #23050's slices are no longer hard-serial behind this PR.Generated by Claude Code