Repository navigation
rest: POST /api/v1/batch answers 500 for an update or delete op whose id is the empty string, where a missing id answers 400 VALIDATION_FAILED #24781
Description
Filing gate: ① a product defect with a named location and a live reproduction.
Reader: the packages/rest lane (batch-routes.ts), next PR touching the cross-object batch door.
Dedupe (open AND closed): "batch empty string id update delete 500 DATABASE_ERROR requires an id" → 0 hits; "cross-object /api/v1/batch blank id answers 500 instead of 400" → 7 hits, none on this (nearest: #24768, a NOT NULL door-parity card; #23491, the dispatcher PATCH schema gate — both different defects).
reach: public HTTP entry, measured once (twice) — POST /api/v1/batch on a stock showcase boot answers 500.
Path: api-backend.batch-transactional-discovery
QA-source: #24779 · api-backend.batch-transactional-discovery · observed beside the clauses (no clause failed)
Checklist item priority: P1
type: Bug
What happens
POST /api/v1/batch checks up front that every update / delete op names a target id, and answers 400 VALIDATION_FAILED "Operation 'update' on 'showcase_task' requires an id" when it does not. The check is op.id == null && op.data?.id == null (packages/rest/src/batch-routes.ts, the loop after enforceBatchSize), so an id that is the empty string passes it and reaches the engine, which throws Update requires an ID or options.multi=true. That throw is answered as a server fault:
updatewith"id": ""→ 500{"error":"Internal data error","code":"DATABASE_ERROR"}deletewith"id": ""→ 500{"error":"Internal server error","code":"INTERNAL_ERROR"}
Server log: [REST] Unhandled error: Error: Update requires an ID or options.multi=true (and Delete requires …).
A malformed client request is answered as a 5xx (the caller is told the server broke, and a retry policy keyed on 5xx retries it), and the two verbs disagree on the code.
Reproduction (stock showcase, objectstack dev --seed-admin, framework 580fd4ed)
- Sign in as admin@objectos.ai (
POST /api/v1/auth/sign-in/email), keep the cookie. POST /api/v1/batchbody{"operations":[{"object":"showcase_task","action":"update","id":"","data":{"done":false}}],"atomic":true}- expected: 400
VALIDATION_FAILED"… requires an id" (what the same op with noidkey answers) - actual: 500
DATABASE_ERROR"Internal data error"
- expected: 400
- Same with
{"object":"showcase_task","action":"delete","id":""}→ actual 500INTERNAL_ERROR.
Controls on the same boot:
- no
idkey → 400VALIDATION_FAILED"Operation 'update' on 'showcase_task' requires an id"; "id":"nonexistent123"→ 404RECORD_NOT_FOUND;- the per-object doors already treat the empty string as absent:
POST /api/v1/data/showcase_task/batchand/updateManywith{"id":""}answer a per-rowVALIDATION_FAILED"Record id is required for update".
Reproduced twice on fresh requests.
Mechanism and fix direction
The cross-object door's up-front check uses == null, while the per-object doors' row check treats a blank id as missing. Treat an empty (or non-string-ish) id as missing in that check so it answers the same 400, and pin both verbs (status + code, and that the engine is never called).
Full evidence chain: run record #24779.
Activity
objectstack-fleet commentedon Oct 20, 2026
Claim: PM loop round 6 ·
domain:cliseat 2 · p3 (filer-labelled; claimed under exception 2: the lane fits the table, and the body carries the filing gate's three answers. Triage spot-checks after) · 2026-10-20T07:07ZSession:
session_01LmjU2x1faLZ39DVTFyVca6Account:
os-justin(the seat's linked user asGET /useranswers it; always the card's assignee)Branch:
claude/issue-24781-batch-empty-id-400Worktree:
objectstack-issue-24781Domain:
domain:cliSeat:
domain:cli#2(seat post #22648)File surface (read on
origin/maina43405d59; line numbers move; stop on a breach and explain it in the report):packages/rest/src/batch-routes.ts, the cross-object/batchdoor's up-front id check (about:206). It readsop.id == null && op.data?.id == null, so anupdate/deleteop whose id is the empty string passes it and reaches the engine, which throws. That throw answers500(DATABASE_ERRORfor update,INTERNAL_ERRORfor delete), where a missing id answers400 VALIDATION_FAILED./data/:object/batchand the dispatcher's twin, if it has one. Where one shares the gap, it is fixed in the same rule and named in the PR body.packages/rest(next torest-batch-endpoint.test.ts):updateanddeletewith"id": ""answer400 VALIDATION_FAILEDand write nothing. A missing id and a real id answer as before.@objectstack/rest(patch).Update requires an ID…/Delete requires…throws and their error mapping, which are A database NOT NULL refusal answers differently per door: the dispatcher /data twin answers 500 INTERNAL_ERROR, and REST batch/import/form/action rows name the column key (RestServer POST/PATCH /data answer 400 with the label after #24715) #24768's region of door parity (open, untriaged);content/docs/references/api/batch.mdx, which PR feat(spec,objectql): report a caller value a stored expression replaced, as DroppedFieldsEvent reason stored_expression #24778 touches;packages/spec/**.Container & model:
S,mode:subagent,model: default tier(dispatch-gates.mjs --tier: "no path-derived mandate")Clause-②: no (narrowing)
Basis for the Clause-② line: a request the door accepted into the engine (and answered
500) is refused400up front. That narrows a public REST door's accept set and widens nothing. It owes oneCONTRACT_REVIEW_TIERreview of record before the queue, and it stays in this lane.Responsibility: this lane's REST door (
packages/rest). No platform path covers it: the engine refuses the write but answers it as a server fault. It is reachable by any authenticated/api/v1/batchclient, and it was measured on a stock showcase boot (card).Thread-read: none
Main-red hold: this lane's main-red main red on the full battery since 04:01Z: Dogfood 1/3 (audit-log-audit-capability, SystemWriteOrganizationRequiredError on sys_user_permission_set after the walled lift window) and Console Pin Gate (pinned objectui reads retired filterableFields) #24780 is open, so this card's PR does not arm the queue until main red on the full battery since 04:01Z: Dogfood 1/3 (audit-log-audit-capability, SystemWriteOrganizationRequiredError on sys_user_permission_set after the walled lift window) and Console Pin Gate (pinned objectui reads retired filterableFields) #24780's row 1 lands.
Serial constraints cleared:
packages/rest/src/batch-routes.tsor its batch tests. All 18 open PRs were read in this act. PR fix(lint)!: the overlay save door refuses a list view bound to an object that exists nowhere (#24668) #24789 touches three otherpackages/rest/src/meta-*tests, and PR feat(spec,objectql): report a caller value a stored expression replaced, as DroppedFieldsEvent reason stored_expression #24778 touchesbatch.mdx(held off) and anotherresttest.plugin-hono-server, CORS) and main red on the full battery since 04:01Z: Dogfood 1/3 (audit-log-audit-capability, SystemWriteOrganizationRequiredError on sys_user_permission_set after the walled lift window) and Console Pin Gate (pinned objectui reads retired filterableFields) #24780 (a dogfood fixture), are disjoint. Seat 1 has no card in flight in this lane at this read.pm:on-holdcards'Restart-touch:lines (15 cards, 1 line:scripts/test-shard-timings.json) have no hit.qa(checklist): clauses for three defects round 3 found outside the le…
qa(checklist): record the a43405d re-measure of the batch and validat…
objectstack-fleet commentedon Oct 20, 2026
os-dev-report
{
"issue": 24781,
"status": "done",
"branch": "claude/issue-24781-batch-empty-id-400",
"pr": "#24820",
"session": "session_01LmjU2x1faLZ39DVTFyVca6 (mode:subagent, the parent PM session's id; the relay envelope stamped it)",
"premise_still_valid": true,
"summary": "Premise reproduced on main a43405d with a real-engine harness: update with id "" answers 500 DATABASE_ERROR, delete with id "" answers 500 INTERNAL_ERROR. packages/rest/src/batch-routes.ts now reads each update/delete op's row address once (batchOpRowAddress: update = id, else data.id; delete = id alone, all its arm ever sent), judges it with the engine's own by-id predicates (resolveEngineUpdateDispatch / resolveEngineDeleteDispatch from @objectstack/metadata-core), and both write arms read the same address. Every address that can never name a row ('', 0, booleans, objects, arrays) now gets the missing-id 400 VALIDATION_FAILED sentence before the transaction opens. A whitespace id is a by-id address and keeps answering 404, and a data.id {$ref} keeps resolving. Also fixed in place under the bounded exemption (same check, same defect class): a delete addressed only by data.id used to pass the check and then answer 400 INVALID_FILTER from the engine; it now gets the same missing-id 400. Zone 2 item 3 measured and falsified: the per-object /data/:object/batch, updateMany and deleteMany already refuse a blank id per row (protocol falsy check), and the runtime dispatcher has no /batch branch, so there is no shared helper. Changeset graded minor, not the dispatched patch (see deviations).",
"tests": "New packages/rest/src/rest-batch-blank-id.test.ts (real ObjectQL + sqlite SqlDriver + real protocol + real route, objectQLProvider wired): 11 refusal rows assert full {status, body} plus engine.transaction never called plus the seeded row unchanged; 1 row proves an earlier create in the batch is not written; 8 controls. On main a43405d: 'Tests 12 failed | 8 passed (20)' (update rows 500 DATABASE_ERROR 'Internal data error'; delete '' rows 500 INTERNAL_ERROR 'Internal server error'; delete by data.id only 400 INVALID_FILTER). Ablation 1 (fix committed at c2ad0e6dac7e blob; ablation-replace.mjs swapped the check back to the old == null test, anchor 1 to 0, blob c2ad0e6dac7e to 7be150e2b515): 'Tests 12 failed | 8 passed (20)', the same rows; restored blob == HEAD, git diff HEAD empty. Ablation 2 (the isBatchRef carve-out deleted): exactly the $ref control failed, 'expected 400 to be 200', 'Tests 1 failed | 19 passed (20)'; restored hash-equal. The path is src (relative import of ./rest-server), so no dist rebuild was involved. Final head 0d4ea3e (after merging origin/main 307ce4c): 'pnpm --filter @objectstack/rest exec vitest run --project local --maxWorkers=2' gave 'Test Files 305 passed (305)', 'Tests 6144 passed | 326 skipped (6470)', VERDICT command-exit 0. 'pnpm --filter @objectstack/rest typecheck' VERDICT command-exit 0 ('check:test-typecheck: OK ... 0 file(s) / 0 error(s)'). Pre-merge, the same suite on 3c1d02d also passed 305/305.",
"gates": "Local, final head 0d4ea3e: 'node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack' derived 67 commands (merge base 307ce4c, 3 paths). All 67 ran with exit 0, each captured before any pipe. '--ran' reconciliation: '67 derived, 67 run, 0 NOT-MEASURED, 0 UNRUN', exit 0. pnpm lint (eslint . --no-inline-config, the family the derivation does not name) on 0d4ea3e: exit 0. Earlier run on 3c1d02d: check:query-options-erasure exit 1 (test surface 236 to 237, from my test's 'as any' read-back), fixed in 7ee83bc by typing the query, now green at 236; check:dual-build-cjs-loads exit 3 PREREQUISITE NOT MET (no dist on other packages), then exit 0 on the final head. Changeset gates: check-adr-0087-registration exit 0 ('[BREAKING+bang+clause-2-narrowing] not-required (no-migration-prescription)'). check-changeset-no-major with this PR's body exit 0 ('LEVEL AXIS ... no (narrowing)'). Not run locally: test:repo (the diff touches none of its 5 files; CI Test Core); check-platform-checklist (no exported symbol renamed or deleted). CI on PR #24820 head 0d4ea3e at report time: 24 check-runs, 21 in_progress, 2 skipped, 1 success; Check Changeset in_progress; Governed Surface Queue Guard in_progress; Lint & Repo Gates, TypeScript Type Check, Test Core and Build Core not yet listed. All in_progress.",
"mcp_calls": "0 (none)",
"api_writes": "2 relay dispatches through scripts/pm/fleet-write (POST /repos/objectstack-ai/objectstack/dispatches, run as objectstack-fleet[bot]): (1) pr_create, which ran POST /repos/objectstack-ai/objectstack/pulls (draft, #24820) and POST /repos//issues/24820/assignees (os-justin); the relay read back 8796 bytes sent and 8796 stored, identical. (2) comment, which ran POST /repos//issues/24781/comments (this report). No label write: the dispatch named none, and skip-changeset does not apply. git push of the branch x5 is not REST.",
"open_questions": [],
"out_of_scope_findings": [
"carrier: #24768 · the engine's 'Update requires an ID...' / 'Delete requires...' throws still answer 500 wherever another caller reaches them, and DATABASE_ERROR and INTERNAL_ERROR disagree between the two verbs. This is the held-off door-parity region; noted in the PR's Acceptance notes, not filed."
],
"line_budget": "287 changed lines (+280 / -7, 3 files) against the 3000-line human-merge threshold: under. No skills/** and no governed surface touched.",
"deviations": [
"Changeset graded minor, not the dispatched patch. Measured on a throwaway commit object: check-changeset-no-major.mjs with the claim's 'no (narrowing)' declaration and a patch grade on @objectstack/rest exits 1 ('clause-2 declares no (narrowing) while no moved package is graded minor or above'). With minor it exits 0. AGENTS.md: a narrowing is breaking and ships minor in the launch window. The changeset carries the declaration line and an ADR-0087 not-required (no-migration-prescription) marker.",
"Scope: a delete addressed only by data.id (before 400 INVALID_FILTER, after 400 VALIDATION_FAILED missing-id) was fixed in place under the bounded exemption: same check, same defect class, same file, same gate family. It is listed in the PR's Contract table.",
"Zone 2 item 2, 'atomic true and false alike': on this door atomic:false is refused 400 BATCH_NOT_ATOMIC before the id check (measured), so the rule holds with atomic true and with atomic omitted.",
"Merged origin/main 307ce4c before opening the PR (AGENTS.md section 10). Install, build, the rest suite, typecheck, the gate union and lint were all re-run on the merge head 0d4ea3e.",
"Commit trailers use the model-free Claude-Session / Co-authored-by pair that AGENTS.md and the pre-push hook require, not the harness reminder's model-named trailer.",
"Worktree cleaned after the PR opened: node_modules removed and 'git worktree remove' exited 0."
],
"files_changed": [
".changeset/24781-rest-batch-blank-id-400.md (+23)",
"packages/rest/src/batch-routes.ts (+70 / -7)",
"packages/rest/src/rest-batch-blank-id.test.ts (+187, new)"
]
}
objectstack-fleet commentedon Oct 20, 2026
ACCEPT → PR #24820 comment
6286890326·domain:cliseat 2 · sessionsession_01LmjU2x1faLZ39DVTFyVca6· 2026-10-20T08:34Z · head0d4ea3e5b. The review of record is6286879313(CONTRACT_REVIEW_TIER, PASS). A blank, falsy or non-scalar id atPOST /batchnow gets the missing-id400before the transaction opens, and no request that succeeded before is refused. Landing waits on every check of the head and on this lane's main-red #24780 (row 3).Generated by Claude Code
objectstack-fleet commentedon Oct 20, 2026
Still reproduces at 307ce4c · QA-source: #24841 · api-backend.batch-transactional-discovery · observed beside the clauses (update with
"id":""→ 500 DATABASE_ERROR, delete → 500 INTERNAL_ERROR, twice each)Generated by Claude Code
objectstack-fleet commentedon Oct 20, 2026
Landed: PR #24820 →
5fcc79497(Fixes #24781)domain:cliseat 2 ·os-justin· sessionsession_01LmjU2x1faLZ39DVTFyVca6· 2026-10-20T09:45Zadded_to_merge_queue2026-10-20T09:17Z, merged 2026-10-20T09:45Z)origin/main5fcc79497is on it, with a single parent99e4eaa22(squash). 3 files, +280 / −7. Each file's blob equals the ACCEPTed head0d4ea3e5b's (c2ad0e6da,64f091a3a,ca4482a4c)gh-readonly-queue/main/*pr-24820*head remainscompletedby the PR'sFixes. In the stroke after this comment,pm:dispatchedcomes off6285421221→ report6286676584→ review of record6286879313(CONTRACT_REVIEW_TIER, PASS) → ACCEPT6286890326on the PR → pointer on this card.POST /batch, and its environment-scoped twin, judge anupdate/deleteop's row address with the engine's own by-id predicates. A blank, falsy or non-scalar address gets the missing-id400 VALIDATION_FAILEDbefore the transaction opens. No request that answered2xxor wrote a row before is refused.@objectstack/restships it asminor, BREAKING, under the launch-window convention.… requires an IDthrows still answer500for other callers, which is A database NOT NULL refusal answers differently per door: the dispatcher /data twin answers 500 INTERNAL_ERROR, and REST batch/import/form/action rows name the column key (RestServer POST/PATCH /data answer 400 with the label after #24715) #24768's door-parity region.Generated by Claude Code