From b21c61bed8fc1be10c03aea636722018f94d43e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 17:33:20 +0000 Subject: [PATCH 1/6] fix(plugin-auth): the owner-bind gate marks its decision in flight before its first await createEnsureDefaultOrganizationOnce re-enters itself on a first boot: the owner write makes plugin-security grant organization_admin, that grant insert is a bootstrap trigger, and the inner call read the latch as undecided, recorded 'admin-already-member' first, and left the outer call's accurate record ('promoted' or 'bound', with the organization id) to be refused by the sys_migration primary key with a warning and a stack on every first boot. The gate now sets an in-flight mark synchronously before its first await. A call that finds it set runs ensure with bindOwner false and records nothing; the deciding call records its own outcome once and clears the mark on the way out, so a call that did not act leaves the decision to the next trigger. Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude --- .../src/default-org-bootstrap-once.ts | 105 +++++++++++------- 1 file changed, 65 insertions(+), 40 deletions(-) diff --git a/packages/plugins/plugin-auth/src/default-org-bootstrap-once.ts b/packages/plugins/plugin-auth/src/default-org-bootstrap-once.ts index 50fbf9da021..27c001b5fcd 100644 --- a/packages/plugins/plugin-auth/src/default-org-bootstrap-once.ts +++ b/packages/plugins/plugin-auth/src/default-org-bootstrap-once.ts @@ -25,7 +25,18 @@ * gets its owner and an existing organization is never re-bound; * - with a ledger that failed to answer it binds nobody on that call (it may * still create the organization) and leaves the decision to the next - * trigger. + * trigger; + * - ONE call decides at a time. The decision is marked in flight + * synchronously, before the first `await`, because the bind can re-enter + * this gate: the owner write (`sys_member` insert or promotion) makes + * plugin-security grant `organization_admin`, and under a non-walled + * posture that grant insert is a bootstrap trigger, so the gate runs again + * INSIDE the deciding call. A call that finds the decision in flight — that + * re-entrant one, or a concurrent trigger — binds nobody and records + * nothing; the deciding call records its own outcome once. Without the mark + * the inner call recorded first (the admin already held the membership the + * outer call had just written), and the outer call's accurate record was + * refused by the ledger's primary key. */ import { @@ -71,53 +82,67 @@ export function createEnsureDefaultOrganizationOnce( // ledger, or acted on now. A record that failed to land does not unlatch // it: the next trigger in this process must not bind again. let decided = false; + // Set while ONE call is deciding: synchronously, before its first `await`, + // and cleared when it returns. The bind re-enters this gate from inside the + // deciding call (see the module doc), so a mark set any later is read too + // late — the inner call would decide, and record, first. + let deciding = false; return async (ql: any): Promise => { const base: EnsureDefaultOrganizationOptions = { logger: options.logger, ...(options.claimSeedOwnership ? { claimSeedOwnership: options.claimSeedOwnership } : {}), }; - if (decided) return ensure(ql, { ...base, bindOwner: false }); + // Decided, or being decided by another call: still (re)create a missing + // default organization, bind nobody, record nothing. + if (decided || deciding) return ensure(ql, { ...base, bindOwner: false }); - const reading = await readLedgerDecision(ql, DEFAULT_ORG_OWNER_BIND_MIGRATION_ID); - if (reading === 'recorded') { - decided = true; - return ensure(ql, { ...base, bindOwner: false }); - } + deciding = true; + try { + const reading = await readLedgerDecision(ql, DEFAULT_ORG_OWNER_BIND_MIGRATION_ID); + if (reading === 'recorded') { + decided = true; + return await ensure(ql, { ...base, bindOwner: false }); + } - // `unavailable` (this kernel has no ledger) binds only on the call that - // CREATES the default organization, so a fresh install without a ledger - // still gets its owner. `unreadable` is a ledger that exists but failed to - // answer: the decision may well be recorded, so this call binds nobody — - // it can still (re)create the organization — and the next trigger, with a - // readable ledger, decides. - const bindOnlyOnCreate = - reading === 'unavailable' && options.organizationCreatedByThisProcess?.() !== true; - const res = - reading === 'unreadable' - ? await ensure(ql, { ...base, bindOwner: false }) - : await ensure(ql, { ...base, bindOnlyOnCreate }); - // Decided: the admin was bound now, promoted from the reconciler's own - // `member` row (ADR-0131 D3), or already held a membership. `no_admin` and - // the failed writes leave it open for the next trigger. - const actedOn = res.memberCreated || res.ownerPromoted === true || res.reason === 'admin_already_in_org'; - if (!actedOn) return res; - decided = true; - if (reading === 'absent') { - await recordLedgerDecision( - ql, - buildLedgerDecisionRecord( - DEFAULT_ORG_OWNER_BIND_MIGRATION_ID, - { - outcome: res.memberCreated ? 'bound' : res.ownerPromoted ? 'promoted' : 'admin-already-member', - ...(res.defaultOrgId ? { organizationId: res.defaultOrgId } : {}), - }, - new Date().toISOString(), - ), - 'default organization owner bind', - options.logger, - ); + // `unavailable` (this kernel has no ledger) binds only on the call that + // CREATES the default organization, so a fresh install without a ledger + // still gets its owner. `unreadable` is a ledger that exists but failed to + // answer: the decision may well be recorded, so this call binds nobody — + // it can still (re)create the organization — and the next trigger, with a + // readable ledger, decides. + const bindOnlyOnCreate = + reading === 'unavailable' && options.organizationCreatedByThisProcess?.() !== true; + const res = + reading === 'unreadable' + ? await ensure(ql, { ...base, bindOwner: false }) + : await ensure(ql, { ...base, bindOnlyOnCreate }); + // Decided: the admin was bound now, promoted from the reconciler's own + // `member` row (ADR-0131 D3), or already held a membership. `no_admin` and + // the failed writes leave it open: the in-flight mark is cleared on the + // way out, so the next trigger decides. + const actedOn = res.memberCreated || res.ownerPromoted === true || res.reason === 'admin_already_in_org'; + if (!actedOn) return res; + decided = true; + if (reading === 'absent') { + // The one record of this decision, written by the call that made it. + await recordLedgerDecision( + ql, + buildLedgerDecisionRecord( + DEFAULT_ORG_OWNER_BIND_MIGRATION_ID, + { + outcome: res.memberCreated ? 'bound' : res.ownerPromoted ? 'promoted' : 'admin-already-member', + ...(res.defaultOrgId ? { organizationId: res.defaultOrgId } : {}), + }, + new Date().toISOString(), + ), + 'default organization owner bind', + options.logger, + ); + } + return res; + } finally { + deciding = false; } - return res; }; } From aa713cb2940943cbe37acfd9e6c97babf25aae37 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 17:35:27 +0000 Subject: [PATCH 2/6] test(plugin-auth): pin the owner-bind gate's in-flight mark against re-entry and a concurrent trigger Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude --- .../src/default-org-bootstrap-once.test.ts | 227 ++++++++++++++++++ 1 file changed, 227 insertions(+) create mode 100644 packages/plugins/plugin-auth/src/default-org-bootstrap-once.test.ts diff --git a/packages/plugins/plugin-auth/src/default-org-bootstrap-once.test.ts b/packages/plugins/plugin-auth/src/default-org-bootstrap-once.test.ts new file mode 100644 index 00000000000..fcfe8ab11d5 --- /dev/null +++ b/packages/plugins/plugin-auth/src/default-org-bootstrap-once.test.ts @@ -0,0 +1,227 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// [#22099] The one-time owner-bind gate (`createEnsureDefaultOrganizationOnce`) +// is decided by ONE call, and that call writes the one ledger record. +// +// ## The defect these pins hold closed +// +// The bind re-enters the gate. On a first boot the owner write (the +// `sys_member` insert, or — since ADR-0131 D3 — the promotion of the +// reconciler's own `member` row) makes plugin-security grant +// `organization_admin`, and that grant insert is a bootstrap trigger under a +// non-walled posture, so plugin-auth's middleware runs the gate again INSIDE +// the deciding call. The latch was set only after `await ensure(...)` returned, +// so the inner call read "undecided" and a ledger with no row, found the admin +// already holding the membership the outer call had just written, and recorded +// `admin-already-member` first. The outer call's accurate record (`bound` or +// `promoted`, with the organization id) was then refused by the +// `sys_migration` primary key — a warning and a stack on every first boot — +// and the ledger kept the wrong outcome. +// +// ## The double +// +// `rig()` is the engine surface the gate and `ensureDefaultOrganization` read +// and write, with the two properties the defect lives in made real: the +// `sys_migration` insert REFUSES a duplicate id the way the primary key does, +// and every `sys_member` write runs a `reenter` callback after it lands — the +// stand-in for plugin-security's grant middleware handing a bootstrap trigger +// back to the gate. Every negative below is paired with the fact that makes it +// non-vacuous: the gate really was re-entered (or really was called twice). + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { resetPlatformAdminEmailMemo } from '@objectstack/core'; +import { assertEngineUpdateDispatch } from '@objectstack/objectql'; +import { + ensureDefaultOrganization, + type EnsureDefaultOrganizationOptions, +} from './ensure-default-organization.js'; +import { createEnsureDefaultOrganizationOnce } from './default-org-bootstrap-once.js'; +import { DEFAULT_ORG_OWNER_BIND_MIGRATION_ID } from './membership-backfill-ledger.js'; + +// The legacy grant anchor this helper falls back to is posture-keyed, and the +// config anchor reads `OS_PLATFORM_OWNER_EMAIL` live: pin both to the +// `single` shape the measured boot runs, never the ambient environment's. +const OWNER_ENV = 'OS_PLATFORM_OWNER_EMAIL'; +const POSTURE_ENV = 'OS_TENANCY_POSTURE'; +const MULTI_ORG_ENV = 'OS_MULTI_ORG_ENABLED'; +const ambient: Record = {}; +beforeEach(() => { + for (const name of [OWNER_ENV, POSTURE_ENV, MULTI_ORG_ENV]) ambient[name] = process.env[name]; + delete process.env[OWNER_ENV]; + delete process.env[MULTI_ORG_ENV]; + process.env[POSTURE_ENV] = 'single'; + resetPlatformAdminEmailMemo(); +}); +afterEach(() => { + for (const [name, value] of Object.entries(ambient)) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + resetPlatformAdminEmailMemo(); +}); + +type Row = Record; + +function rig(seed: Partial> = {}) { + const tables: Record = { + sys_permission_set: [{ id: 'ps_admin', name: 'admin_full_access' }], + // The platform admin `u1`, through the legacy grant anchor `single` reads. + sys_user_permission_set: [{ id: 'ups1', user_id: 'u1', permission_set_id: 'ps_admin', organization_id: null }], + sys_organization: [], + sys_member: [], + sys_migration: [], + ...seed, + }; + /** Every row handed to the ledger insert, landed or refused. */ + const ledgerInserts: Row[] = []; + const hooks: { reenter?: () => Promise; refuseLedger?: Error; refuseMemberInsertOnce?: Error } = {}; + const matches = (row: Row, where: Row | undefined) => + Object.entries(where ?? {}).every(([key, value]) => { + if (key.startsWith('$')) throw new Error(`fake engine: WHERE combinator ${key} is not implemented`); + return value === null ? row[key] == null : row[key] === value; + }); + const ql: any = { + tables, + getObject: (name: string) => (name in tables ? { name } : undefined), + find: vi.fn(async (object: string, query: any) => { + const rows = (tables[object] ?? []).filter((r) => matches(r, query?.where)); + return rows.slice(0, query?.limit ?? rows.length); + }), + findOne: vi.fn(async (object: string, query: any) => + (tables[object] ?? []).find((r) => r.id === query?.where?.id) ?? null, + ), + insert: vi.fn(async (object: string, data: Row) => { + if (object === 'sys_migration') { + ledgerInserts.push(data); + if (hooks.refuseLedger) throw hooks.refuseLedger; + if (tables.sys_migration.some((r) => r.id === data.id)) { + throw new Error('UNIQUE constraint failed: sys_migration.id'); + } + } + if (object === 'sys_member' && hooks.refuseMemberInsertOnce) { + const refusal = hooks.refuseMemberInsertOnce; + hooks.refuseMemberInsertOnce = undefined; + throw refusal; + } + (tables[object] ??= []).push({ ...data }); + if (object === 'sys_member') await hooks.reenter?.(); + return { ...data }; + }), + update: vi.fn(async (object: string, data: Row, options?: any) => { + // Pinned to ObjectQL.update's dispatch predicate (the promotion is by id). + assertEngineUpdateDispatch(data, options); + const row = (tables[object] ?? []).find((r) => r.id === data.id); + if (row) Object.assign(row, data); + if (object === 'sys_member') await hooks.reenter?.(); + return row ?? null; + }), + }; + const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; + /** The options every `ensure` call received, in call order. */ + const ensureCalls: EnsureDefaultOrganizationOptions[] = []; + const gate = createEnsureDefaultOrganizationOnce({ + logger, + ensure: (engine, options) => { + ensureCalls.push(options ?? {}); + return ensureDefaultOrganization(engine, options); + }, + }); + const ownerBindInserts = () => ledgerInserts.filter((r) => r.id === DEFAULT_ORG_OWNER_BIND_MIGRATION_ID); + const recorded = () => { + const row = tables.sys_migration.find((r) => r.id === DEFAULT_ORG_OWNER_BIND_MIGRATION_ID); + return row ? JSON.parse(String(row.details)) : undefined; + }; + return { ql, tables, hooks, logger, gate, ensureCalls, ownerBindInserts, recorded }; +} + +describe('[#22099] the owner bind re-enters the gate: the deciding call records, once', () => { + it('a fresh bind: the inner call binds nobody and records nothing; the ledger reads `bound` with the organization sys_member points at', async () => { + const r = rig(); + let reentered = 0; + r.hooks.reenter = async () => { + reentered += 1; + if (reentered === 1) await r.gate(r.ql); + }; + + const res = await r.gate(r.ql); + + expect(reentered, 'precondition: the bind re-entered the gate').toBeGreaterThanOrEqual(1); + expect(r.ensureCalls).toHaveLength(2); + expect(r.ensureCalls[1]?.bindOwner, 'the in-flight call must not decide').toBe(false); + expect(res).toMatchObject({ memberCreated: true }); + expect(r.tables.sys_member).toHaveLength(1); + expect(r.ownerBindInserts(), 'the decision was written more than once').toHaveLength(1); + expect(r.recorded()).toEqual({ outcome: 'bound', organizationId: r.tables.sys_member[0]!.organization_id }); + expect(r.logger.error).not.toHaveBeenCalled(); + }); + + it('the promotion of the reconciler-written `member` row (the landed first-boot shape): the ledger reads `promoted`', async () => { + const r = rig({ + sys_organization: [{ id: 'org_d', slug: 'default' }], + sys_member: [{ id: 'mem_reconciled', organization_id: 'org_d', user_id: 'u1', role: 'member' }], + }); + let reentered = 0; + r.hooks.reenter = async () => { + reentered += 1; + if (reentered === 1) await r.gate(r.ql); + }; + + const res = await r.gate(r.ql); + + expect(reentered, 'precondition: the promotion re-entered the gate').toBeGreaterThanOrEqual(1); + expect(r.ensureCalls[1]?.bindOwner).toBe(false); + expect(res).toMatchObject({ ownerPromoted: true }); + expect(r.tables.sys_member).toEqual([{ id: 'mem_reconciled', organization_id: 'org_d', user_id: 'u1', role: 'owner' }]); + expect(r.ownerBindInserts()).toHaveLength(1); + expect(r.recorded()).toEqual({ outcome: 'promoted', organizationId: 'org_d' }); + expect(r.logger.error).not.toHaveBeenCalled(); + }); + + it('a concurrent trigger while the decision is in flight binds nobody: one owner row, one record', async () => { + const r = rig({ sys_organization: [{ id: 'org_d', slug: 'default' }] }); + + const [first, second] = await Promise.all([r.gate(r.ql), r.gate(r.ql)]); + + expect(r.ensureCalls, 'precondition: both triggers reached the gate').toHaveLength(2); + // The in-flight call reaches `ensure` FIRST: the deciding one is still + // awaiting its ledger read. Exactly one of the two may not decide. + expect(r.ensureCalls.filter((o) => o.bindOwner === false)).toHaveLength(1); + expect(first).toMatchObject({ memberCreated: true, defaultOrgId: 'org_d' }); + expect(second).toMatchObject({ memberCreated: false, reason: 'owner_bind_decided' }); + expect(r.tables.sys_member.filter((m) => m.role === 'owner')).toHaveLength(1); + expect(r.ownerBindInserts()).toHaveLength(1); + expect(r.recorded()).toEqual({ outcome: 'bound', organizationId: 'org_d' }); + }); + + it('a call that did not act clears the mark: the next trigger still decides', async () => { + const r = rig({ sys_organization: [{ id: 'org_d', slug: 'default' }] }); + r.hooks.refuseMemberInsertOnce = new Error('write refused'); + + const failed = await r.gate(r.ql); + expect(failed).toMatchObject({ memberCreated: false, reason: 'member_insert_failed' }); + expect(r.ownerBindInserts()).toHaveLength(0); + + const next = await r.gate(r.ql); + expect(r.ensureCalls[1]?.bindOwner, 'the next trigger decided, it was not read as in flight').not.toBe(false); + expect(next).toMatchObject({ memberCreated: true }); + expect(r.ownerBindInserts()).toHaveLength(1); + expect(r.recorded()).toEqual({ outcome: 'bound', organizationId: 'org_d' }); + }); + + it('CONTROL: a real ledger insert failure still reaches the error branch — nothing swallows it', async () => { + const r = rig({ sys_organization: [{ id: 'org_d', slug: 'default' }] }); + r.hooks.refuseLedger = new Error('disk I/O error'); + + const res = await r.gate(r.ql); + + expect(res).toMatchObject({ memberCreated: true }); + expect(r.ownerBindInserts()).toHaveLength(1); + expect(r.tables.sys_migration).toEqual([]); + expect(r.logger.error).toHaveBeenCalledTimes(1); + // The error branch, by its subject and its detail — not by its prose. + expect(r.logger.error).toHaveBeenCalledWith( + expect.stringContaining(DEFAULT_ORG_OWNER_BIND_MIGRATION_ID), + { error: 'disk I/O error' }, + ); + }); +}); From 2ed4378bbea727dc8a83488517cba2ce37878416 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 17:37:11 +0000 Subject: [PATCH 3/6] test(organizations): pin the walled wiring's owner bind against a trigger arriving in flight Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude --- ...fault-org-owner-bind-in-flight.pin.test.ts | 180 ++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 packages/plugins/organizations/src/walled-default-org-owner-bind-in-flight.pin.test.ts diff --git a/packages/plugins/organizations/src/walled-default-org-owner-bind-in-flight.pin.test.ts b/packages/plugins/organizations/src/walled-default-org-owner-bind-in-flight.pin.test.ts new file mode 100644 index 00000000000..c315fe19ac8 --- /dev/null +++ b/packages/plugins/organizations/src/walled-default-org-owner-bind-in-flight.pin.test.ts @@ -0,0 +1,180 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#22099] The walled wiring's owner bind is decided by ONE call, and that call +// writes the one ledger record — the in-flight rule of the gate both wirings +// share, measured through THIS plugin's own triggers. +// +// ## Why this file exists +// +// `OrganizationsPlugin` runs plugin-auth's `createEnsureDefaultOrganizationOnce` +// from `kernel:ready` and from its bootstrap middleware, the same gate the +// single-org `AuthPlugin` runs. On a single-org first boot that gate re-entered +// itself: the owner write made plugin-security grant `organization_admin`, the +// grant insert is a bootstrap trigger there, and the inner call recorded +// `admin-already-member` before the outer call's accurate record, which the +// `sys_migration` primary key then refused. The gate now marks its decision in +// flight before its first `await`; a call that finds it in flight binds nobody +// and records nothing. +// +// Measured on a walled first boot (`bootStack`, `isolated`, this plugin +// mounted): ONE `adr-0093-default-org-owner-bind` insert, details `bound` with +// the organization the operator's `sys_member` row names, no warning. Under a +// wall the grant-insert arm of the trigger predicate is retired, so the chain +// that re-enters the single-org wiring does not re-enter this one today. What +// this pin holds is the other half: when a trigger this plugin DOES honour — a +// `sys_user` write that touches `email_verified` — arrives while the decision +// is in flight, the walled wiring inherits the gate's rule instead of deciding +// a second time. +// +// ## The rig +// +// The fake engine of `walled-default-org-self-registrant.pin.test.ts`, plus the +// deployment ledger read by primary key, whose insert REFUSES a duplicate id the +// way the primary key does. The `sys_member` insert drives this plugin's own +// bootstrap middleware with the operator's verifying `sys_user` update before it +// returns — the bind is still in flight at that moment. + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { resetPlatformAdminEmailMemo } from '@objectstack/core'; +import { assertEngineFindOnePredicate } from '@objectstack/objectql'; +import { OrganizationsPlugin } from './organizations-plugin.js'; + +const OWNER_EMAIL = 'ops@operator.test'; +const OWNER_BIND_ID = 'adr-0093-default-org-owner-bind'; + +interface Row { + [key: string]: unknown; +} + +function makeEngine(store: Record) { + const inserts: Array<{ object: string; data: Row }> = []; + const middlewares: Array<(opCtx: any, next: () => Promise) => Promise> = []; + const hooks: { afterMemberInsert?: () => Promise } = {}; + const matches = (row: Row, where: Row | undefined): boolean => + Object.entries(where ?? {}).every(([key, value]) => { + // ⛔ Refuse a combinator rather than reading it as a field name + // (`pnpm check:where-matcher`). + if (key.startsWith('$')) { + throw new Error(`fake engine: WHERE combinator \`${key}\` is not implemented`); + } + return value === null ? row[key] == null : row[key] === value; + }); + const ql: any = { + getSchema: () => null, + registerMiddleware: (mw: any) => middlewares.push(mw), + // The deployment ledger: registered, read by primary key. + getObject: (name: string) => (name in store ? { name } : undefined), + findOne: vi.fn(async (object: string, query: any) => { + assertEngineFindOnePredicate(object, query); + return (store[object] ?? []).find((r) => r.id === query?.where?.id) ?? null; + }), + find: vi.fn(async (object: string, query: any) => { + const rows = (store[object] ?? []).filter((r) => matches(r, query?.where)); + return rows.slice(0, query?.limit ?? rows.length); + }), + insert: vi.fn(async (object: string, data: Row) => { + inserts.push({ object, data }); + if (object === 'sys_migration' && (store.sys_migration ?? []).some((r) => r.id === data.id)) { + throw new Error('UNIQUE constraint failed: sys_migration.id'); + } + (store[object] ??= []).push({ ...data }); + if (object === 'sys_member') await hooks.afterMemberInsert?.(); + return { ...data }; + }), + }; + return { ql, inserts, middlewares, hooks }; +} + +function makeCtx(ql: any) { + const hooks = new Map unknown>>(); + const services: Record = { + manifest: { register: vi.fn() }, + objectql: ql, + // `invite-only` through the settings cascade with a non-`default` source — + // what `membership-policy-gate.ts` requires a walled deployment to present. + settings: { + getNamespace: vi.fn(async () => ({ + values: { membership_policy: { value: 'invite-only', source: 'env' } }, + })), + }, + auth: { getMembershipPolicy: () => 'invite-only' }, + }; + const ctx: any = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + registerService: (name: string, svc: unknown) => { + services[name] = svc; + }, + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + hook: (name: string, handler: () => unknown) => { + if (!hooks.has(name)) hooks.set(name, []); + hooks.get(name)!.push(handler); + }, + }; + const trigger = async (name: string) => { + for (const h of hooks.get(name) ?? []) await h(); + }; + return { ctx, trigger }; +} + +const savedPosture = process.env.OS_TENANCY_POSTURE; +const savedOwner = process.env.OS_PLATFORM_OWNER_EMAIL; +afterEach(() => { + if (savedPosture === undefined) delete process.env.OS_TENANCY_POSTURE; + else process.env.OS_TENANCY_POSTURE = savedPosture; + if (savedOwner === undefined) delete process.env.OS_PLATFORM_OWNER_EMAIL; + else process.env.OS_PLATFORM_OWNER_EMAIL = savedOwner; + resetPlatformAdminEmailMemo(); +}); + +describe('walled: a bootstrap trigger arriving while the owner bind is in flight decides nothing (#22099)', () => { + it('the deciding call records once — `bound`, with the organization sys_member points at', async () => { + process.env.OS_TENANCY_POSTURE = 'isolated'; + process.env.OS_PLATFORM_OWNER_EMAIL = OWNER_EMAIL; + resetPlatformAdminEmailMemo(); + const store: Record = { + sys_permission_set: [{ id: 'ps_admin', name: 'admin_full_access' }], + sys_user_permission_set: [], + sys_user: [{ id: 'usr_ops', email: OWNER_EMAIL, email_verified: true, created_at: '2026-03-03T00:00:00.000Z' }], + sys_organization: [], + sys_member: [], + sys_migration: [], + }; + const { ql, inserts, middlewares, hooks } = makeEngine(store); + const { ctx, trigger } = makeCtx(ql); + const plugin = new OrganizationsPlugin(); + await plugin.init(ctx); + await plugin.start(ctx); + await trigger('kernel:bootstrapped'); + + // The bootstrap re-run arm is the LAST middleware `start()` registers (the + // neighbouring pin's reading). It is driven from inside the bind, once. + const bootstrapMw = middlewares[middlewares.length - 1]!; + let reentered = 0; + hooks.afterMemberInsert = async () => { + reentered += 1; + if (reentered > 1) return; + await bootstrapMw( + { object: 'sys_user', operation: 'update', data: { id: 'usr_ops', email_verified: true } }, + async () => {}, + ); + }; + + await trigger('kernel:ready'); + + const memberInserts = inserts.filter((i) => i.object === 'sys_member'); + const ledgerInserts = inserts.filter((i) => i.object === 'sys_migration' && i.data.id === OWNER_BIND_ID); + expect(reentered, 'precondition: the bind re-entered this plugin\'s bootstrap middleware').toBe(1); + expect(memberInserts).toHaveLength(1); + expect(memberInserts[0]!.data).toMatchObject({ user_id: 'usr_ops', role: 'owner' }); + expect(ledgerInserts, 'the owner-bind decision was written more than once').toHaveLength(1); + const recorded = store.sys_migration!.find((r) => r.id === OWNER_BIND_ID); + expect(JSON.parse(String(recorded?.details))).toEqual({ + outcome: 'bound', + organizationId: store.sys_member![0]!.organization_id, + }); + expect(ctx.logger.error).not.toHaveBeenCalled(); + }); +}); From d286091a08cbb5b14e42468310ad336c590e9721 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 17:38:58 +0000 Subject: [PATCH 4/6] =?UTF-8?q?test(dogfood):=20enumerate=20the=20sys=5Fmi?= =?UTF-8?q?gration=20ledger=20over=20two=20fresh=20boots=20=E2=80=94=20eac?= =?UTF-8?q?h=20row=20written=20once,=20no=20warning=20names=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude --- ...migration-boot-ledger-once.dogfood.test.ts | 179 ++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 packages/qa/dogfood/test/sys-migration-boot-ledger-once.dogfood.test.ts diff --git a/packages/qa/dogfood/test/sys-migration-boot-ledger-once.dogfood.test.ts b/packages/qa/dogfood/test/sys-migration-boot-ledger-once.dogfood.test.ts new file mode 100644 index 00000000000..160cbca1ce8 --- /dev/null +++ b/packages/qa/dogfood/test/sys-migration-boot-ledger-once.dogfood.test.ts @@ -0,0 +1,179 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#22099] Every row of the `sys_migration` ledger is written ONCE across a +// fresh database's first two boots, and neither boot logs a warning that names +// the ledger — enumerated from the table's own rows, over the real boot. +// +// ## Why an enumeration, and why here +// +// This is the third false `sys_migration` warning a fresh boot printed: #20648 +// (a paged read on an upgraded database), #20768 (a read before the table +// exists) and #22099 (plugin-auth's owner-bind gate re-entering itself, so the +// inner call recorded first and the outer call's accurate record was refused by +// the primary key — a warning and a stack on every first boot, and a ledger row +// naming the wrong outcome). Each was found by someone reading boot output. +// The ledger has several writers in several packages, and a new one arrives +// with every one-time pass, so a pin keyed to the ids known today would miss +// the next one. The ids are therefore read from the table after the boots, and +// each one must have been inserted exactly once. +// +// `bootStack` is the cheapest composition that writes the ledger the way a +// served kernel does: `PlatformObjectsPlugin` (the ledger and its fresh-store +// attestations), `AuthPlugin` with its defaults (the owner bind and the +// membership backfill), `SecurityPlugin` (the platform-admin promotion and the +// organization-admin grant that re-entered the owner bind) and the dev admin +// `objectstack dev` seeds. Two boots over one database file make the second a +// genuine cold boot over the first's ledger. +// +// ## What is counted, and how the counts are made non-vacuous +// +// - Insert ATTEMPTS, not rows: a probe plugin in the harness's `extraPlugins` +// slot registers an engine middleware that records the id of every +// `sys_migration` insert before it reaches the driver, refused or not. A +// refused second insert leaves the row count unchanged, which is exactly +// why rows alone could not see the defect. An insert that ran before the +// probe was registered would leave a row with zero attempts — red, not +// green. +// - Every line the process writes during each boot (the kernel logger writes +// to the streams). The control below requires the capture to have parsed an +// `INFO` line naming the ledger, so an empty capture, or a line format this +// file no longer parses, cannot pass the absence. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { DATA_MIGRATION_FLAG_OBJECT } from '@objectstack/spec/system'; + +const LEDGER = DATA_MIGRATION_FLAG_OBJECT; +const SYS = { context: { isSystem: true } } as const; +const OWNER_BIND_ID = 'adr-0093-default-org-owner-bind'; + +/** An app with nothing in it: every ledger row below is the platform's own. */ +const APP = { + manifest: { + id: 'com.dogfood.sys-migration-boot-ledger', + namespace: 'ledgerboot', + version: '0.0.0', + type: 'app', + name: 'Ledger Boot Fixture', + }, + objects: [], +}; + +/** Records the id of every ledger insert the engine is asked to make. */ +function ledgerInsertProbe(attempts: string[]) { + return { + name: 'dogfood.sys-migration-insert-probe', + async init(ctx: any) { + const ql = ctx.getService('objectql'); + ql.registerMiddleware(async (opCtx: any, next: () => Promise) => { + if (opCtx?.object === LEDGER && (opCtx?.operation === 'insert' || opCtx?.operation === 'create')) { + const rows = Array.isArray(opCtx.data) ? opCtx.data : [opCtx.data]; + for (const row of rows) attempts.push(String(row?.id)); + } + await next(); + }); + }, + async start() {}, + }; +} + +/** Every line the process writes while `run` is in flight. */ +async function captureOutput(run: () => Promise): Promise<{ value: T; lines: string[] }> { + const chunks: string[] = []; + const stdout = process.stdout.write.bind(process.stdout); + const stderr = process.stderr.write.bind(process.stderr); + const warn = console.warn; + const error = console.error; + (process.stdout as any).write = (chunk: unknown, ...rest: any[]) => { chunks.push(String(chunk)); return stdout(chunk as any, ...rest); }; + (process.stderr as any).write = (chunk: unknown, ...rest: any[]) => { chunks.push(String(chunk)); return stderr(chunk as any, ...rest); }; + console.warn = (...args: unknown[]) => { chunks.push(`WARN ${args.map(String).join(' ')}\n`); warn(...args); }; + console.error = (...args: unknown[]) => { chunks.push(`ERROR ${args.map(String).join(' ')}\n`); error(...args); }; + try { + const value = await run(); + return { value, lines: chunks.join('').split('\n') }; + } finally { + (process.stdout as any).write = stdout; + (process.stderr as any).write = stderr; + console.warn = warn; + console.error = error; + } +} + +/** The level of one kernel-logger line (` …`), or `undefined`. */ +function levelOf(line: string): string | undefined { + return /^(?:\S+Z\s+)?(DEBUG|INFO|WARN|ERROR|FATAL)\b/.exec(line.trim())?.[1]; +} + +describe('[#22099] a fresh database\'s first two boots write each sys_migration row once, and warn about none', () => { + let dir: string; + const attempts: string[][] = [[], []]; + const lines: string[][] = [[], []]; + let rows: Array<{ id: string; details: unknown }> = []; + let ownerMembers: Array> = []; + let stack: VerifyStack | undefined; + + beforeAll(async () => { + dir = mkdtempSync(join(tmpdir(), 'dogfood-22099-')); + const databaseFile = join(dir, 'ledger.db'); + for (const boot of [0, 1]) { + const run = await captureOutput(() => + bootStack(APP as never, { databaseFile, extraPlugins: [ledgerInsertProbe(attempts[boot]!)] }), + ); + stack = run.value; + lines[boot] = run.lines; + if (boot === 1) { + const ql: any = await stack.kernel.getServiceAsync('objectql'); + rows = (await ql.find(LEDGER, { where: {} }, SYS)).map((r: any) => ({ id: String(r.id), details: r.details })); + ownerMembers = await ql.find('sys_member', { where: { role: 'owner' } }, SYS); + } + await stack.stop(); + stack = undefined; + } + }, 300_000); + + afterAll(async () => { + await stack?.stop(); + if (dir) rmSync(dir, { recursive: true, force: true }); + }); + + it('control: the ledger has rows, and the capture parsed an INFO line naming it', () => { + expect(rows.length, `the boots wrote no ${LEDGER} row — every assertion below would be vacuous`).toBeGreaterThan(0); + const named = lines[0]!.filter((l) => l.includes(LEDGER)); + expect(named.some((l) => levelOf(l) === 'INFO'), `no INFO line naming ${LEDGER} was parsed in boot 1`).toBe(true); + }); + + it('every id in the table was inserted exactly once across both boots — enumerated from the rows', () => { + const all = [...attempts[0]!, ...attempts[1]!]; + const counts = Object.fromEntries(rows.map((r) => [r.id, all.filter((id) => id === r.id).length])); + expect(counts).toEqual(Object.fromEntries(rows.map((r) => [r.id, 1]))); + // And nothing was attempted that the table does not hold. + expect([...new Set(all)].sort()).toEqual(rows.map((r) => r.id).sort()); + // The second boot is a cold boot over a decided ledger: it writes nothing. + expect(attempts[1]).toEqual([]); + }); + + it(`no line at WARN or above names ${LEDGER}, on either boot`, () => { + for (const boot of [0, 1]) { + const loud = lines[boot]!.filter((l) => l.includes(LEDGER) && ['WARN', 'ERROR', 'FATAL'].includes(levelOf(l) ?? '')); + expect(loud, `boot ${boot + 1}`).toEqual([]); + } + }); + + it('the owner-bind row records the deciding call\'s outcome, with the organization its owner row names', () => { + // On this composition the Default Organization exists before the dev admin + // (ADR-0131 D3), the membership reconciler binds the admin as `member`, and + // the gate PROMOTES that row — so the accurate outcome is `promoted`. The + // lost update recorded the re-entrant call's `admin-already-member`, with + // no organization. + const row = rows.find((r) => r.id === OWNER_BIND_ID); + expect(row, `no ${OWNER_BIND_ID} row`).toBeDefined(); + expect(ownerMembers).toHaveLength(1); + expect(JSON.parse(String(row!.details))).toEqual({ + outcome: 'promoted', + organizationId: ownerMembers[0]!.organization_id, + }); + }); +}); From 33520be606459462906c90451593a24c306a3e21 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 17:46:49 +0000 Subject: [PATCH 5/6] chore(changeset): plugin-auth patch for the owner-bind gate's in-flight mark Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude --- .changeset/22099-owner-bind-in-flight.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .changeset/22099-owner-bind-in-flight.md diff --git a/.changeset/22099-owner-bind-in-flight.md b/.changeset/22099-owner-bind-in-flight.md new file mode 100644 index 00000000000..687bd799259 --- /dev/null +++ b/.changeset/22099-owner-bind-in-flight.md @@ -0,0 +1,15 @@ +--- +'@objectstack/plugin-auth': patch +--- + +A first boot no longer logs a refused `sys_migration` insert, and the owner-bind ledger row records the outcome that happened + +Clause-②: no + +On every first boot (`os serve` and `os dev`, on `:memory:`, file SQLite and PostgreSQL), *Boot diagnostics* showed `WARN Insert operation failed {"object":"sys_migration", … UNIQUE constraint failed: sys_migration.id}` with a full stack. The one-time default-organization owner bind (`createEnsureDefaultOrganizationOnce`, ADR-0093 D7) re-entered itself: the owner write makes the security plugin grant `organization_admin`, that grant insert is a bootstrap trigger, and the inner call recorded the decision first. It recorded `admin-already-member` with no organization. The outer call's accurate record (`promoted` or `bound`, with the organization id) was then refused. Nothing was bound twice. The ledger row named the wrong outcome. + +The gate now marks the decision in flight before its first `await`. A call that finds it in flight still creates a missing Default Organization, but binds nobody and records nothing. The deciding call records its own outcome once. A call that did not act (no platform admin yet, or a refused write) clears the mark, so the next trigger still decides. + +- **Nothing you author changes.** No key, export or option is added or removed. The walled `@objectstack/organizations` wiring calls the same gate and gets the same rule. +- **A real ledger write failure is still reported.** Only the gate's own second write is gone; a refused `sys_migration` insert for any other reason still logs at `error`, with the remedy. +- **Rows already written are not rewritten.** A deployment whose first boot recorded `admin-already-member` keeps that row; nothing reads its `details`. From 82ae109f5ee4a42422e630ad626757e2fa110ccf Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 18:15:01 +0000 Subject: [PATCH 6/6] test(plugin-auth): route the gate pin's findOne double through the engine predicate; record the new pinned doubles check:engine-double-contract asks a new findOne double to route through assertEngineFindOnePredicate and the RETAINED ledger to learn the three new pinned (file, verb) rows (node scripts/check-engine-double-contract.mjs --write). Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude --- .../src/default-org-bootstrap-once.test.ts | 10 ++++++---- scripts/engine-double-contract.pinned.json | 15 +++++++++++++++ 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/packages/plugins/plugin-auth/src/default-org-bootstrap-once.test.ts b/packages/plugins/plugin-auth/src/default-org-bootstrap-once.test.ts index fcfe8ab11d5..486f1f99eee 100644 --- a/packages/plugins/plugin-auth/src/default-org-bootstrap-once.test.ts +++ b/packages/plugins/plugin-auth/src/default-org-bootstrap-once.test.ts @@ -30,7 +30,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { resetPlatformAdminEmailMemo } from '@objectstack/core'; -import { assertEngineUpdateDispatch } from '@objectstack/objectql'; +import { assertEngineFindOnePredicate, assertEngineUpdateDispatch } from '@objectstack/objectql'; import { ensureDefaultOrganization, type EnsureDefaultOrganizationOptions, @@ -87,9 +87,11 @@ function rig(seed: Partial> = {}) { const rows = (tables[object] ?? []).filter((r) => matches(r, query?.where)); return rows.slice(0, query?.limit ?? rows.length); }), - findOne: vi.fn(async (object: string, query: any) => - (tables[object] ?? []).find((r) => r.id === query?.where?.id) ?? null, - ), + findOne: vi.fn(async (object: string, query: any) => { + // Pinned to ObjectQL.findOne's predicate contract (the ledger reads by id). + assertEngineFindOnePredicate(object, query); + return (tables[object] ?? []).find((r) => r.id === query?.where?.id) ?? null; + }), insert: vi.fn(async (object: string, data: Row) => { if (object === 'sys_migration') { ledgerInserts.push(data); diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 9d671e8d1d8..ca6c1dd61a4 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -2436,6 +2436,11 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/plugins/organizations/src/walled-default-org-owner-bind-in-flight.pin.test.ts", + "verb": "findOne", + "pinned": 1 + }, { "file": "packages/plugins/plugin-approvals/src/admin-exemption-retired.test.ts", "verb": "delete", @@ -2776,6 +2781,16 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/plugins/plugin-auth/src/default-org-bootstrap-once.test.ts", + "verb": "findOne", + "pinned": 1 + }, + { + "file": "packages/plugins/plugin-auth/src/default-org-bootstrap-once.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/plugins/plugin-auth/src/durability-swallow-repair.test.ts", "verb": "findOne",