Skip to content

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

Merged
objectstack-fleet[bot] merged 20 commits into
mainfrom
claude/issue-24948-org-delete-refuse-nonempty
Oct 21, 2026
Merged

objectstack-fleet[bot] merged 20 commits into
mainfrom
claude/issue-24948-org-delete-refuse-nonempty

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

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.ai/code/session_016gmigttoA9DyRNtz6SepWf

…sture-aware Default Organization reading

Claude-Session: https://claude.ai/code/session_016gmigttoA9DyRNtz6SepWf
Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016gmigttoA9DyRNtz6SepWf
Co-authored-by: Claude <noreply@anthropic.com>
…hrow site, and read the engine options without casts

Claude-Session: https://claude.ai/code/session_016gmigttoA9DyRNtz6SepWf
Co-authored-by: Claude <noreply@anthropic.com>
…rganization-delete write sites

Generated by node scripts/tenant-audit-census.mjs --write, and the page's
census-derived prose numbers restated to match (233 to 236 sites, 131 to 134
decidably elevated, 49 to 50 const-named).

Claude-Session: https://claude.ai/code/session_016gmigttoA9DyRNtz6SepWf
Co-authored-by: Claude <noreply@anthropic.com>
…e through assertEngineDeleteDispatch and honours limit; pin it in the double ledger

Claude-Session: https://claude.ai/code/session_016gmigttoA9DyRNtz6SepWf
Co-authored-by: Claude <noreply@anthropic.com>
…uses combinators it does not implement

Claude-Session: https://claude.ai/code/session_016gmigttoA9DyRNtz6SepWf
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/objectql, @objectstack/plugin-auth, touching 49 documentable anchor(s).

31 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json b2890909aa48b64d91b4b18748b91374f32b4b44.

⛔ 12 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 2 anchor(s) matched too much of the corpus to be a work list: ObjectQL (symbol, 72 pages), PERMISSION_DENIED (literal, 42 pages)
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 28 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json b2890909aa48b64d91b4b18748b91374f32b4b44 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 9147d1987ba7744cfb8596618c146c8c6f4a0194 — the merge of head 139abc45a68ca76907c1a8d3da08774aa6b19e9a into base b2890909aa48b64d91b4b18748b91374f32b4b44, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 9147d1987ba7744cfb8596618c146c8c6f4a0194 && git checkout 9147d1987ba7744cfb8596618c146c8c6f4a0194
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b2890909aa48b64d91b4b18748b91374f32b4b44 139abc45a68ca76907c1a8d3da08774aa6b19e9a && git checkout -B drift-repro b2890909aa48b64d91b4b18748b91374f32b4b44 && git merge --no-ff 139abc45a68ca76907c1a8d3da08774aa6b19e9a

node scripts/docs-audit/affected-docs.mjs --json b2890909aa48b64d91b4b18748b91374f32b4b44

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs b2890909aa48b64d91b4b18748b91374f32b4b44 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…anization delete nor lose their rows

Objects declared managedBy: 'append-only' (sys_activity, sys_audit_log,
sys_email and their siblings) are history: both refusals leave them out, and
their rows keep naming the deleted organization, never NULL and never deleted.
Pinned with the audit plugin composed, plus a business-unit refusal.

Claude-Session: https://claude.ai/code/session_016gmigttoA9DyRNtz6SepWf
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 0573a968239deabada94926ad6748cae5d764ea8
Local-runs: none
Scope: packages/objectql/src/engine.ts, packages/objectql/src/engine-organization-delete-refusal.test.ts, packages/objectql/src/federated-injected-column-readers.test.ts, packages/plugins/plugin-auth/src/auth-manager.ts, packages/plugins/plugin-auth/src/organization-delete.ts, packages/plugins/plugin-auth/src/organization-delete.test.ts, packages/qa/dogfood/test/org-record-admin-reach.dogfood.test.ts, packages/qa/dogfood/test/organization-delete-federated-fixture.dogfood.test.ts, packages/qa/dogfood/test/organization-delete-refuse-nonempty.dogfood.test.ts, scripts/engine-double-contract.pinned.json; read to judge them: packages/objectql/src/tenancy/system-write-organization.ts, packages/objectql/src/lifecycle/lifecycle-service.ts, packages/objectql/src/integrity/dangling-reference-audit.ts, packages/plugins/plugin-auth/src/objectql-adapter.ts, packages/plugins/plugin-auth/src/last-admin-guard.ts, packages/platform-objects/src/identity/sys-member.object.ts, sys-invitation.object.ts, sys-team.object.ts, sys-team-member.object.ts, sys-session.object.ts, sys-api-key.object.ts, packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts, packages/plugins/plugin-security/src/auto-org-admin-grant.ts, packages/plugins/plugin-audit/src/objects/sys-activity.object.ts, sys-audit-log.object.ts, packages/plugins/plugin-audit/src/audit-writers.ts, packages/plugins/plugin-audit/src/read-audit.ts, packages/platform-objects/src/audit/sys-email.object.ts, sys-identity-email.object.ts, sys-job-run.object.ts, packages/metadata-core/src/objects/sys-metadata-audit.object.ts, packages/services/service-realtime/src/objects/sys-presence.object.ts, packages/plugins/plugin-approvals/src/sys-approval-action.object.ts, sys-approval-request.object.ts, packages/plugins/organizations/src/organizations-plugin.ts, packages/drivers/driver-memory/src/memory-driver.ts, packages/drivers/driver-turso/src/turso-driver.ts, packages/drivers/driver-turso/src/remote-transport.ts, packages/cli/src/commands/data/delete.ts, packages/cli/src/utils/organization-ownership-apply.ts, packages/runtime/src/organization-ownership-boot-guard.ts, packages/runtime/src/default-organization-statements.golden.test.ts, packages/plugins/plugin-sharing/src/business-unit-parent-delete-restrict.test.ts, packages/spec/src/data/object.zod.ts, packages/spec/src/data/object.form.ts, scripts/check-changeset-no-major.mjs, scripts/tenant-audit-census.mjs, scripts/check-engine-double-contract.mjs, AGENTS.md, better-auth 1.7.3 dist/plugins/organization/routes/crud-org.mjs and dist/plugins/organization/adapter.mjs (npm tarball).

Read-only. The head 0573a968239deabada94926ad6748cae5d764ea8 was checked out in a detached worktree and read against merge-base 93e6c1a5f8bf256310f9ed477523ff2c90bf1bff (13 files, +1399/-27). The card #24948 (body, measurement 6297602613, rulings 6303358360 and 6303709566, claim 6303408016, reports 6305163348 and 6305715779, seat decision 6305179819), the PR body, the check-runs on the head and on the merge-base, and the two red shard logs (the tail the API serves; the full log host is refused from this container) were read. Nothing was built, run or posted. better-auth 1.7.3 is not installed in this checkout, so its organization delete route and adapter were read from the published npm tarball.

① Derived judgments

1. The door refusal. Before any write: yes. The vendor's deleteOrganization (crud-org.mjs:238) orders disableOrganizationDeletion (:255), member lookup (:268), hasPermission (:269-274), then its first write, adapter.setActiveOrganization(token, null) when the target is the caller's active organization (:275-279), then beforeDeleteOrganization (:282), then adapter.deleteOrganization (:286), which is member deleteMany, invitation deleteMany, organization delete (adapter.mjs:370-396). plugin-auth's global hooks.before branch (auth-manager.ts:2440-2443) runs ahead of all of it and calls assertOrganizationDeletable (:8097), which reads the caller's sys_member row and asks the vendor's own hasPermission with organization: ['delete'], returns to the vendor on no session, no membership, no permission, no organization or the Default Organization (:8125), and otherwise throws APIError('CONFLICT', { code: 'DELETE_RESTRICTED', message, dependents }) (:8133-8136). organizationNotEmptyBody (organization-delete.ts:150-161) builds dependents as { object, count } pairs from counts only; organizationOwnedRowCounts (engine.ts:20102-20129) issues system-identity count calls, never a row read; dogfood (a) asserts no record name rides the body. Typed 409: yes. Coverage: the one vendor route (POST /auth/organization/delete, the SDK's organizations.delete, auth-route-ledger.ts:260) is the only door in the tree; no server-side api.deleteOrganization caller exists; os data delete (cli data/delete.ts:58) is a by-id REST delete, which reaches the engine refusal; no REST multi-delete route exists. A caller the vendor does not admit gets the vendor's 403 and no count (dogfood pin), and for the Default Organization the net is the engine refusal plus the last-admin guard. Wrong refusal: the count filters organization_id = id exactly (referenceProbeFilter), excludes ORGANIZATION_AUTH_STRUCTURE (organization-delete.ts:85-91), skips append-only objects and federated objects whose injected column is unprovisioned (via cascadeRelationBehavior, pinned in the federated dogfood), and counts the grant table separately as total minus organization-admin grants so NULL-named grants count (:140-148). Another organization's rows or the Default Organization's are never counted. Bypass: the door's catch { return } (auth-manager.ts:8126-8131) hands a failed LOOKUP to the vendor, but the count itself propagates; the engine refusal and the request transaction then hold. The one engine path the refusal does not cover is a predicate delete, see B2.

2. The auth structure removed. better-auth removes sys_member and sys_invitation (adapter.mjs:373-386); removeOrganizationAuthStructure (organization-delete.ts:204-224) removes sys_team_member by team (:210-214), each sys_team by id so its own cascade clears sessions' active team (:215-217), and the organization_admin / organization_admin_no_bypass grants scoped to the organization (:219-223), from beforeDeleteOrganization (auth-manager.ts:3883-3889). What a door-created organization owns at birth, read from the tree: the organization row, the owner's sys_member, the default team and its sys_team_member (vendor crud-org.mjs:106-126 with teams: { enabled: true }, auth-manager.ts:3557), the owner's organization-admin grant (plugin-security auto-org-admin-grant.ts, on sys_member insert), and, only where the organizations plugin and an app with seed datasets are composed, the per-organization seed replay (organizations-plugin.ts:140-147, :420-434). No position, business unit, setting or other grant is written at birth by platform code. Nothing outside the organization: every delete is filtered by organization_id or by the organization's own team ids; the grant delete is scoped to organization_id and the two admin set names, so a user's grants in another organization and any other grant in this one stay (unit pin at organization-delete.test.ts:111-150). Complete for the empty case as measured by the dev on both fixtures (members, invitations, teams, team members, grants all 0).

3. Atomicity. handleRequest routes /organization/delete through runSubjectErasureAtomically (auth-manager.ts:6715-6719), which wraps the whole vendor request in engine.transaction and throws SubjectErasureRollback on any status 400 or above (:7029-7048); the engine publishes the handle into the ambient store, so the adapter's writes and the hook's deletes join it (the federated dogfood (e) measures members and teams back after the 403). Per driver: SqlDriver (SQLite, PG, MySQL) opens a real transaction; the memory driver snapshots and restores (memory-driver.ts:1265-1277); Turso remote declares transactionsUnsupported and refuses beginTransaction (turso-driver.ts:3808-3816, remote-transport.ts:2057-2074), so there the engine warns once and runs with no rollback. On that arm what remains after a mid-way failure is whatever the vendor wrote before the failing statement. The changeset states this, and names the pre-write refusal as the guarantee there. That guarantee holds for every organization the door judges, and does NOT hold for the Default Organization, which the door deliberately does not judge: see B3. Side effects outside the datasource (activity rows on a separate telemetry datasource) are not rolled back; the dev measured 310 such rows and the changeset does not claim otherwise.

4. The engine refusal. walkReferencingRelations calls refuseOrganizationDeleteWhileOwned (engine.ts:19602) before the first relation of the walk, after collectCascadeDeleteSet has gathered the cascade set read-only (:19283-19290). The refusal probes every set_null tenant-column relation into sys_organization with the walk's own probeReferencingRows, counts rows outside the cascade set, and throws one DELETE_RESTRICTED naming every table (:20034-20090); the walk then skips those relations (:19614), so no set_null write is issued for a tenant column on the by-id path. isTenantColumnSetNull (:19981-19991) matches resolveTenantFieldName(child) === fieldName, injected or declared (system-write-organization.ts:199-215), so sys_session.active_organization_id, sys_api_key.active_organization_id and any parent-organization lookup keep their set_null, and an authored cascade or restrict keeps its meaning (an authored cascade on a tenant column would still delete rows on a direct engine delete; none exists in the tree, see B10). Paths: the by-id path (runByIdDelete, :20554) is covered, which is what the vendor's adapter.delete and os data delete reach. The predicate path (isPredicatePath, :20380, the driver.deleteMany arm) runs no cascade at all, as on main, and so neither refuses nor clears: B2. set_null on a tenant column is therefore impossible from every path that cascades. The exemption reads managedBy === 'append-only' (:20010-20012) in both the engine refusal (:20045) and the count (:20111).

5. The ledger set. managedBy: 'append-only' is declared on exactly eight objects in the tree: sys_activity, sys_audit_log, sys_email, sys_identity_email, sys_job_run, sys_metadata_audit, sys_approval_action, sys_presence. There is no ninth; "the platform-settings audit" is service-settings' writer into sys_audit_log (config-change-audit.ts:146), not an object. Of the eight, only sys_activity, sys_email (injected organization_id) and sys_approval_action (declared, sys-approval-action.object.ts:100) carry a tenant column; sys_audit_log, sys_identity_email, sys_job_run, sys_metadata_audit and sys_presence declare systemFields: { tenant: false } and have no tenant column, so the exemption is inert for them. sys_presence is deployment-level realtime state with no organization column (sys-presence.object.ts:24-32), so the challenge is moot: nothing of it names an organization. sys_approval_action is the append-only action history; the live request is sys_approval_request, managedBy: 'engine-owned' with an organization_id lookup (sys-approval-request.object.ts:46, :221) but tenancy.enabled: false (:219), so it is NOT counted as a record and its organization_id is cleared to NULL by the walk: B5. Closed under the declaration: no. The enum admits 'append-only' on any authored object (spec object.zod.ts:2349) and the form exposes it (object.form.ts:669), so an app author can make a business object's rows survive an organization delete, naming the deleted organization. Nothing is lost or NULLed by that; the declaration already means an immutable log with no user writes; I call it a declared consequence rather than a hole, flagged for the seat in B7. Kept rows: the ceremony's post-check predicates are IS NULL (organization-ownership-apply.ts:913, :1287, :1318, :1674); the boot guard decides on one field of the sys_migration marker (organization-ownership-boot-guard.ts:14-36); the census is a static read of source; the lifecycle sweep's global pass selects organization_id $nin windows (lifecycle-service.ts:272-276), so rows of a deleted organization are still aged out; the dangling-reference audit reports and never rewrites. One ledger does NOT keep its attribution: sys_audit_log's tenant_id lookup to sys_organization (sys-audit-log.object.ts:420-424, readonly, stamped by audit-writers.ts:2053 and read-audit.ts:651) is, by the object's own words, "never the tenancy anchor" (:28, :416), so isTenantColumnSetNull does not protect it and the walk's set_null arm clears it on every organization delete: B4.

6. Records counted. Business units, positions, sharing rules and settings count: the engine counts every tenant-column relation, the door excludes only the five structure tables, and dogfood pins sys_business_unit (1) refusing. The changeset states that these count and gives the remedy ("delete the organization's records, or move them to another organization"). It does not state the consequence the dev measured on a walled os serve with the showcase: every door-created organization receives the per-organization seed replay (organizations-plugin.ts:140-147), so a fresh organization answers 409 naming 19 tables and 132 rows before anyone touches it. Neither the PR body nor the changeset lists the 19 tables; I cannot read them without booting. The remedy is possible with existing doors (the dev deleted them over DELETE /api/v1/data/... in three ordered passes, then got 200), so the organization is not undeletable, but it is heavy. The seat's premise "a door-created organization has none at birth" holds only without seed datasets, and the pin for it runs on a posture-only boot without the organizations plugin, so it does not pin the seeded case. Not blocking under B as ruled; the changeset should say it: B6.

7. The Default Organization. Both the door refusal and the structure removal return early for it (isDefaultOrganizationTarget, organization-delete.ts:180-189: slug default, else the only organization, never under a wall). The last-admin guard's 403 is kept. On main the vendor's member and invitation deletes committed before that 403 (measured 1 to 0). With this fix on a transactional driver the request transaction rolls them back (federated dogfood (e) pins members and teams unchanged). On a driver without transactions the Default Organization path still deletes members and invitations before the guard answers, and nothing restores them: the (i) partial delete survives there: B3.

8. Tests and measurement. Covered: a refused delete changes nothing (engine test with a write log and row snapshots; dogfood (a) with per-table counts, orphan counts and the caller's active organization before and after); an empty organization deletes fully (dogfood (b), every count 0); per-table counts (engine, unit and dogfood); the engine refusal (engine test, dogfood (d)); the Default Organization (federated (e)); ledgers kept with 0 NULL (engine ledger pins; dogfood (b) on sys_activity). The two dogfood cases were extended, not weakened: org-record-admin-reach keeps the member and admin 403s and the owner's 200, and adds the structure-gone assertions plus a 409 control with members unchanged; the federated fixture keeps its 200 and adds nothing-names-it, (e) and a 409 control proving the count skips the federated anchor. The ablations are coherent: replacing the engine call reddens the three refusal pins; inverting the door gate reddens dogfood (a) while the status stays 409 (the engine net), which is exactly what the adapter mapping predicts (B8); the first gate ablation was a no-op and is reported as such. Not pinned: a transactionless driver; the predicate path; an unwalled object's organization lookup; sys_audit_log.tenant_id after a delete; the seeded-organization case. The dev ran objectql, plugin-auth and dogfood locally and not the repo-wide farm; two suites outside those packages pin the superseded contract and are red on the head: B1.

9. Changeset, docs, semver. minor for both packages is right: AGENTS.md:1087-1088 ("yes takes at least minor, (narrowing) is BREAKING") and check-changeset-no-major.mjs:58-62 (the launch window ships breaking changes as minor). Clause-②: yes (narrowing) fits: the 409 body gains dependents and ObjectQL.organizationOwnedRowCounts is new, and the accept set narrows; the seat accepted the change from the claim's no (narrowing). FROM, TO and a one-line fix are present, the ADR-0087 marker is present, and the 18.0.0 defect is stated as the 500 plus partial delete on a ceremonied database and the orphan to NULL on an 18.0.0-born one, which matches measurement 6297602613. No docs page documents organization delete, so none was owed. Sentences read against the code: "The refusal runs before better-auth's handler, so nothing is written, the caller's active organization included" true (crud-org.mjs:275-279 is after the hook). "A caller the organization does not let delete it still gets better-auth's own 403, with no count" true. "the engine itself refuses ... whatever path reaches the delete" false for a predicate delete (B2). "On a datasource whose driver has no transactions, the refusal above is the guarantee, because it runs before the first write" false for the Default Organization (B3). "Append-only ledgers (... sys_audit_log ...) ... their organization_id goes on naming the deleted organization, never NULL" false for sys_audit_log, which has no organization_id and whose tenant_id is cleared (B4). "A tenant column is never set to NULL" true as a statement about tenant columns; an unwalled object's organization_id is not one and is still cleared (B5). "Deployments that run single are not reached" true. "Every organization created through the door owns a default team" true.

10. Surface. The census page and counts file move 233 to 236 with exactly the three delete sites in organization-delete.ts (two inline names, one through the GRANT_OBJECT const), and every dependent figure moves by the same three (decidable 156 to 159, tenancy-enabled 155 to 158, threading a context 174 to 177, elevated 131 to 134, readable receiver 192 to 195, inline 107 to 109, const 49 to 50); "sources scanned" 681 to 684 matches the three non-test plugin sources added since the previous stamp (reader-locale-labels.ts, client-address-forwarding.ts, organization-delete.ts). check:tenant-audit-census and check:engine-double-contract run under Lint & Repo Gates, green on the head, so generator agreement is CI-verified; the pinned.json entry has the ledger's shape (file, verb delete, pinned 1). Outside claim 6303408016: the three generated files, accepted by the seat; nothing else. packages/spec/src/**: untouched. docs/adr/**: untouched. plugin-security was in the claim and is untouched; the grants are removed from plugin-auth instead, which the PR body explains.

② Semver level

minor on @objectstack/objectql and @objectstack/plugin-auth, declared Clause-②: yes (narrowing), BREAKING with FROM, TO and the fix in the body. Correct under AGENTS.md:1087-1088 and the launch-window rule in check-changeset-no-major.mjs:58-62. The claim's expected no (narrowing) was correctly re-declared from the diff: a new key on a published payload and a new public engine method are Clause-② yes.

③ Boundary flags

B1 BLOCKING. CI red on the required check Test Core, caused by this diff. Test Core (6/6) fails in @objectstack/runtime: src/default-organization-statements.golden.test.ts:192-207 ("the Default Organization deleted and recreated") deletes sys_organization directly while sys_member (1), pin_note (1), sys_user_permission_set (2) name it, and the new refusal throws (refuseOrganizationDeleteWhileOwned, engine.ts:20064, in the shard log). Test Core (5/6) fails in @objectstack/plugin-sharing#test (exit 1); the served log tail holds no test body and the full log host is refused from here, so attribution is by reading: the only plugin-sharing test that deletes an organization is business-unit-parent-delete-restrict.test.ts:161-166 ("an organization's delete completes on a tree: its units stay"), which pins the superseded contract (an organization delete leaving three sys_business_unit rows behind) and is refused under B. Both shards are green on main at the merge-base 93e6c1a5f. The aggregator Test Core fails at "Verify test shard results" (the API listing still shows it queued). Owner: github-actions runs the check; the red is the PR's. The other required checks, TypeScript Type Check, Build Core, Lint & Repo Gates and Governed Surface Queue Guard, are green on the head. Both tests must be rewritten to the ruled contract, not deleted.

B2 BLOCKING (false changeset sentence; the hole is pre-existing). "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". A predicate delete (engine.delete('sys_organization', { where, multi: true })) takes the deleteMany arm (engine.ts:20380, the isPredicatePath branch) which runs no cascade, so it is neither refused nor cleared: the organization row goes and every row keeps naming it, dangling but not NULL. This is main's behaviour for every object and is reachable only from system code in-process (no REST multi-delete route; the CLI deletes by id). Fix: reword to the by-id path, or refuse a predicate delete of sys_organization in the engine. Not a NULL write.

B3 BLOCKING (false changeset sentence; residual partial delete on one driver arm). "On a datasource whose driver has no transactions, the refusal above is the guarantee, because it runs before the first write." The door refusal and the structure removal are skipped for the Default Organization (auth-manager.ts:8125, :3887; organization-delete.ts:180-189), so on a transactionless driver (Turso remote, transactionsUnsupported, turso-driver.ts:3808-3816) the vendor deletes members and invitations (adapter.mjs:373-386) before the last-admin guard's 403 lands on the organization row, and nothing restores them: measurement (i)'s partial delete, narrowed to the Default Organization on a non-walled posture over Turso remote. The PR body's "now with nothing changed behind the answer" was measured on SQLite only. Fix: state the residual in the changeset, or ask the guard's question in hooks.before for the Default Organization so its 403 also lands before the first write.

B4 BLOCKING (false changeset sentence; a NULL write into an append-only ledger). The changeset lists sys_audit_log among the ledgers whose "organization_id goes on naming the deleted organization, never NULL". sys_audit_log has no organization_id (systemFields: { tenant: false }, sys-audit-log.object.ts:31). Its attribution column tenant_id is a readonly lookup to sys_organization (:420-424), stamped with the organization id by audit-writers.ts:2053 and read-audit.ts:651, and by the object's own declaration "never the tenancy anchor" (:28, :416), so isTenantColumnSetNull leaves it to the walk's set_null arm: every organization delete rewrites tenant_id = NULL on every audit row about that organization. This is main's behaviour too, and it is the "other lookups are cleared as before" arm the changeset also declares, but the same changeset promises the opposite for this ledger, and the seat decision's reason for keeping ledgers (history outlives what it audits) is defeated for the audit log. Not measured by the PR: the ledger pins read sys_activity only. Fix: extend the walk's skip to lookups on append-only objects (keep them, as sys_activity is kept), and pin sys_audit_log in dogfood (b); or remove sys_audit_log from the sentence and say its attribution is cleared.

B5 (not blocking; question for the seat). An unwalled object's organization lookup is outside "never NULL": sys_approval_request declares tenancy.enabled: false (sys-approval-request.object.ts:219) with an organization_id lookup (:221), so resolveTenantFieldName answers null, the row is not counted, and the walk clears its organization_id to NULL on the delete. sys_api_key.active_organization_id is the same shape and is a session-like pointer by design. Pre-existing; the ruling's "the engine never sets organization_id to NULL" was written about tenant columns. The seat should say whether an unwalled sys object's attribution column is inside it.

B6 (not blocking; the changeset should state it). On a composition with the organizations plugin and app seed datasets, a door-created organization is never empty: the dev measured 409 naming 19 tables and 132 rows on a fresh showcase organization, and the remedy over the data API took three ordered passes. The tables are not listed in the PR body or anywhere readable. This is B as ruled, no data is lost, and a purge command is a follow-up; but the changeset's "Fix" line should name the seed replay so an operator is not surprised, and the seat's premise "none at birth" should be restated as "none at birth without seed datasets". The pin for it runs on a boot without seed replay.

B7 (not blocking). The ledger set is open by declaration: any author may declare managedBy: 'append-only' (spec object.zod.ts:2349, authorable via object.form.ts:669) and so make a business object's rows survive an organization delete, naming the deleted organization. Nothing is deleted or NULLed by that, and the declaration already removes user writes, so I do not call it a hole; the seat asked for exactly this declaration. Five of the eight declared objects have no tenant column, so the exemption is live for sys_activity, sys_email and sys_approval_action only; sys_presence is deployment-level with no organization column, so its membership is inert. The count "seven more" in the PR body and report 6305715779 is one too many (eight objects, not nine).

B8 (not blocking). When the engine net answers through the door (the Default Organization, or a row inserted between the count and the delete), the adapter maps the engine's DELETE_RESTRICTED to { dependentObject, dependentCount } only (objectql-adapter.ts:618-631), so the wire 409 carries no dependents list; the PR's ablation observed exactly this. The changeset's "the same dependents list" is true at the engine error and not on the wire for that path.

B9 (not blocking). refuseOrganizationDeleteWhileOwned probes with probeReferencingRows, a find with no limit (engine.ts:19485), so a direct engine delete of a large organization reads every owning row of every table into memory before refusing; the door's count uses count. Performance only.

B10 (not blocking; note under "no deletion"). isTenantColumnSetNull intercepts set_null only. An author-declared deleteBehavior: 'cascade' on a tenant column would delete rows on a direct engine delete of the organization; the door refuses first because the count is widened to every behaviour. No platform object declares one. The changeset declares "An authored cascade / restrict keeps its meaning".

B11 (not blocking). The elevation record the refusal files per probed table is a logger.info (engine.ts:19068 onward), not a write, so "a refused delete writes nothing" holds. Activity rows on a separate telemetry datasource written during a rolled-back request persist; declared by the dev, not by the changeset.

CI on the head (item 11). Red: Test Core (5/6) failure, Test Core (6/6) failure, Test Core (aggregator) failure at "Verify test shard results"; all github-actions, all caused by this diff (B1). Green among the required checks: TypeScript Type Check, Build Core, Lint & Repo Gates, Governed Surface Queue Guard. Skipped (opt-in): 18.x upgrade harness, Packed-tarball smoke, Temporal Conformance, Dogfood Verify CLI.

Open questions for the seat or the maintainer. (1) B4: keep an append-only ledger's non-tenant lookups to sys_organization too, or accept that sys_audit_log's attribution is cleared and say so. (2) B5: is an unwalled sys object's organization_id (sys_approval_request) inside "never NULL". (3) B3: is the Default Organization's partial delete on Turso remote accepted as a declared residual, or must the guard answer before the vendor's first write. (4) B6: whether the seeded-organization consequence is to be named in the changeset now or left to the purge follow-up.

Implemented-by: claude/issue-24948-org-delete-refuse-nonempty
Reviewed-by: session_01Y5583bs2RDd53MH6kM1ca3

VERDICT: FAIL

…ion is asked before better-auth deletes any member, judged with the members that leave with it

Claude-Session: https://claude.ai/code/session_016gmigttoA9DyRNtz6SepWf
Co-authored-by: Claude <noreply@anthropic.com>
…aller's own count, with no isSystem shortcut

Claude-Session: https://claude.ai/code/session_016gmigttoA9DyRNtz6SepWf
Co-authored-by: Claude <noreply@anthropic.com>
…judge pins

Keeps the query-options-erasure test surface at its 236 ceiling.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016gmigttoA9DyRNtz6SepWf
…n, 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
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review (delta re-review after FAIL at 0573a96)

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: e184b513c426f05a3e764e0500f7afd19cfb22e8
Local-runs: none
Scope: .changeset/24948-organization-delete-refuse-nonempty.md, packages/objectql/src/engine.ts, packages/objectql/src/engine-organization-delete-refusal.test.ts, packages/objectql/src/federated-injected-column-readers.test.ts, packages/plugins/plugin-auth/src/auth-manager.ts, packages/plugins/plugin-auth/src/auth-plugin.ts, packages/plugins/plugin-auth/src/last-admin-guard.ts, packages/plugins/plugin-auth/src/last-admin-guard.default-organization.test.ts, packages/plugins/plugin-auth/src/objectql-adapter.ts, packages/plugins/plugin-auth/src/objectql-adapter.test.ts, packages/plugins/plugin-auth/src/organization-delete.ts, packages/plugins/plugin-sharing/src/business-unit-parent-delete-restrict.test.ts, packages/runtime/src/default-organization-statements.golden.test.ts, packages/qa/dogfood/test/organization-delete-federated-fixture.dogfood.test.ts, packages/qa/dogfood/test/organization-delete-refuse-nonempty.dogfood.test.ts; read to judge them: packages/core/src/security/tenant-layer0-verdict.ts, packages/spec/src/security/tenancy-posture.ts, packages/spec/src/data/object.zod.ts, packages/objectql/src/tenancy/system-write-organization.ts, packages/plugins/plugin-auth/src/identity-write-guard.ts, packages/plugins/plugin-security/src/system-write-guard.ts, packages/platform-objects/src/identity/sys-organization.object.ts, packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts, packages/plugins/plugin-approvals/src/sys-approval-request.object.ts and sys-approval-delegation.object.ts, and the 31 object files declaring managedBy: 'engine-owned'.

Read-only. The head e184b513c426f05a3e764e0500f7afd19cfb22e8 was checked out in a detached worktree and read against the delta of the dev's own commits since 0573a968 (3889a6f, dbc61ef, 6635c3c, c1dd702, ba33df8, 979fb8a, e184b51; the merge of main 20f8395 excluded), the previous record, the seat decisions 6305179819, 6305996369, 6307135388 and 6307815346, the dev reports 6307116886 and 6307797657, the live PR body, and the check-runs on the head and on 0573a968. Nothing was built, run or posted. Two commit labels in the brief are transposed on the branch: "judged with the members that leave with it" is 6635c3c (dbc61ef is the B3/B6/B8 round), and "asks the caller's own count, with no isSystem shortcut" is ba33df8 (c1dd702 drops an unused test binding).

① Derived judgments

1. B1: CLOSED (code), CI pending at the time of writing, see item 10. packages/runtime/src/default-organization-statements.golden.test.ts:190-233 now pins the ruled contract: a direct delete of the Default Organization while rows name it is refused DELETE_RESTRICTED 409 and standing survives (:193-200, the positive control), then the ruled remedy runs (organizationOwnedRowCounts lists what it owns with a PREMISE that it is non-empty :205-206, every owning row is moved to a holding organization :207-218, the organization is deleted and recreated :219-221, the rows are moved onto the new one :223-225, the holding organization is deleted :226), and the memo assertions the test exists for are kept unchanged (:229-230). packages/plugins/plugin-sharing/src/business-unit-parent-delete-restrict.test.ts:163-172 pins the refusal with dependents: [{ sys_business_unit, 3 }] and the tree whole, and :174-181 is the positive control (units deleted leaf to root, then the organization deletes, tree empty); the header :26-29 says so. Neither test is deleted; the old "its units stay" assertion is replaced by the stronger refusal, not weakened. The count 3 is disclosed because the SYS context's own count equals the elevated one (engine.ts:20105-20118).

2. B2: CLOSED. engine.ts:20491-20493 throws organizationPredicateDeleteRefusal() (:754-769: code DELETE_RESTRICTED, status 409, object sys_organization, a message and developerMessage naming the by-id route) for any sys_organization delete that is not by id, placed after the dispatch ladder resolves and before the pre-image read (:20531) and the beforeDelete hooks (:20537), so before any hook or write. Pinned at engine-organization-delete-refusal.test.ts:391-413 (refused, no write, both organization rows still there; CONTROL: a multi delete of another object is not caught by this rule). The changeset sentence (changeset :19 "An organization is deleted by id only ... refused with 409 DELETE_RESTRICTED naming the by-id route") is true. No in-tree non-test source deletes sys_organization at all, so nothing else is reached by the narrowing.

3. B3: the guard's question CLOSED; the "no transactions" sentence NOT CLOSED on one arm (R1).

  • Asked in hooks.before: yes. auth-manager.ts:2452-2455 calls assertOrganizationDeletable ahead of the vendor handler; :8148 computes defaultOrganization; :8163-8174 calls organizationDeleteStandingJudge for it and maps the guard's PERMISSION_DENIED to an APIError('FORBIDDEN', { code: 'PERMISSION_DENIED' }), which lands before the vendor's first write on every driver (the vendor's setActiveOrganization(null), member and invitation deletes all run inside its handler, after hooks.before, per the round-1 reading of crud-org.mjs:275-286). The judge is registerLastAdminGuard's handle (last-admin-guard.ts:1886-1903), wired at auth-plugin.ts:1695-1701; this.authManager is created at :681, before that wiring, so the handle is set on every composition that registers the guard.
  • Pinned without relying on the transaction: yes. Federated dogfood (e) (organization-delete-federated-fixture.dogfood.test.ts:163-191) records every beforeDelete the request attempts on sys_member and sys_invitation at the engine (:172-176) and asserts attempted is empty (:184), beside the members/invitations/teams-unchanged assertions; the positive control at :141-153 proves the recorder fires on a delete that proceeds (:148-149). Seam pins at last-admin-guard.default-organization.test.ts:178-210: refused on the grant anchor, refused when the only standing is owning the Default Organization (which needs the member companion; the dev's ablation reddens exactly that case), admitted when another administrator stands elsewhere.
  • "Judged with the members that leave with it" (6635c3c): judgeOrganizationDelete scans the organization's members under scan (fail-closed ceiling, :912-929) and passes them as a with companion (:1892-1901); pendingFor (:640-647) and applyPending (:663-673) read the companion per table; applyPending stays one-directional, so the simulation can only remove standing. It cannot open a way to delete the last platform administrator's organization: the member half removes the owner/admin memberships of the deleted organization (:1183-1189), the organization half removes the Default Organization from readDefaultOrganizationId (:1005-1040) so its grants stop anchoring (:1049-1065, :1136-1152), and the config anchor (:1221-1243) and organization-less grants are untouched, which is what really survives. It cannot wrongly refuse: nothing the real request keeps is simulated away, and the engine-side guardOrganizationDelete (:1810-1814, registered :1872) still judges the organization row on the real state after the vendor's member deletes, so the two answers coincide.
  • Is the "no transactions" sentence now true? Not on the admitted arm. Changeset :18 says "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". The record count does NOT run for the Default Organization: :8163-8174 returns after the guard admits, before organizationDeleteRefusal at :8176; and beforeDeleteOrganization (:3908) skips its structure removal. So when the guard ADMITS the Default Organization's delete, the vendor deletes its members and invitations, and only then does the engine refuse (refuseOrganizationDeleteWhileOwned, :20143) on the teams, the grants and the records it owns, which under single is everything, the promotion grant included. On a transactional driver that 409 rolls the request back (:7028-7057); on Turso remote it does not, and the members and invitations stay deleted: measurement (i)'s partial delete, on the admitted arm. The guard admits exactly when another administrator stands elsewhere: an organization-less admin_full_access grant, or a config-anchored platform owner (resolvePlatformAdminEmails, :1221-1243, read under every posture), which is the OS_PLATFORM_OWNER_EMAIL the RUNNER recipe sets. Changeset :8 "Deployments that run single are not reached: their one organization keeps the last-administrator guard's 403" is false on that configuration too. See R1.

4. B4: CLOSED. The walk keeps every set_null lookup to sys_organization on an append-only or engine-owned object: engine.ts:19637 skips the tenant column (already judged), :19639-19640 skips keepsOrganizationReference (:20065-20068), whose first arm is outlivesOrganization (:20042-20045). sys_audit_log declares managedBy: 'append-only' and systemFields.tenant: false, so its tenant_id lookup (sys-audit-log.object.ts:420) resolves set_null through cascadeRelationBehavior (:19396-19470, lookup default) and is kept. Pinned at the seam with an AUDIT_LIKE fixture of exactly that shape (engine-organization-delete-refusal.test.ts:162-172, :367-375: kept, no write) and on the wire in dogfood (b) (organization-delete-refuse-nonempty.dogfood.test.ts:208-211 premise, :225-226 every sys_audit_log row that named the organization still does). No lookup to sys_organization on any append-only or engine-owned object declares cascade or restrict (grep over every .object.ts), so no row of theirs is deleted or blocks.

5. B5 as re-decided: CLOSED.

  • One predicate: ObjectQL.outlivesOrganization (:20042-20045, managedBy 'append-only' or 'engine-owned') is read by the engine refusal (:20153), by organizationOwnedRowCounts, which is the door's count (:20214; plugin-auth calls it at organization-delete.ts:136), and by keepsOrganizationReference (:20066) for the walk. No list of names anywhere.
  • The 17 objects: each declaration carries managedBy: 'engine-owned' (sys-approval-request:46, -approver:34, -token:22, sys-automation-run:44, sys-notification:34, notification-receipt:28, notification-delivery:27, http-delivery:36, sys-mailbox:33, sys-mail-message:36, sys-mail-association:31, sys-audience-binding-suggestion:38, sys-record-share:41, sys-share-link:49, sys-import-job:28, sys-setting:48, sys-setting-audit:41) and each either declares organization_id or takes the injected one. The other 14 engine-owned objects all opt out of the tenant column (systemFields: { tenant: false }, or tenancy.enabled: false on sys_metadata_activation; sys_scim_connection_credential carries no organization column), so the PR body's split 17 / 14 is exact. The ADR-0103 write guard (system-write-guard.ts:1-60) refuses user-context writes to both buckets, so "an administrator cannot delete them over the data API" is true by the same declaration. Beyond the three the seat already decided (sys_setting, sys_mailbox, manual shares and share links), none is an administrator-managed record: approvals, approvers, tokens, runs, notifications, receipts (whose read state a recipient sets through the messaging API, still an engine-written row), HTTP deliveries, mail associations, import jobs, setting audits and audience-binding suggestions are all platform-written consequences. Two to name for the record, not to flag: sys_mail_message is mirrored inbound correspondence (a copy of what the mailbox holds), and sys_import_job is the state of a user-started import; both are residue once the records are gone. The round-1 premise that sys_approval_request is tenancy-disabled was a misreading of a comment (sys-approval-request.object.ts:217-219 describes sys_api_key); the object declares no tenancy key and organization_id at :221, so it is walled, as the dev measured. The closed B5 rule (second arm of keepsOrganizationReference) therefore matches no object in the tree today; it is pinned only by the REQUEST_LIKE fixture (:174-182, :377-389).
  • Business data an administrator would expect to block a delete: none exempted. Business units, positions (sys_user_position, not engine-owned), sharing rules (sys_sharing_rule) and approval delegations (sys_approval_delegation, managedBy: 'system-data') still count.
  • The approval pin is real: engine-organization-delete-refusal.test.ts:329-364 deletes an organization whose only rows are an engine-owned request and two append-only actions, with the organization gone, both rows kept naming it, and no write to either table (:351); the CONTROL (:353-363) adds one record beside them and gets a refusal naming only the record. Dogfood :240-256 repeats it on the wire through the door (200; request and actions unchanged; 0 rows with NULL in either table).
  • Pointer lookups still set_null: sys_session.active_organization_id, sys_api_key.active_organization_id (tenancy-disabled, field name not organization_id) and sys_organization.parent_organization_id (better-auth bucket, not outlivesOrganization). Correct: a pointer to a deleted organization is not attribution, and clearing a child's parent pointer is pre-existing behaviour. Pinned for the session shape at :272-282 and :377-389.

6. B6: CLOSED. Changeset :31 names the seed replay, the composition that triggers it and the remedy. The 19 tables / 132 rows figure (changeset :31, PR body B6) predates the engine-owned exemption and the seat asked for a re-measurement (6307815346); the dev's report 6307797657 says the walled os serve figure was not re-measured on this head. Noted, not failed.

7. B8 and B9: CLOSED; the caller-scoped count is no hole. B8: objectql-adapter.ts:596-606 re-reads dependents from the engine error and :650 carries it on the APIError('CONFLICT') body, a withheld count staying absent; pinned at objectql-adapter.test.ts:559-576. B9: countReferencingRowsOutside (:20079-20097) issues this.count under referenceCheckContext unless the cascade set already holds rows of that table (none on the platform); pinned at :415-427 (five owning rows, no find of the owning table). On the disclosure count: the refusal is decided by the elevated count alone (:20160-20161 push on a count above zero); organizationCountIsDisclosable (:20105-20118) only decides whether the number rides along, a mismatch or a thrown read names the table without a count, and the refusal stands. An undercount under the caller's sharing scope cannot let a delete through. What CAN narrow the elevated count is the tenant wall, not sharing: referenceCheckContext keeps the caller's tenantId (:19044-19046, by design for ordinary probes), and driverReadScope (:7245-7284) projects a walled posture onto a system read whenever the context names an organization (computeTenantLayer0Verdict, core tenant-layer0-verdict.ts:158-161); a bare system context with no tenantId resolves deny and is left unscoped (:7278-7283), which is why the door's { isSystem: true } count and dogfood (d) see every organization. So a by-id engine delete of organization B under a system context carrying tenantId: A on a walled posture counts B's rows as 0 and deletes the row, leaving B's rows naming a deleted organization (dangling, not NULL). Reachability: user-context deletes of sys_organization are refused outright by the identity write guard (identity-write-guard.ts:176-183, registered beforeDelete at :274), the vendor adapter's context is { attributedUserId, isSystem: true } with no tenantId (objectql-adapter.ts:718-724), and no in-tree source deletes sys_organization; only an in-process caller that elevates with a foreign tenant scope reaches it. Not a hole in the ruled contract; R2 names the one-line hardening.

8. Invariants. No NULL write into any organization or tenant column: holds; the tenant-column set_null arm is skipped on the by-id path (:19637) and the predicate path is refused (:20491). No deletion beyond the organization's own auth structure: holds; removeOrganizationAuthStructure (organization-delete.ts:209-229) is unchanged, and no outliving object declares cascade on an organization lookup. The refusal runs before any write: holds for every organization the door counts and for the Default Organization when the guard refuses; does NOT hold for the Default Organization when the guard admits (R1). Atomic where the driver allows: holds (:6735-6740 routes the path through runSubjectErasureAtomically, :7028-7057). The Default Organization stays protected: holds on every transactional driver (403 or 409 with nothing changed); on Turso remote the admitted arm leaves it standing with its members and invitations gone.

9. Changeset and semver. minor on both packages, Clause-②: yes (narrowing), FROM, TO, Fix, Seed datasets and the ADR-0087 marker are present. Read against the code: :12 true; :13 true (audit tenant_id kept, B4); :14 true (the list matches the 17); :15 true; :16 true (the rule is implemented; no object matches it today); :17 true; :18 FALSE on the Default Organization's admitted arm (R1); :19 true (predicate refusal, counts not reads, dependents on the wire from the engine path through :650); :20 true; :8 "Deployments that run single are not reached ... keeps the last-administrator guard's 403" FALSE with a config-anchored platform owner or an organization-less grant, where the guard admits and the engine's 409 answers after the vendor's writes (R1); :30 true as re-decided; :31 stale figure (item 6). organization-delete.ts:46-50 and :55-58 restate the :18 sentence and carry the same gap.

② Semver level

minor on @objectstack/objectql and @objectstack/plugin-auth, Clause-②: yes (narrowing), BREAKING with FROM, TO and the fix in the body. Unchanged from the previous record and still correct: the 409 body gains dependents, ObjectQL.organizationOwnedRowCounts is new, the accept set narrows (a predicate delete of sys_organization is now refused, which is a further narrowing this round), and the launch-window rule ships breaking as minor.

③ Boundary flags

R1 BLOCKING (false changeset sentences; residual partial delete on one driver arm, narrowed). For the Default Organization the door asks only the guard (auth-manager.ts:8163-8174) and never the record count (:8176 is unreachable for it), and its structure is not removed (:3908). When the guard admits, which it does whenever an administrator stands outside the organization (an organization-less admin_full_access grant, or a verified OS_PLATFORM_OWNER_EMAIL user, last-admin-guard.ts:1221-1243), the vendor clears the caller's active organization and deletes the members and invitations, and the engine then refuses on the teams, the grants and the records the Default Organization owns (:20143-20195), which under single is every row the deployment wrote, the promotion grant included. On SQLite, Postgres and MySQL the 409 rolls the request back; on Turso remote (transactionsUnsupported) the members and invitations stay deleted and the organization survives with no owner: measurement (i), on this arm. Changeset :18 ("both run before the first write: the record count above, and, for the Default Organization, the last-administrator guard") and :8 ("their one organization keeps the last-administrator guard's 403") state the opposite; organization-delete.ts:46-58 too. Not pinned: dogfood (e) runs with no config anchor, so the guard refuses. Fix, narrowest: in the defaultOrganization branch, after the judge admits, run organizationOwnedRowCounts(organizationId, { exclude: ['sys_member', 'sys_invitation'] }) (its teams and grants are not removed for it, so they count) and throw the same 409 before returning, so the Default Organization is refused before the first write on every driver; pin it with the (e) recorder shape under a config anchor (admitted by the guard, 409, no member or invitation delete attempted); then :8 and :18 become true as written. Alternatively state the residual in the changeset, which the seat's B3 answer declined.

R2 (not blocking). The engine refusal's elevated count keeps the caller's tenant scope (referenceCheckContext, :19044-19046; driverReadScope, :7245-7284): a system caller that passes tenantId for a DIFFERENT organization on a walled posture counts 0 and deletes the organization row, leaving its rows dangling (never NULL). No such caller exists (item 7), the vendor adapter carries no tenantId, and a user-context delete is refused by the identity write guard before the cascade. Hardening: the organization count's filter is already organization_id = id, so countReferencingRowsOutside and organizationOwnedRowCounts could drop tenantId from the elevated context (a bare { ...context, isSystem: true, tenantId: undefined }), which the wall then leaves unscoped; the ordinary probes keep their scope.

R3 (not blocking). isDefaultOrganizationTarget runs inside the try at auth-manager.ts:8127-8153, so a failed sys_organization count (organization-delete.ts:192) hands the Default Organization back to the vendor (:8153), where the guard's engine hook answers only after the member and invitation deletes, the pre-fix shape. failClosed covers the judge, not this lookup. A transient read failure on one table; worth moving the Default Organization decision outside the catch so a lookup failure refuses rather than falls through.

R4 (not blocking). The second arm of keepsOrganizationReference (:20067, an organization_id on a tenancy-disabled object) matches no object in the tree; the PR body says so. It is pinned only against a synthetic fixture. Harmless; flagged so a later object that declares tenancy.enabled: false with an organization_id is known to be kept and not counted by this rule.

R5 (not blocking; stale figure). The seed-replay figure 19 tables / 132 rows in changeset :31 and the PR body was measured before the engine-owned exemption and has not been re-measured on this head (report 6307797657). The seat has already asked for the re-measurement before landing.

CI on the head (item 10). At 04:22Z, 15 minutes after the run started at 04:06:58Z, the six Test Core shards were still in progress; on the previous head the shards took 15 to 33 minutes. Green and completed among the required checks: Build Core, Build Docs, Type Check (workspace, source gates, consumer gates, debt ledger), every Lint lane (ESLint, CI/release/engine gates, docs and code guards, repo and tooling gates, scoped lanes), Governed Surface Queue Guard, Check Changeset, Dogfood Regression Gate, Spec property liveness. Skipped (opt-in): 18.x upgrade harness, Packed-tarball smoke, Temporal Conformance, Dogfood Verify CLI, Console Pin Gate. Final poll at 04:25:55Z: Test Core (1/6) success, Test Core (5/6) success (the shard that carried the plugin-sharing red at 0573a96); Test Core (2/6), (3/6), (4/6) and (6/6) still in progress, the aggregator not yet run. No red check on the head at that time. Addendum from the watch at 04:26:04Z: Test Core (6/6), the shard that carried the runtime golden-test red at 0573a96, completed success, so both shards B1 reddened last round are now green on the head; (2/6), (3/6) and (4/6) and the aggregator were still pending, so the seat should still read the aggregator before acting on this record. Owner of every Test Core run: github-actions.

Implemented-by: claude/issue-24948-org-delete-refuse-nonempty
Reviewed-by: session_01Y5583bs2RDd53MH6kM1ca3

VERDICT: FAIL

…ted before the first write (#24948)

Delta review 6308041940:
- R1: once the last-administrator guard admits the Default Organization's
  delete, its record count runs in hooks.before too, leaving out only the
  members and invitations better-auth removes; a refusal is the same 409.
- R2: the organization row counts run as the system with none of the
  caller's tenant scope, so a foreign tenantId cannot undercount.
- R3: deciding whether the target is the Default Organization is outside
  the fall-through, so a failed lookup refuses.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016gmigttoA9DyRNtz6SepWf
…aches better-auth's first write (#24948)

The recorder now also watches the session's active-organization write,
the vendor handler's first, so the pin goes red when the lookup falls
through (ablated).

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016gmigttoA9DyRNtz6SepWf
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review (delta re-review after FAIL at e184b51)

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 139abc45a68ca76907c1a8d3da08774aa6b19e9a
Local-runs: none
Scope: .changeset/24948-organization-delete-refuse-nonempty.md, packages/objectql/src/engine.ts, packages/objectql/src/engine-organization-delete-refusal.test.ts, packages/plugins/plugin-auth/src/auth-manager.ts, packages/plugins/plugin-auth/src/organization-delete.ts, packages/plugins/plugin-auth/src/organization-delete.test.ts, packages/qa/dogfood/test/organization-delete-federated-fixture.dogfood.test.ts; read to judge them: packages/plugins/plugin-auth/src/objectql-adapter.ts, packages/plugins/plugin-auth/src/last-admin-guard.ts, packages/plugins/plugin-security/src/security-plugin.ts, packages/plugins/plugin-security/src/tenant-layer.ts, packages/core/src/security/tenant-layer0-verdict.ts, packages/core/src/security/platform-admin.ts, packages/spec/src/kernel/execution-context.zod.ts, packages/spec/src/security/tenancy-posture.ts, packages/objectql/src/tenancy/system-write-organization.ts, packages/platform-objects/src/identity/sys-team.object.ts, sys-member.object.ts, sys-invitation.object.ts, sys-team-member.object.ts, packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts, and better-auth 1.7.3 (the version plugin-auth pins and the lockfile resolves) read from the registry tarball: dist/plugins/organization/adapter.mjs, dist/plugins/organization/routes/crud-org.mjs, dist/api/dispatch.mjs.

Read-only. The head 139abc45a68ca76907c1a8d3da08774aa6b19e9a was checked out in a detached worktree and read against the delta e184b51..139abc4 (e40aea8 and 139abc4, no main merge, 7 files, +252/-25), the previous record (FAIL at e184b51), the dev's round-5 report 6308803407, the live PR body's Round 5 section and rewritten item 6, and the check-runs on the head polled twice (05:33Z and 05:36Z). No node_modules exists on this machine, so the vendor's delete order and what its deleteOrganization removes were re-read from the 1.7.3 tarball downloaded into an empty scratch directory, as text. Nothing was built, run or posted. B1 to B11 of the earlier record stay CLOSED; nothing in the delta touches them.

① Derived judgments

1. R1: CLOSED.

  • Asked before the first write, on every door path: assertOrganizationDeletable (auth-manager.ts:8122-8190) is called from the global hooks.before at :2453-2456, which better-auth runs before the endpoint handler (dispatch.mjs:207 runBeforeHooks, the handler at :226-233). The vendor's first write is adapter.setActiveOrganization(session.token, null) (crud-org.mjs, the /organization/delete route at :239, the write at route line 41), then beforeDeleteOrganization, then adapter.deleteOrganization (adapter.mjs:370-395: deleteMany member where organizationId, deleteMany invitation where organizationId, delete organization; no team, no team member). In the door: the Default Organization decision at :8160-8162; the guard's question at :8173-8181 (the return that used to follow an admission is gone); then the count at :8183-8185, defaultOrganization ? defaultOrganizationDeleteRefusal : organizationDeleteRefusal; the throw at :8186-8189. So once the guard admits (an organization-less admin_full_access grant, or a verified OS_PLATFORM_OWNER_EMAIL user, last-admin-guard.ts:1221-1243), the count runs before setActiveOrganization(null) on every driver. When no judge is registered (organizationDeleteStandingJudge unset, :1655) the count now runs too, where before the request was handed to the vendor: narrower, not wider.
  • The exclusion is exactly sys_member and sys_invitation: VENDOR_REMOVED_ON_DELETE (organization-delete.ts:167), passed as exclude at :184; pinned at organization-delete.test.ts:111-119 (the asked recorder sees exactly that exclusion, calls is empty so no grant is subtracted). Right, because that is exactly what the vendor removes (adapter.mjs:370-395), and the vendor removes ALL of them: the plugin-auth adapter's deleteMany (objectql-adapter.ts:1118-1133) finds every row matching the filter under a system read context and deletes each by id, so no member or invitation row of the organization survives better-auth's delete to be hidden by the exclusion. Teams and grants count: organizationOwnedRowCounts (engine.ts:20224-20250) counts every object whose resolveTenantFieldName (system-write-organization.ts:199-214) answers a column and whose relation to sys_organization resolves; sys_team declares organization_id (sys-team.object.ts:133) and sys_user_permission_set declares it (sys-user-permission-set.object.ts:110), neither opts out of tenancy, and beforeDeleteOrganization returns before removeOrganizationAuthStructure for the Default Organization (auth-manager.ts:3909), so both are rows the delete would leave and both count. sys_team_member reaches the organization only through team_id (sys-team-member.object.ts:135), so it is neither counted nor needed: its team is. The engine's own refusal at the organization row (:20160-20214) counts members and invitations too, after the vendor has deleted them inside the request's unit (ambient txStore, :7148-7151), so the two answers agree.
  • The refusal is the same: APIError('CONFLICT', { code: ORGANIZATION_DELETE_REFUSAL_CODE, ...organizationNotEmptyBody(owned) }) (:8187-8188), DELETE_RESTRICTED with message and dependents: [{ object, count }] (organization-delete.ts:78, :192-203).
  • The dogfood pin is discriminating (organization-delete-federated-fixture.dogfood.test.ts:193-243): the config anchor is real (OS_PLATFORM_OWNER_EMAIL = 'admin@objectos.ai', email_verified set true, resetPlatformAdminEmailMemo() so resolvePlatformAdminEmails re-reads the env, :199-205; the guard's judgeOrganizationDelete with the member companion then finds standing in the config anchor, last-admin-guard.ts:1221-1243, :1892-1901), and the 409 at :228 proves the anchor worked, because pin (e) on the same boot without it answers 403 (:183). Then DELETE_RESTRICTED (:230), showcase_account named (:234), sys_member and sys_invitation not named (:235), attempted empty on the sys_member/sys_invitation beforeDelete recorder (:236), organization row, members, invitations and teams unchanged (:237-240). The stated ablation (skip the Default Organization's count) really removes the count: the door then passes, the vendor's member deletes fire the recorder (the control at :141-153 proves beforeDelete fires on the vendor's member delete), and the engine refuses 409 afterwards, so the status assertion stays green and attempted goes red. Env and email_verified are restored in finally (:209-215).
  • Remaining paths: none that skips the count on a request the door decides. The pre-existing fall-through in the try (:8127-8158: no actor, no member row, vendor would refuse, no organization row, or one of THOSE lookups throwing) hands the request to the vendor uncounted; it is infrastructure-only (not caller-triggerable), was accepted at B3 and R3 as the door's design, and the engine's refusal still answers inside the transaction. Named as S1, not blocking. A non-default organization takes the unchanged organizationDeleteRefusal path (:8185). A direct engine delete bypasses the door but the engine's refusal counts every tenant-column table, members included (:20160-20214), so it is refused while anything names the row, never skipped. A predicate delete is refused outright (:20491, B2).

2. R2: CLOSED. organizationCountContext (engine.ts:20109-20115) is { isSystem: true } plus transaction and traceId when the caller's context carries them; both exist on ExecutionContext (execution-context.zod.ts:560, :563). Both organization counts use it: countReferencingRowsOutside at :20093 and organizationOwnedRowCounts at :20242. It drops nothing the count needs: the transaction handle is kept explicitly, and the engine also falls back to the ambient unit (buildDriverOptions, :7148-7151, explicit wins, txStore is the safety net), which is what the request-level unit (runSubjectErasureAtomically, auth-manager.ts:7029-7057, engine.transaction) and the vendor's own writes (adapter context { attributedUserId, isSystem: true }, no handle) ride on; so the engine-side count sees the request's uncommitted member and invitation deletes. It carries nothing that scopes: no tenantId, no accessible_org_ids (what accessibleOrganizationsOf reads, :4347-4350), no userId, positions, permissions, rlsMembership or org_user_ids. driverReadScope (:7245-7284) with no tenantId resolves none under single and deny under a walled posture (tenant-layer0-verdict.ts:155-161), and deny with no active organization returns undefined, unscoped (:7281-7282); the security middleware short-circuits every isSystem operation before any RLS, sharing or tenant projection (security-plugin.ts:2439-2441). traceId is tracing only. The objectql pin is real with its CONTROL (engine-organization-delete-refusal.test.ts:391-430): the middleware rewrites ast.where on acct counts by context.tenantId (:396-403), count runs the middleware chain with the AST on opCtx (:20843-20848), so the CONTROL engine.count under the foreign tenant reads 0 (:412), then organizationOwnedRowCounts(ORG_ID, { context: foreign }) reads 2 (:414), and the engine delete under { ...foreign, isSystem: true } is refused DELETE_RESTRICTED naming acct with no write (:418-429). Reverting to referenceCheckContext would carry the foreign tenantId into both counts and redden both. Ordinary reference probes keep referenceCheckContext (:9900, :10179, :10245, :19551), and the disclosure re-probe keeps the caller's own context (:20122-20131), as designed. The probeReferencingRows arm of countReferencingRowsOutside (:20090-20092, a table the cascade itself deletes rows of) still runs under the caller's context; no platform object cascades from sys_organization, so it is unreachable today (S4, a note).

3. R3: CLOSED. isDefaultOrganizationTarget runs at auth-manager.ts:8160-8162, after the try/catch (:8127-8158); the defaultOrganization binding and its assignment inside the try are gone. A failed sys_organization count (organization-delete.ts:223) now propagates out of hooks.before: better-auth's runBeforeHooks rethrows a hook's error (dispatch.mjs:88-91), so the handler never runs, and runSubjectErasureAtomically rolls the unit back and rethrows (auth-manager.ts:7050-7056). The pin is discriminating with a CONTROL (dogfood :245-294): an empty organization the admin owns is made active (:252-253, 200), a beforeUpdate recorder on sys_session records the vendor's first write (:257), a middleware scoped to sys_organization (registerMiddleware(fn, { object }), engine.ts:6797) throws on count while a flag is set (:261-268), recorders on member, invitation and organization deletes (:269-271); refused with 400 or above (:283), attempted empty (:285), organization and member unchanged (:286-287); CONTROL: with the flag off the same organization deletes 200 (:290-292). With the decision back inside the try, the catch returns, the vendor's setActiveOrganization(null) updates the session and the recorder goes red; the dev's note that a deletes-only recorder stayed green is consistent (under the ablation beforeDeleteOrganization re-asks the lookup, which throws before any member delete). The middleware stays registered for the rest of the file with the flag off; harmless.

4. Invariants. No NULL write into any organization_id or tenant column: holds; the delta adds no write, the tenant-column set_null arm stays skipped on the by-id path (:19637) and the predicate path stays refused (:20491). No deletion beyond the organization's own auth structure: holds; removeOrganizationAuthStructure is unchanged (organization-delete.ts:240-260) and is not run for the Default Organization (:3909). The refusal runs before any write: holds now for the Default Organization on both arms (403 then 409, both in hooks.before, before setActiveOrganization(null)), and for every other organization as before; the only uncounted arm is the pre-existing try fall-through (S1). Atomic where the driver allows: holds, runSubjectErasureAtomically unchanged (:6735-6740, :7029-7057). The Default Organization stays protected: the guard's 403 is still asked first (:8173-8181), and under single the one organization is the Default one (postureEnforcesWall('single') is false, tenancy-posture.ts:53-55; slug default, else the only organization, organization-delete.ts:221-224).

5. Changeset and semver. Still minor on both packages, Clause-②: yes (narrowing), FROM, TO, Fix, Seed datasets and the ADR-0087 marker present. Every sentence the delta touched is true against the code: :8 "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" (the guard at :8173 before the count at :8183; the creation refusal under single as judged in the previous record); :17 the new Default Organization bullet (identification :221-224; 403 first, :8176-8180; counted before better-auth deletes anything, :8183-8189 in hooks.before; only members and invitations left out, :167 and :184; teams and grants not removed so they count, :3909 and the tenant columns above; the 409 while it holds them). One nuance, not a falsehood: on a composition without the last-administrator guard no 403 is asked, because there is no guard; the count still runs. :19 "all of them run before the first write" is now true on the admitted arm too. organization-delete.ts:46-62 restates the same contract and is true. The exports added (defaultOrganizationDeleteRefusal, VENDOR_REMOVED_ON_DELETE) are additive; the accept set narrows further (an admitted Default Organization delete that still holds teams, grants or records is now 409 at the door, where it used to be a vendor-run delete the engine refused afterwards), which minor under the launch-window rule already covers.

② Semver level

minor on @objectstack/objectql and @objectstack/plugin-auth, Clause-②: yes (narrowing), BREAKING with FROM, TO and the fix in the body. Unchanged from the previous record and still correct for the delta: two additive exports, one further narrowing of the organization delete's accept set, no spec change, no stored shape change.

③ Boundary flags

S1 (not blocking; pre-existing, outside the delta). The try at auth-manager.ts:8127-8158 still hands the request to the vendor, uncounted and unasked, when resolveActor, the caller's sys_member read, hasPermission or the sys_organization read throws (not when they merely answer "not ours"). On that arm the vendor's writes run and the engine's refusal answers at the organization row inside the unit; on Turso remote the members and invitations would stay deleted. Infrastructure-only, not caller-triggerable, and the door's documented design ("everything this cannot decide falls through to the vendor", :8098-8104), accepted at B3 and R3. Narrowest hardening, for a later card: let a THROWN lookup in that block refuse, as R3 now does for the Default Organization decision, and keep the fall-through for the decidable "the vendor answers this" cases only.

S2 (not blocking). An R3 refusal surfaces as a plain Error thrown from hooks.before, not an APIError, so the wire answer is a 500-class fault rather than a 4xx; the pin asserts only 400 or above. It refuses and rolls back, which is the contract; a SERVICE_UNAVAILABLE-shaped APIError would be the tidier wire answer.

S3 (note). A Default Organization that holds its default sys_team is now refused at the door naming sys_team, and the data API cannot remove that team (managedBy: 'better-auth', CRUD suppressed); the remedy for that table is the vendor's team routes or the engine-side move-and-recreate the runtime golden test pins. This is what the changeset says ("refused while it holds them"), so it is the ruled narrowing, not a hole; recorded so the 409's remedy text ("delete or move those records") is read with that in mind.

S4 (note). The probeReferencingRows arm of countReferencingRowsOutside (engine.ts:20090-20092) keeps the caller's context; it is reached only for a table the cascade itself deletes rows of, which no platform object is. Unchanged by the delta; named so a later cascading child of sys_organization is known to take the scoped arm.

R4 and R5 of the previous record stand as noted there, not blocking. On R5 the PR body now argues the 19 tables / 132 rows figure is unchanged because every seeded row is a showcase_* or sys_business_unit row, none engine-owned or append-only; reasoned, not re-measured on this head.

CI on the head (item 6). Run started 05:27Z. At 05:36:44Z: completed green: Build Core (05:32:58Z), Build Docs (05:33:44Z), Lint and Repo Gates, every Lint lane (ESLint; CI, release and engine gates; docs and code guards; repo and tooling gates; PM dispatch-gates; scoped: tooling self-tests, query-options erasure, slot lookup and corpus), Type Check source gates, consumer gates and debt ledger, Check Changeset, Governed Surface Queue Guard, Dogfood Regression Gate, Spec property liveness, Check Documentation Links, Check PR Size, Flag docs affected by code changes, Auto Label, the four claim guards, filter. Skipped (opt-in): 18.x upgrade harness, Packed-tarball smoke, Temporal Conformance, Dogfood Verify CLI, Console Pin Gate, the sharded Dogfood Regression Gate. Still in progress: Test Core (1/6) through (6/6) and Type Check workspace; the Test Core aggregator has not run. No red check on the head at that time. Owner of every check: github-actions. The seat should read the aggregator and Type Check workspace before acting on this record; pending CI is not a FAIL by itself.

Implemented-by: claude/issue-24948-org-delete-refuse-nonempty
Reviewed-by: session_01Y5583bs2RDd53MH6kM1ca3

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Seat ACCEPT · epic PM session_01Y5583bs2RDd53MH6kM1ca3 (os-steve) · 2026-10-21T06:00Z. PR #25016 at 139abc45a68ca76907c1a8d3da08774aa6b19e9a (#24948, ruling B, record 6303358360; os-dev-report 6308803407). ACCEPT.

Seat check

  • Shape.
    • 20 paths, 2,233 changed lines (+2,189 / -44) on the final head, generated files included.
    • check-governed-merges: NOT governed, and under 3,000 lines. This is an ordinary queue landing, released as an 18.0.x patch.
  • Review. CONTRACT_REVIEW_TIER (narrowing face, Clause-②: yes (narrowing), minor). Three rounds:
    • FAIL 6305988777 at 0573a968. Blocking: B1 (CI), B2 (predicate delete), B3 (the Default Organization on a driver without transactions) and B4 (sys_audit_log.tenant_id set to NULL).
    • FAIL 6308041940 at e184b513c. B1 to B11 closed. R1 was blocking: an admitted Default Organization delete skipped the record count.
    • PASS 6308964493 at 139abc45a, with R1, R2 and R3 closed and the invariants holding:
      • no NULL write into a tenant column;
      • no deletion beyond the organization's own auth structure;
      • the refusal runs before any write;
      • the request is atomic where the driver allows;
      • the Default Organization stays protected.
  • Seat decisions the PR implements:
    • 6305179819: ledgers kept; business units, positions and sharing rules count.
    • 6305996369: the answers to the first review.
    • 6307135388: engine-owned is treated like the ledgers.
    • 6307815346: the declaration decides, with no per-table exceptions.
  • Measured (by the dev, on the head):
    • Suites: objectql 8,526, plugin-auth 3,356, runtime 6,794, plugin-sharing 1,200, platform-objects 1,239, hono 225, plugin-approvals 1,208.
    • Dogfood org files 32/32. Upgrade harness 12/12. Gates 117/117, with 0 NOT-MEASURED.
    • Each round-5 pin was ablated, and exactly its own pin went red.
  • Gate check-runs on the head: the required checks (TypeScript Type Check, Test Core, Build Core, Lint & Repo Gates, Governed Surface Queue Guard) all succeeded. All 49 latest check-runs are success or skipped (8 opt-in gates skipped).
  • Out of scope, and where it goes:
  • Landing: the seat flips the PR ready and enables auto-merge. Fixes #24948 closes the card.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 21, 2026 06:02
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 21, 2026 06:02
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 21, 2026
Merged via the queue into main with commit 688052e Oct 21, 2026
50 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-24948-org-delete-refuse-nonempty branch October 21, 2026 06:35
os-steve pushed a commit that referenced this pull request Oct 21, 2026
… into 7b-0

One conflict: service-messaging notification-display-title.test.ts keeps
main's removal of the retired NotificationSubscription import and this
branch's Default Organization testkit import.

Claude-Session: https://claude.ai/code/session_018EszgtA3V2KXBX5tf59Hi4
Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants