Repository navigation
fix(objectql)!: a nested write's stale-roll-up refusal never answers for a delete or transaction that did not land - #29381
Conversation
…for a delete or transaction that did not land A cascade dependent's ERR_SUMMARY_RECOMPUTE stopped the parent delete's walk (and, under the atomic plan, rolled the dependent back), yet reached the caller unchanged, so every surface that answers that refusal as the write's success reported a delete that never happened. The cascade walk and both owned transaction() rollbacks now convert it into a code-less refusal carrying the original as cause. Claude-Session: https://claude.ai/code/session_01ADHkmQQoWsHzMSsiaM89mr Co-authored-by: Claude <noreply@anthropic.com>
…n-object heuristic; changeset Claude-Session: https://claude.ai/code/session_01ADHkmQQoWsHzMSsiaM89mr Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADHkmQQoWsHzMSsiaM89mr Co-authored-by: Claude <noreply@anthropic.com>
…le spies Claude-Session: https://claude.ai/code/session_01ADHkmQQoWsHzMSsiaM89mr Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADHkmQQoWsHzMSsiaM89mr Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check5 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
Coarse fallback — 20 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 3f43b57b50870ffb0ccc6e48bc8c91e17d9e7c4b && git checkout 3f43b57b50870ffb0ccc6e48bc8c91e17d9e7c4b
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 838a802bf36210a52968d9dfb2fa9fe62a29de01 2af3815d9e444653480ccf23f5b29028c57499fc && git checkout -B drift-repro 838a802bf36210a52968d9dfb2fa9fe62a29de01 && git merge --no-ff 2af3815d9e444653480ccf23f5b29028c57499fc
node scripts/docs-audit/affected-docs.mjs --json 838a802bf36210a52968d9dfb2fa9fe62a29de01 |
…ascade as a refusal; Clause-② reads no (narrowing) An action body's ctx.engine.delete (PR #29283 answers a landed write's stale-roll-up refusal as success) whose master_detail cascade a dependent's failing roll-up stopped now answers the action door's 400 refusal, the body does not run on, and nothing is deleted. Ablating the engine conversion answers 200 {success:true,data:{deleted:'a1'}} for a delete that rolled back. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADHkmQQoWsHzMSsiaM89mr
|
domain:server#3 · os-steve · session_01ADHkmQJdgarUjPLNgFBY67 Contract-review-claim: session_01ADHkmQJdgarUjPLNgFBY67 · head 2af3815 · 2026-11-02T14:24Z |
|
domain:server#3 · os-steve · session_01ADHkmQJdgarUjPLNgFBY67 Contract reviewServed-tier: Inputs read: card #29291 body and all five comments (triage 6575733609, claim 6576326086, claim amendment 6577647622, dev reports 6578447879 and 6579552186), PR #29381 body, file list and ① Derived judgments
② Semver levelChangeset ③ Boundary flags
④ Design judgmentThe published spec on Attempts to break it, with findings:
Implemented-by: VERDICT: PASS |
|
domain:server#3 · os-steve · session_01ADHkmQJdgarUjPLNgFBY67 席位核对 · ACCEPT · PR #29381 · 2026-11-02T14:35ZHead-sha: 维护者速读(席位终稿)
核对六项
|
…, declares 500 INTERNAL_ERROR, and never claims a failed rollback - A conversion that meets an earlier one re-leads it (cascade walk and hook seam), so a nested cascade's caller reads its own object; `cause` stays the original ERR_SUMMARY_RECOMPUTE at every depth. - The converted error declares `status: 500` and `code: INTERNAL_ERROR`, so doors read it structurally. Data door body byte-identical; the action door now answers 500 INTERNAL_ERROR (was 400), same sentence. - ObjectQL.transaction no longer says "rolled back" when its rollback threw: rollbackFailedRefusal says nothing is known about what is stored. Forced test edits: the #29381 objectql pin's code assertions (undefined -> INTERNAL_ERROR), the action pin's 400 -> 500, and the data door pin tightened to the whole body. Claude-Session: https://claude.ai/code/session_01ADHkmPCSqt8uDw17RmX9hu Co-authored-by: Claude <noreply@anthropic.com>
Requested by Steve · project thread
Fixes #29291
Clause-②: no (narrowing)
Metadata-protocol: none
Skills-docs: none — no file under
skills/**or the hand-written docs teaches what a cascade delete or a rolled-back transaction answers when a dependent's roll-up cannot be recomputed; the two generated reference pages only list the codeNarrowing note: a delete or a transaction that answered success after rolling back now refuses (
!in the title, full dogfood, seat contract review); claim amendment 6577647622 on #29291.维护者速读
ERR_SUMMARY_RECOMPUTE,意思是"这次写入已落库,只是汇总没算出来")不再原样冒给父记录删除的调用方;事务因这个错误回滚时也一样。两处都改成一个不带错误码的内部错误(数据入口答 500),原错误挂在cause上。DELETE答200 { success: true },记录仍在)。ERR_SUMMARY_RECOMPUTE。无新错误码、无数据迁移,回滚即整体 revert。packages/objectql/src/summary-errors.ts(新增unlandedWriteRefusal及两个包装);packages/objectql/src/engine.ts(runByIdDelete/runPredicateDelete的级联调用,以及ObjectQL.transaction与ScopedContext.transaction的回滚 catch)。pnpm --filter @objectstack/objectql exec vitest run src/engine-cascade-summary-rollback.test.ts,应看到 7 个测试全部通过;动作入口另见packages/runtime/src/action-delete-cascade-summary-rollback.test.ts(2 个,动作体内ctx.engine.delete答 400 拒绝而非成功)。What changes
SummaryRecomputeError(ERR_SUMMARY_RECOMPUTE) means "this write is stored, only a parent roll-up recompute failed", and every answering surface turns it into the write's own success. Two engine units carried a nested write's refusal past the point where it stopped being true. Both now convert it at the engine, so no answering surface changes:packages/objectql/src/engine.ts, thecascadeDeleteRelationscall inrunByIdDeleteand inrunPredicateDelete). A dependent's refusal stops the walk before the parent's own row is removed, so it is never the parent delete's answer. It becomescascadeStoppedRefusal(e, object). This covers every plan: the atomic plan (the owning transaction then rolls back), a joined transaction, the'split'plan and a runtime that cannot roll back.ObjectQL.transactionandScopedContext.transaction, the catch after the rollback). A refusal escaping the callback is no longer true once every write in it is undone. It becomesrolledBackRefusal(e).packages/objectql/src/summary-errors.ts).unlandedWriteRefusalis the single conversion: a code-lessError(an unrecomputable roll-up is an internal failure, so the data doors answer500 INTERNAL_ERROR). Its message names the unit that did not complete and the stale summary (PARENT.FIELD), and the original refusal is kept ascause. Every other error passes through unchanged. It matches bycode, as every other reader of the refusal does. Nothing new is exported, and there is no new error code or member.inAfterAbortUnitcommit-then-rethrow included), and a joined transaction, whose owner decides.153869d7) made the action, sandbox and flow writers answer a landed write's refusal as success; they read the engine's answer, so the conversion reaches them with no edit there..changeset/29291-cascade-summary-refusal-rolled-back.md,@objectstack/objectqlpatch, BREAKING (narrowing),adr-0087: not-required (no-migration-prescription).Why
Measured on
origin/maina19759acbefore the fix, withengine-cascade-summary-rollback.test.ts(a stub driver that really rolls back; a two-levelmaster_detailcascade where each line's delete recomputes a roll-up on another parent, and that parent's update is refused):ERR_SUMMARY_RECOMPUTE,written: true(the line's result)ERR_SUMMARY_RECOMPUTE,written: truetransaction()around a line deleteERR_SUMMARY_RECOMPUTE,written: trueAt the REST door on a real
SqlDriver(better-sqlite3),DELETE /api/v1/data/qcr_account/a1answered200 {"object":"qcr_account","id":"a1","success":true}while the account and all three lines were still stored.The non-atomic plan, as the delta asked. On a runtime that cannot roll back, the dependent the walk reached first stays deleted (its own write landed) and the parent and every later dependent stay stored. The parent's own write did not land, so the ruling's first sentence covers it without any reading of my own: the parent's caller now gets the same refusal. The message says that dependents written before the stop stay written unless a transaction rolls them back. What lands is unchanged. Whether the walk should instead tolerate a dependent's stale summary and finish the delete is a widening the ruling does not cover; see Acceptance notes.
Tests
Three new files, red first (no existing test was edited; no forced test edits):
packages/objectql/src/engine-cascade-summary-rollback.test.ts(7). Refusal pins assertcodeundefined andcause.code === 'ERR_SUMMARY_RECOMPUTE', plus what is stored:multi: true): refused, account stored;transaction()the refusal rolls back: refused, nothing stored;transaction()(ctx.api);ERR_SUMMARY_RECOMPUTE,written: true, line gone;packages/rest/src/data-delete-cascade-summary-rollback.test.ts(2). The composed REST harness ofdata-write-summary-recompute-refused.test.tson a realSqlDriver:DELETEof the account answers500 INTERNAL_ERRORwith the account and all lines stored; CONTROLDELETEof a line answers200 { success: true }and the line is gone. Red on base (objectqldistproven to be main's): 1 of 2,expected 200 to be 500.packages/runtime/src/action-delete-cascade-summary-rollback.test.ts(2), added after PR fix(runtime,service-automation): a write in an action, hook or job body, or a flow record node, that landed under a failing roll-up answers its own success #29283 merged. The real stack ofdispatcher-unique-violation-wire-code.test.ts(HttpDispatcher→POST /api/v1/actions/:object/:action→ctx.engine→ObjectQL→SqlDriveron better-sqlite3), same cascade shape: an action whose body runsctx.engine.delete('qca_account', 'a1')answers the action door's400refusal (success: false, the engine's message), the body's next line never runs, and the account and all three lines are still stored. CONTROL an action deleting a line itself answers200with its body's result and the line is gone. Red with the conversion ablated (AB0-action below):expected 200 to be 400, body{"success":true,"data":{"deleted":"a1"}}, the bug as reported. The door answers400because it answers every throw out of an action body that way (a hook's or body's ownthrowis a deliberate 4xx); this PR changes nothing there.Ablations: each mutation landed with
ablation-replace, red, restored to blob == HEADunlandedWriteRefusalpasses every error through (built;ablation-dist-preflightmarker present in 4 dist files; restored, rebuilt,--absentand tree clean)summary-errors.ts2af3815d(built; marker present in 4 dist files; restored, rebuilt,--absentand tree clean)summary-errors.tsexpected 200 to be 400); CONTROL green; green again after restorecascadeStoppedRefusalengine.tsengine.tsObjectQL.transactionrollback withoutrolledBackRefusalengine.tsScopedContext.transactionrollback without itengine.tsAB1 to AB4 ran on
a5bc9c90plus the predicate pin (committed as the next commit), and AB0 on that commit. The first AB0 attempt used a comment marker, which tsup strips, so the dist preflight could not see it (the red reading was the same). It was rerun with a string-literal marker, and the counts above are from that rerun.Acceptance notes
ERR_SUMMARY_RECOMPUTE, read as success by every door) and now answer500 INTERNAL_ERRORat the data door and400at the action door. The published@objectstack/objectqlbehaves differently on that answer; the data that lands is unchanged.{"error":"Internal server error","code":"INTERNAL_ERROR"}. The message (what did not complete, which summary is stale, the fix) reaches the server log andcause. It names the object in double quotes on purpose: a single-quoted object name beside a "not" is read by the data door's unknown-object heuristic (data-error-classification.ts) as404 OBJECT_NOT_FOUND. The first green leg measured exactly that (404 OBJECT_NOT_FOUND), and the quotes were changed before the pin was finalised.beginTransaction/commit/rollbacktrio onScopedContext(the QuickJS sandbox'sctx.api.transaction). The caller drives the rollback there, so the engine never sees the refusal leave the unit. Since PR fix(runtime,service-automation): a write in an action, hook or job body, or a flow record node, that landed under a failing roll-up answers its own success #29283 (merged as153869d7), a sandbox body's write already answers its own success inside such a unit. Also unchanged is the degrade path oftransaction()on a runtime that cannot roll back, where every write before the refusing one, and the refusing one itself, did land.after*hook whosectx.apiwrite raises the refusal. The outer write's abort unit (inAfterAbortUnit) catches it, commits and rethrows, so both writes did land and the code is true. Thewrittenthe caller reads, though, is the hook write's result, not the outer write's.ERR_SUMMARY_RECOMPUTE, would turn refused deletes into landed ones (widening). The ruling does not cover it, so it is not done here; seeopen_questionsin the card report.153869d7while this PR was open.mainis merged in (8fc7f886) and the one pin through an action surface the seat asked for is added (the runtime file above).packages/spec/**,ui/**, governed or workflow file.Verification
Suites and typecheck
@objectstack/objectqlfull local suite onf1eec6fd(main merged at504ba510): 529 of 529 files, 10266 of 10266 tests;typecheck(tsc --noEmit+check:test-typecheck): exit 0.os-verify-lockVERDICT command-exit 0 for the whole chain.@objectstack/restfull local suite onf1eec6fd: 368 of 368 files, 6976 passed and 332 skipped;typecheck: exit 0.2af3815d(main merged at8fc7f886, then the action pin): the three new files re-run green (objectql 7 of 7, rest 2 of 2, runtime 2 of 2), and@objectstack/runtimetypecheck(tsc --noEmit+check:test-typecheck) exit 0. The objectql and rest full suites above are not re-run: sincef1eec6fdthis branch changed only test files and the changeset, and main's own changes are covered by the dogfood below.2af3815d(rerun, because merging main changed code): closure built (turbo run build --filter='@objectstack/dogfood^...', 68 of 68), thenpnpm --filter @objectstack/dogfood testunder the lock: VERDICT command-exit 0 (34m19s, shared box), Test Files 393 passed and 3 skipped (396), Tests 3243 passed and 28 skipped (3271). The earlier run onf1eec6fdwas also green (391 files, 3223 tests).Gates
On the final head
2af3815d, run after the dogfood closure build (unlocked):node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 63 commands (the same 63 onf1eec6fd,4dd59050and2af3815d). Each ran with< /dev/null.--ranreconciles 63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN (exit 0), and all 63 exit 0.check:dual-build-cjs-loadsfirst exited 3 (prerequisite: some package dists missing) and is recorded from its re-run afterturbo run build --filter='./packages/**'(78 of 78, all cache hits): exit 0. On round 1 the four changeset-reading gates (check-adr-0087-registration,check-changeset-no-major,check-empty-changeset,check:pm-changeset-deadline-census) were re-run after the last changeset edit, and all exit 0.f1eec6fd: two real reds, both in my new test files, fixed in4dd59050rather than by any baseline entry.check:query-options-erasure(test surface 236 → 237: anas anyon the REST pin'sengine.findoptions; typed now, and the engine pin'sdeleteoptions are no longer cast).check:rest-log-spy-declared(the REST pin spied onconsolewith noOS_REST_LOGlevel; the spies are removed, since the pin asserts no log). Two prerequisite exit-3s cleared on re-run:check-plugin-teardown-shape --self-testaftergit fetch --depth=1 origin 621a4876…, andcheck:dual-build-cjs-loadsafter the build.pnpm lint: exit 0 on2af3815d(eslint . --no-inline-config).check-changeset-no-major --base origin/main --event(this body and title) exit 0, LEVEL AXISno (narrowing)+noneatpatch.check-closing-keyword-parity --body: onlyFixes #29291binds.Human Minimum
packages/specholds no runtime logic. (no spec edit)pnpm lintpasses.main, one pull request.packages/spectouched: n/a.content/docs/references/,packages/*/CHANGELOG.mdorcontent/docs/releases/.Generated by Claude Code