Repository navigation
fix(objectql)!: a restrict reference refuses the delete of the record it protects even when the referencing row also cascades from it (#26718) - #26864
Conversation
…des from (red on main) Claude-Session: https://claude.ai/code/session_01FEkQ5mmLAr61euRoHc183L Co-authored-by: Claude <noreply@anthropic.com>
… first write Claude-Session: https://claude.ai/code/session_01FEkQ5mmLAr61euRoHc183L Co-authored-by: Claude <noreply@anthropic.com>
…ascade Claude-Session: https://claude.ai/code/session_01FEkQ5mmLAr61euRoHc183L Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEkQ5mmLAr61euRoHc183L Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 7 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 19 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 9d47f74605fca0a0ef7076ed529b2605b7a35107 && git checkout 9d47f74605fca0a0ef7076ed529b2605b7a35107
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e9e344a9dd3ac55c5cfdc3ff29eaaa5c0ad30b00 ba42fbd96c6fb14d60ccd6f5fc2b93b5bbbf23f2 && git checkout -B drift-repro e9e344a9dd3ac55c5cfdc3ff29eaaa5c0ad30b00 && git merge --no-ff ba42fbd96c6fb14d60ccd6f5fc2b93b5bbbf23f2
node scripts/docs-audit/affected-docs.mjs --json e9e344a9dd3ac55c5cfdc3ff29eaaa5c0ad30b00
|
Contract reviewServed-tier: Read at 2026-10-27T19:10Z: card #26718 (body, triage ① Derived judgmentsAccept-set change the diff implies (one, narrowing): a by-id delete of record R now refuses Public-surface changes: none. Mechanism, read in the cited code. Seat question 1 (letter A, #22305 pin). Confirmed. Seat question 2 (every delete door). Every door that deletes by id reaches
Predicate delete. Seat question 3 (atomicity). Yes. The step is read-only and runs before Seat question 4 (controls). Yes, exactly as on main. Seat question 5 (dogfood battery). Met. The report's Check-runs on the head (gate verdicts), read at 2026-10-27T19:10Z: 42 runs; 22 ② Semver levelChangeset Clause-②: no (narrowing) — matches what the diff publishes: one by-id delete that succeeded now refuses 409; nothing widened. ③ Boundary flags
Implemented-by: VERDICT: PASS |
席位判读 · flake ·
|
席位核对 · ACCEPT ·
|
| item | reading |
|---|---|
| Shape | Draft, base main, Fixes #26718 under triage 6446928568 (p1). 4 files: engine.ts (a read-only restrict step in the by-id delete, before any write), a 105-line append to the #22305 pin file engine-cascade-delete-sibling-restrict.test.ts (the 487 lines on main byte-identical), a runtime real-driver integration test, and a patch changeset on @objectstack/objectql with a BREAKING block. Clause-②: no (narrowing) and Metadata-protocol: none appear in both the body and the changeset. |
| Fix | Deleting a parent now refuses 409 DELETE_RESTRICTED, naming the restricting field, when a row the delete reaches through that parent (the card's child in either field order, or a grandchild) holds an authored restrict to it. The refusal runs before the first write, inside the by-id transaction, so nothing is deleted. Every by-id door shares the walk: engine, deleteData/deleteManyData, REST, dispatcher, import undo, the action facade, and flow delete_record without multi. |
| Narrowing | A parent delete that cascade-deleted the child on main is refused at head. The controls are unchanged: set_null, a plain master_detail cascade, and the two-different-parents refusal. Under ruling A, a sibling member's authored restrict stays exempt per #22305; the pin is unchanged. |
| Dogfood battery | Pinned to ba42fbd96c (this head), full, no shard: 314 files and 2609 tests passed, declared skips only. review-checklist.md :50 is met. |
| Evidence | CI green on head ba42fbd96c (34 success, 11 skipped, 0 failed) after one re-run of Test Core (2/8) under the seat's flake judgment 6451851503 (attempt 2 green). Governed check: NOT governed, 667 changed lines. Red first on c8583f80 (4 red, 3 controls green, real SqlDriver through deleteData/deleteManyData), and the ablation turns the 4 red. 63 of 63 derived gates. |
| Carrier | The predicate delete (multi: true with a filter) runs no referential walk. The HTTP data doors cannot reach it, but an authored flow delete_record with multi: true can; it predates this PR and is unchanged here. It is filed as #26871 for triage. |
| Landing | 「按照 pm-dispatch 规则复核过的都授权」 (Leehom, 2026-10-25T11:35Z): ready and auto-merge through the relay, once every check on this head is green or an expected skip. |
Generated by Claude Code
Requested by Leehom · project thread
Fixes #26718
Clause-②: no (narrowing)
Metadata-protocol: none
Skills-docs: none — the docs and skills already say a
restrictrefuses the parent delete while a referencing row exists (content/docs/data-modeling/field-types.mdx,skills/objectstack-data/rules/relationships.md); this makes the runtime honour that on one more path, and nothing taught changes.维护者速读
deleteBehavior: 'restrict'引用同一父记录(或级联更深一层的孙记录这样引用),现在先判定 restrict,返回 409DELETE_RESTRICTED,什么都不删。packages/objectql/src/engine.ts的refuseRestrictsInsideOwnCascade(新增的写前判定)与collectCascadeDeleteSet里记录级联边的几行;packages/runtime/src/cascade-delete-restrict-same-parent-real-driver.integration.test.ts。pnpm --filter @objectstack/runtime exec vitest run --project local src/cascade-delete-restrict-same-parent-real-driver.integration.test.ts(先 build objectql),预期 7 条全绿;在 main 上同一文件 4 红。What changes
ObjectQL.cascadeDeleteRelations(the by-id delete's referential walk,packages/objectql/src/engine.ts) gains a read-only step between its two phases. After it collects the cascade set and before its first write,refuseRestrictsInsideOwnCascadejudges every AUTHOREDrestrict(the resolved behaviour ofcascadeRelationBehavior: alookup,treeormaster_detailthat declaresdeleteBehavior: 'restrict') held by a member that the cascade reaches only THROUGH the record that restrict protects. Such a row refuses the delete with the walk's existing409 DELETE_RESTRICTEDenvelope, built by one helper (deleteRestrictedError, lifted unchanged out of the walk), so the two refusals cannot drift."Reaches through" is read from the cascade edges the collection walk already probes, now recorded on the set (
CascadeDeleteSet.cascadeEdges,cascadeReachOf). No new probe kind and no new read of the registry: the step probes a restrict relation only when its object has a member in the reach of the protected record.Why — where the ordering lives and why the cascade won
Re-checked on
origin/mainc8583f80. The card's suspected site holds: the walk iscascadeDeleteRelations→collectCascadeDeleteSet(phase 1) →walkReferencingRelations(phase 2) inpackages/objectql/src/engine.ts. Two things let the cascade win:restrictonly against rows OUTSIDE the cascade set (dependents.filter(… !cascadeSetHas(cascadeSet.members, …))). The child is in the parent's set by its own master_detail, so its restrict lookup was never asked, in either field order.Judging the restricts before the first write removes both. The #22305 rule itself stays for a row the cascade reaches through ANOTHER record (two details of one account, one restricting the other): the account's delete removes both.
Tests
packages/runtime/src/cascade-delete-restrict-same-parent-real-driver.integration.test.ts(new): realObjectQLover a realSqlDriver(better-sqlite3 on disk), through the protocol'sdeleteData(the methodDELETE /api/v1/data/:object/:idserves) anddeleteManyData(the bulk door). The card's shape in both field orders, the bulk door, a grandchild holding the restrict; controls: the two fields at different parents, a plain master_detail, the parent deleting once the child is gone. Row counts are read out of the driver.packages/objectql/src/engine-cascade-delete-sibling-restrict.test.ts(new section, the objectql: cascade delete trips a restrict on a record the same cascade was about to delete (registry-order, depth-first walk) #22305 file and its rollback-capable driver): the same shapes plus a grandchild restricting the member above it (refused from the root and directly alike), each asserting no write reached the driver; two controls. Every pre-existing objectql: cascade delete trips a restrict on a record the same cascade was about to delete (registry-order, depth-first walk) #22305 pin in the file is unchanged and green.Acceptance notes
engine-cascade-delete-sibling-restrict.test.ts("an AUTHORED restrict between two members does not refuse"), exempts an authored restrict held by a SIBLING member: deleting an account removes a quote and the contact the quote restricts, both details of the account. This PR keeps that pin and refuses only the rows reached through the record the restrict protects, which covers the card's shape, the grandchild case and the reverse field order. Whether the sibling case should refuse too is a reversal of the objectql: cascade delete trips a restrict on a record the same cascade was about to delete (registry-order, depth-first walk) #22305 pin and is left to the seat; it is a one-line change in the new step (drop the reach filter for authored restricts). Measured producers: hotcrm (afdf9a0) declares authored restricts only oncrm_event_attendee.crm_eventandcrm_campaign_member.crm_campaign, whose targets are never cascade members together with those rows, and this repo ships one,sys_business_unit.parent_business_unit_id; none of them meets either rule's changed case.deleteManyDatadeletes by id, row by row, throughengine.delete, and refuses the same row (pinned). The engine's PREDICATE delete (multi: truewith a non-idwhere) runs no referential walk at all, by its documented contract (thecascadeDeleteRelationsdocblock, andengine-cascade-delete-atomic.test.ts"opens no transaction when … the delete is by predicate"); it is unchanged here and not reached by this card.set_null, refused asrestrictbecause it cannot be cleared, still steps aside for a row the cascade deletes (objectql: cascade delete trips a restrict on a record the same cascade was about to delete (registry-order, depth-first walk) #22305 "a member holding a required lookup to the ROOT does not refuse the root", green).packages/speccard). Noui/**, nopackages/spec/**.engine.tsin a different region.Verification
All readings on head
ba42fbd96c(branch merged withorigin/main0ea90c7a18) unless named otherwise.Red first on main, green after (real driver)
pnpm --filter @objectstack/runtime exec vitest run --project local src/cascade-delete-restrict-same-parent-real-driver.integration.test.tsc8583f80(main, test file only):Tests 4 failed | 3 passed (7). The card shape answered success in both field orders (expected { object: 'zz_widget_part', … } to be an instance of Error), the bulk door answeredsuccess: true, and the grandchild case answered no code. The three controls were green.ba42fbd96c:Tests 7 passed (7).Ablation (fix committed first, restored by blob hash)
node scripts/ablation-replace.mjsreplaced therefuseRestrictsInsideOwnCascadecall incascadeDeleteRelationswith a no-op (anchor 1 → 0, blob024eb5bf8ca8→08a2e3488091) and ranengine-cascade-delete-sibling-restrict.test.ts:Tests 4 failed | 23 passed (27). The four that went red are the new non-control cases; both new controls and every #22305 pin stayed green. Restored: blob after restore = blob at HEAD =024eb5bf8ca8,git diff HEADempty. The objectql suite imports./engine.jsfrom source, so no build was involved.Package suites, typecheck, lint
pnpm --filter @objectstack/objectql test:Test Files 475 passed (475),Tests 8849 passed (8849)(on40aace604d, the same objectql source as the head).engine-cascade-delete-sibling-restrict.test.ts27/27 and the new runtime file 7/7 again onba42fbd96c.pnpm --filter @objectstack/objectql typecheck && pnpm --filter @objectstack/runtime typecheck: exit 0 (both includecheck:test-typecheck).pnpm exec eslint --no-inline-configover the three changed source files: exit 0.Derived gates
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderives 63 families on this diff (unchanged after the merge). All 63 ran onba42fbd96c, every one exit 0;--ranreconciles63 run, 0 NOT-MEASURED, 0 UNRUN. One read needed a rerun:check:watch-hint-literalfirst exited 1 onENOENT … packages/spec/tsup.config.bundled_….mjs, a temp file a concurrent spec build removed mid-scan; rerun after the build, exit 0.check-plugin-teardown-shape.mjs --self-testneeds the unshallowed clone (exit 3 on the shallow one, exit 0 aftergit fetch --unshallow).Dogfood battery (narrowing arm)
On
ba42fbd96c, afterpnpm --workspace-concurrency=2 --filter '@objectstack/dogfood^...' build(exit 0),pnpm --filter @objectstack/dogfood testunder the verify lock:VERDICT command-exit 0,Test Files 314 passed | 3 skipped (317),Tests 2609 passed | 20 skipped (2629), no failure.Human Minimum
packages/specholds no runtime logic. (Nopackages/specchange.)pnpm lintpasses. (eslint over the changed files; repo-wide lint is CI's.)main, one pull request.packages/spectouched: its generated artifacts are regenerated and committed. (Not touched.)content/docs/references/,packages/*/CHANGELOG.mdorcontent/docs/releases/.Generated by Claude Code