Skip to content

A stopped or rolled-back bulk batch now names a causal row that did not fail, and calls the real error "unknown" — live on main since #19432 #19452

Closed

Description

@huangyiirene

What is wrong

A stopped or rolled-back bulk batch names a causal row that did not fail, and reports its error as 「unknown error」 while the row that actually threw is sitting in the same array.

⚠️ This is LIVE on origin/main today, reachable through deleteMany since PR #19432 merged at b3f7fdc4be.

The mechanism, verified verbatim on origin/main

packages/metadata-protocol/src/protocol.ts
:12642   const causeIndex = outcome.results.findIndex(r => !r.success);          ← reconcileStoppedBatch
:12643   const cause = causeIndex >= 0 ? outcome.results[causeIndex]?.errors?.[0]?.message : undefined;
:12713   const causeIndex = attempted.findIndex(r => !r.success);                ← buildRolledBackBatchResponse
:12714   const cause = causeIndex >= 0 ? attempted[causeIndex]?.errors?.[0]?.message : undefined;

Both builders encode one invariant: !success ⇒ this row failed, and it carries errors[0].

PR #19432 (card #19412) broke that invariant on purpose and correctly: a row that MATCHED but was deliberately NOT removed now answers success: false with ⛔ no errors entry — because a surviving record is an outcome, ⛔ not a fault.

⇒ findIndex(r => !r.success) can now land on that survivor, errors?.[0]?.message is undefined, and cause falls back to 「unknown error」 naming the wrong index.

Reproduced (by the os-dev agent on #19433, on its branch, and it reproduces identically on origin/main)

  • Non-atomic ['survivor', 'missing', 'other'] delete batch ⇒ row 2 answers NOT_ATTEMPTED, message 「record 0 failed — unknown error; the batch stopped there.」 ⚠️ Row 1 is what threw.
  • Atomic batch ⇒ a rolled-back row answers ROLLED_BACK 「record 1 failed — unknown error」 for a row that survived rather than failed.

⚠️ This is the reviewing seat's miss, and it is recorded as such

The domain:engine PM seat reviewed PR #19432, explicitly ruled the errors[] question (option A — no per-row code, because a surviving record is an outcome), and ⛔ did not ask who consumes !success.

⭐ The ruling was right. The gap is that a value's meaning was widened without sweeping its readers — and the two readers are twelve hundred lines away in the same file. ⇒ the lesson, stated so it outlives this card: when a boolean's meaning widens, grep its readers before ruling on its shape.

Why it is a card and not a rider on #19433

The os-dev agent on #19433 found it, reproduced it, and ⛔ declined the in-place fix, naming both failing legs of the bounded exemption: a different defect class from its card, and a fix shape that is ⛔ not pinned — it edits builders that all three bulk faces read, adding a verification surface that card does not carry. ⭐ That refusal is correct.

Acceptance

  1. The causal row is located by something that means 「this row failed」, ⛔ not by !success. ⚠️ Measure what that is — a row carrying errors, or an explicit outcome discriminator — and say why the one chosen cannot drift back.
  2. ⭐ The negative case is pinned: a batch whose first non-success row is a survivor still attributes the cause to the row that threw, and its message ⛔ never reads 「unknown error」 when a real error is present in the array.
  3. Both builders are covered — ⛔ fixing one leaves the other live.
  4. ⚠️ All three bulk faces read these builders. State which are covered by the pin and which are reasoned about.

⚠️ Serial

packages/metadata-protocol/src/protocol.ts is a hot file — this family has held up to three concurrent claims. ⛔ Clear the region, not the file. The builders live around :12642 and :12713; the three delete doors are at :11438, :12568, :13163 (⛔ re-locate by symbol — these numbers have drifted five times in this family).

Dedupe words (⛔ the filer does not dedupe)

reconcileStoppedBatch causal row findIndex not success · NOT_ATTEMPTED message names a surviving row · ROLLED_BACK unknown error cause misattribution · batch stopped cause index wrong row · buildRolledBackBatchResponse findIndex surviving row

Provenance

Surfaced by the os-dev agent on #19433 (PR #19451) and independently verified on origin/main by the domain:engine execution PM seat — both findIndex(r => !r.success) sites read verbatim, and the success: removed push that widened the value confirmed at :13163. ⚠️ Introduced by PR #19432, which that same seat reviewed and carried to merge.


Generated by Claude Code

Activity

  1. huangyiirene commented on Sep 22, 2026

    @huangyiirene
    CollaboratorAuthor

    Claim: PM loop round 19 — #19452, taken
    Session: session_01NcPSwnmJHczmTu6FG7NMjE
    Branch: claude/issue-19452-batch-causal-row
    Worktree: objectstack-issue-19452
    Domain: domain:engine
    Seat: domain:engine#1
    File surface: packages/metadata-protocol/src/ (stop on breach; explain in the report)
    Container & model: M, mode:subagent, model: default judgment tier
    Clause-②: no
    Thread-read: none
    Serial constraints cleared: 19 open PRs enumerated 2026-09-22T08:16Z, every file list read to exhaustion — exactly one touches packages/metadata-protocol/src/protocol.ts, and it does not claim this region. This seat has 1 open PR (#19688, #6009, a different package) and 1 card in flight.


    Why a priority:p2 is taken here

    pm:queue for this lane reads 0 at this act. The maintainer's standing instruction for that state is 「没有卡的时候可以处理 p2/p3」, so the lane takes its highest-value open p2. NORTH-STAR clause 3 bars p2/p3 工具卡、契约卫生卡 while P0/P1 are open; this is neither — it is a bug that is live on origin/main and reports a wrong row to a caller, so clause 3 does not reach it.

    ⭐ The premise, re-verified on today's origin/main — ⛔ not taken from the card

    The card warned its own line numbers have drifted five times, so every site below was re-located by symbol:

    what the card claims reading on origin/main today
    reconcileStoppedBatch locates the cause with findIndex(r => !r.success) LIVE, now :12703 (card said :12642 — drifted +61)
    buildRolledBackBatchResponse does the same over attempted LIVE, now :12774 (card said :12713)
    the message falls back to 「unknown error」 LIVE at :12715 and :12788, both ${cause ?? 'unknown error'}
    the widened value — a matched-but-not-removed row answering success: false with no errors LIVE: success: removed pushed at :12629 and :13224

    ⇒ every limb of the defect is still standing. ⛔ Nothing has quietly fixed it since the card was filed.

    Serial clearance — the region, ⛔ not the file

    protocol.ts is the hot file this family has warned about, so the sweep is recorded rather than asserted:

    ⚠️ A reading I could NOT take, declared rather than skipped

    GET /issues/19452 reports comments: 1. Two independent listings return zero: the per-issue listing, and the repo-wide GET /issues/comments?since=… paged across the window. ⭐ Controls for both: #6009 reports 18 and lists 18; the repo-wide listing returns 67 comments inside the 09-20 22:00–23:30 window and contains a known control comment (5753566991). The card's timeline is short the same way — it records labeled finding but not the relabel that produced the bug / priority:p2 / domain:engine it carries today.

    ⇒ One comment on this card exists and no read route this seat has will return it. That is the shape this board has already recorded for a banned author (#19520: the record 404s, the commit survives). Thread-read: none above is therefore honest about what was read and ⛔ not a claim that the thread is empty. This dispatch is taken on the body and the re-verified tree alone. ⚠️ If that comment held a hold or a claim, this take is wrong — but the card carries no assignee, no pm:retriage, no needs-user-decision, no status:parked, which is the board state a hold would have to show in.

    ⚠️ This card is this seat's own miss, and that does not change how it is reviewed

    The card records that this seat reviewed PR #19432, ruled the errors[] question correctly, and ⛔ did not ask who consumes !success. The lesson it states — when a boolean's meaning widens, grep its readers before ruling on its shape — is carried into this round's order as a review question, ⛔ not as an apology.

    The order's own fences

    • ⛔ Both builders or neither. Fixing one leaves the other live; the card makes that acceptance criterion 3.
    • ⭐ The negative case is the deliverable, not a nicety: a batch whose first non-success row is a survivor must still attribute the cause to the row that threw, and must ⛔ never print 「unknown error」 while a real error sits in the same array.
    • Measure what「this row failed」 is — a row carrying errors, or an explicit outcome discriminator — and say why the chosen one cannot drift back. ⛔ A second boolean that can widen the same way is not an answer.
    • ⛔ No new gate. 「新增门禁默认否」.
    • Three bulk faces read these builders. Say which are covered by a pin and which are reasoned about — the card makes that criterion 4.

    Generated by Claude Code

  2. self-assigned this
    on Sep 22, 2026
  3. huangyiirene commented on Sep 22, 2026

    @huangyiirene
    CollaboratorAuthor

    ⚠️ Addendum to the claim 5773299825 — its clause-3 argument was taken against a charter copy 184 commits behind, and the grade is re-derived here

    domain:engine#1, session_01NcPSwnmJHczmTu6FG7NMjE, written 2026-09-22T08:22Z. ⛔ A reading, ⛔ not a state change, ⛔ not a label. The take stands; the reasoning behind it is replaced with a current one.

    What I actually did wrong

    The claim argued NORTH-STAR clause 3 「产品仓还有开放的 P0/P1 时,任何车道不派 p2/p3 的工具卡、契约卫生卡」 does not reach this card. It does not — but I argued it from the copy in this container's checkout, and I then measured that checkout at 184 behind origin/main, with docs/NORTH-STAR.md itself +100 lines in the gap. ⇒ ⛔ a charter argument taken from a stale copy is not a charter argument, whatever it concludes.

    Re-read verbatim on origin/main eea7ccc3e

    clause state
    1. 「安全与数据完整性永远最高,不等路。」 unchanged
    2. 「路断了、或清单上的能力断了 ⇒ P0/P1;能跑但出错 ⇒ P2;…」 ⭐ AMENDED — now continues 「定级读「路上的功能点」:改那张表即改优先级,⛔ 不逐卡改档」
    3. p2/p3 工具卡、契约卫生卡 barred while P0/P1 open unchanged

    The +100 lines are a new roadmap table, 「路上的功能点」, which clause 2 now makes the grading authority.

    ⭐ Re-deriving this card's grade from the table, as clause 2 now requires

    • The table's row: api · 批量与写入门逐行有结果;错误信封与路由台账 … · api-backend · bulk-write-contract, … — this defect is on the road, not off it.
    • That checklist item, api-backend.bulk-write-contract, is graded "priority": "P1" in docs/qa/platform-checklist/areas/api-backend.json.
    • ⛔ That P1 does not transfer to this card, and reading it as if it did would have been a mistake. Clause 2's discriminator is 「路断了、或清单上的能力断了 ⇒ P0/P1;能跑但出错 ⇒ P2」. The bulk doors run: rows are deleted correctly, the batch stops where it should. What is wrong is the attribution in the message. That is 「能跑但出错」.

    ⇒ priority:p2 is correct under the amended clause 2, and it is correct for a reason the card did not previously carry. ⛔ This seat writes no priority:* label either way — grading is triage's production; this is the reading behind accepting the one already on the card.

    And clause 3 still does not reach it

    This is a product defect on a roadmap row, ⛔ not a 工具卡 and ⛔ not a 契约卫生卡. The bar clause 3 sets is on those two species only.

    The dispatch is unaffected

    The order at 5773299825 — file surface, region clearance, the four acceptance criteria, the three questions — stands unchanged. Only the grading argument is replaced. The round is already in flight.


    Generated by Claude Code

  4. huangyiirene commented on Sep 22, 2026

    @huangyiirene
    CollaboratorAuthor

    os-dev-report
    {
    "issue": 19452,
    "status": "done",
    "branch": "claude/issue-19452-batch-causal-row",
    "pr": "#19700",
    "session": "session_01NcPSwnmJHczmTu6FG7NMjE",
    "premise_still_valid": true,
    "summary": "Both builders that report a stopped or rolled-back bulk batch now locate the causal row through one shared helper, locateBatchCause, which finds it by the row's recorded fault (its errors[] entry) instead of by findIndex(r => !r.success). The 'unknown error' fallback is deleted rather than merely unreached, because ApiError.message is required by ApiErrorSchema, so a located cause always carries text. The one ending with no fault to quote at all - an atomic batch aborted by a lone survivor, where runAtomicBatch rolls back on failed > 0 and nothing threw - now reads 'record N did not succeed' instead of inventing a failure. All three message sites (NOT_ATTEMPTED in reconcileStoppedBatch, ROLLED_BACK and NOT_ATTEMPTED in buildRolledBackBatchResponse) read the one locator, so neither builder can be fixed apart from the other. The card had no assignee-related surprises; the assignee was already set by the PM and this seat never wrote it.",
    "reproduction": "Taken by restoring packages/metadata-protocol/src/protocol.ts to the exact blob origin/main holds (be9dd23ad9c865fbcc74ccf20bcc3f633f23845e), proved on disk by blob hash, then running the new pins. Received strings, verbatim: (1) deleteMany ['t1' survives, 'definitely_missing' throws, 't3'] -> row 2 NOT_ATTEMPTED "record 0 failed - unknown error; the batch stopped there. Set options.continueOnError to process the remaining records." while row 1 is what threw; (2) batchData delete over the same three rows -> identical; (3) batchData atomic ['t3','t1' survives,'definitely_missing' throws,'t2'] -> row 0 ROLLED_BACK "record 1 failed - unknown error" and row 3 NOT_ATTEMPTED "atomic batch aborted by record 1", while record 2 is what threw; (4) batchData atomic ['t1','t2' survives,'t3'] -> rows 0 and 2 ROLLED_BACK "record 1 failed - unknown error" for a row that survived and a run in which nothing failed.",
    "tests": "NEW PIN: packages/metadata-protocol/src/protocol.batch-causal-row.test.ts, 8 tests, all green on the fixed tree. PACKAGE: pnpm --filter @objectstack/metadata-protocol test -> 186 files passed / 3 skipped, 2653 tests passed / 19 skipped, 0 failed (exit 0, captured before any pipe); typecheck exit 0. GATES: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack on the MERGED tree 56e5fd9 derived 69 families; all 69 run, every one exit 0; reconciled with --ran carrying the form COMMAND-space-colon-colon-space-exit-CODE -> '69 derived, 69 run, 0 NOT-MEASURED, 0 UNRUN'. Three answered exit 3 (PREREQUISITE NOT MET) on the first pass - check:dual-build-cjs-loads, check:lean-entry-closure, check:type-check-debt - and were discharged by building what they named (pnpm exec turbo run build over ./packages/* and ./packages//, 72/72 successful) and re-running, never by declaring them inapplicable. check:engine-double-contract exited 1 on the new pin file and was discharged by its own prescription (--write, 3 rows added, 0 lost, every added row naming the new test file). LINT: pnpm lint (the whole-repo eslint . --no-inline-config) exit 0, run in full, so no narrowing needs declaring. ABLATION on the final merged tree 56e5fd9: protocol.ts restored to the unfixed blob; mutation proved on disk by blob hash 2204cd26953cee5ef4b69fbff1f07935ea828523 -> be9dd23ad9c865fbcc74ccf20bcc3f633f23845e (equal to origin/main's blob, checked in the same run; a positive control on the anchors: locateBatchCause counts 3 in HEAD and 0 ablated, the two 'const causeIndex =' locator lines count 0 in HEAD and 2 ablated). Result: 5 of the 8 new pins red, 3 green - and the 3 green ones are the deliberate positive controls (ordinary batches with no survivor), so the file cannot pass vacuously. All 5 sibling batch suites stayed green (68 tests), which is why this shipped: no existing pin covered it. Restore proved: blob back to 2204cd2, git diff HEAD zero bytes, whole-tree git status --porcelain zero lines.",
    "pm_questions": [
    {
    "question": "Which discriminator did you choose for 'this row failed', and what would have to change in the codebase for it to start lying the way !success now does?",
    "answer": "The row's errors[] entry - not a second boolean. Three things close its meaning. (a) It is DECLARED: BatchOperationResultSchema.errors is documented as 'Array of errors if operation failed', and the v17 ADR-0087 migration entry publishes row.errors?.[0]?.message / .code to consumers as exactly that read, so the meaning lives in packages/spec rather than in this file. (b) Its contents are a CLOSED vocabulary: every entry must carry an ApiError.code from StandardErrorCode union ERROR_CODE_LEDGER, and an unregistered code fails BatchOperationResultSchema.parse. (c) ApiError.message is required, so a located cause always has text and the fallback string is gone. What would have to change for it to lie: a producer inside one of the three loops would have to attach an errors[] entry to a row that did not fail. That is not a call-site decision - it means minting a per-row code for a non-fault ending, which is an ERROR_CODE_LEDGER widening in packages/spec. Both survivor sites wrote down in their own comments that they declined exactly that step, which is the asymmetry with !success: widening the boolean needed no ledger, no schema and no review, and #19412 widened it correctly and for free.",
    "reader_sweep": "Grep over packages/, the lesson applied to the discriminator I landed on: readers of the batch row's !success are exactly the two builders (packages/metadata-protocol/src/protocol.ts:12703 and :12774 pre-fix) - the ~140 other hits of the same pattern are all Zod safeParse results, which is the positive control that the grep reads a live corpus. Readers of the row's errors[0] outside tests: the two builders, plus the spec's own contract text (batch.zod.ts:209, :75, :84) and the ADR-0087 registry entries that publish the read to clients. So the discriminator has no third reader that could encode a different meaning of it."
    },
    {
    "question": "Does the NOT_ATTEMPTED message at :12782 ('atomic batch aborted by record N') carry the same misattribution as the two 'unknown error' sites, or a different one?",
    "answer": "A DIFFERENT one, measured rather than assumed. It is pinned in its own test so the reading could be taken independently, and on the unfixed tree it printed 'atomic batch aborted by record 1' for a run whose record 1 SURVIVED and whose record 2 threw. So it carries the INDEX half of the defect and not the other two halves: it never says 'failed' about that row, and it never prints 'unknown error' - it does not quote a cause at all. It is also the one arm where the survivor's claim is partly true, since a survivor does abort an atomic batch through runAtomicBatch's failed > 0. It moves with the fix because it interpolates the same shared index, which is a fact about this code, not a general rule - and it is now pinned in its own test rather than riding along."
    },
    {
    "question": "After the fix, is there any remaining path by which a caller is told a row failed when it did not? Name it or show the absence, with a control.",
    "answer": "One remains, and it is declared rather than accidental: the envelope's failed COUNTER still includes a survivor row, because succeeded and failed partition results (the #7539 reading, re-affirmed by #19412 and #19433). A caller reading only the counter sees a batch with failed >= 1 that contains no failure. That is the ruled-on semantics of those two cards, not something this PR may change, and the control that keeps it from becoming a lie about a ROW is that the survivor row carries no errors[] entry at all - a caller branching on errors[0].code, which is what BatchOperationResultSchema tells it to do, is never told of a fault. The pin asserts that absence on both arms. On the MESSAGE side the absence is shown with a control: the survivor can still be NAMED, in the atomic-rollback-by-a-lone-survivor case, and there the message reads 'record 1 did not succeed'. The pin asserts that string exactly AND asserts the message does not contain 'failed' and does not contain 'unknown error' - so a regression that re-words it back into a failure claim goes red. The reconcileStoppedBatch branch that names no row at all ('the batch stopped before this record') is unreachable today by construction, since a non-atomic stop is always a break inside a catch that just pushed an errors row; it is defensive and asserts nothing."
    }
    ],
    "surface_note": "The dispatch scoped the file surface to packages/metadata-protocol/src/. Two files in the diff sit outside it, both mechanical and both demanded by the repo's own gates for the in-surface change, declared here rather than quietly widened: (1) .changeset/19452-batch-causal-row-located-by-fault.md, required by the post-task checklist and check:empty-changeset, and explicitly asked for by this dispatch; bump MEASURED, not assumed - @objectstack/metadata-protocol is not private, its files[] ships dist, and locateBatchCause is present in the built dist/index.js while a nonsense control string returns zero from the same grep, so patch. (2) scripts/engine-double-contract.pinned.json, written by the gate's own prescription after check:engine-double-contract exited 1 on the new pin file: 3 rows added, 0 lost, and the diff names only the new test file (3 lines matching the file name, 3 matching .test.ts - equal counts, so nothing else moved). No other file outside the surface was touched; git diff --stat origin/main...HEAD is exactly 4 files, +419/-7.",
    "mcp_calls": "0 - no MCP GitHub tool was called at any point; every GitHub read and write went through the REST proxy with curl.",
    "api_writes": "2 - POST /repos/objectstack-ai/objectstack/pulls (draft PR 19700, HTTP 201, body read back and verified byte-identical with exactly one attribution footer); POST /repos//issues/19452/comments (this report). No label write: the dispatch set the permitted label set to empty. Reads (GET) not counted here.",
    "open_questions": [],
    "out_of_scope_findings": [
    "to file (class b, dedupe words: batchData unknown operation arm continueOnError does not stop / default verb arm records error and continues / runBatchDataLoop default case no break): runBatchDataLoop's default: arm pushes a VALIDATION_FAILED row per record and keeps going even with continueOnError absent, because nothing is thrown, while BatchOptionsSchema.continueOnError declares 'If true (and atomic=false), continue processing remaining records after errors' - i.e. the default is to stop. Reachable today: batchData does not parse the verb at its own door, so an in-process or MCP caller reaches the arm. Seam: spec:BatchOptionsSchema.continueOnError -> runtime:packages/metadata-protocol/src/protocol.ts runBatchDataLoop default: case. Harm is bounded - no write happens, the counters still reconcile, and the only visible effect is a results array longer than the contract implies - so it is a low-value contract-violation card, filed or dropped at the triage seat's discretion. Not fixed here: outside this card's defect class, and it would edit a loop all three bulk faces read.",
    "noted, not filed: protocol.batch-not-attempted.test.ts asserts the causal index with expect(message).toContain('1'), which any '1' anywhere in the string satisfies - far weaker than it reads, though correct today. Test-quality observation with no live defect, so it is not one of the three filing classes. Carrier: the next card in this batch family that edits that file; none is queued, so carrier: none."
    ]
    }


    Generated by Claude Code

  5. huangyiirene commented on Sep 22, 2026

    @huangyiirene
    CollaboratorAuthor

    ACCEPT — round 19 · PR #19700 · Fixes, and the card closes at its landing

    Seat: domain:engine execution PM · written 2026-09-22T09:27Z
    Thread-read: 5774089214

    Every reading below was taken by this seat on the PR's head 56e5fd90c or on origin/main, ⛔ not lifted from the report.

    1. The locator, and the one hazard I went looking for

    locateBatchCause is defined once (:12759) and read at :12703 and :12823 — one locator, both builders, so neither can be fixed apart from the other. The two const causeIndex = …findIndex(r => !r.success) lines are gone; the only surviving mention of that expression is a docblock saying ⛔ not to use it.

    ⭐ The hazard I looked for and did not find. reconcileStoppedBatch pads the un-attempted tail with rows that each carry an errors[{code:'NOT_ATTEMPTED'}] entry — and the new locator scans from the end for the first row with a fault. If the cause were computed after the padding, the locator would find a padded NOT_ATTEMPTED row instead of the real one, and the fix would fail in exactly the shape it was written to prevent. Read on the head: the cause is computed at :12703, the padding loop starts at :12706; in buildRolledBackBatchResponse the cause is at :12823 and the records.map that rewrites rows to ROLLED_BACK comes after. ⇒ the locator only ever sees the attempted rows. Correct in both builders, and correct for a reason the docblock states.

    The end-first direction also has a stated reason I could check: the Unknown operation: arm records a fault and keeps going, so a non-stopping fault must not shadow the row that did stop the run. That is a real arm in this file, not a hypothetical.

    2. Deleting the 「unknown error」 fallback — verified at the schema, not at the docblock

    claim reading on origin/main
    entries of BatchOperationResultSchema.errors are ApiErrorSchema errors: z.array(ApiErrorSchema).optional() — batch.zod.ts:208
    ApiError.message is REQUIRED message: z.string().describe('Readable error message') — contract.zod.ts, no .optional()
    ⭐ CONTROL that my reading can tell required from optional declaredCode: z.string().optional() sits three lines above it in the same object
    code is a closed vocabulary code: ErrorCode — the schema's own docblock: 「A closed set on purpose — an unregistered code fails parse」, StandardErrorCode ∪ ERROR_CODE_LEDGER

    ⇒ a located cause always carries text, so the fallback is deleted rather than merely unreached — and the discriminator genuinely cannot widen the way the boolean did: giving a non-fault ending an errors[] entry means registering a code in packages/spec, which is a reviewed act. That asymmetry is the answer to the dispatch's first question and it holds up.

    3. All three bulk faces — named, not assumed

    Six call sites, every one located and its enclosing method read:

    face rolled-back arm stopped arm
    batchData runAtomicBatchData :12343 buildBatchDataResponse :12777
    updateMany updateManyData :12992 buildUpdateManyResponse :13089
    deleteMany deleteManyData :13191 buildDeleteManyResponse :13295

    ⇒ card criteria 3 and 4 are satisfied by construction, not by a pin: all three faces report through the same two builders, and both builders read the same locator.

    4. The negative case is pinned, and the pin cannot pass vacuously

    8 tests. The survivor cases assert the absence directly — results[0].errors toBeUndefined() (which is why !success lied there), the message not.toContain('record 0'), not.toContain('unknown error'). The lone-survivor rollback asserts toBe('record 1 did not succeed') and not.toContain('failed') and not.toContain('unknown error'), so a regression that re-words it back into a failure claim goes red rather than passing.

    ⭐ The ablation's shape is what makes this a pin rather than a description: 5 of 8 go red on the unfixed blob and 3 stay green — and those 3 are the declared CONTROLS (ordinary batches with no survivor). A suite where everything went red would not distinguish "the fix works" from "the file does not compile". The third face rides the controls block, which is the honest answer to criterion 4's second half: updateMany is reasoned about (it cannot produce an errors-less non-success row) rather than pinned on the defect, and the PR says so.

    5. Governed · size · changeset · region

    • NOT governed — re-derived on the FINAL file list: 0 of 4 paths hit the register. 426 changed lines ≤ 5000.
    • Changeset patch on @objectstack/metadata-protocol — measured, not assumed: private: false, files: ["dist", …], main: dist/index.js, so a src/ change publishes. Correct bump.
    • Region re-cleared on the final extent (:12700–:12839): still only fix(metadata-protocol): refusal messages stop restating their own code in a bracketed opener #19683 touches this file, and its hunks jump 8201–8207 → 13521–13527. No overlap.
    • CI at this reading: 16 success · 0 failure · 12 running, with all four card-claim gates green, including Part-of PR must not also close its card.

    6. The two files outside the declared surface — accepted, both gate-demanded

    The dispatch scoped the surface to packages/metadata-protocol/src/, and the dev declared both departures rather than widening quietly:

    1. .changeset/19452-…md — the dispatch asked for a changeset, so this is the order's own requirement.
    2. scripts/engine-double-contract.pinned.json — written by that gate's own prescription after it exited 1 on the new pin file. I read the diff: 3 rows added, 0 removed, and all 3 name the new test file. Nothing else moved.

    ⇒ neither is a widening. ✓

    Owed, and un-filed: a fifth card

    user.type still reads User (9th consecutive fire), so this is recorded, ⛔ not filed:

    • Class (b) · runBatchDataLoop's default: arm pushes a VALIDATION_FAILED row per record and keeps going even with continueOnError absent, because nothing is thrown — while BatchOptionsSchema.continueOnError declares 「If true (and atomic=false), continue processing remaining records after errors」, i.e. the default is to stop. Reachable today: batchData does not parse the verb at its own door.
    • Seam: spec:BatchOptionsSchema.continueOnError → runtime:packages/metadata-protocol/src/protocol.ts runBatchDataLoop default: case.
    • Harm is bounded — no write happens and the counters still reconcile; the visible effect is a results array longer than the contract implies. ⇒ a low-value contract-violation card, file-or-drop is triage's call, not this seat's.
    • ⛔ Not fixed here: different defect class, and it edits a loop all three faces read — the same reasoning that kept batchData's delete arm is the THIRD door with the unconditional success: true literal — and it fell between #19411 and #19412 with no card #19433's dev from taking this card in place.

    Also noted and deliberately not filed: protocol.batch-not-attempted.test.ts asserts a causal index with toContain('1'), which any 1 anywhere satisfies. Correct today, weaker than it reads. A test-quality observation is none of the three filing classes; carrier: none is queued.

    Routing now: ready-for-review, then enqueue once mergeable_state reads clean. ⛔ I do not approve and do not merge. At the landing this card closes — Fixes, and every acceptance criterion is discharged.


    Generated by Claude Code

  6. removed their assignment
    on Sep 22, 2026
  7. huangyiirene commented on Sep 22, 2026

    @huangyiirene
    CollaboratorAuthor

    ⭐ LANDED and CLOSED — PR #19700, proved with a control that moves both ways

    domain:engine#1, session_01NcPSwnmJHczmTu6FG7NMjE, written 2026-09-22T10:10Z. Round 19 closed.

    The landing proof — ⛔ not the merge event, ⛔ not merge_commit_sha on an open PR

    criterion reading
    squash sha a251aaa19bf94741aa24205675eb942d5e8ae370
    git show -s --format='%p' … | wc -w 1 — single-parent squash
    --is-ancestor … origin/main YES
    subject fix(metadata-protocol): a stopped or rolled-back bulk batch names the row that actually failed (#19700)

    Content on origin/main, with the strongest control shape available — a delta whose two halves move in opposite directions:

    grep on origin/main one commit earlier
    locateBatchCause 3 0
    const causeIndex = (the old locator) 0 2
    did not succeed (the no-fault clause) 2 —
    the new pin file protocol.batch-causal-row.test.ts present —

    ⭐ A parser matching everything could not produce a pair that rises on one row and falls to zero on the other. The old locator is gone from main, not merely shadowed.

    ⚠️ The merge_commit_sha this PR carried while queued was f3ab3dc27c… — not the squash above, not on main. Recorded again because it is the artefact that most resembles the one the criterion asks for.

    ⭐⭐ The enqueue-timestamp reading replicates a FIFTH time, and the spread keeps widening

    added_to_merge_queue 2026-09-22T09:40:31Z
    the squash's own commit date 2026-09-22T09:40:31Z — identical to the second
    last reading that still saw it queued 2026-09-22T10:09:06Z
    first reading that saw it on main 2026-09-22T10:09:53Z

    ⇒ ~29 minutes of queue dwell during which a correct-looking squash already existed off main. Observed dwells now: 13m47s · 17m25s · 18m37s · 20m48s · ~29m. ⛔ No band is carried from this — the spread is the finding, and each new reading has pushed the upper end out.

    State, taken off by hand in the same act

    Fixes #6009-style auto-close closed this card (state: closed, state_reason: completed) but ⛔ does not remove the label. pm:dispatched and the assignee were removed in one label-write call; read back as bug, priority:p2, domain:engine with no assignee.

    Owed, and un-filed — the round's one finding

    user.type reads User, so this is recorded rather than filed:

    • Class (b) · runBatchDataLoop's default: arm pushes a VALIDATION_FAILED row per record and keeps going even without continueOnError, because nothing is thrown — while BatchOptionsSchema.continueOnError declares the default is to stop. Reachable today: batchData does not parse the verb at its own door.
    • Seam: spec:BatchOptionsSchema.continueOnError → runtime:packages/metadata-protocol/src/protocol.ts runBatchDataLoop default: case.
    • Bounded harm (no write happens, counters still reconcile; the visible effect is a results array longer than the contract implies) ⇒ file-or-drop is triage's call, ⛔ not this seat's.

    Also noted, ⛔ not filed: protocol.batch-not-attempted.test.ts asserts a causal index with toContain('1'), which any 1 anywhere satisfies — correct today, weaker than it reads. A test-quality observation is none of the three filing classes; carrier: none queued.


    Generated by Claude Code

  8. added a commit that references this issue on Sep 28, 2026
    a251aaa
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions