From b8daeb51f1ff308dc2cd2e42262fddd0888e8fbb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 23:38:22 +0000 Subject: [PATCH 1/4] fix(plugin-security): run the seed-ownership claim whenever a seed settles, on every boot The app:seeded handler now resolves its claim target itself (the existing platform admin, by the bootstrap's own already_have_admin rule, extracted into one shared function) when no bootstrap pass of this boot has named it, and is subscribed in init() so an in-budget seed fired from an earlier plugin's start() is heard. The claim's log lines now say what happens. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude --- .../src/bootstrap-platform-admin.ts | 300 +++++++++++------- .../src/claim-seed-ownership.ts | 51 +-- .../plugin-security/src/security-plugin.ts | 229 +++++++------ 3 files changed, 360 insertions(+), 220 deletions(-) diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts index 6f8b98a0a43..69a64c91ce2 100644 --- a/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts +++ b/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts @@ -540,6 +540,176 @@ function platformOwnedFields(ps: PermissionSet): Record { }; } +/** + * The permission set whose unscoped grant makes a user the platform admin under + * `single`. Named once so the bootstrap's guard and + * {@link findExistingPlatformAdmin} cannot ask about two different sets. + */ +const PLATFORM_ADMIN_PERMISSION_SET_NAME = 'admin_full_access'; + +/** + * Who ALREADY holds the unscoped platform-admin grant (commit 1c83ca226) — the + * one rule both {@link bootstrapPlatformAdmin}'s `already_have_admin` guard and + * {@link findExistingPlatformAdmin} answer with. + * + * The read was once `tryFind(ql, 'sys_user_permission_set', { + * permission_set_id: adminPsId }, 50)` — no `orderBy`, cap 50 — with the + * predicate that actually decides (`!organization_id`) applied CLIENT-SIDE to + * whatever 50 rows the driver produced first. `admin_full_access` is not only + * the platform-admin set: every ORGANIZATION-SCOPED grant of it writes a row + * carrying the same `permission_set_id`, so this population grows with the + * number of ORG admins, not with the number of platform admins. A tenant with + * fifty-odd of them filled the window with rows that all fail the filter, the + * short-circuit did not fire, a SECOND unscoped grant was minted, and + * `claimSeedOwnership` re-owned the seeded business records to the newly + * promoted user — silently, because the boot logs a successful promotion + * exactly as it does on a genuinely fresh install. What fails open there is + * #14348 case D: 「Moving an already-granted platform admin is reserved to the + * maintainer.」 + * + * ## Why this is TWO reads and not `organization_id: null` in the `where` + * + * The card's suggested one-line narrowing was MEASURED before it was taken, + * and on its own it would have RELAXED this guard. Null matching itself is + * uniform across the families that can be measured — each answers "the column + * holds no value": + * + * driver-sql (better-sqlite3) via ObjectQL + these real objects -> the unscoped row only + * driver-sqlite-wasm via ObjectQL + these real objects -> the unscoped row only + * driver-memory driver face -> null-valued AND key-absent rows + * driver-mongodb translator (its own live suites -> `{organization_id: null}`, + * need a 123 MB binary download) Mongo's null-or-missing reading + * + * What is NOT uniform is the narrowed read against THIS code's own predicate. + * `organization_id: ''` is storable on both SQL families and reads back as + * `''`: `!organization_id` counts that row UNSCOPED, and `where: { + * organization_id: null }` does NOT return it. A narrowing that REPLACED the + * client-side predicate would therefore stop seeing a legacy unscoped holder + * stored that way, fire less often, and mint the second grant. ⛔ So the + * predicate is untouched and the READ is what changed: + * + * Leg A — ask the driver the narrow question. Independent of how many + * org-scoped grants exist, so no org-admin count can crowd the + * answer out of a window. + * Leg B — only when leg A found nobody: scan the grant population for this + * set, ORDERED so each page is a deterministic slice rather than + * "whatever the driver produced first", bounded, and reporting + * `truncated` at the bound with the number of rows examined. This is + * the leg that still sees a `''`-shaped legacy row. + * + * Both legs are strictly ADDITIVE to what the old read could see, so the + * guard can only fire MORE often than before, never less. + * + * ## Several holders + * + * Leg A's rows arrive ordered by grant-row `id` ({@link ADMIN_GRANT_SCAN_ORDER}) + * and the FIRST unscoped human row wins, so with two platform admins the answer + * is the holder of the grant row whose `id` sorts first — deterministic on every + * driver that honours the order, and the same answer for every caller. + * + * The seed-data owner `usr_system` (provisioned by the SeedLoader, see + * runtime/app-plugin.ts `ensureSeedIdentity`) never counts — otherwise a DB + * where it was wrongly promoted would block every real admin forever. + * Ignoring it here makes the bootstrap self-healing on restart. + */ +async function findPlatformAdminGrantHolder( + ql: any, + adminPsId: string, +): Promise<{ holder: any | undefined; adminGrantRowsExamined: number; truncated: boolean }> { + const isUnscopedHumanHolder = (r: any) => + !r.organization_id && r.user_id !== SystemUserId.SYSTEM; + + // Counted by row IDENTITY, not by read: the two legs overlap by construction + // (leg A's rows are a subset of leg B's population), and a number that + // double-counted them would answer "how many reads did you make" while + // calling itself rows examined. + const examinedGrantRowIds = new Set(); + const countExamined = (rows: any[]) => { + for (const r of rows) { + examinedGrantRowIds.add( + r?.id === undefined || r?.id === null ? `?${examinedGrantRowIds.size}` : String(r.id), + ); + } + }; + let truncated = false; + + // Leg A — the narrow question, asked of the driver. + const unscopedGrantRows = await tryFind( + ql, + 'sys_user_permission_set', + { permission_set_id: adminPsId, organization_id: null }, + PLATFORM_ADMIN_GRANT_PAGE_SIZE, + ADMIN_GRANT_SCAN_ORDER, + ); + countExamined(unscopedGrantRows); + let holder: any | undefined = unscopedGrantRows.find(isUnscopedHumanHolder); + + // Leg B — the ordered, bounded scan that still applies the exact predicate. + if (!holder) { + const pageSize = PLATFORM_ADMIN_GRANT_PAGE_SIZE; + const ceiling = PLATFORM_ADMIN_GRANT_SCAN_CEILING; + for (let offset = 0; offset < ceiling && !holder; offset += pageSize) { + const pageLimit = Math.min(pageSize, ceiling - offset); + const page = await tryFind( + ql, + 'sys_user_permission_set', + { permission_set_id: adminPsId }, + pageLimit, + ADMIN_GRANT_SCAN_ORDER, + offset, + ); + if (page.length === 0) break; + countExamined(page); + holder = page.find(isUnscopedHumanHolder); + if (holder) break; + if (page.length < pageLimit) break; + if (offset + page.length >= ceiling) truncated = true; + } + } + + return { holder, adminGrantRowsExamined: examinedGrantRowIds.size, truncated }; +} + +/** + * The platform admin a seed-ownership claim hands rows to when no bootstrap + * pass of THIS boot has answered yet — the existing holder, by the one rule. + * + * The seed-ownership claim runs whenever a seed settles (`app:seeded`), on every + * boot. An in-budget seed settles inside `AppPlugin.start()`, BEFORE + * `kernel:ready` runs {@link bootstrapPlatformAdmin}, so on a later boot that + * already has an admin the claim cannot wait for the bootstrap to name its + * target: nothing would ever re-run it. This answers the same question the + * bootstrap's `already_have_admin` guard answers, with the same function + * ({@link findPlatformAdminGrantHolder}) — ⛔ never a second selection rule. + * + * `undefined` exactly where the bootstrap names no target either: + * - a walled posture — no grant row anchors platform-admin standing there, and + * the bootstrap never promotes, so no claim ever ran under a wall; + * - no {@link PLATFORM_ADMIN_PERMISSION_SET_NAME} among the sets this plugin + * seeds (`admin_permission_set_missing`), or no stored row for it yet; + * - nobody holds the unscoped grant yet — a first boot, where the promotion + * that follows does its own claim. + * + * Read per call and never cached: the answer is final only once the grant + * table stops moving, and a sign-up can promote someone later in this boot. + */ +export async function findExistingPlatformAdmin( + ql: any, + bootstrapPermissionSets: readonly PermissionSet[], +): Promise { + if (!ql || typeof ql.find !== 'function') return undefined; + if (postureEnforcesWall(resolveTenancyPosture())) return undefined; + if (!bootstrapPermissionSets.some((ps) => ps.name === PLATFORM_ADMIN_PERMISSION_SET_NAME)) { + return undefined; + } + // The read the bootstrap's own seed loop resolves this id with. + const sets = await tryFind(ql, 'sys_permission_set', { name: PLATFORM_ADMIN_PERMISSION_SET_NAME }, 1); + const adminPsId = sets[0]?.id; + if (!adminPsId) return undefined; + const { holder } = await findPlatformAdminGrantHolder(ql, adminPsId); + return holder?.user_id ? String(holder.user_id) : undefined; +} + /** * Persist seed permission sets and answer the posture-keyed platform-admin * question: promote the first registered human user under `single`, report @@ -568,10 +738,11 @@ export async function bootstrapPlatformAdmin( * bundle that overruns `OS_INLINE_SEED_BUDGET_MS` keeps writing rows after the * promotion instant, and the re-run on `app:seeded` must re-own them to the * SAME admin. Before this, the short-circuited pass knew the answer and threw - * it away, so the only way to re-own the missed rows was to re-derive the - * holder — a second implementation of the two-leg scan above, which is how the - * guard and its copy drift apart (commit 1c83ca226 is what that scan costs to get - * right). One owner, read by both passes. + * it away. A seed that settles BEFORE this pass runs (an in-budget seed on a + * later boot) cannot wait for it, so the re-run then asks + * {@link findExistingPlatformAdmin} — the same two-leg scan this pass runs, + * one function, never a copy of it (commit 1c83ca226 is what that scan costs + * to get right). */ adminUserId?: string; /** [#2705] Existing platform-owned rows reconciled to dist under `resync`. */ @@ -707,115 +878,22 @@ export async function bootstrapPlatformAdmin( // fail toward the stricter reading, same direction ADR-0093 D5 fails. const walled = postureEnforcesWall(resolveTenancyPosture()); - const adminPsId = seeded['admin_full_access']; + const adminPsId = seeded[PLATFORM_ADMIN_PERMISSION_SET_NAME]; if (!adminPsId) { return { seeded: seededCount, adminPromoted: false, reason: 'admin_permission_set_missing', ...resyncCounts }; } // ── Does this deployment ALREADY have a platform admin? (commit 1c83ca226) ─ // - // This read was `tryFind(ql, 'sys_user_permission_set', { permission_set_id: - // adminPsId }, 50)` — no `orderBy`, cap 50 — with the predicate that actually - // decides (`!organization_id`) applied CLIENT-SIDE to whatever 50 rows the - // driver produced first. `admin_full_access` is not only the platform-admin - // set: every ORGANIZATION-SCOPED grant of it writes a row carrying the same - // `permission_set_id`, so this population grows with the number of ORG - // admins, not with the number of platform admins. A tenant with fifty-odd of - // them filled the window with rows that all fail the filter, the short-circuit - // did not fire, a SECOND unscoped grant was minted, and `claimSeedOwnership` - // re-owned the seeded business records to the newly promoted user — silently, - // because the boot logs a successful promotion exactly as it does on a - // genuinely fresh install. What fails open there is #14348 case D: - // 「Moving an already-granted platform admin is reserved to the maintainer.」 - // - // ## Why this is TWO reads and not `organization_id: null` in the `where` - // - // The card's suggested one-line narrowing was MEASURED before it was taken, - // and on its own it would have RELAXED this guard. Null matching itself is - // uniform across the families that can be measured — each answers "the column - // holds no value": - // - // driver-sql (better-sqlite3) via ObjectQL + these real objects -> the unscoped row only - // driver-sqlite-wasm via ObjectQL + these real objects -> the unscoped row only - // driver-memory driver face -> null-valued AND key-absent rows - // driver-mongodb translator (its own live suites -> `{organization_id: null}`, - // need a 123 MB binary download) Mongo's null-or-missing reading - // - // What is NOT uniform is the narrowed read against THIS code's own predicate. - // `organization_id: ''` is storable on both SQL families and reads back as - // `''`: `!organization_id` counts that row UNSCOPED, and `where: { - // organization_id: null }` does NOT return it. A narrowing that REPLACED the - // client-side predicate would therefore stop seeing a legacy unscoped holder - // stored that way, fire less often, and mint the second grant this card is - // about. ⛔ This card only tightens, so the predicate is untouched and the - // READ is what changes: - // - // Leg A — ask the driver the narrow question. Independent of how many - // org-scoped grants exist, so no org-admin count can crowd the - // answer out of a window. - // Leg B — only when leg A found nobody: scan the grant population for this - // set, ORDERED so each page is a deterministic slice rather than - // "whatever the driver produced first", bounded, and WARNING at the - // bound with the number of rows examined. This is the leg that - // still sees a `''`-shaped legacy row. - // - // Both legs are strictly ADDITIVE to what the old read could see, so the - // guard can only fire MORE often than before, never less. - // - // The seed-data owner `usr_system` (provisioned by the SeedLoader, see - // runtime/app-plugin.ts `ensureSeedIdentity`) never counts — otherwise a DB - // where it was wrongly promoted would block every real admin forever. - // Ignoring it here makes the bootstrap self-healing on restart. - const isUnscopedHumanHolder = (r: any) => - !r.organization_id && r.user_id !== SystemUserId.SYSTEM; - - // Counted by row IDENTITY, not by read: the two legs overlap by construction - // (leg A's rows are a subset of leg B's population), and a number that - // double-counted them would answer "how many reads did you make" while - // calling itself rows examined. - const examinedGrantRowIds = new Set(); - const countExamined = (rows: any[]) => { - for (const r of rows) { - examinedGrantRowIds.add( - r?.id === undefined || r?.id === null ? `?${examinedGrantRowIds.size}` : String(r.id), - ); - } - }; - let adminGrantScanTruncated = false; - - // Leg A — the narrow question, asked of the driver. - const unscopedGrantRows = await tryFind( - ql, - 'sys_user_permission_set', - { permission_set_id: adminPsId, organization_id: null }, - PLATFORM_ADMIN_GRANT_PAGE_SIZE, - ADMIN_GRANT_SCAN_ORDER, - ); - countExamined(unscopedGrantRows); - let unscopedHolder: any | undefined = unscopedGrantRows.find(isUnscopedHumanHolder); - - // Leg B — the ordered, bounded scan that still applies the exact predicate. - if (!unscopedHolder) { - const pageSize = PLATFORM_ADMIN_GRANT_PAGE_SIZE; - const ceiling = PLATFORM_ADMIN_GRANT_SCAN_CEILING; - for (let offset = 0; offset < ceiling && !unscopedHolder; offset += pageSize) { - const pageLimit = Math.min(pageSize, ceiling - offset); - const page = await tryFind( - ql, - 'sys_user_permission_set', - { permission_set_id: adminPsId }, - pageLimit, - ADMIN_GRANT_SCAN_ORDER, - offset, - ); - if (page.length === 0) break; - countExamined(page); - unscopedHolder = page.find(isUnscopedHumanHolder); - if (unscopedHolder) break; - if (page.length < pageLimit) break; - if (offset + page.length >= ceiling) adminGrantScanTruncated = true; - } - } + // The ONE rule — {@link findPlatformAdminGrantHolder} — that the seed-settle + // claim also asks when no pass of this boot has answered yet + // ({@link findExistingPlatformAdmin}). Why it is two legs and why each is + // bounded and ordered is on the function. + const { + holder: unscopedHolder, + adminGrantRowsExamined, + truncated: adminGrantScanTruncated, + } = await findPlatformAdminGrantHolder(ql, adminPsId); // ⛔ The truncation is never silent (commit 1c83ca226). Reaching the ceiling is the one // way this scan still answers "no platform admin yet" while one exists, and @@ -823,7 +901,6 @@ export async function bootstrapPlatformAdmin( // grant and hands it the seeded business records. So it says the number it // examined rather than letting the promotion below read as a statement about // the whole table. - const adminGrantRowsExamined = examinedGrantRowIds.size; if (adminGrantScanTruncated && !unscopedHolder) { const truncation = '[security] the existing-platform-admin check stopped at its ceiling of ' @@ -851,9 +928,10 @@ export async function bootstrapPlatformAdmin( seeded: seededCount, adminPromoted: false, reason: 'already_have_admin', - // The promotion is a no-op forever; the CLAIM is not. This pass is the - // only thing on a later boot that knows who the seeded rows belong to, - // and the seed-settle re-run needs that name (see `adminUserId` above). + // The promotion is a no-op forever; the CLAIM is not. The seed-settle + // re-run hands rows to this name — or, when it fires before this pass + // has run at all, asks {@link findExistingPlatformAdmin}, which answers + // with this same holder (see `adminUserId` above). ...(unscopedHolder.user_id ? { adminUserId: String(unscopedHolder.user_id) } : {}), ...resyncCounts, ...grantScanCounts, diff --git a/packages/plugins/plugin-security/src/claim-seed-ownership.ts b/packages/plugins/plugin-security/src/claim-seed-ownership.ts index 1c9fa5b8aa2..78f3850d4af 100644 --- a/packages/plugins/plugin-security/src/claim-seed-ownership.ts +++ b/packages/plugins/plugin-security/src/claim-seed-ownership.ts @@ -12,8 +12,10 @@ * notifications — is empty out of the box. * * This helper runs right after `bootstrapPlatformAdmin` promotes the first human - * user to platform admin, and transfers ownership of those orphan rows to that - * admin. It is the ownership twin of org-scoping's `claimOrphanOrgRows` (which + * user to platform admin, and again whenever a seed settles (`app:seeded`) on + * every boot — the first one and every later one — and transfers ownership of + * those orphan rows to the platform admin. It is the ownership twin of + * org-scoping's `claimOrphanOrgRows` (which * back-fills `organization_id`): walk every user-authored object that declares * the canonical `owner_id` column, and re-own the rows that no human owns yet. * @@ -30,10 +32,13 @@ * answering 403 on every write at `modifyAllRecords: false`. * * A claim on admin promotion that races a seeder the platform itself deferred - * cannot be correct as a single pass. `security-plugin.ts` therefore re-runs - * this helper on `app:seeded` — the published settle signal for exactly that - * background continuation — and every pass reports whether its own reading was - * final ({@link reportClaimPass}). + * cannot be correct as a single pass. `security-plugin.ts` therefore runs this + * helper on every `app:seeded` — the published settle signal for exactly that + * background continuation, and the only signal a LATER boot's seed replay gives + * (an in-budget replay settles before `kernel:ready`, and a bootstrap that finds + * an admin already in place promotes nobody, so it never reaches this helper) — + * and every pass reports whether its own reading was final + * ({@link reportClaimPass}). * * Mistake-proof by construction: authors write plain seed records (no * `owner_id`), and the platform — not the author — performs the handoff. There @@ -317,7 +322,8 @@ async function claimPredicate( } logger?.warn?.( `[security] claimSeedOwnership stopped after ${MAX_CLAIM_PAGES} fallback page(s) on ${objectName}; ` + - 'unowned rows may remain and the next run will claim them', + 'unowned rows may remain until the claim next runs — the next seed settle (`app:seeded`, on this ' + + 'boot or a later one) or the next platform-admin promotion', { object: objectName, where, pages: MAX_CLAIM_PAGES }, ); return total; @@ -356,7 +362,7 @@ async function claimPredicate( * pass is a reading and not a verdict. Rows that land after it are NOT * covered by it. `warn`, because at the moment the line is printed those rows * are unowned and nothing else about the boot looks wrong; the re-run on - * `app:seeded` is a promise, not yet a fact. + * `app:seeded` is still ahead at that moment. * - **final** (`inFlight === 0`) — every source this boot writes has settled, * so "nothing matched" really does mean "nothing to claim". `info`. * - **unattested** (no snapshot) — no seed pipeline registered on this kernel, @@ -370,9 +376,11 @@ async function claimPredicate( * provisional forever — a permanent warning about behaviour that is correct by * design, which is how a log level gets trained away. * - * The `handed N seeded record(s) to first admin X` prefix is unchanged and now - * fires on every pass including `N = 0`: existing consumers match on it, and the - * finality clause is appended rather than replacing it. + * The `handed N seeded record(s)` prefix is unchanged and fires on every pass + * including `N = 0`: existing consumers match on it, and the finality clause is + * appended rather than replacing it. The recipient reads `platform admin X`, not + * `first admin X`: on a later boot it is the admin who already holds the grant, + * not anyone this boot promoted. */ function reportClaimPass( logger: ClaimOwnershipOptions['logger'], @@ -383,7 +391,7 @@ function reportClaimPass( ): void { const total = results.reduce((s, r) => s + r.count, 0); const head = - `[security] handed ${total} seeded record(s) to first admin ${adminUserId} ` + + `[security] handed ${total} seeded record(s) to platform admin ${adminUserId} ` + `(${results.length} of ${eligibleObjects} eligible object(s) had unowned rows)`; const meta = { adminUserId, @@ -397,8 +405,9 @@ function reportClaimPass( if (seedSettlement && seedSettlement.inFlight > 0) { logger?.warn?.( `${head} — PROVISIONAL: ${seedSettlement.inFlight} seed source(s) were still writing when this ` + - 'pass ran, so rows seeded after it are NOT covered by it and stay unowned until the claim ' + - 're-runs on `app:seeded`. A count of 0 here is "nothing had landed yet", never "nothing to claim".', + 'pass ran, so rows seeded after it are NOT covered by it. The claim runs again as each of those ' + + 'sources settles (`app:seeded`) and hands those rows to the same platform admin. A count of 0 ' + + 'here is "nothing had landed yet", never "nothing to claim".', meta, ); return; @@ -490,12 +499,16 @@ export async function claimSeedOwnership( } catch (e) { // Best-effort per predicate, exactly as the per-id loop was: one // predicate that cannot land must not cost the object its other one, - // nor any later object. The rows stay unowned and the next run — boot, - // the bootstrap replay, or `meta resync` — claims them, because the - // predicate is still true of them. + // nor any later object. The rows stay unowned and the next run claims + // them, because the predicate is still true of them. That run is the + // next `app:seeded` (this boot or a later one) or the next promotion — + // ⚠️ NOT `os meta resync` and NOT a bootstrap replay: on an install that + // already has an admin both short-circuit on `already_have_admin` and + // never reach this function. logger?.warn?.( - `[security] claimSeedOwnership failed for ${schema.name}; those rows stay unowned ` + - 'and the next run will claim them', + `[security] claimSeedOwnership failed for ${schema.name}; those rows stay unowned until the ` + + 'claim next runs — the next seed settle (`app:seeded`, on this boot or a later one) or the ' + + 'next platform-admin promotion', { object: schema.name, where, error: (e as Error).message }, ); } diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index a8296eb4092..843b795a760 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -139,7 +139,7 @@ import { PermissionSetReadUnansweredError, } from './errors.js'; import { assertEngineOwnedWriteAllowed } from './system-write-guard.js'; -import { bootstrapPlatformAdmin, shouldReplayBootstrapFor } from './bootstrap-platform-admin.js'; +import { bootstrapPlatformAdmin, findExistingPlatformAdmin, shouldReplayBootstrapFor } from './bootstrap-platform-admin.js'; import { claimSeedOwnership } from './claim-seed-ownership.js'; import { createPlatformAdminService } from './platform-admin-service.js'; import { @@ -935,6 +935,27 @@ function writeCheckPolicies( ); } +/** + * "Has this boot's own seed data finished landing?", asked through the + * published `seed-settlement` contract rather than by sniffing the runtime's + * internal `seed-datasets` service — that array's presence says a seed source + * EXISTS, never whether it has SETTLED, and the gap between those two facts is + * the whole defect. `undefined` means no seed pipeline registered on this + * kernel, which by `kernel:ready` is a fact and not a not-yet (every source is + * declared in Phase 2 `start()`). Read per use, never cached. + */ +function readSeedSettlementSnapshot(ctx: PluginContext): SeedSettlementSnapshot | undefined { + try { + const svc = (ctx as any).getService?.(SEED_SETTLEMENT_SERVICE) as + | ISeedSettlementService + | undefined; + if (!svc || typeof svc.snapshot !== 'function') return undefined; + return svc.snapshot(); + } catch { + return undefined; + } +} + export class SecurityPlugin implements Plugin { name = 'com.objectstack.security'; /** @@ -1068,6 +1089,20 @@ export class SecurityPlugin implements Plugin { */ private metadata: any = null; private ql: any = null; + /** + * Who the seed-ownership claim hands rows to, as THIS boot's bootstrap + * answered it — the admin the last pass promoted, or the one it found + * already holding the unscoped grant. `undefined` until a bootstrap pass + * names one, and only ever overwritten with a real answer. + * + * Kept because the claim is not a single pass (see + * {@link claimSeedOwnershipOnSettle}). When a seed settles before any pass + * has named a target — an in-budget seed settles before `kernel:ready` — + * the claim asks `findExistingPlatformAdmin`, the same rule the bootstrap's + * `already_have_admin` guard runs, rather than growing a second copy of that + * scan here. + */ + private claimTargetAdminUserId: string | undefined = undefined; /** [ADR-0090 D12] Delegated-admin write gate — wired in start() once `ql` exists. */ private delegatedAdminGate: DelegatedAdminGate | null = null; /** @@ -1309,11 +1344,104 @@ export class SecurityPlugin implements Plugin { }); } + // The seed-ownership claim runs whenever a seed settles, on every boot — + // see {@link claimSeedOwnershipOnSettle}. Subscribed in `init()`, before ANY + // plugin's `start()`: an in-budget seed fires `app:seeded` from inside + // `AppPlugin.start()`, and the kernel orders starts by registration among + // plugins with no edge between them (ADR-0116), so a subscription made in + // this plugin's own `start()` misses that signal on every composition that + // registers the app first. Nothing here resolves a service: the handler + // reads the engine when it runs. + if (typeof (ctx as any).hook === 'function') { + (ctx as any).hook('app:seeded', (payload?: { appId?: string; overBudget?: boolean }) => + this.claimSeedOwnershipOnSettle(ctx, payload), + ); + } + ctx.logger.info('Security Plugin initialized', { defaultPermissionSets: this.bootstrapPermissionSets.map((p) => p.name), }); } + /** + * The seed-ownership CLAIM, run on `app:seeded` — whenever a seed settles, on + * every boot, the first one and every later one. + * + * ## Why a settle signal, and not the promotion alone + * + * The claim used to run exactly once per database lifetime, inside the one + * pass that promotes the first admin — and that instant is not the moment the + * seed is done. `AppPlugin` races its inline seed against + * `OS_INLINE_SEED_BUDGET_MS` (default 8 s) and continues an over-budget bundle + * in the BACKGROUND rather than block kernel start, so for any non-trivial app + * the seeder is still writing while the claim walks the registry. Registry + * order and seed order are unrelated: every object whose rows land after its + * walk stayed `owner_id IS NULL` forever. Measured on a CRM bundle: 73 rows + * across six objects, the same loser set on two independent boots. + * + * ⛔ The fix is NOT to widen `shouldReplayBootstrapFor`. A replayed bootstrap + * short-circuits on `already_have_admin` and RETURNS before it ever reaches + * the claim, so a wider trigger re-runs a pass that cannot do the thing that + * was missed. What runs here is the claim itself. + * + * ## Why the target is resolved HERE when the bootstrap has not named it + * + * A later boot replays its seed into a database whose admin already exists, + * and an in-budget replay settles inside `AppPlugin.start()` — BEFORE + * `kernel:ready` runs the bootstrap that would name the target. Reading only + * {@link claimTargetAdminUserId} there returned with nobody to claim to, and + * the bootstrap that followed found the admin, promoted nobody and never + * reached the claim: every row that replay inserted stayed ownerless for good. + * So when no pass of this boot has named a target, this asks + * `findExistingPlatformAdmin` — the bootstrap's own `already_have_admin` + * rule, the same function, ⛔ never a second selection rule and never a + * second claim path. On a first boot nobody holds the grant yet, the answer is + * `undefined`, and the promotion that follows does its own claim. + * + * `app:seeded` fires once per app bundle, so the first fire is not + * necessarily the last; the runtime settles the source BEFORE it triggers, so + * this handler sees its own signal already reflected in the tally. The claim + * is idempotent (only NULL / `usr_system`-owned rows match) and every pass + * reports whether its own reading was final, so running on each fire costs a + * no-op walk and buys the guarantee. + * + * ⚠️ Scope: the predicates and the object filter are the promotion-time + * pass's own, unchanged. A row a human already owns is not matched by either + * predicate and cannot be touched here. + */ + private async claimSeedOwnershipOnSettle( + ctx: PluginContext, + payload?: { appId?: string; overBudget?: boolean }, + ): Promise { + let ql: any; + try { + ql = ctx.getService('objectql'); + } catch { + return; + } + if (!ql) return; + try { + const adminUserId = + this.claimTargetAdminUserId ?? + (await findExistingPlatformAdmin(ql, this.bootstrapPermissionSets)); + if (!adminUserId) return; + await claimSeedOwnership(ql, adminUserId, { + logger: ctx.logger, + seedSettlement: readSeedSettlementSnapshot(ctx), + }); + } catch (e) { + // Best-effort, exactly like the promotion-time call: a failed claim + // leaves the rows unowned and the next run claims them, because the + // predicate is still true of them. It must not break the boot — `trigger` + // dispatch PROPAGATES, and a seed that landed must not fail on this. + ctx.logger.warn('[security] seed-settle ownership claim failed', { + appId: payload?.appId, + overBudget: payload?.overBudget, + error: (e as Error).message, + }); + } + } + async start(ctx: PluginContext): Promise { ctx.logger.info('Starting Security Plugin...'); @@ -3993,37 +4121,7 @@ export class SecurityPlugin implements Plugin { // insert seed rows. Falls back to immediate execution when the // kernel does not expose `hook` (test stubs). let bootstrapRanOnce = false; - /** - * Who the seed-ownership claim hands rows to — the admin the last bootstrap - * pass promoted, or the one it found already holding the unscoped grant. - * - * Kept because the claim is not a single pass (see the `app:seeded` hook - * below). `bootstrapPlatformAdmin` is the ONE place that answers "who is the - * platform admin" from the grant rows — through a two-leg, ordered, bounded - * scan that took its own card to get right — so the re-run reads its answer - * rather than growing a second copy of that scan here. - */ - let claimTargetAdminUserId: string | undefined; - /** - * "Has this boot's own seed data finished landing?", asked through the - * published `seed-settlement` contract rather than by sniffing the runtime's - * internal `seed-datasets` service — that array's presence says a seed - * source EXISTS, never whether it has SETTLED, and the gap between those two - * facts is the whole defect. `undefined` means no seed pipeline registered - * on this kernel, which by `kernel:ready` is a fact and not a not-yet (every - * source is declared in Phase 2 `start()`). - */ - const readSeedSettlement = (): SeedSettlementSnapshot | undefined => { - try { - const svc = (ctx as any).getService?.(SEED_SETTLEMENT_SERVICE) as - | ISeedSettlementService - | undefined; - if (!svc || typeof svc.snapshot !== 'function') return undefined; - return svc.snapshot(); - } catch { - return undefined; - } - }; + const readSeedSettlement = (): SeedSettlementSnapshot | undefined => readSeedSettlementSnapshot(ctx); // [ADR-0094] Guard so the env-projection wiring runs exactly once even // though runBootstrap re-runs (e.g. after the first user insert) — // registerMutationProjector replaces idempotently, but the legacy @@ -4193,11 +4291,11 @@ export class SecurityPlugin implements Plugin { // difference that decides whether that pass's claim is the last word. seedSettlement: readSeedSettlement(), }); - // Remember the claim's target for the `app:seeded` re-run below. Only - // ever overwritten with a real answer: a later pass that returns none - // (walled posture, an unreadable engine) must not erase the admin an - // earlier pass resolved and leave the re-run with nobody to claim to. - if (report?.adminUserId) claimTargetAdminUserId = report.adminUserId; + // Remember the claim's target for the `app:seeded` re-run + // ({@link claimSeedOwnershipOnSettle}). Only ever overwritten with a + // real answer: a later pass that returns none (walled posture, an + // unreadable engine) must not erase the admin an earlier pass resolved. + if (report?.adminUserId) this.claimTargetAdminUserId = report.adminUserId; // Which organizations this boot seeds. Resolved ONCE per bootstrap run // and reused by all four catalog steps, so a sweep costs one // organization enumeration rather than four. @@ -4484,59 +4582,10 @@ export class SecurityPlugin implements Plugin { // ── Re-run the seed-ownership CLAIM when the seed actually settles ──────── // - // The claim used to run exactly once per database lifetime, inside the one - // pass that promotes the first admin — and that instant is not the moment - // the seed is done. `AppPlugin` races its inline seed against - // `OS_INLINE_SEED_BUDGET_MS` (default 8 s) and continues an over-budget - // bundle in the BACKGROUND rather than block kernel start, so for any - // non-trivial app the seeder is still writing while the claim walks the - // registry. Registry order and seed order are unrelated: every object whose - // rows land after its walk stayed `owner_id IS NULL` forever, because - // nothing re-ran the claim. Measured on a CRM bundle: 73 rows across six - // objects, the same loser set on two independent boots. - // - // ⛔ The fix is NOT to widen `shouldReplayBootstrapFor`. A replayed - // bootstrap short-circuits on `already_have_admin` and RETURNS before it - // ever reaches the claim, so a wider trigger re-runs a pass that cannot do - // the thing that was missed. What re-runs here is the claim itself. - // - // `app:seeded` is the published settle signal for exactly that background - // continuation — the runtime settles the source BEFORE it triggers, so a - // consumer inside this hook sees its own signal already reflected in the - // tally. It fires once per app bundle, so the first fire is not necessarily - // the last; the claim is idempotent (only NULL / `usr_system`-owned rows - // match) and every pass reports whether its own reading was final, so - // running on each fire costs a no-op walk and buys the guarantee. - // - // ⚠️ Scope: this moves ownership for exactly the rows the promotion-time - // pass missed — the predicates, the target admin and the object filter are - // the one-shot pass's own, unchanged. A row a human already owns is not - // matched by either predicate and cannot be touched here. - // - // No admin yet ⇒ nothing to do: an in-budget seed settles before any user - // exists, and the promotion that follows does its own claim against a seed - // that has already settled. - if (typeof (ctx as any).hook === 'function') { - (ctx as any).hook('app:seeded', async (payload?: { appId?: string; overBudget?: boolean }) => { - const adminUserId = claimTargetAdminUserId; - if (!adminUserId) return; - try { - await claimSeedOwnership(ql, adminUserId, { - logger: ctx.logger, - seedSettlement: readSeedSettlement(), - }); - } catch (e) { - // Best-effort, exactly like the promotion-time call: a failed claim - // leaves the rows unowned and the next run claims them, because the - // predicate is still true of them. It must not break the boot. - ctx.logger.warn('[security] seed-settle ownership claim failed', { - appId: payload?.appId, - overBudget: payload?.overBudget, - error: (e as Error).message, - }); - } - }); - } + // Subscribed in `init()`, not here — see {@link claimSeedOwnershipOnSettle}. + // `runBootstrap` above only NAMES the claim's target for it + // (`this.claimTargetAdminUserId`); a seed that settles before this boot's + // bootstrap has run resolves the target itself, by the same rule. // Re-run bootstrap after a sys_user write that can change the promotion // answer, so the platform admin is promoted without a server restart: From c7860bce49a7da18d3a10c409fa034663e91ca53 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 23:41:31 +0000 Subject: [PATCH 2/4] test(plugin-security): pin the seed-ownership claim on a warm boot, in every settle order Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude --- .changeset/21486-warm-boot-seed-claim.md | 12 + .../claim-seed-ownership-warm-boot.test.ts | 328 ++++++++++++++++++ 2 files changed, 340 insertions(+) create mode 100644 .changeset/21486-warm-boot-seed-claim.md create mode 100644 packages/plugins/plugin-security/src/claim-seed-ownership-warm-boot.test.ts diff --git a/.changeset/21486-warm-boot-seed-claim.md b/.changeset/21486-warm-boot-seed-claim.md new file mode 100644 index 00000000000..1e9f6d0c0a7 --- /dev/null +++ b/.changeset/21486-warm-boot-seed-claim.md @@ -0,0 +1,12 @@ +--- +"@objectstack/plugin-security": patch +--- + +The seed-ownership claim now runs whenever a seed settles, on every boot, not only on the boot that promotes the first platform admin. + +Clause-②: no + +- **Before:** a later boot whose seed replay inserted rows into a database that already had a platform admin left those rows `owner_id` NULL for good. An in-budget seed settles before `kernel:ready`, and the bootstrap that runs there finds the existing admin (`already_have_admin`) and promotes nobody, so neither path reached the claim. A `readScope: 'own'` grant never saw those rows. +- **Now:** when a seed settles (`app:seeded`) before this boot's bootstrap has named a claim target, the handler resolves the target itself: the existing platform admin, by the bootstrap's own `already_have_admin` rule. The claim then hands the replayed rows to that admin. The handler subscribes in `init()`, so a seed that settles before this plugin's `start()` is heard too. That happens on any composition that registers the app first. +- Unchanged: the claim's predicates (`owner_id` NULL or `usr_system`), its object filter and the first-boot promotion path. A row someone else owns is never touched. Under a walled tenancy posture no claim runs, as before. +- Log lines: the claim report reads `handed N seeded record(s) to platform admin USER_ID`, where it used to say `first admin`. Its provisional and failure lines now say when the claim actually runs next: the next seed settle, on this boot or a later one, or the next platform-admin promotion. `os meta resync` is not such a run. diff --git a/packages/plugins/plugin-security/src/claim-seed-ownership-warm-boot.test.ts b/packages/plugins/plugin-security/src/claim-seed-ownership-warm-boot.test.ts new file mode 100644 index 00000000000..80f39337a3c --- /dev/null +++ b/packages/plugins/plugin-security/src/claim-seed-ownership-warm-boot.test.ts @@ -0,0 +1,328 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The seed-ownership claim runs whenever a seed settles, on EVERY boot — the + * first one and every later one. + * + * ## The defect these pins are written against + * + * The claim reached rows by two doors: `promote()` (inside the bootstrap that + * runs at `kernel:ready`) and the `app:seeded` handler. On a later boot both + * stayed shut for an in-budget seed replay: + * + * - the bootstrap found the existing admin (`already_have_admin`), promoted + * nobody and never reached the claim; + * - the `app:seeded` handler read the target only from that bootstrap, and an + * in-budget seed settles inside `AppPlugin.start()` — BEFORE `kernel:ready` + * — so it returned with nobody to claim to; + * - and on a composition that registers the app before this plugin, the + * handler was not even subscribed yet: it subscribed in `start()`. + * + * So every row a later boot's seed replay inserted stayed `owner_id IS NULL` + * for good — invisible to every `readScope: 'own'` grant. Measured on this + * branch's base with the rig below (one SQLite file, two boots, a replay that + * re-inserts one deleted seed row, one planted null): 2 ownerless rows after + * `app:seeded` in every in-budget order, 0 only when the seed settled after + * `kernel:ready`. + * + * ## Why a real engine over a real file, booted twice + * + * "Warm boot" is a property of a DATABASE that outlives the process: the grant + * row the first boot minted is what the second boot's claim has to find. A + * hand-built double would answer that read by construction. Each boot here is + * a fresh `ObjectQL` + `SqlDriver` (better-sqlite3) over the same file and a + * fresh `SecurityPlugin`, and the kernel's phases are driven in the order the + * kernel runs them: every `init()`, then each `start()` in registration order, + * then `kernel:ready`. Where `app:seeded` lands among those is the variable + * each case sets. + * + * The first-boot path (`claim-seed-ownership-seed-settle-rerun.test.ts`) is + * the control and stays as it is; the first boot below re-measures it on the + * real engine before every warm boot. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { SysUser, SysAccount, SysOrganization, SysMember } from '@objectstack/platform-objects/identity'; +import { SEED_SETTLEMENT_SERVICE } from '@objectstack/spec/contracts'; +import { SecurityPlugin } from './security-plugin.js'; +import { securityObjects } from './manifest.js'; +import { findExistingPlatformAdmin } from './bootstrap-platform-admin.js'; +import { defaultPermissionSets } from './objects/default-permission-sets.js'; + +const SYS = { context: { isSystem: true } } as any; +const ADMIN = 'usr_admin_human'; +const OTHER = 'usr_someone_else'; +const APP = { appId: 'com.example.crm', overBudget: false }; + +/** A business object the claim walks: it declares the canonical `owner_id`. */ +const CASE: any = { + name: 'crm_case', + label: 'Case', + fields: { + id: { type: 'text', label: 'Id', primary: true }, + name: { type: 'text', label: 'Name' }, + owner_id: { type: 'text', label: 'Owner' }, + }, +}; + +const cleanups: Array<() => Promise | void> = []; +afterEach(async () => { + while (cleanups.length) { + try { + await cleanups.pop()!(); + } catch { + /* noop */ + } + } +}); + +/** A fresh database file that outlives every boot of one case. */ +function databaseFile(): string { + const dir = mkdtempSync(join(tmpdir(), 'os-claim-warm-boot-')); + cleanups.unshift(() => rmSync(dir, { recursive: true, force: true })); + return join(dir, 'boot.sqlite'); +} + +/** One process's engine over `file`, with the REAL shipped declarations. */ +async function openEngine(file: string): Promise { + const engine = new ObjectQL(); + engine.registerDriver( + new SqlDriver({ client: 'better-sqlite3', connection: { filename: file }, useNullAsDefault: true }), + true, + ); + await engine.init(); + engine.registerApp({ + id: 'com.example.claim-warm-boot', + name: 'Claim warm boot', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects: [...securityObjects, SysUser, SysAccount, SysOrganization, SysMember, CASE], + } as any); + await engine.syncSchemas(); + cleanups.push(() => engine.destroy()); + return engine; +} + +/** + * One process's `SecurityPlugin` over `engine`, with the kernel's phases as + * separate steps. `fire` runs the handlers subscribed SO FAR — a hook + * subscribed after an event was triggered never hears it, as on the kernel. + */ +function securityProcess(engine: any) { + let inFlight = 1; + const hooks: Array<[string, (...a: any[]) => any]> = []; + const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; + const services: Record = { + manifest: { register: () => {} }, + objectql: engine, + metadata: { get: async () => null, list: async () => [] }, + [SEED_SETTLEMENT_SERVICE]: { + snapshot: () => ({ pending: inFlight, inFlight, suppressed: [] }), + }, + }; + const ctx: any = { + logger, + registerService: () => {}, + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + hook: (name: string, cb: any) => hooks.push([name, cb]), + }; + const plugin = new SecurityPlugin(); + const fire = async (event: string, payload?: unknown) => { + const subscribed = hooks.filter(([n]) => n === event); + for (const [, cb] of subscribed) await cb(payload); + return subscribed.length; + }; + return { + logger, + fire, + init: () => plugin.init(ctx), + start: () => plugin.start(ctx), + /** The runtime settles the source BEFORE it triggers `app:seeded`. */ + seedSettles: () => { + inFlight = 0; + return fire('app:seeded', APP); + }, + /** Every claim pass's report, as `{ claimed, adminUserId }`. */ + claimReports: () => + [...logger.info.mock.calls, ...logger.warn.mock.calls] + .filter(([message]) => String(message).includes('seeded record(s)')) + .map(([, meta]) => ({ claimed: meta?.claimed, adminUserId: meta?.adminUserId })), + /** The meta of each `platform bootstrap complete` line. */ + bootstrapReports: () => + logger.info.mock.calls + .filter(([message]) => String(message).includes('platform bootstrap complete')) + .map(([, meta]) => meta), + }; +} + +async function owners(engine: any): Promise> { + const rows = await engine.find('crm_case', { where: {} }, SYS); + const out: Record = {}; + for (const r of rows) out[r.id] = r.owner_id ?? null; + return out; +} + +const seedCase = (engine: any, id: string) => engine.insert('crm_case', { id, name: id }, SYS); + +/** + * The FIRST boot, in the order `objectstack dev` composes it (this plugin + * registered before the app): the in-budget seed settles before anyone exists, + * then a human signs up and is promoted. Then, between processes, the operator + * deletes one seeded row (the next seed replay re-inserts it), a null is + * planted on another, and a row owned by somebody else is added. + * + * The first half is the #17628 control on a real engine: the promotion's own + * claim hands every seeded row to the admin. + */ +async function firstBoot(file: string) { + const engine = await openEngine(file); + const proc = securityProcess(engine); + await proc.init(); + await proc.start(); + for (const id of ['c1', 'c2', 'c3']) await seedCase(engine, id); + await proc.seedSettles(); + // No admin yet: the settle claims nothing and says nothing. + expect(proc.claimReports()).toEqual([]); + await proc.fire('kernel:ready'); + // The sign-up: the user row, then the login. The bootstrap replay promotes on + // the login and its claim hands the seeded rows over. + await engine.insert('sys_user', { id: ADMIN, email: 'admin@example.test', name: 'admin' }, SYS); + await engine.insert( + 'sys_account', + { id: 'acc_admin', user_id: ADMIN, account_id: 'admin@example.test', provider_id: 'credential' }, + SYS, + ); + expect(await owners(engine)).toEqual({ c1: ADMIN, c2: ADMIN, c3: ADMIN }); + expect(proc.bootstrapReports().at(-1)).toMatchObject({ adminPromoted: true, ownershipClaimed: 3 }); + + await engine.delete('crm_case', { where: { id: 'c2' }, context: { isSystem: true } } as any); + await engine.update('crm_case', { owner_id: null }, { where: { id: 'c3' }, multi: true, context: { isSystem: true } }); + await engine.insert('crm_case', { id: 'c4', name: 'c4', owner_id: OTHER }, SYS); + await engine.destroy(); +} + +/** What every warm boot must leave once its seed has settled. */ +const SETTLED = { c1: ADMIN, c2: ADMIN, c3: ADMIN, c4: OTHER }; + +describe('seed-ownership claim on a WARM boot — an admin exists, the seed replays in budget', () => { + it('claims the replayed rows when the seed settles BEFORE kernel:ready (plugin registered before the app)', async () => { + const file = databaseFile(); + await firstBoot(file); + + const engine = await openEngine(file); + const proc = securityProcess(engine); + await proc.init(); + await proc.start(); + // AppPlugin.start(): the replay re-inserts the deleted row, then settles. + await seedCase(engine, 'c2'); + expect((await owners(engine)).c2).toBeNull(); + await proc.seedSettles(); + + // 0 ownerless after `app:seeded` — kernel:ready has not run. + expect(await owners(engine)).toEqual(SETTLED); + expect(proc.claimReports()).toEqual([{ claimed: 2, adminUserId: ADMIN }]); + + // The bootstrap that follows promotes nobody and claims nothing more. + await proc.fire('kernel:ready'); + expect(proc.bootstrapReports().at(-1)).toMatchObject({ reason: 'already_have_admin', adminUserId: ADMIN }); + expect(await owners(engine)).toEqual(SETTLED); + expect(proc.claimReports()).toHaveLength(1); + }, 120_000); + + it('claims them when the seed settles before this plugin has even STARTED (app registered first)', async () => { + const file = databaseFile(); + await firstBoot(file); + + const engine = await openEngine(file); + const proc = securityProcess(engine); + await proc.init(); + // AppPlugin.start() runs first: replay, settle — this plugin's start() has + // not run yet. + await seedCase(engine, 'c2'); + const heard = await proc.seedSettles(); + expect(heard).toBe(1); + expect(await owners(engine)).toEqual(SETTLED); + + await proc.start(); + await proc.fire('kernel:ready'); + expect(await owners(engine)).toEqual(SETTLED); + expect(proc.claimReports()).toEqual([{ claimed: 2, adminUserId: ADMIN }]); + }, 120_000); + + it('claims them when the seed settles AFTER kernel:ready (over budget) — the bootstrap-named target', async () => { + const file = databaseFile(); + await firstBoot(file); + + const engine = await openEngine(file); + const proc = securityProcess(engine); + await proc.init(); + await proc.start(); + await proc.fire('kernel:ready'); + await seedCase(engine, 'c2'); + await proc.seedSettles(); + + expect(await owners(engine)).toEqual(SETTLED); + expect(proc.claimReports()).toEqual([{ claimed: 2, adminUserId: ADMIN }]); + }, 120_000); +}); + +describe('the claim target is the existing platform admin, by the bootstrap rule', () => { + /** + * A database with TWO unscoped human holders of `admin_full_access`, built + * so that each candidate rule would answer differently: `usr_zed` holds the + * grant row whose id sorts FIRST, while `usr_amy` is both the OLDER user and + * the one whose user id sorts first. + */ + async function twoAdmins(file: string) { + const engine = await openEngine(file); + await engine.insert('sys_permission_set', { id: 'ps_admin', name: 'admin_full_access', label: 'Admin', active: true }, SYS); + for (const [id, createdAt] of [ + ['usr_amy', '2026-01-01T00:00:00.000Z'], + ['usr_zed', '2026-06-01T00:00:00.000Z'], + ] as const) { + await engine.insert('sys_user', { id, email: `${id}@example.test`, name: id, created_at: createdAt }, SYS); + await engine.insert('sys_account', { id: `acc_${id}`, user_id: id, account_id: id, provider_id: 'credential' }, SYS); + } + await engine.insert('sys_user_permission_set', { id: 'ups_1', user_id: 'usr_zed', permission_set_id: 'ps_admin', organization_id: null }, SYS); + await engine.insert('sys_user_permission_set', { id: 'ups_2', user_id: 'usr_amy', permission_set_id: 'ps_admin', organization_id: null }, SYS); + return engine; + } + + it('with several admins, the claim and the bootstrap name the SAME one: the holder of the first grant row by id', async () => { + const file = databaseFile(); + const engine = await twoAdmins(file); + const proc = securityProcess(engine); + await proc.init(); + await proc.start(); + await seedCase(engine, 'c1'); + await proc.seedSettles(); + + expect(await owners(engine)).toEqual({ c1: 'usr_zed' }); + + await proc.fire('kernel:ready'); + expect(proc.bootstrapReports().at(-1)).toMatchObject({ reason: 'already_have_admin', adminUserId: 'usr_zed' }); + }, 120_000); + + it('a walled posture names nobody — the claim never ran under a wall, and still does not', async () => { + const file = databaseFile(); + const engine = await twoAdmins(file); + const previous = process.env.OS_TENANCY_POSTURE; + cleanups.push(() => { + if (previous === undefined) delete process.env.OS_TENANCY_POSTURE; + else process.env.OS_TENANCY_POSTURE = previous; + }); + + expect(await findExistingPlatformAdmin(engine, defaultPermissionSets)).toBe('usr_zed'); + process.env.OS_TENANCY_POSTURE = 'isolated'; + expect(await findExistingPlatformAdmin(engine, defaultPermissionSets)).toBeUndefined(); + }, 120_000); +}); From a5e8928662eddf4492ab80fedf530f518bb9570a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 23:46:18 +0000 Subject: [PATCH 3/4] docs(seed-data): the ownership handoff runs whenever a seed settles, not once Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude --- content/docs/data-modeling/seed-data.mdx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/content/docs/data-modeling/seed-data.mdx b/content/docs/data-modeling/seed-data.mdx index d7bbb79e3f4..86dfe6f1976 100644 --- a/content/docs/data-modeling/seed-data.mdx +++ b/content/docs/data-modeling/seed-data.mdx @@ -422,9 +422,12 @@ whatever identity the load run carries (`config.identity`). During the normal boot sequence no identity is supplied, so `os.user` resolves to a null identity and `cel\`os.user.id\`` evaluates to `null` — the record still seeds successfully, with the field left `null` rather than the load failing. Once -the first human user is promoted to platform admin, a one-time ownership -handoff re-owns every orphaned row (`owner_id` `null`, or the legacy -`usr_system` value from older databases) to that admin. +the first human user is promoted to platform admin, an ownership handoff +re-owns every orphaned row (`owner_id` `null`, or the legacy `usr_system` +value from older databases) to that admin. The handoff is not one-time: it +runs again whenever a seed settles, on that boot and on every later one, so +rows a later boot's seed replay inserts go to the existing platform admin the +same way. A row someone already owns is never touched. - Because `cel\`os.user.id\`` resolves to `null` before an admin exists, a **required** (non-nullable) owner-style field must not depend on it — From 1f33f8ee589b0db26f6d2104b567414d5b48eaa3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 00:23:56 +0000 Subject: [PATCH 4/4] fix(plugin-security): type the seed-settle claim's engine lookup instead of erasing it to any Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude --- packages/plugins/plugin-security/src/security-plugin.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 1b89fce35af..b58073a52d9 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -1413,7 +1413,7 @@ export class SecurityPlugin implements Plugin { ctx: PluginContext, payload?: { appId?: string; overBudget?: boolean }, ): Promise { - let ql: any; + let ql: IObjectQLEngine | undefined; try { ql = ctx.getService('objectql'); } catch {