Skip to content

platform-objects: deleting a business unit that has children silently turns them into tenant roots — parent_business_unit_id declares no deleteBehavior and takes set_null #24939

Closed

Description

@objectstack-fleet

Filing gate ③ a task the maintainer set: objectstack-ai/hotcrm#2207, the maintainer's browser round on ObjectStack 18.0.0 (「hotcrm 升级到 objectstack 18.0 然后重新安排一轮浏览器测试」). Each defect gets its own card, by the maintainer's ruling 「按照按照具体的issue单独提交卡片,不受数量限制。」 (recorded on objectstack-ai/hotcrm#2188, 6283467378). This is finding F-04 of track 2 (the Setup pass as the system administrator), by the cloud tester session_01BkRPwuAufrFwoEEfuigDm9. It was measured on HotCRM main 16c28664 with @objectstack/* 18.0.0 (framework 06cab4849, console objectui 616df6598b42), in Chromium at 1440×900 on a locally booted, seeded install. The evidence is on the branch claude/qa-v18-setup-pass at 4d7cc467d (REPORT.md), which is never merged. Filed by the repo:hotcrm seat 2 (os-support-ai, session session_01KqkTQNLcxVc588eZpJmp1r). ⛔ Not a claim. Grade: triage's.

Reader (who acts): objectstack triage routes it.

The finding, as measured

  • Persona / URL: dev admin, Setup → People & Org → Business Units.
  • Steps:
    1. Create "QA EMEA", then "QA EMEA Office" with Parent = QA EMEA (the Org Chart shows the tree).
    2. Delete QA EMEA from its row menu.
  • Expected: the delete is refused while children exist, or its confirm names what it does to the tree. The field's own help reads "Self-reference for the org tree. Null = root of tenant", and the Sharing Rules recipient type "Business unit and subordinates" reads that tree.
  • Actual: the confirm reads only Delete Business Unit "QA EMEA"? Are you sure you want to delete this record?. After 200, GET /api/v1/data/sys_business_unit shows QA EMEA Office with parent_business_unit_id = null: it is now a root of the tenant, without a word.
  • Evidence: 09-people-business-units-org-chart.png (before), 09-people-business-units-delete-parent-confirm.png, 09-people-business-units-delete-parent-result.png.
  • Reach: every admin restructuring business units. A unit_and_subordinates grant anchored above the deleted unit no longer reaches its former children. That is lost reach, not added access.

Seat review (source at 18.0.0, 06cab4849)

  • packages/platform-objects/src/identity/sys-business-unit.object.ts:131-136: parent_business_unit_id: Field.lookup('sys_business_unit', { required: false, … }) declares no deleteBehavior.
  • A lookup with none defaults to set_null (packages/spec/src/data/field.zod.ts:2981), which the engine applies (packages/objectql/src/engine.ts:19341). The engine also honours an authored restrict on a lookup (:19330-19331).
  • No hook refuses the delete. plugin-sharing has only after-write grant recomputes (packages/plugins/plugin-sharing/src/bu-tree-recompute.ts:303).
  • The console's confirm is the generic one and has no server data about children.
  • Unchanged on main.

Dedupe

Method: every objectstack-ai/objectstack issue title (every state, #1–#24932) and every objectstack-ai/objectui issue title (#7–#13268), listed through the repo-scoped issues?state=all endpoint (refreshed with since=2026-10-19) and matched case-insensitively. The search API is refused for this session.

  • Different, closed: objectstack#22957 (a tree-typed field's deleteBehavior was not honoured; this is a lookup, and set_null is honoured); objectstack#9625 (an explicit set_null on a required lookup); objectstack#7807 (subtree semantics of the recipient type).
  • New.

Generated by Claude Code

Activity

  1. objectstack-fleet commented on Oct 20, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 19 · 2026-10-20T20:29Z · p2, domain:engine (triage's grade), from objectstack-ai/hotcrm#2207's v18 Setup pass (F-04). Deleting a business unit that has children answers 200 and silently re-roots each child at the tenant (parent_business_unit_id → null). The field declares no deleteBehavior, so a lookup takes set_null. A unit_and_subordinates sharing grant anchored above the deleted unit loses its reach over the former children.
    Session: session_01JtSQQ3EWsbNPzyYMug1tXy (the dispatching seat; the dev runs as its own cloud session, named in the dispatch comment)
    Account: os-project-manager (the seat's linked user as GET /user answers it; always the card's assignee)
    Branch: claude/issue-24939-bu-parent-delete-restrict
    Worktree: objectstack-issue-24939
    Domain: domain:engine
    Seat: domain:engine#1 (seat post #6367)
    File surface:

    • Measured first:
      • every path that deletes sys_business_unit rows: the data API by id and in bulk, an organization's deletion or teardown (does it cascade to its units, and in what order?), seeds, resets, os commands, tests and fixtures;
      • what the engine's referential pass does for a self-referencing restrict lookup when the parent and its children are deleted in one cascade or one bulk call;
      • the refusal the console shows for a restrict delete today (status, code, sentence).
    • packages/platform-objects/src/identity/sys-business-unit.object.ts: parent_business_unit_id declares deleteBehavior: 'restrict'. Deleting a unit that still has children is refused, loudly, until they are re-parented or deleted. The field's help text is made true to that.
    • Pins in platform-objects' tests (or the engine's, where a real engine is needed):
      • a unit with children is refused, and its children keep their parent;
      • a leaf unit deletes;
      • every organization-level or bulk path measured above still completes.
    • The changeset: minor for @objectstack/platform-objects, BREAKING (FROM / TO / Fix), under the launch-window convention.

    ⛔ Not in scope:

    • cascade (deleting a subtree with its parent);
    • a console change: the refusal's display is objectui's, read only;
    • plugin-sharing's recompute;
    • any new spec key.

    If the measurement shows an organization teardown or another platform path that restrict would break, and no ordering inside this file fixes it, the dev stops and reports needs_decision.
    Container & model: M, mode:cloud (one declaration, with every deleter of the object measured first and pinned), model: TIER_DEFAULT (this run's dispatch-gates.mjs --tier: "no path-derived mandate")
    Clause-②: no (narrowing) (a delete that answered 200 is refused while the unit has children; it owes a CONTRACT_REVIEW_TIER review on the narrowing face before the queue)
    Responsibility: an administrator deleting a business unit silently detaches its subtree, and sharing grants anchored above lose their reach | the self-referencing lookup takes the default set_null | every administrator restructuring business units
    Thread-read: none
    Serial constraints cleared: open PRs' file lists and the pm:dispatched claims were read at this claim. No open PR touches sys-business-unit.object.ts or plugin-sharing's bu-tree-recompute.ts.


    Generated by Claude Code

  2. objectstack-fleet commented on Oct 20, 2026

    @objectstack-fleet
    ContributorAuthor

    Dispatch · domain:engine seat 1 · session_01JtSQQ3EWsbNPzyYMug1tXy · 2026-10-20T20:32Z: mode:cloud. The dev session is session_01VBFXArkqADbAp54xq6RAZE. It builds on branch claude/issue-24939-bu-parent-delete-restrict, under claim 6300791873. The review of record is at CONTRACT_REVIEW_TIER, on the narrowing face.

    • Read first, before any edit:
      • every deleter of sys_business_unit: by id, in bulk, an organization's teardown, seeds, resets, os commands and plugin-sharing;
      • what objectql's referential pass does with a self-referencing restrict when a unit and its children go in one cascade or one bulk call;
      • the refusal a restrict delete answers on /api/v1/data today.
    • What lands:
      • parent_business_unit_id declares deleteBehavior: 'restrict', so a unit with children is refused until they are re-parented or deleted;
      • its help text made true;
      • pins for the refusal, a leaf delete and every measured organization-level or bulk deleter;
      • an ablation, and a minor BREAKING changeset for @objectstack/platform-objects.
    • The stop condition: if an organization teardown or another platform path would break, and no change inside this file fixes it, the dev stops and reports needs_decision.
    • Not built: cascade, a console change, plugin-sharing's recompute, a new spec key.

    Generated by Claude Code

  3. objectstack-fleet commented on Oct 20, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
      "issue": 24939,
      "status": "done",
      "branch": "claude/issue-24939-bu-parent-delete-restrict",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/24984",
      "session": "session_01VBFXArkqADbAp54xq6RAZE",
      "premise_still_valid": true,
      "summary": "Premise confirmed on main f32d795ce5: a real-engine probe with the shipped SysBusinessUnit answered true for the parent delete and set the child's parent_business_unit_id to null. parent_business_unit_id now declares deleteBehavior 'restrict' with a true help text (en regenerated, zh-CN/ja-JP/es-ES values translated) and a why-comment; nothing else in the object moved. On the wire (fresh showcase, this head): DELETE of a unit with children answers 409 DELETE_RESTRICTED with dependentObject sys_business_unit and dependentCount (1, and 2 for the seeded bu_field_ops), sentence 'This Business Unit is still referenced by N Business Unit record(s) through “Parent Business Unit”. Delete or reassign them first.'; children keep their parent; a leaf deletes 200; deleteMany leaf-to-root (atomic) succeeds 2/2; root-first refuses row 0 and stops the batch. Deleter census, read before the edit: by-id REST and engine refuse; deleteMany/batchData are per-id loops in caller order (leaf-to-root completes); nothing cascades into sys_business_unit (organization_id is set_null), so no cascade set holds units and the engine's #22305 rule would exempt in-set rows anyway; an organization delete leaves units and tree intact (pinned); the v18 ownership ceremony gives the object fate 'attribution' and never deletes it; the sample-data purge deletes in reverse insert order (showcase seed is parent-first, so leaf-to-root); seed loader, os commands and plugin-sharing (afterDelete only) delete nothing. Stop condition not met. Assignee on the card was os-project-manager at start (not written).",
      "tests": "Pins: new packages/plugins/plugin-sharing/src/business-unit-parent-delete-restrict.test.ts (real ObjectQL + SqlDriver/better-sqlite3, shipped SysBusinessUnit + SysOrganization; placed in plugin-sharing because platform-objects has no engine or driver dependency): declared restrict; refusal code DELETE_RESTRICTED + status 409 + dependentObject + dependentCount 1, tree unchanged; leaf deletes; re-parented unit deletes; bulk leaf-to-root; same in one transaction; root-first refuses root only; organization delete completes on a tree. 8/8 green. Ablation (ablation-replace.mjs --hold, anchor 1 -> 0; platform-objects rebuilt; ablation-dist-preflight --absent exit 0, marker absent from all 66 built files): 3 failed / 5 passed (declared, refusal, root-first). Restore: git checkout HEAD, blob c68f61b6c63a == HEAD blob, git diff HEAD empty, whole-tree status clean, rebuilt, preflight present exit 0 (6 files), 8/8 green. Suites at head cbcab4e10 (origin/main 3a7a60af8 merged): platform-objects typecheck exit 0, test exit 0 (82 files / 1232 tests); plugin-sharing typecheck exit 0, test exit 0 (53 / 1199); objectql test exit 0 (450 / 8490); core test:repo exit 0 (6 / 82). Wire probe on a fresh showcase backend, server torn down (port free).",
      "gates": "Local, head cbcab4e10: dispatch-gates --commands --repo objectstack-ai/objectstack (no paths) printed 68 lines; each run with exit captured before any pipe. 67 exit 0 first pass; pnpm check:dual-build-cjs-loads exit 3 (PREREQUISITE NOT MET, 10 packages without dist) -> full turbo build 77/77 -> re-run exit 0, and its ran.list line was updated to the re-run's code. dispatch-gates --ran: 68 derived, 68 run, 0 NOT-MEASURED, 0 UNRUN, exit 0. Also: check-adr-0087-registration exit 0 (not-required, no-migration-prescription); check-changeset-no-major --base origin/main exit 0 (in the derived set; it cannot read the PR-body Clause-② line without --event, CI's Check Changeset does); check-i18n-bundles --write exit 0 after building its closure (first attempt exit 3). CI on PR #24984 at report time: 'No other open PR may claim the same issue' success; two opt-in jobs skipped; Type Check (workspace, consumer gates, debt ledger, source gates), Lint (repo and tooling, ESLint, scoped families, PM dispatch-gates, docs and code guards, CI/release/engine), Check Changeset, Check PR Size, Check Documentation Links, Governed Surface Queue Guard, card-claims-branch and single-writer checks: in_progress. Test Core / Build Core not yet listed.",
      "line_budget": "none set. Diff vs origin/main: .changeset/24939-bu-parent-delete-restrict.md +16/-0; packages/platform-objects/src/identity/sys-business-unit.object.ts +10/-1; packages/platform-objects/src/apps/translations/en.objects.generated.ts +1/-1; es-ES.objects.generated.ts +1/-1; ja-JP.objects.generated.ts +1/-1; zh-CN.objects.generated.ts +1/-1; packages/plugins/plugin-sharing/src/business-unit-parent-delete-restrict.test.ts +168/-0. Total +198/-5, 7 files.",
      "files_changed": [
        ".changeset/24939-bu-parent-delete-restrict.md",
        "packages/platform-objects/src/identity/sys-business-unit.object.ts",
        "packages/platform-objects/src/apps/translations/en.objects.generated.ts",
        "packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts",
        "packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts",
        "packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts",
        "packages/plugins/plugin-sharing/src/business-unit-parent-delete-restrict.test.ts"
      ],
      "deviations": [
        "Pin file lives in plugin-sharing, not platform-objects: platform-objects has no engine or driver dependency and adding devDependencies would widen the fence (package.json + lockfile). plugin-sharing already depends on objectql, driver-sql and platform-objects and owns the subtree walk; no recompute code touched. The dispatch allowed an engine-level test file elsewhere when a real engine is needed.",
        "Translated help values (zh-CN, ja-JP, es-ES) hand-edited to match the new en source; AGENTS.md allows editing translated-locale values, no key moved.",
        "ran.list line for pnpm check:dual-build-cjs-loads rewritten from exit 3 to exit 0 after the full build and re-run (both runs recorded here).",
        "origin/main advanced 3 commits after the pre-PR merge (plugin-approvals, service-datasource, service-messaging, docs); none touches this diff's files or packages, so no second merge was made before the PR opened.",
        "Changeset carries the adr-0087 marker as the gate requires (an HTML comment inside the repo file); the PR body carries the Clause-② line but no HTML comment, per os-dev.",
        "MCP read tools were used for issue/PR reads (no MCP write)."
      ],
      "mcp_calls": "6 — mcp__github__issue_read x5 (#24939 get, #24939 get_comments, #22957 get, #9625 get, #7807 get), mcp__github__pull_request_read x1 (#24984 get_check_runs). No MCP write tool.",
      "api_writes": "3 — git push to claude/issue-24939-bu-parent-delete-restrict (5 pushes of the same branch: empty branch, 3 commits, merge); fleet-write relay pr_create (POST /repos/objectstack-ai/objectstack/pulls draft + POST /issues/24984/assignees, run 39500979755 success, body 8371 bytes read back identical); fleet-write relay comment (POST /repos/objectstack-ai/objectstack/issues/24939/comments, this report).",
      "open_questions": [
        {
          "question": "Bulk delete order: a deleteMany/batch that lists a parent before its child now refuses the parent (and without continueOnError stops the batch). The console's bulk delete sends ids in grid order (objectui). Is a refusal-and-retry acceptable, or should the platform order a self-referencing bulk delete leaf-first?",
          "options": [
            "A: keep as is — the refusal is loud, names the object and count, and leaf-to-root completes",
            "B: card for the engine/protocol to order a per-id bulk delete of a self-referencing object leaf-first",
            "C: card for objectui to send selected tree rows leaf-first"
          ],
          "recommendation": "A. Business need: no measured caller bulk-deletes a whole BU subtree; the card's own scenario is a single delete. Long-term: ordering inside the protocol is a new semantic for every restrict self-reference, not this card's. AI-proofing: a loud 409 beats a silent re-root. Startup focus: no pull for B or C yet."
        },
        {
          "question": "Sample-data purge of a seed that lists a child unit before its parent would have the parent refused (counted in the purge's errors). No shipped seed does this.",
          "options": [
            "A: note only",
            "B: card for the purge to order self-referencing rows by the reference graph"
          ],
          "recommendation": "A — every shipped sys_business_unit seed (examples/app-showcase) is parent-first; B has no named producer."
        },
        {
          "question": "Docs: content/docs/permissions/administrator-guide.mdx Step 1 does not describe deleting a unit (nothing false); a leaf-first retirement sentence, and a docs/qa/platform-checklist item for the refusal (domain:devx), were not added (outside the fence).",
          "options": [
            "A: seat files a docs/checklist follow-up",
            "B: leave"
          ],
          "recommendation": "A, small: the refusal sentence already tells the admin what to do, so it is polish, not a defect."
        }
      ],
      "out_of_scope_findings": [
        "carrier: none (承接者:无) — the restrict refusal's developerMessage still suggests deleteBehavior:'cascade' on sys_business_unit.parent_business_unit_id, which the card ruled out; engine-owned generic text, noted only, not filed (polish)."
      ]
    }

    Generated by Claude Code

  4. objectstack-fleet commented on Oct 20, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed · domain:engine seat 1 · session_01JtSQQ3EWsbNPzyYMug1tXy · 2026-10-20T23:01Z

    Probe Reading
    Review CONTRACT_REVIEW_TIER, narrowing face. Record 6303118411 PASS on cbcab4e10a. ACCEPT 6303134877. Not governed.
    Queue It entered the merge queue at 2026-10-20T22:27Z and left it merged at 2026-10-20T22:59Z. No gh-readonly-queue/main/pr-24984-* branch remains.
    origin/main 4b8f597ba3 is on origin/main, one squash commit carrying PR #24984's 7 files, +198 / −5.
    Card Closed completed through Fixes #24939; pm:dispatched is cleared.

    What main now holds:

    • A business unit with child units is not deleted out from under them. sys_business_unit.parent_business_unit_id declares deleteBehavior: 'restrict'. Deleting such a unit answers 409 DELETE_RESTRICTED. The sentence names the object and the child count: "This Business Unit is still referenced by N Business Unit record(s) through “Parent Business Unit”. Delete or reassign them first." The children keep their parent, so unit_and_subordinates sharing grants anchored above keep their reach.
    • Still deletes: a leaf unit; a unit whose children were re-parented; a bulk delete that lists leaves before their parent. A bulk delete that lists the parent first refuses the parent and stops (or continues, with continueOnError). The console's bulk delete sends each id on its own, so the root answers 409 and a second delete finishes.
    • Unchanged: an organization's delete leaves its units and their tree intact. No platform path (the v18 ceremony, the sample-data purge, seeds, os commands, plugin-sharing) deletes a parent before its children. A unit with members was already refused.
    • The help text says so in all four locales.
    • Shipped as: minor of @objectstack/platform-objects, BREAKING (an accept-set narrowing), Clause-②: no (narrowing).

    Beside it: plugin-sharing's business-unit-parent-delete-restrict.test.ts is a test-only file in domain:services' package, declared at 6302719803. A leaf-first retirement sentence for the administrator guide, or a platform-checklist item for the refusal, is domain:devx's to take if wanted. Neither meets a filing gate, so no card is filed.

    The dev session session_01VBFXArkqADbAp54xq6RAZE is archived.


    Generated by Claude Code

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

Metadata

Metadata

Labels

area:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guardsbugSomething isn't workingdomain:enginepriority:p2Medium: important, M3

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions