Skip to content

fix(rest)!: POST /batch answers an update or delete op whose id names no row with the missing-id 400, not a 500 - #24820

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-24781-batch-empty-id-400
Oct 20, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-24781-batch-empty-id-400

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #24781

Clause-②: no (narrowing)

What changed

POST /api/v1/batch (the cross-object transactional batch, and its environment-scoped twin from the same registrar) checked up front that an update / delete op names a row. But it tested only for an absent or null id: op.id == null && op.data?.id == null. An id the engine does not take as one passed that check and reached ql.update / ql.delete. The engine's Update requires an ID or options.multi=true / Delete requires an ID or options.multi=true throw then answered as a server fault.

The fix is in packages/rest/src/batch-routes.ts:

  • batchOpRowAddress(op, data) is the one reading of the row an op addresses. update reads id, else data.id (the arm's own op.id ?? data?.id). delete reads id alone, which is all its arm ever sent. The up-front check and both write arms read it, so the check can no longer pass an op that its arm then sends with no address.
  • namesNoRow(action, address) judges that address with the engine's own by-id predicates, resolveEngineUpdateDispatch / resolveEngineDeleteDispatch from @objectstack/metadata-core. These are the functions ObjectQL.update / ObjectQL.delete dispatch on, and @objectstack/rest already depends on that package. A by-id address is a truthy string or number.
  • isBatchRef is now the one spelling of the { $ref } test, shared with the ref resolver. An update whose data.id references an earlier create is not judged up front; the resolver still refuses an unresolvable one with BATCH_UNRESOLVED_REF.

The refusal's code and sentence are the ones a missing id already got: 400 VALIDATION_FAILED, Operation 'update' on 'OBJECT' requires an id (or 'delete').

Contract

These are all the inputs whose answer changes. Before, the transaction opened, the engine threw and the batch rolled back, so nothing was written. Now the batch is refused before the transaction opens, and nothing is written.

op on cross-object POST /batch before after
update, "id": "" (the card) 500 DATABASE_ERROR, "Internal data error" 400 VALIDATION_FAILED, Operation 'update' on 'OBJECT' requires an id
update, no id, data.id is "", 0, false, true, {} or [] 500 DATABASE_ERROR the same 400
update, "id": "" beside a real data.id (id is read first) 500 DATABASE_ERROR the same 400
delete, "id": "" (the card), with or without a data.id 500 INTERNAL_ERROR, "Internal server error" 400 VALIDATION_FAILED, Operation 'delete' on 'OBJECT' requires an id
delete, no id, the record's id only in data.id 400 INVALID_FILTER, the engine's filter guard ("Filter comparand at where.id is undefined …") the same 400 VALIDATION_FAILED "… requires an id"
any of the above, where a later step used to answer first (the per-object API-exposure gate's 404 / 405, or another op in the batch) that later step's answer this 400. The id check runs before the exposure check and the transaction, as it already did for a missing id

These answers do not move, and the pins below hold them as controls:

  • A missing or null id gets the same 400.
  • An update by id or by data.id, and a delete by id, write as before.
  • An update whose data.id is { "$ref": 0 } still resolves to the row created by op 0.
  • An unknown id answers 404 RECORD_NOT_FOUND. So does a whitespace id (" "), which the engine looks up like any other id.
  • atomic: false still answers 400 BATCH_NOT_ATOMIC first, because that check comes before this one. With atomic: true and with atomic omitted, the rule is the same.

The delete-by-data.id row is fixed in place under the bounded exemption. It is the same defect class (the up-front check passed an op that its arm sent with no address), in the same check, the same file and the same gate family. Leaving it out would mean the check knowingly accepts data.id for a delete while the arm ignores it.

Which ids count as missing

The rule is "refuse what can never address a record", judged by the engine. Measured with resolveEngine*Dispatch and with the 500s above on main:

  • "", 0, booleans, objects and arrays are never a by-id address, so they are refused.
  • A whitespace string is a by-id address. The engine and the driver look it up, and the answer is an honest 404, so it is not refused here.
  • null in data.id was already refused.
  • op.id is z.string().optional() in the spec, so a non-string id is still refused by the schema (Invalid batch request) before this step.

This is not id-format validation.

Other doors that serve the operation

  • The per-object POST /data/:object/batch, updateMany and deleteMany have no REST-level up-front id check. The protocol's row check is if (!record.id) throw rowRequiredIdError(…), a falsy test. Measured with the same real-engine harness:

    • update / delete rows with "id": "" answer a per-row VALIDATION_FAILED, "Record id is required for update" (or delete).
    • deleteMany with ids: [""] answers 400 VALIDATION_FAILED.
    • A whitespace id answers a per-row 404.

    These doors do not share the gap, so there is no shared helper.

  • @objectstack/runtime's dispatcher has no /batch branch. domains/data.ts routes only query, and http-dispatcher.ts reports transactionalBatch: false for exactly that reason. There is no twin.

Tests

The new packages/rest/src/rest-batch-blank-id.test.ts runs a real engine (ObjectQL + sqlite SqlDriver), the real protocol and the real route, with objectQLProvider wired. It has:

  • 11 refusal rows. Each asserts the whole { status, body } (status, code and sentence), that engine.transaction was never called, and that the seeded row is unchanged.
  • One row proving that an earlier create in the same batch is not written.
  • 8 controls, the ones listed above.

Red and green:

  • On main a43405d59, the pin file gave 12 failed / 8 passed. Update rows answered 500 DATABASE_ERROR, delete "" rows 500 INTERNAL_ERROR, and delete-by-data.id 400 INVALID_FILTER.
  • Ablation 1, with the fix committed: scripts/ablation-replace.mjs swapped the check back to op.id == null && op.data?.id == null. The landing was proven by the anchor count going 1 → 0 and the blob going c2ad0e6dac7e → 7be150e2b515. Result: 12 failed / 8 passed, the same rows.
  • Ablation 2: the isBatchRef carve-out deleted. Exactly the $ref control failed (expected 400 to be 200).
  • Both ablations were restored with blob == HEAD and git diff HEAD empty.
  • After the fix: pin file 20/20. pnpm --filter @objectstack/rest exec vitest run --project local --maxWorkers=2 on 0d4ea3e5b (after merging origin/main 307ce4c4b): 305 files passed, 6144 tests passed, 326 skipped. pnpm --filter @objectstack/rest typecheck (tsc plus the test-layer check) exited 0.

Gates (local, on 0d4ea3e5b)

  • node scripts/pm/dispatch-gates.mjs --commands derived 67 commands for this diff against merge base 307ce4c4b. All 67 ran and exited 0, with each exit code captured before any pipe. The --ran reconciliation reports 67 derived, 67 run, 0 NOT-MEASURED, 0 UNRUN.
  • pnpm lint (eslint . --no-inline-config), the family the derivation does not name: exit 0.
  • test:repo (the repo vitest project) was not run locally. The diff touches none of its five files, and CI's Test Core runs it.

Changeset

@objectstack/rest: minor, carrying this PR's narrowing declaration line and an ADR-0087 not-required (no-migration-prescription) disposition. The dispatch asked for patch. Measured on a throwaway commit object (no branch or tree change): check-changeset-no-major.mjs with this PR's declaration line and a patch grade on @objectstack/rest exits 1 ("clause-② declares no (narrowing) while no moved package is graded minor or above"). With minor it passes. A narrowing ships minor in the launch window.

Acceptance notes


Generated by Claude Code

…ing-id 400

The cross-object batch's up-front id check read `op.id == null &&
op.data?.id == null`, so an address the engine does not take as an id (the
empty string, 0, a boolean, an object or an array in data.id) passed it and
the engine's "requires an ID" throw answered 500. A delete naming its id only
in data.id passed it too, though the delete arm never read data.id.

The check now judges the address each arm hands the engine
(batchOpRowAddress), with the engine's own by-id predicates, and both arms
read that same address.

Claude-Session: https://claude.ai/code/session_01LmjU2x1faLZ39DVTFyVca6
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 20, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

5 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 18 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json c56115bf962cb301fb046c54680620d37e5ab2fb → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 69459504060ff760f072257b064a5882ead74c0e — the merge of head 0d4ea3e5b0f7e2109f5dc661b1ea02aede4c7acd into base c56115bf962cb301fb046c54680620d37e5ab2fb, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 69459504060ff760f072257b064a5882ead74c0e && git checkout 69459504060ff760f072257b064a5882ead74c0e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c56115bf962cb301fb046c54680620d37e5ab2fb 0d4ea3e5b0f7e2109f5dc661b1ea02aede4c7acd && git checkout -B drift-repro c56115bf962cb301fb046c54680620d37e5ab2fb && git merge --no-ff 0d4ea3e5b0f7e2109f5dc661b1ea02aede4c7acd

node scripts/docs-audit/affected-docs.mjs --json c56115bf962cb301fb046c54680620d37e5ab2fb

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 0d4ea3e5b0f7e2109f5dc661b1ea02aede4c7acd
Local-runs: none
Scope: packages/rest/src/batch-routes.ts, packages/rest/src/rest-batch-blank-id.test.ts + packages/rest/src/rest-server.ts (the per-base-path registrar loop at 4796, the environment-scoped twin), packages/metadata-core/src/engine-update-dispatch.ts, packages/metadata-core/src/engine-delete-dispatch.ts, packages/metadata-core/src/index.ts, packages/objectql/src/engine.ts (the update dispatch at 16854 and the delete dispatch at 19900), packages/spec/src/api/batch.zod.ts (CrossObjectBatchOperationSchema, CrossObjectBatchRequestSchema, DeleteManyRequestSchema), packages/spec/src/data/filter-comparand-type.ts, packages/metadata-protocol/src/protocol.ts (rowRequiredIdError at 16714/16786/17277, deleteManyData at 17391), packages/runtime/src/http-dispatcher.ts, packages/runtime/src/domains/data.ts, packages/rest/package.json, scripts/check-changeset-no-major.mjs, scripts/check-adr-0087-registration.mjs, .github/workflows/pr-automation.yml (WHICH LEVEL), .changeset/config.json, .changeset/pre.json, .claude/agents/os-dev.md (the bounded in-place-fix exemption); prose face .changeset/24781-rest-batch-blank-id-400.md

① Derived judgments

Gate state at render (latest run per name on the head, 40 names).

  • 34 completed success or skipped. Green: Lint & Repo Gates, TypeScript Type Check, Build Core, Governed Surface Queue Guard, Check Changeset, Check PR Size, every Lint and Type Check lane (the scoped query-options erasure lane included), and the claim and issue guards.
  • Test Core (1/6) through (6/6) are in_progress: NOT MEASURED, never a pass. The dev's local run (305 files, 6144 tests) is the implementation claim, not the gate.
  • Judged on the diff.

Net diff vs main (c56115bf9, merge base 307ce4c4b): 3 files, +280/-7. No governed surface. The head repo is the base repo. Far under 3,000 lines.

The door and its twin. registerBatchEndpoints(bp) runs once per base path (rest-server.ts:4796). So POST /api/v1/batch and POST /api/v1/environments/:environmentId/batch are one handler, and isScoped changes only how environmentId is read. Both mounts move together. Right.

The new rule, read against the engine.

  • batchOpRowAddress: update reads op.id ?? data?.id, and delete reads op.id. These are byte-identical to the expressions the two write arms already used (const id = op.id ?? data?.id; where: { id: op.id }). Both arms now call the helper, so the check and the arm cannot diverge.
  • namesNoRow asks resolveEngineUpdateDispatch({ id }, undefined) / resolveEngineDeleteDispatch({ where: { id } }) whether kind !== 'by-id'. Those are the functions ObjectQL.update (engine.ts:16854) and ObjectQL.delete (engine.ts:19900) dispatch on, exported from @objectstack/metadata-core's index.
  • The batch arms pass no where and no multi. So for these calls the engine's only non-reject outcome is by-id, which is a truthy string / number / bigint (asScalarId, scalarDeleteId, then if (id)).
  • Every address the check now refuses is therefore one the engine would have thrown on (… requires an ID or options.multi=true) inside the atomic transaction. The batch rolled back and answered 5xx.
  • No request that answered 2xx or wrote a row before is refused now. That holds by construction, not only by the pins.
  • @objectstack/metadata-core is already a dependencies entry of @objectstack/rest, imported by six other rest source files. No new package edge.

The PR body's ## Contract table, row by row:

  • update, "id": "": was 500 DATABASE_ERROR, now the missing-id 400. Right. The card measured the 500, and "" is a falsy scalar, never by-id.
  • update, no id, data.id in "" / 0 / false / true / {} / []: right. asScalarId yields undefined for the non-scalars, and the falsy scalars fail if (id). Before, each reached the engine and was rejected.
  • update, "id": "" beside a real data.id: right. "" is not nullish, so ?? keeps it in the check and in the arm alike.
  • delete, "id": "", with or without data.id: was 500 INTERNAL_ERROR, now the 400. Right. Delete reads id alone, and "" is falsy.
  • delete, no id, the id only in data.id: was 400 INVALID_FILTER, now 400 VALIDATION_FAILED. Right.
    • The old check passed on op.data?.id, and the arm then sent where: { id: undefined }.
    • The spec's filter-comparand guard refuses an undefined comparand before dispatch (scalarDeleteId would reject after it), so nothing was ever deleted through data.id.
    • This is a code change on an already-refused shape, and it is named honestly.
  • "a later step used to answer first": right. The id loop sits where it always did, on main and on the head alike: after enforceBatchSize, and before loadObjectItems / apiAccessDenialFromEnable and the transaction. A blank id now takes the path a missing id already took.

The unchanged list. Every item is right:

  • a missing or null id → the same 400 (null is not a scalar);
  • a real id, by id or data.id (update) or by id (delete) → by-id, with the arms unchanged;
  • data.id: { $ref: n } → isBatchRef is the old inline predicate verbatim (v && typeof v === 'object' && '$ref' in v), so the check skips it, and the in-transaction resolver still refuses an unresolvable one with BATCH_UNRESOLVED_REF;
  • unknown and whitespace ids → truthy strings, by-id, and the engine answers 404;
  • atomic: false → BATCH_NOT_ATOMIC precedes the loop, and an omitted atomic defaults to true in the schema;
  • op.id is z.string().optional(), so a non-string id gets the schema's 400 first.

Other doors. Both are right and untouched by the diff:

  • The per-object batchData rows refuse on if (!record.id) throw rowRequiredIdError(...) (protocol.ts:16714, :16786, and update again at :17277). That is a falsy test, so "" is refused per row.
  • deleteManyData's isScalarId admits a string only when it is non-empty, so ids: [""] answers 400 VALIDATION_FAILED.
  • @objectstack/runtime has no twin to fix: http-dispatcher.ts:2263 reports transactionalBatch: { enabled: false }, and domains/data.ts mounts no /batch.

Public surface.

  • batch-routes.ts still exports only BatchRouteHost and registerBatchEndpoints. The three helpers are module-private.
  • No spec, export, type or stored shape moves. The accept-set change is confined to the one door (two mounts).

Pins.

  • 11 refusal rows assert the whole { status, body }, that engine.transaction is never called, and that the seeded row is unchanged.
  • One row pins that an earlier create is not written, and 8 controls cover the rest.
  • They were red on main (12 failed, 8 passed), and the two ablations were restored hash-equal.
  • That is the pin shape the card asked for: status, code, and the engine never called.
  • The test imports are package imports of declared rest devDependencies (92 rest tests already boot @objectstack/driver-sql), with no cross-package path read.

Noted, not a verdict item: CrossObjectBatchOp restates the spec's CrossObjectBatchOperationParsed by hand. It names the inline literal the old line already carried, with the same shape.

② Semver level

The changeset is right on all three axes. .changeset/24781-rest-batch-blank-id-400.md carries:

  • '@objectstack/rest': minor, with a **BREAKING** banner;
  • the adr-0087 comment marker not-required (no-migration-prescription);
  • Clause-②: no (narrowing), the same line the PR body carries.

Level.

  • The tree is in pre mode (.changeset/pre.json; rest is at 18.0.0-next.5), and the launch-window convention of check-changeset-no-major.mjs is the authority: a declared narrowing is BREAKING and ships minor.
  • Its LEVEL axis enforces exactly that (declaration.value === 'no' with the narrowing arm → enforce). So the dispatched patch would have been refused, and minor is the only grade the gate admits.
  • Check Changeset on the head: success.

Disposition.

  • Nothing authorable, exported, typed or stored moves, so objectstack migrate meta has nothing to rewrite and no tombstone is owed.
  • The changeset's FROM/TO lines describe wire answers (500 to 400), with the remedy "send the record's id". That is not a consumer-code or metadata rewrite, and check-adr-0087-registration admitted it.

Prose. It states the shipped behaviour truly. The banner's "No request that succeeded before is refused now" is the ① invariant, and the whitespace-id, per-object-door and ordering sentences each match the cited code.

skip-changeset does not apply: a released package moves.

③ Boundary flags

  1. "Changeset graded minor, not the dispatched patch." Answered, right: see ②. The dispatch's patch contradicts the narrowing line the same dispatch wrote, and the gate decides.

  2. "A delete addressed only by data.id, fixed in place under the bounded exemption." Answered, accepted. .claude/agents/os-dev.md sets four conditions, all required, and each holds:

    • It is the card's own defect class: the up-front check passing an op that its arm then sends with no address, on the identical line.
    • It is mechanical and pinned: one rule, namesNoRow(batchOpRowAddress(...)), and REFUSED row 11, red on main as 400 INVALID_FILTER.
    • No other claim is on the file: the claim's serial read found no open PR touching batch-routes.ts.
    • It is the same gate family with no new verification surface: the same local vitest project, and one test file beside rest-batch-endpoint.test.ts.

    The two debts are paid. The file surface was already the claim's (batch-routes.ts, nothing to supplement), and the PR body names the fix with evidence. The dispatch's own "one rule serves both … fixed in the same rule and named in the PR body" says the same.

  3. "atomic: false is refused BATCH_NOT_ATOMIC before the id check." Answered, verified in code. The rule holds for atomic: true and for atomic omitted.

  4. "Merged origin/main 307ce4c4b before opening." AGENTS.md §10. The head is that merge, and the net diff is exactly the three files. Accepted.

  5. "Model-free commit trailers" and "worktree cleaned". Housekeeping per AGENTS.md. Nothing for this record.

  6. Out-of-scope finding, carrier #24768. The engine's … requires an ID throws still answer 500 for other callers, and DATABASE_ERROR and INTERNAL_ERROR disagree between the verbs. Holding it off is right: the dispatch named it a hold-off, and the carrier is open. Not escalated.

  7. Acceptance note: CrossObjectBatchOperationSchema.id carries no .min(1). Not filing it is right, for three reasons:

    • packages/spec/** was held off.
    • A schema .min(1) could never reach data.id (0, false, {}) anyway.
    • After this fix a blank id is refused at the door, so the schema's permissiveness is not a defect.

    No carrier is owed.

Landing stays the owning seat's. This PASS arms nothing: the six Test Core shards must conclude success, and the claim's main-red hold (#24780) governs the queue.

Implemented-by: claude/issue-24781-batch-empty-id-400
Reviewed-by: session_01LmjU2x1faLZ39DVTFyVca6

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · ACCEPT

domain:cli seat 2 (#22648) · os-justin · session session_01LmjU2x1faLZ39DVTFyVca6 · 2026-10-20T08:33Z · head 0d4ea3e5b0f7e2109f5dc661b1ea02aede4c7acd · claim 6285421221 → report 6286676584 → review of record 6286879313 (CONTRACT_REVIEW_TIER, PASS). This seat adopted it after re-reading its cited lines at source: rest-server.ts registerBatchEndpoints(bp) per base path, engine.ts importing both dispatch resolvers, metadata-core/src/index.ts exports, protocol.ts:16714, and @objectstack/rest's metadata-core dependency.

Check Reading
Shape Draft, base main. First line Fixes #24781; Clause-②: no (narrowing) line-initial. Assignee os-justin = the card's. One changeset, @objectstack/rest: minor with a BREAKING banner and an ADR-0087 disposition. That is what check-changeset-no-major admits for a declared narrowing under the launch-window convention (the review's ②).
Surface 3 files, +280 / −7: batch-routes.ts, a new pin file and the changeset, inside the claim. No held-off file is touched. check-governed-merges.mjs --pr 24820: NOT governed.
Mechanism One address reader (batchOpRowAddress), read by the check and by both write arms, is judged by the engine's own by-id predicates. A blank, falsy or non-scalar address gets the missing-id 400 before the transaction opens. No request that answered 2xx or wrote a row before is refused (the review's ①, by construction).
Evidence 12 of 20 pins were red on main and are green after, with the same 12 red under the ablation and the $ref carve-out ablation red on exactly its control. The rest suite is 305 files / 6144 tests green on the head.
Gates Local: 67/67 derived families exit 0, 0 UNRUN; pnpm lint exit 0. Check-runs on this head (40, latest per name), read at this act: 29 success, 7 skipped, 4 in_progress (Test Core 1–4/6). Landing waits on all of them.

Accepted deviations:

  • The changeset is minor, not the dispatched patch. The dispatch's patch contradicted its own narrowing line, and the gate decides.
  • A delete addressed only by data.id is fixed in place under the bounded exemption (the review's ③.2).
  • atomic: false is refused before the id check.
  • main was merged before the PR opened.

Out-of-scope dispositions (one line each):

Landing: through the merge queue by this seat once every check on this head reads success (or an expected skip), and once the lane's main-red #24780 has fully landed (row 3 is in flight).


Generated by Claude Code

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

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants