Repository navigation
fix(platform-objects)!: a business unit with child units refuses its delete instead of silently re-rooting them - #24984
Conversation
…elete (parent_business_unit_id restrict) Claude-Session: https://claude.ai/code/session_01VBFXArkqADbAp54xq6RAZE Co-authored-by: Claude <noreply@anthropic.com>
…a real engine; translate the help text Claude-Session: https://claude.ai/code/session_01VBFXArkqADbAp54xq6RAZE Co-authored-by: Claude <noreply@anthropic.com>
…ith child units refuses its delete Claude-Session: https://claude.ai/code/session_01VBFXArkqADbAp54xq6RAZE Co-authored-by: Claude <noreply@anthropic.com>
…-parent-delete-restrict
📓 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:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 3 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 c90ad414cb3b3b6567cb3ce7744011bd56a2d0b6 && git checkout c90ad414cb3b3b6567cb3ce7744011bd56a2d0b6
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0603bfa1cfb02a6e787f8ab2e389ce4752565ba9 cbcab4e10a22e5ea5efeb282c635f03a4dda2d2f && git checkout -B drift-repro 0603bfa1cfb02a6e787f8ab2e389ce4752565ba9 && git merge --no-ff cbcab4e10a22e5ea5efeb282c635f03a4dda2d2f
node scripts/docs-audit/affected-docs.mjs --json 0603bfa1cfb02a6e787f8ab2e389ce4752565ba9
|
Contract reviewServed-tier: ① Derived judgmentsGate verdicts on the head. At 2026-10-20T22:01Z, 40 check-run names: every one success or skipped except The refusal's reach — who is refused now who answered before. Every delete of a
Platform paths that could meet the refusal, each read in the code:
Does any platform-owned path now fail where it completed? No. Every platform deleter either never addresses a unit or addresses leaves before parents. The claim's stop condition (6300791873: stop and report The exemptions. In-set parent and child: moot for this object, since no cascade reaches it; were one ever to, The wire answer. In the restrict branch Lost reach vs added access. Before: the parent delete wrote The help text and comment. en (regenerated: the The pins. ② Semver level
③ Boundary flags
The three
The out-of-scope finding (the Implemented-by: VERDICT: PASS Generated by Claude Code |
席位核对 ·
|
| Item | Reading |
|---|---|
| Shape | Draft PR → main. The body opens Fixes #24939. 7 files, +198/−5. Not governed: check-governed-merges matched 0 of 7 paths; 203 changed lines. |
| Scope | sys_business_unit.parent_business_unit_id declares deleteBehavior: 'restrict'. A unit with child units is refused (409 DELETE_RESTRICTED, the object and the child count named) instead of silently re-rooting them. A leaf, a re-parented unit and a leaf-to-root bulk delete all complete. Nothing else in the object moves. |
| Seat's own read | The declaration is at the head (deleteBehavior: 'restrict'). The console's bulk delete is N parallel by-id deletes under Promise.allSettled (objectui main's recordDelete.ts), as the record corrects: a subtree selected whole loses its leaves, the root answers 409, and a retry finishes. Nothing is stranded. |
| Review | CONTRACT_REVIEW_TIER, narrowing face. Record 6303118411 is PASS on this head. The claim's stop condition is not met: no platform-owned deleter (organization delete, the v18 ceremony, the sample-data purge, seeds, os commands, plugin-sharing) meets the refusal. |
| Declaration | One changeset, @objectstack/platform-objects minor, BREAKING (FROM / TO / Unchanged / Fix), Clause-②: no (narrowing) at a line start, as on the claim and the PR body; adr-0087: not-required (no-migration-prescription). No export, spec key or error code is added. |
| Cross-lane | plugin-sharing's new business-unit-parent-delete-restrict.test.ts (test-only), declared to domain:services on #6021 at 6302719803. No objection on the card, the PR or the seat posts at this read. |
| Gates | Dev at cbcab4e10: 68 derived, 68 run, all exit 0. platform-objects 1232 passed, plugin-sharing 1199 passed, objectql 8490 passed, core test:repo 82 passed. Ablation: tests 1, 2 and 7 red, 3–6 and 8 green, as the record derives; restored to HEAD. |
| CI | At this read on cbcab4e10a: ci-failure GREEN, all 41 check-runs completed, none failed. git merge-tree against today's origin/main is clean. |
Disposition:
open_questions:- Bulk order: A, keep. The refusal is loud and retryable, and no caller is stranded.
- Purge order: A, note only. Every shipped seed of this object is parent-first.
- Docs or checklist: A, small, left with
domain:devxas a note. The help text and the refusal sentence already instruct the administrator, so no filing gate compels a card.
deviations: all accepted (record ③): the pin's placement inplugin-sharing, the hand-translated values (the repo's rule), theran.listre-run line, and the five pushes.out_of_scope_findings: the engine-genericdeveloperMessagenamingcascademeets no filing gate.
Landing: ordinary queue (pr_ready + automerge_enable through the relay). Fixes #24939 then closes the card.
Generated by Claude Code
Fixes #24939
Clause-②: no (narrowing)
Dispatched by the
domain:engineseat 1 (claim 6300791873); built by dev sessionsession_01VBFXArkqADbAp54xq6RAZE. The narrowing face owes aCONTRACT_REVIEW_TIERreview of record before the queue.What changes
packages/platform-objects/src/identity/sys-business-unit.object.ts:parent_business_unit_iddeclaresdeleteBehavior: 'restrict'. Nothing else in the object moves.restrictand not the defaultset_null.enbundle is regenerated (node scripts/check-i18n-bundles.mjs --write); the hand-writtenzh-CN/ja-JP/es-EShelp strings are translated to match (values only, no key moves).minorfor@objectstack/platform-objects, BREAKING with FROM / TO / Fix, theClause-②line and theadr-0087: not-required (no-migration-prescription)marker (check-adr-0087-registrationgreen).Before / after, measured on the wire
Fresh showcase backend (
pnpm dev -- --fresh) at this branch's head, as the seeded admin, the card's steps. The before column is the card's own measurement on 18.0.0 and, onmainf32d795ce5, a real-engine probe with the shipped object (delete answeredtrue, the child's parent becamenull):set_null)DELETE /api/v1/data/sys_business_unit/bu_qa_emea(has 1 child)200; the child's parent becomesnull(card's measurement, and re-measured on a real engine below)409,code: DELETE_RESTRICTED,dependentObject: sys_business_unit,dependentCount: 1,error: "This Business Unit is still referenced by 1 Business Unit record(s) through “Parent Business Unit”. Delete or reassign them first." The child keepsparent_business_unit_id = bu_qa_emea.DELETE …/bu_field_ops(seeded, 2 children)200, both children re-rooted (inferred from the engine probe, not re-measured on the wire)409 DELETE_RESTRICTED,dependentCount: 2POST …/deleteMany{"ids":["bu_qa_emea","bu_qa_office"]}(root first)200, both deleted (inferred, not re-measured)200withsuccess: false: row 0DELETE_RESTRICTED(409), row 1NOT_ATTEMPTED(the batch stops, asdeleteManyalways does withoutcontinueOnError); nothing deletedPOST …/deleteMany{"ids":["bu_qa_office","bu_qa_emea"],"options":{"atomic":true}}(leaf to root)200(inferred)200,succeeded: 2DELETE …/bu_west_coast(leaf)200(inferred)200The refusal names the blocking object and the child count, so the administrator knows to re-parent first. How the console displays it is objectui's (read only, not changed here).
Every deleter of
sys_business_unit, read before the editDELETE /data/:object/:id) →engine.deleteby id →cascadeDeleteRelations→ refused while children exist.POST /data/:object/deleteMany,batchData's delete case): a per-id loop in the caller's order (runDeleteManyLoop),atomic= one transaction. Leaf to root completes; root first refuses the root (measured above). Amulti: trueengine delete addressed by id was also refused on a real engine.sys_business_unit(itsorganization_idis a plain lookup,set_null), so no cascade set ever holds units. Were one to, the engine's objectql: cascade delete trips a restrict on a record the same cascade was about to delete (registry-order, depth-first walk) #22305 rule already judgesrestrictonly against rows outside the cascade set.organization_idisset_null, so the units stay and keep their tree (pinned below). No organization teardown deletes units. The v18 ownership ceremony (os migrate organization-ownership) givessys_business_unitfateattribution, never a delete.cloud-connectionmarketplace-install-local-purge.ts): deletes through the engine in the REVERSE of the loader's insert order. The showcase seed lists the tree parent first, so the purge deletes leaf to root and completes. See open note 2.oscommands / plugin-sharing: the seed loader never deletes; nooscommand deletes units; plugin-sharing bindsafterDeleterecomputes only, which a refused delete never reaches.sys_business_unit(engine-delete-restricted-locale,engine-cascade-federated-tenant-anchor) register their own fixture objects;bu-tree-recompute.test.tsuses a fake engine. No existing pin moves.The stop condition was not met: no platform path deletes a parent before its children in a way
restrictbreaks.Pins
packages/plugins/plugin-sharing/src/business-unit-parent-delete-restrict.test.ts(new): a REAL engine (ObjectQL + SqlDriver over in-memory SQLite) carrying the SHIPPEDSysBusinessUnitandSysOrganization. It lives in plugin-sharing becauseplatform-objectshas no engine or driver among its dependencies; plugin-sharing already depends on both and owns the subtree walk the refusal protects. No recompute code is touched.restrict;codeDELETE_RESTRICTED,status409,dependentObject,dependentCount1; the tree is unchanged;Ablation (
scripts/ablation-replace.mjs+scripts/ablation-dist-preflight.mjs, platform-objects rebuilt on each leg because plugin-sharing reads itsdist/): removingdeleteBehavior: 'restrict'(anchor 1 → 0, dist marker absent from all 66 built files) turns pins 1, 2 and 7 red (3 failed / 5 passed). Restored: blobc68f61b6c63aequalsHEAD, whole-treegit statusclean, dist marker present again, 8/8 green.Verification record (head
cbcab4e10, origin/main3a7a60af8merged)pnpm --filter @objectstack/platform-objects typecheckexit 0;testexit 0 (82 files, 1232 tests).pnpm --filter @objectstack/plugin-sharing typecheckexit 0 (test-typecheck included);testexit 0 (53 files, 1199 tests).pnpm --filter @objectstack/objectql testexit 0 (450 files, 8490 tests).pnpm --filter @objectstack/core test:repoexit 0 (6 files, 82 tests).node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack(no paths): 68 lines, each run with its exit code recorded before any pipe; 67 exit 0 first time,pnpm check:dual-build-cjs-loadsexit 3 (PREREQUISITE NOT MET, nodist/for 10 packages) → fullturbo run build(77/77) → re-run exit 0.--ran: 68 derived, 68 run, 0 NOT-MEASURED, 0 UNRUN, exit 0.node scripts/check-adr-0087-registration.mjsexit 0.node scripts/check-i18n-bundles.mjs --writeexit 0 after building its closure (first attempt exit 3, PREREQUISITE NOT MET, built and re-run).Acceptance notes
continueOnError, stops the batch). The console's bulk delete sends ids in the grid's order, which is objectui's; leaf to root completes.errors, which already names "a restrict" as a cause). Every shipped seed of this object (examples/app-showcase) lists parents first.content/docs/permissions/administrator-guide.mdxStep 1 says nothing false (it does not describe deleting a unit); a sentence on retiring a unit leaf-first would help. Adocs/qa/platform-checklist/**item for the refusal would bedomain:devx's.🤖 Generated with Claude Code
https://claude.ai/code/session_01VBFXArkqADbAp54xq6RAZE
Generated by Claude Code