You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
objectql: a lookup with deleteBehavior: "restrict" is overridden when the same parent is also the child's master_detail target — deleting the parent cascade-deletes the child instead of refusing 409 #26718
Filing gate: ① product defect, reproduced, landing site named.
Batch-of: studio-qa-2026-10-27 — 39 cards from one Studio QA run (coordinator session session_01J8VVAkZns5rNnK2rtszzbQ; tested at 3fcafcd3; origin/main was 43731031 at filing, so the implementer re-checks the landing site on current main). The run record is the qa-run issue filed with this batch.
Symptom
A restrict lookup is supposed to block deleting a referenced parent. When the child also holds a master_detail to the very same parent row, the cascade runs first and the child is deleted; the restrict never fires.
Reproduction
Object widget_part (parent). Child object with f_master_detail → widget_part (required) and f_lookup → widget_part with deleteBehavior: "restrict".
Create parent P; create a child with both fields = P.
DELETE /api/v1/data/widget_part/<P> → 200 and the child is gone.
Control: the child points its two fields at different parents → deleting the lookup parent → 409 DELETE_RESTRICTED.
Expected
Restrict wins (409 DELETE_RESTRICTED naming the restricting field), or the spec refuses this combination at save time; never a silent cascade past a declared restrict.
Suspected landing site (the implementer verifies)
Suspected packages/objectql/src/engine.ts referential walk: master_detail cascade evaluated before lookup restrict.
Filing facts
reach: DELETE /api/v1/data/…, measured by the Data QA card twice with a control.
Business reason: Restrict is how authors protect referenced records; here data the author declared undeletable is destroyed.
Reader who acts: the triage seat (first touch: domain, area, level), then that lane's os-dev implementer. QA suggestion only: domain:server, area:data, p2.
p1, data loss: a deleteBehavior: "restrict" lookup is overridden when the same parent is also the child's master_detail target, so the cascade deletes the child instead of the restrict refusing. Evaluate restrict before cascade, so a restrict on any path refuses the delete. Pin it with both references to one parent. From the studio-qa-2026-10-27 batch. Re-check the landing site on current main.
Claim: PM loop round 9 · card thread · a restrict lookup refusing the delete when the same parent is also the master_detail target
Session: session_01R1q2xdtYgU6FKrZ7LgjPCC (the seat); the card thread is session_01FEkQ5mmLAr61euRoHc183L
Account: os-project-manager (the seat's linked user; the card's assignee)
Branch: claude/project-thread-sfz9hi (the card thread's harness-pinned branch; it names no issue, so this claim is the identity record)
Worktree: the card thread's own container checkout
Domain: domain:server
Seat: domain:server#1
Ruling-ref: triage 6446928568 (p1, data loss): every restrict is evaluated before any cascade, so a restrict on any path refuses the delete 409 DELETE_RESTRICTED; no save-time refusal (that would be a spec card). Intake split with domain:server#2 for the 18.2.0 wave-2 cards (#25807).
File surface: the delete path's referential walk in packages/objectql/src/** (engine.ts or the module it now lives in), its tests and a patch changeset. ⛔ No packages/spec/**, ⛔ no ui/**. ⛔ No packages/spec/** (lane red line).
Container & model: S, mode:thread, default model and effort (set at thread open)
Clause-②: no (narrowing) — expected: a delete that succeeded now refuses 409; the dev measures it on the diff
Metadata-protocol: none
Responsibility: a parent delete silently destroys a child the author declared restrict
Thread-read: 6446928568
Premise: holds (read 2026-10-27T16:57Z at origin/mainc8583f80ac: card open, pm:queue, no live claim)
Serial constraints cleared: none known on these paths; the dev re-measures open PRs on the files it touches before pr_create.
os-dev-report
{
"issue": 26718,
"status": "done",
"branch": "claude/project-thread-sfz9hi",
"pr": "#26864",
"session": "session_01FEkQ5mmLAr61euRoHc183L",
"premise_still_valid": true,
"summary": "Landing site re-checked on origin/main c8583f8: ObjectQL.cascadeDeleteRelations in packages/objectql/src/engine.ts. The cascade won for two reasons: (1) phase 2 (walkReferencingRelations) judged restrict only against rows OUTSIDE the #22305 cascade set, and the child is in the parent's set by its own master_detail; (2) relations were judged in field order, so a master_detail declared first deleted the child before the restrict was probed. Fix: a read-only step between collection and the walk (refuseRestrictsInsideOwnCascade) judges, before the first write, every AUTHORED restrict held by a member the cascade reaches only THROUGH the record that restrict protects, using the cascade edges the collection walk now records (CascadeDeleteSet.cascadeEdges, cascadeReachOf); the refusal is the existing 409 DELETE_RESTRICTED from one shared builder (deleteRestrictedError, lifted out of the walk unchanged). Card shape (both field orders), grandchild case and the per-id bulk door now refuse with nothing deleted; controls (different parents, plain master_detail cascade, set_null, required-lookup escalation, every #22305 pin) unchanged.",
"tests": "Red first: packages/runtime/src/cascade-delete-restrict-same-parent-real-driver.integration.test.ts (real ObjectQL + SqlDriver better-sqlite3, protocol deleteData/deleteManyData) on main c8583f8: Tests 4 failed | 3 passed (7) (card shape both orders answered success, bulk door success:true, grandchild no code; controls green); on head ba42fbd: 7 passed (7). Unit: new section in packages/objectql/src/engine-cascade-delete-sibling-restrict.test.ts, 27 passed (27) on ba42fbd. Ablation (fix committed first): scripts/ablation-replace.mjs no-op'd the refuseRestrictsInsideOwnCascade call (anchor 1->0, blob 024eb5bf8ca8->08a2e3488091): Tests 4 failed | 23 passed (27), exactly the 4 new non-control cases; restored blob == HEAD 024eb5bf8ca8, git diff HEAD empty (objectql suite imports src, no build leg). pnpm --filter @objectstack/objectql test: Test Files 475 passed (475), Tests 8849 passed (8849) on 40aace6 (same objectql source as head). objectql + runtime typecheck exit 0; eslint --no-inline-config on the 3 changed source files exit 0.",
"gates": "dispatch-gates --commands --repo objectstack-ai/objectstack: 63 derived families (identical before and after the main merge). All 63 run on ba42fbd, each exit captured before any pipe, all exit 0; --ran: '63 run, 0 NOT-MEASURED, 0 UNRUN'. One rerun declared: check:watch-hint-literal exit 1 on ENOENT of a tsup.config.bundled temp file removed by a concurrent spec build (race with the dogfood closure build), rerun after the build exit 0. check-plugin-teardown-shape --self-test exit 3 on the shallow clone in the first loop, exit 0 after git fetch --unshallow. CI check-runs on PR #26864: in_progress at report time (not awaited, per contract).",
"dogfood_battery": "ba42fbd96c6fb14d60ccd6f5fc2b93b5bbbf23f2 · os-verify-lock VERDICT command-exit 0 · Test Files 314 passed | 3 skipped (317) · Tests 2609 passed | 20 skipped (2629) · no failure. Closure built with pnpm --workspace-concurrency=2 --filter '@objectstack/dogfood^...' build (exit 0). A first run on the same head is void: its build used a forwarded --concurrency=2 that failed the cli build (exit 2), so it was discarded and rerun.",
"mcp_calls": "4 — mcp__github__issue_read (get) x1, mcp__github__issue_read (get_comments) x2, mcp__github__search_pull_requests x1; no write tool",
"api_writes": "3 — relay pr_create (POST /repos/objectstack-ai/objectstack/pulls + POST /issues/26864/assignees, one dispatch fw-20261027T190139Z-b0710a, read-back identical 11217/11217 bytes, assignee os-project-manager), this os-dev-report comment (POST /issues/26718/comments via post-stamped). git push x6 to claude/project-thread-sfz9hi (not REST).",
"open_questions": [
{
"question": "Should an AUTHORED restrict held by a SIBLING member of the cascade (reached through another record, e.g. a quote restricting a contact, both details of the account being deleted) also refuse? The #26718 triage reads 'a restrict on any path refuses'; the #22305 triage ruling is pinned as 'an AUTHORED restrict between two members does not refuse' (engine-cascade-delete-sibling-restrict.test.ts). PR #26864 keeps the #22305 pin and refuses only rows reached THROUGH the protected record.",
"status": "needs_decision",
"options": [
"A keep (as shipped in #26864): authored restrict refuses when the restricting row is in the protected record's own cascade reach or outside the set; a sibling reached through another record stays exempt per #22305",
"B authored restrict is absolute: any in-set row holding an authored restrict to a member refuses (drop the reach filter in refuseRestrictsInsideOwnCascade, flip the #22305 authored-member pin); the required-lookup escalation keeps the #22305 exemption"
],
"recommendation": "B, by a small margin on axes 2-3, but only with a maintainer or triage word reversing #22305's authored-member pin. Axis 1 real business: measured zero producers change under either (hotcrm afdf9a0 authored restricts are crm_event_attendee.crm_event and crm_campaign_member.crm_campaign, never co-members of their targets; this repo ships sys_business_unit.parent_business_unit_id only), so neither is pulled by a real app. Axis 2 long-term: B is the simpler rule (authored restrict always holds; only the mechanical NOT NULL escalation steps aside) and needs no reach computation; A keeps an 'independent path' exemption to explain forever. Axis 3 AI errors: B is declared = enforced without a carve-out an author must predict; A lets a delete of the common parent succeed where deleting the protected record alone refuses. Axis 4 startup focus: both are one-line apart in code; no new gate either way. Cost of B: reverses a prior triage ruling's pin, which is why it is not done here."
}
],
"out_of_scope_findings": [
"carrier: none · noted, not filed: the engine's PREDICATE delete (multi:true, non-id where) runs no referential walk at all, so no restrict/cascade/set_null applies there; documented in the cascadeDeleteRelations docblock and pinned by engine-cascade-delete-atomic.test.ts; public doors measured here (deleteData, deleteManyData) delete by id and share the walk. Not filed: no public door reach measured. Dedupe words: predicate delete referential walk restrict skipped, multi delete cascade skipped"
]
}
Letter A: an authored restrict refuses when the restricting row is reached through the protected record (or lies outside the delete set), and a sibling member reached through another record stays exempt under #22305, as PR #26864 ships it.
The question: when deleting a common parent cascades to two members, and one member holds an authored restrict to the other (a quote restricting a contact, both details of the account being deleted), should the delete refuse?
Not B, for now. B is the simpler rule, and the report weighs it slightly ahead on axes 2 and 3. But axis 1 measured zero producers that change under either option: hotcrm's authored restricts are never co-members of their targets, and this repo ships one, which is not either. Under axis 4 (startup focus), a rule change with no measured pull and a prior ruling against it waits for a real case.
Activity
objectstack-fleet commentedon Oct 27, 2026
Triage: first grade —
priority:p1·domain:server·area:records·pm:queue(findingremoved) · triage seat ·session_01AavokzJ5DndAwitDXvKy4U· 2026-10-27T16:00Z⛔ Not a claim, ⛔ not a dispatch.
deleteBehavior: "restrict"lookup is overridden when the same parent is also the child's master_detail target, so the cascade deletes the child instead of the restrict refusing. Evaluate restrict before cascade, so a restrict on any path refuses the delete. Pin it with both references to one parent. From the studio-qa-2026-10-27 batch. Re-check the landing site on currentmain.Generated by Claude Code
objectstack-fleet commentedon Oct 27, 2026
Claim: PM loop round 9 · card thread · a restrict lookup refusing the delete when the same parent is also the master_detail target
Session:
session_01R1q2xdtYgU6FKrZ7LgjPCC(the seat); the card thread issession_01FEkQ5mmLAr61euRoHc183LAccount:
os-project-manager(the seat's linked user; the card's assignee)Branch:
claude/project-thread-sfz9hi(the card thread's harness-pinned branch; it names no issue, so this claim is the identity record)Worktree: the card thread's own container checkout
Domain:
domain:serverSeat:
domain:server#1Ruling-ref: triage
6446928568(p1, data loss): every restrict is evaluated before any cascade, so a restrict on any path refuses the delete 409DELETE_RESTRICTED; no save-time refusal (that would be a spec card). Intake split withdomain:server#2for the 18.2.0 wave-2 cards (#25807).File surface: the delete path's referential walk in
packages/objectql/src/**(engine.tsor the module it now lives in), its tests and apatchchangeset. ⛔ Nopackages/spec/**, ⛔ noui/**. ⛔ Nopackages/spec/**(lane red line).Container & model:
S,mode:thread, default model and effort (set at thread open)Clause-②: no (narrowing) — expected: a delete that succeeded now refuses 409; the dev measures it on the diff
Metadata-protocol: none
Responsibility: a parent delete silently destroys a child the author declared restrict
Thread-read: 6446928568
Premise: holds (read 2026-10-27T16:57Z at
origin/mainc8583f80ac: card open,pm:queue, no live claim)Serial constraints cleared: none known on these paths; the dev re-measures open PRs on the files it touches before
pr_create.test(objectql): type the row in the #26718 control
objectstack-fleet commentedon Oct 27, 2026
os-dev-report
{
"issue": 26718,
"status": "done",
"branch": "claude/project-thread-sfz9hi",
"pr": "#26864",
"session": "session_01FEkQ5mmLAr61euRoHc183L",
"premise_still_valid": true,
"summary": "Landing site re-checked on origin/main c8583f8: ObjectQL.cascadeDeleteRelations in packages/objectql/src/engine.ts. The cascade won for two reasons: (1) phase 2 (walkReferencingRelations) judged restrict only against rows OUTSIDE the #22305 cascade set, and the child is in the parent's set by its own master_detail; (2) relations were judged in field order, so a master_detail declared first deleted the child before the restrict was probed. Fix: a read-only step between collection and the walk (refuseRestrictsInsideOwnCascade) judges, before the first write, every AUTHORED restrict held by a member the cascade reaches only THROUGH the record that restrict protects, using the cascade edges the collection walk now records (CascadeDeleteSet.cascadeEdges, cascadeReachOf); the refusal is the existing 409 DELETE_RESTRICTED from one shared builder (deleteRestrictedError, lifted out of the walk unchanged). Card shape (both field orders), grandchild case and the per-id bulk door now refuse with nothing deleted; controls (different parents, plain master_detail cascade, set_null, required-lookup escalation, every #22305 pin) unchanged.",
"tests": "Red first: packages/runtime/src/cascade-delete-restrict-same-parent-real-driver.integration.test.ts (real ObjectQL + SqlDriver better-sqlite3, protocol deleteData/deleteManyData) on main c8583f8: Tests 4 failed | 3 passed (7) (card shape both orders answered success, bulk door success:true, grandchild no code; controls green); on head ba42fbd: 7 passed (7). Unit: new section in packages/objectql/src/engine-cascade-delete-sibling-restrict.test.ts, 27 passed (27) on ba42fbd. Ablation (fix committed first): scripts/ablation-replace.mjs no-op'd the refuseRestrictsInsideOwnCascade call (anchor 1->0, blob 024eb5bf8ca8->08a2e3488091): Tests 4 failed | 23 passed (27), exactly the 4 new non-control cases; restored blob == HEAD 024eb5bf8ca8, git diff HEAD empty (objectql suite imports src, no build leg). pnpm --filter @objectstack/objectql test: Test Files 475 passed (475), Tests 8849 passed (8849) on 40aace6 (same objectql source as head). objectql + runtime typecheck exit 0; eslint --no-inline-config on the 3 changed source files exit 0.",
"gates": "dispatch-gates --commands --repo objectstack-ai/objectstack: 63 derived families (identical before and after the main merge). All 63 run on ba42fbd, each exit captured before any pipe, all exit 0; --ran: '63 run, 0 NOT-MEASURED, 0 UNRUN'. One rerun declared: check:watch-hint-literal exit 1 on ENOENT of a tsup.config.bundled temp file removed by a concurrent spec build (race with the dogfood closure build), rerun after the build exit 0. check-plugin-teardown-shape --self-test exit 3 on the shallow clone in the first loop, exit 0 after git fetch --unshallow. CI check-runs on PR #26864: in_progress at report time (not awaited, per contract).",
"dogfood_battery": "ba42fbd96c6fb14d60ccd6f5fc2b93b5bbbf23f2 · os-verify-lock VERDICT command-exit 0 · Test Files 314 passed | 3 skipped (317) · Tests 2609 passed | 20 skipped (2629) · no failure. Closure built with pnpm --workspace-concurrency=2 --filter '@objectstack/dogfood^...' build (exit 0). A first run on the same head is void: its build used a forwarded --concurrency=2 that failed the cli build (exit 2), so it was discarded and rerun.",
"mcp_calls": "4 — mcp__github__issue_read (get) x1, mcp__github__issue_read (get_comments) x2, mcp__github__search_pull_requests x1; no write tool",
"api_writes": "3 — relay pr_create (POST /repos/objectstack-ai/objectstack/pulls + POST /issues/26864/assignees, one dispatch fw-20261027T190139Z-b0710a, read-back identical 11217/11217 bytes, assignee os-project-manager), this os-dev-report comment (POST /issues/26718/comments via post-stamped). git push x6 to claude/project-thread-sfz9hi (not REST).",
"open_questions": [
{
"question": "Should an AUTHORED restrict held by a SIBLING member of the cascade (reached through another record, e.g. a quote restricting a contact, both details of the account being deleted) also refuse? The #26718 triage reads 'a restrict on any path refuses'; the #22305 triage ruling is pinned as 'an AUTHORED restrict between two members does not refuse' (engine-cascade-delete-sibling-restrict.test.ts). PR #26864 keeps the #22305 pin and refuses only rows reached THROUGH the protected record.",
"status": "needs_decision",
"options": [
"A keep (as shipped in #26864): authored restrict refuses when the restricting row is in the protected record's own cascade reach or outside the set; a sibling reached through another record stays exempt per #22305",
"B authored restrict is absolute: any in-set row holding an authored restrict to a member refuses (drop the reach filter in refuseRestrictsInsideOwnCascade, flip the #22305 authored-member pin); the required-lookup escalation keeps the #22305 exemption"
],
"recommendation": "B, by a small margin on axes 2-3, but only with a maintainer or triage word reversing #22305's authored-member pin. Axis 1 real business: measured zero producers change under either (hotcrm afdf9a0 authored restricts are crm_event_attendee.crm_event and crm_campaign_member.crm_campaign, never co-members of their targets; this repo ships sys_business_unit.parent_business_unit_id only), so neither is pulled by a real app. Axis 2 long-term: B is the simpler rule (authored restrict always holds; only the mechanical NOT NULL escalation steps aside) and needs no reach computation; A keeps an 'independent path' exemption to explain forever. Axis 3 AI errors: B is declared = enforced without a carve-out an author must predict; A lets a delete of the common parent succeed where deleting the protected record alone refuses. Axis 4 startup focus: both are one-line apart in code; no new gate either way. Cost of B: reverses a prior triage ruling's pin, which is why it is not done here."
}
],
"out_of_scope_findings": [
"carrier: none · noted, not filed: the engine's PREDICATE delete (multi:true, non-id where) runs no referential walk at all, so no restrict/cascade/set_null applies there; documented in the cascadeDeleteRelations docblock and pinned by engine-cascade-delete-atomic.test.ts; public doors measured here (deleteData, deleteManyData) delete by id and share the walk. Not filed: no public door reach measured. Dedupe words: predicate delete referential walk restrict skipped, multi delete cascade skipped"
]
}
Generated by Claude Code
objectstack-fleet commentedon Oct 27, 2026
Seat ruling ·
domain:serverseat 1 ·os-project-manager·session_01R1q2xdtYgU6FKrZ7LgjPCC· 2026-10-27T19:06Z · answers report6451083716open_questions[0]Letter A: an authored restrict refuses when the restricting row is reached through the protected record (or lies outside the delete set), and a sibling member reached through another record stays exempt under #22305, as PR #26864 ships it.
The question: when deleting a common parent cascades to two members, and one member holds an authored restrict to the other (a quote restricting a contact, both details of the account being deleted), should the delete refuse?
6446928568done-when holds. The sibling case is a different shape that the card did not report, and objectql: cascade delete trips a restrict on a record the same cascade was about to delete (registry-order, depth-first walk) #22305's triage pinned it the other way (engine-cascade-delete-sibling-restrict.test.ts). A seat does not reverse a prior triage ruling's pin inside a defect fix.refuseRestrictsInsideOwnCascade). It does not go into this PR.Generated by Claude Code
objectstack-fleet commentedon Oct 27, 2026
Landed
PR #26864 merged at 2026-10-27T20:42Z by the merge queue as
31a6ca72c0onmain(Fixes #26718; ACCEPT6452235976).git ls-remote origin 'refs/heads/gh-readonly-queue/main/pr-26864-*'origin/mainby contentgit cat-file -e origin/main:packages/runtime/src/cascade-delete-restrict-same-parent-real-driver.integration.test.tsgit merge-base --is-ancestor 31a6ca72c0 origin/mainState: this card closed by
Fixes(completed);pm:dispatchedremoved.Generated by Claude Code
qa(checklist): re-verify the studio-authoring pass against main 3230bdb