Skip to content

Commit 688052e

Browse files
fix(objectql, plugin-auth)!: organization delete refuses while records remain, removes its own auth structure as one unit, and never orphans a tenant column (#25016)
Fixes #24948 Clause-②: yes (narrowing) Executes the maintainer's ruling on #24948 (letter B, record 6303358360), against the measurement 6297602613 and the claim 6303408016. Also executes the seat decisions 6305179819 (append-only ledgers exempt and kept; business units, positions, sharing rules and settings stay records; the deviations accepted), 6305996369 (the answers to contract review 6305988777, addressed item by item below) 6307135388 (option B: engine-owned rows are treated like the ledgers; see **Round 4** below) and 6307815346 (the declaration decides, with no per-table exceptions). It also answers delta review 6308041940 (**Round 5** below). ## What changes **1. Refused while the organization owns records, before anything is deleted.** `POST /api/v1/auth/organization/delete` answers `409` with the standard `DELETE_RESTRICTED` code and `dependents: [{ object, count }]`, one entry per table that still names the organization in its tenant column. It carries counts only, never a row value. - The refusal runs in better-auth's global `hooks.before`, ahead of the vendor handler, so a refused delete writes nothing, the caller's active organization included. - It runs only for a caller the vendor itself would admit (a member whose role holds `organization: ['delete']`, asked of the vendor's own `hasPermission`), so a caller the vendor refuses gets the vendor's 403 and no count. - `packages/plugins/plugin-auth/src/auth-manager.ts:2453` (the `hooks.before` branch), `:8122` (`assertOrganizationDeletable`); `packages/plugins/plugin-auth/src/organization-delete.ts:134` (`organizationDeleteRefusal`). **2. The organization's own auth structure goes with it.** Measured on a booted showcase: a door-created organization owns 1 `sys_member`, 1 `sys_team`, 1 `sys_team_member` and 1 `sys_user_permission_set`, and nothing else. The creator's session also names it, through `active_organization_id`, which is a pointer, not a tenant column. | Table | How it names the organization | Removed by | |---|---|---| | `sys_member` | `organization_id`, `packages/platform-objects/src/identity/sys-member.object.ts:312` | better-auth `adapter.deleteOrganization` (better-auth 1.7.3 `dist/plugins/organization/adapter.mjs:370-395`) | | `sys_invitation` | `organization_id`, `sys-invitation.object.ts:255` | better-auth, same call | | `sys_team` (the default team: `teams: { enabled: true }`, `auth-manager.ts:3579`; created by better-auth `routes/crud-org.mjs:106-126`) | `organization_id`, `sys-team.object.ts:133` | plugin-auth `removeOrganizationAuthStructure`, `organization-delete.ts:240`, from `beforeDeleteOrganization`, `auth-manager.ts:3910` | | `sys_team_member` | only through `team_id`, `sys-team-member.object.ts:135` | same, by team, before the teams (`organization-delete.ts:246`) | | `sys_user_permission_set`, **only** grants of `organization_admin` / `organization_admin_no_bypass` | `organization_id`, `packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts:110` | same (`organization-delete.ts:256`); plugin-security's member-delete reconcile did not remove them in time on a booted showcase. Every other grant scoped to the organization counts as a record. | The exclusion list is `ORGANIZATION_AUTH_STRUCTURE`, `organization-delete.ts:94`. **3. Rows that outlive the organization, and the references the delete keeps: never NULL, never deleted, never counted.** The set is read from a declaration the objects already carry, never from a list of names (`ObjectQL.outlivesOrganization`, `engine.ts:20042`). Both the engine refusal and the door's count leave it out, and the walk keeps every reference such a row holds (`keepsOrganizationReference`, `engine.ts:20065`, applied at `:19640`): - **`managedBy: 'append-only'`, the ledgers** (seat decision 6305179819). There are exactly **eight**: `sys_activity`, `sys_audit_log`, `sys_email`, `sys_identity_email`, `sys_job_run`, `sys_metadata_audit`, `sys_approval_action`, `sys_presence`. Four of them hold a reference to an organization: - `sys_activity.organization_id`, `sys_email.organization_id` and `sys_approval_action.organization_id`; - `sys_audit_log`'s attribution `tenant_id` (`systemFields.tenant: false`, so it is not the tenant column). - **`managedBy: 'engine-owned'`, rows the platform writes** (seat decision 6307135388). Seventeen of them have an `organization_id`; they are listed in **Round 4** below. - **An attribution `organization_id` on a tenancy-disabled object** (seat decision 6305996369, B5, the closed rule). Measured over every object registered on a booted showcase with audit, approvals, notifications, mail and sharing composed: **no object matches**. `sys_api_key` is the only tenancy-disabled object with an organization lookup, and its `active_organization_id` is a pointer. - **Pointers keep `set_null`**: `sys_session.active_organization_id`, `sys_api_key.active_organization_id`, `sys_organization.parent_organization_id`. **4. Atomic.** `/organization/delete` runs through the same request-level unit of work as subject erasure (`auth-manager.ts:6740`): one engine transaction, rolled back on any 4xx/5xx. On a driver without transactions (Turso remote, `transactionsUnsupported`) the refusals are the guarantee, because both run before the first write: the record count (1), and the Default Organization's guard question (6). **5. The engine never clears a tenant column.** `refuseOrganizationDeleteWhileOwned` (`engine.ts:20160`, called at `:19624`) refuses a by-id `sys_organization` delete while any tenant-column row outside the cascade set names it. It refuses with `409 DELETE_RESTRICTED` and the same `dependents` list, before the walk cascades or clears anything. - It **counts** each table (`countReferencingRowsOutside`, `:20079`), and never reads rows. The exception is a table the cascade itself deletes rows of, which no platform object is. - A count is disclosed only when the caller's own identity counts the same (`:20122`). - A **predicate (`multi`) delete of `sys_organization` is refused** before any hook or write, with `409 DELETE_RESTRICTED` naming the by-id route (`organizationPredicateDeleteRefusal`, `:754`, applied at `:20509`): only the by-id path runs this referential pass. - The door's 409 and the adapter's mapping of the engine refusal both carry `dependents` to the wire (`packages/plugins/plugin-auth/src/objectql-adapter.ts:596`, `:650`). - Nothing in `packages/spec` changes. **6. The Default Organization.** It is identified by `isDefaultOrganizationTarget` (`organization-delete.ts:216`: slug `default`, else the only organization, never under a wall). That decision is made outside the door's fall-through (`auth-manager.ts:8160`), so a failed lookup refuses instead of handing the delete to better-auth. Its structure is not removed. It is asked two questions, both **in `hooks.before`, before better-auth deletes any member**: 1. The last-administrator guard's question (`auth-manager.ts:8175`), through the guard's own judgement (`LastAdminGuardHandle.judgeOrganizationDelete`, `last-admin-guard.ts:900`, `:1892`, wired in `auth-plugin.ts:1701`). It answers `403 PERMISSION_DENIED` while the delete would leave no platform administrator. 2. Once the guard admits (an organization-less `admin_full_access` grant, or a verified `OS_PLATFORM_OWNER_EMAIL` user, stands), its record count (`defaultOrganizationDeleteRefusal`, `organization-delete.ts:178`, asked at `auth-manager.ts:8184`). This leaves out only the members and invitations better-auth removes, so its teams, its grants and its records count. A refusal is the same `409 DELETE_RESTRICTED`. - Asked before the vendor's writes, the guard judges the whole change: the organization row **and the members that leave with it** (`pendingFor`, `last-admin-guard.ts:640`). Otherwise an owner's membership of the very organization being deleted would still read as standing. - On main the 403 arrived after the members and invitations were gone. ### The wire code: `DELETE_RESTRICTED`, not a new `ORGANIZATION_NOT_EMPTY` A new code needs a row in `packages/spec/src/api/error-code-ledger.zod.ts`, outside the claim's surface. The ledger also refuses a registered synonym of a standard member, and the condition is the standard catalog's `DELETE_RESTRICTED`. Accepted by the seat. ## Review round: contract review 6305988777 (FAIL), answered - **B1 (CI red).** Two suites pinned the superseded contract and are rewritten to the ruled one, each with a positive control; neither is deleted or weakened. - `packages/runtime/src/default-organization-statements.golden.test.ts`, "the Default Organization deleted and recreated". The direct delete is first refused (`DELETE_RESTRICTED`, 409, standing survives). Then the ruled remedy runs: the rows it owns are moved to a holding organization, the organization is deleted and recreated, the rows are moved onto the new one, and the next request reads the new id, as the memo pin asks. - `packages/plugins/plugin-sharing/src/business-unit-parent-delete-restrict.test.ts`. "An organization's delete completes, its units stay" becomes "refused while it owns units, `dependents: [{ sys_business_unit, 3 }]`, tree whole", plus the CONTROL "once its units are deleted, leaf to root, the organization deletes". - Every test in the tree that deletes an organization was found by grep (objectql, runtime, plugin-auth, plugin-sharing, platform-objects, the hono adapter, dogfood). Their packages' full suites are in **Final readings**. - **B2.** A predicate delete of `sys_organization` is refused (item 5). Pinned, with a control that another object's multi delete is not caught. - **B3.** The Default Organization's guard question is asked before the first write (item 6). - Seam pins (`last-admin-guard.default-organization.test.ts`): it refuses on the grant anchor; it refuses when the admin's only standing is owning the Default Organization, which needs the member companion (ablated: without it, exactly that case goes red); it admits when another administrator stands elsewhere. - Door pin (federated dogfood (e)): an engine `beforeDelete` recorder on `sys_member` and `sys_invitation` sees **no delete attempted** by the refused request, and members, invitations and teams are unchanged. That does not depend on the transaction. A positive control on the 200 delete in the same file proves the recorder fires. - The changeset's no-transactions sentence is kept, now true for both refusals. - **B4.** Every lookup to `sys_organization` on an append-only ledger is kept (item 3). Pinned in objectql (an append-only object's non-tenant lookup keeps naming it, with no write) and in dogfood (b) (every `sys_audit_log` row whose `tenant_id` named the organization still does after the delete). - **B5.** The closed rule is implemented as written and pinned in objectql (a tenancy-disabled object's `organization_id` is kept and not counted; a pointer is still cleared). It touches no object in this tree. `sys_approval_request` is walled, so the decision's premise about it did not hold. Decision 6307135388 settles it (**Round 4**). - **B6.** The changeset names the seed replay and its remedy. Measured on a walled `os serve` (showcase + `@objectstack/organizations`): a fresh organization owns **132 rows in 19 tables**: - `showcase_account` 14, `showcase_announcement` 2, `showcase_business_unit` 9, `showcase_category` 4, `showcase_client_brief` 2, `showcase_contact` 32, `showcase_expense_line` 13; - `showcase_expense_report` 4, `showcase_field_zoo` 2, `showcase_inquiry` 3, `showcase_invoice` 12, `showcase_invoice_line` 5, `showcase_preference` 1, `showcase_product` 4; - `showcase_project` 5, `showcase_project_membership` 3, `showcase_task` 10, `showcase_team` 2, `sys_business_unit` 5. - **B8.** The adapter carries `dependents` through to the wire 409 (item 5). Pinned in `objectql-adapter.test.ts`, a withheld count staying absent. - **B9.** The engine refusal counts (item 5). Pinned: refusing an organization that owns 5 rows issues no row read of the owning table. - **B7, B10, B11.** Accepted as declared. The ledger count is corrected: eight objects, not nine. ## Measurements (counts only) **Upgrade fixtures**, re-measured on `e184b513c`, after the engine-owned exemption; the figures are identical to round 3's (walled boot over each, `AuditPlugin` composed). Three door-created organizations per fixture: untouched; holding one `sys_business_unit`; holding one record. | Fixture | `sys_team.organization_id` NOT NULL | Untouched | With a business unit | With a record | |---|---|---|---|---| | ceremonied 18.0 (after `os migrate organization-ownership --apply`: complete, 0 blocking) | yes | `200`; organization, members, invitations, teams, team members, grants all 0; `sys_activity` naming it 4 → 7 (kept, plus the delete's own), 0 NULL | `409` `sys_business_unit (1)`, every count unchanged | `409` `showcase_category (1)`, every count unchanged | | `fixture-18.0.0` | no | the same | the same | the same | After that delete, on the ceremonied database: the ceremony re-run answers `complete: true`, `blocking: 0` (its post-check holds only `organization_id IS NULL` rows), and HEAD boots it through `OrganizationOwnershipBootGuardPlugin` (which decides on one field of the `sys_migration` marker). `check:tenant-audit-census` is a static count of source. A REST read over the kept ledger rows answers `200`. The lifecycle sweep's dangling-reference audit only reports, into its sweep report. **Walled `os serve`**, re-measured on `e184b513c` (`OS_TENANCY_POSTURE=isolated`, `OS_PLATFORM_OWNER_EMAIL`, `OS_AUTH_MEMBERSHIP_POLICY=invite-only`, a scratch copy of the showcase declaring `@objectstack/organizations`, the RUNNER.md recipe; `Organizations`, `Security` and `Audit` mounted): - health `200`, sign-in `200`, 0 error lines; - a fresh organization: `409` naming the 19 tables / 132 rows above, every count unchanged; - after the operator's remedy (its records deleted over `DELETE /api/v1/data/...`, three passes for ordering): `200`, with organization, members, teams and grants 0; - read from the databases: `sys_activity.organization_id` 312 rows still name the deleted organization, 0 NULL; `sys_audit_log.tenant_id` 312 rows still name it. `sys_approval_request`, `sys_setting`, `sys_record_share` and `sys_notification` hold no row naming it on this boot, and none with a NULL `organization_id`. The 11 audit rows with a NULL `tenant_id` are deployment-level rows written at boot and by the delete itself, not cleared ones; dogfood (b) pins that every row naming the organization before the delete still does after it. **R1** fresh `pnpm dev -- --fresh` (showcase, `single`): health `200`, seeded-admin sign-in `200`, 0 error lines; Default Organization delete `403`, members 3 → 3, records 14 → 14 (measured last round; the Default Organization path is re-pinned this round by dogfood (e)). **R2** `upgrade-18x-harness.dogfood.test.ts`: see **Final readings**. **Main baseline** (`e4d604efd`): Default Organization delete `403` with `sys_member` 1 → 0. ## Round 5: delta review 6308041940 (R1 blocking, R2, R3), answered - **R1 (blocking): the Default Organization's delete is counted before the first write.** Once the last-administrator guard admits the delete, `hooks.before` asks `defaultOrganizationDeleteRefusal` (`organization-delete.ts:178`, asked at `auth-manager.ts:8184`). It counts every table holding rows that name the organization, leaving out only the members and invitations better-auth removes (`VENDOR_REMOVED_ON_DELETE`). Its teams, its grants and its records count, since plugin-auth removes none of its structure. A refusal is the same `409 DELETE_RESTRICTED`, before better-auth's first write, so a driver without transactions no longer gets a partial delete on this path. - Pinned in federated dogfood under a config anchor: the admin is named in `OS_PLATFORM_OWNER_EMAIL` and verified, so the guard admits. - The response is `409 DELETE_RESTRICTED`. It names the Default Organization's records and does not list `sys_member` or `sys_invitation`. - An engine recorder on `sys_member` and `sys_invitation` sees **no delete attempted**. - Members, invitations, teams and the organization row are unchanged. - Ablated (the count skipped for the Default Organization): exactly that pin goes red, and the other 5 stay green. - At the seam (`organization-delete.test.ts`): the exclusion is exactly members and invitations, and no organization-admin grant is subtracted. - Re-read and corrected so they hold: - the changeset's opening paragraph (`single` deployments) and its no-transactions bullet; - a new Default Organization bullet; - the contract notes in `organization-delete.ts`, items 3 and the closing paragraph, and `isDefaultOrganizationTarget`'s doc. - **R2: the organization counts carry none of the caller's tenant scope.** `countReferencingRowsOutside` and `organizationOwnedRowCounts` now count under `organizationCountContext` (`engine.ts:20109`). That context is system identity, keeping only the request's transaction (so the count sees the request's own writes) and its trace. Ordinary reference probes keep `referenceCheckContext` and the caller's scope. - Pinned in objectql with a tenant-scoping middleware. CONTROL: the caller's own count for the organization reads 0 under it. - `organizationOwnedRowCounts` under a foreign `tenantId` still counts 2. - The engine refusal still refuses a caller scoped to another organization. - Ablated (back to `referenceCheckContext`): both pins go red. - **R3: deciding whether the target is the Default Organization is outside the fall-through.** `isDefaultOrganizationTarget` runs after the `try` (`auth-manager.ts:8160`), so a lookup that fails there refuses instead of handing the delete to better-auth. - Pinned in federated dogfood. A non-default organization the admin owns is made active, and the organization count fails (a middleware on `sys_organization`). Under these conditions: - the request is refused (≥ 400); - a recorder on the session's active-organization write (better-auth's first) and on member, invitation and organization deletes sees nothing attempted; - the organization and its member are unchanged. - CONTROL: with the lookup back, the same organization deletes with `200`. - Ablated (the decision moved back into the `try`): the pin goes red, because better-auth's session write was attempted. - Before this round the pin recorded deletes only, and stayed green under the same ablation, because the last-administrator guard's own member hook threw first. The session write is what makes it discriminating. ## Round 4: seat decision 6307135388 (option B), answered **Exempt by declaration.** `managedBy: 'engine-owned'` joins `managedBy: 'append-only'` in `ObjectQL.outlivesOrganization` (`engine.ts:20042`), which is the one predicate behind all three of these: - the engine refusal (`refuseOrganizationDeleteWhileOwned`); - the door's count (`organizationOwnedRowCounts`, which plugin-auth asks); - the walk's kept references (`keepsOrganizationReference`). No list of names is involved. Such rows are not counted. They are kept, and go on naming the deleted organization: never NULL, never deleted. **The 17 engine-owned objects with an `organization_id`**, measured over every object registered on a booted showcase (audit, approvals, automation, messaging, mail and sharing composed: 109 objects). The other fourteen of the 31 engine-owned objects carry no organization reference and are untouched: the `sys_metadata*` family (with `sys_metadata_activation`), `sys_platform_setting*`, `sys_migration*`, `sys_secret`, `sys_flow_dispatch`, `sys_flow_credential`, `sys_job`, `sys_job_queue`, `sys_scim_connection_credential`. | Object | What writes its rows | |---|---| | `sys_approval_request` | plugin-approvals' approval service: one row per submission, advanced by the flow until it completes. | | `sys_approval_approver` | the approval service: the normalized pending-approver rows behind the inbox query, one per approver per step. | | `sys_approval_token` | the approval service: the single-use tokens behind actionable approval links. | | `sys_automation_run` | service-automation's flow runner: paused runs waiting to resume, and the history of finished runs. | | `sys_notification` | service-messaging: one row per notification event the platform sent. | | `sys_notification_receipt` | service-messaging: per recipient × channel. Read state is set by the recipient through the messaging API. | | `sys_notification_delivery` | service-messaging: the durable per-recipient × channel delivery outbox. | | `sys_http_delivery` | service-messaging: one row per outgoing webhook or flow HTTP call, with its delivery status. | | `sys_mailbox` | **see below.** The mailbox sync writes its IMAP cursor. | | `sys_mail_message` | service-mailbox's sync and ingest: inbound mail mirrored from a mailbox. | | `sys_mail_association` | service-mailbox's ingest: links a mirrored message to the records it matched. | | `sys_audience_binding_suggestion` | a package's suggestion, written by the platform when the package is installed. A `manage_metadata` holder accepts or dismisses it. | | `sys_record_share` | plugin-sharing: rule grants (`source = 'rule'`) written and reconciled by the sharing-rule evaluator, and **see below** for `source = 'manual'`. | | `sys_share_link` | **see below.** | | `sys_import_job` | the import service: the state, progress and history of a bulk import. | | `sys_setting` | **see below.** | | `sys_setting_audit` | `SettingsService`: the audit trail of every settings change. | **Rows that are an administrator's or a user's own act, with a door other than the data API.** These were named for a separate decision. **Decision 6307815346 settles them: the declaration decides, with no per-table exceptions, so all are kept, and it amends 6305179819 on settings.** 1. **`sys_setting`** (organization-level settings values). An administrator sets them through `SettingsService.set` (the Setup settings pages), and `resetNamespace` removes them. Decision 6305179819 said "settings stay records". Under 6307135388's rule, a `sys_setting` row no longer refuses the delete. If 6305179819 meant `sys_setting`, the two decisions now conflict on this table. (If it meant organization-level settings stored elsewhere, there is no conflict: no other object named for settings has an `organization_id`.) 2. **`sys_mailbox`.** It is a mailbox's identity (address, owner), and only `manage_mailboxes` holders can read the directory. No code in this tree creates one; tests insert them directly, so in a deployment the operator provisions them. It is administrator configuration with no delete door. 3. **`sys_record_share` with `source = 'manual'`, and `sys_share_link`.** A user creates them through `POST /data/:object/:id/shares` and the share-link service's `createLink`, and revokes them through the same service: `revokeLink` sets `revoked_at` and keeps the row. Decision 6307135388 names shares as engine-derived, so they are exempt as decided. **Pinned.** - objectql (`engine-organization-delete-refusal.test.ts`): - an organization whose only rows are an engine-owned approval request and its append-only actions deletes, and both are kept naming it with no write; - CONTROL: beside them, a record still refuses, and only the record is named. - Ablated (`engine-owned` removed from the predicate): exactly these two pins go red, and the other 15 stay green. - dogfood (`organization-delete-refuse-nonempty.dogfood.test.ts`), on a booted showcase: - an organization created through the door, holding one real `sys_approval_request` and its two `sys_approval_action` rows, deletes with `200`; - the request and both actions are unchanged and still name it; - 0 rows in either table have a NULL `organization_id`. **Changeset.** It says that engine-owned and append-only rows outlive the organization and go on naming it, and it lists what engine-owned covers. The **Fix** bullet no longer lists settings values among the records. **Merged `main`** (`3b3830ca2`, which includes `62bc12930`) with a merge commit. It merged cleanly. ## File surface Inside the claim: `packages/objectql/src/**`, `packages/plugins/plugin-auth/src/**`, `packages/qa/dogfood/test/**`, one changeset. Outside it: - **Accepted by the seat for generated artefacts only:** `content/docs/permissions/tenant-audit-census.mdx` and `docs/audits/2026-08-tenant-audit-write-call-sites.counts.md` (`tenant-audit-census.mjs --write`), `scripts/engine-double-contract.pinned.json` (`check-engine-double-contract --write`). - **This round, by the review's B1:** `packages/runtime/src/default-organization-statements.golden.test.ts` and `packages/plugins/plugin-sharing/src/business-unit-parent-delete-restrict.test.ts`, the two suites the review named. No `packages/spec/src/**`, no `docs/adr/**`, no governed surface. ## Tests - `packages/objectql/src/engine-organization-delete-refusal.test.ts`: - the refusal, nothing written, a declared `set_null` tenant column refused; - controls: an empty organization deletes, a pointer is cleared, a missing table refuses nothing; - ledgers: kept, a record still refusing beside one, the count excluding them; - B4 (non-tenant ledger lookup kept), B5 (tenancy-disabled attribution kept and not counted, pointer cleared); - B2 (predicate delete refused, control); - B9 (no row read while refusing); - R2 (round 5): the counts under a foreign tenant scope, through a tenant-scoping middleware with its CONTROL; - `organizationOwnedRowCounts`. - engine-owned (round 4): an approval request and its actions are kept and the organization deletes; a record beside them still refuses. - `packages/objectql/src/federated-injected-column-readers.test.ts`: census rows for the new tenant-column readers. - `packages/plugins/plugin-auth/src/organization-delete.test.ts`, `last-admin-guard.default-organization.test.ts` (B3), `objectql-adapter.test.ts` (B8). - `packages/qa/dogfood/test/organization-delete-refuse-nonempty.dogfood.test.ts`, booted with `AuditPlugin`: - (a) (c): a refused delete changes nothing; - (b): the empty delete, with `sys_activity` and `sys_audit_log` rows kept; - an organization holding only an approval request and its actions deletes, and they are kept (round 4); - a business-unit-only refusal; - (d): the engine refusal; - the vendor-refused caller, and the remedy. - `organization-delete-federated-fixture.dogfood.test.ts`: nothing names a deleted organization; (e) the Default Organization's 403 with no member or invitation delete attempted, plus its recorder control; (round 5) R1, the Default Organization admitted by the guard on a config anchor and refused 409 with no member or invitation delete attempted; R3, a failed Default Organization lookup refusing before better-auth's first write, with its CONTROL; the count skipping the federated anchor. - `org-record-admin-reach.dogfood.test.ts`: structure gone, no orphan, 409 control. - The B1 rewrites above. - **Ablations** (restored to HEAD's blob): - the engine refusal call replaced: its 3 pins red; - the door gate inverted (rebuilt, dist preflight): dogfood (a) red; - the guard's member companion deleted: exactly the membership case red, 8 others green. - `engine-owned` removed from `outlivesOrganization`: exactly the two round-4 pins red, 15 others green. - Round 5: - the Default Organization's count skipped: R1's pin red, 5 others green; - `organizationCountContext` back to `referenceCheckContext`: both R2 pins red; - the Default Organization decision moved back into the `try`: R3's pin red. - A first attempt at the last one used an empty replacement, which the tool refuses, and it restored the file to HEAD before that work was committed. It was re-applied from the edit scripts, committed, then ablated. ## Verification on the head (139abc4) Every reading below was taken on 139abc4, which is `e184b513c` plus the round-5 commits `e40aea862` and `139abc45a`, rebuilt. | Check | Result | |---|---| | `turbo run build --filter='@objectstack/dogfood^...'` | exit 0 | | objectql tests | 452 files, 8526 passed | | plugin-auth tests | 176 files, 3356 passed, 10 skipped | | runtime tests | 432 files, 6794 passed, 19 skipped | | plugin-sharing tests | 53 files, 1200 passed | | platform-objects tests | 83 files, 1239 passed | | hono tests | 12 files, 225 passed | | plugin-approvals tests | 93 files, 1208 passed | | typecheck: objectql, plugin-auth, runtime, plugin-sharing, dogfood | exit 0 each | | dogfood organization files (refuse-nonempty, federated-fixture, org-record-admin-reach, organization-update-door) | 4 files, 32 passed | | upgrade harness | 1 file, 12 passed | | `dispatch-gates.mjs --commands` | 117 commands, all exit 0 | | `dispatch-gates.mjs --ran` | 117 derived families: 117 run, 0 NOT-MEASURED | | `check-governed-merges --branch` | exit 0. 2233 changed lines (≤ 3000) | The fixture and walled `os serve` measurements above were taken on `e184b513c`. Round 5 changes only the Default Organization's path and the count's context; neither fixture's organizations nor the serve's fresh organization is the Default Organization. **19 tables / 132 rows is unchanged by the engine-owned exemption.** Every seeded row is in a `showcase_*` table or `sys_business_unit`, and none of those is engine-owned or append-only, so the changeset's figure stands as written. CI check-runs were not waited on. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016gmigttoA9DyRNtz6SepWf --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4bf079c commit 688052e

20 files changed

Lines changed: 2189 additions & 44 deletions
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
'@objectstack/objectql': minor
3+
'@objectstack/plugin-auth': minor
4+
---
5+
6+
fix(objectql, plugin-auth)!: deleting an organization that still owns records is refused with 409 and a per-table count, before anything is deleted; an empty one is deleted whole, its teams and organization-admin grants with it; the engine never clears a tenant column
7+
8+
**The 18.0.0 defect this fixes.** `POST /api/v1/auth/organization/delete` ran better-auth's delete, which removes the organization's members, then its invitations, then the organization row, as three separate writes. The engine's delete cascade then applied the tenant column's `set_null` default to every row the organization still owned. On a database whose tenant column is NOT NULL (one that took the v18 organization-ownership ceremony), the delete answered 500 on `sys_team.organization_id` after the members and invitations were already gone, so the organization survived with no owner and nobody could delete it. On a database 18.0.0 created, where the column is still nullable, the delete answered 200 and left the organization's teams and records with `organization_id = NULL`, owned by nobody and visible to nobody. Every organization created through the door owns a default team, so on a NOT NULL database not even an empty organization could be deleted. Deployments that run `single` are not reached the same way: their one organization is the Default Organization, which the last-administrator guard answers first, and they cannot create another.
9+
10+
**What happens now.**
11+
12+
- An organization that still owns records is refused with `409` and the standard `DELETE_RESTRICTED` code. The body names each table and its row count, `dependents: [{ object, count }]`, and never a row's values. The refusal runs before better-auth's handler, so nothing is written, the caller's active organization included. A caller the organization does not let delete it still gets better-auth's own `403`, with no count.
13+
- Append-only ledgers (objects declared `managedBy: 'append-only'`, such as `sys_activity`, `sys_audit_log` and `sys_email`) are history, not records. They never refuse the delete, and their rows are kept: every reference they hold to the organization (`organization_id` on `sys_activity` and `sys_email`, the attribution `tenant_id` on `sys_audit_log`) goes on naming the deleted organization, never NULL and never deleted. The delete writes activity rows of its own, so counting them would refuse every delete.
14+
- Engine-owned rows (objects declared `managedBy: 'engine-owned'`) are treated the same way. The platform writes them as a consequence of records or activity, and an administrator cannot delete them over the data API: approval requests, approvers and tokens; automation runs; notifications, their receipts and deliveries; outgoing HTTP deliveries; mailboxes and mirrored mail; record shares and share links; import jobs; audience-binding suggestions; and organization-level settings values and their audit. They never refuse the delete, and they are kept, still naming the deleted organization.
15+
- So engine-owned and append-only rows outlive the organization, and go on naming it. Under tenant scoping no organization reads them.
16+
- An attribution column `organization_id` on an object whose tenancy is disabled is kept the same way: not counted, not cleared. A pointer to the organization, such as a session's or an API key's `active_organization_id`, is cleared as before.
17+
- The Default Organization (slug `default`, or the deployment's only organization, never under an organization wall) is asked the last-administrator guard's question first, and answers `403 PERMISSION_DENIED` while deleting it would leave no platform administrator. Once the guard admits, it is counted like any other organization, before better-auth deletes anything, except that only its members and invitations are left out: its teams and grants are not removed, so they count, and the delete is refused `409 DELETE_RESTRICTED` while it holds them.
18+
- An organization that owns nothing but its own auth structure is deleted whole: its members and invitations (better-auth), and its teams, their members and the organization-admin grants its owners and admins hold (plugin-auth, from `beforeDeleteOrganization`). Any other grant scoped to the organization counts as a record.
19+
- The request runs as one engine transaction and rolls back on any error answer, so a delete either removes all of that or none of it. On a datasource whose driver has no transactions, the refusals are the guarantee, because all of them run before the first write: the record count above, and, for the Default Organization, the last-administrator guard, whose `403` is now asked before better-auth deletes any member (it used to arrive after the members and invitations were gone).
20+
- Under all of this, the engine itself refuses to delete a `sys_organization` row while any row still names it in its tenant column, whatever path reaches the delete: `409 DELETE_RESTRICTED` with the same `dependents` list, which better-auth's door now carries to the wire too. It counts each table rather than reading its rows. An organization is deleted by id only: a delete matching organizations by a filter (`multi: true`) is refused with `409 DELETE_RESTRICTED` naming the by-id route, because only the by-id delete runs this referential pass. A tenant column is never set to NULL.
21+
- `ObjectQL.organizationOwnedRowCounts(organizationId, { exclude })` answers, per object, how many rows name an organization in their tenant column. It is the question the refusal asks.
22+
23+
Clause-②: yes (narrowing)
24+
25+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What changes is the answer the organization delete gives while the organization still owns records (409 instead of a 500 that left a partial delete, or a 200 that orphaned the rows to NULL), and the engine's answer to a `sys_organization` delete that rows still name in their tenant column. The remedy is an operator action on the organization's records, never a rewrite of a package source or a stored row. The other categories are closed on facts: both packages publish (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed, one engine method is added (not runtime-interface-only, type-surface-only or runtime-export-retired). -->
26+
27+
**BREAKING** (the organization delete's accept set narrows), shipped as `minor` under the launch-window convention for breaking changes.
28+
29+
- **FROM:** deleting an organization that still owned records answered `500` after deleting its members and invitations (tenant column NOT NULL), or `200` with its teams and records orphaned to `organization_id = NULL` (tenant column nullable, an 18.0.0-born database). A direct engine delete of such a `sys_organization` row did the same.
30+
- **TO:** both answer `409 DELETE_RESTRICTED`, with `dependents: [{ object, count }]` naming every table that still holds rows. Nothing is deleted or changed.
31+
- **Fix:** delete the organization's records, or move them to another organization, then delete the organization. The refusal names the tables to look at. Business units, positions and sharing rules count as records. Activity, audit and mail-log rows do not, nor do engine-owned rows (approval state, runs, deliveries, shares, settings values).
32+
- **Seed datasets:** on a composition with the organizations runtime (`@objectstack/organizations`) and an app that ships seed datasets, a new organization receives that seed replay at creation, so it owns records from its first moment and its delete is refused until they are removed. The refusal lists them. Delete them (or move them) like any other records, then delete the organization. Measured on the showcase: a fresh organization owns 132 seeded rows in 19 tables.
33+
- **Already orphaned on 18.0.0?** Rows an earlier organization delete set to `organization_id = NULL` are not touched by this release. Organization-less rows are what the v18 organization-ownership ceremony (`os migrate organization-ownership`) attributes or reports.

‎content/docs/permissions/tenant-audit-census.mdx‎

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ are reported as `undecidable` rather than assumed either way.
122122

123123
The same holds twice over for the context. An options argument spelled as a
124124
literal can be read; one spelled `options`, `{ ...opts }`, or handed through a
125-
forwarding shim cannot, and **51 of the 233 sites are spelled that way**. A
125+
forwarding shim cannot, and **51 of the 236 sites are spelled that way**. A
126126
context resolved from an inline literal or a local `const` can be tested for
127127
`isSystem`; one arriving from a helper call cannot.
128128

@@ -187,10 +187,10 @@ reproduce them. Where it disagrees, it disagrees on the page:
187187

188188
| carried figure | where it survives | this census |
189189
| :--- | :--- | ---: |
190-
| 175 write call sites | quoted in the merged changeset | **233** |
190+
| 175 write call sites | quoted in the merged changeset | **236** |
191191
| 24 carrying no tenant context | quoted in the merged changeset | **2** provable and tenancy-enabled; **29** more whose options argument is unreadable |
192-
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **156 of 233** decidable, **77** undecidable |
193-
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 131 decidably elevated, 0 decidably not, 94 undecidable |
192+
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **159 of 236** decidable, **77** undecidable |
193+
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 134 decidably elevated, 0 decidably not, 94 undecidable |
194194
| 141 and 132, two independent re-derivations | the card that filed this work | — |
195195

196196
**The differences are not reconciled, and deliberately so.** The old census's
@@ -201,17 +201,17 @@ at any commit.
201201

202202
Two structural facts do plausibly widen this reading against any hand or regex
203203
one, and both are counted in the generated tables below: the 41 sites reached
204-
through an erased (`any`) receiver, and the 49 that name their object through a
204+
through an erased (`any`) receiver, and the 50 that name their object through a
205205
`const` rather than inline. An instrument that read either the way a person does
206206
would report a smaller number and would not say so.
207207

208208
The fourth row is the one worth flagging to anyone citing it. **The 135 / 77%
209209
figure has no surviving corroboration anywhere in the tree.** This census reads
210-
131 of 233 (56%) as decidably elevated, with 94 more whose elevation is a
210+
134 of 236 (57%) as decidably elevated, with 94 more whose elevation is a
211211
run-time fact — so the claim is neither confirmed nor refuted, and the honest
212212
answer is that a static reading cannot settle it.
213213

214-
⇒ **Cite `2 / 233`, and say what it is**: the sites whose options argument was
214+
⇒ **Cite `2 / 236`, and say what it is**: the sites whose options argument was
215215
READ and holds no tenant context, against a decidably tenancy-enabled object.
216216
That is the control's provable yield surface. ⛔ Do not cite it as "the sites
217217
without tenant context" — **29 further sites** have an options argument this
@@ -223,29 +223,29 @@ cannot read, and they are neither in nor out.
223223

224224
| what | count |
225225
| :--- | ---: |
226-
| write call sites on the application surface | **233** |
227-
| …whose object name is statically decidable | 156 |
226+
| write call sites on the application surface | **236** |
227+
| …whose object name is statically decidable | 159 |
228228
| …whose object name is chosen at run time | 77 |
229-
| …against an object with tenancy ENABLED | 155 |
229+
| …against an object with tenancy ENABLED | 158 |
230230
| …against an object that declares tenancy off | 1 |
231-
| threading a tenant context | 174 |
231+
| threading a tenant context | 177 |
232232
| PROVABLY carrying none (options read, no context key) | **8** |
233233
| …of those, against a decidably tenancy-enabled object | **2** |
234234
| options argument UNREADABLE — may or may not carry one | 51 |
235235
| …of those, against a decidably tenancy-enabled object | 29 |
236-
| threading a decidably ELEVATED (`isSystem`) context | 131 |
236+
| threading a decidably ELEVATED (`isSystem`) context | 134 |
237237
| threading a context that is decidably NOT elevated | 0 |
238238
| threading a context whose elevation is a run-time fact | 94 |
239239

240240
| how the instrument reached the site | count |
241241
| :--- | ---: |
242-
| receiver carried a readable engine type | 192 |
242+
| receiver carried a readable engine type | 195 |
243243
| receiver erased, placed by the object NAME | 31 |
244244
| receiver erased, placed by an `object: string` PARAMETER | 5 |
245245
| receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 |
246246

247-
| object name spelled inline | 107 |
248-
| object name spelled through a `const` | 49 |
247+
| object name spelled inline | 109 |
248+
| object name spelled through a `const` | 50 |
249249
| object name is an `object: string` parameter | 7 |
250250
| object name is some other run-time expression | 70 |
251251

@@ -297,11 +297,11 @@ holds still. They are required to be HERE and to say WHEN they were true;
297297
their values are not compared. The reasoning, and the measurement behind it,
298298
are in `scripts/check-tenant-audit-census.mjs`.
299299

300-
Measured on 2026-10-20 at `39fc914f7`.
300+
Measured on 2026-10-20 at `a454cbcc4`.
301301

302302
| corpus scale (not enforced) | count |
303303
| :--- | ---: |
304-
| tracked non-test sources scanned | 681 |
304+
| tracked non-test sources scanned | 684 |
305305
| engine-shaped types recognised | 82 |
306306
| declared objects in the registry | 118 |
307307
| same-named calls subtracted as non-engine | 169 |

‎docs/audits/2026-08-tenant-audit-write-call-sites.counts.md‎

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,17 +33,17 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution.
3333

3434
| Measure | Value |
3535
|---|---:|
36-
| Write call sites | 233 |
37-
| Object name statically decidable | 156 |
36+
| Write call sites | 236 |
37+
| Object name statically decidable | 159 |
3838
| Object name chosen at run time | 77 |
39-
| Against a tenancy-enabled object | 155 |
39+
| Against a tenancy-enabled object | 158 |
4040
| Against an object declaring tenancy off | 1 |
41-
| Threading a tenant context | 174 |
41+
| Threading a tenant context | 177 |
4242
| Provably carrying none | 8 |
4343
| …and decidably tenancy-enabled | 2 |
4444
| Options argument unreadable | 51 |
4545
| …and decidably tenancy-enabled | 29 |
46-
| Threading a decidably elevated context | 131 |
46+
| Threading a decidably elevated context | 134 |
4747
| Threading a decidably non-elevated context | 0 |
4848
| Threading a context of undecidable elevation | 94 |
4949

@@ -90,11 +90,11 @@ holds still. They are required to be HERE and to say WHEN they were true;
9090
their values are not compared. The reasoning, and the measurement behind it,
9191
are in `scripts/check-tenant-audit-census.mjs`.
9292

93-
Measured on 2026-10-20 at `39fc914f7`.
93+
Measured on 2026-10-20 at `a454cbcc4`.
9494

9595
| corpus scale (not enforced) | count |
9696
| :--- | ---: |
97-
| tracked non-test sources scanned | 681 |
97+
| tracked non-test sources scanned | 684 |
9898
| engine-shaped types recognised | 82 |
9999
| declared objects in the registry | 118 |
100100
| same-named calls subtracted as non-engine | 169 |
@@ -155,6 +155,9 @@ Measured on 2026-10-20 at `39fc914f7`.
155155
| `packages/plugins/plugin-auth/src/objectql-adapter.ts` | `delete` | `objectName` | undecidable | PROVABLY NONE | 5 |
156156
| `packages/plugins/plugin-auth/src/objectql-adapter.ts` | `insert` | `objectName` | undecidable | options unreadable | 2 |
157157
| `packages/plugins/plugin-auth/src/objectql-adapter.ts` | `update` | `objectName` | undecidable | options unreadable | 5 |
158+
| `packages/plugins/plugin-auth/src/organization-delete.ts` | `delete` | `sys_team` | enabled | elevated | 1 |
159+
| `packages/plugins/plugin-auth/src/organization-delete.ts` | `delete` | `sys_team_member` | enabled | elevated | 1 |
160+
| `packages/plugins/plugin-auth/src/organization-delete.ts` | `delete` | `sys_user_permission_set` | enabled | elevated | 1 |
158161
| `packages/plugins/plugin-auth/src/reconcile-membership.ts` | `insert` | `sys_member` | enabled | context, elevation undecidable | 1 |
159162
| `packages/plugins/plugin-auth/src/scim-connection-service.ts` | `insert` | `sys_scim_connection_credential` | enabled | PROVABLY NONE | 1 |
160163
| `packages/plugins/plugin-auth/src/session-tombstone.ts` | `update` | `objectName` | undecidable | options unreadable | 1 |

0 commit comments

Comments
 (0)