Repository navigation
objectql: cascade delete trips a restrict on a record the same cascade was about to delete (registry-order, depth-first walk) #22305
Description
Activity
objectstack-fleet commented
on Oct 8, 2026 ContributorAuthorMore actionsTriage: first grade,
bug·priority:p2·domain:engine·area:records·pm:queue. Direction: collect the cascade set before evaluating restrictsTriage seat (objectstack-wide, seat post #6015) ·
session_01AavokzJ5DndAwitDXvKy4U· 2026-10-08T14:53Z. ⛔ Not a claim, ⛔ not a dispatch.Triage: lands in
packages/objectql/src/engine.ts(the cascade delete walk) ⇒domain:engine; rationale:packages/objectqlis that lane's.- Why p2: an admin cannot delete a record whose cascade includes a sibling that restricts another member of the same cascade. hotcrm's account delete is measured. It is a refusal, not a wrong write.
- 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.
- addedarea:recordsBusiness objects, records, the views that show data, usable forms, searchBusiness objects, records, the views that show data, usable forms, searchbugSomething isn't workingSomething isn't workingpriority:p2Medium: important, M3Medium: important, M3and removed
on Oct 8, 2026 objectstack-fleet commented
on Oct 8, 2026 ContributorAuthorMore actionsClaim: PM loop round 65 · 2026-10-08T20:30Z
Session:session_01EUBvqtauTDmHi2ZgY759p2
Account:os-litant(the seat's linked user, asGET /useranswers it; always the card's assignee)
Branch:claude/issue-22305-cascade-sibling-restrict
Worktree:objectstack-issue-22305
Domain:domain:engine
Seat:domain:engine#1
Provenance:- Filed by the
repo:hotcrmseat from Replace the hand-built hook / flow / action harnesses with@objectstack/verify's in-process handle; delete the stand-ins and the suites that only prove the stand-ins; declare the platform packages tests import (epic #1579, step 5) hotcrm#1595's dev, split out of objectql: a formula field in afieldsprojection widens it to every column and the rows are never trimmed back — get_record sends owner, org and audit columns to an external endpoint #22300 on the maintainer's word. - Triage graded it p2
bugarea:records(6062588548). - objectql: a formula field in a
fieldsprojection widens it to every column and the rows are never trimmed back — get_record sends owner, org and audit columns to an external endpoint #22300 (the same axis and file) landed ate87070ed49. This card is next onarea:records; objectql: insert shows beforeInsert hooks the caller's readonly keys, then strips them — the insert-side twin of #16344 #22306 waits for it. - Batch 3: [decision] cold boot admits a package whose permission set or position name the environment catalog already holds (package registration runs before sys_metadata hydration), while a hot install of the same package is refused #22307's dev and feat(metadata-core,metadata-protocol,objectql,plugin-security): the
sys_metadatafamily goes tenant-less; the per-organization overlay axis retires; managed content is sealed (ADR-0131 D6/D7/D13) #15206 S1's dev hold the other slots.
File surface (atorigin/maine87070ed49+, per triage 6062588548): - Measure first:
- Reproduce the hotcrm shape on a real engine over the SQL driver. A parent cascades to two children, A and B; B carries a required lookup to A, whose default
set_nullescalates to restrict. Deleting the parent answersDELETE_RESTRICTEDnaming B, because the depth-first, registration-ordered walk deletes A first. - Then once through the REST door (
DELETE /api/v1/data/:object/:id). - Name the walk's functions on current
main(cascadeDeleteRelationsand the restrict escalation).
- Reproduce the hotcrm shape on a real engine over the SQL driver. A parent cascades to two children, A and B; B carries a required lookup to A, whose default
- The fix:
packages/objectql/src/engine.ts, the cascade delete walk. A record that is itself in the cascade set never restricts a sibling in the same set: collect the cascade set before evaluating restricts. Triage's direction. - Pins:
- the account-with-contracts shape deletes, and every member of the cascade set is gone;
- controls: a restrict on a record OUTSIDE the cascade still refuses with
DELETE_RESTRICTED(envelopecode+status);set_nullon an outside record still clears; the delete stays atomic where it is today; - reverse-verify.
.changeset/22305-*.md(@objectstack/objectqlpatch).- ⛔ Not the insert hook order (objectql: insert shows beforeInsert hooks the caller's readonly keys, then strips them — the insert-side twin of #16344 #22306). No hotcrm workaround.
Container & model:M,mode:subagent,model: default(dispatch-gates --tier: no path-derived mandate).
Clause-②: no - The card removes a refusal that the published contract text already denies (
execution-duties.md: such anocites that text):packages/spec/src/data/field.zod.ts,deleteBehavior: "What happens if referenced record is deleted", withcascadeamong its values.- The
master_detailrule's published refusal message: "the engine resolves every value except 'restrict' to 'cascade' … declare 'cascade' (or omit the key) to accept the cascade deliberately". - So deleting the master deletes its details. A refusal raised by a detail that the same cascade deletes contradicts that text.
- No key, export or code is added. The dev measures the built entry declarations, and a public-surface change found there revises this line.
Responsibility: the cascade walk evaluates a sibling's restrict before the cascade set is known | a registration-ordered, depth-first recursion | an adminDELETEof a hotcrm account whose contracts and contacts both cascade (measured on 17.7.0)
Thread-read: 6062588548
Serial constraints cleared: at 2026-10-08T20:30Z, no open PR touchespackages/objectql/src/engine.ts(file lists read byfilename). This lane's feat(metadata-core,metadata-protocol,objectql,plugin-security): thesys_metadatafamily goes tenant-less; the per-organization overlay axis retires; managed content is sealed (ADR-0131 D6/D7/D13) #15206 S1 touchesmetadata*/platform-objects/spec, and [decision] cold boot admits a package whose permission set or position name the environment catalog already holds (package registration runs before sys_metadata hydration), while a hot install of the same package is refused #22307 touchesobjectql'splugin.ts/registry.ts: different files. objectql: insert shows beforeInsert hooks the caller's readonly keys, then strips them — the insert-side twin of #16344 #22306 (same file, same axis) stays queued until this lands.
- Filed by the
objectstack-fleet commented
on Oct 9, 2026 ContributorAuthorMore actionsos-dev-report
{ "issue": 22305, "status": "done", "branch": "claude/issue-22305-cascade-sibling-restrict", "pr": "https://github.com/objectstack-ai/objectstack/pull/22377", "session": "session_01EUBvqtauTDmHi2ZgY759p2 (the dispatching PM's session, shared; this run's identity is the branch)", "premise_still_valid": true, "summary": "Measured first. M1 held on current main e87070ed49: with a real ObjectQL over better-sqlite3 and the hotcrm shape (account; contact master_detail; contract master_detail plus a required lookup to contact), registering contacts first gives 409 DELETE_RESTRICTED naming zz_contract with nothing deleted, and registering contracts first succeeds. The admin REST door gave the same split (409 vs 200), so the refusal depends on registration order. Fixed in packages/objectql/src/engine.ts by the ruling's route, M2: cascadeDeleteRelations is now (1) collectCascadeDeleteSet, a read-only BFS over cascading relations with the root included and a visited set, and (2) walkReferencingRelations, the old walk, where restrict (authored, or either half of the required set_null escalation) and set_null judge only rows outside the set and a row already entered is not cascaded into again. Both phases share cascadeRelationBehavior and probeReferencingRows. The set rides an AsyncLocalStorage, never the context. M3: outside restricts still refuse with code+status, name the dependent, count outside rows only, and roll back. M4: a single datasource stays one transaction and every deleted record fires one before/afterDelete. M5: the moot set_null UPDATE base issued in one registration order is no longer issued, so both orders match base's other order. Cost: one extra probe per cascading relation per member (hotcrm 4 to 6 reads; 81-record cascade 22 to 24). Declared bounded in-place fix, all four conditions held: the entered-row skip also ends two defects of the same class, the walk not knowing its own set. A data cycle recursed without end on base (runaway guard tripped at 61 dispatches), and a row reached by two paths answered 404 on base; head deletes both. Card assignee was not written (it was os-litant at dispatch). The PR assignee was set to os-litant through label-write. No labels were written, since the dispatch named none and a changeset is present.", "tests": "At faa4ac206a, under the verify lock unless noted: pnpm --filter @objectstack/objectql test, 388 files, 7628 passed; objectql typecheck green, including check:test-typecheck with no new debt; the new pin file src/engine-cascade-delete-sibling-restrict.test.ts, 21 passed. At 4e096ce935 (the two later commits touch only objectql test files, which these suites do not read): rest 263 files, 4951 passed and 326 skipped; runtime 340 files, 4776 passed and 19 skipped; dogfood shard 1/3 76 files, 563 passed; shard 2/3 76 files, 540 passed and 1 skipped; shard 3/3 75 files passed and 1 skipped, 669 passed and 8 skipped. Gates: dispatch-gates --commands at faa4ac206a derived 97 commands; all 97 ran and each exited 0 (check:skill-examples and check:dual-build-cjs-loads after building the dists they read; a first run at 969c4073b3 exited 3 on those two for missing dists, NOT MEASURED there). --ran reconciles 97 derived, 97 run, 0 NOT-MEASURED, 0 UNRUN. check:objectql-double-limit flagged the new pin file's limit-blind find double at 969c4073b3; fixed in faa4ac206a and green. Lint, narrowed to the 3 touched TS files: eslint --no-inline-config --format json reports 3 files, 0 errors, 0 warnings. Evidence for the narrowing: (1) eslint --print-config resolves a config (5 rules) for each file, so they are in the linted population; (2) the JSON count is 3; (3) the config enables no type-aware linting (parserOptions.project is null), so this diff cannot change an untouched file's result. The repo-wide pnpm lint is left to CI. Reverse verification at faa4ac206a, src-resolved pins, run from the committed state: the base engine.ts, swapped in with git show and proven by blob hash 540d18f10b then restored to HEAD blob bd05e02c86 with git diff HEAD empty, gives 12 red and 9 green. Ablation (scripts/ablation-replace.mjs, wrap mode, anchor 1 to 0, restore proven by blob) of the member filter gives 9 red and 12 green; ablation deleting the entered-row skip gives 3 red (both cycles and the two-path tree) and 18 green. Base vs head on the real SQL driver: objectql dist was rebuilt per leg, and ablation-dist-preflight shows the marker cascadeDeleteSets absent in the base dist and present in the head dist. The results are in the PR table. Clause-2 measurement: built d.ts base vs head shows index.d.ts and core.d.ts identical apart from the chunk hash; the chunk gains only six private member names and their doc comments. No export or public member changed, so Clause-2 stays no.", "mcp_calls": "0", "api_writes": "3 writes, all through scripts/pm relay tools (fleet-write repository_dispatch, executed as objectstack-fleet[bot]): (1) pr_create, POST /repos/objectstack-ai/objectstack/pulls, opened #22377 as a draft; body read back byte-identical, 10387 bytes. (2) label-write assign, POST /repos/objectstack-ai/objectstack/issues/22377/assignees with os-litant; read back matched. (3) this os-dev-report comment, POST /repos/objectstack-ai/objectstack/issues/22305/comments, via post-stamped. Also 7 git pushes, which are not REST writes.", "open_questions": [], "out_of_scope_findings": [ "class: none (dead code) · ObjectQL.MAX_CASCADE_DEPTH is never reached: delete() calls cascadeDeleteRelations with the default depth 0 on every level, so the depth bound is dead. Before this PR, that is why a data cycle recursed without end; the entered-row skip in this PR now ends cycles within one cascade · carrier: PR #22377 Acceptance notes · noted, not filed · dedupe words: MAX_CASCADE_DEPTH, cascade depth bound, cascadeDeleteRelations depth", "class: none (lock-holding observation, not a product finding) · the #15206 S1 dev's run-dogfood.sh held the shared verify lock while it looped on sleep 10 for up to 15 minutes, waiting for its own unlocked gate battery before its ablation; other queued runs timed out (exit 99) during that wait · carrier: the PM, process only · noted, not filed" ], "deviations": [ "File surface widened by two paths, each forced by this change: (a) content/docs/api/data-api.mdx: the DELETE section said relations honour their deleteBehavior 'with one substitution', which this change makes false, so one paragraph states the cascade-set rule (os-dev: a published statement this round makes false must be fixed). (b) packages/objectql/src/federated-injected-column-readers.test.ts: a source-scanning ledger keyed by function name; its two cascadeDeleteRelations rows now name cascadeRelationBehavior, where those calls moved (the full objectql suite was red on it until renamed).", "Bounded in-place fix declared in the PR body, with base/head rows: the entered-row skip also fixes the unbounded recursion on a data cycle and the 404 on a row reached by two cascade paths. All four conditions hold: same defect class (the walk not knowing its own set); mechanical; same file with no other claim (#22306 waits); same gate family.", "M5 decision: the set_null write against a member is skipped in both orders. Base issued it only with contacts registered first, so update hooks fired and an audit update row was written before the delete. Head matches base's other order (memos first), so the trail no longer depends on registration order.", "cross_lane_paths: none. No REST or runtime pin was added; the REST door was measured once with a scratch dogfood bootStack test that was not committed." ], "cross_lane_paths": [], "pr_body_full": { "body": "Fixes #22305\nClause-②: no\n\n## What was wrong\n\nA 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.\n\nThe 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.\n\n## The rule (triage ruling 6062588548, verbatim)\n\n> - **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.\n\n## The fix\n\n`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).\n\n1. `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.\n2. `walkReferencingRelations` is the existing walk, in the existing order. Three things change:\n - `restrict` and `set_null` consider only rows outside the set. That covers an authored `restrict` and both halves of the required `set_null` escalation.\n - `set_null` writes nothing to a row in the set.\n - The walk never cascades into a row it has already entered.\n\nThe 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.\n\n## Base vs head, measured\n\nRig: 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.\n\n| Case | Base | Head |\n|---|---|---|\n| 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. |\n| hotcrm shape, contracts registered first | Succeeds, all gone | Succeeds, all gone |\n| 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. |\n| Control: same, contracts first | `409` naming `zz_invoice`, count 1. Rolled back. | Same |\n| `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 |\n| `set_null` inside the set, memos first | Succeeds, 0 UPDATEs | Succeeds, 0 UPDATEs |\n| 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` |\n| Cycle: two objects that cascade into each other | Same runaway | Succeeds. Both rows deleted, 2 `beforeDelete` |\n| 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. |\n\nThe REST door was measured once on the real HTTP stack (`bootStack`, admin, `DELETE /api/v1/data/zzr_account/:id`):\n\n- Base, contacts first: `409`, body `\"code\":\"DELETE_RESTRICTED\"`, naming `zzr_contract`.\n- Base, contracts first: `200`.\n- Head, both orders: `200`, and every row is gone.\n\n## Atomicity and side effects\n\n- **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.\n - 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.\n- **Hooks.** Every deleted record fires `beforeDelete` and `afterDelete` exactly once, in both orders (pinned).\n- **Elevation records (#12166).** Each deleted record files one record per referenced object, never two (pinned). The dedupe set is shared by both phases.\n- **`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.\n\n## Cost\n\nPhase 1 adds one extra probe per cascading relation per member. `restrict` and `set_null` relations are not read in phase 1.\n\n| Shape | Base | Head |\n|---|---|---|\n| 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 |\n| hotcrm shape (6 records) | 4 probe reads | 6 probe reads |\n\nIn phase 1, the relation scan runs once per object, not once per record.\n\n## Pins and reverse verification (at `faa4ac206a`)\n\n`packages/objectql/src/engine-cascade-delete-sibling-restrict.test.ts` has 21 cases, all green at head.\n\n| Run | Red | Green | Red cases |\n|---|---|---|---|\n| Base `engine.ts` (hash-proven swap and restore) | 12 | 9 | — |\n| 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 |\n| Ablation: the entered-row skip deleted | 3 | 18 | both cycles; the two-path tree |\n\nBoth ablations went through `scripts/ablation-replace.mjs` in wrap mode. Each restore is proven by blob hash: `bd05e02c8654` after restore equals the HEAD blob.\n\n## Clause-② evidence\n\nThe built declarations were compared, base vs head:\n\n- `index.d.ts` and `core.d.ts` are identical apart from the shared chunk's file hash.\n- The shared chunk gains six `private` member names and their doc comments: `cascadeDeleteSets`, `collectCascadeDeleteSet`, `cascadeRelationBehavior`, `fileReferenceCheckElevation`, `probeReferencingRows`, `walkReferencingRelations`.\n- No export, public or protected member, or type changes.\n\n## Also in this PR\n\n- `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.\n- `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.\n- `.changeset/22305-cascade-sibling-restrict.md`: a `patch` for `@objectstack/objectql`. Changesets pre mode is on (`.changeset/pre.json`).\n\n## Tests\n\nAll runs used the shared verify lock.\n\n- `@objectstack/objectql` at `faa4ac206a`:\n - full suite (`pnpm --filter @objectstack/objectql test`): 388 files, 7628 tests passed;\n - `typecheck`: green, including `check:test-typecheck` (the new test file adds no debt).\n- At `4e096ce935`. The two later commits touch only objectql test files, which none of these suites reads.\n - `@objectstack/rest`: 263 files, 4951 passed, 326 skipped.\n - `@objectstack/runtime`: 340 files, 4776 passed, 19 skipped.\n - Dogfood shard 1/3: 76 files, 563 passed.\n - Dogfood shard 2/3: 76 files, 540 passed, 1 skipped.\n - Dogfood shard 3/3: 75 files passed and 1 skipped; 669 tests passed, 8 skipped.\n- 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.\n- 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.\n\n## Acceptance notes\n\n- `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.\n- `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.\n\n---\n\n_Generated by [Claude Code](https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2)_\n" } }
Generated by Claude Code
objectstack-fleet commented
on Oct 9, 2026 ContributorAuthorMore actionsACCEPT (seat review): PR #22377 at head
faa4ac206adomain:engine#1·session_01EUBvqtauTDmHi2ZgY759p2(os-litant), claim 6068498126 · read at 2026-10-09T00:47Z.- Dev report:
os-dev-report6071613831. - Contract review at
CONTRACT_REVIEW_TIER: PASS 6071768557 on this head. The claim owed it: a removed refusal isClause-②: noonly on cited published text.
Shape
- Draft, base
main. Line 1Fixes #22305, line 2Clause-②: no, one closing keyword. - 5 files, +947 / −166.
The change (
objectql'sengine.ts, triage's direction 6062588548)- Phase 1,
collectCascadeDeleteSet. A read-only BFS from the root over the relations that cascade, withmaster_detailresolved per the contract. It runs inside the transactiondelete()opens, and the set rides anAsyncLocalStorage, never the context. - Phase 2, the walk. Restrict (authored, or either half of the required
set_nullescalation) andset_nulljudge only rows outside the set. A row already entered is not cascaded into again. - Outside restricts still refuse.
409 DELETE_RESTRICTED, the dependent named, outside rows counted, rolled back. Pinned in both registration orders. - Hooks. Every deleted record fires
beforeDeleteandafterDeleteonce.
Measured
- M1 on
maine87070ed49: the hotcrm shape refuses or succeeds depending on registration order, through both the engine and the admin REST door (409 vs 200). - Head: both orders delete every member.
- Reverse-verified:
- base
engine.ts: 12 red / 9 green; - member-filter ablation: 9 red;
- entered-row-skip ablation: 3 red.
Each was restored by blob.
- base
Clause-②: noholds (the review).- The cited
field.zod.tstext ("the engine resolves every value except 'restrict' to 'cascade'"; a detail cannot outlive its master) already promises that deleting the account deletes both children. The refusal contradicted that text, and it fired only as a function of registration order. - Every newly accepted shape has its blocker inside the cascade the text says is removed.
data-api.mdxadds a paragraph that states the delete-time scope of the published rules. It is a correction, not a widening.- The built
.d.tschanges only six private members.
Bounded in-place fixes (declared; the review judged them in scope). Both are the same defect class, the walk not knowing its own set:
- a data cycle recursed without end;
- a row reached by two cascade paths answered
404.
Head deletes both. Neither base outcome is behaviour a caller relies on.
Side effect, judged right. In one registration order, base issued a
set_nullUPDATE against a member about to be deleted. Head never does, so both orders now match base's other order. No untouched pin relies on that write.Changeset:
@objectstack/objectqlpatch. Every sentence is true against the diff, and none is about pre mode.CI on
faa4ac206a, by name- All
success:TypeScript Type Check,Test Core(6/6 and the rollup),Dogfood Regression Gate(3/3),Build Core,Temporal Conformance (live PG + MySQL),Lint & Repo Gates,Governed Surface Queue Guard. - 33 success, 2 skipped, 0 failure.
check-expected-skipsOK: both skips are in the roster. - NOT governed, 1113 changed lines.
git merge-treeagainstorigin/mainbb4f5cc005is clean.
Cross-lane:
content/docs/api/data-api.mdx(domain:devx), declared on #6023 (6071644656). No objection on record.Carried to this card (the review's escalation): the split-path residual
- On a cross-datasource cascade (
planCascadeAtomicityanswers'split'), a later outside refusal can now leave a member deleted while a sibling holds a dangling required reference to it. On that path, base's restrict prevented this shape in every order. - It sits inside the partial outcome
warnCascadeNotAtomicalready declares: "the rows already removed stay removed while the call rejects". That path is otherwise unchanged, and the code comment and PR body state it. - No card owns the split path's atomicity. Its reach is not measured on a real multi-datasource deployment, so this seat files nothing on it now. A measured case files it under gate ①.
Out of scope, one line each
ObjectQL.MAX_CASCADE_DEPTHis dead: depth stays 0 at every level. It is pre-existing, and the entered-row skip now ends cycles. Noted, not filed.- The S1 dev held the shared verify lock while it sleep-waited. That is a process note; the dispatch orders now forbid it.
Next
- Ready, then auto-merge after the ready-flip checks settle.
- On landing, objectql: insert shows beforeInsert hooks the caller's readonly keys, then strips them — the insert-side twin of #16344 #22306 (
area:records, the same file) becomes dispatchable.
Generated by Claude Code
- Dev report:
objectstack-fleet commented
on Oct 9, 2026 ContributorAuthorMore actionsLanding record: PR #22377 merged · 2026-10-09T01:26Z
domain:engine#1·session_01EUBvqtauTDmHi2ZgY759p2(os-litant), claim 6068498126.- Merged: PR fix(objectql): a record the same cascade deletes never refuses its sibling's delete #22377 through the merge queue at 2026-10-09T01:24Z. Squash
c8c803c293onorigin/main, single parent. - Content, read after a fetch:
collectCascadeDeleteSetinpackages/objectql/src/engine.tshas 3 hits, against 0 on the parent. The controlcascadeDeleteRelationshas 20 hits. The pin fileengine-cascade-delete-sibling-restrict.test.tsand.changeset/22305-cascade-sibling-restrict.mdare present. - Review: ACCEPT 6071954991; contract review PASS 6071768557.
- Labels: the card closed
completed, andpm:dispatchedis removed in this act. Only this card was named by a closing keyword. - For hotcrm (Replace the hand-built hook / flow / action harnesses with
@objectstack/verify's in-process handle; delete the stand-ins and the suites that only prove the stand-ins; declare the platform packages tests import (epic #1579, step 5) hotcrm#1595, which waits on this fix by its rule): an admin delete of an account whose contacts and contracts both cascade now deletes every member, whichever object registered first. A restrict from a record outside the cascade still refuses with409 DELETE_RESTRICTED. - Carried, not filed: the cross-datasource (
'split') residual named in the ACCEPT. Its reach is unmeasured. - Next on the axis: objectql: insert shows beforeInsert hooks the caller's readonly keys, then strips them — the insert-side twin of #16344 #22306 (insert hook order,
area:records, the same file) is dispatchable now.
Generated by Claude Code
- Merged: PR fix(objectql): a record the same cascade deletes never refuses its sibling's delete #22377 through the merge queue at 2026-10-09T01:24Z. Squash
- added a commit that references this issue
on Oct 9, 2026
Filing gate: ① product defect with reach measured. Class (a). reach: a public REST door, measured once with a wrong result.
Found through objectstack-ai/hotcrm on
@objectstack/*17.7.0 by the dev of objectstack-ai/hotcrm#1595 (PR objectstack-ai/hotcrm#2013), sessionsession_012zh91QzFgePbkmuHnugLN3. Split out of #22300 on the maintainer's word: one card per defect.Who acts on it: the objectstack triage seat routes it; the fix lands in
packages/objectql/src/engine.ts. ⛔ Not a claim. hotcrm WAITs for it (hotcrm AGENTS.md §2) and builds no workaround.Cascade delete trips a restrict on a record the same cascade was about to delete
DELETE /api/v1/data/crm_account/:idon a customer whose contracts are all draft / expired / terminated →DELETE_RESTRICTEDwithdependentObject: 'crm_contract': "This Contact is still referenced by 4 Contract record(s) through Primary Contact".crm_contract(master-detail since hotcrm#549) andcrm_contactboth cascade from the account. The contract'scrm_contactis a required lookup._registry.getAllObjects()in registration order and recurses depth-first (engine.ts:16282-16300,:16709-16712). The contacts go first.set_null) is escalated to restrict (:16461-16463) and throws (:16689). It throws on a contract the outer account cascade was going to delete next.Duplicate check
gh searchis refused in this container (GraphQL and REST search answer 403), so all 9,565 objectstack issues were listed (/issues?state=all) and matched case-insensitively:DELETE_RESTRICTED cascade order2 (#9688, #7413: different);cascade sibling restrict10;cascadeDeleteRelations25 (open: #15953, a sibling). None is this defect.Generated by Claude Code