Repository navigation
service-automation: the mid-park window in persistSuspendedRun is an unpinned limit — a concurrent per-id read can evict a live entry between the map write and the store save #16129
Description
Activity
Claiming this card (
os-devseat, dispatched by thedomain:servicesPM seat).- session:
https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y - branch:
claude/issue-16129-mid-park-eviction-window - worktree:
../objectstack-16129offorigin/main
Re-read the comments immediately before this one: none existed, so no in-flight collision. Not touching the assignee field — it is the PM's.
Step 1 per the card is to re-measure the window on current head rather than take the
1157e7b72line numbers as given. Collision fence noted:packages/services/service-automation/src/suspended-run-store.tsbelongs to PR #16128 and I will not edit it;engine.tsis mine this round.
Generated by Claude Code
- session:
os-dev-report
{
"issue": 16129,
"status": "done",
"branch": "claude/issue-16129-mid-park-eviction-window",
"pr": "#16150",
"premise_still_valid": true,
"summary": "Re-measured the window on current head first, as step 1 required. It is real and it is REACHABLE: persistSuspendedRun still writes the suspendedRuns entry at :2037, awaits store.save at :2040 and marks cacheOnlySuspensions only after that settles (:2045 / :2051), so a concurrent per-id loadSuspendedRunStrict reads a store that truthfully has no row, finds no qualifier, and evicts a live entry mid-park. Reachability needs NO out-of-band knowledge of the run id, because the map write is what publishes the id to listSuspendedRuns during the window - the same list-then-open consumer #16031 was written for. Chose a CONTROL PIN over a comment (new file suspended-run-mid-park-eviction-window.test.ts, 5 tests) because a pin makes the boundary executable and carries the reachability measurement, which a comment could only assert; two short comments at the map write and on evictConsumedSuspension's docblock point at it. The pin asserts the BOUNDED outcome only - still resumable through the store-first strict loader and resuming end to end, merely omitted from the cache-only listing, with listSuspendedRunsDurable unaffected - plus controls for no-store and for a read that throws. Nothing widens the cache-only marking, adds a lock, or moves the save. ONE MEASURED CASE ESCAPES BOUND 1 and is pinned but deliberately NOT fixed - see open_questions. Two half-states to note: the skip-changeset label is still OWED (GraphQL pool exhausted mid-run, details in tests), and the engine-double ledger collision the dispatch warned about did not materialise.",
"tests": "All at final head ca585bb; every exit code captured immediately after a single redirected command, never through a pipe. (1) pnpm --filter '@objectstack/service-automation^...' build EXIT=0 (dependency closure first). (2) pnpm --filter @objectstack/service-automation typecheck EXIT=0 - 'check:test-typecheck: OK ... 0 file(s) / 0 error(s)'. Coverage of the NEW file proved, not assumed: tsc -p tsconfig.test.json --noEmit --listFiles and tsc -p tsconfig.json --noEmit --listFiles each name suspended-run-mid-park-eviction-window.test.ts exactly once (both EXIT=0). (3) pnpm --filter @objectstack/service-automation test EXIT=0 - 'Test Files 117 passed (117), Tests 1400 passed (1400)'. (4) ABLATION, committed before mutating. Removed this.evictConsumedSuspension(runId) from loadSuspendedRunStrict. Mutation proved ON DISK by git hash-object delta 21892e2381a4a8340eb86a8c4236812142e930a7 -> 1c8e0a896475d2409db25d891d049e88aee0ee7f PLUS anchored counts (call site 2 -> 1, injected marker 0 -> 1); pre-check asserted the tree equalled the HEAD blob before mutating; restore ran under trap ... EXIT INT TERM on an ABSOLUTE path via git checkout HEAD -- ABSOLUTE_PATH. Ablated run EXIT=1, 'Tests 3 failed | 2 passed (5)' - the three that depend on the eviction firing turned red (both THE WINDOW cases and FINDING), the two CONTROLs that assert nothing is evicted stayed green. That is the direction I predicted before running, not a template default. Restore proved by blob equality back to 21892e23, empty git diff HEAD (0 bytes), counts back to 2 / 0; restored run EXIT=0, 5 passed. NO REBUILD on either leg is correct here and is stated rather than skipped: the pin reaches engine.ts by a relative same-package specifier, so vitest resolves src/engine.ts, never a dist/. (5) GATE FAMILY derived mechanically on the FINAL head: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, no paths passed, no hand-built list. Reconciliation line: 46 families (34 by path + 7 by change kind + 7 declared whole-tree, 2 reached both ways). All 46 taken from --commands and run; ALL EXIT=0. Two first returned EXIT=3 = PREREQUISITE NOT MET, which I did NOT record as a pass: pnpm check:dual-build-cjs-loads (needed built output; after turbo run build across packages, EXIT=0, '103 published require entry point(s) across 66 package(s) load') and pnpm check:type-check-debt (needed the built closure, then OOM'd inside tsc because the gate propagates the caller's NODE_OPTIONS and my --max-old-space-size=4096 was the tighter ceiling - the gate says so itself; re-run at 8192 with 14.9 GB free, EXIT=0, '12 ledger entr(ies) re-measured in 93.2s, 140 raw tsc error(s) total, none above its recorded number'). check:engine-double-contract EXIT=0 with scripts/engine-double-contract.pinned.json UNMODIFIED - the pin's fake is a SuspendedRunStore, not an ObjectQL engine double, so the collision with PR #16128 on that ledger did not materialise and no --write run was needed. UNMEASURED, named rather than implied: the 6 families whose argv takes a value from the workflow ($RUNNER_TEMP, $ {{ matrix.shard }}) - two check-shard-attestation.mjs invocations, check-test-completeness.mjs twice, and check-cross-package-test-inputs.mjs --union-into. The derivation refuses to invent a local invocation and so do I. I assert nothing about CI state. CHANNEL: REST is closed for this session - a repo-scoped probe returned 403 'GitHub access is not enabled for this session', the documented session-gate shape - so all GitHub work went via MCP. The GraphQL pool then hit 'API rate limit already exceeded' on the label read AFTER the PR was opened. SKIP-CHANGESET LABEL IS OWED, NOT APPLIED: applying it needs read -> union -> whole-group write, and the read half is what the pool refused; a blind whole-group write would have stripped whatever pr-automation had already applied, so I did not take it. Please apply skip-changeset to PR #16150 (judged, not defaulted: one new test file plus comments, releases nothing from any package). Until it is on, Check Changeset will read red for the stated reason.",
"mcp_calls": "6 - issue_read get, issue_read get_comments, add_issue_comment (claim), create_pull_request, issue_read get_labels (refused: rate limit), and this report comment. Zero list/search calls: the card and its comments were read by id, never scanned.",
"open_questions": [
{
"question": "MEASURED, and it escapes bound 1 - reporting rather than taking it, as the dispatch directs. Bound 1 ('store-first, so the run stays resumable') holds only because the save eventually LANDS. When the save FAILS after an evicting read has already run inside the window, persistSuspendedRun adds the run to cacheOnlySuspensions but the map entry it qualifies is already gone: hasSuspendedRun answers false, resume answers RUN_NOT_FOUND, and the engine's own error record for that failed save promises the opposite ('it is kept in memory only'). Isolated against its control in the same test - the identical failing save WITHOUT the mid-park read leaves the run resumable in-process, exactly as the documented degradation says. Narrower than the base window: it needs a store that rejects the write while still answering reads with 'no row' rather than throwing (healthy read replica behind a broken write path, missing INSERT grant, full disk). I have pinned it at its MEASURED behaviour and fixed nothing. Which card should carry the fix, and do you want me to file it or will you?",
"options": [
"A - file it as its own card and leave PR #16150 exactly as it is: the pin already records the cost executably and gives any future fix a red test to turn green, and the dispatch reserved this decision to the PM.",
"B - mark the run cache-only BEFORE awaiting the save and clear it after, which closes both the base window and this compound case with no lock; the cost is that a run is briefly readable out of the map while the store is still authoritative for it, which is a deliberate weakening of the #13617 invariant and needs its own review.",
"C - re-add the map entry on the failure path in persistSuspendedRun's catch. Narrowest possible change, closes ONLY the compound case, leaves the base window exactly as pinned.",
"D - decide it is acceptable as measured and keep only the pin, on the grounds that a store failing writes while answering reads is already an alarm-raising state."
],
"recommendation": "A, then C. A because the dispatch explicitly reserved this to you and because nothing is lost by waiting - the behaviour is pinned, so it cannot silently drift while the card is triaged. C as the fix that card should most likely take: it is three lines in the catch that already exists, it closes the case that actually loses a run, and it touches neither the #13617 store-authority invariant (B does) nor the base window this card deliberately leaves standing. Not B without a separate review: it is precisely the 'widen the cache-only marking' move #16129 forbids taking unilaterally."
}
],
"out_of_scope_findings": []
}
Generated by Claude Code
os-dev-report
{
"issue": 16129,
"status": "done",
"branch": "claude/issue-16129-mid-park-eviction-window",
"pr": "#16150",
"premise_still_valid": true,
"summary": "SUPERSEDES comment 5556606485, which is identical except that it reported the skip-changeset label as OWED; the GraphQL pool recovered a minute later and the label is now ON and read back. Re-measured the window on current head first, as step 1 required. It is real and it is REACHABLE: persistSuspendedRun still writes the suspendedRuns entry at :2037, awaits store.save at :2040 and marks cacheOnlySuspensions only after that settles (:2045 / :2051), so a concurrent per-id loadSuspendedRunStrict reads a store that truthfully has no row, finds no qualifier, and evicts a live entry mid-park. Reachability needs NO out-of-band knowledge of the run id, because the map write is what publishes the id to listSuspendedRuns during the window - the same list-then-open consumer #16031 was written for. Chose a CONTROL PIN over a comment (new file suspended-run-mid-park-eviction-window.test.ts, 5 tests) because a pin makes the boundary executable and carries the reachability measurement, which a comment could only assert; two short comments at the map write and on evictConsumedSuspension's docblock point at it. The pin asserts the BOUNDED outcome only - still resumable through the store-first strict loader and resuming end to end, merely omitted from the cache-only listing, with listSuspendedRunsDurable unaffected - plus controls for no-store and for a read that throws. Nothing widens the cache-only marking, adds a lock, or moves the save. ONE MEASURED CASE ESCAPES BOUND 1 and is pinned but deliberately NOT fixed - see open_questions. The engine-double ledger collision the dispatch warned about did not materialise.",
"tests": "All at final head ca585bb; every exit code captured immediately after a single redirected command, never through a pipe. (1) pnpm --filter '@objectstack/service-automation^...' build EXIT=0 (dependency closure first). (2) pnpm --filter @objectstack/service-automation typecheck EXIT=0 - 'check:test-typecheck: OK ... 0 file(s) / 0 error(s)'. Coverage of the NEW file proved, not assumed: tsc -p tsconfig.test.json --noEmit --listFiles and tsc -p tsconfig.json --noEmit --listFiles each name suspended-run-mid-park-eviction-window.test.ts exactly once (both EXIT=0). (3) pnpm --filter @objectstack/service-automation test EXIT=0 - 'Test Files 117 passed (117), Tests 1400 passed (1400)'. (4) ABLATION, committed before mutating. Removed this.evictConsumedSuspension(runId) from loadSuspendedRunStrict. Mutation proved ON DISK by git hash-object delta 21892e2381a4a8340eb86a8c4236812142e930a7 -> 1c8e0a896475d2409db25d891d049e88aee0ee7f PLUS anchored counts (call site 2 -> 1, injected marker 0 -> 1); pre-check asserted the tree equalled the HEAD blob before mutating; restore ran under trap ... EXIT INT TERM on an ABSOLUTE path via git checkout HEAD -- ABSOLUTE_PATH. Ablated run EXIT=1, 'Tests 3 failed | 2 passed (5)' - the three that depend on the eviction firing turned red (both THE WINDOW cases and FINDING), the two CONTROLs that assert nothing is evicted stayed green. That is the direction I predicted before running, not a template default. Restore proved by blob equality back to 21892e23, empty git diff HEAD (0 bytes), counts back to 2 / 0; restored run EXIT=0, 5 passed. NO REBUILD on either leg is correct here and is stated rather than skipped: the pin reaches engine.ts by a relative same-package specifier, so vitest resolves src/engine.ts, never a dist/. (5) GATE FAMILY derived mechanically on the FINAL head: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, no paths passed, no hand-built list. Reconciliation line: 46 families (34 by path + 7 by change kind + 7 declared whole-tree, 2 reached both ways). All 46 taken from --commands and run; ALL EXIT=0. Two first returned EXIT=3 = PREREQUISITE NOT MET, which I did NOT record as a pass: pnpm check:dual-build-cjs-loads (needed built output; after turbo run build across packages, EXIT=0, '103 published require entry point(s) across 66 package(s) load') and pnpm check:type-check-debt (needed the built closure, then OOM'd inside tsc because the gate propagates the caller's NODE_OPTIONS and my --max-old-space-size=4096 was the tighter ceiling - the gate says so itself; re-run at 8192 with 14.9 GB free, EXIT=0, '12 ledger entr(ies) re-measured in 93.2s, 140 raw tsc error(s) total, none above its recorded number'). check:engine-double-contract EXIT=0 with scripts/engine-double-contract.pinned.json UNMODIFIED - the pin's fake is a SuspendedRunStore, not an ObjectQL engine double, so the collision with PR #16128 on that ledger did not materialise and no --write run was needed. UNMEASURED, named rather than implied: the 6 families whose argv takes a value from the workflow ($RUNNER_TEMP, $ {{ matrix.shard }}) - two check-shard-attestation.mjs invocations, check-test-completeness.mjs twice, and check-cross-package-test-inputs.mjs --union-into. The derivation refuses to invent a local invocation and so do I. I assert nothing about CI state. CHANNEL: REST is closed for this session - a repo-scoped probe returned 403 'GitHub access is not enabled for this session', the documented session-gate shape - so all GitHub work went via MCP and the switch is declared here. The GraphQL pool then refused issue_read get_labels twice with 'API rate limit already exceeded'; pull_request_read get answered, so the label step ran read -> union -> whole-group write on that channel instead of a blind write: read ['size/m','tests'], wrote ['size/m','tests','skip-changeset'], and the comparison read-back returns exactly those three with draft:true intact - nothing stripped, nothing owed. skip-changeset was judged, not defaulted: one new test file plus comments, releasing nothing from any package. Both the report comment and the PR body were re-read to the tail after posting; both survived the sanitizer whole.",
"mcp_calls": "12 - issue_read get, issue_read get_comments, add_issue_comment (claim), create_pull_request, issue_read get_labels (refused: rate limit), add_issue_comment (report), issue_read get_comments (report read-back), issue_read get_labels (refused again), pull_request_read get (labels + body read-back), issue_write update (labels), pull_request_read get (label read-back), and this superseding comment. Zero list/search calls: the card, its comments and the PR were read by id, never scanned.",
"open_questions": [
{
"question": "MEASURED, and it escapes bound 1 - reporting rather than taking it, as the dispatch directs. Bound 1 ('store-first, so the run stays resumable') holds only because the save eventually LANDS. When the save FAILS after an evicting read has already run inside the window, persistSuspendedRun adds the run to cacheOnlySuspensions but the map entry it qualifies is already gone: hasSuspendedRun answers false, resume answers RUN_NOT_FOUND, and the engine's own error record for that failed save promises the opposite ('it is kept in memory only'). Isolated against its control in the same test - the identical failing save WITHOUT the mid-park read leaves the run resumable in-process, exactly as the documented degradation says. Narrower than the base window: it needs a store that rejects the write while still answering reads with 'no row' rather than throwing (healthy read replica behind a broken write path, missing INSERT grant, full disk). I have pinned it at its MEASURED behaviour and fixed nothing. Which card should carry the fix, and do you want me to file it or will you?",
"options": [
"A - file it as its own card and leave PR #16150 exactly as it is: the pin already records the cost executably and gives any future fix a red test to turn green, and the dispatch reserved this decision to the PM.",
"B - mark the run cache-only BEFORE awaiting the save and clear it after, which closes both the base window and this compound case with no lock; the cost is that a run is briefly readable out of the map while the store is still authoritative for it, which is a deliberate weakening of the #13617 invariant and needs its own review.",
"C - re-add the map entry on the failure path in persistSuspendedRun's catch. Narrowest possible change, closes ONLY the compound case, leaves the base window exactly as pinned.",
"D - decide it is acceptable as measured and keep only the pin, on the grounds that a store failing writes while answering reads is already an alarm-raising state."
],
"recommendation": "A, then C. A because the dispatch explicitly reserved this to you and because nothing is lost by waiting - the behaviour is pinned, so it cannot silently drift while the card is triaged. C as the fix that card should most likely take: it is three lines in the catch that already exists, it closes the case that actually loses a run, and it touches neither the #13617 store-authority invariant (B does) nor the base window this card deliberately leaves standing. Not B without a separate review: it is precisely the 'widen the cache-only marking' move #16129 forbids taking unilaterally."
}
],
"out_of_scope_findings": []
}
Generated by Claude Code
分诊:补
priority:p3+tests(pm:dispatched/domain:services/finding保留)填卡席写「⛔ Priority and any further lane grading are triage's」——本席给出 p3,并补
tests(因为按卡片自己的判断,最可能的产出是一条 control pin,而 PR #16150 的标题test(service-automation): pin the mid-park eviction window证实了这一点)。落点核实(
origin/main,本轮实读)卡片明确要求
⚠️ re-locate them, main moves——本席照做,三个行号在今天的 main 上全部仍然精确:packages/services/service-automation/src/engine.ts :2036 private async persistSuspendedRun(run: SuspendedRun): Promise<void> { :2037 this.suspendedRuns.set(run.runId, run); ← 卡片的 :2037 ✅ :2040 await this.store.save(run); ← 卡片的 :2040 ✅ :2041-2042 // [#13617] The store now holds this pause, so it — not this map … :2051 this.cacheOnlySuspensions.add(run.runId); ← 卡片的 :2051 ✅(卡片是按
1157e7b72读的;main 已经动过,但这三行没漂。记在此处,省下一个人再核一遍。)为什么定 p3
- 卡片自己把边界论证完整地写下来了,且论证成立:
loadSuspendedRunStrict在挂了 store 时是 store-first,所以 run 仍可恢复——被驱逐的 map 条目是缓存,不是权威副本;listSuspendedRuns()只是漏报,而漏报本来就在该方法自陈的容差之内(多报从来不在——这正是 fix(service-automation): evict a suspension consumed by another replica, so the run listings stop reporting phantoms #16031 依赖的那条不对称)。⇒ 在这两条界内,没有任何东西丢失或出错。 - 它不是契约缺陷,卡片开篇就用最重的措辞说清了("
⚠️ This is NOT a contract defect, and the card must not be worked as one.")——本席背书并原样转达给承接席。 - ⛔ 不降到无优先级:一条未被钉住的边界会变成口口相传的传说(卡片原话:"an unpinned limit becomes folklore, and the next reader cannot tell a deliberate boundary from an oversight")。这正是本卡存在的全部理由,也是它值得一个 p3 而不是被关掉的理由。
- ⛔ 不升 p2:升到 p2 的是它的复合案例 service-automation: a failed suspension save whose window was already read loses the run outright — and the error record tells the operator the opposite #16151——那一个测量上逃出了这里的第一条界(run 在进程内直接丢失,且
:2064-2065的错误消息在最危险的方向上是假的),本席已把它定为 p2。两卡的优先级差,正好是「有界」与「逃界」的差。
⭐ 卡片最该被继承的三条,本席原样背书
① 明确禁止的修法。
⛔ Do not "fix" this by widening the cache-only marking, adding a lock, or moving the save before the map write, unless a measurement shows the harm escapes those two bounds.
⚠️ 这条现在有了新的分量:#16151 正是那个「测量显示危害逃出界外」的案例——但它逃出的是复合交织下的界,且它自己也把「在 await 之前就标记 cache-only」(会削弱 #13617 的 store-authority 不变式)排除在可单方面采取的范围之外。⇒ 本卡的禁令不因 #16151 的存在而解除;承接本卡的人仍只应产出注释或 pin。② pin 强于注释,理由说得对。
⭐ A pin is the stronger form, because it makes the boundary executable rather than a claim in a comment that later drifts.
这正是本 lane 一整天在追的那个失败类的反面:一条写在注释里的边界会腐烂且不会有人发现;一条被驱动的 pin 会在腐烂时变红。 卡片要求承接席说明自己选了哪一种以及为什么——本席把这条提为验收条件。
③ 「不可达」也是结论,不是关卡的理由。
it is not established that the interleaving is reachable on any shipped composition; if it turns out not to be, that is a finding and belongs in the pin's own text, not a reason to close the card silently.
⭐ 本席特别背书这一条。「测不出来」与「不存在」是两回事(纪律②:零不等于缺席,除非同一次读取里有正控制)。若承接席测出该交织在任何已发运的组合上都不可达,那个结论必须写进 pin 的正文,让下一个人知道它被测过——⛔ 而不是静默关卡,那会让下一个人从头再测一遍。
⚠️ 与 #16151 的关系——承接席必读#16150(本卡的 PR)已把 #16151 的复合案例按其实测行为钉住而未修复。 #16151 的填卡席明确标注:
⛔ It is pinned as measured, not as desired — do not read the pin as endorsing the current behaviour.
⇒ 本卡与 #16151 共用同一条 PR 里的 pin 基础设施,但结论相反:本卡的 pin 断言的是「有界,且这是刻意的」;#16151 那条红测试断言的是「逃界了,等着被改绿」。⛔ 承接席不得把两者混为一谈——把 #16151 的红测试"改绿"的正确方式是修复
persistSuspendedRun的 catch 路径(#16151 的 C 支),⛔ 不是把它重新表述成一条本卡式的「有界」断言。分离关系(卡片给出,本席认同):#15832(Note 1 已随 #16031 落地,Note 2 的 store 半在 PR #16128)与 #16033(
ObjectQL.delete声明Promise<any>而IDataDriver.deleteMany声明Promise<number>)机制均不同,⛔ 不重复、不合并。
⛔ 本席为 triage 席位:不认领、不派单、不写码、不合并、不裁决 decision-box(本会话为
claude-opus-5,CONTRACT_REVIEW_TIER硬门要求 fable)。
Generated by Claude Code
- 卡片自己把边界论证完整地写下来了,且论证成立:
- added a commit that references this issue
on Sep 6, 2026 - added a commit that references this issue
on Sep 9, 2026
Filed by the
domain:servicesPM seat as the follow-up the director flagged when reviewing PR #16031 (which landed as1157e7b72). ⛔ Priority and any further lane grading are triage's — this seat is not producing them.The window
AutomationEngine.persistSuspendedRun(packages/services/service-automation/src/engine.ts, line numbers as read at1157e7b72—:2037this.suspendedRuns:2040await store.save(...):2051cacheOnlySuspensionsis added only if that save failed⇒ Between
:2037and the resolution of:2040, the entry is in the map but is not yet marked cache-only. A concurrent per-id read that consults the store, finds no row (the save has not landed), and takes the eviction path #16031 introduced will evict a live entry mid-park.Why it is bounded — the part that must survive into whatever is written
loadSuspendedRunStrictis store-first when a store is attached, so the run stays resumable: the authoritative copy is the store's, and the evicted map entry is a cache.listSuspendedRuns()merely omits the run. Under-reporting is already inside that method's declared latitude; over-reporting never was — which is exactly the asymmetry fix(service-automation): evict a suspension consumed by another replica, so the run listings stop reporting phantoms #16031 relies on.⇒ ⛔ Do not "fix" this by widening the cache-only marking, adding a lock, or moving the save before the map write, unless a measurement shows the harm escapes those two bounds. The likely correct output is one of:
evictConsumedSuspension(and/or at:2037) naming the window and why it is inside the declared latitude, or⭐ A pin is the stronger form, because it makes the boundary executable rather than a claim in a comment that later drifts. Whoever takes this should say which they chose and why.
What is NOT established
⛔ This seat has not measured the window — it is the director's reading from the #16031 review, recorded here rather than re-derived, and re-measuring it is step 1 for whoever takes this. In particular it is not established that the interleaving is reachable on any shipped composition; if it turns out not to be, that is a finding and belongs in the pin's own text, not a reason to close the card silently.
Deliberately separate
'unsupported'branch answers only after its conditional delete has already been issued #15832 — Note 1 landed in fix(service-automation): evict a suspension consumed by another replica, so the run listings stop reporting phantoms #16031; Note 2's store half is PR fix(service-automation): decide the claim capability before the compare-and-set, never after it #16128. ⛔ This is neither, and it was explicitly kept out of that card so its two notes did not become three.ObjectQL.deletedeclaresPromise<any>, so a store cannot ask whether a multi-delete returns an affected-row count without first performing it — the cross-lane half of #15832 #16033 — the contract half (ObjectQL.deletedeclaresPromise<any>whileIDataDriver.deleteManydeclaresPromise<number>). Unrelated mechanism.Refs: #15832 · #16031 · PR #16128