Skip to content

Commit 6ff6ed6

Browse files
fix(plugin-auth): the owner-bind gate decides once, so a first boot logs no refused sys_migration insert (#22336)
Fixes #22099 Clause-②: no The one-time owner-bind gate (`createEnsureDefaultOrganizationOnce`, `packages/plugins/plugin-auth/src/default-org-bootstrap-once.ts`) now marks its decision in flight synchronously, before its first `await`. A call that finds the decision in flight runs `ensure(ql, { bindOwner: false })` and records nothing. The deciding call records its own outcome once. A call that did not act (`no_admin`, a refused write) clears the mark on the way out, so the next trigger still decides. This is the direction triage ruled (`6042758404`, option A). There is no promise chain, no ledger upsert or insert-if-absent, and no catch-all. Triage folded #22102 into this card as a duplicate (same row, same mechanism); #22102 remains open, and its disposition is the seat's. ## Re-measured on the landed shape (`origin/main` `28bff18d`, after PR #22186) The gate still re-enters itself. The Default Organization boot invariant (ADR-0131 D3) changes which write re-enters it: the outer call now PROMOTES the reconciler-written `member` row instead of inserting `sys_member`. The chain on a first boot, outermost first (probe stacks through `bootStack`): - `kernel:ready`, `security-plugin.ts:4701` `runBootstrap`, `bootstrap-platform-admin.ts:1170` `promote`, `:407` insert `sys_user_permission_set`; - plugin-auth middleware `auth-plugin.ts:1255`, `runEnsure` `:1224`: the OUTER call; - `ensure-default-organization.ts:498`, `promoteReconciledMemberToOwner` `:379`, `ql.update('sys_member', { role: 'owner' })`; - security-plugin middleware `security-plugin.ts:5145`, `reconcileOrgAdminGrant` `auto-org-admin-grant.ts:777`, `:238` insert `sys_user_permission_set`; - plugin-auth middleware `auth-plugin.ts:1255` again: the INNER call. It records `{ outcome: 'admin-already-member' }` first. The outer call's `{ outcome: 'promoted', organizationId }` is then refused by the primary key. | boot (CLI `os serve`, `examples/app-crm`, `NODE_ENV=development`) | `sys_migration` failed-insert lines, before | after | |---|---|---| | `:memory:` | 1 | 0 | | file SQLite, first boot | 1 | 0 | | same file, second boot | 0 | 0 | | `bootStack` (dogfood harness, empty app) | `adr-0093-default-org-owner-bind` insert attempts, before | after | |---|---|---| | `:memory:` | 2 (second refused) | 1 | | file, first boot | 2 (second refused) | 1 | | same file, second boot | 0 | 0 | Persisted owner-bind `details` (file DB, read back): - before: `{"outcome":"admin-already-member"}`, while `sys_member` held the admin as `owner` of the one default organization; - after: `{"outcome":"promoted","organizationId":"org_muzty5e5bxpe2dtt"}`, which is the organization of the admin's `owner` row. Walled first boot (`bootStack`, `isolated`, `OrganizationsPlugin` mounted), measured on `main`: ONE owner-bind insert, `{"outcome":"bound","organizationId":…}` matching the operator's `sys_member` row, and no warning. Under a wall the grant-insert arm of `isDefaultOrganizationBootstrapTrigger` is retired, so this chain does not re-enter the walled wiring today. The walled wiring calls the same gate and gets the same rule. ## Pins - `packages/plugins/plugin-auth/src/default-org-bootstrap-once.test.ts`: the gate over a fake engine whose `sys_migration` insert refuses a duplicate id and whose `sys_member` writes re-enter the gate. - a fresh bind records `bound` once, with the org `sys_member` points at; - the promotion (the landed boot shape) records `promoted` once; - a concurrent trigger binds nobody: one owner row, one record; - a call that did not act clears the mark; - CONTROL: a real ledger insert failure still reaches `recordLedgerDecision`'s `error` branch. - `packages/plugins/organizations/src/walled-default-org-owner-bind-in-flight.pin.test.ts` sits beside `walled-default-org-self-registrant.pin.test.ts`. A `sys_user` `email_verified` update reaches `OrganizationsPlugin`'s own bootstrap middleware while the bind is in flight. The ledger is written once, `bound`, with the org `sys_member` points at. - `packages/qa/dogfood/test/sys-migration-boot-ledger-once.dogfood.test.ts` is the family's enumeration pin. It boots a fresh database twice over one file through `bootStack`. The ids come from the table's own rows, and each one must have been inserted exactly once. No line at `WARN` or above may name `sys_migration`. The owner-bind row must read the deciding call's outcome with its owner's organization. The control requires the capture to have parsed an `INFO` line naming the ledger. - **Deviation from the ruled pin text:** triage's pin says the persisted `details` read `bound`. On the landed full boot the accurate outcome is `promoted` (ADR-0131 D3 binds the admin as `member` before the gate runs). So the dogfood pin asserts `promoted`, and the `bound` arm is pinned at the unit and walled layers, where the gate inserts the owner row. ## Ablation (at `d286091a08`, fix committed first; delete the synchronous mark) The mark (`deciding = true;`) was deleted with `scripts/ablation-replace.mjs`: anchor 1 → 0, blob `27c001b5` → `635eb0e8`. plugin-auth was rebuilt (exit 0), and `ablation-dist-preflight --absent 'deciding = true'` passed. - plugin-auth pin: 3 failed, 2 passed. The re-entry, promotion and concurrent cases are red; the clears-the-mark case and the CONTROL are green, as predicted. - walled pin: 1 failed. `expected [ …(2) ] to have a length of 1 but got 2`. - dogfood pin: 3 failed, 1 passed. `adr-0093-default-org-owner-bind: 2` against 1; `boot 1: expected [ Array(1) ] to deeply equal []` (the warning); `{ outcome: 'admin-already-member' }` (the lost update). The control was green. The restore was proved: blob `27c001b5` matches HEAD and `git diff HEAD` is empty. plugin-auth was rebuilt and the preflight found `deciding = true` in dist again. `git status --porcelain` was empty. A first ablation leg deleted only the `|| deciding` read. Its JS reached dist, but the build exited 1 at the DTS step (TS6133: `deciding` was never read). That leg is VOIDED as a reading, and the leg above replaces it. Its colours were the same. ## Verification The package runs below are at `33520be606`. The final commit `82ae109f5e` changed only the plugin-auth pin's `findOne` double and the generated `scripts/engine-double-contract.pinned.json`, and the runs it can move were repeated there. The gate union ran at `82ae109f5e`. - `pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2`: 130 files, 2662 passed, 10 skipped. At `82ae109f5e` the new file re-ran 5/5 and `check:test-typecheck` re-ran OK. - `pnpm --filter @objectstack/plugin-auth typecheck`: exit 0. `check:test-typecheck` held 10 files / 94 errors, unchanged. - `pnpm --filter @objectstack/organizations exec vitest run --maxWorkers=2`: 12 files, 152 passed. Its `typecheck` exited 0. - `pnpm --filter @objectstack/dogfood typecheck`: exit 0. The enumeration pin: 4/4. - The full dogfood suite is NOT run locally; it is declared to CI's `Dogfood Regression Gate`. Only the new file ran here. No importer of `plugin-auth` owes a test: the diff changes a function body and a module comment, and no exported declaration. - ① `turbo run build --filter='@objectstack/plugin-auth^...' --filter='@objectstack/organizations^...' --filter='@objectstack/dogfood^...' --concurrency=1`: 63/63. - Gates: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 76 commands from this branch's change set at `82ae109f5e`. All 76 ran there and exited 0. `--ran` reconciled them: `76 derived famil(ies) accounted for — 76 run, 0 NOT-MEASURED`. Sample verdict lines: - `check-engine-double-contract: OK — 986 pinned, 129 in the DEBT ledger, 3 exempt.` - `check-nul-bytes: OK (scanned 10308 text file(s) …; no raw ASCII control bytes).` - `check-test-source-alias OK — 73 packages with tests scanned; 60 registered as still resolving a workspace dep through dist/ …` - `OK: 30 package(s) read outside themselves, all declared …` (`check:cross-package-test-inputs`). - The first pass at `33520be606` had two non-zero lines. `check:engine-double-contract` was red: the new findOne double was not routed through `assertEngineFindOnePredicate`, and the ledger lacked the new rows. Repaired in `82ae109f5e`. `check:dual-build-cjs-loads` printed `PREREQUISITE NOT MET` (8 packages had no dist). It was NOT MEASURED then, and it exited 0 after those dists were restored from the turbo cache. - eslint, narrowed (CI owns `pnpm lint`): 4 files, 0 errors and 0 warnings by `--format json`. Each file is matched by `eslint.config.mjs` (`--print-config`), and none is ignored. `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`), so this diff cannot move the verdict on any untouched file. ## Acceptance notes - `scripts/engine-double-contract.pinned.json` gains three generated rows (`--write`, grow-only coverage ledger) for the two new pinned doubles. This file is outside the claim's declared surface, and the gate requires it for any new pinned double. - Read-only inference, not reproduced, and no card filed (carrier: none). On a kernel with NO ledger, an in-flight caller runs `ensure` with `bindOwner: false` and may still CREATE the default organization while the deciding call waits. The decider then sees an existing organization under `bindOnlyOnCreate` and binds nobody. `ensureDefaultOrganization`'s org creation was already not concurrency-safe (two concurrent calls can both insert one). Every served kernel composes the ledger (`PlatformObjectsPlugin`), and `single` creates the organization at boot. - Not measured: a production first sign-up (no dev admin). The gate's rule does not depend on which trigger re-enters it. - Rows already written as `admin-already-member` are not rewritten. Nothing reads `details`: `readLedgerDecision` answers existence only. --- _Generated by [Claude Code](https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e9a1f5c commit 6ff6ed6

6 files changed

Lines changed: 683 additions & 40 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
---
4+
5+
A first boot no longer logs a refused `sys_migration` insert, and the owner-bind ledger row records the outcome that happened
6+
7+
Clause-②: no
8+
9+
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.
10+
11+
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.
12+
13+
- **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.
14+
- **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.
15+
- **Rows already written are not rewritten.** A deployment whose first boot recorded `admin-already-member` keeps that row; nothing reads its `details`.
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#22099] The walled wiring's owner bind is decided by ONE call, and that call
4+
// writes the one ledger record — the in-flight rule of the gate both wirings
5+
// share, measured through THIS plugin's own triggers.
6+
//
7+
// ## Why this file exists
8+
//
9+
// `OrganizationsPlugin` runs plugin-auth's `createEnsureDefaultOrganizationOnce`
10+
// from `kernel:ready` and from its bootstrap middleware, the same gate the
11+
// single-org `AuthPlugin` runs. On a single-org first boot that gate re-entered
12+
// itself: the owner write made plugin-security grant `organization_admin`, the
13+
// grant insert is a bootstrap trigger there, and the inner call recorded
14+
// `admin-already-member` before the outer call's accurate record, which the
15+
// `sys_migration` primary key then refused. The gate now marks its decision in
16+
// flight before its first `await`; a call that finds it in flight binds nobody
17+
// and records nothing.
18+
//
19+
// Measured on a walled first boot (`bootStack`, `isolated`, this plugin
20+
// mounted): ONE `adr-0093-default-org-owner-bind` insert, details `bound` with
21+
// the organization the operator's `sys_member` row names, no warning. Under a
22+
// wall the grant-insert arm of the trigger predicate is retired, so the chain
23+
// that re-enters the single-org wiring does not re-enter this one today. What
24+
// this pin holds is the other half: when a trigger this plugin DOES honour — a
25+
// `sys_user` write that touches `email_verified` — arrives while the decision
26+
// is in flight, the walled wiring inherits the gate's rule instead of deciding
27+
// a second time.
28+
//
29+
// ## The rig
30+
//
31+
// The fake engine of `walled-default-org-self-registrant.pin.test.ts`, plus the
32+
// deployment ledger read by primary key, whose insert REFUSES a duplicate id the
33+
// way the primary key does. The `sys_member` insert drives this plugin's own
34+
// bootstrap middleware with the operator's verifying `sys_user` update before it
35+
// returns — the bind is still in flight at that moment.
36+
37+
import { describe, it, expect, vi, afterEach } from 'vitest';
38+
import { resetPlatformAdminEmailMemo } from '@objectstack/core';
39+
import { assertEngineFindOnePredicate } from '@objectstack/objectql';
40+
import { OrganizationsPlugin } from './organizations-plugin.js';
41+
42+
const OWNER_EMAIL = 'ops@operator.test';
43+
const OWNER_BIND_ID = 'adr-0093-default-org-owner-bind';
44+
45+
interface Row {
46+
[key: string]: unknown;
47+
}
48+
49+
function makeEngine(store: Record<string, Row[]>) {
50+
const inserts: Array<{ object: string; data: Row }> = [];
51+
const middlewares: Array<(opCtx: any, next: () => Promise<void>) => Promise<void>> = [];
52+
const hooks: { afterMemberInsert?: () => Promise<void> } = {};
53+
const matches = (row: Row, where: Row | undefined): boolean =>
54+
Object.entries(where ?? {}).every(([key, value]) => {
55+
// ⛔ Refuse a combinator rather than reading it as a field name
56+
// (`pnpm check:where-matcher`).
57+
if (key.startsWith('$')) {
58+
throw new Error(`fake engine: WHERE combinator \`${key}\` is not implemented`);
59+
}
60+
return value === null ? row[key] == null : row[key] === value;
61+
});
62+
const ql: any = {
63+
getSchema: () => null,
64+
registerMiddleware: (mw: any) => middlewares.push(mw),
65+
// The deployment ledger: registered, read by primary key.
66+
getObject: (name: string) => (name in store ? { name } : undefined),
67+
findOne: vi.fn(async (object: string, query: any) => {
68+
assertEngineFindOnePredicate(object, query);
69+
return (store[object] ?? []).find((r) => r.id === query?.where?.id) ?? null;
70+
}),
71+
find: vi.fn(async (object: string, query: any) => {
72+
const rows = (store[object] ?? []).filter((r) => matches(r, query?.where));
73+
return rows.slice(0, query?.limit ?? rows.length);
74+
}),
75+
insert: vi.fn(async (object: string, data: Row) => {
76+
inserts.push({ object, data });
77+
if (object === 'sys_migration' && (store.sys_migration ?? []).some((r) => r.id === data.id)) {
78+
throw new Error('UNIQUE constraint failed: sys_migration.id');
79+
}
80+
(store[object] ??= []).push({ ...data });
81+
if (object === 'sys_member') await hooks.afterMemberInsert?.();
82+
return { ...data };
83+
}),
84+
};
85+
return { ql, inserts, middlewares, hooks };
86+
}
87+
88+
function makeCtx(ql: any) {
89+
const hooks = new Map<string, Array<() => unknown>>();
90+
const services: Record<string, unknown> = {
91+
manifest: { register: vi.fn() },
92+
objectql: ql,
93+
// `invite-only` through the settings cascade with a non-`default` source —
94+
// what `membership-policy-gate.ts` requires a walled deployment to present.
95+
settings: {
96+
getNamespace: vi.fn(async () => ({
97+
values: { membership_policy: { value: 'invite-only', source: 'env' } },
98+
})),
99+
},
100+
auth: { getMembershipPolicy: () => 'invite-only' },
101+
};
102+
const ctx: any = {
103+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
104+
registerService: (name: string, svc: unknown) => {
105+
services[name] = svc;
106+
},
107+
getService: (name: string) => {
108+
if (!(name in services)) throw new Error(`service not registered: ${name}`);
109+
return services[name];
110+
},
111+
hook: (name: string, handler: () => unknown) => {
112+
if (!hooks.has(name)) hooks.set(name, []);
113+
hooks.get(name)!.push(handler);
114+
},
115+
};
116+
const trigger = async (name: string) => {
117+
for (const h of hooks.get(name) ?? []) await h();
118+
};
119+
return { ctx, trigger };
120+
}
121+
122+
const savedPosture = process.env.OS_TENANCY_POSTURE;
123+
const savedOwner = process.env.OS_PLATFORM_OWNER_EMAIL;
124+
afterEach(() => {
125+
if (savedPosture === undefined) delete process.env.OS_TENANCY_POSTURE;
126+
else process.env.OS_TENANCY_POSTURE = savedPosture;
127+
if (savedOwner === undefined) delete process.env.OS_PLATFORM_OWNER_EMAIL;
128+
else process.env.OS_PLATFORM_OWNER_EMAIL = savedOwner;
129+
resetPlatformAdminEmailMemo();
130+
});
131+
132+
describe('walled: a bootstrap trigger arriving while the owner bind is in flight decides nothing (#22099)', () => {
133+
it('the deciding call records once — `bound`, with the organization sys_member points at', async () => {
134+
process.env.OS_TENANCY_POSTURE = 'isolated';
135+
process.env.OS_PLATFORM_OWNER_EMAIL = OWNER_EMAIL;
136+
resetPlatformAdminEmailMemo();
137+
const store: Record<string, Row[]> = {
138+
sys_permission_set: [{ id: 'ps_admin', name: 'admin_full_access' }],
139+
sys_user_permission_set: [],
140+
sys_user: [{ id: 'usr_ops', email: OWNER_EMAIL, email_verified: true, created_at: '2026-03-03T00:00:00.000Z' }],
141+
sys_organization: [],
142+
sys_member: [],
143+
sys_migration: [],
144+
};
145+
const { ql, inserts, middlewares, hooks } = makeEngine(store);
146+
const { ctx, trigger } = makeCtx(ql);
147+
const plugin = new OrganizationsPlugin();
148+
await plugin.init(ctx);
149+
await plugin.start(ctx);
150+
await trigger('kernel:bootstrapped');
151+
152+
// The bootstrap re-run arm is the LAST middleware `start()` registers (the
153+
// neighbouring pin's reading). It is driven from inside the bind, once.
154+
const bootstrapMw = middlewares[middlewares.length - 1]!;
155+
let reentered = 0;
156+
hooks.afterMemberInsert = async () => {
157+
reentered += 1;
158+
if (reentered > 1) return;
159+
await bootstrapMw(
160+
{ object: 'sys_user', operation: 'update', data: { id: 'usr_ops', email_verified: true } },
161+
async () => {},
162+
);
163+
};
164+
165+
await trigger('kernel:ready');
166+
167+
const memberInserts = inserts.filter((i) => i.object === 'sys_member');
168+
const ledgerInserts = inserts.filter((i) => i.object === 'sys_migration' && i.data.id === OWNER_BIND_ID);
169+
expect(reentered, 'precondition: the bind re-entered this plugin\'s bootstrap middleware').toBe(1);
170+
expect(memberInserts).toHaveLength(1);
171+
expect(memberInserts[0]!.data).toMatchObject({ user_id: 'usr_ops', role: 'owner' });
172+
expect(ledgerInserts, 'the owner-bind decision was written more than once').toHaveLength(1);
173+
const recorded = store.sys_migration!.find((r) => r.id === OWNER_BIND_ID);
174+
expect(JSON.parse(String(recorded?.details))).toEqual({
175+
outcome: 'bound',
176+
organizationId: store.sys_member![0]!.organization_id,
177+
});
178+
expect(ctx.logger.error).not.toHaveBeenCalled();
179+
});
180+
});

0 commit comments

Comments
 (0)