Skip to content

fix(platform-objects)!: a business unit with child units refuses its delete instead of silently re-rooting them - #24984

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-24939-bu-parent-delete-restrict
Oct 20, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-24939-bu-parent-delete-restrict

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #24939

Clause-②: no (narrowing)

Dispatched by the domain:engine seat 1 (claim 6300791873); built by dev session session_01VBFXArkqADbAp54xq6RAZE. The narrowing face owes a CONTRACT_REVIEW_TIER review of record before the queue.

What changes

  • packages/platform-objects/src/identity/sys-business-unit.object.ts: parent_business_unit_id declares deleteBehavior: 'restrict'. Nothing else in the object moves.
  • Its help text becomes true: "Self-reference for the org tree. Null = root of tenant. A unit that still has child units cannot be deleted until they are re-parented or deleted." A comment beside the field says why restrict and not the default set_null.
  • The en bundle is regenerated (node scripts/check-i18n-bundles.mjs --write); the hand-written zh-CN / ja-JP / es-ES help strings are translated to match (values only, no key moves).
  • Changeset: minor for @objectstack/platform-objects, BREAKING with FROM / TO / Fix, the Clause-② line and the adr-0087: not-required (no-migration-prescription) marker (check-adr-0087-registration green).

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, on main f32d795ce5, a real-engine probe with the shipped object (delete answered true, the child's parent became null):

request before (main, set_null) after (this PR)
DELETE /api/v1/data/sys_business_unit/bu_qa_emea (has 1 child) 200; the child's parent becomes null (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 keeps parent_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: 2
POST …/deleteMany {"ids":["bu_qa_emea","bu_qa_office"]} (root first) 200, both deleted (inferred, not re-measured) 200 with success: false: row 0 DELETE_RESTRICTED (409), row 1 NOT_ATTEMPTED (the batch stops, as deleteMany always does without continueOnError); nothing deleted
POST …/deleteMany {"ids":["bu_qa_office","bu_qa_emea"],"options":{"atomic":true}} (leaf to root) 200 (inferred) 200, succeeded: 2
DELETE …/bu_west_coast (leaf) 200 (inferred) 200

The 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 edit

  • Data API by id (DELETE /data/:object/:id) → engine.delete by id → cascadeDeleteRelations → refused while children exist.
  • Bulk (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). A multi: true engine delete addressed by id was also refused on a real engine.
  • One cascade: nothing cascades into sys_business_unit (its organization_id is 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 judges restrict only against rows outside the cascade set.
  • An organization's delete: organization_id is set_null, so the units stay and keep their tree (pinned below). No organization teardown deletes units. The v18 ownership ceremony (os migrate organization-ownership) gives sys_business_unit fate attribution, never a delete.
  • Sample-data purge (cloud-connection marketplace-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.
  • Seeds / resets / os commands / plugin-sharing: the seed loader never deletes; no os command deletes units; plugin-sharing binds afterDelete recomputes only, which a refused delete never reaches.
  • Tests and fixtures: the objectql suites that delete a sys_business_unit (engine-delete-restricted-locale, engine-cascade-federated-tenant-anchor) register their own fixture objects; bu-tree-recompute.test.ts uses 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 restrict breaks.

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 SHIPPED SysBusinessUnit and SysOrganization. It lives in plugin-sharing because platform-objects has 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.

  1. the field declares restrict;
  2. a unit with children: code DELETE_RESTRICTED, status 409, dependentObject, dependentCount 1; the tree is unchanged;
  3. a leaf deletes;
  4. a unit whose child was re-parented deletes, and the child keeps its new parent;
  5. bulk leaf to root completes; 6. the same in one transaction (the atomic arm) completes;
  6. bulk root first: the root alone is refused, its subtree whole;
  7. an organization's delete completes on a tree, the units and their parents intact.

Ablation (scripts/ablation-replace.mjs + scripts/ablation-dist-preflight.mjs, platform-objects rebuilt on each leg because plugin-sharing reads its dist/): removing deleteBehavior: 'restrict' (anchor 1 → 0, dist marker absent from all 66 built files) turns pins 1, 2 and 7 red (3 failed / 5 passed). Restored: blob c68f61b6c63a equals HEAD, whole-tree git status clean, dist marker present again, 8/8 green.

Verification record (head cbcab4e10, origin/main 3a7a60af8 merged)

  • pnpm --filter @objectstack/platform-objects typecheck exit 0; test exit 0 (82 files, 1232 tests).
  • pnpm --filter @objectstack/plugin-sharing typecheck exit 0 (test-typecheck included); test exit 0 (53 files, 1199 tests).
  • pnpm --filter @objectstack/objectql test exit 0 (450 files, 8490 tests).
  • pnpm --filter @objectstack/core test:repo exit 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-loads exit 3 (PREREQUISITE NOT MET, no dist/ for 10 packages) → full turbo 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.mjs exit 0.
  • node scripts/check-i18n-bundles.mjs --write exit 0 after building its closure (first attempt exit 3, PREREQUISITE NOT MET, built and re-run).

Acceptance notes

  1. Bulk order is the caller's. A bulk delete 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 the grid's order, which is objectui's; leaf to root completes.
  2. Sample-data purge on a seed authored child before parent. The purge deletes in reverse record order within one self-referencing dataset; a seed listing a child unit before its parent would have its parent refused (counted in the purge's errors, which already names "a restrict" as a cause). Every shipped seed of this object (examples/app-showcase) lists parents first.
  3. Docs. content/docs/permissions/administrator-guide.mdx Step 1 says nothing false (it does not describe deleting a unit); a sentence on retiring a unit leaf-first would help. A docs/qa/platform-checklist/** item for the refusal would be domain:devx's.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VBFXArkqADbAp54xq6RAZE


Generated by Claude Code

…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>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 20, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/platform-objects, touching 2 documentable anchor(s).

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

  • content/docs/permissions/administrator-guide.mdx (via sys_business_unit (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects))
  • content/docs/permissions/authorization.mdx (via sys_business_unit (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects))
  • content/docs/permissions/delegated-administration.mdx (via sys_business_unit (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects))
  • content/docs/permissions/permissions-matrix.mdx (via sys_business_unit (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects))
  • content/docs/permissions/positions.mdx (via sys_business_unit (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects))
  • content/docs/permissions/sharing-rules.mdx (via sys_business_unit (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects))
  • content/docs/protocol/objectql/security.mdx (via sys_business_unit (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects))

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

  • content/docs/releases/v13.mdx (via sys_business_unit (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects))
  • content/docs/releases/v16.mdx (via sys_business_unit (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects))
  • content/docs/releases/v17/17-4.mdx (via sys_business_unit (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects))

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
  • the SDK route bridge reached 54 of 206 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 — 3 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 0603bfa1cfb02a6e787f8ab2e389ce4752565ba9 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from c90ad414cb3b3b6567cb3ce7744011bd56a2d0b6 — the merge of head cbcab4e10a22e5ea5efeb282c635f03a4dda2d2f into base 0603bfa1cfb02a6e787f8ab2e389ce4752565ba9, 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 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

⚠️ 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 0603bfa1cfb02a6e787f8ab2e389ce4752565ba9 → 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: cbcab4e10a22e5ea5efeb282c635f03a4dda2d2f
Local-runs: none
Scope: 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, .changeset/24939-bu-parent-delete-restrict.md + packages/objectql/src/engine.ts (delete() by-id and predicate branches, cascadeDeleteRelations, collectCascadeDeleteSet, cascadeRelationBehavior, walkReferencingRelations restrict branch, dependentCountIsDisclosable), packages/spec/src/system/operation-message.ts (delete_restricted* in four locales), packages/spec/src/data/field.zod.ts (the materialized lookup default), packages/metadata-protocol/src/protocol.ts (deleteData, deleteManyData, runDeleteManyLoop, batchData delete case), packages/spec/src/api/batch.zod.ts (continueOnError default), packages/rest/src/crud-routes.ts, packages/rest/src/data-action-routes.ts (undo), packages/rest/src/rest-route-ledger.ts, packages/plugins/plugin-hono-server/src/adapter.ts (declaredEnvelopeForThrow, declaredArmContext), packages/platform-objects/src/identity/sys-business-unit-member.object.ts, packages/spec/src/data/injected-system-column-provenance.ts (owning_business_unit_id), packages/cli/src/utils/organization-ownership-inventory.ts (the sys_business_unit fate row) and packages/cli/src/commands/migrate/organization-ownership.ts, packages/cloud-connection/src/marketplace-install-local-purge.ts, packages/metadata-protocol/src/seed-loader.ts, packages/runtime/src/seed-loader.ts, packages/plugins/plugin-sharing/src/bu-tree-recompute.ts, packages/mcp/src/mcp-http-tools.ts (delete_record), packages/core/src/utils/import-runner.ts, examples/app-showcase/src/data/seed/index.ts (orgUnits), packages/platform-objects/src/apps/translations/source-hash.ts and the three *.source-hashes.generated.ts, packages/objectql/src/engine-delete-restricted-locale.test.ts, packages/objectql/src/engine-cascade-federated-tenant-anchor.test.ts, packages/plugins/plugin-sharing/src/bu-tree-recompute.test.ts, scripts/check-changeset-no-major.mjs, scripts/check-adr-0087-registration.mjs, .github/workflows/pr-automation.yml, scripts/pm/dispatch-gates.mjs (--ran, EXIT_PREREQUISITE_NOT_MET), scripts/pm/record-recognisers.mjs (--paths), AGENTS.md (translations row; Post-Task Checklist 3), .claude/skills/pm-dispatch/SKILL.md (lane table), the package.json of platform-objects, plugin-sharing, core and objectql; objectui main 53dea27a, read only: packages/core/src/actions/recordDelete.ts, packages/app-shell/src/views/ObjectView.tsx (onBulkDelete), packages/data-objectstack/src/index.ts (bulkDelete).

① Derived judgments

Gate verdicts on the head. At 2026-10-20T22:01Z, 40 check-run names: every one success or skipped except Test Core (3/6), in_progress. Re-read at 2026-10-20T22:14Z: 41 names, all success or skipped, Test Core (3/6) included. Check Changeset, Governed Surface Queue Guard, Lint & Repo Gates (carries check:i18n), Type Check · workspace, Build Core, Test Core (1..6), the card-claims-branch and single-writer checks: success.

The refusal's reach — who is refused now who answered before. Every delete of a sys_business_unit row that enters engine.delete with where.id runs cascadeDeleteRelations, whose walk resolves parent_business_unit_id through cascadeRelationBehavior as fdef.deleteBehavior || 'set_null', now restrict, and refuses when any row outside the cascade set still holds the id. Before this head the same walk wrote null into each child (the spec materializes set_null when the key is undefined, field.zod.ts:2981). Write shapes that reach that branch:

  • by id: DELETE /api/v1/data/sys_business_unit/:id (crud-routes.ts → protocol.deleteData → engine.delete(object, { where: { id } })), the SDK's data.delete, MCP delete_record({ objectName, recordId }) (mcp-http-tools.ts) and the data-action undo of a create (undoDeleteRequest, data-action-routes.ts) are one door. Refused while a child exists. Right.
  • bulk: POST …/deleteMany is runDeleteManyLoop, one engine.delete({ where: { id } }) per id in the caller's order; a refused parent is one results[] row carrying DELETE_RESTRICTED, the loop stops on it unless continueOnError (declared default false, batch.zod.ts:81) and the tail is NOT_ATTEMPTED; atomic: true rolls the batch back. Leaf-to-root completes, root-first refuses the root. Right.
  • /batch (batchData, case 'delete'): the same per-id engine.delete, the same stop semantics. Right.
  • an import: import-runner.ts deletes nothing. Not a shape of the refusal.
  • the console's bulk delete (objectui main): not an ordered deleteMany, as acceptance note 1 says. ObjectView.onBulkDelete calls actions.deleteRecords, and recordDelete.ts fires one dataSource.delete(objectName, id) per selected row in parallel under Promise.allSettled, then shows one summary toast (bulkDeletePartial, "N deleted, M failed"). A subtree selected whole loses its leaves, keeps its root with a 409, and a second delete of the root succeeds. Nothing is stranded and no batch stops; the refusal's sentence is not shown on that path, only the count (objectui's display, read only). data-objectstack.bulkDelete (deleteMany with continueOnError: true) exists but is not what ObjectView calls; it would complete the children and refuse the root the same way. The dev's conclusion (loud, retryable) holds; the description of the console path is wrong and corrected here.
  • Not reached, unchanged: the engine's predicate branch (a where without id, isPredicatePath) skips the referential pass entirely, as it skipped set_null before. No door exposes it for this object (deleteMany is ids only, fail-closed) and no caller in the tree predicate-deletes sys_business_unit.
  • Context: a unit with MEMBERS was already refused on this head's base: sys_business_unit_member.business_unit_id is a required lookup with no deleteBehavior, which walkReferencingRelations escalates to restrict (requiredSetNull && !multiValued). This PR adds child UNITS to the refusing set; it is the only new refusal.

Platform paths that could meet the refusal, each read in the code:

  • an organization's delete: sys_business_unit.organization_id is Field.lookup('sys_organization', { required: false }) with no deleteBehavior, so set_null: the units stay, parent links untouched; pinned (test 8). Nothing cascades into sys_business_unit: its own relations are parent_business_unit_id (restrict), organization_id and manager_user_id (set_null), none cascade or master_detail, so no cascade set ever holds a unit. Right.
  • the v18 ceremony: organization-ownership-inventory.ts gives sys_business_unit fate attribution (anchor parent_business_unit_id); neither the inventory nor organization-ownership.ts calls a delete. Right.
  • the sample-data purge (marketplace-install-local-purge.ts): rows are collected per object in the loader's insert order (parents first; within one dataset, record order) and deleted as [...match.rows].reverse(), one engine.delete({ where: { id } }) each; a refused row is counted in errors, warned, and the loop continues. The only shipped seed of this object (showcase orgUnits: bu_acme, bu_field_ops, bu_west_coast, bu_east_coast, bu_hq_finance, mode: 'upsert') is parent-first, so the purge deletes leaf to root and completes. Right.
  • seeds, os commands, plugin-sharing: both seed loaders delete nothing; no sys_business_unit delete under packages/cli/src; bu-tree-recompute.ts binds afterInsert / afterUpdate / afterDelete only (priority 190), and no plugin binds a beforeDelete on this object. Right.
  • tests and fixtures: the two objectql suites that delete a sys_business_unit register their own fixture objects (name: 'sys_business_unit', engine-cascade-federated-tenant-anchor.test.ts:308, engine-delete-restricted-locale.test.ts:30); bu-tree-recompute.test.ts drives a fake engine. No existing pin moves; Test Core 1 to 6 green on the head.

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 needs_decision) is not met. Right.

The exemptions. In-set parent and child: moot for this object, since no cascade reaches it; were one ever to, walkReferencingRelations filters dependents by cascadeSetHas(cascadeSet.members, …) before the restrict branch (the #22305 rule), and the by-id delete runs inside one transaction() when planCascadeAtomicity says atomic. Handled as the dev says. A re-parented unit: the probe reads the child's current parent_business_unit_id; after the update no row holds the old id, the relation yields zero dependents and the delete proceeds; pinned (test 4). Right.

The wire answer. In the restrict branch required = fdef.deleteBehavior !== 'restrict' && fdef.required === true is false here, so the key is delete_restricted (not _required); the en template is This {{object}} is still referenced by {{count}} {{dependentObject}} record(s) through “{{field}}”. Delete or reassign them first., rendered with the labels "Business Unit" and "Parent Business Unit": the sentence the dev quotes, byte for byte, with zh-CN / ja-JP / es-ES siblings in the catalog. err.status = 409, err.code = 'DELETE_RESTRICTED', err.dependentObject = 'sys_business_unit', err.dependentCount only when dependentCountIsDisclosable (the caller's own read of the children yields the same ids). The flat REST door carries error, code, dependentObject, dependentCount, developerMessage at the top level; the Hono doors put the last three in error.details. For the administrator the card describes the count is disclosed and the sentence is true and actionable: the blocking object, how many, and the field to re-point. One nuance the object comment and the changeset elide: a caller who cannot read the children gets the _opaque sentence without a count, correct by #12166 and not the administrator addressed. The developerMessage ends "…or set deleteBehavior:'cascade' on sys_business_unit.parent_business_unit_id": engine-generic, true of the engine's option space, addressed to a developer editing metadata, and inert here because the object is managedBy: 'platform' and the card parked cascade rather than ruling it wrong; no user-facing surface renders it (the console toast reads error). Not misleading enough to matter; see ③.

Lost reach vs added access. Before: the parent delete wrote null into each child through the referential clear, and plugin-sharing's afterUpdate recompute withdrew unit_and_subordinates grants from the detached subtree: a silent loss of reach. After: the refusal is thrown before any row is written, inside the delete's own transaction, so no row changes and no after-hook fires (beforeDelete dispatches earlier, and plugin-sharing binds none); the grants anchored above keep reaching the tree. Nothing about the recompute changes: a leaf delete still fires afterDelete, a re-parent still fires afterUpdate, both still revoke and re-grant. There is no path on which restrict leaves a grant stale, because a refused delete changes no membership and no edge; and no access is added, since the post-refusal state is the pre-attempt state. Right.

The help text and comment. en (regenerated: the en.objects.generated.ts leaf equals the new description verbatim), zh-CN, ja-JP and es-ES say the same true thing, that a unit with child units cannot be deleted until they are re-parented or deleted, and nothing false (none promises a count or a code). The object comment is true of the by-id path and names the mechanism (409 DELETE_RESTRICTED, this object, the child count; leaf to root completes). The hand edit of the three translated values is the repo's rule: AGENTS.md's translations row says translated-locale VALUES are hand-written and editing one is fine while keys must not move, and each generated file's header says to re-translate a leaf by hand when its source changes; no key moved; the source-hashes.generated.ts ledgers carry no entry for parent_business_unit_id.help, so the leaf is legacy-trusted and no digest moves. pnpm check:i18n runs in Lint & Repo Gates, green on the head. Right.

The pins. business-unit-parent-delete-restrict.test.ts boots a real ObjectQL over SqlDriver (better-sqlite3 in memory), registers the SHIPPED SysOrganization and SysBusinessUnit from @objectstack/platform-objects/identity (the built declaration, as production reads it), and enters the engine through engine.delete(object, { where: { id } }), the exact call deleteData, runDeleteManyLoop and batchData make. Teeth: test 2 asserts code, status, dependentObject, dependentCount 1 AND the whole tree unchanged; tests 3 to 6 and 8 assert resolves.toBe(true) plus the resulting tree. The ablation's red set, judged from the code: with deleteBehavior: 'restrict' removed the declaration reads set_null (test 1 red), the root delete succeeds and re-roots the child (test 2 red: nothing throws, err is not an Error) and test 7 goes red for the same reason; 3, 4, 5, 6 and 8 stay green, since a leaf, a re-parented unit, leaf-to-root loops and an organization delete behave identically under set_null. 3 failed / 5 passed, as claimed. One nuance: tests 5 to 7 loop engine.delete themselves and do not drive deleteManyData or batchData, so the NOT_ATTEMPTED / continueOnError / atomic envelope remains the protocol's own pins' claim (unchanged); the test names say "the per-id loop of…", which is honest, and the PR body's wire table measured the real loop.

② Semver level

  • The face: record-recognisers --paths on the seven paths reports that the paths decide no face (no published schema, no governed path) and the changeset's Clause-②: line decides; it carries (narrowing), so the narrowing face at CONTRACT_REVIEW_TIER, which this record serves.
  • '@objectstack/platform-objects': minor, BREAKING with FROM / TO / Unchanged / Fix: right. AGENTS.md Post-Task Checklist 3 makes (narrowing) BREAKING and requires the migration text, present; the launch-window convention (check-changeset-no-major) refuses major; Check Changeset success on the head is the gate verdict.
  • Clause-②: no (narrowing) stands at a line start in the changeset (line 7) and in the PR body (line 3); check-changeset-no-major.mjs reads it "in the fixed spelling at the start of a line" from github.event.pull_request.body (pr-automation.yml header). Right.
  • The adr-0087 marker: exactly one HTML-comment marker reading adr-0087: not-required (no-migration-prescription) … in the changeset body, matched by the gate's comment regex (check-adr-0087-registration.mjs:1990) and category regex (:2049); the dev's local exit 0 is consistent with that grammar, and the PR body carries the Clause-② line without a marker, as the dev says. Right.
  • Nothing widens: no export, no spec key (deleteBehavior is an existing authorable key on every lookup), no error code (DELETE_RESTRICTED is an existing ADR-0112 member). The narrowing is exactly one accept set: the delete of a unit that still has child units.

③ Boundary flags

  • Pin file in packages/plugins/plugin-sharing. That package is domain:services' per the lane table (SKILL.md line 162), the card is domain:engine, and the claim's declared surface named "platform-objects' tests (or the engine's, where a real engine is needed)". Placement justified on the dependency facts: platform-objects declares no engine or driver; objectql and core declare no @objectstack/platform-objects, so a pin there could not import the shipped object without widening a fence; plugin-sharing already carries @objectstack/objectql and @objectstack/platform-objects as dependencies and @objectstack/driver-sql with better-sqlite3 as devDependencies, and owns the subtree walk the refusal protects. It is nonetheless a cross-lane, test-only file outside the claim's declared file surface: the seat declares it (the claim comment's file surface) before the queue. No services code moved; No other open PR may claim the same single-writer path is success on the head.
  • Hand-translated values. The repo's rule (AGENTS.md translations row; the generated-file header); keys unmoved; no source-hash entry for the leaf; en regenerated by the tool. Answered.
  • ran.list exit rewritten after the re-run. The first pnpm check:dual-build-cjs-loads exit was 3, the gate's declared EXIT_PREREQUISITE_NOT_MET class (no dist/ for 10 packages), not a verdict; the full build and re-run produced the verdict, exit 0; the report discloses both runs. Recording the measured verdict is right; Lint & Repo Gates on the head is the gate of record anyway.
  • main advanced after the PR's merge. Merge-base 3a7a60af8f; origin/main at 2026-10-20T22:05Z is 7adfaec2a1, six commits on; git merge-tree --write-tree origin/main pr-head exits 0, clean (tree 3a1eefaca2); none of the six touches a file of this diff (their translation hunks are service-messaging's and service-settings'). The queue re-merges on landing. Answered.
  • Five pushes to one branch. WIP pushes before minute-long steps are what AGENTS.md asks for; the PR holds 4 commits (3 plus the main merge). Answered.

The three open_questions.

  1. Bulk order — A, keep, with the facts corrected: the console's bulk delete is N parallel by-id deletes settled together with a summary toast, not an ordered deleteMany; a subtree selected whole loses its leaves, keeps its root with a 409, and a retry finishes: refuse-and-retry, never stranded. No caller in either tree bulk-deletes a subtree root-first; deleteMany root-first answers one DELETE_RESTRICTED row and a NOT_ATTEMPTED tail, loud and retryable. Four axes: no measured business need for B or C; long-term, graph ordering inside a per-id loop is a new protocol semantic for every self-referencing restrict, and the honest answer to "delete the subtree" is the cascade the card parked, so it is a decision, not this card's; AI-proofing: a 409 naming object, count and field beats a silent re-root; startup focus: no pull. Real caller stranded: none.
  2. Purge order — A, note only: the purge reverses the loader's insert order and continues past a refused row (counted in errors, warned); every shipped seed of this object is parent-first; B names no producer.
  3. Docs / checklist follow-up — A, small, as a note or a domain:devx card at the seat's choice: the help text and the refusal sentence already instruct the administrator, administrator-guide.mdx says nothing false, so no filing gate (Prime Directive 10) compels a card; a leaf-first retirement sentence and a platform-checklist item are polish in devx's lane.

The out-of-scope finding (the developerMessage suggesting cascade): meets no filing gate. Not a reproducible defect (the sentence is true of the engine's option space), not a contract violation (the developer half is declared as the unchanged generic sentence at the restrict branch), not a metadata-authoring trap for this object (managedBy: 'platform', so no author acts on it). Noted and not filed is right; if the clause is ever made conditional, that is engine-generic (domain:engine), one line, no card owed by this PR.

Implemented-by: claude/issue-24939-bu-parent-delete-restrict
Reviewed-by: session_01JtSQQ3EWsbNPzyYMug1tXy

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · domain:engine seat 1 · session_01JtSQQ3EWsbNPzyYMug1tXy · 2026-10-20T22:24Z · ACCEPT

Head: cbcab4e10a. It carries the declaration, the help text in four locales, the real-engine pins, the changeset and a merge of main.

Card #24939 (p2, bug, area:access; from objectstack-ai/hotcrm#2207's v18 Setup pass, F-04): claim 6300791873, dispatch 6300848539. Dev report: 6301966819.

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:devx as 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 in plugin-sharing, the hand-translated values (the repo's rule), the ran.list re-run line, and the five pushes.
  • out_of_scope_findings: the engine-generic developerMessage naming cascade meets no filing gate.

Landing: ordinary queue (pr_ready + automerge_enable through the relay). Fixes #24939 then closes the card.


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/m tests tooling

Projects

None yet

2 participants