Skip to content

fix(metadata-protocol,spec): a stopped bulk write reports every record — NOT_ATTEMPTED tail and reconciling counters (#7539) - #7581

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-7539-batch-not-attempted
Aug 11, 2026
Merged

fix(metadata-protocol,spec): a stopped bulk write reports every record — NOT_ATTEMPTED tail and reconciling counters (#7539)#7581
os-zhuang merged 2 commits into
mainfrom
claude/issue-7539-batch-not-attempted

Conversation

@claude

@claude claude Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Fixes #7539

(B) is RESOLVED by ADR-0119 D4 — and it resolves AGAINST the card's "Expected"

The dispatch split this card in two and told me not to guess on (B). D4 settles
it, so this PR implements the settled answer rather than reporting an open
question. Quoting the record:

D4 test plan, item 5 — "Non-atomic batches behave exactly as before (no
transaction opened; prior successes retained)."

D4 body — "atomic takes precedence over continueOnErrorwhose own
description already scopes it to atomic=false
, making this precedence
documented rather than new."

And the description D4 defers to, BatchOptionsSchema.continueOnError
(packages/spec/src/api/batch.zod.ts):

"If true (and atomic=false), continue processing remaining records after
errors"

So atomic: false alone promises best-effort that stops at the first
failure
. continueOnError is the knob that buys continuation, and it is
scoped to exactly atomic=false. D4 never touched non-atomic continuation — it
explicitly pinned it as unchanged. The card's "Expected per ADR-0119 D4 … valid
rows land, and neither valid row is blocked by the other" is a misreading: D4
says that about atomic: true's rollback reporting, not about the non-atomic
arm's continuation.

The dispatch's own suspicion was right — the continueOnError: true control
producing exactly the card's "expected" result is the tell. If atomic:false
alone continued, continueOnError would be inert, which is the
declared-but-unenforced shape ADR-0049 and this ADR exist to eliminate.

So the break stays, and the third record still does not land. Three of the
card's four symptoms are real defects and are fixed; the fourth ("the third,
valid record never lands") is correct behaviour under the declared contract, and
the caller's remedy is continueOnError: true — which the response now names in
the skipped row's message.

(A) The reporting defect — fixed outright

buildBatchDataResponse read total from the REQUEST (records.length) while
results / succeeded / failed came from a loop that had stopped early, so an
un-attempted record was invisible twice over: no results[] entry, and
counted in neither bucket. The only trace was succeeded + failed != total — an
arithmetic mismatch no client should have to notice, let alone interpret.

Every record now gets a row saying what happened to it. Records after the failure
carry errors[0].code === 'NOT_ATTEMPTED' — the same registered ADR-0112 code
the atomic arm has emitted since #4793, because "never ran" means the same thing
to a client whether the batch stopped to roll back or stopped because it was told
to. The message names the causal row index and continueOnError, since on this
arm the caller's next action is a flag rather than a fixed row.

The card's same-family observation — per-object bulk counters under-report
whenever a row fails without continueOnError
— is the identical defect on
updateManyData and deleteManyData, fixed in the same pass through one
shared reconciler
, not a third and fourth copy of the arithmetic (#4620's
lesson: copies are how these three drifted apart before).

Counter shape: why no new notAttempted field

BatchUpdateResponseSchema declares { total, succeeded, failed, results }.
The already-shipped atomic precedent (buildRolledBackBatchResponse) counts
never-reached rows into failed and holds succeeded + failed === total. This
matches it, so the envelope reads the same on both arms:

  • succeeded = rows with success === true
  • failed = rows with success === false
  • succeeded + failed === total === results.length

A notAttempted envelope field would have bought the same information at the
price of two meanings for failed on two arms of one endpoint. The three-term
identity is still asserted in the tests, with notAttempted derived from the
row codes
. No schema field added or removed.

Base SHA re-verification of the two controls

Re-measured at my branch point (9051802), before any source edit — both behave
exactly as the card records:

control at base 9051802 verdict
continueOnError: true 3 results, succeeded 2 / failed 1, both valid rows persisted ✅ as recorded
atomic: true ROLLED_BACK / VALIDATION_FAILED / NOT_ATTEMPTED, succeeded 0 / failed 3, nothing persisted ✅ as recorded

Both are kept as GUARD cases in the new suite and are green in both
directions.

File surface

file change
packages/metadata-protocol/src/protocol.ts new reconcileStoppedBatch (pads a stopped outcome to the request length with NOT_ATTEMPTED rows; failed becomes the count of non-success rows). Wired into buildBatchDataResponse, buildUpdateManyResponse, buildDeleteManyResponse. No change to any loop or to runAtomicBatch.
packages/spec/src/api/batch.zod.ts continueOnError and BatchOperationResultSchema.errors descriptions state the non-atomic stop's reporting contract.
packages/spec/src/api/error-code-ledger.zod.ts NOT_ATTEMPTED's ledger comment widened from "atomic data-batch row" to either stop reason.
packages/metadata-protocol/src/protocol.batch-not-attempted.test.ts new — 8 pins incl. both card controls as GUARD.
packages/metadata-protocol/src/protocol.batch-atomic.test.ts the assertion that literally encoded the bug (expect(res.results).toHaveLength(2) against total: 3) rewritten; the STOP re-pinned via insert call count.
packages/metadata-protocol/src/protocol.many-data-atomic.test.ts same under-report on updateManyData; added a store assertion that the tail is still not written.
packages/metadata-protocol/src/protocol.delete-many.test.ts, protocol.record-not-found.test.ts same under-report on deleteManyData.
content/docs/api/data-api.mdx hand-written /batch + /deleteMany sections describe the NOT_ATTEMPTED tail.
content/docs/references/api/batch.mdx generated — regenerated from the two .describe() edits (check:docs gate caught it).
.changeset/batch-not-attempted-tail-reporting.md one patch changeset naming @objectstack/spec + @objectstack/metadata-protocol.

buildBatchDataResponse and both siblings are private; the only callers are
the four sites in protocol.ts (grepped). REST is a pure pass-through
(res.json(result) — no status or counter derivation), so the wire shape moves
exactly with the protocol.

Reverse verification

Predictions were written before the first run
(succeeded + failed + notAttempted === total and results.length === total,
plus per-index outcomes by identity — not "one new entry is present"). Revert
was a one-line early return outcome in the reconciler, which restores the
defect exactly.

pin @base @fix @revert verdict
batchData: 3 results for 3 records, idx 2 NOT_ATTEMPTED, counters reconcile RED GREEN RED
batchData: returnRecords:false keeps the NOT_ATTEMPTED row RED GREEN RED
updateManyData stopped run: length + counters RED GREEN RED
deleteManyData stopped run: length + counters RED GREEN RED
protocol.batch-atomic.test.ts non-atomic regression net RED GREEN RED
protocol.many-data-atomic.test.ts non-atomic regression net RED GREEN RED
protocol.delete-many.test.ts stop-at-first-failure RED GREEN RED
protocol.record-not-found.test.ts missing-id stop RED GREEN RED
GUARD control 1 — continueOnError:true → 3 results, both valid rows land GREEN GREEN GREEN
GUARD control 2 — atomic:trueROLLED_BACK/causal/NOT_ATTEMPTED GREEN GREEN GREEN
GUARD semantics — idx 2 never attempted, never persisted, no transaction GREEN GREEN GREEN
GUARD all-success batch unaffected by the reconciliation GREEN GREEN GREEN

Revert turned exactly the 8 predicted pins red and left all 4 GUARDs green.

Missed prediction (1, disclosed)

I predicted the semantics GUARD ("idx 2 is never attempted and never lands")
would be GREEN at base. It was REDTypeError: Cannot read properties of undefined (reading 'success'). Cause: I had put two assertions about
results[2]'s shape inside a control whose job is the semantics, and at base
results[2] does not exist — the very defect under repair. A both-directions
GUARD must not depend on the thing being fixed. Fixed by moving those two
assertions into the reporting pin, leaving the GUARD on engine-call counts and
store contents only; it is green in all three columns above. No other prediction
missed. (One further self-inflicted red during implementation: an assertion I
added guessed a fixture's initial value as 'c' when it was 'c-old' — a test
authoring slip, corrected, no product implication.)

Gates

gate result
@objectstack/metadata-protocol vitest ✅ 72 files / 1059 tests
@objectstack/spec vitest ✅ 374 files / 9805 tests
@objectstack/rest vitest ✅ 82 files / 1341 tests
@objectstack/objectql vitest ✅ 178 files / 3153 tests
turbo typecheck (spec, metadata-protocol, rest, objectql) ✅ 20/20
eslint --no-inline-config (all changed files) ✅ clean
pnpm --filter @objectstack/spec check:docs ✅ 231 generated files in sync
check-regen-pending / check:doc-authoring ✅ clean

Out of scope

#4620 (deleteManyData / updateManyData atomicity) is not folded in — it
is closed, and it covered the atomic arm, not this loop's break-and-miscount.
The two siblings are touched here only for the identical counter
under-report
, which the card names explicitly as same-family.

Note for the record

No semantic-migration registry entry: the response schema is unchanged, the code
is already registered in the ADR-0112 ledger, and no consumer must move a read.
This restores a declared invariant rather than changing a declared shape. The
behaviour change is nonetheless real for clients — a stopped batch now returns
more results rows and a larger failed count for the same request and the same
writes — and the changeset carries that upgrade note.

🤖 Generated with Claude Code

https://claude.ai/code/session_016gd2bypaK4KYs78q8RP38G


Generated by Claude Code

claude added 2 commits August 11, 2026 05:51
…write's tail, and make the counters reconcile (#7539)

A non-atomic `/batch` that stopped at the first failure answered with a
truncated `results` array and counters that did not add up: two results for
three records, no entry for the un-attempted record, and
`succeeded + failed != total`. The skipped record was invisible twice over —
no `results[]` entry and counted in neither bucket — so the arithmetic
mismatch was its only trace.

`buildBatchDataResponse` read `total` from the request while `results`,
`succeeded` and `failed` came from a loop that had stopped early.
`buildUpdateManyResponse` and `buildDeleteManyResponse` under-reported the
same way. All three now share one reconciler that pads the outcome out to the
request length with `NOT_ATTEMPTED` rows — the registered ADR-0112 code the
atomic arm has emitted since #4793 — and returns `failed` as the count of
every non-success row, so `succeeded + failed === total === results.length`
on both arms.

The stop itself is unchanged, per `BatchOptionsSchema.continueOnError` ("If
true (and atomic=false), continue processing remaining records after errors")
and ADR-0119 D4, whose test plan holds non-atomic batches to "behave exactly
as before". This is a reporting fix; `continueOnError` remains the flag that
buys continuation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016gd2bypaK4KYs78q8RP38G
…tomic arm (#7539)

`data-api.mdx` described `atomic: false` as "sequential best-effort, stopping
at the first failure" without saying what the response contains for the
records it never reached — the shape the fix makes explicit. `batch.mdx` is
regenerated from the two `.describe()` strings this change touches.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016gd2bypaK4KYs78q8RP38G
@vercel

vercel Bot commented Aug 11, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 11, 2026 6:02am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/metadata-protocol, @objectstack/spec.

106 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/agents.mdx (via @objectstack/spec)
  • content/docs/ai/skills-reference.mdx (via @objectstack/spec)
  • content/docs/ai/skills.mdx (via @objectstack/spec)
  • content/docs/api/client-sdk.mdx (via @objectstack/spec)
  • content/docs/api/environment-routing.mdx (via @objectstack/spec)
  • content/docs/api/error-catalog.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-client.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-server.mdx (via @objectstack/spec)
  • content/docs/api/index.mdx (via @objectstack/spec)
  • content/docs/automation/approvals.mdx (via @objectstack/spec)
  • content/docs/automation/connectors.mdx (via @objectstack/spec)
  • content/docs/automation/flows.mdx (via @objectstack/spec)
  • content/docs/automation/hook-bodies.mdx (via packages/spec)
  • content/docs/automation/hooks.mdx (via @objectstack/spec)
  • content/docs/automation/index.mdx (via @objectstack/spec)
  • content/docs/automation/webhooks.mdx (via @objectstack/spec)
  • content/docs/automation/workflows.mdx (via @objectstack/spec)
  • content/docs/concepts/architecture.mdx (via @objectstack/spec)
  • content/docs/concepts/design-principles.mdx (via packages/spec)
  • content/docs/concepts/index.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-driven.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/metadata-protocol, packages/spec)
  • content/docs/concepts/north-star.mdx (via @objectstack/spec)
  • content/docs/data-modeling/analytics.mdx (via @objectstack/spec)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/spec)
  • content/docs/data-modeling/external-datasources.mdx (via @objectstack/spec)
  • content/docs/data-modeling/field-types.mdx (via @objectstack/spec)
  • content/docs/data-modeling/fields.mdx (via @objectstack/spec)
  • content/docs/data-modeling/formulas.mdx (via @objectstack/spec)
  • content/docs/data-modeling/index.mdx (via @objectstack/spec)
  • content/docs/data-modeling/objects.mdx (via @objectstack/spec)
  • content/docs/data-modeling/queries.mdx (via @objectstack/spec)
  • content/docs/data-modeling/schema-design.mdx (via @objectstack/spec)
  • content/docs/data-modeling/seed-data.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation-rules.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation.mdx (via @objectstack/spec)
  • content/docs/deployment/cli.mdx (via @objectstack/spec)
  • content/docs/deployment/tenancy-modes.mdx (via @objectstack/spec)
  • content/docs/deployment/troubleshooting.mdx (via @objectstack/spec)
  • content/docs/deployment/validating-metadata.mdx (via @objectstack/spec)
  • content/docs/getting-started/build-with-claude-code.mdx (via @objectstack/spec)
  • content/docs/getting-started/common-patterns.mdx (via @objectstack/spec)
  • content/docs/getting-started/examples.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-reference.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-start.mdx (via @objectstack/spec)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/spec)
  • content/docs/kernel/cluster.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/auth-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/cache-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/data-engine.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/index.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/metadata-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/storage-service.mdx (via @objectstack/spec)
  • content/docs/kernel/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/data-service.mdx (via @objectstack/spec)
  • content/docs/kernel/runtime-services/email-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/examples.mdx (via @objectstack/spec)
  • content/docs/kernel/runtime-services/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sharing-service.mdx (via @objectstack/spec)
  • content/docs/kernel/runtime-services/sms-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/storage-service.mdx (via @objectstack/spec)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/metadata-protocol, @objectstack/spec)
  • content/docs/kernel/services.mdx (via @objectstack/spec)
  • content/docs/permissions/authorization.mdx (via @objectstack/spec)
  • content/docs/permissions/permission-sets.mdx (via @objectstack/spec)
  • content/docs/permissions/permissions-matrix.mdx (via @objectstack/spec)
  • content/docs/permissions/positions.mdx (via @objectstack/spec)
  • content/docs/permissions/rls.mdx (via @objectstack/spec)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/spec)
  • content/docs/permissions/system-context.mdx (via packages/spec)
  • content/docs/plugins/adding-a-metadata-type.mdx (via @objectstack/spec)
  • content/docs/plugins/development.mdx (via @objectstack/spec)
  • content/docs/plugins/index.mdx (via @objectstack/spec)
  • content/docs/plugins/packages.mdx (via @objectstack/spec)
  • content/docs/protocol/backward-compatibility.mdx (via @objectstack/spec)
  • content/docs/protocol/diagram.mdx (via packages/spec)
  • content/docs/protocol/kernel/config-resolution.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/http-protocol.mdx (via @objectstack/metadata-protocol, @objectstack/spec)
  • content/docs/protocol/kernel/i18n-standard.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/plugin-spec.mdx (via @objectstack/spec)
  • content/docs/protocol/knowledge.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/query-syntax.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/schema.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/security.mdx (via packages/spec)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/actions.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/concept.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/layout-dsl.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/record-alert.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/widget-contract.mdx (via @objectstack/spec)
  • content/docs/ui/actions.mdx (via @objectstack/spec)
  • content/docs/ui/apps.mdx (via @objectstack/spec)
  • content/docs/ui/create-vs-edit-form.mdx (via @objectstack/spec)
  • content/docs/ui/dashboards.mdx (via @objectstack/spec)
  • content/docs/ui/field-grouping-and-order.mdx (via @objectstack/spec)
  • content/docs/ui/forms.mdx (via @objectstack/spec)
  • content/docs/ui/index.mdx (via @objectstack/spec)
  • content/docs/ui/public-data-collection.mdx (via @objectstack/spec)
  • content/docs/ui/setup-app.mdx (via @objectstack/spec)
  • content/docs/ui/translations.mdx (via @objectstack/spec)
  • content/docs/ui/views.mdx (via @objectstack/spec)

7 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx (via @objectstack/spec)
  • content/docs/releases/index.mdx (via @objectstack/spec)
  • content/docs/releases/v12.mdx (via @objectstack/spec)
  • content/docs/releases/v13.mdx (via @objectstack/spec)
  • content/docs/releases/v16.mdx (via @objectstack/spec)
  • content/docs/releases/v17.mdx (via @objectstack/spec)
  • content/docs/releases/v9.mdx (via @objectstack/metadata-protocol, @objectstack/spec)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Aug 11, 2026
@os-zhuang
os-zhuang marked this pull request as ready for review August 11, 2026 06:36
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 11, 2026
Merged via the queue into main with commit 744b8f5 Aug 11, 2026
27 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-7539-batch-not-attempted branch August 11, 2026 06:52
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/l tests tooling

Projects

None yet

2 participants