Skip to content

Commit 149153c

Browse files
fix(plugin-auth): settle membership under the auto policy at user creation (ADR-0093 D7) (#21813)
Fixes #21791 Clause-②: yes (widening) ## What Under the `auto` membership policy, membership is now decided when the user is created (ADR-0093 D7), per the maintainer ruling recorded on the card. Packages: `@objectstack/plugin-auth`, `@objectstack/organizations`, `@objectstack/types`. - **Creation.** User creation binds a new user to the default organization, as before, and the first session minted by that same request carries it. The request recognises its own new user from the store adapter's create result, staged per request context; nothing is read back, and nothing outlives the request. Later sign-ins do not decide membership. - **One-time decisions, recorded in the `sys_migration` ledger.** - The backfill of users who predate the policy (`adr-0093-membership-backfill`) scans the user and member tables in full with keyset pages, with no row cap. An incomplete scan binds nobody and records nothing. A multi-org deployment with no default target is recorded as such. A deployment with no organization at all defers, and the pass runs when the default organization is first created. - The default-organization owner bind (`adr-0093-default-org-owner-bind`) runs once. One shared gate (`createEnsureDefaultOrganizationOnce`) is used by both the single-organization wiring and the walled `@objectstack/organizations` wiring. Once decided, a missing default organization is recreated with nobody bound and no seed-ownership handoff. On a kernel without the ledger, the owner is bound only when the bootstrap creates the organization; if the ledger exists but cannot be read, that call binds nobody and the next trigger decides. - Each decision is latched in-process once acted on, even if writing its record fails (that failure is logged at `error`). - **`keysetWalk` (`@objectstack/types`)** detects a stalled cursor by key equality or a repeated page, never by string order. - **Showcase.** The approval-demo seed writes its demo personas' membership when it creates them. - **New public surface of `@objectstack/plugin-auth` (additive, `minor`):** `createEnsureDefaultOrganizationOnce` and `EnsureDefaultOrganizationOnceOptions`; `ObjectQLAdapterFactoryOptions.onRecordCreated` via the new optional second argument of `createObjectQLAdapterFactory`; `bindOnlyOnCreate` / `bindOwner` on `EnsureDefaultOrganizationOptions`; reason members `'owner_bind_decided'` and `'scan-incomplete'`. `@objectstack/organizations` and `@objectstack/types` add no public export (`patch`). - **Deprecated, not removed:** the ungated `ensureDefaultOrganization` (plugin-auth helper and the organizations wrapper), in favour of the gated factory. - **Recovery:** after a default organization is recreated with nobody bound, an administrator re-adds members, including themselves, through member management. - **Unchanged.** `invite-only` binds nobody; multi-org has no automatic binding; the creation paths (sign-up, admin create, import, SSO JIT) still bind under `auto`. - **Derived artefacts,** regenerated with their tools: the tenant-audit census (one new engine write site) with its prose figures, the engine test-double ledger, the durability gate vocabulary (`persistLedgerDecisionRow`) and its swallow-census copy. ## Tests Measured at `3ed15b1836`: - `@objectstack/plugin-auth`: typecheck green; 121 files, 2541 passed, 10 skipped. - `@objectstack/organizations`: typecheck green; 9 files, 131 passed. - `@objectstack/types`: typecheck green; 23 files, 706 passed (`--project local`). - Dogfood (real showcase boot): membership decided at creation; demo personas hold an organization; neighbouring persona, approval-override and membership-revoke suites — 5 files, 10 passed. - New coverage: the one-time ledger (first pass, recorded verdict, multi-org refusal, deferral, pagination past one page, incomplete scan, unreadable ledger, failed record then later triggers), creation recognised only within its own request (including a different pre-existing user in a creating request), restart after a membership change, the walled owner-bind gate, recreate-without-bind, and keyset stall detection under non-JS collations. - Ablations through `scripts/ablation-replace.mjs`, each restored to HEAD: the request-scoped creation check, the in-process latches (owner bind, backfill), the keyset equality check, and the persona membership write each turned their test red. - `pnpm` gates via `dispatch-gates`: 121 derived, 121 run, all exit 0 (including the changeset gates against `origin/main`); CI-environment jobs (shards, test completeness, workspace type-check lanes) left to CI. ## Acceptance notes - **Upgrade boot.** The first boot of this version has no record, so both one-time passes run once; after that they are recorded. - **Users inserted directly through the data engine** (including seeds that finish after their budget) never cross user creation and stay unbound once the backfill is recorded. Seeds should create users through the creation seam or write membership themselves, as the showcase seed now does. - **ADR-0093 D6's text** still describes the recurring `app:seeded` re-run; amending it is a governed (Tier H) edit left to the maintainer. --- _Generated by [Claude Code](https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 88a39c0 commit 149153c

28 files changed

Lines changed: 1678 additions & 96 deletions
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
'@objectstack/plugin-auth': minor
3+
'@objectstack/organizations': patch
4+
'@objectstack/types': patch
5+
---
6+
7+
Membership under the `auto` policy is settled when the user is created, per ADR-0093 D7.
8+
9+
Clause-②: yes (widening)
10+
11+
- **At creation.** A user created under `auto` is bound to the default organization at creation, and the first session of that creating request carries it. Membership is not decided again when the user signs in later.
12+
- **One-time backfill.** The ADR-0093 D6 backfill of pre-existing users runs once per deployment, and once per process even if its record cannot be written. Its verdict is recorded in the `sys_migration` ledger with id `adr-0093-membership-backfill`. A pass on a deployment with no organization at all records nothing, and the backfill runs again once the default organization is created. If the ledger is missing or cannot be read, the pass does not run and logs a warning. If the record cannot be written, that is logged as an error. `OS_SKIP_MEMBERSHIP_BACKFILL=1` still disables the pass.
13+
- **Default organization owner.** The platform admin is bound as owner of the default organization once, by the bootstrap that first decides it, in both the single-org and the walled organizations wiring. The decision is recorded in the same ledger with id `adr-0093-default-org-owner-bind` and held for the rest of the process even if the record cannot be written. After that, a missing default organization is recreated without binding anyone. To recover, an administrator re-adds members, including themselves, through member management. On a kernel without the ledger, the owner is bound only when the bootstrap creates the default organization. If the ledger exists but cannot be read, that call binds nobody and the next trigger decides.
14+
- **Full scan.** The backfill reads the user and membership tables page by page with no row cap. A scan that cannot read either table in full binds nobody and records nothing. With organizations present but no default target, as in multi-organization deployments, the refusal is recorded.
15+
- **Upgrade.** The first boot of an upgraded deployment runs the backfill once.
16+
- **Unchanged.** `invite-only` binds nobody. Multi-organization deployments get no automatic binding. Users created through sign-up, admin create-user, import or SSO are bound under `auto` as before.
17+
- **Narrowed.** A `sys_user` row inserted straight through the data engine never passes through user creation. Once the backfill is recorded, a later `app:seeded` pass leaves it unbound. That includes users written by a seed that finishes after its inline budget. Code that inserts users this way must write their membership itself; the showcase approval-demo personas now do.
18+
- **`keysetWalk` (`@objectstack/types`).** The walk now decides that a page did not advance only when it gets back the same cursor key or the same page again. It no longer compares keys in JavaScript string order, which disagrees with database collations and could report a healthy walk as truncated.
19+
- **New public surface of `@objectstack/plugin-auth` (additive).**
20+
- `createEnsureDefaultOrganizationOnce` and `EnsureDefaultOrganizationOnceOptions` are the gated bootstrap both wirings call.
21+
- `ObjectQLAdapterFactoryOptions` adds `onRecordCreated`, passed as the new optional second argument of `createObjectQLAdapterFactory`.
22+
- `EnsureDefaultOrganizationOptions` gains `bindOnlyOnCreate` and `bindOwner`.
23+
- `EnsureDefaultOrganizationResult.reason` gains `'owner_bind_decided'`.
24+
- `BackfillMembershipsResult.reason` gains `'scan-incomplete'`.
25+
- Code that switches exhaustively over those reasons sees one more member.
26+
- **`backfillMemberships` (exported) changed behaviour.** Its `limit` option used to cap the rows scanned (default 5000); it is now the page size of a full scan with no cap. The function now needs a reader that can page by `id`; a reader that cannot gets `scan-incomplete` and binds nobody, where it used to bind. A direct call is not gated by the one-time ledger and decides membership again on every call; call it through the one-time pass instead.
27+
- **Policy switch.** Once a pass under `invite-only` is recorded, switching the policy to `auto` later does not backfill the users who existed then; they get membership through invitation or member management.
28+
- **Deprecated, not removed.** The ungated `ensureDefaultOrganization`, both plugin-auth's helper and the `@objectstack/organizations` wrapper, is `@deprecated` in favour of `createEnsureDefaultOrganizationOnce`.

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

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

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

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

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

196196
**The differences are not reconciled, and deliberately so.** The old census's
@@ -207,11 +207,11 @@ would report a smaller number and would not say so.
207207

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

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

224224
| what | count |
225225
| :--- | ---: |
226-
| write call sites on the application surface | **231** |
226+
| write call sites on the application surface | **232** |
227227
| …whose object name is statically decidable | 154 |
228-
| …whose object name is chosen at run time | 77 |
228+
| …whose object name is chosen at run time | 78 |
229229
| …against an object with tenancy ENABLED | 153 |
230230
| …against an object that declares tenancy off | 1 |
231-
| threading a tenant context | 147 |
231+
| threading a tenant context | 148 |
232232
| PROVABLY carrying none (options read, no context key) | **17** |
233233
| …of those, against a decidably tenancy-enabled object | **9** |
234234
| options argument UNREADABLE — may or may not carry one | 67 |
235235
| …of those, against a decidably tenancy-enabled object | 34 |
236-
| threading a decidably ELEVATED (`isSystem`) context | 112 |
236+
| threading a decidably ELEVATED (`isSystem`) context | 113 |
237237
| threading a context that is decidably NOT elevated | 0 |
238238
| threading a context whose elevation is a run-time fact | 102 |
239239

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

247247
| object name spelled inline | 103 |
248248
| object name spelled through a `const` | 51 |
249249
| object name is an `object: string` parameter | 17 |
250-
| object name is some other run-time expression | 60 |
250+
| object name is some other run-time expression | 61 |
251251

252252
### Subtractions the census could NOT defend — enforced
253253

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

300-
Measured on 2026-10-02 at `b668cf134`.
300+
Measured on 2026-10-05 at `34782539c`.
301301

302302
| corpus scale (not enforced) | count |
303303
| :--- | ---: |
304-
| tracked non-test sources scanned | 602 |
305-
| engine-shaped types recognised | 67 |
304+
| tracked non-test sources scanned | 605 |
305+
| engine-shaped types recognised | 68 |
306306
| declared objects in the registry | 116 |
307-
| same-named calls subtracted as non-engine | 152 |
307+
| same-named calls subtracted as non-engine | 155 |
308308

309309
{/* END GENERATED: tenant-audit-census */}

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

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

3434
| Measure | Value |
3535
|---|---:|
36-
| Write call sites | 231 |
36+
| Write call sites | 232 |
3737
| Object name statically decidable | 154 |
38-
| Object name chosen at run time | 77 |
38+
| Object name chosen at run time | 78 |
3939
| Against a tenancy-enabled object | 153 |
4040
| Against an object declaring tenancy off | 1 |
41-
| Threading a tenant context | 147 |
41+
| Threading a tenant context | 148 |
4242
| Provably carrying none | 17 |
4343
| …and decidably tenancy-enabled | 9 |
4444
| Options argument unreadable | 67 |
4545
| …and decidably tenancy-enabled | 34 |
46-
| Threading a decidably elevated context | 112 |
46+
| Threading a decidably elevated context | 113 |
4747
| Threading a decidably non-elevated context | 0 |
4848
| Threading a context of undecidable elevation | 102 |
4949

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

93-
Measured on 2026-10-02 at `b668cf134`.
93+
Measured on 2026-10-05 at `34782539c`.
9494

9595
| corpus scale (not enforced) | count |
9696
| :--- | ---: |
97-
| tracked non-test sources scanned | 602 |
98-
| engine-shaped types recognised | 67 |
97+
| tracked non-test sources scanned | 605 |
98+
| engine-shaped types recognised | 68 |
9999
| declared objects in the registry | 116 |
100-
| same-named calls subtracted as non-engine | 152 |
100+
| same-named calls subtracted as non-engine | 155 |
101101

102102
## Every site
103103

@@ -135,6 +135,7 @@ Measured on 2026-10-02 at `b668cf134`.
135135
| `packages/plugins/plugin-auth/src/auth-plugin.ts` | `update` | `SystemObjectName.USER` | undecidable | elevated | 1 |
136136
| `packages/plugins/plugin-auth/src/ensure-default-organization.ts` | `insert` | `object` | undecidable | elevated | 1 |
137137
| `packages/plugins/plugin-auth/src/member-role-canonical.ts` | `update` | `MEMBER_OBJECT` | undecidable | elevated | 1 |
138+
| `packages/plugins/plugin-auth/src/membership-backfill-ledger.ts` | `insert` | `DATA_MIGRATION_FLAG_OBJECT` | undecidable | elevated | 1 |
138139
| `packages/plugins/plugin-auth/src/membership-ended-session.ts` | `update` | `SystemObjectName.SESSION` | undecidable | elevated | 2 |
139140
| `packages/plugins/plugin-auth/src/objectql-adapter.ts` | `delete` | `m` | undecidable | options unreadable | 1 |
140141
| `packages/plugins/plugin-auth/src/objectql-adapter.ts` | `insert` | `m` | undecidable | options unreadable | 1 |

‎examples/app-showcase/src/security/seed-approval-demo.ts‎

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,7 @@ async function ensureCredentialAccount(
235235
async function ensureDemoUser(
236236
ctx: ApprovalDemoContext,
237237
user: { id: string; name: string; email: string; phone_number?: string },
238+
organizationId: string | null,
238239
): Promise<string | undefined> {
239240
const existing = await findOne(ctx, 'sys_user', { email: user.email });
240241
if (existing?.id) return String(existing.id);
@@ -246,6 +247,12 @@ async function ensureDemoUser(
246247
// never provisioned and its surfaces render empty.
247248
await ctx.ql.insert('sys_user', { ...user }, { context: SYS });
248249
ctx.logger?.info?.('[showcase] approval-demo persona provisioned', { email: user.email });
250+
// This raw insert never crosses the platform's user-creation seam, so the
251+
// membership a created user gets there (ADR-0093 D7: decided at creation)
252+
// is written here, once, by the code that creates the persona. A persona
253+
// that already exists is left as it is — its membership was decided when
254+
// it was created.
255+
if (organizationId) await ensureDemoMembership(ctx, user.id, organizationId);
249256
return user.id;
250257
} catch (err) {
251258
// Non-fatal, and the whole persona is lost when it happens: with no row
@@ -260,6 +267,24 @@ async function ensureDemoUser(
260267
}
261268
}
262269

270+
/** Bind a freshly created persona to the admin's organization as a plain member. */
271+
async function ensureDemoMembership(ctx: ApprovalDemoContext, userId: string, organizationId: string): Promise<void> {
272+
const existing = await findOne(ctx, 'sys_member', { user_id: userId, organization_id: organizationId });
273+
if (existing) return;
274+
try {
275+
await ctx.ql.insert(
276+
'sys_member',
277+
{ id: `mem_showcase_${userId}`, organization_id: organizationId, user_id: userId, role: 'member' },
278+
{ context: SYS },
279+
);
280+
} catch (err) {
281+
ctx.logger?.warn?.('[showcase] approval-demo persona membership failed (persona has no organization)', {
282+
userId,
283+
error: err instanceof Error ? err.message : String(err),
284+
});
285+
}
286+
}
287+
263288
/**
264289
* Launch a signoff flow on a record through the real automation engine, unless
265290
* a pending request already exists for it.
@@ -350,11 +375,11 @@ export function registerShowcaseApprovalDemo(ctx: ApprovalDemoContext): void {
350375
await assignPositions(ctx, adminId, ADMIN_APPROVAL_POSITIONS, organizationId, 'admin');
351376
// Mei holds no approval position, which makes her a clean *submitter* — a
352377
// requester who is never also one of her own approvers.
353-
const submitterId = (await ensureDemoUser(ctx, PHONE_DEMO_USER)) ?? null;
378+
const submitterId = (await ensureDemoUser(ctx, PHONE_DEMO_USER, organizationId)) ?? null;
354379
// The auditor persona backs the `finance` group of the per-group demo. It
355380
// deliberately holds ONLY `auditor`, so the two groups have distinct
356381
// holders and the request stays open until each group has answered.
357-
const auditorId = await ensureDemoUser(ctx, AUDITOR_DEMO_USER);
382+
const auditorId = await ensureDemoUser(ctx, AUDITOR_DEMO_USER, organizationId);
358383
if (auditorId) await assignPositions(ctx, auditorId, ['auditor'], organizationId, 'auditor');
359384

360385
// [#9308 fixture 1] Make both personas SIGN-INABLE. Provisioning them as

‎packages/plugins/organizations/src/ensure-default-organization.ts‎

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,14 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
/**
4-
* ensureDefaultOrganization — multi-org flavour of the default-org bootstrap.
4+
* ensureDefaultOrganization — the walled-posture default-org bootstrap with the
5+
* per-org seed-ownership handoff (`claimOrgSeedOwnership`) injected.
56
*
6-
* The helper itself moved to `@objectstack/plugin-auth` (cloud ADR-0081 D1: the
7-
* open member-management basics own it — single-org mode runs it too, from
8-
* AuthPlugin). This wrapper keeps the multi-org semantics this plugin always
9-
* had by injecting the per-org seed-ownership handoff step
10-
* (`claimOrgSeedOwnership`), which belongs to the org seed pipeline here,
11-
* not to the basics.
12-
*
13-
* See the plugin-auth helper for the full strategy documentation.
7+
* The helper itself lives in `@objectstack/plugin-auth` (cloud ADR-0081 D1).
8+
* `OrganizationsPlugin` no longer calls this wrapper: it calls plugin-auth's
9+
* `createEnsureDefaultOrganizationOnce` with the same handoff injected, which
10+
* decides the owner bind once (ADR-0093 D7). This wrapper is kept for
11+
* existing importers only.
1412
*/
1513

1614
import {
@@ -32,6 +30,12 @@ export type { EnsureDefaultOrganizationResult };
3230
* Ensure the platform admin has a Default Organization to operate in,
3331
* then hand the org's seeded rows to them. Idempotent (stable slug
3432
* `default` + the admin's existing memberships short-circuit).
33+
*
34+
* @deprecated Use `createEnsureDefaultOrganizationOnce({ claimSeedOwnership:
35+
* claimOrgSeedOwnership })` from `@objectstack/plugin-auth`. Called directly,
36+
* this re-binds an owner whose membership was removed and re-runs the seed
37+
* handoff on every call; the gated factory decides the bind once
38+
* (ADR-0093 D7).
3539
*/
3640
export async function ensureDefaultOrganization(
3741
ql: any,

‎packages/plugins/organizations/src/organizations-plugin.ts‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
import { Plugin, PluginContext } from '@objectstack/core';
44
import { claimOrphanOrgRows } from './claim-orphan-org-rows.js';
55
import type { OrgScopingEngine } from './org-scoping-engine.js';
6-
import { isDefaultOrganizationBootstrapTrigger } from '@objectstack/plugin-auth';
7-
import { ensureDefaultOrganization } from './ensure-default-organization.js';
6+
import { createEnsureDefaultOrganizationOnce, isDefaultOrganizationBootstrapTrigger } from '@objectstack/plugin-auth';
7+
import { claimOrgSeedOwnership } from './claim-org-seed-ownership.js';
88
import { assertWalledMembershipPolicyDeclared } from './membership-policy-gate.js';
99
import {
1010
organizationsObjects,
@@ -468,9 +468,18 @@ export class OrganizationsPlugin implements Plugin {
468468

469469
// ── Default-org bootstrap on kernel:ready + on admin grant ────────
470470
if (this.opts.ensureDefaultOrganization) {
471+
// ADR-0093 D7 — the SAME once-gate the single-org AuthPlugin uses: the
472+
// owner bind (and the seed-ownership handoff that follows it) is decided
473+
// once, recorded in the `sys_migration` ledger and latched in-process;
474+
// after that a missing default organization is recreated without
475+
// binding anyone, so a removed owner's membership stays removed.
476+
const ensureOnce = createEnsureDefaultOrganizationOnce({
477+
logger: ctx.logger,
478+
claimSeedOwnership: claimOrgSeedOwnership,
479+
});
471480
const runEnsure = async () => {
472481
try {
473-
const res = await ensureDefaultOrganization(ql, { logger: ctx.logger });
482+
const res = await ensureOnce(ql);
474483
if (res.defaultOrgCreated) {
475484
ctx.logger.info(
476485
`[org-scoping] created Default Organization ${res.defaultOrgId} for platform admin`,

0 commit comments

Comments
 (0)