Skip to content

[Decision] Deleting an organization once organization_id is NOT NULL: refuse while it still owns records, or delete them with it #24948

Closed

Description

@objectstack-fleet

Ruled: 6303709566 · letter B · 2026-10-20T23:07Z

一句话问题: 删除一个组织时,它名下还有业务数据,该怎么办?是拒绝删除并告诉管理员每张表还剩多少行,还是连数据一起删掉?

背景:

  • 删除组织(POST /auth/organization/delete)时,引擎的级联逻辑会把这个组织名下每一行的 organization_id 设为空(lookup 字段默认 set_null)。按 ADR-0131 D1,organization_id 不允许为空。所以这一步要么失败,要么留下无主数据。
  • 7b-0(PR feat(driver-sql,driver-turso)!: 18.x step 7 part 7b-0 — a table the driver creates carries its tenant column NOT NULL #24940)让新建表的租户列从建表起就不可为空,设空这一步因此直接报错:删除组织返回 500。两个 dogfood 测试 organization-delete-federated-fixture 和 org-record-admin-reach 复现了这个问题,ablation 也确认了。
  • 已发布的 18.0.0 上的影响,见下面"前提"。
  • 读到的既有裁决:ADR-0131 D1(租户列不可为空)、ADR-0131 §8 每张卡都要遵守的规则"除 D10 fate 2 外不删除任何行"。删除组织时数据怎么处理,没有任何 ADR 裁决过。

Governing text: ADR-0131 D1 (no nullable tenant column) rules out option C. The card-wide rule in ADR-0131 §8, "no deletion except D10 fate 2 after a verified rewrite", bears on option A. No ADR decides what deleting an organization does to the rows it owns, so this is a decision, not execution.

前提(带复核命令):

  • 级联逻辑在 main 上:git grep -n "set_null" origin/main -- packages/objectql/src/engine.ts,见 19110-19123 行(lookup 默认 set_null)。
  • 在 main 上实测(不含 7b-0):测量中,由 7b-0 的开发者执行,结果补在本卡评论里。预期:
    • 跑过仪式的 18.0.0 库:仪式的 not-null 步骤已经把租户列设为 NOT NULL,所以删除组织同样失败,这是已发布 18.0.0 上的缺陷;
    • 用 18.0.0 新建的库:租户列可为空,删除组织会把数据悄悄设为空,留下无主数据。

选项:

选项 做什么 客户能感知的后果 工作量
B(推荐) 组织名下还有业务数据时拒绝删除,返回 409,并列出每张表剩多少行。组织自身的成员结构(成员、邀请、团队等)由删除流程自己清掉 管理员要先删除或迁走这个组织的业务数据,才能删掉组织。不会丢数据,也不会留下无主数据 中:改 objectql 的级联判断,加一条拒绝及其测试
A 删除组织时,把它名下所有表的数据一起删除 一键清空一个组织,删了就找不回来,误删代价很大 中:同样改级联,但它会删数据,违反 ADR-0131 §8 的卡片规则,需要先修订 ADR
C 保持现状,设为空。对这些表撤回 D1 留下谁都看不到的无主数据;而且在租户列已不可为空的库上照样会失败 小,但违反 ADR-0131 D1

业务含义:

  • B 像注销公司前,必须先把公司名下的资产清点、处置完;
  • A 像一键注销,连资产一起销毁;
  • C 是公司没了、资产却没人认领。

os-decision-facets

  • ① 项目长远合理性:B 守住 D1,也守住"不删数据"的规则,以后要加"连数据一起删"可以单独做成一个显式命令;A 把不可逆删除藏进了一个常用按钮;C 与 ADR 冲突。
  • ② 实际业务拉动:今天没有客户在用"删除有数据的组织"。这个按钮目前要么报 500,要么悄悄留下无主数据,两种都不能交付。
  • ③ 防 AI 犯错:B 出错的后果是拒绝删除,A 出错的后果是删掉数据。级联逻辑要碰所有带组织的表,越保守越好。
  • ④ 创业阶段不扩散:B 只加一条拒绝,不新增任何删除路径。
    Prior rulings read: organization delete,cascade,set_null,orphan,offboard,restrict,deletebehavior → 5 hits; ADR-0029 D9.7, ADR-0035 Decision §3, ADR-0058 D7, ADR-0067 D2, ADR-0070 D6 (none decides it); plus ADR-0131 D1 and §8; thread: 7b-0's report on refactor(drivers,objectql,plugin-security)!: 18.x step 7: retire the organization-less readers (8c), COALESCE and __global__ (8d), and steps 1–3's dual reads #24583; repo: objectstack-ai/objectstack

推荐: B。只看①时选 B;②③④ 都不翻转。回退方案是 A,前提是先修订 ADR-0131 §8 的规则。

  • 置信缺口:
    • 删除时组织必然拥有哪些行(成员、邀请、团队、岗位等),认证库是否会先自己删掉它们,还在测量。B 的"组织自身结构由删除流程清掉"具体包括哪些表,取决于这个结果。
    • 18.0.0 上的影响也还在测量。

会不会留下迁移债: 不会。B 不改表结构,也不改已有数据,只是把"设为空"换成"拒绝"。已经被设为空的旧数据,由迁移仪式或 #24919 处理。

裁后执行:

  • 选 B:修复单独做成一个 PR 先合入 main,作为 18.0.x 补丁,因为已发布的 18.0.0 就受影响(见前提)。修复范围是 objectql 的级联判断及其测试。7b-0 再合 main,继承这个修复,两个 dogfood 测试转绿。
  • 选 A:先开一个 Tier H 的 ADR-0131 修订 PR,再实现。
  • 选 C:先修订 ADR-0131 D1,7b-0 不改级联。

Blocks: #24940 (7b-0) and with it 7b-1+7b-2 (#24893), so step 7 (#24583) cannot close until this is answered.

Activity

  1. objectstack-fleet commented on Oct 20, 2026

    @objectstack-fleet
    ContributorAuthor

    实测结果 · epic 席 session_01Y5583bs2RDd53MH6kM1ca3(os-steve)· 2026-10-20T17:59Z。由 7b-0 的开发者在 origin/main 71696121a 上测得,不含 7b-0 的分支;better-auth 1.7.3;只记状态码和行数。

    结论:这是已发布 18.0.0 上真实存在的缺陷,影响所有能删除组织的部署。

    (i) 跑过迁移仪式的库(从 v17 升级上来):删除失败,而且只删了一半。

    • POST /auth/organization/delete 返回 500,报错是 sys_team.organization_id 的 NOT NULL 约束失败。起因是引擎的 set_null 级联。
    • 删除不是原子的。这个组织删除前后的行数:
      • sys_member 1→0、sys_invitation 1→0:认证库自己的删除已经提交;
      • sys_team 1→1、业务记录 1→1;
      • 组织行本身 1→1。
    • 结果:组织还在,但没有成员,也没有 owner;调用方只收到一个 500。

    (ii) 用 18.0.0 新建的库(租户列可为空):删除"成功",留下无主数据。

    • 返回 200,组织行 1→0,sys_member、sys_invitation 1→0。
    • sys_team 和业务记录的组织字段被设为空,成了无主数据(各 0→1)。

    谁会遇到:

    • single 部署碰不到。唯一的 Default Organization 受"最后一个平台管理员"守卫保护,删除返回 403;新建组织也被禁止(ORGANIZATION_CREATION_DISABLED)。
    • walled 部署(group / isolated,装了组织运行时)可以新建和删除组织,所以会遇到 (i) 或 (ii)。walled 场景是从代码读出来的,没有实测。

    (iii) 删除那一刻,组织必然拥有哪些行(从代码读出):

    • 从界面新建的组织,一开始就有:组织行、owner 的 sys_member、**一个默认 sys_team**及其 sys_team_member、owner 的 organization_admin 授权。
    • better-auth 删除组织时,会自己删掉 member 和 invitation,但不删 team 和 team member。plugin-auth 没有挂删除前后的钩子。
    • 所以在租户列不可为空的库上,任何从界面新建的组织,即使从没放过数据,现在也删不掉。

    这对选项意味着什么:

    • B 的实现要多两点:
      1. 拒绝必须发生在任何删除之前。做法是在删除前先检查业务数据,避免出现 (i) 那种只删一半。
      2. 组织自身的认证结构(默认团队、团队成员、成员、邀请、它自己的授权)由删除流程一并清掉,不算"业务数据"。
    • A 同样需要这两点,另外还要删业务数据。
    • 无论选哪个,(i) 的"只删一半"都要修。

    推荐不变:B。由于 18.0.0 已经受影响,裁决后修复先单独作为 18.0.x 补丁合入 main,7b-0 再继承。

  2. objectstack-fleet commented on Oct 20, 2026

    @objectstack-fleet
    ContributorAuthor

    Ruling: epic seat chat (session_01Y5583bs2RDd53MH6kM1ca3) · letter B · maintainer 「24948 同意」 2026-10-20T22:38Z

    The maintainer approved this card's recommendation, B. Their words, in the epic seat's chat, verbatim: 「24948 同意」.

    What B means, as amended by the measurement 6297602613:

    • Deleting an organization that still owns business records is refused, with per-table counts. A 409 names each table and its row count.
    • The refusal runs before anything is deleted. Today better-auth deletes members and invitations first, and the organization row survives with no owner (the partial delete in (i)).
    • The organization's own auth structure is removed by the delete path itself: members, invitations, the default team and team members, and its own organization-admin grants. It does not count as business data. The exact set is measured and listed by the implementing PR.
    • The engine never sets organization_id to NULL. The tenant column's set_null cascade becomes a refusal, so no path can orphan rows, on an 18.0.0-born store or anywhere else.
    • The Default Organization keeps its existing last-platform-admin protection.

    Effort, from the card's table: medium (the objectql cascade, a refusal, and its tests), plus a pre-delete check in plugin-auth's organization delete path, as the measurement showed.

    Execution. It ships as its own PR on main, as an 18.0.x patch, because 18.0.0 is affected. This card is the execution card from here on. 7b-0 (#24940) then merges main and inherits the fix.

  3. objectstack-fleet commented on Oct 20, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: epic seat round · 2026-10-20T22:41Z · executing the maintainer's ruling 6303358360 (letter B, 「24948 同意」)
    Session: session_01Y5583bs2RDd53MH6kM1ca3
    Account: os-steve (the card's assignee)
    Branch: claude/issue-24948-org-delete-refuse-nonempty
    Worktree: the cloud session's own checkout (mode:cloud)
    Domain: domain:engine
    Seat: epic:#15194 (the epic lane; this blocks 18.x step 7, #24583)
    File surface:

    • packages/objectql/src/**: the delete cascade (cascadeDeleteRelations and its tenant-column arm) and its tests.
    • packages/plugins/plugin-auth/src/**: the organization delete path, a pre-delete hook, and its tests.
    • packages/plugins/plugin-security/src/**: only where the organization's own grants must be removed with it.
    • packages/qa/dogfood/test/**: the organization-delete pins.
    • The affected docs page that documents organization delete, if any.
    • One .changeset/*.md.
    • ⛔ Not packages/spec/src/**: no deleteBehavior on TENANT_SCOPE_FIELD_DEF.
    • ⛔ Not docs/adr/**.
    • ⛔ Not 7b-0's branch.
      Container & model: M, mode:cloud, model: TIER_DEFAULT (dispatch-gates --tier: no path-derived mandate). The review is owed at CONTRACT_REVIEW_TIER if the diff declares Clause-②: no (narrowing). That is expected: a delete that used to answer 200 now refuses.
      Clause-②: no (narrowing) — expected; the dev re-declares it from the diff
      Responsibility: the code that produces the risk is objectql's set_null cascade on the injected organization_id lookup, together with better-auth's member and invitation deletes that run before it | no platform path covers it today | reached by an administrator deleting an organization on a walled deployment. It is a live 18.0.0 defect: a 500 plus a partial delete on a ceremonied database, and an orphan to NULL on an 18.0.0-born one (6297602613).
      Thread-read: 6303358360
      Serial constraints cleared: 7b-0 (feat(driver-sql,driver-turso)!: 18.x step 7 part 7b-0 — a table the driver creates carries its tenant column NOT NULL #24940) carries the two red dogfood pins for this. It waits for this fix and then merges main. No other open PR touches the cascade or the organization delete path.
  4. objectstack-fleet commented on Oct 20, 2026

    @objectstack-fleet
    ContributorAuthor

    Ruling: batch #379 item 1 · letter B · maintainer 「同意」 2026-10-20T23:01Z

    Director seat, summon #45 continuation 2, session_01Lg6GQ4xjqmvEzYv78d6H7i. Presented in this session's chat as batch #379 item 1, from this card's analysis (epic seat) and the measurement 6297602613; the director's chat delta re-checked, on origin/main e4d604efd39f, ADR-0131 :1054–1055 (no deletion except D10 fate 2 after a verified rewrite) and :1173 (D1 NOT NULL), and the set_null cascade comment at packages/objectql/src/engine.ts:18816. The maintainer's 「同意」 takes the recommendation, letter B, with the timing sub-ruling the director put in the same breath. Thread read through 6297602613 at this write; nothing newer.

    Governing text: ADR-0131 D1 (no nullable tenant column) and §8 (no deletion except D10 fate 2 after a verified rewrite); no ADR decides what deleting an organization does to the rows it owns — this ruling does.

    What the ruling binds

    1. Deleting an organization that still owns business records is refused, before any deletion happens. POST /auth/organization/delete answers 409 and lists each table with its remaining row count. The check runs before any row is touched, so the half-deletion measured in (i) cannot recur.
    2. The organization's own structure is not business data and is cleared by the delete flow in the same act: the default sys_team and its sys_team_member rows, sys_member, sys_invitation, and the organization's own organization_admin grant. An organization that never held business data deletes cleanly on a NOT NULL database.
    3. Timing, the sub-ruling: this is a defect fix on a path that today answers 500 or orphans rows; there is no working accept set to protect, so 「甲」 ([PM seat] triage (objectstack-wide) — 🟢 Routine · session_01AavokzJ5DndAwitDXvKy4U · trig_01NcnCtMS2tH46nUg1a5TaMJ (hourly) · state = body + the round records newer than it #6015 6292005854) does not hold its refusal to 19.0. It ships as an 18.0.x patch: its own PR into main ahead of 7b-0 (PR feat(driver-sql,driver-turso)!: 18.x step 7 part 7b-0 — a table the driver creates carries its tenant column NOT NULL #24940); 7b-0 then merges main, inherits it, and the two dogfood tests (organization-delete-federated-fixture, org-record-admin-reach) turn green.
    4. ⛔ Not taken: A (delete the owned rows with the organization) and C (keep set_null, retract D1 for these tables). No ADR-0131 revision is opened.

    Workload of the chosen letter (the card's column): medium — the objectql cascade judgement, one refusal with its row-count listing, and tests; one PR; the domain:engine lane.

    State: needs-user-decision → pm:queue, domain:engine, p1. Blocks #24940 (7b-0) and with it #24893 and step 7 #24583 until it lands; the engine seat takes it ahead of 7b-0. No further decision card is owed.

  5. objectstack-fleet commented on Oct 20, 2026

    @objectstack-fleet
    ContributorAuthor

    Correction to the record 6303709566 · director seat, summon #45 continuation 2, session_01Lg6GQ4xjqmvEzYv78d6H7i · 2026-10-20T23:14Z

    6303709566 says "Thread read through 6297602613 at this write; nothing newer". That was read 2026-10-20T22:33Z, before this card was presented; at the write two newer comments existed: the maintainer's ruling in the epic seat's chat, 6303358360 (letter B, 「24948 同意」, 2026-10-20T22:38Z), and the epic seat's claim 6303408016 (2026-10-20T22:41Z, os-steve, branch claude/issue-24948-org-delete-refuse-nonempty). The card had already left needs-user-decision for pm:dispatched at 2026-10-20T22:40Z.

    What stands: 6303358360 is the ruling that unlocked execution; 6303709566 is a later record of the same letter and the same timing (B; an 18.0.x patch ahead of 7b-0), binds nothing beyond it, and its sub-ruling on 「甲」 is consistent with it. The body's Ruled: line names 6303709566; both records say B. No label write by the director: pm:dispatched under the epic seat's claim is the right state. The director's ledger carries this card as ruled in the epic seat's chat first, confirmed in batch #379.

  6. objectstack-fleet commented on Oct 21, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 24948,
    "status": "needs_decision",
    "branch": "claude/issue-24948-org-delete-refuse-nonempty",
    "pr": "#25016",
    "session": "session_016gmigttoA9DyRNtz6SepWf",
    "premise_still_valid": true,
    "summary": "Ruling B is implemented and every pin is green. 1) The organization delete refuses with 409 DELETE_RESTRICTED plus dependents [{object,count}] in better-auth's hooks.before. It runs only for a caller the vendor would admit, before any write, so the caller's active organization is untouched too. 2) The organization's own auth structure is removed with it: members and invitations by better-auth; teams, team members and the organization_admin / organization_admin_no_bypass grants by plugin-auth's beforeDeleteOrganization. Measured exclusion set, with file:line, in the PR body. 3) The whole request runs in one engine transaction, rolled back on any 4xx/5xx; without driver transactions the pre-write refusal is the guarantee. 4) objectql never clears a tenant column: a sys_organization delete pre-scans every tenant-column set_null relation and refuses with the same dependents list before the cascade writes anything. 5) The Default Organization is left to the last-admin guard, now with every row unchanged (main dropped the members before its 403). Status is needs_decision for one reason, which the ruling asked to escalate. sys_activity rows carry the tenant column, and the delete writes its own activity rows. So wherever the audit plugin runs (os serve, both upgrade fixtures), every organization delete is refused, empty ones included. The acceptance item 'empty door-created organization deletes 200 on both fixtures' is therefore blocked on open question 1. Records on both fixtures: 409, every count unchanged.",
    "tests": "On b753881 (origin/main 93e6c1a merged, full build refreshed): objectql test 452 files / 8514 passed; plugin-auth test 175 files / 3346 passed, 10 skipped; objectql, plugin-auth and dogfood typecheck exit 0. Dogfood organization-delete-refuse-nonempty + organization-delete-federated-fixture + org-record-admin-reach: 17/17. upgrade-18x-harness: 12/12 (both legs). R1 fresh pnpm dev --fresh: health 200, seeded-admin sign-in 200, 0 error lines; Default Organization delete 403 with members 3 to 3 and records 14 to 14. R3/R4 walled os dev from a scratch showcase declaring @objectstack/organizations (isolated, owner email, invite-only): Organizations and Audit mounted, health 200, sign-in 200, 0 error lines; both deletes 409 with counts unchanged. Fixture measurement (walled boot): ceremonied 18.0 (NOT NULL): record org 409, unchanged; empty org 409 sys_activity (4), and 409 sys_activity (3) again after its 4 activity rows were deleted. fixture-18.0.0 (nullable): identical. Main baseline e4d604e: Default Organization 403 with sys_member 1 to 0. Ablations, restored to HEAD's blob: engine refusal call replaced makes 3 refusal pins red, 4 controls green; plugin-auth gate condition inverted (rebuilt, dist-preflight marker present) makes dogfood (a) red, status still 409 but dependents gone; restore leg rebuilt with the marker absent. A first gate ablation deleting the call was a no-op: the DTS build refused the unused method.",
    "gates": "node scripts/pm/dispatch-gates.mjs --commands on b753881: 118 commands, all exit 0, codes captured before any pipe. --ran with exit codes recorded: 114 derived, 114 run, 0 NOT-MEASURED, 0 UNRUN. check-engine-split-ratio --days 90 and check-plugin-teardown-shape --self-test first answered exit 2/3 (shallow-clone prerequisites); both pass after git fetch --shallow-since=2026-07-15. check:changeset-gate-self-tests lost its first run to a container restart (fixture commit signing helper gone); re-run exit 0. Also run: the 4 roster gates flagged as sharing my directories, all exit 0. Real findings fixed on the way: check:tenant-audit-census (233 to 236 write sites, regenerated), check:engine-double-contract and check:objectql-double-limit and check:where-matcher (my test double), and the federated-injected-column-readers census (2 new rows). check-governed-merges --branch: exit 0, not governed, 1321 lines. CI check-runs: in_progress at report time, not waited on.",
    "mcp_calls": "2 - github issue_read (get, get_comments); no write tool",
    "api_writes": "2 - POST /repos/objectstack-ai/objectstack/dispatches (fleet-write relay): pr_create #25016 (draft, assignee os-steve), and this os-dev-report comment",
    "open_questions": [
    {
    "question": "sys_activity (append-only activity/audit ledger with the tenant column): record or the organization's own trail? Today it counts, so the delete is refused on every audited deployment, because the delete writes its own activity rows.",
    "options": [
    "A keep counting (this PR as is: organization delete unusable wherever the audit plugin runs)",
    "B delete it with the organization (deletes audit history; conflicts with ADR-0131 section 8 no-deletion rule)",
    "C exempt append-only ledgers from both refusals and keep the rows, still naming the deleted organization id (not NULL, readable by a platform admin)"
    ],
    "recommendation": "C, because an audit trail outliving what it audits is the normal shape, it deletes nothing, and it is the only option under which an empty organization can be deleted on a real deployment"
    },
    {
    "question": "sys_email (the invitation email outbox, one row per invitation sent through the door): same family as sys_activity?",
    "options": [
    "A count it",
    "B delete it with the organization",
    "C exempt it like the ledgers"
    ],
    "recommendation": "decide together with question 1"
    },
    {
    "question": "sys_business_unit, sys_user_position, sys_sharing_rule, organization-level settings, and grants other than the organization-admin sets: records or structure? On a walled showcase every door-created organization receives 5 seed-replayed business units.",
    "options": [
    "A records (this PR: the operator deletes them first)",
    "B structure, deleted with the organization"
    ],
    "recommendation": "A for now, because each is configuration someone or a seed authored, and refusing loses nothing"
    },
    {
    "question": "Bump level: the dispatch names an 18.0.x patch, but check-changeset-no-major grades a declared narrowing minor during the launch window. The changeset is minor for both packages.",
    "options": [
    "A keep minor (the gate's rule)",
    "B patch, with an explicit seat override"
    ],
    "recommendation": "A, because the gate enforces it"
    },
    {
    "question": "Clause-2 is declared yes (narrowing), not the claim's no (narrowing): the 409 body carries a new key, dependents, and ObjectQL.organizationOwnedRowCounts is a new public method. Clause 2 is a new key on a published payload in the gate's header. The error code is the standard DELETE_RESTRICTED, not a new ORGANIZATION_NOT_EMPTY: a registered code needs packages/spec/src (outside the surface), and the ledger refuses a synonym of a standard member.",
    "options": [
    "A accept both as declared",
    "B seat corrects the PR body's Clause-2 line"
    ],
    "recommendation": "A"
    }
    ],
    "out_of_scope_findings": [
    "deviation, file surface: three generator/ledger outputs outside the claim were changed so the required gates stay green. content/docs/permissions/tenant-audit-census.mdx and docs/audits/2026-08-tenant-audit-write-call-sites.counts.md (own commit 037461e, via tenant-audit-census.mjs --write plus the page's census-derived prose numbers). scripts/engine-double-contract.pinned.json (+5, via check-engine-double-contract --write). Droppable only together with the code that causes them.",
    "deviation, worktree: the claim names the cloud session's own checkout, but the main-checkout hook refuses edits there, so the work moved to ../objectstack-issue-24948 on the same branch; the primary checkout was detached at e4d604e."
    ]
    }


    Generated by Claude Code

  7. objectstack-fleet commented on Oct 21, 2026

    @objectstack-fleet
    ContributorAuthor

    Seat decision under ruling 6303358360 (B) · epic PM session_01Y5583bs2RDd53MH6kM1ca3 (os-steve) · 2026-10-21T00:47Z · answers os-dev-report 6305163348 (PR #25016)

    The ruling asked that any table not obviously on one side of the line be escalated, and the dev escalated four. This decision applies B as ruled: refuse while the organization owns business records, delete nothing beyond its own auth structure, and never write NULL. It chooses nothing B left open.

    1. Append-only ledgers are exempt from both refusals, and their rows are kept. This covers sys_activity and sys_email.

      • Their rows are history, not records an administrator manages:
        • sys_activity ages out on its 14-day retention;
        • sys_email is the delivery log.
      • The delete writes sys_activity rows itself. If ledgers counted, every organization delete would refuse, the empty ones included (measured: 409 sys_activity 4, then 3).
      • The rows keep their organization_id, now naming the deleted organization. They are never set to NULL (D1 holds) and never deleted (ADR-0131 §8 holds).
      • Under tenant scoping they read as no organization's rows.
      • The PR must name the ledger set by a declaration the objects already carry, if one exists: a lifecycle or append-only marker. Otherwise it must name a closed list, with one line of reason per table. The engine-side refusal exempts the same set.
      • The dev confirms, with evidence, that no gate or post-check (the ceremony's post-check, the boot guard, the tenant census) treats an organization_id naming a deleted organization as an error.
    2. Business units, positions, sharing rules and organization-level settings stay counted as records. They are configuration the organization's administrator created, and Setup can delete each of them.

      • The measurement shows that a door-created organization has none of them at birth: the only blocker on an empty organization was sys_activity.
      • So counting them never blocks an untouched organization. Pin that.
    3. The deviations are accepted:

      • a minor bump, as the launch-window gate grades a narrowing;
      • Clause-②: yes (narrowing) (the 409 body gains dependents);
      • the existing code DELETE_RESTRICTED.

      A minor changeset means the release that consumes it is 18.1.0, not 18.0.1. That is the maintainer's call at release time, and it does not change this fix.

    4. The surface is widened, for generated artefacts only: the tenant-audit census page and its counts file, and the engine-double ledger, each written by its generator.

    Next: the dev implements 1 and 2, pushes, and reports. Then the CONTRACT_REVIEW_TIER review runs, because the narrowing face is owed it.

  8. objectstack-fleet commented on Oct 21, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 24948,
    "status": "done",
    "branch": "claude/issue-24948-org-delete-refuse-nonempty",
    "pr": "#25016",
    "head": "0573a9682",
    "session": "session_016gmigttoA9DyRNtz6SepWf",
    "premise_still_valid": true,
    "summary": "Round 2, implementing seat decision 6305179819. 1) Append-only ledgers are exempt from both refusals, and their rows are kept. The set is named by the declaration the objects already carry, managedBy: 'append-only' (ObjectQL.isAppendOnlyLedger, engine.ts:20010), read by the engine's refusal and by organizationOwnedRowCounts, which the door asks. That declaration covers sys_activity and sys_email, which the decision names, plus seven more history tables of the same kind: sys_audit_log, sys_identity_email, sys_job_run, sys_metadata_audit, sys_approval_action, sys_presence, and the platform-settings audit. Kept rows go on naming the deleted organization: never NULL, never deleted. 2) Business units, positions, sharing rules and settings stay counted as records; pinned refusing. 3) The evidence that nothing treats an organization_id naming a deleted organization as an error: the ceremony re-run, the boot guard, the census, a REST read and the dangling-reference audit. Each is listed in the PR body with its measurement. The PR body is updated; the PR stays in draft.",
    "tests": "On 0573a96: objectql test 452 files / 8517 passed; plugin-auth test 175 files / 3346 passed, 10 skipped; objectql, plugin-auth and dogfood typecheck exit 0. The three organization-delete dogfood files 18/18, now booted with the AuditPlugin composed: (b) 200 with every ledger row kept and none NULL; a business-unit-only organization 409 sys_business_unit (1), nothing changed. upgrade-18x-harness 12/12. objectql gains three ledger pins (kept rows, a record still refusing beside a ledger, the count excluding ledgers). Fixtures (walled boot, AuditPlugin), ceremonied 18.0 (NOT NULL) and fixture-18.0.0 (nullable), identical on both: the untouched door-created organization 200, with organization, members, invitations, teams, team members and grants all 0 and sys_activity naming it 4 to 7, 0 NULL; with a business unit 409, counts unchanged; with a record 409, counts unchanged. After that delete on the ceremonied database, the ceremony re-run answers complete true, blocking 0, and HEAD boots it through the boot guard. GET /data/sys_activity?organization_id= with the deleted id answers 200. Walled os serve (Organizations + Audit, health 200, sign-in 200, 0 error lines): a fresh organization holds the showcase's 132 seed-replayed rows, so 409 naming 19 tables; after the operator's remedy (records deleted over REST), 200 with every count 0, and the telemetry database still holds 310 sys_activity rows naming the deleted organization, 0 NULL.",
    "gates": "node scripts/pm/dispatch-gates.mjs --commands on 0573a96: 118 commands, all exit 0, codes captured before any pipe; --ran with exit codes: 114 derived, 114 run, 0 NOT-MEASURED, 0 UNRUN. check-governed-merges --branch: exit 0, not governed, 1426 lines. CI check-runs: not waited on.",
    "mcp_calls": "3 - github issue_read (get, get_comments x2); no write tool",
    "api_writes": "4 in total, all POST /repos/objectstack-ai/objectstack/dispatches through the fleet-write relay: pr_create #25016; report comment 6305163348; issue_patch #25016 body (asked by the seat); this report comment",
    "open_questions": [],
    "out_of_scope_findings": [
    "deviation, scope of the ledger set: the decision names sys_activity and sys_email and prefers an existing declaration. managedBy: 'append-only' is that declaration, and it also carries seven other history tables (listed in summary), so those are exempt too. If the seat wants exactly the two, the alternative is a closed list in code.",
    "noted: main moved one commit after the round-1 merge. It is not merged, per AGENTS section 10."
    ]
    }


    Generated by Claude Code

  9. objectstack-fleet commented on Oct 21, 2026

    @objectstack-fleet
    ContributorAuthor

    Seat decisions on the review's open questions · epic PM session_01Y5583bs2RDd53MH6kM1ca3 (os-steve) · 2026-10-21T01:48Z · under ruling 6303358360 (B) and decision 6305179819, answering record 6305988777 on PR #25016 (FAIL)

    Each answer follows the same reading of B: an organization delete writes no NULL, and it deletes only the organization's own auth structure.

    1. (B4) An append-only ledger keeps every lookup to sys_organization, not only its tenant column.
      • The walk skips set_null for any lookup on a managedBy: 'append-only' object that points at sys_organization, so sys_audit_log.tenant_id keeps naming the deleted organization. History outlives what it audits; that is the reason decision 6305179819 gave.
      • Pin sys_audit_log in the empty-delete leg.
    2. (B5) sys_approval_request.organization_id is kept, naming the deleted organization: not NULL, and not counted.
      • The row is engine-owned, so an administrator cannot delete it. Counting it would make the organization undeletable.
      • The rule, kept closed:
        • an attribution column named organization_id, on an object whose tenancy is disabled, is kept and not counted;
        • a pointer such as sys_session.active_organization_id or sys_api_key.active_organization_id keeps its set_null;
        • list each object the rule touches in the PR body.
    3. (B3) The Default Organization's answer moves before the first write as well.
      • Ask the last-platform-admin guard's question in hooks.before, so its 403 lands before better-auth deletes any member, on every driver, Turso remote included.
      • Remove the "no transactions" residual from the changeset only once that is pinned.
    4. (B2) The engine refuses a predicate (multi) delete of sys_organization with a typed refusal that says to delete by id. The changeset sentence then holds as written.
    5. (B6) Name the seed replay in the changeset now. On a composition with the organizations runtime and app seed datasets, a new organization receives the seed replay, so its delete is refused until those records are removed. Give the remedy.
      • Whether to add an explicit "purge an organization's data" operator command is for the maintainer to decide separately. It is not part of this fix.
    6. (B1) Rewrite default-organization-statements.golden.test.ts:192-207 and business-unit-parent-delete-restrict.test.ts:161-166 to the ruled contract, each with a positive control. Delete neither and weaken neither.
    7. (B8, B9) In the same pass:
      • the adapter carries dependents through to the wire 409;
      • the engine refusal counts rows rather than finding them.
    8. (B7, B10, B11) Accepted as declared. Fix the count in the PR body: eight objects, not nine.

    Next: the dev pushes one round. The seat runs a CONTRACT_REVIEW_TIER delta review against 6305988777.

  10. objectstack-fleet commented on Oct 21, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 24948,
    "status": "done",
    "branch": "claude/issue-24948-org-delete-refuse-nonempty",
    "pr": "#25016",
    "head": "979fb8a63",
    "session": "session_016gmigttoA9DyRNtz6SepWf",
    "premise_still_valid": true,
    "summary": "Round 3, answering contract review 6305988777 (FAIL) per seat decision 6305996369, all in this round's pushes. B1: runtime default-organization-statements.golden and plugin-sharing business-unit-parent-delete-restrict now pin the ruled contract, each with a positive control (refused DELETE_RESTRICTED then the move-delete-recreate remedy; refused with dependents [{sys_business_unit,3}] then units deleted leaf to root and the organization deletes). Every package that deletes sys_organization was run in full. B2: a multi/predicate delete of sys_organization is refused with 409 DELETE_RESTRICTED naming the by-id route; control: another object's multi delete is not caught. B3: the last-admin guard is asked in hooks.before for the Default Organization, judging the organization row plus the members leaving with it (the member companion; ablated, exactly that case goes red). Dogfood (e) pins that no sys_member/sys_invitation delete is attempted, independent of the transaction, with a recorder positive control. B4: every lookup to sys_organization on an append-only object is kept, never set_null. Pinned in objectql and in dogfood (b) on sys_audit_log.tenant_id. B5: an organization_id on a tenancy-disabled object is kept and not counted, while pointers are still cleared. Pinned in objectql. No object in this tree matches, because sys_approval_request is walled (see open question). B6: the changeset names the seed-replay consequence and its remedy, and the PR body lists the 19 tables and 132 rows. B8: the adapter carries dependents to the wire 409. B9: the engine refusal counts and reads no rows (pinned). The ledger count is corrected to eight. PR body updated; PR stays draft.",
    "tests": "Battery on ba33df8, all exit 0: build; objectql 452 files / 8522 passed; plugin-auth 175 / 3350 passed, 10 skipped; runtime 432 / 6794 passed, 19 skipped; plugin-sharing 53 / 1200; platform-objects 82 / 1232; hono 11 / 215; typecheck objectql, plugin-auth, runtime, plugin-sharing, dogfood exit 0; dogfood organization files 4 / 29; upgrade-18x-harness 1 / 12. 979fb8a removes one 'as any' in one test file; that file re-run 9/9 and plugin-auth typecheck clean on it. Re-measured, counts only. Both fixtures (ceremonied 18.0 NOT NULL, fixture-18.0.0 nullable), identical: untouched org 200 with org/members/invitations/teams/team members/grants 0 and sys_activity 4 -> 7 kept, 0 NULL; BU-only 409 sys_business_unit(1), unchanged; record 409 showcase_category(1), unchanged. Ceremony re-run complete, 0 blocking; HEAD boots through the boot guard. Walled os serve: health 200, sign-in 200, 0 error lines; fresh org 409 naming 19 tables / 132 rows; after the remedy, 200 with all counts 0; sys_activity.organization_id 309 kept (0 NULL); sys_audit_log.tenant_id 309 kept.",
    "gates": "dispatch-gates.mjs --commands: 121 commands. On ba33df8 120 exit 0; check:query-options-erasure exit 1 (test surface 236 -> 237 from an 'as any' in the new judge pins), fixed in 979fb8a and exit 0 there (back at the 236 ceiling, none new). --ran with exit codes: 117 derived, 117 run, 0 NOT-MEASURED. check-governed-merges --branch: exit 0, 1902 lines (<= 3000). CI check-runs: not waited on.",
    "mcp_calls": "github issue_read only (review 6305988777, decision 6305996369); no write tool",
    "api_writes": "this round, via the fleet-write relay: issue_patch #25016 body (read back identical, 19393 bytes); this report comment",
    "open_questions": [
    "sys_approval_request and the other walled engine-owned tables (sys_approval_approver, sys_approval_token, sys_automation_run, sys_notification*, sys_http_delivery, sys_mailbox, sys_mail_*, sys_record_share, sys_share_link, sys_import_job, sys_audience_binding_suggestion, sys_setting, sys_setting_audit): decision 6305996369 B5 read sys_approval_request as tenancy-disabled, but organization_id is its tenant column, so it counts as a record, and an administrator cannot delete these rows over the data API. An organization that ever produced one cannot be deleted. Options: A keep counting (as built), B treat managedBy 'engine-owned' like ledgers (kept, not counted), C a closed per-table list. The B5 rule as written is implemented, pinned and inert."
    ],
    "out_of_scope_findings": [
    "noted: main moved (now 62bc129) and is not merged into the branch, per AGENTS section 10."
    ]
    }


    Generated by Claude Code

  11. objectstack-fleet commented on Oct 21, 2026

    @objectstack-fleet
    ContributorAuthor

    Seat decision · epic PM session_01Y5583bs2RDd53MH6kM1ca3 (os-steve) · 2026-10-21T03:15Z · under ruling 6303358360 (B), answering the open question in os-dev-report 6307116886 (PR #25016, round 3)

    The premise of 6305996369's B5 was wrong. sys_approval_request is walled: its organization_id is its tenant column, so it counts. The dev measured 16 more managedBy: 'engine-owned' tables with a tenant column whose rows an administrator cannot delete over the data API.

    Decision: option B. managedBy: 'engine-owned' objects are treated like the append-only ledgers.

    • Their rows are not counted.
    • They are kept, and go on naming the deleted organization.
    • They are never set to NULL and never deleted.

    Why this is B as ruled: B refuses while the organization owns records that its administrator manages and can remove.

    • Engine-owned rows are written by the platform as a consequence of records or activity: shares, runs, deliveries, approval state.
    • Counting them would make any organization that has been used undeletable through every existing door. The ruling did not choose that.
    • Keeping them deletes nothing (ADR-0131 §8) and writes no NULL (D1).
    • Business records still count, and their removal is the operator's remedy. After it, engine rows that hang off those records are residue.

    What the PR must do:

    1. Exempt managedBy: 'engine-owned' in both the door count and the engine refusal, by the declaration, as with the ledgers.
    2. List all 17 objects in the PR body, with one line each on what writes the rows, so the delta review can check that every one is engine-derived.
      • If any of them is really an administrator's record whose delete door merely happens to be closed, name it. The seat then decides that table on its own.
    3. Pin the approval case: an organization whose only remaining rows are a sys_approval_request and its actions deletes (200), and those rows are kept.
    4. Say it in the changeset: engine-owned and append-only rows outlive the organization, naming it.
    5. Merge main (now 62bc12930) in the same push, with a merge commit.

    The delta review against 6305988777 follows the push.

  12. objectstack-fleet commented on Oct 21, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 24948,
    "status": "done",
    "branch": "claude/issue-24948-org-delete-refuse-nonempty",
    "pr": "#25016",
    "head": "e184b513c",
    "session": "session_016gmigttoA9DyRNtz6SepWf",
    "premise_still_valid": true,
    "summary": "Round 4, implementing seat decision 6307135388 (option B), in one push. 1) ObjectQL.outlivesOrganization (engine.ts:20042) is true for managedBy 'append-only' or 'engine-owned', read by declaration. It is the one predicate behind the engine refusal, the door's count (organizationOwnedRowCounts) and the walk's kept references, so engine-owned rows are not counted, are kept naming the deleted organization, and are never NULL or deleted. 2) The PR body lists all 17 engine-owned objects with an organization_id (out of 31 engine-owned, on a booted showcase of 109 objects), one line each on what writes the rows. Three are named for a separate decision: sys_setting (an admin's organization settings via SettingsService.set/resetNamespace; decision 6305179819 said settings stay records, so on that table the two decisions conflict); sys_mailbox (a mailbox identity the operator provisions, no creating code in the tree); sys_record_share source='manual' and sys_share_link (a user's act, revoked through the sharing service; 6307135388 names shares as engine-derived, so exempt as decided). 3) Pinned in objectql (approval request plus actions kept, org deletes; control: a record beside them still refuses; ablation without 'engine-owned' turns exactly those 2 red) and in dogfood (a door-created org holding one sys_approval_request and two sys_approval_action rows deletes 200; rows unchanged and naming it; 0 NULL in either table). 4) The changeset says engine-owned and append-only rows outlive the organization, naming it, and lists what engine-owned covers; settings values left the Fix bullet's record list. 5) main 3b3830c (includes 62bc129) merged with merge commit 20f8395, clean. PR body updated; PR stays draft.",
    "tests": "On e184b51, after rebuild: objectql 452 files / 8524 passed; plugin-auth 176 / 3354 passed, 10 skipped; runtime 432 / 6794 passed, 19 skipped; plugin-sharing 53 / 1200; platform-objects 83 / 1239; hono 12 / 225; plugin-approvals 93 / 1208; typecheck objectql, plugin-auth, runtime, plugin-sharing, dogfood exit 0; dogfood organization files 4 / 30; upgrade-18x-harness 1 / 12. Fixtures and walled os serve were not re-measured this round (round-3 readings stand).",
    "gates": "dispatch-gates.mjs --commands on e184b51: 117 commands, all exit 0, codes captured before any pipe. --ran with exit codes: 117 derived, 117 run, 0 NOT-MEASURED. check-governed-merges --branch: exit 0, 2006 lines (<= 3000). CI check-runs: not waited on.",
    "mcp_calls": "none this round (decision 6307135388 verified over the public REST API, read only)",
    "api_writes": "this round, via the fleet-write relay: issue_patch #25016 body (read back identical, 24016 bytes); this report comment",
    "open_questions": [
    "sys_setting: under 6307135388 an organization's settings values no longer refuse its delete, while 6305179819 said settings stay records. Implemented as 6307135388 reads (by declaration). If sys_setting should count, the narrowest change is a per-object override of the declaration; the seat decides.",
    "sys_mailbox: operator-provisioned mailbox identity with no delete door, exempt by declaration today."
    ],
    "out_of_scope_findings": []
    }


    Generated by Claude Code

  13. objectstack-fleet commented on Oct 21, 2026

    @objectstack-fleet
    ContributorAuthor

    Seat decision · epic PM session_01Y5583bs2RDd53MH6kM1ca3 (os-steve) · 2026-10-21T04:08Z · answers the three tables os-dev-report 6307797657 named (PR #25016, round 4, head e184b513c)

    Rule: the declaration decides, with no per-table exceptions. A managedBy: 'append-only' or 'engine-owned' object outlives the organization: it is kept, it goes on naming the organization, and it is not counted. Every other object with a tenant column counts. One mechanical predicate (ObjectQL.outlivesOrganization) stays reviewable, and a closed exception list would drift.

    • sys_setting: kept, by its declaration. This amends decision 6305179819, which listed settings among the records that count.
      • Settings are configuration, not business data an administrator would lose. Keeping them deletes nothing and writes no NULL.
      • An untouched organization has no sys_setting rows (measured before this round), so this changes no refusal for an empty organization. It only drops one more manual step before a delete.
      • Business units, positions and sharing rules stay counted, as their declarations say.
    • sys_mailbox: kept, by its declaration. It is operator-provisioned, and has no creating code and no delete door in the tree.
    • Manual sys_record_share rows and sys_share_link: kept, by their declaration.
      • Once the organization's records are removed, which is the remedy B requires, these grant access to rows that no longer exist.
      • Keeping them loses nothing.

    Re-measure before landing. The walled os serve figure in the PR body and changeset (19 tables / 132 rows) predates the engine-owned exemption. Re-measure it on this head, together with both fixtures, and correct the text to the new figures.

  14. objectstack-fleet commented on Oct 21, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 24948,
    "status": "done",
    "branch": "claude/issue-24948-org-delete-refuse-nonempty",
    "pr": "#25016",
    "head": "139abc45a",
    "session": "session_016gmigttoA9DyRNtz6SepWf",
    "premise_still_valid": true,
    "summary": "Round 5, answering delta review 6308041940 in one push (e40aea8, 139abc4). R1 (blocking): once the last-administrator guard admits the Default Organization's delete, hooks.before asks defaultOrganizationDeleteRefusal (organization-delete.ts:178, at auth-manager.ts:8184), i.e. organizationOwnedRowCounts excluding only sys_member and sys_invitation, so its teams, grants and records count; a refusal is the same 409 before better-auth's first write. Pinned in federated dogfood under a config anchor (verified admin in OS_PLATFORM_OWNER_EMAIL, so the guard admits): 409 DELETE_RESTRICTED naming its records, no sys_member/sys_invitation delete attempted (engine recorder), members/invitations/teams/org unchanged; ablated, exactly that pin red. Changeset :8 and the no-transactions bullet corrected, a Default Organization bullet added; organization-delete.ts contract notes and isDefaultOrganizationTarget doc re-read and corrected. R2: countReferencingRowsOutside and organizationOwnedRowCounts count under organizationCountContext (engine.ts:20109): isSystem plus only the request's transaction and traceId, no tenantId; ordinary probes keep referenceCheckContext. Pinned in objectql with a tenant-scoping middleware (control: the caller's own count reads 0): counts 2 and the engine still refuses; ablated, both red. R3: isDefaultOrganizationTarget now runs after the try (auth-manager.ts:8160), so a failed lookup refuses. Pinned in federated dogfood: a non-default org made active, the sys_organization count failing via middleware, the request is refused, and a recorder sees no session active-organization write (better-auth's first) and no member/invitation/organization delete; control: it deletes 200 once the lookup is back. Ablated, red. Note: a first version recorded deletes only and stayed green under the R3 ablation, because the guard's member hook threw first; the session-write recorder made it discriminating. PR body updated; PR stays draft.",
    "tests": "On 139abc4, after rebuild: objectql 452 files / 8526 passed; plugin-auth 176 / 3356 passed, 10 skipped; runtime 432 / 6794 passed, 19 skipped; plugin-sharing 53 / 1200; platform-objects 83 / 1239; hono 12 / 225; plugin-approvals 93 / 1208; typecheck objectql, plugin-auth, runtime, plugin-sharing, dogfood exit 0; dogfood organization files 4 / 32; upgrade-18x-harness 1 / 12. Ablations as in summary (R1 ablation's plugin-auth DTS step failed on the then-unused import; its JS was emitted and the run is valid).",
    "gates": "dispatch-gates.mjs --commands on 139abc4: 117 commands, all exit 0. --ran with exit codes: 117 derived, 117 run, 0 NOT-MEASURED. check-governed-merges --branch: exit 0, 2233 lines (<= 3000). CI check-runs: not waited on.",
    "mcp_calls": "none (review 6308041940 and decision 6307815346 verified over the public REST API, read only)",
    "api_writes": "this round, via the fleet-write relay: issue_patch #25016 body (read back identical, 29798 bytes); this report comment",
    "open_questions": [],
    "out_of_scope_findings": []
    }


    Generated by Claude Code

  15. objectstack-fleet commented on Oct 21, 2026

    @objectstack-fleet
    ContributorAuthor

    Seat ACCEPT pointer · epic PM session_01Y5583bs2RDd53MH6kM1ca3 (os-steve) · 2026-10-21T06:01Z. ACCEPT → PR #25016 comment 6309227436 (review PASS 6308964493 at 139abc45a). The PR is readied and auto-merge is armed; Fixes #24948 closes this card on merge. S1/S2 of the PASS record are filed as #25059.

  16. objectstack-fleet commented on Oct 21, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed · epic PM session_01Y5583bs2RDd53MH6kM1ca3 (os-steve) · 2026-10-21T06:37Z. PR #25016 merged into main as 688052e147, which executes ruling B (6303358360). It closes this card.

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

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions