Repository navigation
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
Conversation
…remain Claude-Session: https://claude.ai/code/session_016gmigttoA9DyRNtz6SepWf Co-authored-by: Claude <noreply@anthropic.com>
…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>
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>
…g-delete-refuse-nonempty
📓 Docs Drift CheckThis PR changes 2 package(s): 31 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 12 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 28 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
…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>
Contract reviewServed-tier: Read-only. The head ① Derived judgments1. The door refusal. Before any write: yes. The vendor's 2. The auth structure removed. better-auth removes 3. Atomicity. 4. The engine refusal. 5. The ledger set. 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 7. The Default Organization. Both the door refusal and the structure removal return early for it ( 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. 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 ② Semver level
③ Boundary flagsB1 BLOCKING. CI red on the required check Test Core, caused by this diff. Test Core (6/6) fails in B2 BLOCKING (false changeset sentence; the hole is pre-existing). "the engine itself refuses to delete a 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, 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 B5 (not blocking; question for the seat). An unwalled object's organization lookup is outside "never NULL": sys_approval_request declares 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 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 B9 (not blocking). B10 (not blocking; note under "no deletion"). B11 (not blocking). The elevation record the refusal files per probed table is a 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 Implemented-by: VERDICT: FAIL |
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>
…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>
… guard pin 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
…g-delete-refuse-nonempty
…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
Contract review (delta re-review after FAIL at 0573a96)Served-tier: Read-only. The head ① Derived judgments1. B1: CLOSED (code), CI pending at the time of writing, see item 10. 2. B2: CLOSED. 3. B3: the guard's question CLOSED; the "no transactions" sentence NOT CLOSED on one arm (R1).
4. B4: CLOSED. The walk keeps every 5. B5 as re-decided: CLOSED.
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 7. B8 and B9: CLOSED; the caller-scoped count is no hole. B8: 8. Invariants. No NULL write into any organization or tenant column: holds; the tenant-column 9. Changeset and semver. ② Semver level
③ Boundary flagsR1 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 R2 (not blocking). The engine refusal's elevated count keeps the caller's tenant scope ( R3 (not blocking). R4 (not blocking). The second arm of 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: 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
Contract review (delta re-review after FAIL at e184b51)Served-tier: Read-only. The head ① Derived judgments1. R1: CLOSED.
2. R2: CLOSED. 3. R3: CLOSED. 4. Invariants. No NULL write into any 5. Changeset and semver. Still ② Semver level
③ Boundary flagsS1 (not blocking; pre-existing, outside the delta). The S2 (not blocking). An R3 refusal surfaces as a plain S3 (note). A Default Organization that holds its default S4 (note). The 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 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: VERDICT: PASS |
|
Seat ACCEPT · epic PM Seat check
|
… 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>
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/deleteanswers409with the standardDELETE_RESTRICTEDcode anddependents: [{ object, count }], one entry per table that still names the organization in its tenant column. It carries counts only, never a row value.hooks.before, ahead of the vendor handler, so a refused delete writes nothing, the caller's active organization included.organization: ['delete'], asked of the vendor's ownhasPermission), so a caller the vendor refuses gets the vendor's 403 and no count.packages/plugins/plugin-auth/src/auth-manager.ts:2453(thehooks.beforebranch),: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, 1sys_team, 1sys_team_memberand 1sys_user_permission_set, and nothing else. The creator's session also names it, throughactive_organization_id, which is a pointer, not a tenant column.sys_memberorganization_id,packages/platform-objects/src/identity/sys-member.object.ts:312adapter.deleteOrganization(better-auth 1.7.3dist/plugins/organization/adapter.mjs:370-395)sys_invitationorganization_id,sys-invitation.object.ts:255sys_team(the default team:teams: { enabled: true },auth-manager.ts:3579; created by better-authroutes/crud-org.mjs:106-126)organization_id,sys-team.object.ts:133removeOrganizationAuthStructure,organization-delete.ts:240, frombeforeDeleteOrganization,auth-manager.ts:3910sys_team_memberteam_id,sys-team-member.object.ts:135organization-delete.ts:246)sys_user_permission_set, only grants oforganization_admin/organization_admin_no_bypassorganization_id,packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts:110organization-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_idandsys_approval_action.organization_id;sys_audit_log's attributiontenant_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 anorganization_id; they are listed in Round 4 below.organization_idon 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_keyis the only tenancy-disabled object with an organization lookup, and itsactive_organization_idis a pointer.set_null:sys_session.active_organization_id,sys_api_key.active_organization_id,sys_organization.parent_organization_id.4. Atomic.
/organization/deleteruns 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-idsys_organizationdelete while any tenant-column row outside the cascade set names it. It refuses with409 DELETE_RESTRICTEDand the samedependentslist, before the walk cascades or clears anything.countReferencingRowsOutside,:20079), and never reads rows. The exception is a table the cascade itself deletes rows of, which no platform object is.:20122).multi) delete ofsys_organizationis refused before any hook or write, with409 DELETE_RESTRICTEDnaming the by-id route (organizationPredicateDeleteRefusal,:754, applied at:20509): only the by-id path runs this referential pass.dependentsto the wire (packages/plugins/plugin-auth/src/objectql-adapter.ts:596,:650).packages/specchanges.6. The Default Organization. It is identified by
isDefaultOrganizationTarget(organization-delete.ts:216: slugdefault, 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 inhooks.before, before better-auth deletes any member:auth-manager.ts:8175), through the guard's own judgement (LastAdminGuardHandle.judgeOrganizationDelete,last-admin-guard.ts:900,:1892, wired inauth-plugin.ts:1701). It answers403 PERMISSION_DENIEDwhile the delete would leave no platform administrator.admin_full_accessgrant, or a verifiedOS_PLATFORM_OWNER_EMAILuser, stands), its record count (defaultOrganizationDeleteRefusal,organization-delete.ts:178, asked atauth-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 same409 DELETE_RESTRICTED.pendingFor,last-admin-guard.ts:640). Otherwise an owner's membership of the very organization being deleted would still read as standing.The wire code:
DELETE_RESTRICTED, not a newORGANIZATION_NOT_EMPTYA 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'sDELETE_RESTRICTED. Accepted by the seat.Review round: contract review 6305988777 (FAIL), answered
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".sys_organizationis refused (item 5). Pinned, with a control that another object's multi delete is not caught.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.beforeDeleterecorder onsys_memberandsys_invitationsees 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.sys_organizationon 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) (everysys_audit_logrow whosetenant_idnamed the organization still does after the delete).organization_idis kept and not counted; a pointer is still cleared). It touches no object in this tree.sys_approval_requestis walled, so the decision's premise about it did not hold. Decision 6307135388 settles it (Round 4).os serve(showcase +@objectstack/organizations): a fresh organization owns 132 rows in 19 tables:showcase_account14,showcase_announcement2,showcase_business_unit9,showcase_category4,showcase_client_brief2,showcase_contact32,showcase_expense_line13;showcase_expense_report4,showcase_field_zoo2,showcase_inquiry3,showcase_invoice12,showcase_invoice_line5,showcase_preference1,showcase_product4;showcase_project5,showcase_project_membership3,showcase_task10,showcase_team2,sys_business_unit5.dependentsthrough to the wire 409 (item 5). Pinned inobjectql-adapter.test.ts, a withheld count staying absent.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,AuditPlugincomposed). Three door-created organizations per fixture: untouched; holding onesys_business_unit; holding one record.sys_team.organization_idNOT NULLos migrate organization-ownership --apply: complete, 0 blocking)200; organization, members, invitations, teams, team members, grants all 0;sys_activitynaming it 4 → 7 (kept, plus the delete's own), 0 NULL409sys_business_unit (1), every count unchanged409showcase_category (1), every count unchangedfixture-18.0.0After that delete, on the ceremonied database: the ceremony re-run answers
complete: true,blocking: 0(its post-check holds onlyorganization_id IS NULLrows), and HEAD boots it throughOrganizationOwnershipBootGuardPlugin(which decides on one field of thesys_migrationmarker).check:tenant-audit-censusis a static count of source. A REST read over the kept ledger rows answers200. The lifecycle sweep's dangling-reference audit only reports, into its sweep report.Walled
os serve, re-measured one184b513c(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,SecurityandAuditmounted):200, sign-in200, 0 error lines;409naming the 19 tables / 132 rows above, every count unchanged;DELETE /api/v1/data/..., three passes for ordering):200, with organization, members, teams and grants 0;sys_activity.organization_id312 rows still name the deleted organization, 0 NULL;sys_audit_log.tenant_id312 rows still name it.sys_approval_request,sys_setting,sys_record_shareandsys_notificationhold no row naming it on this boot, and none with a NULLorganization_id. The 11 audit rows with a NULLtenant_idare 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): health200, seeded-admin sign-in200, 0 error lines; Default Organization delete403, 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 delete403withsys_member1 → 0.Round 5: delta review 6308041940 (R1 blocking, R2, R3), answered
hooks.beforeasksdefaultOrganizationDeleteRefusal(organization-delete.ts:178, asked atauth-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 same409 DELETE_RESTRICTED, before better-auth's first write, so a driver without transactions no longer gets a partial delete on this path.OS_PLATFORM_OWNER_EMAILand verified, so the guard admits.409 DELETE_RESTRICTED. It names the Default Organization's records and does not listsys_memberorsys_invitation.sys_memberandsys_invitationsees no delete attempted.organization-delete.test.ts): the exclusion is exactly members and invitations, and no organization-admin grant is subtracted.singledeployments) and its no-transactions bullet;organization-delete.ts, items 3 and the closing paragraph, andisDefaultOrganizationTarget's doc.countReferencingRowsOutsideandorganizationOwnedRowCountsnow count underorganizationCountContext(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 keepreferenceCheckContextand the caller's scope.organizationOwnedRowCountsunder a foreigntenantIdstill counts 2.referenceCheckContext): both pins go red.isDefaultOrganizationTargetruns after thetry(auth-manager.ts:8160), so a lookup that fails there refuses instead of handing the delete to better-auth.sys_organization). Under these conditions:200.try): the pin goes red, because better-auth's session write was attempted.Round 4: seat decision 6307135388 (option B), answered
Exempt by declaration.
managedBy: 'engine-owned'joinsmanagedBy: 'append-only'inObjectQL.outlivesOrganization(engine.ts:20042), which is the one predicate behind all three of these:refuseOrganizationDeleteWhileOwned);organizationOwnedRowCounts, which plugin-auth asks);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: thesys_metadata*family (withsys_metadata_activation),sys_platform_setting*,sys_migration*,sys_secret,sys_flow_dispatch,sys_flow_credential,sys_job,sys_job_queue,sys_scim_connection_credential.sys_approval_requestsys_approval_approversys_approval_tokensys_automation_runsys_notificationsys_notification_receiptsys_notification_deliverysys_http_deliverysys_mailboxsys_mail_messagesys_mail_associationsys_audience_binding_suggestionmanage_metadataholder accepts or dismisses it.sys_record_sharesource = 'rule') written and reconciled by the sharing-rule evaluator, and see below forsource = 'manual'.sys_share_linksys_import_jobsys_settingsys_setting_auditSettingsService: 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.
sys_setting(organization-level settings values). An administrator sets them throughSettingsService.set(the Setup settings pages), andresetNamespaceremoves them. Decision 6305179819 said "settings stay records". Under 6307135388's rule, asys_settingrow no longer refuses the delete. If 6305179819 meantsys_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 anorganization_id.)sys_mailbox. It is a mailbox's identity (address, owner), and onlymanage_mailboxesholders 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.sys_record_sharewithsource = 'manual', andsys_share_link. A user creates them throughPOST /data/:object/:id/sharesand the share-link service'screateLink, and revokes them through the same service:revokeLinksetsrevoked_atand keeps the row. Decision 6307135388 names shares as engine-derived, so they are exempt as decided.Pinned.
engine-organization-delete-refusal.test.ts):engine-ownedremoved from the predicate): exactly these two pins go red, and the other 15 stay green.organization-delete-refuse-nonempty.dogfood.test.ts), on a booted showcase:sys_approval_requestand its twosys_approval_actionrows, deletes with200;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 includes62bc12930) 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:content/docs/permissions/tenant-audit-census.mdxanddocs/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).packages/runtime/src/default-organization-statements.golden.test.tsandpackages/plugins/plugin-sharing/src/business-unit-parent-delete-restrict.test.ts, the two suites the review named.No
packages/spec/src/**, nodocs/adr/**, no governed surface.Tests
packages/objectql/src/engine-organization-delete-refusal.test.ts:set_nulltenant column refused;organizationOwnedRowCounts.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 withAuditPlugin:sys_activityandsys_audit_logrows kept;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.engine-ownedremoved fromoutlivesOrganization: exactly the two round-4 pins red, 15 others green.organizationCountContextback toreferenceCheckContext: both R2 pins red;try: R3's pin red.Verification on the head (139abc4)
Every reading below was taken on 139abc4, which is
e184b513cplus the round-5 commitse40aea862and139abc45a, rebuilt.turbo run build --filter='@objectstack/dogfood^...'dispatch-gates.mjs --commandsdispatch-gates.mjs --rancheck-governed-merges --branchThe fixture and walled
os servemeasurements above were taken one184b513c. 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 ashowcase_*table orsys_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