Skip to content

fix(objectql)!: a restrict reference refuses the delete of the record it protects even when the referencing row also cascades from it (#26718) - #26864

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/project-thread-sfz9hi
Oct 27, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/project-thread-sfz9hi

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Leehom · project thread

Fixes #26718

Clause-②: no (narrowing)
Metadata-protocol: none
Skills-docs: none — the docs and skills already say a restrict refuses 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.

维护者速读

  • 改了什么:按 id 删除父记录时,若某条明细既通过 master_detail 归属该父记录、又用 deleteBehavior: 'restrict' 引用同一父记录(或级联更深一层的孙记录这样引用),现在先判定 restrict,返回 409 DELETE_RESTRICTED,什么都不删。
  • 为什么改:以前级联把这条明细算进“本次要删的集合”,集合内的行一律不触发 restrict,于是明细被静默级联删除,作者声明“不可删”的数据丢失(p1 数据丢失)。
  • 风险与回滚:只收窄这一种删除;集合外的 restrict、普通级联、set_null、必填 lookup 的升级 restrict 行为不变。回滚即撤销本 PR。objectql: cascade delete trips a restrict on a record the same cascade was about to delete (registry-order, depth-first walk) #22305 定下的“同一父记录下兄弟之间的 restrict 不拦”保持原样,是否也要拦见 Acceptance notes 的待决问题。
  • 先看: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, refuseRestrictsInsideOwnCascade judges every AUTHORED restrict (the resolved behaviour of cascadeRelationBehavior: a lookup, tree or master_detail that declares deleteBehavior: '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 existing 409 DELETE_RESTRICTED envelope, 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/main c8583f80. The card's suspected site holds: the walk is cascadeDeleteRelations → collectCascadeDeleteSet (phase 1) → walkReferencingRelations (phase 2) in packages/objectql/src/engine.ts. Two things let the cascade win:

  1. The objectql: cascade delete trips a restrict on a record the same cascade was about to delete (registry-order, depth-first walk) #22305 exemption. Phase 2 judged restrict only 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.
  2. Order inside the walk. Each relation was judged as the field loop reached it, so with the master_detail declared first the child was already deleted when the restrict relation was probed.

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

Acceptance notes

  • Open question for the seat (not decided here). The triage ruling reads "a restrict on any path refuses the delete". The objectql: cascade delete trips a restrict on a record the same cascade was about to delete (registry-order, depth-first walk) #22305 triage ruling, pinned in 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 on crm_event_attendee.crm_event and crm_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.
  • The bulk door shares the walk. deleteManyData deletes by id, row by row, through engine.delete, and refuses the same row (pinned). The engine's PREDICATE delete (multi: true with a non-id where) runs no referential walk at all, by its documented contract (the cascadeDeleteRelations docblock, and engine-cascade-delete-atomic.test.ts "opens no transaction when … the delete is by predicate"); it is unchanged here and not reached by this card.
  • Required-lookup escalation unchanged. A required lookup's set_null, refused as restrict because 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).
  • Cost. One extra probe per authored restrict relation per member, only when the restricting object has a member in that member's cascade reach; the walk already probed the same relation.
  • No save-time refusal of the combination was added (that would be a packages/spec card). No ui/**, no packages/spec/**.
  • Overlapping open PR measured before opening: refactor(objectql): move the validate body out of engine.ts into engine-validate.ts (#25522 cut 16) #26792 (engine split cut 16, the validate body) touches engine.ts in a different region.

Verification

All readings on head ba42fbd96c (branch merged with origin/main 0ea90c7a18) 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.ts

  • On c8583f80 (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 answered success: true, and the grandchild case answered no code. The three controls were green.
  • On ba42fbd96c: Tests 7 passed (7).
Ablation (fix committed first, restored by blob hash)

node scripts/ablation-replace.mjs replaced the refuseRestrictsInsideOwnCascade call in cascadeDeleteRelations with a no-op (anchor 1 → 0, blob 024eb5bf8ca8 → 08a2e3488091) and ran engine-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 HEAD empty. The objectql suite imports ./engine.js from source, so no build was involved.

Package suites, typecheck, lint
  • pnpm --filter @objectstack/objectql test: Test Files 475 passed (475), Tests 8849 passed (8849) (on 40aace604d, the same objectql source as the head).
  • engine-cascade-delete-sibling-restrict.test.ts 27/27 and the new runtime file 7/7 again on ba42fbd96c.
  • pnpm --filter @objectstack/objectql typecheck && pnpm --filter @objectstack/runtime typecheck: exit 0 (both include check:test-typecheck).
  • pnpm exec eslint --no-inline-config over the three changed source files: exit 0.
Derived gates

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derives 63 families on this diff (unchanged after the merge). All 63 ran on ba42fbd96c, every one exit 0; --ran reconciles 63 run, 0 NOT-MEASURED, 0 UNRUN. One read needed a rerun: check:watch-hint-literal first exited 1 on ENOENT … 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-test needs the unshallowed clone (exit 3 on the shallow one, exit 0 after git fetch --unshallow).

Dogfood battery (narrowing arm)

On ba42fbd96c, after pnpm --workspace-concurrency=2 --filter '@objectstack/dogfood^...' build (exit 0), pnpm --filter @objectstack/dogfood test under the verify lock: VERDICT command-exit 0, Test Files 314 passed | 3 skipped (317), Tests 2609 passed | 20 skipped (2629), no failure.

Human Minimum

  • 1. Schemas start as Zod; packages/spec holds no runtime logic. (No packages/spec change.)
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; pnpm lint passes. (eslint over the changed files; repo-wide lint is CI's.)
  • 4. One issue, one branch off an up-to-date main, one pull request.
  • 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset.
  • 6. packages/spec touched: its generated artifacts are regenerated and committed. (Not touched.)
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English.
  • The Before Submitting list in CONTRIBUTING.md is done.

Generated by Claude Code

@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 27, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/api/data-api.mdx (via DELETE_RESTRICTED (literal, a string literal in deleteRestrictedError))
  • content/docs/api/error-catalog.mdx (via DELETE_RESTRICTED (literal, a string literal in deleteRestrictedError))
  • content/docs/api/error-handling-server.mdx (via DELETE_RESTRICTED (literal, a string literal in deleteRestrictedError))
  • content/docs/api/index.mdx (via DELETE_RESTRICTED (literal, a string literal in deleteRestrictedError))
  • content/docs/kernel/contracts/data-engine.mdx (via DELETE_RESTRICTED (literal, a string literal in deleteRestrictedError))
  • content/docs/protocol/kernel/error-handling.mdx (via DELETE_RESTRICTED (literal, a string literal in deleteRestrictedError))
  • content/docs/protocol/objectql/types.mdx (via DELETE_RESTRICTED (literal, a string literal in deleteRestrictedError))

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

  • content/docs/releases/v17/17-1.mdx (via cascadeDeleteRelations (symbol, a method of class ObjectQL))
  • content/docs/releases/v17/17-6.mdx (via DELETE_RESTRICTED (literal, a string literal in deleteRestrictedError))

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
  • 1 anchor(s) matched too much of the corpus to be a work list: ObjectQL (symbol, 72 pages)
  • the SDK route bridge reached 55 of 207 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 — 19 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 e9e344a9dd3ac55c5cfdc3ff29eaaa5c0ad30b00 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 9d47f74605fca0a0ef7076ed529b2605b7a35107 — the merge of head ba42fbd96c6fb14d60ccd6f5fc2b93b5bbbf23f2 into base e9e344a9dd3ac55c5cfdc3ff29eaaa5c0ad30b00, 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 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

⚠️ 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 e9e344a9dd3ac55c5cfdc3ff29eaaa5c0ad30b00 → 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: ba42fbd96c6fb14d60ccd6f5fc2b93b5bbbf23f2
Local-runs: none
Scope: packages/objectql/src/engine.ts, packages/objectql/src/engine-cascade-delete-sibling-restrict.test.ts, packages/runtime/src/cascade-delete-restrict-same-parent-real-driver.integration.test.ts + .changeset/26718-restrict-over-own-cascade.md (prose face, read for ②), packages/metadata-protocol/src/protocol.ts (deleteData), packages/metadata-protocol/src/protocol-batch-data.ts (deleteManyData, runDeleteManyLoop, runAtomicBatch), packages/rest/src/crud-routes.ts, packages/rest/src/batch-routes.ts, packages/rest/src/data-action-routes.ts, packages/runtime/src/domains/data.ts, packages/runtime/src/action-execution.ts, packages/services/service-automation/src/builtin/crud-nodes.ts, packages/metadata-core/src/engine-delete-dispatch.ts, packages/objectql/src/engine-cascade-delete-atomic.test.ts, content/docs/data-modeling/field-types.mdx, skills/objectstack-data/rules/relationships.md

Read at 2026-10-27T19:10Z: card #26718 (body, triage 6446928568, claim 6448161362, os-dev-report 6451083716, seat ruling 6451117438), PR #26864 body and file list, git diff origin/main...ba42fbd96c (merge-base 0ea90c7a18, 4 files, +587/−80), the cited code at the head via git show, and the head's check-runs. Nothing built, run or re-run. Rendered by the seat's subagent.

① Derived judgments

Accept-set change the diff implies (one, narrowing): a by-id delete of record R now refuses 409 DELETE_RESTRICTED where it answered success, when a row the cascade would remove only because R goes holds an authored restrict (resolved behaviour of cascadeRelationBehavior: lookup/tree with deleteBehavior: 'restrict', or master_detail with deleteBehavior: 'restrict') pointing at R, directly or at a member in R's own cascade reach. Judged RIGHT: it is exactly the card's reproduction (child with master_detail + restrict lookup to one parent, both field orders) plus the grandchild case, and it is the triage done-when (6446928568: "a restrict on any path refuses"), read by ruling 6451117438 as "any path through the protected record".

Public-surface changes: none. CascadeDeleteSet.cascadeEdges, cascadeMemberKey, cascadeReachOf, refuseRestrictsInsideOwnCascade, deleteRestrictedError are module-private / private members of packages/objectql/src/engine.ts; no export, type, schema key or error code is added. The refusal envelope is the pre-existing DELETE_RESTRICTED (ADR-0112): the builder body lifted into deleteRestrictedError is byte-identical to main's inline block modulo throw err → return err (78 lines compared whitespace-stripped). RIGHT.

Mechanism, read in the cited code. cascadeDeleteRelations (engine.ts 18731) → collectCascadeDeleteSet (18799, read-only, now records every cascading edge, including one to a row already in the set) → NEW refuseRestrictsInsideOwnCascade (19431, read-only) → cascadeDeleteSets.run(walkReferencingRelations) (first write). The new step iterates root + every edge target, and for each member whose object has a restrict relation from an object that has a member in the set, probes that relation and refuses only rows inside cascadeReachOf(member). A nested delete the walk issues joins the inherited set (18761) and skips the step, which is correct: the root already judged every member's reach. The walk's phase-2 restrict/set_null filter against rows outside the set is unchanged (19288-19291).

Seat question 1 (letter A, #22305 pin). Confirmed. engine-cascade-delete-sibling-restrict.test.ts: the 487 lines on main are byte-identical in the head (diff of main's file against the head's first 487 lines: empty); the diff is a pure append of 105 lines (one new describe('[#26718] …') with 5 cases: card shape × 2 field orders, grandchild restricting the root, grandchild restricting the member above it, 2 controls). No existing assertion was loosened, removed or flipped. The two #22305 pins the fix could have disturbed stay green by construction: (a) "an AUTHORED restrict between two members does not refuse" (quote → contact, both details of the account): the quote is not in the contact's cascadeReachOf (no cascading edge contact → quote) and no restrict relation points at the account from the quote, so neither member refuses; (b) "a member holding a required lookup to the ROOT does not refuse the root" (ledger): cascadeRelationBehavior returns set_null for a required lookup (the escalation happens later, in the walk), so the new step never probes it. The new refusal is limited to rows whose membership in the set passes through the protected record (its cascadeReachOf), or rows outside the set (phase 2, unchanged). One consequence worth naming, consistent with A: a row reached by TWO paths (through the protected record and through another member) is in the protected record's reach and refuses; the dev's comment at 18845 states this on purpose. Report line: 27/27 on this head; ablation of the one call reds exactly the 4 new non-control cases and no #22305 pin.

Seat question 2 (every delete door). Every door that deletes by id reaches ObjectQL.delete with a scalar where.id (resolveEngineDeleteDispatch → by-id), which calls cascadeDeleteRelations inside runByIdDelete (engine.ts 20285) and therefore now refuses the card's shape:

  • ObjectQL.delete(object, { where: { id } }) — the walk itself (unit pins).
  • protocol.deleteData (protocol.ts 13883-13888) → engine.delete(where:{id}) — refuses (real-driver pin, developerMessage contains via f_lookup).
  • protocol.deleteManyData → runDeleteManyLoop (protocol-batch-data.ts 863) → engine.delete(where:{id}) per id — refuses per id (real-driver pin: success:false, succeeded:0, failed:1, both rows still present).
  • REST DELETE /api/v1/data/:object/:id: packages/rest/src/crud-routes.ts 532 → p.deleteData; the runtime /data domain packages/runtime/src/domains/data.ts 354 → actionExec.callData('delete') → protocol.deleteData (action-execution.ts 629), or the no-protocol fallback ql.delete(where:{id}) (676) — both by id, both refuse.
  • REST POST /api/v1/data/:object/deleteMany: packages/rest/src/batch-routes.ts 635 → p.deleteManyData — refuses per id.
  • Import undo (data-action-routes.ts 714) → p.deleteData per created id — refuses.
  • Action facade delete(object, idOrIds) (action-execution.ts 2140) → ql.delete(where:{id}) per id — refuses.
  • Flow delete_record with multi off (crud-nodes.ts 882): the filter must name one id or the engine refuses the predicate shape, so it is by id — refuses.
  • CLI data delete → client.data.delete → the REST door above.

Predicate delete. engine.delete with multi: true and a non-id where routes to driver.deleteMany and never calls cascadeDeleteRelations (engine.ts 20325-20327; dispatch table in engine-delete-dispatch.ts 274-290). Pre-existing on main, unchanged by this diff, documented in the cascadeDeleteRelations docblock and pinned by engine-cascade-delete-atomic.test.ts 407-421 ("opens no transaction when … the delete is by predicate"). It is NOT reachable through the HTTP data doors: deleteManyData fails closed on anything but a list of scalar ids (protocol-batch-data.ts 806-815) and deleteData takes one id. Precision on the dev's "not reachable through a public door": it IS reachable by an authored flow delete_record node with multi: true and a non-id filter (crud-nodes.ts 845-882, the author's declared bulk intent), and by in-process system callers (lifecycle-service.ts 1691, plugin-auth/organization-delete.ts 279-288, suspended-run-store.ts 516). All pre-existing, outside the card's measured reach (DELETE /api/v1/data/…), and not a defect this PR introduces or widens. Escalated in ③ as a note for the seat, not a blocker.

Seat question 3 (atomicity). Yes. The step is read-only and runs before walkReferencingRelations and before the parent's own driver.delete, inside runByIdDelete; under planCascadeAtomicity === 'atomic' that is inside this.transaction(...), and under 'none'/'split' nothing has been written when it throws either way. Pins: unit cases assert driver.writes is [] and every row count intact; real-driver cases read counts out of the driver (parent 1, mid 1, leaf 1 for the grandchild case; parent 1, child 1 for the card shape and the bulk door). For deleteManyData: each id is its own by-id delete, so each refusal leaves that id's parent, child and grandchild in place; an earlier id in a non-atomic list that already succeeded stays deleted and is reported per row (succeeded/failed, the #4435/#19412 contract), and with options.atomic: true the whole batch rolls back through runAtomicBatch (protocol-batch-data.ts 198, #4620). Both batch semantics are pre-existing and unchanged.

Seat question 4 (controls). Yes, exactly as on main. set_null: the new step probes only relations whose resolved behaviour is restrict, so a set_null relation (optional lookup, or a required lookup before escalation) is never touched; the walk's set_null branch is untouched by the diff; #22305 pins "issues no set_null write against a member" (both registration orders) and "still clears an outside optional lookup" stand unchanged. Plain master_detail cascade with no restrict: no restrict relation exists, restrictingInto returns [], the step is a no-op; pinned on the real driver ("a plain master_detail with no restrict still cascades", counts 0/0). Two-different-parents control: the child is outside the guarded parent's cascade set, so phase 2 refuses it as before; pinned on the real driver and in the unit file ("deleting the restricted record of a pledge owned elsewhere still refuses"). Ablation evidence in the report: the controls stay green with the step no-op'd, so they are not carried by the new code.

Seat question 5 (dogfood battery). Met. The report's dogfood_battery line names ba42fbd96c6fb14d60ccd6f5fc2b93b5bbbf23f2, os-verify-lock VERDICT command-exit 0, Test Files 314 passed | 3 skipped (317), Tests 2609 passed | 20 skipped (2629), no shard named; the voided first run (cli build exit 2 under a forwarded flag) is disclosed and was rerun on the same head.

Check-runs on the head (gate verdicts), read at 2026-10-27T19:10Z: 42 runs; 22 success (incl. Lint · ESLint, Lint · CI/release & engine gates, Lint · PM dispatch-gates, Lint · docs & code guards, Lint · repo & tooling gates, Lint · scoped ×3, Type Check · source gates, Type Check · debt ledger, Build Core, Check Changeset, Check PR Size, PR-body guards, Governed Surface Queue Guard, Dogfood Regression Gate, Plan Test Core shards, Flag docs affected, Auto Label, filter, Test UI); 11 skipped by path filter or opt-in (UI jobs, Build Docs, Dogfood shards/Verify CLI, Temporal Conformance, Packed-tarball smoke, 18.x upgrade harness); 10 still in_progress: Test Core (1/8)…(8/8), Type Check · consumer gates, Type Check · workspace. No failure read. The verdict below stands on what is read; a red among the 10 in-progress runs reopens it.

② Semver level

Changeset .changeset/26718-restrict-over-own-cascade.md: '@objectstack/objectql': patch. Correct per AGENTS.md Post-Task Checklist §3: a non-protocol change, breaking, ships patch and keeps its BREAKING banner and ADR-0087 disposition. The body carries both declaration lines (Clause-②: no (narrowing) — …, Metadata-protocol: none), the **BREAKING** accept-set narrowing banner, the Before/Now/Unchanged/Who-sees-it paragraphs, the remedy, and exactly one ADR-0087 marker (adr-0087: not-required (no-migration-prescription)), which is right: no authorable key, export or type is added, removed or re-shaped, so there is nothing for a migration to rewrite and no tombstone is owed. The PR body carries the same two lines (Clause-②: no (narrowing), Metadata-protocol: none) and the title is fix(objectql)!:. @objectstack/runtime gains a test file only and publishes nothing, so one changeset is correct. Check Changeset on the head: success. Skills-docs: none holds: content/docs/data-modeling/field-types.mdx 312/334 and skills/objectstack-data/rules/relationships.md 173-185 already teach that restrict refuses the parent delete while a referencing row exists; nothing taught changes.

Clause-②: no (narrowing) — matches what the diff publishes: one by-id delete that succeeded now refuses 409; nothing widened.

③ Boundary flags

  • open_questions[0] (sibling-held authored restrict: A keep vs B absolute): answered by seat ruling 6451117438, letter A. The diff is A as shipped: the reach filter in refuseRestrictsInsideOwnCascade (if (!reach.has(childName)) continue; and cascadeSetHas(reach, …)) keeps the objectql: cascade delete trips a restrict on a record the same cascade was about to delete (registry-order, depth-first walk) #22305 authored-member pin, which is unchanged in the file. No pin change is owed; the B follow-up is a separate triage card if a real app reports it.
  • out_of_scope_findings[0] (predicate delete runs no walk): answered — pre-existing on main, documented and pinned, unchanged here, not reachable through the HTTP data doors. Escalated as a note: the dev's "not reachable through a public door" is true of the data doors but not of an authored flow delete_record with multi: true (and in-process system callers); whether that pre-existing contract wants a card of its own is the seat's call, outside this PR.
  • Dev flag "no save-time refusal added": consistent with the claim's ruling (6448161362: no save-time refusal, that would be a spec card) and with the lane red line (no packages/spec/**, no ui/** in the file list).
  • Dev flag "overlapping open PR refactor(objectql): move the validate body out of engine.ts into engine-validate.ts (#25522 cut 16) #26792 touches engine.ts in a different region": not an input of this review; the diff's hunks are at 4518-4551, 18710-18775, 18803-18855, 19253-19305, 19396-19592, which the PR body states is away from refactor(objectql): move the validate body out of engine.ts into engine-validate.ts (#25522 cut 16) #26792's validate-body cut. Not re-measured here.
  • Dev flag "check:watch-hint-literal rerun / check-plugin-teardown-shape --self-test needs the unshallowed clone": local-gate notes; the head's CI check-runs are the gate verdicts and none of the completed ones is red.
  • Deviation from the claim's expected file surface: none (packages/objectql/src/**, its tests, a patch changeset, plus one runtime integration test, which the claim's "its tests" covers).

Implemented-by: claude/project-thread-sfz9hi
Reviewed-by: session_01R1q2xdtYgU6FKrZ7LgjPCC

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位判读 · flake · domain:server seat 1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC · 2026-10-27T19:42Z

Test Core (2/8) failed on head ba42fbd96c (run 40565443484, job 122406406514, attempt 1). The seat judges it a flake and re-runs the failed jobs once through scripts/pm/rerun-failed.mjs. A second failure is real.

  • Which task. The only failed task in the shard is @objectstack/plugin-security#test. Runtime in the same shard is green at 7,464 tests, and check-test-completeness accounts for all 15,351 declared tests. The test name is outside the 5,000-line log tail the MCP read returns, and the full log blob is refused by this container's proxy.
  • Not this diff. The PR touches only packages/objectql (engine.ts and its tests) and one runtime integration test. plugin-security does not depend on the changed delete path.
  • Does not reproduce. The card thread rebuilt the exact merge ref CI tested (ba42fbd96c plus main at e9e344a9dd, which brings in refactor(objectql): move the validate body out of engine.ts into engine-validate.ts (#25522 cut 16) #26792 and fix(plugin-security)!: security explain refuses a userId that no sys_user record has with 404 RECORD_NOT_FOUND #26801). plugin-security passed 231/231 files, 5,032 tests. It passed again under CI=true OS_SKIP_DTS=1 with the full objectql suite running alongside for load (objectql 8,854/8,854). On the PR head alone it passed 5,029. main's Test Core (2/8) was green at e9e344a9dd and 4c29f38447.

Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · ACCEPT · domain:server seat 1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC · 2026-10-27T20:01Z

Head ba42fbd96c, report 6451083716 (on #26718), claim 6448161362, seat ruling 6451117438 (letter A), contract review 6451264868 (CONTRACT_REVIEW_TIER, narrowing face, PASS on this head).

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants