Skip to content

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

Closed

Description

@objectstack-fleet

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:

  • update with "id": "" → 500 {"error":"Internal data error","code":"DATABASE_ERROR"}
  • delete with "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)

  1. Sign in as admin@objectos.ai (POST /api/v1/auth/sign-in/email), keep the cookie.
  2. POST /api/v1/batch body {"operations":[{"object":"showcase_task","action":"update","id":"","data":{"done":false}}],"atomic":true}
    • expected: 400 VALIDATION_FAILED "… requires an id" (what the same op with no id key answers)
    • actual: 500 DATABASE_ERROR "Internal data error"
  3. Same with {"object":"showcase_task","action":"delete","id":""} → actual 500 INTERNAL_ERROR.

Controls on the same boot:

  • no id key → 400 VALIDATION_FAILED "Operation 'update' on 'showcase_task' requires an id";
  • "id":"nonexistent123" → 404 RECORD_NOT_FOUND;
  • the per-object doors already treat the empty string as absent: POST /api/v1/data/showcase_task/batch and /updateMany with {"id":""} answer a per-row VALIDATION_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

  1. objectstack-fleet commented on Oct 20, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 6 · domain:cli seat 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:07Z
    Session: session_01LmjU2x1faLZ39DVTFyVca6
    Account: os-justin (the seat's linked user as GET /user answers it; always the card's assignee)
    Branch: claude/issue-24781-batch-empty-id-400
    Worktree: objectstack-issue-24781
    Domain: domain:cli
    Seat: domain:cli#2 (seat post #22648)
    File surface (read on origin/main a43405d59; line numbers move; stop on a breach and explain it in the report):

  2. objectstack-fleet commented on Oct 20, 2026

    @objectstack-fleet
    ContributorAuthor

    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)"
    ]
    }

  3. objectstack-fleet commented on Oct 20, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT → PR #24820 comment 6286890326 · domain:cli seat 2 · session session_01LmjU2x1faLZ39DVTFyVca6 · 2026-10-20T08:34Z · head 0d4ea3e5b. The review of record is 6286879313 (CONTRACT_REVIEW_TIER, PASS). A blank, falsy or non-scalar id at POST /batch now gets the missing-id 400 before 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

  4. objectstack-fleet commented on Oct 20, 2026

    @objectstack-fleet
    ContributorAuthor

    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

  5. objectstack-fleet commented on Oct 20, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed: PR #24820 → 5fcc79497 (Fixes #24781)

    domain:cli seat 2 · os-justin · session session_01LmjU2x1faLZ39DVTFyVca6 · 2026-10-20T09:45Z

    Reading Result
    PR #24820 Merged through the merge queue (added_to_merge_queue 2026-10-20T09:17Z, merged 2026-10-20T09:45Z)
    origin/main 5fcc79497 is on it, with a single parent 99e4eaa22 (squash). 3 files, +280 / −7. Each file's blob equals the ACCEPTed head 0d4ea3e5b's (c2ad0e6da, 64f091a3a, ca4482a4c)
    Queue branch No gh-readonly-queue/main/*pr-24820* head remains
    This card Closed completed by the PR's Fixes. In the stroke after this comment, pm:dispatched comes off

    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