Skip to content

fix(objectql)!: a nested write's stale-roll-up refusal never answers for a delete or transaction that did not land - #29381

Merged
objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/project-thread-qf9muq
Nov 2, 2026
Merged

objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/project-thread-qf9muq

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

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 code

Narrowing 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 上。
  • 为什么改:数据入口、MCP、动作/沙箱/流程都把这个错误码当作"写入成功"。原子级联里子记录一失败整个事务回滚,父记录和子记录都还在,可调用方被告知删除成功(主干实测: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:

  • The cascade walk (packages/objectql/src/engine.ts, the cascadeDeleteRelations call in runByIdDelete and in runPredicateDelete). 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 becomes cascadeStoppedRefusal(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.
  • An owned transaction that rolls back (ObjectQL.transaction and ScopedContext.transaction, the catch after the rollback). A refusal escaping the callback is no longer true once every write in it is undone. It becomes rolledBackRefusal(e).
  • One meaning, one place (packages/objectql/src/summary-errors.ts). unlandedWriteRefusal is the single conversion: a code-less Error (an unrecomputable roll-up is an internal failure, so the data doors answer 500 INTERNAL_ERROR). Its message names the unit that did not complete and the stale summary (PARENT.FIELD), and the original refusal is kept as cause. Every other error passes through unchanged. It matches by code, as every other reader of the refusal does. Nothing new is exported, and there is no new error code or member.
  • Unchanged, by design: the refusal anywhere it is still true. That covers a dependent's own delete, a write outside any unit, a transaction that catches it and commits (the inAfterAbortUnit commit-then-rethrow included), and a joined transaction, whose owner decides.
  • No source change outside the engine. 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 as 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: .changeset/29291-cascade-summary-refusal-rolled-back.md, @objectstack/objectql patch, BREAKING (narrowing), adr-0087: not-required (no-migration-prescription).

Why

Measured on origin/main a19759ac before the fix, with engine-cascade-summary-rollback.test.ts (a stub driver that really rolls back; a two-level master_detail cascade where each line's delete recomputes a roll-up on another parent, and that parent's update is refused):

case stored after caller was told
atomic plan, delete the account account and both lines (rolled back, 1 begin / 1 rollback) ERR_SUMMARY_RECOMPUTE, written: true (the line's result)
runtime that cannot roll back account and line 2; line 1 deleted ERR_SUMMARY_RECOMPUTE, written: true
caller's own transaction() around a line delete both lines (rolled back) ERR_SUMMARY_RECOMPUTE, written: true

At the REST door on a real SqlDriver (better-sqlite3), DELETE /api/v1/data/qcr_account/a1 answered 200 {"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 assert code undefined and cause.code === 'ERR_SUMMARY_RECOMPUTE', plus what is stored:
    • atomic plan by id: refused, 1 rollback, account and lines stored;
    • a runtime that cannot roll back, by id: refused, account stored, line 1 gone (the measured reading);
    • the same by predicate (multi: true): refused, account stored;
    • a caller's engine transaction() the refusal rolls back: refused, nothing stored;
    • the same through a scoped context's transaction() (ctx.api);
    • CONTROL the line's own delete: ERR_SUMMARY_RECOMPUTE, written: true, line gone;
    • CONTROL a caller's transaction that catches the refusal and commits: the refusal stays, 0 rollbacks, line gone.
    • Red on base: 3 of the first 5 (the predicate and scoped-context cases were added after the fix; each is shown red by its own ablation below).
  • packages/rest/src/data-delete-cascade-summary-rollback.test.ts (2). The composed REST harness of data-write-summary-recompute-refused.test.ts on a real SqlDriver: DELETE of the account answers 500 INTERNAL_ERROR with the account and all lines stored; CONTROL DELETE of a line answers 200 { success: true } and the line is gone. Red on base (objectql dist proven 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 of dispatcher-unique-violation-wire-code.test.ts (HttpDispatcher → POST /api/v1/actions/:object/:action → ctx.engine → ObjectQL → SqlDriver on better-sqlite3), same cascade shape: an action whose body runs ctx.engine.delete('qca_account', 'a1') answers the action door's 400 refusal (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 answers 200 with 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 answers 400 because it answers every throw out of an action body that way (a hook's or body's own throw is a deliberate 4xx); this PR changes nothing there.
Ablations: each mutation landed with ablation-replace, red, restored to blob == HEAD
# mutation file red
AB0 unlandedWriteRefusal passes every error through (built; ablation-dist-preflight marker present in 4 dist files; restored, rebuilt, --absent and tree clean) summary-errors.ts engine 5 of 7 (every refusal pin, both controls green); REST 1 of 2
AB0-action the same mutation, on 2af3815d (built; marker present in 4 dist files; restored, rebuilt, --absent and tree clean) summary-errors.ts action pin 1 of 2 (expected 200 to be 400); CONTROL green; green again after restore
AB1 by-id walk without cascadeStoppedRefusal engine.ts 1 of 7 (no-rollback by id; the atomic case stays green through the transaction conversion)
AB2 predicate walk without it engine.ts 1 of 7 (predicate)
AB3 ObjectQL.transaction rollback without rolledBackRefusal engine.ts 1 of 7 (caller's transaction)
AB4 ScopedContext.transaction rollback without it engine.ts 1 of 7 (scoped context)

AB1 to AB4 ran on a5bc9c90 plus 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

  • Declared as narrowing (the declaration line above; claim amendment 6577647622). A delete whose cascade a dependent's failing roll-up stopped, and a transaction that refusal rolled back, answered success (ERR_SUMMARY_RECOMPUTE, read as success by every door) and now answer 500 INTERNAL_ERROR at the data door and 400 at the action door. The published @objectstack/objectql behaves differently on that answer; the data that lands is unchanged.
  • The 500 body is the door's sanitised {"error":"Internal server error","code":"INTERNAL_ERROR"}. The message (what did not complete, which summary is stale, the fix) reaches the server log and cause. 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) as 404 OBJECT_NOT_FOUND. The first green leg measured exactly that (404 OBJECT_NOT_FOUND), and the quotes were changed before the pin was finalised.
  • Out of reach on purpose: the discrete beginTransaction / commit / rollback trio on ScopedContext (the QuickJS sandbox's ctx.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 as 153869d7), a sandbox body's write already answers its own success inside such a unit. Also unchanged is the degrade path of transaction() on a runtime that cannot roll back, where every write before the refusing one, and the refusing one itself, did land.
  • Noted, not filed (read-only inference, not measured): an after* hook whose ctx.api write 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. The written the caller reads, though, is the hook write's result, not the outer write's.
  • The non-atomic alternative left open: tolerating a dependent's stale summary, so that the walk continues and the parent delete lands with ERR_SUMMARY_RECOMPUTE, would turn refused deletes into landed ones (widening). The ruling does not cover it, so it is not done here; see open_questions in the card report.
  • 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 (scripts: a body's ctx.engine / ctx.api write that lands under a failing parent summary recompute throws into the author's code, so later steps never run and the action, hook or job reports a failure #28861) merged as 153869d7 while this PR was open. main is merged in (8fc7f886) and the one pin through an action surface the seat asked for is added (the runtime file above).
  • No packages/spec/**, ui/**, governed or workflow file.

Verification

Suites and typecheck
  • @objectstack/objectql full local suite on f1eec6fd (main merged at 504ba510): 529 of 529 files, 10266 of 10266 tests; typecheck (tsc --noEmit + check:test-typecheck): exit 0. os-verify-lock VERDICT command-exit 0 for the whole chain.
  • @objectstack/rest full local suite on f1eec6fd: 368 of 368 files, 6976 passed and 332 skipped; typecheck: exit 0.
  • Final head 2af3815d (main merged at 8fc7f886, 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/runtime typecheck (tsc --noEmit + check:test-typecheck) exit 0. The objectql and rest full suites above are not re-run: since f1eec6fd this branch changed only test files and the changeset, and main's own changes are covered by the dogfood below.
  • Full dogfood on the final head 2af3815d (rerun, because merging main changed code): closure built (turbo run build --filter='@objectstack/dogfood^...', 68 of 68), then pnpm --filter @objectstack/dogfood test under 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 on f1eec6fd was 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/objectstack derived 63 commands (the same 63 on f1eec6fd, 4dd59050 and 2af3815d). Each ran with < /dev/null. --ran reconciles 63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN (exit 0), and all 63 exit 0. check:dual-build-cjs-loads first exited 3 (prerequisite: some package dists missing) and is recorded from its re-run after turbo 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.
  • First pass, on f1eec6fd: two real reds, both in my new test files, fixed in 4dd59050 rather than by any baseline entry. check:query-options-erasure (test surface 236 → 237: an as any on the REST pin's engine.find options; typed now, and the engine pin's delete options are no longer cast). check:rest-log-spy-declared (the REST pin spied on console with no OS_REST_LOG level; the spies are removed, since the pin asserts no log). Two prerequisite exit-3s cleared on re-run: check-plugin-teardown-shape --self-test after git fetch --depth=1 origin 621a4876…, and check:dual-build-cjs-loads after the build.
  • pnpm lint: exit 0 on 2af3815d (eslint . --no-inline-config).
  • PR body offline: check-changeset-no-major --base origin/main --event (this body and title) exit 0, LEVEL AXIS no (narrowing) + none at patch. check-closing-keyword-parity --body: only Fixes #29291 binds.

Human Minimum

  • 1. Schemas start as Zod; packages/spec holds no runtime logic. (no spec edit)
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; pnpm lint passes.
  • 4. One issue, one branch off an up-to-date main, one pull request.
  • 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset.
  • 6. packages/spec touched: n/a.
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English.

Generated by Claude Code

…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>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Nov 2, 2026
@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

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

Which tree this was computed on

This run read content/docs from 3f43b57b50870ffb0ccc6e48bc8c91e17d9e7c4b — the merge of head 2af3815d9e444653480ccf23f5b29028c57499fc into base 838a802bf36210a52968d9dfb2fa9fe62a29de01, which is what actions/checkout gives a pull_request run. Not the PR head.

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

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 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

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

…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
@github-actions github-actions Bot added size/l and removed size/m labels Nov 2, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#3 · os-steve · session_01ADHkmQJdgarUjPLNgFBY67

Contract-review-claim: session_01ADHkmQJdgarUjPLNgFBY67 · head 2af3815 · 2026-11-02T14:24Z

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#3 · os-steve · session_01ADHkmQJdgarUjPLNgFBY67

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 2af3815d9e444653480ccf23f5b29028c57499fc
Local-runs: none
Scope: packages/objectql/src/engine.ts, packages/objectql/src/summary-errors.ts, packages/objectql/src/engine-cascade-summary-rollback.test.ts, packages/rest/src/data-delete-cascade-summary-rollback.test.ts, packages/runtime/src/action-delete-cascade-summary-rollback.test.ts + packages/objectql/src/engine-hook-dispatch.ts (inAfterAbortUnit), packages/objectql/src/index.ts, packages/objectql/src/core.ts, packages/types/src/summary-recompute-answer.ts, packages/types/src/driver-fault-redaction.ts, packages/types/src/data-error-classification.ts, packages/rest/src/error-response.ts, packages/metadata-protocol/src/protocol-batch-data.ts, packages/runtime/src/landed-write-answer.ts, packages/runtime/src/action-execution.ts, packages/spec/src/contracts/objectql-engine.ts, packages/spec/src/contracts/scoped-context.ts, packages/spec/src/api/error-code-ledger.zod.ts

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 git diff origin/main...pr29381, the check-runs on the head (every non-skipped run success: Check Changeset, PR-body guards, Governed Surface Queue Guard, Lint & Repo Gates with all scoped lint legs, TypeScript Type Check with all legs, Build Core, Test Core 1/8 to 8/8, Dogfood Regression Gate, Test UI, Live E2E UI, Flag docs affected). The cited code was read with git show on pr29381 and origin/main; nothing was built, run or re-run.

① Derived judgments

  1. unlandedWriteRefusal (summary-errors.ts): matches by code === SUMMARY_RECOMPUTE_CODE, returns every other value unchanged, builds a plain Error with no code, sets cause to the original refusal and names the stale summaries from failures[].parentObject.field. Right: matched by code as every reader of the refusal matches it (isSummaryRecomputeRefusal, import-runner, seed-loader), and a code-less Error is what the data door's terminal classifies as 500 INTERNAL_ERROR (types data-error-classification.ts line 162).
  2. By-id delete, runByIdDelete: cascadeDeleteRelations(...).catch(e => throw cascadeStoppedRefusal(e, object)) before driver.delete. Right: the walk deletes dependents through the public this.delete (engine.ts line 20064) before the parent's own row, so a dependent's refusal reaching here means the parent row was never removed; converting there covers the atomic plan (the owning this.transaction then sees an already code-less error and passes it through), the 'split' plan and a runtime that cannot roll back. Pinned by tests 1 and 2 of the engine file.
  3. Predicate delete arm: the same .catch around the one walk rooted at matchedIds, before deleteMany. Right, same reasoning; pinned by test 3.
  4. ObjectQL.transaction owned catch: redactPropagatedDriverFault(rolledBackRefusal(err)) after the rollback. Right: this catch is reached only on the owned path (the joined path returns the callback directly at line 21818, the degrade path at line 21856), so the refusal escaping here always describes a write that was undone. Redaction carries own properties including cause and recurses into it (driver-fault-redaction.ts contract, MAX_CAUSE_DEPTH 4), so the operator keeps the original refusal and its failures. Pinned by test 4 and the commit-and-keep control (test 7).
  5. ScopedContext.transaction owned catch: the twin, same judgment; joined and degrade paths unchanged. Pinned by test 5.
  6. Joined transaction, degrade path (no beginTransaction), a dependent's own delete, a write outside any unit, inAfterAbortUnit (engine-hook-dispatch.ts lines 74 to 85, catches instanceof SummaryRecomputeError INSIDE the callback, commits, rethrows): all unchanged, and rightly so — in each the write that raised the refusal stands. Where the parent delete runs inside inAfterAbortUnit, the cascade conversion fires first inside the joined unit, the code-less error is not caught by that instanceof check, the owning unit rolls back and passes it through: the caller is refused. Right.
  7. Atomic batch (protocol-batch-data.ts runAtomicBatch / landedDespiteStaleSummary): the per-row loop catches every row error inside the transaction callback and aborts through an identity-checked ABORT sentinel, which rolledBackRefusal leaves untouched. No change to the batch envelope. Right.
  8. Answering surfaces: REST and MCP data doors (staleSummaryRefusalOrThrow), action ctx.engine / ctx.api and sandbox (landedWriteAnswer), flow nodes (crud-nodes.ts) all read the engine's answer and are not edited; a stopped cascade or a rolled-back transaction now reaches them as an ordinary error. REST pin: DELETE 200 to 500 INTERNAL_ERROR, rows intact. Action pin: 400 success:false with the engine message, body stopped, rows intact — the 400 is the action door's standing rule for any throw out of a body, not a change in this diff. Right.
  9. Public surface: cascadeStoppedRefusal and rolledBackRefusal are module exports of summary-errors.ts only; index.ts re-exports SummaryRecomputeError, SUMMARY_RECOMPUTE_CODE and the SummaryRecomputeFailure type and nothing else from that module; core.ts imports nothing from it; package exports are . and ./core. Nothing new is published; no new error code, no ledger row. Right. The ERR_SUMMARY_RECOMPUTE ledger row and its meaning ("the triggering records WERE written") are unchanged, and now true everywhere the code is emitted.
  10. The accept set narrowed: a delete whose cascade a dependent's refusal stopped, and an owned transaction() the refusal rolled back, answered the refusal (read as success by every door) and now answer a refusal that is not it. Correctly declared as a narrowing of @objectstack/objectql.

② Semver level

Changeset .changeset/29291-cascade-summary-refusal-rolled-back.md: '@objectstack/objectql': patch, title carries !, body carries the **BREAKING** banner, Metadata-protocol: none, and the ADR-0087 comment not-required (no-migration-prescription) with its category closure. Under maintainer ruling A (level follows the metadata protocol alone; clause2-line.mjs header) none at patch is the right level for an engine-answer narrowing that adds, removes or re-shapes no spec key, declaration or stored shape; Check Changeset on the head is success, and that is the gate verdict.
Clause-②: no (narrowing) — the PR body names the key on exactly one line-initial line (body line 6) and the changeset on exactly one (line 7); the "Narrowing note" lines on both carriers do not spell the key. The amendment 6577647622 is honoured: round 1's widen-one-narrow-another value is gone from both carriers. Level axis measured. Right.

③ Boundary flags

  • open_questions (round 1, non-atomic plan A vs B): round 2 records "ruled A by the seat — keep refusing the parent's caller". Answered. The tree matches A: on a runtime that cannot roll back the first dependent stays deleted, the parent and later dependents stay stored, the caller is refused (engine test 2), and the message says dependents written before the stop stay written. B would turn refused deletes into landed ones and is a widening needing its own card, as the dev says.
  • Deviation "action door answers 400 not 500": the door's own rule for any throw out of an action body (verified on the pin; no door edit in the diff). Accepted as measured.
  • Deviation "dogfood ran on f1eec6f not the final head" (round 1): superseded — round 2 reports the dogfood rerun on 2af3815d and the Dogfood Regression Gate check-run on the head is success.
  • Deviations on the attribution block, no assignee, the AB0 marker rerun, the changeset declaration line rewrite, 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 having merged (main merged at 8fc7f88), the signed trailer, the queued typecheck rerun: housekeeping, none touches the contract.
  • out_of_scope finding 1 (an after* hook's ctx.api write raising the refusal: inAfterAbortUnit commits and rethrows, so the outer caller reads written as the hook write's result): real in the tree (engine-hook-dispatch.ts line 84 rethrows the hook write's own error object), correctly left out of this PR (both writes landed, the code is true), and it should become its own card — escalated to the seat, not a FAIL.
  • out_of_scope finding 2 (the ScopedContext begin/commit/rollback trio is caller-driven): correct reading, nothing for the engine to convert there; noted.

④ Design judgment

The published spec on main declares the refusal's meaning in the .d.ts of SummaryRecomputeError ("The triggering records WERE written — this signals a stale/incorrect summary, not a failed write") and in summary-recompute-answer.ts ("a write that landed"); IScopedContext.transaction declares "committed when it returns, rolled back when it throws"; the ledger row and the v16 release note say only that recompute failures surface as ERR_SUMMARY_RECOMPUTE. No spec surface declares that a nested write's refusal is the outer unit's answer, and none promises error identity across transaction(). The narrowing therefore takes no declared capability away at any consumer: every consumer's accept set for the refusal (bulk callers reading written, doors answering success) is unchanged wherever the declaration is true, and the two units that carried a false "stored" past a rollback now answer what the spec's own words require. This is the spec's refusal made real, done at the producer, not a consumer-side narrowing. The conversion site is right: the walk's .catch sits exactly between the dependents' writes and the parent's own driver.delete, and the owned-rollback catch is the only place a refusal can leave a unit after being undone.

Attempts to break it, with findings:

  • Three-level cascade (grandchild refuses): the child's own runByIdDelete converts first, naming the child object; the parent's walk then receives a code-less error and cascadeStoppedRefusal passes it through, so the parent's caller reads 'The delete of "child" did not complete'. Status and rows are right (500 at the data door, nothing landed under atomic); only the lead names an inner object, and the action door relays that message to the client. should-fix: re-lead (or chain) the message at each outer walk, or carry the root object on the converted error.
  • The REST 500 depends on the message text falling through every heuristic in classifyDataError (the double-quote dodge of the 'object' + "not" unknown-object rule is documented in the code and pinned by the REST test). A refusal that declared status: 500 would take the structural pass-through arm (which drops prose in the 5xx band anyway) and stop depending on phrasing. should-fix.
  • ObjectQL.transaction swallows a rollback failure and then still converts to "The transaction rolled back, so nothing written inside it was stored"; when the rollback itself failed that sentence may be false (pre-existing posture that the original error surfaces; ScopedContext.transaction does not swallow, so the twin is unaffected). should-fix: hedge the lead or skip the conversion when the rollback threw.
  • Owned transaction whose callback catches the refusal and commits: unchanged, refusal stays, no rollback (control test 7) — holds.
  • Degrade path of transaction() on a driver with no transactions: no catch, refusal passes through, and the write did land — holds.
  • Redaction interplay: the converted Error's cause is the refusal whose failures[].error may carry driver text; redactPropagatedDriverFault cuts down the cause chain and preserves own properties, so cause.code survives (engine tests assert it) — holds.
  • Export leakage: neither helper reaches index.ts or core.ts — holds.
    No must-fix found. Judged on the tree at 2af3815d, not on the PR body's account.

Implemented-by: claude/project-thread-qf9muq
Reviewed-by: session_01ADHkmQJdgarUjPLNgFBY67

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#3 · os-steve · session_01ADHkmQJdgarUjPLNgFBY67

席位核对 · ACCEPT · PR #29381 · 2026-11-02T14:35Z

Head-sha: 2af3815d9e444653480ccf23f5b29028c57499fc

维护者速读(席位终稿)

核对六项

  1. 形态:draft → main;标题带 !;Fixes #29291,与认领 6576326086 及其修正 6577647622 一致;6 个文件 +618/-8,都在申报面内(objectql 引擎与 summary-errors、三个入口的测试,本卡 changeset)。
  2. 前提实测:契约复审(CONTRACT_REVIEW_TIER,收窄面)在本 head 上 PASS,记录 6579773378,无必修项。复审确认:转换点正好在子记录写完、父记录 driver.delete 之前;回滚的 catch 只在自有路径上;不导出新符号,不加新错误码。
  3. 门禁与 dogfood:dev 在 2af3815d 跑全量 dogfood,VERDICT 0(393 个文件 / 3243 条测试);dispatch-gates 63 derived / 63 run / 0 NOT-MEASURED;pnpm lint、runtime typecheck 为 0。消融:动作入口的测试在去掉转换后转红(expected 200 to be 400),恢复后转绿。Check Changeset 在本 head 是绿,级别轴已测。
  4. 接受的偏差:动作入口答 400 而不是 500,这是动作入口对任何抛错的既有规则,本 PR 没改它,按实测钉住,接受。第一轮的 dogfood head 已被本轮重跑取代。
  5. 范围外去向:复审的三条 should-fix 已立 objectql: the unlanded-write refusal from #29291 leads with an inner object on a nested cascade, reaches 500 by message heuristics, and claims "rolled back" when the rollback itself failed #29421(建议 p3):嵌套级联的消息领头写的是内层对象;500 依赖文字启发式;回滚本身失败时仍说「已回滚」。dev 的范围外发现已立 objectql: an after-hook's own write that raises ERR_SUMMARY_RECOMPUTE is rethrown to the outer caller, so the caller reads the hook write's result as its own #29422:after* 钩子自己的写入触发拒绝时,外层调用方读到的是钩子的 written。开放问题(不能回滚的运行时)由席位裁定 A,实现与之一致。
  6. 落地条款:Clause-②: no (narrowing),PR 正文与 changeset 都有,各只一行;收窄面契约复审与全量 dogfood 已做。semver:@objectstack/objectql patch,带 BREAKING 说明,ADR-0087 标记 not-required (no-migration-prescription)。Metadata-protocol: none。check-governed-merges --pr 29381:NOT governed(入队前复核)。落地授权,维护者原话:「按照 pm-dispatch 规则复核过的都授权」(2026-10-25T11:35Z)。

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 2, 2026 14:36
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 2, 2026
Merged via the queue into main with commit 3291213 Nov 2, 2026
53 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-qf9muq branch November 2, 2026 14:58
os-steve pushed a commit that referenced this pull request Nov 2, 2026
…, 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>
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

1 participant