Skip to content

Commit e184b51

Browse files
committed
fix(objectql, plugin-auth): engine-owned rows outlive the organization, like the ledgers (#24948)
Seat decision 6307135388 (option B): an object declared managedBy 'engine-owned' is neither counted by the organization delete's refusal (engine and door) nor cleared by its walk. Its rows are kept, naming the deleted organization. Pinned in objectql (an approval request and its actions; a record beside them still refuses) and in dogfood. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016gmigttoA9DyRNtz6SepWf
1 parent 20f8395 commit e184b51

5 files changed

Lines changed: 142 additions & 38 deletions

File tree

‎.changeset/24948-organization-delete-refuse-nonempty.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ fix(objectql, plugin-auth)!: deleting an organization that still owns records is
1111

1212
- 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.
1313
- 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-
- An attribution column `organization_id` on an object whose tenancy is disabled (such as `sys_approval_request`, an engine-owned row an administrator cannot delete) 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.
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.
1517
- 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.
1618
- 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 both 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).
1719
- 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.
@@ -25,6 +27,6 @@ Clause-②: yes (narrowing)
2527

2628
- **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.
2729
- **TO:** both answer `409 DELETE_RESTRICTED`, with `dependents: [{ object, count }]` naming every table that still holds rows. Nothing is deleted or changed.
28-
- **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, sharing rules and organization-level settings count as records; activity, audit and mail-log rows do not.
30+
- **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).
2931
- **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.
3032
- **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.

‎packages/objectql/src/engine-organization-delete-refusal.test.ts‎

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ const AUDIT_LIKE = {
170170
},
171171
};
172172

173-
/** Tenancy disabled, with an `organization_id` attribution column (`sys_approval_request`). */
173+
/** Tenancy disabled, with an `organization_id` attribution column (the closed rule of seat decision 6305996369, B5). */
174174
const REQUEST_LIKE = {
175175
name: 'request_like',
176176
label: 'Request',
@@ -181,6 +181,28 @@ const REQUEST_LIKE = {
181181
},
182182
};
183183

184+
/**
185+
* An engine-owned object with a tenant column (`sys_approval_request`): the
186+
* platform writes its rows, an administrator cannot delete them, and they
187+
* outlive the organization (seat decision 6307135388).
188+
*/
189+
const APPROVAL_LIKE = {
190+
name: 'approval_like',
191+
label: 'Approval request',
192+
managedBy: 'engine-owned' as const,
193+
fields: { status: { name: 'status', label: 'Status', type: 'text' as const } },
194+
};
195+
/** Its actions: an append-only ledger (`sys_approval_action`). */
196+
const APPROVAL_ACTION_LIKE = {
197+
name: 'approval_action_like',
198+
label: 'Approval action',
199+
managedBy: 'append-only' as const,
200+
fields: {
201+
request_id: { name: 'request_id', label: 'Request', type: 'lookup' as const, reference: 'approval_like' },
202+
action: { name: 'action', label: 'Action', type: 'text' as const },
203+
},
204+
};
205+
184206
async function makeEngine(objects: any[]) {
185207
const engine = new ObjectQL({ logger: { debug() {}, info() {}, warn() {}, error() {} } } as any);
186208
const store = makeDriver();
@@ -304,6 +326,43 @@ describe('[#24948] an append-only ledger neither refuses the delete nor is clear
304326
});
305327
});
306328

329+
describe('[#24948] an engine-owned object neither refuses the delete nor is cleared by it (seat decision 6307135388)', () => {
330+
const seedApproval = (store: ReturnType<typeof makeDriver>) => {
331+
store.seed('approval_like', { id: 'ar1', status: 'approved', organization_id: ORG_ID });
332+
store.seed('approval_action_like', { id: 'aa1', request_id: 'ar1', action: 'submit', organization_id: ORG_ID });
333+
store.seed('approval_action_like', { id: 'aa2', request_id: 'ar1', action: 'approve', organization_id: ORG_ID });
334+
};
335+
336+
it('deletes an organization whose only rows are an approval request and its actions, and keeps them naming it', async () => {
337+
const { engine, store } = await makeEngine([ORGANIZATION, ACCOUNT, APPROVAL_LIKE, APPROVAL_ACTION_LIKE]);
338+
seedApproval(store);
339+
expect(await engine.organizationOwnedRowCounts(ORG_ID)).toEqual([]);
340+
341+
expect(await deleteOrg(engine)).toBeNull();
342+
343+
expect(store.rows('sys_organization').map((r) => r.id)).toEqual([OTHER_ORG]);
344+
// Kept, and still naming the deleted organization: never NULL, never deleted.
345+
expect(store.rows('approval_like').map((r) => [r.id, r.organization_id])).toEqual([['ar1', ORG_ID]]);
346+
expect(store.rows('approval_action_like').map((r) => [r.id, r.request_id, r.organization_id])).toEqual([
347+
['aa1', 'ar1', ORG_ID],
348+
['aa2', 'ar1', ORG_ID],
349+
]);
350+
expect(store.writes.filter((w) => w.includes('approval_'))).toEqual([]);
351+
});
352+
353+
it('CONTROL: beside them, a record still refuses, and only the record is named', async () => {
354+
const { engine, store } = await makeEngine([ORGANIZATION, ACCOUNT, APPROVAL_LIKE, APPROVAL_ACTION_LIKE]);
355+
seedApproval(store);
356+
store.seed('acct', { id: 'a1', organization_id: ORG_ID });
357+
358+
const err = await deleteOrg(engine);
359+
360+
expect(err?.code).toBe('DELETE_RESTRICTED');
361+
expect(err?.dependents).toEqual([{ object: 'acct', count: 1 }]);
362+
expect(store.writes).toEqual([]);
363+
});
364+
});
365+
307366
describe('[#24948] references the delete keeps as they are (seat decision 6305996369)', () => {
308367
it("an append-only ledger's non-tenant lookup keeps naming the deleted organization", async () => {
309368
const { engine, store } = await makeEngine([ORGANIZATION, AUDIT_LIKE]);

‎packages/objectql/src/engine.ts‎

Lines changed: 37 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -20018,45 +20018,52 @@ export class ObjectQL implements IObjectQLEngine {
2001820018
}
2001920019

2002020020
/**
20021-
* [#24948] Is `child` an append-only ledger, whose rows outlive the
20022-
* organization they name? Read from the declaration the object already
20023-
* carries, `managedBy: 'append-only'` (the audit bucket: `sys_activity`,
20024-
* `sys_audit_log`, `sys_email` and their siblings), never from a list of
20025-
* names.
20021+
* [#24948] Do `child`'s rows outlive the organization they name? Read from
20022+
* the declaration the object already carries, never from a list of names:
2002620023
*
20027-
* Such a row is history, not a record an administrator manages: it ages out
20028-
* on its own retention, or is the delivery log of something that already
20029-
* happened. The organization delete itself writes `sys_activity` rows, so a
20030-
* ledger that counted would refuse every organization delete, the empty ones
20031-
* included. Neither refusal counts a ledger. Its rows are kept, and so is
20032-
* their tenant column: it goes on naming the deleted organization, never
20033-
* NULL (ADR-0131 D1) and never deleted (ADR-0131 §8), and under tenant
20034-
* scoping the rows read as no organization's (seat decision 6305179819).
20024+
* - `managedBy: 'append-only'`, a ledger (the audit bucket: `sys_activity`,
20025+
* `sys_audit_log`, `sys_email` and their siblings). Such a row is history:
20026+
* it ages out on its own retention, or is the delivery log of something
20027+
* that already happened. The organization delete itself writes
20028+
* `sys_activity` rows, so a ledger that counted would refuse every
20029+
* organization delete, the empty ones included (seat decision 6305179819).
20030+
* - `managedBy: 'engine-owned'`, a row the platform writes as a consequence
20031+
* of records or activity (approval state, automation runs, notification
20032+
* and HTTP deliveries, mirrored mail, record shares, import jobs,
20033+
* settings values), which an administrator cannot delete over the data
20034+
* API. Counting it would make any organization that has been used
20035+
* undeletable through every door (seat decision 6307135388).
20036+
*
20037+
* Neither refusal counts such a row. It is kept, and so is every reference it
20038+
* holds to the organization: it goes on naming the deleted organization,
20039+
* never NULL (ADR-0131 D1) and never deleted (ADR-0131 §8), and under tenant
20040+
* scoping the row reads as no organization's.
2003520041
*/
20036-
private static isAppendOnlyLedger(child: ServiceObject): boolean {
20037-
return (child as { managedBy?: unknown })?.managedBy === 'append-only';
20042+
private static outlivesOrganization(child: ServiceObject): boolean {
20043+
const managedBy = (child as { managedBy?: unknown })?.managedBy;
20044+
return managedBy === 'append-only' || managedBy === 'engine-owned';
2003820045
}
2003920046

2004020047
/**
2004120048
* [#24948] Does an organization delete keep `child.fieldName`, a `set_null`
2004220049
* lookup into `sys_organization` that is not `child`'s tenant column, as it
20043-
* is? Two kinds of reference, and only these (seat decisions 6305179819 and
20044-
* 6305996369):
20050+
* is? Two kinds of reference, and only these (seat decisions 6305179819,
20051+
* 6305996369 and 6307135388):
2004520052
*
20046-
* - every such lookup on an append-only ledger ({@link isAppendOnlyLedger}):
20047-
* `sys_audit_log.tenant_id` is the audit log's attribution, not its tenant
20048-
* column, and history outlives what it audits;
20053+
* - every such lookup on an object whose rows outlive the organization
20054+
* ({@link outlivesOrganization}): `sys_audit_log.tenant_id` is the audit
20055+
* log's attribution, not its tenant column, and history outlives what it
20056+
* audits;
2004920057
* - an attribution column named `organization_id` on an object whose
20050-
* tenancy is disabled (`resolveTenantFieldName` answers null), such as
20051-
* `sys_approval_request`: an engine-owned row an administrator cannot
20052-
* delete, so it is neither counted nor cleared.
20058+
* tenancy is disabled (`resolveTenantFieldName` answers null): neither
20059+
* counted nor cleared. No object in this tree declares one today.
2005320060
*
2005420061
* Everything else keeps its `set_null`: a pointer such as a session's or an
2005520062
* API key's `active_organization_id`, or a parent-organization lookup, is
2005620063
* cleared exactly as before.
2005720064
*/
2005820065
private static keepsOrganizationReference(child: ServiceObject, fieldName: string): boolean {
20059-
if (ObjectQL.isAppendOnlyLedger(child)) return true;
20066+
if (ObjectQL.outlivesOrganization(child)) return true;
2006020067
return fieldName === SystemFieldName.ORGANIZATION_ID && resolveTenantFieldName(child) === null;
2006120068
}
2006220069

@@ -20115,7 +20122,7 @@ export class ObjectQL implements IObjectQLEngine {
2011520122
/**
2011620123
* [#24948] The engine half of organization delete: refuse while any row
2011720124
* outside the cascade set still names the organization in its tenant column,
20118-
* an append-only ledger's excepted ({@link isAppendOnlyLedger}).
20125+
* a row that outlives the organization excepted ({@link outlivesOrganization}).
2011920126
*
2012020127
* Runs before the walk touches the first relation, and reads every tenant
2012120128
* relation before it throws, so the one refusal names every table that holds
@@ -20143,8 +20150,8 @@ export class ObjectQL implements IObjectQLEngine {
2014320150
for (const child of objects) {
2014420151
const childName = (child as any)?.name as string | undefined;
2014520152
const fields = (child as any)?.fields as Record<string, any> | undefined;
20146-
// An append-only ledger's rows outlive the organization: not counted, kept as they are.
20147-
if (!childName || !fields || ObjectQL.isAppendOnlyLedger(child)) continue;
20153+
// An append-only or engine-owned object's rows outlive the organization: not counted, kept as they are.
20154+
if (!childName || !fields || ObjectQL.outlivesOrganization(child)) continue;
2014820155
for (const [fieldName, fdef] of Object.entries(fields)) {
2014920156
const behavior = this.cascadeRelationBehavior(ORGANIZATION_OBJECT, child, fieldName, fdef);
2015020157
if (!this.isTenantColumnSetNull(ORGANIZATION_OBJECT, child, fieldName, behavior)) continue;
@@ -20194,8 +20201,8 @@ export class ObjectQL implements IObjectQLEngine {
2019420201
* row.
2019520202
*
2019620203
* `exclude` names the objects the caller removes as part of the delete
20197-
* itself. An append-only ledger is never counted ({@link isAppendOnlyLedger}),
20198-
* the same set the engine's own refusal leaves out.
20204+
* itself. An append-only or engine-owned object is never counted ({@link
20205+
* outlivesOrganization}), the same set the engine's own refusal leaves out.
2019920206
*/
2020020207
async organizationOwnedRowCounts(
2020120208
organizationId: string,
@@ -20206,7 +20213,7 @@ export class ObjectQL implements IObjectQLEngine {
2020620213
for (const child of this._registry.getAllObjects()) {
2020720214
const childName = (child as any)?.name as string | undefined;
2020820215
const fields = (child as any)?.fields as Record<string, any> | undefined;
20209-
if (!childName || !fields || exclude.has(childName) || ObjectQL.isAppendOnlyLedger(child)) continue;
20216+
if (!childName || !fields || exclude.has(childName) || ObjectQL.outlivesOrganization(child)) continue;
2021020217
const tenantField = resolveTenantFieldName(child);
2021120218
if (!tenantField || !fields[tenantField]) continue;
2021220219
const fdef = fields[tenantField];

‎packages/plugins/plugin-auth/src/organization-delete.ts‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,12 @@
2525
* 1. **Refused while the organization owns records.** {@link
2626
* organizationDeleteRefusal} counts, per table, the rows that name the
2727
* organization in their tenant column, leaving out the organization's own
28-
* auth structure and every append-only ledger (`managedBy: 'append-only'`:
29-
* `sys_activity`, `sys_email` and their siblings), whose rows are history,
30-
* are kept, and go on naming the deleted organization (seat decision
31-
* 6305179819; the engine reads the same set). Any count above zero refuses with 409
28+
* auth structure and every object declared `managedBy: 'append-only'`
29+
* (ledgers: `sys_activity`, `sys_email` and their siblings) or
30+
* `managedBy: 'engine-owned'` (rows the platform writes: approval state,
31+
* runs, deliveries, shares), whose rows are kept and go on naming the
32+
* deleted organization (seat decisions 6305179819 and 6307135388; the
33+
* engine reads the same set). Any count above zero refuses with 409
3234
* `DELETE_RESTRICTED` and `dependents: [{ object, count }]`: counts, never a
3335
* row value. It runs in `hooks.before`, ahead of the vendor's handler, and
3436
* only for a caller the vendor itself would admit (a member holding the

0 commit comments

Comments
 (0)