Skip to content

fix(objectql): a record the same cascade deletes never refuses its sibling's delete - #22377

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-22305-cascade-sibling-restrict
Oct 9, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-22305-cascade-sibling-restrict

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #22305
Clause-②: no

What was wrong

A by-id delete runs ObjectQL.cascadeDeleteRelations. It walks _registry.getAllObjects() in registration order and recurses depth-first through the public delete(). Each level judged its own restrict refusals without knowing about the cascade that called it.

The measured shape is hotcrm's account. An account cascades (master_detail) to its contacts and to its contracts. Each contract holds a required lookup to a contact, and that lookup's default set_null escalates to restrict. With the contacts registered first, the walk deleted a contact first, and the contact's own walk refused on a contract that the same account delete was about to remove. With the contracts registered first, the identical delete succeeded.

The rule (triage ruling 6062588548, verbatim)

  • Direction: a child that is itself in the cascade set never restricts a sibling in the same set. Pin: the account-with-contracts shape deletes. Control: a restrict on a record outside the cascade still refuses.

The fix

cascadeDeleteRelations now runs in two phases. Both phases read relations and rows one way: cascadeRelationBehavior decides which relations point at an object, and probeReferencingRows reads the referencing rows (system identity, the same filter, the same missing-table and multi-value handling).

  1. collectCascadeDeleteSet is read-only. It walks breadth-first across every relation whose resolved behaviour is cascade, starting from the root, and collects every record the delete will remove. The root is in the set. A visited check stops it on a cycle.
  2. walkReferencingRelations is the existing walk, in the existing order. Three things change:
    • restrict and set_null consider only rows outside the set. That covers an authored restrict and both halves of the required set_null escalation.
    • set_null writes nothing to a row in the set.
    • The walk never cascades into a row it has already entered.

The set is carried by an AsyncLocalStorage on the engine (cascadeDeleteSets), the same kind of carrier as txStore. A nested delete() for a member joins the set. Any other delete, such as one a hook makes, is the root of its own cascade, as before. The set is not stored on the request context, for two reasons: a caller that passed no context still passes none, and no request input can add a record to the set.

Base vs head, measured

Rig: a real ObjectQL over a real SqlDriver (better-sqlite3), called through ObjectStackProtocolImplementation.deleteData, the method DELETE /api/v1/data/:object/:id serves. Base is e87070ed49. Head is 4e096ce935, whose engine.ts is byte-identical to the final head's. The objectql dist/ was rebuilt for each leg. ablation-dist-preflight proves which build each leg read: the marker cascadeDeleteSets is absent from base's dist/ and present in head's.

Case Base Head
hotcrm shape, contacts registered first 409 DELETE_RESTRICTED, dependentObject zz_contract, count 2. Nothing deleted. Succeeds. The account, 2 contacts and 3 contracts are all gone.
hotcrm shape, contracts registered first Succeeds, all gone Succeeds, all gone
Control: an outside required lookup (zz_invoice to a contact), contacts first 409 naming zz_contract. The defect hid the real refusal. 409 DELETE_RESTRICTED, dependentObject zz_invoice, count 1. Rolled back; all 6 rows remain.
Control: same, contracts first 409 naming zz_invoice, count 1. Rolled back. Same
set_null inside the set (zz_memo to a contact), contacts first Succeeds, but issues 1 UPDATE on the memo before deleting it Succeeds, 0 UPDATEs
set_null inside the set, memos first Succeeds, 0 UPDATEs Succeeds, 0 UPDATEs
Cycle: a self-referencing cascade whose two rows point at each other The walk re-enters without end; the test's runaway guard stopped it at 61 beforeDelete dispatches. Nothing deleted. Succeeds. Both rows deleted, 2 beforeDelete
Cycle: two objects that cascade into each other Same runaway Succeeds. Both rows deleted, 2 beforeDelete
A row that two cascade paths reach (an item tree under an account) 404 RECORD_NOT_FOUND on the second path Succeeds. Each row deleted once.

The REST door was measured once on the real HTTP stack (bootStack, admin, DELETE /api/v1/data/zzr_account/:id):

  • Base, contacts first: 409, body "code":"DELETE_RESTRICTED", naming zzr_contract.
  • Base, contracts first: 200.
  • Head, both orders: 200, and every row is gone.

Atomicity and side effects

  • Atomicity. A cascade on one datasource is one transaction (planCascadeAtomicity answers 'atomic'), and it stays one: the set is collected inside that transaction. In the outside-restrict control, contracts first, head deletes 4 rows before the refusal, and the rollback restores every one of them.
    • The cross-datasource path ('split') is unchanged and stays non-atomic, as warnCascadeNotAtomic declares. On that path, a later refusal can also leave behind a member whose restrict was skipped. The code comment states this.
  • Hooks. Every deleted record fires beforeDelete and afterDelete exactly once, in both orders (pinned).
  • Elevation records (删除记录时「引用清理」用操作人身份查询引用表,读权不足即整体 403(应以系统身份执行) #12166). Each deleted record files one record per referenced object, never two (pinned). The dedupe set is shared by both phases.
  • set_null on a member. Base issued the extra UPDATE only with the contacts registered first: update hooks fired and an audit update row was written, then the record was deleted. Head never issues it. Both orders now leave the trail that base left when the memos were registered first.

Cost

Phase 1 adds one extra probe per cascading relation per member. restrict and set_null relations are not read in phase 1.

Shape Base Head
Wide cascade: 1 account, 20 contacts, 3 contracts each (81 records) 22 probe reads (contracts first; contacts first refused after 3) 24 probe reads, both orders
hotcrm shape (6 records) 4 probe reads 6 probe reads

In phase 1, the relation scan runs once per object, not once per record.

Pins and reverse verification (at faa4ac206a)

packages/objectql/src/engine-cascade-delete-sibling-restrict.test.ts has 21 cases, all green at head.

Run Red Green Red cases
Base engine.ts (hash-proven swap and restore) 12 9 —
Ablation: the member filter disabled 9 12 hotcrm (contacts first); hooks; elevation; the three outside controls (contacts first); set_null member; authored restrict between members; root membership
Ablation: the entered-row skip deleted 3 18 both cycles; the two-path tree

Both ablations went through scripts/ablation-replace.mjs in wrap mode. Each restore is proven by blob hash: bd05e02c8654 after restore equals the HEAD blob.

Clause-② evidence

The built declarations were compared, base vs head:

  • index.d.ts and core.d.ts are identical apart from the shared chunk's file hash.
  • The shared chunk gains six private member names and their doc comments: cascadeDeleteSets, collectCascadeDeleteSet, cascadeRelationBehavior, fileReferenceCheckElevation, probeReferencingRows, walkReferencingRelations.
  • No export, public or protected member, or type changes.

Also in this PR

  • packages/objectql/src/federated-injected-column-readers.test.ts: this ledger names the readers of injected columns by function. Its two cascadeDeleteRelations rows now name cascadeRelationBehavior, where those calls moved.
  • content/docs/api/data-api.mdx: the DELETE /data/:object/:id section said relations honour their deleteBehavior "with one substitution". That sentence would be false after this change, so the section now states the cascade-set rule. This file is outside the dispatched file surface.
  • .changeset/22305-cascade-sibling-restrict.md: a patch for @objectstack/objectql. Changesets pre mode is on (.changeset/pre.json).

Tests

All runs used the shared verify lock.

  • @objectstack/objectql at faa4ac206a:
    • full suite (pnpm --filter @objectstack/objectql test): 388 files, 7628 tests passed;
    • typecheck: green, including check:test-typecheck (the new test file adds no debt).
  • At 4e096ce935. The two later commits touch only objectql test files, which none of these suites reads.
    • @objectstack/rest: 263 files, 4951 passed, 326 skipped.
    • @objectstack/runtime: 340 files, 4776 passed, 19 skipped.
    • Dogfood shard 1/3: 76 files, 563 passed.
    • Dogfood shard 2/3: 76 files, 540 passed, 1 skipped.
    • Dogfood shard 3/3: 75 files passed and 1 skipped; 669 tests passed, 8 skipped.
  • Gates: node scripts/pm/dispatch-gates.mjs --commands was derived at faa4ac206a (97 commands). All 97 were run on that head and each exited 0, after the dists that check:skill-examples and check:dual-build-cjs-loads read were built. --ran reconciles 97 derived, 97 run, 0 NOT-MEASURED, 0 UNRUN.
  • Lint, narrowed to the three touched TypeScript files at faa4ac206a: eslint --no-inline-config --format json reports 3 files, 0 errors and 0 warnings. The narrowing is a measurement, not a skip: eslint --print-config resolves a config (5 rules) for each file, so they are in the linted population. The config enables no type-aware linting (parserOptions.project is null), so this diff cannot change the result for any untouched file. The repo-wide pnpm lint is left to CI.

Acceptance notes

  • content/docs/data-modeling/field-types.mdx says cascade and restrict are "the values honored as written". That sentence describes how a field's behaviour resolves, and it is still true per field. The cascade-set rule is a delete-time rule and is documented in data-api.mdx (above). No other doc page states a refusal this change removes.
  • ObjectQL.MAX_CASCADE_DEPTH (10) is not threaded through the recursion: delete() calls cascadeDeleteRelations with the default depth 0, so the bound never fires. That was true before this PR, and it is why a data cycle recursed without end. The entered-row skip now ends a cycle within one cascade. The dead bound is left as it was.

Generated by Claude Code

claude added 7 commits October 8, 2026 20:42
…ecting the set; document the rule on the DELETE door

Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2
Co-authored-by: Claude <noreply@anthropic.com>
…g the cascade's two phases share

Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling labels Oct 9, 2026
@github-actions

github-actions Bot commented Oct 9, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/objectql, touching 12 documentable anchor(s).

6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/data-api.mdx (via set_null (literal, a string literal in a comment in cascadeRelationBehavior; a string literal in cascadeRelationBehavior))
  • content/docs/data-modeling/field-types.mdx (via set_null (literal, a string literal in a comment in cascadeRelationBehavior; a string literal in cascadeRelationBehavior))
  • content/docs/data-modeling/fields.mdx (via set_null (literal, a string literal in a comment in cascadeRelationBehavior; a string literal in cascadeRelationBehavior))
  • content/docs/data-modeling/validation-rules.mdx (via set_null (literal, a string literal in a comment in cascadeRelationBehavior; a string literal in cascadeRelationBehavior))
  • content/docs/deployment/troubleshooting.mdx (via set_null (literal, a string literal in a comment in cascadeRelationBehavior; a string literal in cascadeRelationBehavior))
  • content/docs/protocol/objectql/types.mdx (via set_null (literal, a string literal in a comment in cascadeRelationBehavior; a string literal in cascadeRelationBehavior))

⛔ 2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v15.mdx (via set_null (literal, a string literal in a comment in cascadeRelationBehavior; a string literal in cascadeRelationBehavior))
  • content/docs/releases/v17/17-1.mdx (via cascadeDeleteRelations (symbol, a method of class ObjectQL), set_null (literal, a string literal in a comment in cascadeRelationBehavior; a string literal in cascadeRelationBehavior))

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

What this run could not see
  • 2 anchor(s) matched too much of the corpus to be a work list: ObjectQL (symbol, 72 pages), master_detail (literal, 32 pages)
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 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; 97 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 — 17 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 16096e8d7be20a584bc1ada8a437617f4ea7e997 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 7347cb9633e8163f71719af24341194afdb3e120 — the merge of head faa4ac206aac62e12cb5259b9d7dc4c95d31d386 into base 16096e8d7be20a584bc1ada8a437617f4ea7e997, 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 7347cb9633e8163f71719af24341194afdb3e120 && git checkout 7347cb9633e8163f71719af24341194afdb3e120
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 16096e8d7be20a584bc1ada8a437617f4ea7e997 faa4ac206aac62e12cb5259b9d7dc4c95d31d386 && git checkout -B drift-repro 16096e8d7be20a584bc1ada8a437617f4ea7e997 && git merge --no-ff faa4ac206aac62e12cb5259b9d7dc4c95d31d386

node scripts/docs-audit/affected-docs.mjs --json 16096e8d7be20a584bc1ada8a437617f4ea7e997

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 16096e8d7be20a584bc1ada8a437617f4ea7e997 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: faa4ac206aac62e12cb5259b9d7dc4c95d31d386
Local-runs: none

Inputs: card #22305 (body; triage 6062588548; claim 6068498126; os-dev-report 6071613831); packages/spec/src/data/field.zod.ts and content/docs/api/data-api.mdx on main (16096e8d7b); siblings #22300 / #22306 / #15953 for scope; PR #22377 (body, file list, net diff origin/main...faa4ac206a: 5 files, +947 / -166); the head's check-runs. Read-only throughout: gh api, git show and git diff against remote refs.

① Derived judgments

The refusal removed, against the cited text: Clause-②: no holds.

  • The claim cites field.zod.ts deleteBehavior ("What happens if referenced record is deleted", values set_null | cascade | restrict) and the master_detail refusal message: "a detail row cannot outlive its master … the engine resolves every value except 'restrict' to 'cascade' — the children this declaration asks to keep would be DELETED … declare 'cascade' (or omit the key) to accept the cascade deliberately". Read plainly, that text promises that deleting hotcrm's account deletes its contacts AND its contracts: both are details of the account.
  • The refusal base raised was raised BY a contract (a detail the text says this delete removes) ON BEHALF OF its required lookup to a contact (another detail the text says this delete removes). Its premise, that the contract would be left holding a required reference to a deleted contact, is the one the cited text denies: the detail does not outlive its master. And it fired as a function of registration order, which no published text names; base already accepted the identical delete with the contracts registered first. That is a mis-refusal the published text already denies (execution-duties.md line 106), not a ruling guard, a security refusal or a fail-closed close (line 107): the cascadeDeleteRelations refuses a required multi-value lookup delete even when member removal would leave the set non-empty #9688 / FieldSchema accepts deleteBehavior: 'set_null' on a master_detail, and the engine silently resolves it to cascade #9689 required-set_null escalation is kept verbatim for every row outside the set, which is exactly the rows whose reference could dangle.
  • Every delete head newly accepts, checked for cover by that text: (1) the required-lookup escalation between two members (hotcrm's shape): covered as above. (2) An authored restrict between two members (zz_quote to a contact, both details of the account): covered. The quote is a detail the text says is deleted, base accepted this with the quotes registered first, and field-types.mdx's "cascade and restrict are the values honored as written" describes per-field value resolution, not a walk order the page never published. (3) The root in its own set (zz_ledger, a detail holding a required lookup to the master it details): covered, and base's answer depended on field declaration order. (4) A data cycle (a self-cascade; two objects cascading into each other): base never answered (unbounded re-entry), and the cascade text says each row is deleted. (5) A row two cascade paths reach: base answered 404 RECORD_NOT_FOUND on a detail the text says this delete removes. No newly accepted shape falls outside the cited text, so the PR stays in this lane.
  • content/docs/api/data-api.mdx, DELETE section: the diff ADDS one paragraph; the "with one substitution" sentence is kept, not rewritten. The paragraph states the delete-time scope of rules the page already published, names no new value, key, code or status, and its one count statement (dependentCount counts outside rows only) matches the page's existing per-row count rule. A correction to match the cascade promise, not a widening. Wording note only: "with one substitution" now sits beside a second qualification.

The rule as built (engine.ts, net diff).

  • collectCascadeDeleteSet is a read-only BFS from the root (the root is added first) over relations whose cascadeRelationBehavior is cascade: master_detail resolved per the contract (everything but restrict reads cascade), lookup honouring its authored value, through the same probeReferencingRows (system identity, same filter, same missing-table and multi-value narrowing) that phase 2 reads. Phase 2's escalation only ever turns set_null into restrict and never demotes cascade, so the set is computed over exactly the relations phase 2 cascades through. The engine's find applies no default limit, so both phases issue the same unbounded probe.
  • A row in the set the walk never deletes: on the 'atomic' path, none. The set is collected inside runByIdDelete, inside the transaction() that delete() opens, and a later refusal rolls back every member (pinned against a driver with real snapshot rollback). On the 'split' path, possible: a later outside refusal can leave a member deleted and its sibling holding a dangling required reference. The dev declares this in the code comment and the PR body; judged in ③.
  • Scope: cascadeDeleteSets is an AsyncLocalStorage on the engine, never a context key. A nested delete() for a member joins the set (cascadeSetHas(inherited.members, …)); any other delete, a hook's included, collects its own set under a nested run, and the outer store is restored on return. Concurrent deletes each see their own store. Right.
  • entered is written at the top of walkReferencingRelations, before any nested delete() can re-enter, and the cascade branch skips an entered row. That ends a cycle (the ancestor finishes its own delete after its walk returns) and makes a two-path row delete once. Right.
  • The bounded in-place fixes: both are the defect class the card names (the walk not knowing its own set), one if on the entered index, same file, no other open claim on it (objectql: insert shows beforeInsert hooks the caller's readonly keys, then strips them — the insert-side twin of #16344 #22306 waits). Base answered neither shape; a hang and a 404 are not behaviour a caller relies on. Inside scope. MAX_CASCADE_DEPTH stays dead (depth is never incremented): noted, as the dev noted it.
  • Side effect: the set_null UPDATE base issued against a member in one registration order is gone in both. Right under the contract: set_null says what happens to a referencing row that survives the delete, and this row does not; the end state is identical, and base already produced it with the memos registered first. No untouched pin relies on that write: engine-cascade-delete-atomic.test.ts (link), runtime/src/sandbox/referential-field-clear-signal.integration.test.ts (probe_rfc_note) and plugin-security/src/delete-reference-cleanup-system-identity.test.ts each expect an update on a set_null row OUTSIDE the cascade set, and head still issues those.
  • Outside restricts: restrict and set_null filter members out AFTER probedIds is captured, so disclosure is still judged over the whole relation while dependentCount counts outside rows only; code DELETE_RESTRICTED, status 409, dependentObject named; rollback on the atomic path. Pinned in both registration orders, escalated and authored. One transaction per single datasource is unchanged; every deleted record fires beforeDelete and afterDelete once (pinned, both orders).
  • Cost: phase 1 adds one probe per cascading relation per member and scans each object's relations once. Bounded by members × cascading relations, at most doubling the cascading-relation reads base made; measured 4 to 6 and 22 to 24. Proportionate.
  • Elevation ledger (删除记录时「引用清理」用操作人身份查询引用表,读权不足即整体 403(应以系统身份执行) #12166): the dedupe key [object, id, childName] is shared by both phases, so one record per deleted record per referenced object, as before (pinned).

Pins and reverse verification. engine-cascade-delete-sibling-restrict.test.ts: 21 cases, counted from the file (2 orders × 3, 2 orders × 3 controls, 6 member-relation cases, 3 cycle and two-path cases). They test the subject: hotcrm's shape in both orders, hooks once, elevation once, outside required / authored / set_null controls in both orders with rollback, set_null and authored restrict between members, the root in its own set with its outside control, two cycles under a runaway guard, the two-path tree with an exact delete count. None is vacuous: the driver has real snapshot rollback, the runaway guard turns a hang into a named fail, and the nine base-green legs are the symmetric controls that pin the unchanged order. The reported 12 / 9 / 3 red partitions match the file's structure. federated-injected-column-readers.test.ts: the ledger keys a reader by its enclosing method declaration (containerName); the two calls moved into cascadeRelationBehavior and the rows follow them, so the ledger's subject is kept. The dev's base-vs-head rig measured 4e096ce935: that commit's engine.ts blob is bd05e02c86, the head's, and the two later commits touch only the two test files, so the claim holds.

② Semver level

.changeset/22305-cascade-sibling-restrict.md: '@objectstack/objectql': patch. Right: a bug fix in a released package (17.7.0) that adds no key, export, error code or type; the net diff adds no export, and the built declarations gain six private members only, per the dev's base-vs-head d.ts comparison. Pre mode is on (.changeset/pre.json: mode pre, tag next, on main too); a patch changeset there is what the next -next prerelease takes, and the changeset says nothing about pre mode that could be false; the PR body's "Changesets pre mode is on" is true. Every sentence checked against the diff is true: the order-dependent 409, the set collected before any refusal with the root included, authored and escalated restrict covered, no set_null write on a member, outside refusals unchanged with code and dependent object, rollback on one datasource, the count over outside rows, cycle and two-path termination, one extra read per cascading relation per member. Clause-②: no in the PR body (line 2, at line start) and in the changeset agree with each other and with the diff.

③ Boundary flags

  • Dev deviation (a), content/docs/api/data-api.mdx outside the dispatched surface: answered. Forced by the change and judged in ① as a correction. It is a published docs page and the dev's cross_lane_paths is empty, so the seat's declaration should name it.
  • Dev deviation (b), the ledger rename: answered, subject kept (①).
  • Dev deviation, the bounded in-place fix (cycles, two-path): answered, same class, mechanical, in scope (①).
  • Dev deviation M5, the dropped member set_null write: answered, right under the contract, no pin relies on it (①).
  • The split-path residual the dev declared (code comment; PR "Atomicity"): on a cross-datasource cascade a later outside refusal can now leave a member deleted and a sibling holding a dangling required reference, a shape base's restrict prevented in every order. It sits inside the partial outcome warnCascadeNotAtomic already declares ("the rows already removed stay removed while the call rejects") and that path is otherwise unchanged, so it does not fail this head. Escalated as a note for the seat to carry to the card: no sibling card owns the split path.
  • open_questions: none filed. Out-of-scope findings: MAX_CASCADE_DEPTH dead, noted and not filed, agreed; the lock-holding observation is process only.
  • CI on this head, read once at 2026-10-09T00:29Z (the head still faa4ac206a): nothing red. Required contexts green: TypeScript Type Check, Dogfood Regression Gate (and its 3 shards), Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard; Test Core shards 2 to 6 green. Still running at the read: Test Core (1/6) and Lint & Repo Gates. A red on either is this PR's own: the diff is the only change on the head, and this PASS does not carry over a red. Also green: Check Changeset, Check Documentation Links, Flag docs affected by code changes, Spec property liveness, the four Type Check jobs and the claim guards; Console Pin Gate and Packed-tarball smoke skipped by design.

Implemented-by: claude/issue-22305-cascade-sibling-restrict
Reviewed-by: session_01EUBvqtauTDmHi2ZgY759p2

VERDICT: PASS

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/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

objectql: cascade delete trips a restrict on a record the same cascade was about to delete (registry-order, depth-first walk)

2 participants