diff --git a/.changeset/23354-seed-claim-seed-rows.md b/.changeset/23354-seed-claim-seed-rows.md new file mode 100644 index 00000000000..99ecb66d82f --- /dev/null +++ b/.changeset/23354-seed-claim-seed-rows.md @@ -0,0 +1,16 @@ +--- +'@objectstack/plugin-security': patch +'@objectstack/organizations': patch +--- + +fix(plugin-security, organizations): the seed-ownership claims hand over only the rows the seed wrote, and a guest-written or public-form row keeps no owner (ADR-0138 D1) + +The claim that hands seeded records to the platform admin runs on every `app:seeded`, so on every boot of an app with seed data. Its NULL-owner arm re-owned every unowned row of every eligible object, and no stored column tells a seed row from a guest-written row or a public form's intake. Those rows keep no owner on purpose (ADR-0138 D1: "the owner stays unset"), and the claim handed them to the platform admin at the next boot. + +- **`claimSeedOwnership`** (`@objectstack/plugin-security`) re-owns a NULL-owner row only when the seed reported landing on it: `owner_id IS NULL AND id IN (seeded ids)`, in chunks of half the per-row hook ceiling, read through the `seed-settlement` contract's `seededRecordIds`. The settle pass (`app:seeded`) and the first-admin promotion (`bootstrapPlatformAdmin`, new option `seededRecordIds`) both pass the reading. A replay reports the rows an earlier boot wrote, so a seed row is still claimed on a later boot. The `usr_system` arm is unchanged: only a seed or a legacy install writes that owner. +- **`claimOrgSeedOwnership`** (`@objectstack/organizations`) takes the same selector, read for the organization the owner bind claims for: the rows that organization's seed replay landed on, which ran in the same call that created it. Each write carries at most half the per-row hook ceiling's worth of ids, so none is refused for its size and the claim no longer reads pages. +- A row a hook, a flow or an assignment rule gave an owner keeps it, as before. + +**Direct callers.** Called without `seededRecordIds`, `claimSeedOwnership` re-owns only `usr_system`-owned rows and its pass report says no seed reading was published; `claimOrgSeedOwnership` re-owns nothing and says so. The deprecated `ensureDefaultOrganization` wrapper in `@objectstack/organizations` holds no kernel, so it hands over no row; use `OrganizationsPlugin`, which binds the reading. + +**Outside the claim's purpose** (handing the boot seed to the first admin), and so no longer re-owned by a later boot's pass: rows written by seed writers that emit no `app:seeded` — a package install's or a publish's seed apply, the dev hot-reload seeder, and the marketplace install's seed. A dataset they share with the boot bundle is re-reported by the next boot's replay and claimed then. diff --git a/.changeset/23354-seed-landed-rows.md b/.changeset/23354-seed-landed-rows.md new file mode 100644 index 00000000000..5e883a8d6dc --- /dev/null +++ b/.changeset/23354-seed-landed-rows.md @@ -0,0 +1,14 @@ +--- +'@objectstack/spec': minor +'@objectstack/metadata-protocol': minor +'@objectstack/runtime': minor +--- + +feat(spec): the seed loader reports the rows each dataset landed on, and the seed-settlement contract hands them to readers + +- **`SeedLoadResult.landedIds`** (`@objectstack/spec/data`, default `[]`): the ids of the stored rows a dataset's records landed on — a row a record inserted, a row it updated, and a row its key matched on a replay and left as it was. Each id appears once, in record order. A dry run reports none. A replay therefore reports the rows an earlier load wrote, so the answer holds across boots. The loader's own key and reference resolution is behind it: a dataset keyed on a reference reports the matched row, whose stored key is the resolved id, never the dataset's value. +- **`ISeedSettlementService.seededRecordIds?(object, organizationId?)`** (`@objectstack/spec/contracts`, optional): "which rows of this object did this process's seed write?". Without `organizationId` it answers for the boot-time seed sources (the inline seed `app:seeded` announces); a source publishes its rows before it settles, so a reader inside `app:seeded` already sees them. With `organizationId` it answers for that organization's per-organization replay; the host keeps the most recent replay only, so a process that creates many organizations does not hold every replay's rows. A host that does not implement it publishes no reading, and a reader treats every unowned row as one it cannot place. +- **`@objectstack/metadata-protocol`**: `SeedLoaderService.load()` fills `landedIds` on every per-dataset result. +- **`@objectstack/runtime`**: the `seed-settlement` service implements `seededRecordIds`. `AppPlugin` publishes the inline seed's rows before the source settles (the loader path and the basic-insert fallback alike), and its per-organization `seed-replayer` records each replay's rows under that organization. `SeedSourceHandle` gains `publishLanded(report)`. + +Nothing is removed or renamed. The reader this exists for is the seed-ownership claim, which now hands over only the seed's rows (see the `@objectstack/plugin-security` and `@objectstack/organizations` entries). diff --git a/content/docs/references/data/seed-loader.mdx b/content/docs/references/data/seed-loader.mdx index a0628bc1fc6..36ede7b4da4 100644 --- a/content/docs/references/data/seed-loader.mdx +++ b/content/docs/references/data/seed-loader.mdx @@ -163,6 +163,7 @@ Result of loading a single dataset | **referencesDeferred** | `integer` | ✅ | References deferred to second pass | | **referencesDropped** | `integer` | optional (default: `0`) | Reference fields dropped from records that were still written | | **summariesStale** | `integer` | optional (default: `0`) | Roll-up summary values left stale by writes for this dataset | +| **landedIds** | `string[]` | optional (default: `[]`) | Ids of the stored rows this dataset landed on: inserted, updated, or matched on replay | | **errors** | `{ sourceObject: string; field: string; targetObject: string; targetField: string; … }[]` | optional (default: `[]`) | Reference resolution errors | ### Nested Shape: `SeedLoadResult.errors[number]` @@ -298,6 +299,7 @@ Result of loading a single dataset | **referencesDeferred** | `integer` | ✅ | References deferred to second pass | | **referencesDropped** | `integer` | optional (default: `0`) | Reference fields dropped from records that were still written | | **summariesStale** | `integer` | optional (default: `0`) | Roll-up summary values left stale by writes for this dataset | +| **landedIds** | `string[]` | optional (default: `[]`) | Ids of the stored rows this dataset landed on: inserted, updated, or matched on replay | | **errors** | `{ sourceObject: string; field: string; targetObject: string; targetField: string; … }[]` | optional (default: `[]`) | Reference resolution errors | ### Nested Shape: `SeedLoaderResult.errors[number]` diff --git a/packages/metadata-protocol/src/seed-loader-landed-ids.test.ts b/packages/metadata-protocol/src/seed-loader-landed-ids.test.ts new file mode 100644 index 00000000000..aa11de17476 --- /dev/null +++ b/packages/metadata-protocol/src/seed-loader-landed-ids.test.ts @@ -0,0 +1,278 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, vi } from 'vitest'; +import { SeedLoaderService } from './seed-loader.js'; +import type { IDataEngine, IMetadataService } from '@objectstack/spec/contracts'; +import { SeedLoadResultSchema } from '@objectstack/spec/data'; +import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate } from '@objectstack/metadata-core'; + +/** + * #23354 — `SeedLoadResult.landedIds`: the rows each dataset landed on. + * + * The seed-ownership claim (ADR-0138 D1) may hand to the platform admin only + * the unowned rows the SEED wrote: a guest-written row and a public form's + * intake keep no owner, and no stored column tells them from a seed row. The + * loader is the one place that knows which rows its records resolved to — it + * matches natural keys, resolves references to the target's id first, and + * re-identifies rows per organization — so it publishes the answer instead of + * a reader re-deriving it from the dataset. + * + * What the claim depends on, pinned here: + * + * - the insert path reports the ids it wrote; + * - a REPLAY reports the rows an earlier load wrote, on the skip path and on + * the update path alike — that is what keeps the claim correct on every + * later boot; + * - a dataset keyed on a REFERENCE reports the matched row: the stored key is + * the resolved id, never the dataset's own value, which is exactly what a + * reader copying the dataset would get wrong; + * - a row the load did not land (a refused insert) and a dry run report none. + * + * The engine is a faithful in-memory double (`where` filters, ids minted on + * insert), the same shape `seed-loader-replay.test.ts` drives; the loader is + * the real one. + */ + +function createLogger() { + return { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; +} + +function createFaithfulEngine(opts: { refuseInsert?: (object: string, row: any) => boolean } = {}) { + const store: Record = {}; + let idCounter = 0; + const mint = (object: string, d: any) => { + if (opts.refuseInsert?.(object, d)) { + throw Object.assign(new Error('Validation failed: name is reserved'), { code: 'VALIDATION_FAILED', status: 400 }); + } + return { id: `gen-${++idCounter}`, ...d }; + }; + + const engine = { + find: vi.fn(async (objectName: string, query?: any) => { + let records = store[objectName] || []; + if (query?.where) { + records = records.filter((r) => + Object.entries(query.where).every(([k, v]) => { + if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); + return r[k] === v; + }), + ); + } + if (typeof query?.limit === 'number') records = records.slice(0, query.limit); + return records; + }), + findOne: vi.fn(async (objectName: string, query?: any) => { + assertEngineFindOnePredicate(objectName, query); + const rows = await (engine.find as any)(objectName, { ...query, limit: 1 }); + return rows[0] ?? null; + }), + insert: vi.fn(async (objectName: string, data: any) => { + if (!store[objectName]) store[objectName] = []; + if (Array.isArray(data)) { + const records = data.map((d) => mint(objectName, d)); + store[objectName].push(...records); + return records; + } + const record = mint(objectName, data); + store[objectName].push(record); + return record; + }), + update: vi.fn(async (objectName: string, data: any) => { + assertEngineUpdateDispatch(data, undefined); + const records = store[objectName] || []; + const idx = records.findIndex((r) => r.id === data.id); + if (idx >= 0) { + records[idx] = { ...records[idx], ...data }; + return records[idx]; + } + return data; + }), + delete: vi.fn(async (_objectName: string, options?: any) => { + assertEngineDeleteDispatch(options); + return { deleted: 1 }; + }), + count: vi.fn(async (objectName: string) => (store[objectName] || []).length), + aggregate: vi.fn(async () => []), + } as unknown as IDataEngine; + + return { engine, store }; +} + +function createMetadata(): IMetadataService { + const objects: Record = { + lid_team: { name: 'lid_team', fields: { name: { type: 'text' } } }, + lid_case: { name: 'lid_case', fields: { name: { type: 'text' }, status: { type: 'text' } } }, + // A junction keyed on its REFERENCE: the stored `team` is the resolved + // team id, never the dataset's natural key. + lid_membership: { + name: 'lid_membership', + fields: { + team: { type: 'lookup', reference: 'lid_team', required: true }, + role: { type: 'text' }, + }, + }, + }; + return { + getObject: vi.fn(async (name: string) => objects[name]), + listObjects: vi.fn(async () => Object.values(objects)), + register: vi.fn(async () => {}), + get: vi.fn(async (_t: string, name: string) => objects[name]), + list: vi.fn(async () => []), + unregister: vi.fn(async () => {}), + exists: vi.fn(async () => false), + listNames: vi.fn(async () => []), + } as unknown as IMetadataService; +} + +// The AppPlugin inline-seed config: upsert, multi-pass. +const CONFIG = { + dryRun: false, + haltOnError: false, + multiPass: true, + defaultMode: 'upsert', + batchSize: 1000, + transaction: false, +} as any; + +const SEEDS = [ + { + object: 'lid_team', + externalId: 'name', + mode: 'upsert', + records: [{ name: 'Experience' }, { name: 'Platform' }], + }, + { + object: 'lid_case', + externalId: 'name', + mode: 'upsert', + records: [ + { name: 'CASE-1', status: 'open' }, + { name: 'CASE-2', status: 'open' }, + ], + }, + { + object: 'lid_membership', + externalId: 'team', + mode: 'upsert', + records: [{ team: 'Experience', role: 'lead' }], + }, +] as any[]; + +function landed(result: { results: Array<{ object: string; landedIds: string[] }> }, object: string): string[] { + return result.results.filter((r) => r.object === object).flatMap((r) => r.landedIds); +} + +const idsOf = (rows: any[] | undefined) => (rows ?? []).map((r) => r.id); + +describe('SeedLoadResult.landedIds — the rows a seed landed on (#23354)', () => { + it('the insert path reports the ids it wrote, per dataset, in record order', async () => { + const { engine, store } = createFaithfulEngine(); + const result = await new SeedLoaderService(engine, createMetadata(), createLogger()).load({ seeds: SEEDS, config: CONFIG }); + + expect(result.success).toBe(true); + expect(landed(result, 'lid_team')).toEqual(idsOf(store.lid_team)); + expect(landed(result, 'lid_case')).toEqual(idsOf(store.lid_case)); + expect(landed(result, 'lid_membership')).toEqual(idsOf(store.lid_membership)); + expect(landed(result, 'lid_case')).toHaveLength(2); + // The published shape is the spec's: it parses, and the field survives. + for (const r of result.results) expect(SeedLoadResultSchema.parse(r).landedIds).toEqual(r.landedIds); + }); + + it('a replay of an unchanged seed reports the rows the FIRST load wrote (skip path)', async () => { + const { engine, store } = createFaithfulEngine(); + const metadata = createMetadata(); + await new SeedLoaderService(engine, metadata, createLogger()).load({ seeds: SEEDS, config: CONFIG }); + const firstIds = { team: idsOf(store.lid_team), cases: idsOf(store.lid_case), membership: idsOf(store.lid_membership) }; + + const replay = await new SeedLoaderService(engine, metadata, createLogger()).load({ seeds: SEEDS, config: CONFIG }); + + // Positive control: this really was the skip path, nothing written. + expect(replay.summary.totalInserted).toBe(0); + expect(replay.summary.totalUpdated).toBe(0); + expect(replay.summary.totalSkipped).toBe(5); + expect(landed(replay, 'lid_team')).toEqual(firstIds.team); + expect(landed(replay, 'lid_case')).toEqual(firstIds.cases); + expect(landed(replay, 'lid_membership')).toEqual(firstIds.membership); + }); + + it('a replay that updates the rows reports them too (update path)', async () => { + const { engine, store } = createFaithfulEngine(); + const metadata = createMetadata(); + await new SeedLoaderService(engine, metadata, createLogger()).load({ seeds: SEEDS, config: CONFIG }); + for (const row of store.lid_case) row.status = 'edited at runtime'; + + const replay = await new SeedLoaderService(engine, metadata, createLogger()).load({ seeds: SEEDS, config: CONFIG }); + + expect(replay.results.find((r) => r.object === 'lid_case')!.updated).toBe(2); + expect(landed(replay, 'lid_case')).toEqual(idsOf(store.lid_case)); + }); + + it('a dataset keyed on a reference reports the matched row, whose stored key is the resolved id', async () => { + const { engine, store } = createFaithfulEngine(); + const metadata = createMetadata(); + await new SeedLoaderService(engine, metadata, createLogger()).load({ seeds: SEEDS, config: CONFIG }); + const experienceId = store.lid_team.find((r) => r.name === 'Experience')!.id; + const membership = store.lid_membership[0]; + // The trap a reader copying the dataset falls into: the stored key is NOT + // the dataset's value. + expect(membership.team).toBe(experienceId); + expect(membership.team).not.toBe('Experience'); + + const replay = await new SeedLoaderService(engine, metadata, createLogger()).load({ seeds: SEEDS, config: CONFIG }); + + expect(replay.results.find((r) => r.object === 'lid_membership')!.skipped).toBe(1); + expect(landed(replay, 'lid_membership')).toEqual([membership.id]); + }); + + it('a row that is not in the seed is never reported, even when it shares the object', async () => { + const { engine, store } = createFaithfulEngine(); + const metadata = createMetadata(); + await new SeedLoaderService(engine, metadata, createLogger()).load({ seeds: SEEDS, config: CONFIG }); + // Written by someone else between boots — a guest, a form door, a user. + store.lid_case.push({ id: 'guest-row', name: 'Guest enquiry', status: 'new' }); + + const replay = await new SeedLoaderService(engine, metadata, createLogger()).load({ seeds: SEEDS, config: CONFIG }); + + expect(landed(replay, 'lid_case')).not.toContain('guest-row'); + expect(landed(replay, 'lid_case')).toHaveLength(2); + }); + + it('a refused insert lands no row, so it reports no id', async () => { + const { engine, store } = createFaithfulEngine({ refuseInsert: (o, row) => o === 'lid_case' && row.name === 'CASE-2' }); + const result = await new SeedLoaderService(engine, createMetadata(), createLogger()).load({ seeds: SEEDS, config: CONFIG }); + + expect(result.results.find((r) => r.object === 'lid_case')!.errored).toBe(1); + expect(store.lid_case.map((r) => r.name)).toEqual(['CASE-1']); + expect(landed(result, 'lid_case')).toEqual(idsOf(store.lid_case)); + }); + + it('two records that resolve to one row report it once', async () => { + const { engine, store } = createFaithfulEngine(); + const metadata = createMetadata(); + await new SeedLoaderService(engine, metadata, createLogger()).load({ seeds: SEEDS, config: CONFIG }); + const doubled = [{ + object: 'lid_case', + externalId: 'name', + mode: 'upsert', + records: [{ name: 'CASE-1', status: 'open' }, { name: 'CASE-1', status: 'open' }], + }] as any[]; + + const replay = await new SeedLoaderService(engine, metadata, createLogger()).load({ seeds: doubled, config: CONFIG }); + + const case1 = store.lid_case.find((r) => r.name === 'CASE-1')!.id; + expect(landed(replay, 'lid_case')).toEqual([case1]); + }); + + it('a dry run writes nothing and reports nothing', async () => { + const { engine, store } = createFaithfulEngine(); + const result = await new SeedLoaderService(engine, createMetadata(), createLogger()).load({ + seeds: SEEDS, + config: { ...CONFIG, dryRun: true }, + }); + + // Positive control: the dry run did count the would-be inserts. + expect(result.summary.totalInserted).toBe(5); + expect(store.lid_case).toBeUndefined(); + for (const r of result.results) expect(r.landedIds).toEqual([]); + }); +}); diff --git a/packages/metadata-protocol/src/seed-loader.ts b/packages/metadata-protocol/src/seed-loader.ts index c559be1b27d..79041821095 100644 --- a/packages/metadata-protocol/src/seed-loader.ts +++ b/packages/metadata-protocol/src/seed-loader.ts @@ -1835,6 +1835,19 @@ export class SeedLoaderService implements ISeedLoaderService { if (internalId) entry.internalId = internalId; } + // [#23354] The same map, published: the rows this dataset landed on. Every + // write site records through `noteLanded` — an insert, an update, and a key + // matched on a replay and skipped — so a replay reports the rows an earlier + // load wrote, and the seed-ownership claim (ADR-0138 D1) can hand over + // exactly the seed's rows without a second copy of the key and reference + // rules above. Record order, each id once: two records can resolve to one + // row. A dry run never calls `noteLanded`, so it reports none. + const landedIds = [...new Set( + [...internalIdByRecordIndex.entries()] + .sort(([a], [b]) => a - b) + .map(([, id]) => id), + )]; + return { object: objectName, mode, @@ -1847,6 +1860,7 @@ export class SeedLoaderService implements ISeedLoaderService { referencesDeferred, referencesDropped, summariesStale: this.summariesStale - summariesStaleAtStart, + landedIds, errors, }; } diff --git a/packages/plugins/organizations/src/claim-org-seed-ownership-dispatch.pin.test.ts b/packages/plugins/organizations/src/claim-org-seed-ownership-dispatch.pin.test.ts index 545259bd438..b910b934800 100644 --- a/packages/plugins/organizations/src/claim-org-seed-ownership-dispatch.pin.test.ts +++ b/packages/plugins/organizations/src/claim-org-seed-ownership-dispatch.pin.test.ts @@ -9,10 +9,12 @@ * `claimOrgSeedOwnership` re-owns an organization's seeded rows to its owner. * That is attribution, the step that completes the seed, not a user event, so * its write runs under `{ isSystem: true, skipAutomations: true }`. Since - * [#23054] that write is a PREDICATE write (`multi: true`), paged only when the - * engine refuses it for its per-row hook budget. This file pins what that - * context reaches on the engine along that path, because a flag that is - * silently dropped on the way would read as green in every double: + * [#23054] that write is a PREDICATE write (`multi: true`); since [#23354] it + * names the seeded ids (`id IN (chunk)`), so it re-owns only the rows the + * organization's seed replay reported and leaves a guest's or a form's unowned + * row unowned (ADR-0138 D1). This file pins what that context reaches on the + * engine along that path, because a flag that is silently dropped on the way + * would read as green in every double: * * 1. **the metadata-hook filter** — a hook bound through the real binder * (`bindHooksToEngine`, which is what stamps `entry.meta`) does not fire, @@ -32,12 +34,11 @@ * that was never bound. * * [#23054] And it pins the claim's reach past the per-row hook ceiling: 10 500 - * owner-less rows of one object in one organization are ALL owned after one - * pass. The flag does not exempt the write from that ceiling (code-registered - * hooks still cover the object), so the whole-set write is refused and the - * claim pages; the page read is a plain system read, so the metadata hook, - * bound on `beforeFind`/`afterFind` too, sees it — the in-run positive control - * of that case. + * seeded owner-less rows of one object in one organization are ALL owned after + * one pass. The flag does not exempt a write from that ceiling (code-registered + * hooks still cover the object), which is why every claim write carries at + * most half the ceiling's worth of ids: each is under it, none is refused, and + * the claim reads nothing. */ import { describe, it, expect, afterEach } from 'vitest'; @@ -47,6 +48,9 @@ import { BULK_PER_ROW_HOOK_LIMIT_ERROR_CODE, MAX_BULK_PER_ROW_HOOK_ROWS } from ' import { claimOrgSeedOwnership } from './claim-org-seed-ownership.js'; +/** [#23354] The organization's seed replay reported landing on these rows. */ +const seededAre = (ids: string[]) => (object: string): readonly string[] => (object === OBJECT ? ids : []); + const SYS = { context: { isSystem: true } } as const; const ORG = 'org_alpha'; const OTHER_ORG = 'org_north'; @@ -167,9 +171,14 @@ describe('[#23008] the organization seed-ownership claim dispatches no metadata- // Out of the claim's scope: owned already, and another organization's row. { id: 'owned', name: 'Owned', stage: 'open', organization_id: ORG, owner_id: SOMEONE }, { id: 'foreign', name: 'Foreign', stage: 'open', organization_id: OTHER_ORG, owner_id: null }, + // [#23354] Unowned and in scope, but never reported by the seed: a + // guest's or a public form's row keeps no owner (ADR-0138 D1). + { id: 'guest', name: 'Guest enquiry', stage: 'new', organization_id: ORG, owner_id: null }, ]); - const result = await claimOrgSeedOwnership(engine, ORG, OWNER); + const result = await claimOrgSeedOwnership(engine, ORG, OWNER, { + seededRecordIds: seededAre([...claimed, 'owned', 'foreign']), + }); // Taken before any read of the test's own: `ownersOf` is a `find` too. const metadataSeen = [...metadataHook]; const codeSeen = [...codeHook]; @@ -179,6 +188,7 @@ describe('[#23008] the organization seed-ownership claim dispatches no metadata- for (const id of claimed) expect(owners.get(id)).toBe(OWNER); expect(owners.get('owned')).toBe(SOMEONE); expect(owners.get('foreign')).toBeNull(); + expect(owners.get('guest')).toBeNull(); // ⭐ 1 — the metadata-hook filter: not one dispatch, before or after. Under // the ceiling the claim is one predicate write and reads nothing, so the @@ -219,17 +229,18 @@ describe('[#23008] the organization seed-ownership claim dispatches no metadata- it('a second run claims nothing and dispatches no write hook of either kind', async () => { const { engine, metadataHook, codeHook } = await boot(); await seed(engine, [{ id: 'd0', name: 'Deal 0', stage: 'open', organization_id: ORG, owner_id: null }]); + const seededRecordIds = seededAre(['d0']); - expect(await claimOrgSeedOwnership(engine, ORG, OWNER)).toEqual([{ object: OBJECT, count: 1 }]); + expect(await claimOrgSeedOwnership(engine, ORG, OWNER, { seededRecordIds })).toEqual([{ object: OBJECT, count: 1 }]); metadataHook.length = 0; codeHook.length = 0; - expect(await claimOrgSeedOwnership(engine, ORG, OWNER)).toEqual([]); + expect(await claimOrgSeedOwnership(engine, ORG, OWNER, { seededRecordIds })).toEqual([]); expect(writeEvents(metadataHook)).toEqual([]); expect(codeHook).toEqual([]); }, 120_000); - it('[#23054] 10 500 owner-less rows are all owned after one pass: refused whole under the flag, then paged to every row', async () => { + it('[#23054] 10 500 seeded owner-less rows are all owned after one pass, in chunk writes none of which is refused', async () => { const { engine, metadataHook, codeHook } = await boot(); const N = 10_500; expect(N, 'the fixture must be past the per-row hook ceiling').toBeGreaterThan(MAX_BULK_PER_ROW_HOOK_ROWS); @@ -240,9 +251,10 @@ describe('[#23008] the organization seed-ownership claim dispatches no metadata- { id: 'foreign', name: 'Foreign', stage: 'open', organization_id: OTHER_ORG, owner_id: null }, ]); - // The measurement the paging rests on: with metadata hooks switched off, - // code-registered hooks still cover the object, so the ceiling still - // applies and refuses the whole-set write — nothing written, no hook run. + // The measurement the chunk size rests on: with metadata hooks switched + // off, code-registered hooks still cover the object, so the ceiling still + // applies and refuses an unbounded whole-set write — nothing written, no + // hook run. await expect( (engine as any).update(OBJECT, { owner_id: OWNER }, { where: { organization_id: ORG, owner_id: null }, @@ -254,7 +266,7 @@ describe('[#23008] the organization seed-ownership claim dispatches no metadata- expect(codeHook).toEqual([]); metadataHook.length = 0; - const result = await claimOrgSeedOwnership(engine, ORG, OWNER); + const result = await claimOrgSeedOwnership(engine, ORG, OWNER, { seededRecordIds: seededAre(claimed) }); const metadataSeen = [...metadataHook]; const codeSeen = [...codeHook]; @@ -265,14 +277,10 @@ describe('[#23008] the organization seed-ownership claim dispatches no metadata- expect(owners.get('owned')).toBe(SOMEONE); expect(owners.get('foreign')).toBeNull(); - // ⭐ 1 on every landed write, page or whole set; the page read is the - // in-run positive control: a plain system read the metadata hook sees. - expect(writeEvents(metadataSeen), 'a metadata-bound hook fired on a claim write').toEqual([]); - expect(metadataSeen.map((s) => s.event)).toEqual(['beforeFind', 'afterFind']); - for (const s of metadataSeen) { - expect(s.isSystem).toBe(true); - expect(s.skipAutomations).toBeUndefined(); - } + // ⭐ 1 on every chunk write — and the claim issued no read at all: every + // chunk landed whole, so nothing was paged (the hook is bound on the read + // events too). + expect(metadataSeen, 'a metadata-bound hook fired for the claim').toEqual([]); // ⭐ 2 and 3: one dispatch per claimed row and phase, every one flagged. for (const event of ['beforeUpdate', 'afterUpdate']) { expect(codeSeen.filter((s) => s.event === event), `${event} per claimed row`).toHaveLength(N); diff --git a/packages/plugins/organizations/src/claim-org-seed-ownership.test.ts b/packages/plugins/organizations/src/claim-org-seed-ownership.test.ts index 24fbb103451..2c517b999fe 100644 --- a/packages/plugins/organizations/src/claim-org-seed-ownership.test.ts +++ b/packages/plugins/organizations/src/claim-org-seed-ownership.test.ts @@ -28,7 +28,7 @@ interface RecordedWrite { /** * `where` as `claimOrgSeedOwnership` spells it: field equality, plus - * `{ id: { $in: [...] } }` on a page write. + * `{ id: { $in: [...] } }` for the chunk of seeded ids every write carries. * * Anything else is REFUSED rather than answered (`check:where-matcher`): a * combinator read as a field name, or an unimplemented value operator read as a @@ -43,8 +43,8 @@ function rowMatches(row: any, where: Record): boolean { if (v !== null && typeof v === 'object' && !Array.isArray(v)) { const ops = Object.keys(v as Record); if (ops.length === 1 && ops[0] === '$in') { - // A Set, not `.some`: the over-ceiling fixture pairs a 10 500-row table - // with 5 000-member pages. + // A Set, not `.some`: the large fixture pairs a 10 500-row table with + // 5 000-member chunks. let members: Set | undefined = (v as any).__set; if (!members) { members = new Set(((v as any).$in as unknown[]).map((m) => m ?? null)); @@ -72,8 +72,6 @@ function makeQL( rowsByObject: Record, opts: { ceiling?: number; - /** Runs after a page read, before the page write: a concurrent writer. */ - afterRead?: (object: string, ids: string[]) => void; /** Replaces the write's answer (the rows it would have moved stay put). */ writeAnswer?: (object: string, where: any) => unknown; /** Throws instead of writing. */ @@ -87,9 +85,7 @@ function makeQL( find: vi.fn(async (object: string, query: any) => { const hits = (rowsByObject[object] ?? []).filter((r) => rowMatches(r, query?.where ?? {})); const page = typeof query?.limit === 'number' ? hits.slice(0, query.limit) : hits; - const out = page.map((r) => ({ id: r.id })); - opts.afterRead?.(object, out.map((r) => r.id)); - return out; + return page.map((r) => ({ id: r.id })); }), update: vi.fn(async (object: string, data: any, options?: any) => { const dispatch = assertEngineUpdateDispatch(data, options); @@ -118,17 +114,25 @@ function makeLogger() { return { info: vi.fn(), warn: vi.fn() }; } +/** [#23354] The organization's seed replay reported landing on these rows. */ +function seededIds(byObject: Record): (object: string) => readonly string[] { + return (object) => byObject[object] ?? []; +} + +const CHUNK = Math.floor(MAX_BULK_PER_ROW_HOOK_ROWS / 2); + describe('claimOrgSeedOwnership', () => { it('returns [] when registry is unavailable', async () => { const ql: any = { find: vi.fn(), update: vi.fn() }; - expect(await claimOrgSeedOwnership(ql, ORG, OWNER)).toEqual([]); + expect(await claimOrgSeedOwnership(ql, ORG, OWNER, { seededRecordIds: seededIds({}) })).toEqual([]); }); it('no-ops without an org or owner', async () => { const schemas = [{ name: 'crm_lead', fields: BOTH_COLUMNS }]; const { ql, writes } = makeQL(schemas, { crm_lead: [{ id: 'l1', organization_id: ORG, owner_id: null }] }); - expect(await claimOrgSeedOwnership(ql, '', OWNER)).toEqual([]); - expect(await claimOrgSeedOwnership(ql, ORG, '')).toEqual([]); + const seededRecordIds = seededIds({ crm_lead: ['l1'] }); + expect(await claimOrgSeedOwnership(ql, '', OWNER, { seededRecordIds })).toEqual([]); + expect(await claimOrgSeedOwnership(ql, ORG, '', { seededRecordIds })).toEqual([]); expect(writes).toHaveLength(0); }); @@ -145,12 +149,14 @@ describe('claimOrgSeedOwnership', () => { crm_pricebook: [{ id: 'p1', organization_id: ORG }], crm_global: [{ id: 'g1', owner_id: null }], }); - expect(await claimOrgSeedOwnership(ql, ORG, OWNER)).toEqual([]); + // The seed reported every one of these rows, so each skip is the filter's. + const seededRecordIds = seededIds({ sys_user: ['u1'], sys_widget: ['w1'], crm_pricebook: ['p1'], crm_global: ['g1'] }); + expect(await claimOrgSeedOwnership(ql, ORG, OWNER, { seededRecordIds })).toEqual([]); expect(writes).toHaveLength(0); expect(ql.update).not.toHaveBeenCalled(); }); - it('claims this org\'s NULL-owner rows in ONE predicate write, leaving other orgs and human-owned rows untouched', async () => { + it('claims this org\'s seeded NULL-owner rows in ONE predicate write, leaving other orgs and human-owned rows untouched', async () => { const schemas = [{ name: 'crm_lead', fields: BOTH_COLUMNS }]; const rows = [ { id: 'l1', organization_id: ORG, owner_id: null }, // claimed @@ -159,19 +165,20 @@ describe('claimOrgSeedOwnership', () => { { id: 'l4', organization_id: ORG, owner_id: null }, // claimed ]; const { ql, writes } = makeQL(schemas, { crm_lead: rows }); - const result = await claimOrgSeedOwnership(ql, ORG, OWNER); + const seeded = ['l1', 'l2', 'l3', 'l4']; + const result = await claimOrgSeedOwnership(ql, ORG, OWNER, { seededRecordIds: seededIds({ crm_lead: seeded }) }); expect(result).toEqual([{ object: 'crm_lead', count: 2 }]); expect(writes).toEqual([ { object: 'crm_lead', data: { owner_id: OWNER }, - where: { organization_id: ORG, owner_id: null }, + where: { organization_id: ORG, owner_id: null, id: { $in: seeded } }, matched: ['l1', 'l4'], context: { isSystem: true, skipAutomations: true }, }, ]); - // Under the ceiling the claim reads nothing: the predicate is the selection. + // The claim reads nothing: the predicate is the selection. expect(ql.find).not.toHaveBeenCalled(); expect(rows.find((r) => r.id === 'l2')!.owner_id).toBe(SOMEONE); expect(rows.find((r) => r.id === 'l3')!.owner_id).toBeNull(); @@ -180,8 +187,49 @@ describe('claimOrgSeedOwnership', () => { it('is idempotent — a second run claims nothing', async () => { const schemas = [{ name: 'crm_lead', fields: BOTH_COLUMNS }]; const { ql } = makeQL(schemas, { crm_lead: [{ id: 'l1', organization_id: ORG, owner_id: null }] }); - await claimOrgSeedOwnership(ql, ORG, OWNER); - expect(await claimOrgSeedOwnership(ql, ORG, OWNER)).toEqual([]); + const seededRecordIds = seededIds({ crm_lead: ['l1'] }); + expect(await claimOrgSeedOwnership(ql, ORG, OWNER, { seededRecordIds })).toEqual([{ object: 'crm_lead', count: 1 }]); + expect(await claimOrgSeedOwnership(ql, ORG, OWNER, { seededRecordIds })).toEqual([]); + }); + + // ── [#23354] only the rows the organization's seed replay wrote ────────── + + it('a NULL-owner row the replay did not report keeps no owner — a guest\'s, a form\'s (ADR-0138 D1)', async () => { + const schemas = [{ name: 'crm_case', fields: BOTH_COLUMNS }]; + const rows = [ + { id: 'seed_1', organization_id: ORG, owner_id: null }, + { id: 'guest_1', organization_id: ORG, owner_id: null }, + { id: 'form_1', organization_id: ORG, owner_id: null }, + { id: 'hook_1', organization_id: ORG, owner_id: 'usr_set_by_hook' }, + ]; + const { ql } = makeQL(schemas, { crm_case: rows }); + + const result = await claimOrgSeedOwnership(ql, ORG, OWNER, { + seededRecordIds: seededIds({ crm_case: ['seed_1', 'hook_1'] }), + }); + + expect(result).toEqual([{ object: 'crm_case', count: 1 }]); + expect(Object.fromEntries(rows.map((r) => [r.id, r.owner_id]))).toEqual({ + seed_1: OWNER, + guest_1: null, + form_1: null, + hook_1: 'usr_set_by_hook', + }); + }); + + it('without a seeded reading it re-owns nothing — never every unowned row — and says so', async () => { + const schemas = [{ name: 'crm_case', fields: BOTH_COLUMNS }]; + const rows = [{ id: 'c1', organization_id: ORG, owner_id: null }]; + const { ql } = makeQL(schemas, { crm_case: rows }); + const logger = makeLogger(); + + expect(await claimOrgSeedOwnership(ql, ORG, OWNER, { logger })).toEqual([]); + expect(ql.update).not.toHaveBeenCalled(); + expect(rows[0].owner_id).toBeNull(); + expect(logger.info).toHaveBeenCalledWith( + expect.stringContaining('no seed host on this kernel publishes the rows its replay landed on'), + { organizationId: ORG }, + ); }); // ── [#23008] the claim's write dispatches no metadata-bound automation ──── @@ -205,7 +253,9 @@ describe('claimOrgSeedOwnership', () => { crm_deal: [{ id: 'd1', organization_id: ORG, owner_id: null }], }); - expect(await claimOrgSeedOwnership(ql, ORG, OWNER)).toEqual([ + expect( + await claimOrgSeedOwnership(ql, ORG, OWNER, { seededRecordIds: seededIds({ crm_lead: ['l1', 'l2'], crm_deal: ['d1'] }) }), + ).toEqual([ { object: 'crm_lead', count: 2 }, { object: 'crm_deal', count: 1 }, ]); @@ -216,119 +266,104 @@ describe('claimOrgSeedOwnership', () => { } }); - // ── [#23054] past the per-row hook ceiling: paged, until none is left ───── + // ── [#23054] past the per-row hook ceiling: every seeded row, no write refused ── - it('10 500 owner-less rows are all owned after one pass, and count says 10 500', async () => { - // The card's pin. The whole-set write is refused (ADR-0058 D6: over the - // per-row hook ceiling, nothing written), so the claim takes a page off - // the top and tries the whole set again. A single read at the old - // 10 000-row cap owned 10 000 and left 500 owner-less for good. + it('10 500 seeded owner-less rows are all owned after one pass, and count says 10 500', async () => { + // The card's pin. A single read at the old 10 000-row cap owned 10 000 and + // left 500 owner-less for good; an unbounded predicate write is refused + // whole over the per-row hook ceiling (ADR-0058 D6). A chunk of seeded ids + // is half the ceiling, so every write lands and none is refused. const N = 10_500; const schemas = [{ name: 'crm_lead', fields: BOTH_COLUMNS }]; + const seeded = Array.from({ length: N }, (_, i) => `l${String(i).padStart(5, '0')}`); const rows = [ - ...Array.from({ length: N }, (_, i) => ({ id: `l${String(i).padStart(5, '0')}`, organization_id: ORG, owner_id: null })), + ...seeded.map((id) => ({ id, organization_id: ORG, owner_id: null as string | null })), { id: 'owned', organization_id: ORG, owner_id: SOMEONE }, { id: 'foreign', organization_id: OTHER_ORG, owner_id: null }, ]; const { ql, writes } = makeQL(schemas, { crm_lead: rows }); - expect(await claimOrgSeedOwnership(ql, ORG, OWNER)).toEqual([{ object: 'crm_lead', count: N }]); + expect( + await claimOrgSeedOwnership(ql, ORG, OWNER, { seededRecordIds: seededIds({ crm_lead: seeded }) }), + ).toEqual([{ object: 'crm_lead', count: N }]); expect(rows.filter((r) => r.organization_id === ORG && r.owner_id === null)).toEqual([]); expect(rows.filter((r) => r.owner_id === OWNER)).toHaveLength(N); expect(rows.find((r) => r.id === 'owned')!.owner_id).toBe(SOMEONE); expect(rows.find((r) => r.id === 'foreign')!.owner_id).toBeNull(); - // One page, then the whole remainder: the page keeps the claim's predicate - // and ANDs the page's ids onto it; no landed write is over the ceiling. - const pageRows = Math.floor(MAX_BULK_PER_ROW_HOOK_ROWS / 2); - expect(writes.map((w) => w.matched.length)).toEqual([pageRows, N - pageRows]); - expect(Object.keys(writes[0].where).sort()).toEqual(['id', 'organization_id', 'owner_id']); - expect(writes[0].where).toMatchObject({ organization_id: ORG, owner_id: null }); - expect(writes[1].where).toEqual({ organization_id: ORG, owner_id: null }); - for (const w of writes) expect(w.context).toEqual({ isSystem: true, skipAutomations: true }); - // The page read is a plain system read of the same predicate, bounded. - expect(ql.find).toHaveBeenCalledTimes(1); - expect(ql.find.mock.calls[0]).toEqual([ - 'crm_lead', - { where: { organization_id: ORG, owner_id: null }, limit: pageRows, fields: ['id'] }, - { context: { isSystem: true } }, - ]); + // Three chunks, each under the ceiling; every write keeps the claim's + // predicate and names its chunk; nothing was read. + expect(writes.map((w) => w.matched.length)).toEqual([CHUNK, CHUNK, N - 2 * CHUNK]); + for (const w of writes) { + expect(Object.keys(w.where).sort()).toEqual(['id', 'organization_id', 'owner_id']); + expect(w.where).toMatchObject({ organization_id: ORG, owner_id: null }); + expect(w.where.id.$in.length).toBeLessThanOrEqual(CHUNK); + expect(w.context).toEqual({ isSystem: true, skipAutomations: true }); + } + expect(ql.find).not.toHaveBeenCalled(); }); - // The three below run one row past the ceiling, so the whole-set write is - // refused and the claim pages: the page size is derived from the engine's - // ceiling, so a smaller fake ceiling would refuse the page write too. - const pastCeiling = (prefix: string) => - Array.from({ length: MAX_BULK_PER_ROW_HOOK_ROWS + 1 }, (_, i) => ({ - id: `${prefix}${String(i).padStart(5, '0')}`, - organization_id: ORG, - owner_id: null as string | null, - })); - const PAGE_ROWS = Math.floor(MAX_BULK_PER_ROW_HOOK_ROWS / 2); - - it('a page write never re-owns a row claimed between its read and its write', async () => { - // The page write carries the predicate, not just the ids: a row someone - // else owned in between keeps that owner and is not counted. + it('an unreadable count is warned as unknown, counts nothing, and the next chunk still lands', async () => { const schemas = [{ name: 'crm_lead', fields: BOTH_COLUMNS }]; - const rows = pastCeiling('l'); - let raced = false; + const seeded = Array.from({ length: CHUNK + 1 }, (_, i) => `l${String(i).padStart(5, '0')}`); + const rows = seeded.map((id) => ({ id, organization_id: ORG, owner_id: null as string | null })); + const logger = makeLogger(); + let call = 0; const { ql } = makeQL(schemas, { crm_lead: rows }, { - afterRead: (_object, ids) => { - if (raced) return; - raced = true; - rows.find((r) => r.id === ids[0])!.owner_id = SOMEONE; + // The first chunk's driver answers a record rather than a count. + writeAnswer: (_object, where) => { + call += 1; + if (call === 1) return { id: 'not-a-count' }; + const ids = new Set(where.id.$in as string[]); + let moved = 0; + for (const r of rows) if (ids.has(r.id) && r.owner_id === null) { r.owner_id = OWNER; moved += 1; } + return moved; }, }); - expect(await claimOrgSeedOwnership(ql, ORG, OWNER)).toEqual([{ object: 'crm_lead', count: rows.length - 1 }]); - expect(rows[0].owner_id).toBe(SOMEONE); - expect(rows.filter((r) => r.owner_id === OWNER)).toHaveLength(rows.length - 1); - }); - - it('stops, and says so, when a page write re-owns nothing (it would read the same page forever)', async () => { - const schemas = [{ name: 'crm_lead', fields: BOTH_COLUMNS }]; - const logger = makeLogger(); - // A write-scoping middleware that narrows every page write to nothing. - const { ql } = makeQL(schemas, { crm_lead: pastCeiling('l') }, { - writeAnswer: () => 0, - }); - - expect(await claimOrgSeedOwnership(ql, ORG, OWNER, { logger })).toEqual([]); - expect(ql.update).toHaveBeenCalledTimes(2); // the refused whole set, then the one page + expect( + await claimOrgSeedOwnership(ql, ORG, OWNER, { logger, seededRecordIds: seededIds({ crm_lead: seeded }) }), + ).toEqual([{ object: 'crm_lead', count: 1 }]); + expect(ql.update).toHaveBeenCalledTimes(2); expect(logger.warn).toHaveBeenCalledWith( - expect.stringContaining('re-owned none of them'), - expect.objectContaining({ object: 'crm_lead', organizationId: ORG, matched: PAGE_ROWS }), + expect.stringContaining('could not read an affected-row count'), + expect.objectContaining({ object: 'crm_lead', organizationId: ORG, ids: CHUNK }), ); }); - it('keeps counting what landed when a later write fails, and a failed object costs no later object its claim', async () => { + it('a failed chunk costs that chunk only — the next chunk and the next object are still claimed', async () => { const schemas = [ { name: 'crm_lead', fields: BOTH_COLUMNS }, { name: 'crm_deal', fields: BOTH_COLUMNS }, ]; - const leads = pastCeiling('l'); + const seeded = Array.from({ length: 2 * CHUNK + 1 }, (_, i) => `l${String(i).padStart(5, '0')}`); + const leads = seeded.map((id) => ({ id, organization_id: ORG, owner_id: null as string | null })); const deals = [{ id: 'd1', organization_id: ORG, owner_id: null }]; const logger = makeLogger(); - let leadWholeSetWrites = 0; + let leadWrites = 0; const { ql } = makeQL(schemas, { crm_lead: leads, crm_deal: deals }, { - // The first whole-set write on crm_lead is refused for its size; the page - // lands; the retried whole set then fails outright. - failWrite: (object, where) => { - if (object !== 'crm_lead' || 'id' in where) return undefined; - leadWholeSetWrites += 1; - return leadWholeSetWrites === 2 ? new Error('a code hook refused the write') : undefined; + // The second crm_lead chunk is refused outright by a code hook. + failWrite: (object) => { + if (object !== 'crm_lead') return undefined; + leadWrites += 1; + return leadWrites === 2 ? new Error('a code hook refused the write') : undefined; }, }); - expect(await claimOrgSeedOwnership(ql, ORG, OWNER, { logger })).toEqual([ - { object: 'crm_lead', count: PAGE_ROWS }, + expect( + await claimOrgSeedOwnership(ql, ORG, OWNER, { + logger, + seededRecordIds: seededIds({ crm_lead: seeded, crm_deal: ['d1'] }), + }), + ).toEqual([ + { object: 'crm_lead', count: CHUNK + 1 }, { object: 'crm_deal', count: 1 }, ]); - expect(leads.filter((r) => r.owner_id === OWNER)).toHaveLength(PAGE_ROWS); + expect(leads.filter((r) => r.owner_id === OWNER)).toHaveLength(CHUNK + 1); expect(deals[0].owner_id).toBe(OWNER); expect(logger.warn).toHaveBeenCalledWith( expect.stringContaining('failed for crm_lead'), - expect.objectContaining({ object: 'crm_lead', claimed: PAGE_ROWS, error: 'a code hook refused the write' }), + expect.objectContaining({ object: 'crm_lead', ids: CHUNK, error: 'a code hook refused the write' }), ); }); }); diff --git a/packages/plugins/organizations/src/claim-org-seed-ownership.ts b/packages/plugins/organizations/src/claim-org-seed-ownership.ts index d60e2ef023e..9557b70aaa7 100644 --- a/packages/plugins/organizations/src/claim-org-seed-ownership.ts +++ b/packages/plugins/organizations/src/claim-org-seed-ownership.ts @@ -20,6 +20,25 @@ * touches another tenant's rows. Idempotent: only NULL-owned rows are updated. * `managedBy` and `sys_*` tables are skipped. * + * ## [#23354] Only the rows the org's seed replay wrote (ADR-0138 D1) + * + * A NULL owner is not a seed row's mark: a guest-written row and a public + * form's intake keep no owner on purpose (ADR-0138 D1 item 5), and no stored + * column tells them apart. So this re-owns a NULL-owner row only when the + * organization's per-org seed replay reported landing on it — the + * `seed-settlement` contract's `seededRecordIds(object, organizationId)`, bound + * by the caller ({@link ClaimOwnershipOptions.seededRecordIds}): the predicate + * is `organization_id = org AND owner_id IS NULL AND id IN (seeded ids)`, one + * per chunk of {@link CLAIM_CHUNK_IDS} ids. It is the selector its single-tenant + * twin `claimSeedOwnership` takes. Without a reading it re-owns nothing. + * + * The reading covers the replay that ran in THIS process, and the host keeps + * only the most recent one. That is the shape of the call: the owner bind runs + * in the same call that created the organization, right after its replay. An + * organization that existed before this process (a bind retried on a later + * boot) has no reading, and its NULL-owner rows keep no owner — the record's + * empty state, never a guest's row handed over. + * * ## [#23008] The claim dispatches no automation * * Re-owning a seed row is attribution, the step that completes the seed, not a @@ -35,7 +54,7 @@ * * The real-engine half of this is `claim-org-seed-ownership-dispatch.pin.test.ts`. * - * ## [#23054] PAGED predicate writes, until no owner-less row is left + * ## [#23054] Predicate writes, until no seeded owner-less row is left * * This used to read each object's owner-less ids ONCE, at `limit: 10_000`, and * then issue one single-id `update` per id. Nothing re-read, so on an object @@ -46,46 +65,32 @@ * `ObjectQL` over `SqliteWasmDriver`: 10 500 owner-less rows, one pass, 10 000 * owned, 500 owner-less for good. * - * It now takes the shape its single-tenant twin `claimSeedOwnership` took in - * #14530 and #14719, measured to fit here before it was adopted (the doors are - * the two `OrgScopingEngine` already names, and the engine carries - * `skipAutomations` onto every per-row context of a predicate write): - * - * - **One predicate write for the whole set.** `owner_id = ownerUserId` where - * `{ organization_id: organizationId, owner_id: null }`, as `multi: true`. - * The predicate is the claim's whole selection rule, so the write matches - * exactly the rows the old read selected, without the read and without a - * cap. That is the path every organization small enough for it takes, and - * it issues no read at all. - * - **A page off the top when the engine refuses that write.** A predicate - * write fires `beforeUpdate`/`afterUpdate` per matched row (ADR-0058, - * bulk-write addendum D6), and above {@link MAX_BULK_PER_ROW_HOOK_ROWS} - * matched rows the engine refuses it WHOLE, nothing written. Every object - * carries such hooks in practice (objectql's own audit stamp is registered - * on `'*'`, and `skipAutomations` leaves code-registered hooks running), so - * an object past the ceiling is refused on exactly the size this card is - * about. The refusal is answered by reading one page of ids - * ({@link CLAIM_PAGE_ROWS}) and re-owning that page, then trying the whole - * set again; each page shrinks what is left until one write carries it. - * - * The page write keeps the whole predicate and ANDs `id IN (page)` onto it, - * rather than writing the page by id alone as the twin does: between the page - * read and its write a row can be claimed by someone else, and this function's - * two promises are "only owner-less rows" and "only this organization's rows". - * A page write narrowed to nothing by that race re-owns 0 rows, which stops the - * pass loudly rather than re-reading the same page forever. + * Every write is now a predicate write, `multi: true`, over one chunk of the + * seeded ids: `{ organization_id, owner_id: null, id: { $in: chunk } }`. The + * predicate is the claim's whole selection rule, so the write matches exactly + * the rows it selects, with no read; every chunk is written, so no seeded row + * is left past any cap. A predicate write fires `beforeUpdate`/`afterUpdate` + * per matched row (ADR-0058, bulk-write addendum D6), and the engine refuses + * one WHOLE above {@link MAX_BULK_PER_ROW_HOOK_ROWS} matched rows — so a chunk + * holds at most {@link CLAIM_CHUNK_IDS} ids, half that ceiling, and can never + * match enough rows to be refused. (#23054 answered that refusal with a page + * read and a retry; a write whose `IN` list is bounded below the ceiling has + * none to answer, so the paging went with the unbounded predicate.) Half, not + * all of it, because the `IN` list binds one parameter per id and a driver's + * bound-parameter limit is a question about statement width. * * `count` in the result is the SUM of the affected-row counts every write * resolved, never a length this function counted for itself. * - * ⚠️ The unit of failure moved from the row to the write. A per-id loop lost - * only the row whose write threw; a predicate write that a hook or a - * validation rule refuses writes nothing, so the whole object (or the page) - * stays owner-less and the warning says so. The twin made the same trade. + * ⚠️ The unit of failure is the write. A per-id loop lost only the row whose + * write threw; a predicate write that a hook or a validation rule refuses + * writes nothing, so that chunk stays owner-less and the warning says so. The + * next chunk and the next object are still claimed. The twin made the same + * trade. */ import type { ServiceObject } from '@objectstack/spec/data'; -import { BULK_PER_ROW_HOOK_LIMIT_ERROR_CODE, MAX_BULK_PER_ROW_HOOK_ROWS } from '@objectstack/spec/data'; +import { MAX_BULK_PER_ROW_HOOK_ROWS } from '@objectstack/spec/data'; import type { OrgScopingEngine } from './org-scoping-engine.js'; @@ -94,20 +99,16 @@ interface ClaimOwnershipOptions { info: (message: string, meta?: Record) => void; warn: (message: string, meta?: Record) => void; }; + /** + * [#23354] The ids of the rows this organization's seed replay landed on, + * per object — the `seed-settlement` contract's `seededRecordIds`, already + * bound to the organization by the caller. It is this claim's whole + * selection: a NULL-owner row it does not name keeps no owner (ADR-0138 D1). + * `undefined` means no host publishes a reading, and nothing is re-owned. + */ + seededRecordIds?: ((objectName: string) => readonly string[]) | undefined; } -/** - * The context of the claim's page READ (`readPage`, the over-ceiling fallback - * only): system-elevated, nothing more. - * - * The flag stays off the read on purpose. A read starts no record-change flow, - * and a metadata-bound `beforeFind` hook is an app's read-filter seam: keeping - * it in force keeps a page equal to what every other system read of that - * object sees. The selection itself is the WRITE's predicate, so a page only - * decides which rows go first, never which rows are claimed. - */ -const SYSTEM_CTX = { isSystem: true }; - /** * The context of the claim's WRITE (`reown`): system-elevated, with * metadata-bound automation off. See "The claim dispatches no automation" @@ -118,48 +119,26 @@ const SYSTEM_CTX = { isSystem: true }; * (`ExecutionContext`, `@objectstack/spec/kernel`), and `skipTriggers` alone * would leave the app's metadata hooks firing on every claimed row. * - * A plain literal, like {@link SYSTEM_CTX}: `pnpm check:tenant-audit-census` - * reads `isSystem` off it statically and does not see through a `satisfies` - * wrapper. + * A plain literal: `pnpm check:tenant-audit-census` reads `isSystem` off it + * statically and does not see through a `satisfies` wrapper. * * It does not move the per-row hook ceiling: the engine counts the matched rows * whenever ANY hook covers the object, without consulting the flag, and - * code-registered hooks still run per row under it. + * code-registered hooks still run per row under it — which is why a chunk is + * bounded below that ceiling ({@link CLAIM_CHUNK_IDS}). */ const CLAIM_WRITE_CTX = { isSystem: true, skipAutomations: true }; /** - * Rows one fallback page takes off the top of an over-ceiling predicate. + * Seeded ids one claim write carries in its `IN` list. * * Derived from the engine's per-row hook ceiling, never a free literal, so it - * moves with the number that refuses the whole-set write. Half of it is the - * margin the twin measured: the page write sends an `id IN (…)` list, and a - * driver's bound-parameter limit is a question about statement width, not - * hook fan-out. + * moves with the number that would refuse the write: a chunk of this many ids + * matches at most this many rows, half the ceiling, so the engine never + * refuses a claim write for its size. The other half is the margin for a + * driver's bound-parameter limit, which the `IN` list spends one per id. */ -const CLAIM_PAGE_ROWS = Math.floor(MAX_BULK_PER_ROW_HOOK_ROWS / 2); - -/** - * Fallback pages one object may take before this function gives up on it. - * - * Termination does not depend on this: a page that re-owns rows makes them stop - * matching the predicate, so what is left strictly shrinks, and a page that - * re-owns none stops the pass. The belt is for a driver that reports an - * affected count for rows it did not write, where the alternative is a - * bootstrap that never finishes. Hitting it is reported, never silent. - */ -const MAX_CLAIM_PAGES = 1_000; - -/** - * Is this the engine's per-row hook budget refusal (ADR-0058 D6)? - * - * The code is imported from the contract that defines it, so a rename breaks - * the build instead of quietly turning the paged fallback off. Every other - * failure is somebody else's and is rethrown. - */ -function isPerRowHookBudgetRefusal(e: unknown): boolean { - return (e as { code?: unknown } | null)?.code === BULK_PER_ROW_HOOK_LIMIT_ERROR_CODE; -} +const CLAIM_CHUNK_IDS = Math.floor(MAX_BULK_PER_ROW_HOOK_ROWS / 2); /** * The affected-row count a predicate write resolved, or `undefined` when the @@ -175,110 +154,42 @@ function affectedRowCount(value: unknown): number | undefined { return value; } -/** Ids from a `find` result: `ObjectQL.find` resolves a bare array. */ -function idsFrom(rows: unknown): string[] { - const out: string[] = []; - if (!Array.isArray(rows)) return out; - for (const r of rows) if (r?.id) out.push(String(r.id)); - return out; -} - -/** - * The two engine calls the claim makes on ONE object, already bound to it. - * - * Bound by the caller, where `schema.name` is a literal argument at the call - * site: `pnpm check:tenant-audit-census` records a write whose object argument - * is a parameter as `undecidable`, so binding here keeps the census's row for - * this file what it was. - */ -interface ObjectClaimIo { - /** Re-own every row `where` matches, under {@link CLAIM_WRITE_CTX}; resolves the affected-row count. */ - reown: (where: Record) => Promise; - /** At most one page of ids the claim's predicate still matches. */ - readPage: () => Promise; -} - /** - * Re-own every row `unowned` matches: one write for the whole set, and a page - * off the top whenever the engine refuses that write for its per-row hook - * budget. Returns the sum of the affected-row counts every write resolved. + * Re-own the rows one chunk's predicate matches, in one write. Returns the + * affected-row count it resolved. * - * Never throws. Best-effort per object, as the per-id loop was: an object that - * cannot be claimed must not cost any later object its claim, and the pages it - * already re-owned stay counted, because those rows WERE written. + * Never throws. Best-effort per chunk, as the per-id loop was per row: a chunk + * that cannot be claimed must not cost a later chunk or a later object its + * claim. An unreadable count is warned as unknown and counts nothing, because + * this function never adds a number it cannot attest. */ -async function claimUnowned( - io: ObjectClaimIo, +async function claimChunk( + reown: (where: Record) => Promise, objectName: string, organizationId: string, - unowned: Record, + where: Record, logger: ClaimOwnershipOptions['logger'], ): Promise { - const at = { object: objectName, organizationId }; - let total = 0; + const ids = (where.id as { $in: readonly string[] }).$in.length; + const at = { object: objectName, organizationId, ids }; try { - for (let page = 0; page < MAX_CLAIM_PAGES; page += 1) { - try { - const whole = affectedRowCount(await io.reown(unowned)); - if (whole === undefined) { - logger?.warn?.( - `[org-scoping] claimOrgSeedOwnership could not read an affected-row count for ${objectName} ` + - `in org ${organizationId}: the rows were re-owned but this run cannot say how many`, - { ...at, page }, - ); - return total; - } - return total + whole; - } catch (e) { - if (!isPerRowHookBudgetRefusal(e)) throw e; - } - - // Refused whole: more rows than one write may fan per-row hooks over. - const ids = idsFrom(await io.readPage()); - if (ids.length === 0) { - logger?.warn?.( - `[org-scoping] claimOrgSeedOwnership could not page ${objectName} in org ${organizationId}: the write ` + - 'was refused as over-sized but the read found no owner-less row to page; those rows stay owner-less', - { ...at, page }, - ); - return total; - } - - const count = affectedRowCount(await io.reown({ ...unowned, id: { $in: ids } })); - if (count === undefined) { - logger?.warn?.( - `[org-scoping] claimOrgSeedOwnership could not read an affected-row count for a page of ${objectName} ` + - `in org ${organizationId}: the page was re-owned but this run cannot say how many, and paging stops here`, - { ...at, page }, - ); - return total; - } - if (count === 0) { - // The read found rows and the write moved none of them. Reading the - // same predicate again would return the same page forever, so stop, - // loudly: "could not claim these" is not "there was nothing here". - logger?.warn?.( - `[org-scoping] claimOrgSeedOwnership read ${ids.length} owner-less row(s) of ${objectName} ` + - `in org ${organizationId} but re-owned none of them; those rows stay owner-less`, - { ...at, page, matched: ids.length }, - ); - return total; - } - total += count; + const count = affectedRowCount(await reown(where)); + if (count === undefined) { + logger?.warn?.( + `[org-scoping] claimOrgSeedOwnership could not read an affected-row count for ${objectName} ` + + `in org ${organizationId}: the seeded rows were re-owned but this run cannot say how many`, + at, + ); + return 0; } - logger?.warn?.( - `[org-scoping] claimOrgSeedOwnership stopped after ${MAX_CLAIM_PAGES} page(s) on ${objectName} ` + - `in org ${organizationId}; owner-less rows may remain`, - { ...at, pages: MAX_CLAIM_PAGES }, - ); - return total; + return count; } catch (e) { logger?.warn?.( - `[org-scoping] claimOrgSeedOwnership failed for ${objectName} in org ${organizationId} after ` + - `re-owning ${total} row(s); its remaining owner-less rows stay owner-less`, - { ...at, claimed: total, error: (e as Error).message }, + `[org-scoping] claimOrgSeedOwnership failed for ${objectName} in org ${organizationId}: ` + + `the ${ids} seeded row(s) of this write stay owner-less`, + { ...at, error: (e as Error).message }, ); - return total; + return 0; } } @@ -296,11 +207,11 @@ function hasField(schema: ServiceObject, field: string): boolean { * (a) are not `managedBy` (skip sys_/auth/platform tables), * (b) are not `sys_*`-namespaced, * (c) declare BOTH `owner_id` and `organization_id`, - * and re-owns the org's unowned rows as `isSystem` with `skipAutomations`, in - * one predicate write per object, paging that write only when the engine - * refuses it for its per-row hook budget, until no owner-less row is left. - * Returns a per-object summary whose `count` is the sum of every write's - * affected-row count. + * (d) have rows the org's seed replay landed on ({@link ClaimOwnershipOptions.seededRecordIds}), + * and re-owns the org's unowned seeded rows as `isSystem` with + * `skipAutomations`, in one predicate write per chunk of + * {@link CLAIM_CHUNK_IDS} seeded ids. Returns a per-object summary whose + * `count` is the sum of every write's affected-row count. */ export async function claimOrgSeedOwnership( ql: OrgScopingEngine, @@ -310,7 +221,7 @@ export async function claimOrgSeedOwnership( ): Promise<{ object: string; count: number }[]> { const logger = options.logger; if (!organizationId || !ownerUserId) return []; - if (!ql || typeof ql.update !== 'function' || typeof ql.find !== 'function') return []; + if (!ql || typeof ql.update !== 'function') return []; const registry = ql.registry; if (!registry || typeof registry.getAllObjects !== 'function') { logger?.warn?.('[org-scoping] claimOrgSeedOwnership: registry unavailable'); @@ -319,6 +230,18 @@ export async function claimOrgSeedOwnership( const schemas: ServiceObject[] = registry.getAllObjects(); const results: { object: string; count: number }[] = []; + const seededRecordIds = options.seededRecordIds; + if (typeof seededRecordIds !== 'function') { + // Said once, because "handed 0" and "could not name the seed's rows" are + // different facts and only the first is silent. + logger?.info?.( + `[org-scoping] claimOrgSeedOwnership handed no row of org ${organizationId} to owner ${ownerUserId}: ` + + 'no seed host on this kernel publishes the rows its replay landed on, and an unowned row this ' + + 'claim cannot name as seeded keeps no owner (ADR-0138 D1)', + { organizationId }, + ); + return results; + } for (const schema of schemas) { if (!schema?.name) continue; @@ -326,24 +249,28 @@ export async function claimOrgSeedOwnership( if (schema.name.startsWith('sys_')) continue; // Both columns are required: owner_id to assign, organization_id to scope. if (!hasField(schema, 'owner_id') || !hasField(schema, 'organization_id')) continue; + const seededIds = seededRecordIds(schema.name); + if (seededIds.length === 0) continue; - // Bound HERE, where `schema.name` is a literal argument at the call site; - // see {@link ObjectClaimIo}. - const unowned = { organization_id: organizationId, owner_id: null }; - const io: ObjectClaimIo = { - reown: (where) => ql.update( - schema.name, - { owner_id: ownerUserId }, - { where, multi: true, context: CLAIM_WRITE_CTX }, - ), - readPage: () => ql.find( - schema.name, - { where: unowned, limit: CLAIM_PAGE_ROWS, fields: ['id'] }, - { context: SYSTEM_CTX }, - ), - }; + // Bound HERE, where `schema.name` is a literal argument at the call site: + // `pnpm check:tenant-audit-census` records a write whose object argument is + // a parameter as `undecidable`, so binding here keeps the census's row for + // this file what it was. + const reown = (where: Record) => ql.update( + schema.name, + { owner_id: ownerUserId }, + { where, multi: true, context: CLAIM_WRITE_CTX }, + ); - const updated = await claimUnowned(io, schema.name, organizationId, unowned, logger); + let updated = 0; + for (let at = 0; at < seededIds.length; at += CLAIM_CHUNK_IDS) { + const where = { + organization_id: organizationId, + owner_id: null, + id: { $in: seededIds.slice(at, at + CLAIM_CHUNK_IDS) }, + }; + updated += await claimChunk(reown, schema.name, organizationId, where, logger); + } if (updated > 0) results.push({ object: schema.name, count: updated }); } diff --git a/packages/plugins/organizations/src/ensure-default-organization.ts b/packages/plugins/organizations/src/ensure-default-organization.ts index a3a21122f3a..b6908700a54 100644 --- a/packages/plugins/organizations/src/ensure-default-organization.ts +++ b/packages/plugins/organizations/src/ensure-default-organization.ts @@ -31,6 +31,12 @@ export type { EnsureDefaultOrganizationResult }; * then hand the org's seeded rows to them. Idempotent (stable slug * `default` + the admin's existing memberships short-circuit). * + * [#23354] The handoff re-owns only rows the org's seed replay reported + * landing on, read from the kernel's `seed-settlement` service. This wrapper + * holds no kernel, so it has no such reading and hands over no row: an + * unowned row it cannot name as seeded keeps no owner (ADR-0138 D1). + * `OrganizationsPlugin` binds the reading for the gated bootstrap below. + * * @deprecated Use `createEnsureDefaultOrganizationOnce({ claimSeedOwnership: * claimOrgSeedOwnership })` from `@objectstack/plugin-auth`. Called directly, * this re-binds an owner whose membership was removed and re-runs the seed diff --git a/packages/plugins/organizations/src/organizations-plugin.ts b/packages/plugins/organizations/src/organizations-plugin.ts index 85d3d597242..dbb1d2cc182 100644 --- a/packages/plugins/organizations/src/organizations-plugin.ts +++ b/packages/plugins/organizations/src/organizations-plugin.ts @@ -5,6 +5,7 @@ import { claimOrphanOrgRows } from './claim-orphan-org-rows.js'; import type { OrgScopingEngine } from './org-scoping-engine.js'; import { createEnsureDefaultOrganizationOnce, isDefaultOrganizationBootstrapTrigger } from '@objectstack/plugin-auth'; import { claimOrgSeedOwnership } from './claim-org-seed-ownership.js'; +import { SEED_SETTLEMENT_SERVICE, type ISeedSettlementService } from '@objectstack/spec/contracts'; import { assertWalledMembershipPolicyDeclared } from './membership-policy-gate.js'; import { organizationsObjects, @@ -22,6 +23,26 @@ import { * tests that register plain values. Returns `undefined` when the service is * not registered either way — callers decide how loudly that matters. */ +/** + * [#23354] The rows an organization's seed replay landed on, per object — + * read through the published `seed-settlement` contract, bound to the + * organization the owner bind is claiming for. `undefined` when no host + * publishes the reading; `claimOrgSeedOwnership` then re-owns nothing, because + * a NULL-owner row it cannot name as seeded keeps no owner (ADR-0138 D1). + */ +function readSeededRecordIds( + ctx: PluginContext, + organizationId: string, +): ((objectName: string) => readonly string[]) | undefined { + try { + const svc = (ctx as any).getService?.(SEED_SETTLEMENT_SERVICE) as ISeedSettlementService | undefined; + if (!svc || typeof svc.seededRecordIds !== 'function') return undefined; + return (objectName: string) => svc.seededRecordIds!(objectName, organizationId); + } catch { + return undefined; + } +} + async function resolveKernelService(kernel: any, name: string): Promise { if (typeof kernel?.getServiceAsync === 'function') { try { @@ -485,7 +506,14 @@ export class OrganizationsPlugin implements Plugin { // binding anyone, so a removed owner's membership stays removed. const ensureOnce = createEnsureDefaultOrganizationOnce({ logger: ctx.logger, - claimSeedOwnership: claimOrgSeedOwnership, + // [#23354] The claim re-owns only the rows the organization's seed + // replay landed on — which ran moments earlier, inside the + // `sys_organization` insert that created it (Middleware B above). + claimSeedOwnership: (engine, organizationId, ownerUserId, claimOptions) => + claimOrgSeedOwnership(engine, organizationId, ownerUserId, { + ...claimOptions, + seededRecordIds: readSeededRecordIds(ctx, organizationId), + }), }); const runEnsure = async () => { try { diff --git a/packages/plugins/organizations/vitest.config.ts b/packages/plugins/organizations/vitest.config.ts index c37b1e59edc..0f4c6ed08a9 100644 --- a/packages/plugins/organizations/vitest.config.ts +++ b/packages/plugins/organizations/vitest.config.ts @@ -112,6 +112,10 @@ export default defineConfig({ find: /^@objectstack\/spec\/data$/, replacement: path.resolve(__dirname, '../../spec/src/data/index.ts'), }, + { + find: /^@objectstack\/spec\/contracts$/, + replacement: path.resolve(__dirname, '../../spec/src/contracts/index.ts'), + }, ], }, test: { diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin-authenticable-target.test.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin-authenticable-target.test.ts index 79634affc8f..358de73ab5c 100644 --- a/packages/plugins/plugin-security/src/bootstrap-platform-admin-authenticable-target.test.ts +++ b/packages/plugins/plugin-security/src/bootstrap-platform-admin-authenticable-target.test.ts @@ -216,12 +216,19 @@ describe('#14348 — the promotion target is the oldest human that can AUTHENTIC // Seeded business rows carry no owner — the state `claimSeedOwnership` // re-owns. This is the second half of the harm: a wrong promotion target // also mis-assigns every seeded record. + const seededIds: string[] = []; for (const name of ['Seeded Task A', 'Seeded Task B']) { - await (engine as any).insert('demo_task', { name }, { context: SYSTEM_CTX }); + const row = await (engine as any).insert('demo_task', { name }, { context: SYSTEM_CTX }); + seededIds.push(String(row.id)); } const { logger } = collectingLogger(); - const report = await bootstrapPlatformAdmin(engine as any, defaultPermissionSets, { logger }); + // [#23354] The seed reports the rows it landed on; the promotion hands + // over those (ADR-0138 D1), read through the `seed-settlement` contract. + const report = await bootstrapPlatformAdmin(engine as any, defaultPermissionSets, { + logger, + seededRecordIds: (object: string) => (object === 'demo_task' ? seededIds : []), + }); expect(report.adminPromoted).toBe(true); expect(report.ownershipClaimed).toBe(2); diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts index b8141cc432b..d1677dac6d5 100644 --- a/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts +++ b/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts @@ -234,6 +234,16 @@ interface BootstrapOptions { * which the claim reports as `unattested` rather than guessing. */ seedSettlement?: SeedSettlementSnapshot | undefined; + /** + * [#23354] The rows this process's boot seed landed on, per object — the + * `seed-settlement` contract's `seededRecordIds`, bound by the caller. + * + * Handed straight to {@link claimSeedOwnership} and used for nothing else: it + * is the claim's NULL-arm selection, so the promotion hands over the seed's + * unowned rows and leaves a guest's or a form's as their writer left them + * (ADR-0138 D1). Absent, the claim re-owns no NULL-owner row and says so. + */ + seededRecordIds?: ((objectName: string) => readonly string[]) | undefined; } const SYSTEM_CTX = { isSystem: true }; @@ -1031,8 +1041,9 @@ export async function bootstrapPlatformAdmin( { basis: audit.basis, candidatePoolSize: audit.candidatePoolSize, userId: String(chosen.id) }, ); - // Hand seeded business records (owner_id NULL / usr_system) to the freshly - // promoted admin so owner-keyed UX works out of the box. Best-effort and + // Hand seeded business records (the seed's NULL-owner rows, and usr_system + // rows) to the freshly promoted admin so owner-keyed UX works out of the + // box — and no other unowned row (ADR-0138 D1). Best-effort and // idempotent — failures here must not undo the promotion above. // // ⚠️ This pass is NOT the last word, and does not pretend to be. The @@ -1046,6 +1057,7 @@ export async function bootstrapPlatformAdmin( const claims = await claimSeedOwnership(ql, chosen.id, { logger, seedSettlement: options.seedSettlement, + seededRecordIds: options.seededRecordIds, }); ownershipClaimed = claims.reduce((sum, c) => sum + c.count, 0); } catch (e) { diff --git a/packages/plugins/plugin-security/src/claim-seed-ownership-dispatch.pin.test.ts b/packages/plugins/plugin-security/src/claim-seed-ownership-dispatch.pin.test.ts index 618d4a8cc8b..0fc9ba7ae93 100644 --- a/packages/plugins/plugin-security/src/claim-seed-ownership-dispatch.pin.test.ts +++ b/packages/plugins/plugin-security/src/claim-seed-ownership-dispatch.pin.test.ts @@ -35,6 +35,13 @@ * `{ isSystem: true }` predicate write over the same rows fires the metadata * hook per row. Without it, "the hook fired 0 times" could be a hook that was * never bound. + * + * [#23354] The NULL arm re-owns only the rows the seed reported landing on, as + * `owner_id IS NULL AND id IN (chunk)`, one write per chunk of half the + * ceiling. So its writes are under the ceiling by construction, and the + * over-ceiling case is driven through the seed-identity arm, which is still + * one predicate over every such row. The chunked write is measured here on the + * real driver too: an `IN` list that size binds one parameter per id. */ import { describe, it, expect, afterEach } from 'vitest'; @@ -52,6 +59,9 @@ const ADMIN = 'usr_admin_human'; const SEED_IDENTITY = 'usr_system'; const OBJECT = 'probe_deal'; +/** [#23354] The seed's report of the rows it landed on: every id given. */ +const seededAre = (ids: string[]) => (object: string): readonly string[] => (object === OBJECT ? ids : []); + const PROBE_OBJECT: any = { name: OBJECT, label: 'Probe Deal', @@ -161,14 +171,15 @@ describe('[#22067] the seed ownership claim dispatches no metadata-bound automat it('the whole-set write: no metadata hook fires, code hooks fire per row with the flag and skipTriggers', async () => { const { engine, metadataHook, codeHook } = await boot(); const N = 40; - await seed(engine, Array.from({ length: N }, (_, i) => ({ + const rows = Array.from({ length: N }, (_, i) => ({ id: `d${i}`, name: `Deal ${i}`, stage: 'open', owner_id: i % 2 === 0 ? null : SEED_IDENTITY, - }))); + })); + await seed(engine, rows); - const result = await claimSeedOwnership(engine, ADMIN); + const result = await claimSeedOwnership(engine, ADMIN, { seededRecordIds: seededAre(rows.map((r) => r.id)) }); expect(result).toEqual([{ object: OBJECT, count: N }]); const owners = await ownersOf(engine); @@ -218,7 +229,7 @@ describe('[#22067] the seed ownership claim dispatches no metadata-bound automat id: `d${String(i).padStart(6, '0')}`, name: `Deal ${i}`, stage: 'open', - owner_id: null, + owner_id: SEED_IDENTITY, }))); // The measurement the fallback's page size rests on: with metadata hooks @@ -227,12 +238,12 @@ describe('[#22067] the seed ownership claim dispatches no metadata-bound automat // no hook run. await expect( (engine as any).update(OBJECT, { owner_id: ADMIN }, { - where: { owner_id: null }, + where: { owner_id: SEED_IDENTITY }, multi: true, context: { isSystem: true, skipAutomations: true }, }), ).rejects.toMatchObject({ code: BULK_PER_ROW_HOOK_LIMIT_ERROR_CODE, limit: MAX_BULK_PER_ROW_HOOK_ROWS }); - expect([...(await ownersOf(engine)).values()].every((o) => o === null)).toBe(true); + expect([...(await ownersOf(engine)).values()].every((o) => o === SEED_IDENTITY)).toBe(true); expect(codeHook).toEqual([]); const result = await claimSeedOwnership(engine, ADMIN); @@ -260,4 +271,30 @@ describe('[#22067] the seed ownership claim dispatches no metadata-bound automat }); expect(metadataHook).toHaveLength(6); }, 180_000); + + it('[#23354] the NULL arm in chunks of seeded ids: never refused, every seeded row claimed, an unreported row left alone', async () => { + const { engine, metadataHook, codeHook } = await boot(); + const N = MAX_BULK_PER_ROW_HOOK_ROWS + 500; + const seededRows = Array.from({ length: N }, (_, i) => ({ + id: `d${String(i).padStart(6, '0')}`, + name: `Deal ${i}`, + stage: 'open', + owner_id: null, + })); + // Unowned, and never reported by the seed: a guest's, a form's (ADR-0138 D1). + const unreported = { id: 'guest_row', name: 'Guest enquiry', stage: 'new', owner_id: null }; + await seed(engine, [...seededRows, unreported]); + + const result = await claimSeedOwnership(engine, ADMIN, { seededRecordIds: seededAre(seededRows.map((r) => r.id)) }); + + expect(result).toEqual([{ object: OBJECT, count: N }]); + const owners = await ownersOf(engine); + expect(owners.get('guest_row')).toBeNull(); + expect([...owners.entries()].filter(([id]) => id !== 'guest_row').every(([, o]) => o === ADMIN)).toBe(true); + // Every matched row went through the per-row path under the flag, and no + // write was refused whole: one afterUpdate per seeded row, none for the + // unreported one. + expect(codeHook.filter((s) => s.event === 'afterUpdate')).toHaveLength(N); + expect(metadataHook).toEqual([]); + }, 180_000); }); diff --git a/packages/plugins/plugin-security/src/claim-seed-ownership-seed-settle-rerun.test.ts b/packages/plugins/plugin-security/src/claim-seed-ownership-seed-settle-rerun.test.ts index afee98f3087..f1f4fb69542 100644 --- a/packages/plugins/plugin-security/src/claim-seed-ownership-seed-settle-rerun.test.ts +++ b/packages/plugins/plugin-security/src/claim-seed-ownership-seed-settle-rerun.test.ts @@ -142,15 +142,29 @@ function makeEngine(tables: Record, schemas: any[]) { return engine; } -/** A seed-settlement tracker whose tally the test drives by hand. */ +/** + * A seed-settlement tracker whose tally the test drives by hand. + * + * [#23354] It also carries the contract's `seededRecordIds`: the claim + * re-owns only the NULL-owner rows the seed reported landing on, so the test + * publishes each seed row it lands (`publish`), exactly as the runtime host + * publishes the loader's `landedIds` before the source settles. + */ function makeSettlement(initialInFlight: number) { let inFlight = initialInFlight; + const seeded = new Map>(); return { settleOne: () => { inFlight = Math.max(0, inFlight - 1); }, + publish: (object: string, ...ids: string[]) => { + const set = seeded.get(object) ?? new Set(); + for (const id of ids) set.add(id); + seeded.set(object, set); + }, service: { snapshot: (): SeedSettlementSnapshot => ({ pending: inFlight, inFlight, suppressed: [] }), + seededRecordIds: (object: string): readonly string[] => [...(seeded.get(object) ?? [])], }, }; } @@ -172,10 +186,10 @@ const seedRow = (id: string) => ({ id, owner_id: null }); * Boot `SecurityPlugin` over the double with one promotable human, one object * whose seed rows have already landed, and one whose rows land later. */ -async function bootPlugin() { +async function bootPlugin(existing?: Record) { const settlement = makeSettlement(1); const schemas = [businessObject('crm_account'), businessObject('crm_contract')]; - const tables: Record = { + const tables: Record = existing ?? { // The seeder's identity row plus one human who can authenticate — the // promotion target. sys_user: [ @@ -190,6 +204,9 @@ async function bootPlugin() { // LOSER: the object whose rows the background seed has not reached yet. crm_contract: [], }; + // The seed reports the rows it landed on; a replay on a later boot reports + // the same rows again (the loader records a key it matched). + settlement.publish('crm_account', 'acc_seed_1', 'acc_seed_2'); const engine = makeEngine(tables, schemas); const hooks: Array<[string, (...a: any[]) => any]> = []; const services: Record = { @@ -219,7 +236,12 @@ async function bootPlugin() { return matching.length; }; const ownerOf = (object: string) => (tables[object] ?? []).map((r) => r.owner_id); - return { tables, logger, fire, ownerOf, settlement }; + /** The background seed lands rows, and publishes them, as the host does. */ + const landSeedRows = (object: string, ...rows: any[]) => { + (tables[object] ??= []).push(...rows); + settlement.publish(object, ...rows.map((r) => r.id)); + }; + return { tables, logger, fire, ownerOf, settlement, landSeedRows }; } describe('seed-ownership claim — the one-shot pass and the seed it races', () => { @@ -236,7 +258,7 @@ describe('seed-ownership claim — the one-shot pass and the seed it races', () expect(rig.ownerOf('crm_account')).toEqual([ADMIN, ADMIN]); // ── The background seed lands its remaining rows, past kernel start ───── - rig.tables.crm_contract.push(seedRow('con_1'), seedRow('con_2'), seedRow('con_3')); + rig.landSeedRows('crm_contract', seedRow('con_1'), seedRow('con_2'), seedRow('con_3')); // Ownerless at this instant — this is the defect's own moment, measured // rather than asserted away: the one-shot pass walked `crm_contract` before // these rows existed and nothing re-reads it. @@ -260,7 +282,8 @@ describe('seed-ownership claim — the one-shot pass and the seed it races', () // DIFFERENT human. The third is the permission boundary this repair must // not move — re-owning it would be a change of who owns a row beyond the // rows the one-shot pass missed. - rig.tables.crm_contract.push( + rig.landSeedRows( + 'crm_contract', { id: 'con_null', owner_id: null }, { id: 'con_system', owner_id: SYSTEM }, { id: 'con_other', owner_id: 'usr_someone_else' }, @@ -287,7 +310,7 @@ describe('seed-ownership claim — the one-shot pass and the seed it races', () rig.tables.sys_permission_set.push({ id: 'ps_admin_full_access', name: 'admin_full_access' }); await rig.fire('kernel:ready'); - rig.tables.crm_contract.push(seedRow('con_late')); + rig.landSeedRows('crm_contract', seedRow('con_late')); rig.settlement.settleOne(); await rig.fire('app:seeded', { appId: 'com.example.crm', overBudget: true }); @@ -302,7 +325,7 @@ describe('seed-ownership claim — the one-shot pass and the seed it races', () rig.tables.sys_user = []; rig.tables.sys_account = []; await rig.fire('kernel:ready'); - rig.tables.crm_contract.push(seedRow('con_1')); + rig.landSeedRows('crm_contract', seedRow('con_1')); rig.settlement.settleOne(); await rig.fire('app:seeded', { appId: 'com.example.crm', overBudget: false }); @@ -320,7 +343,9 @@ async function runClaim(rows: any[], seedSettlement: SeedSettlementSnapshot | un const schemas = [businessObject('crm_contract')]; const engine = makeEngine({ crm_contract: rows }, schemas); const logger = { info: vi.fn(), warn: vi.fn() }; - const results = await claimSeedOwnership(engine, ADMIN, { logger, seedSettlement }); + // Every row here is one the seed wrote, and the seed says so. + const seededRecordIds = (object: string) => (object === 'crm_contract' ? rows.map((r) => r.id) : []); + const results = await claimSeedOwnership(engine, ADMIN, { logger, seedSettlement, seededRecordIds }); return { results, logger }; } @@ -405,3 +430,52 @@ describe('seed-ownership claim — "claimed 0 of 0" versus "nothing to claim"', expect(meta).toMatchObject({ claimed: 2, eligibleObjects: 1 }); }); }); + +// ─────────────────────────────────────────────────────────────────────────── +// [#23354] ADR-0138 D1 — the rows the seed did not write, across two boots +// ─────────────────────────────────────────────────────────────────────────── + +describe('seed-ownership claim — a guest\'s and a form\'s unowned rows keep no owner (#23354)', () => { + /** + * ADR-0138 D1 item 5: "Empty state: the owner stays unset. When nothing in + * the scenario sets it, a guest-written row keeps no owner, exactly as the + * public form doors' intake does today; hooks and flows may set it." + * + * The claim runs on EVERY `app:seeded` — every boot of an app with seed data + * — so a row it wrongly re-owns is re-owned at the very next boot. Driven + * through the real plugin, over one table set, across two boots: the second + * boot is a fresh plugin and a fresh tracker whose replay re-reports the + * seed's rows, as the loader's matched-key record does. + */ + it('a seed row is claimed; a guest row and a form row keep no owner on either boot; a hook-set owner stands', async () => { + // ── Boot 1: the seed has landed; then a guest, a form and a hook write ── + const boot1 = await bootPlugin(); + boot1.tables.crm_contract.push( + { id: 'guest_row', owner_id: null }, // the guest door left it unset (E3) + { id: 'form_row', owner_id: null }, // a public form's intake + { id: 'hook_row', owner_id: 'usr_hook_set' }, // a hook set the scenario's owner + ); + await boot1.fire('kernel:ready'); + boot1.settlement.settleOne(); + await boot1.fire('app:seeded', { appId: 'com.example.crm', overBudget: false }); + + // (a) the seed rows are claimed — the positive control for everything below. + expect(boot1.ownerOf('crm_account')).toEqual([ADMIN, ADMIN]); + // (b) and (c) + expect(boot1.ownerOf('crm_contract')).toEqual([null, null, 'usr_hook_set']); + + // ── Boot 2: same tables, an admin already holds the grant; the seed replays ── + const boot2 = await bootPlugin(boot1.tables); + // A seed row an earlier boot wrote and nobody owned yet (say the admin + // appeared after boot 1's claim): the replay reports it, so it is claimed. + boot1.tables.crm_account.push(seedRow('acc_seed_3')); + boot2.settlement.publish('crm_account', 'acc_seed_3'); + await boot2.fire('kernel:ready'); + boot2.settlement.settleOne(); + await boot2.fire('app:seeded', { appId: 'com.example.crm', overBudget: false }); + + expect(boot2.ownerOf('crm_account')).toEqual([ADMIN, ADMIN, ADMIN]); + expect(boot2.ownerOf('crm_contract')).toEqual([null, null, 'usr_hook_set']); + }); +}); + 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 index 05756224b36..9e02809c1ad 100644 --- 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 @@ -133,6 +133,11 @@ async function ensureDefaultOrganization(engine: any): Promise { */ function securityProcess(engine: any) { let inFlight = 1; + // [#23354] The rows this process's seed reported landing on — what the + // runtime host publishes from the loader's `landedIds` before it settles. A + // replay reports the rows an earlier boot wrote as well as the ones it + // writes, so a warm boot publishes every seed row it matched. + const seeded = new Set(); const hooks: Array<[string, (...a: any[]) => any]> = []; const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; const services: Record = { @@ -141,6 +146,7 @@ function securityProcess(engine: any) { metadata: { get: async () => null, list: async () => [] }, [SEED_SETTLEMENT_SERVICE]: { snapshot: () => ({ pending: inFlight, inFlight, suppressed: [] }), + seededRecordIds: (object: string) => (object === 'crm_case' ? [...seeded] : []), }, }; const ctx: any = { @@ -163,6 +169,15 @@ function securityProcess(engine: any) { fire, init: () => plugin.init(ctx), start: () => plugin.start(ctx), + /** The seed writes a row, and reports it. */ + seed: async (id: string) => { + await seedCase(engine, id); + seeded.add(id); + }, + /** A replay matched rows an earlier boot wrote, and reports them. */ + replayed: (...ids: string[]) => { + for (const id of ids) seeded.add(id); + }, /** The runtime settles the source BEFORE it triggers `app:seeded`. */ seedSettles: () => { inFlight = 0; @@ -205,7 +220,7 @@ async function firstBoot(file: string) { const proc = securityProcess(engine); await proc.init(); await proc.start(); - for (const id of ['c1', 'c2', 'c3']) await seedCase(engine, id); + for (const id of ['c1', 'c2', 'c3']) await proc.seed(id); await proc.seedSettles(); // No admin yet: the settle claims nothing and says nothing. expect(proc.claimReports()).toEqual([]); @@ -224,11 +239,14 @@ async function firstBoot(file: string) { 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); + // [#23354] A row the seed never wrote, left unowned on purpose — a guest's or + // a public form's (ADR-0138 D1). No warm boot may hand it over. + await engine.insert('crm_case', { id: 'g1', name: 'g1', owner_id: null }, 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 }; +const SETTLED = { c1: ADMIN, c2: ADMIN, c3: ADMIN, c4: OTHER, g1: null }; 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 () => { @@ -240,7 +258,8 @@ describe('seed-ownership claim on a WARM boot — an admin exists, the seed repl await proc.init(); await proc.start(); // AppPlugin.start(): the replay re-inserts the deleted row, then settles. - await seedCase(engine, 'c2'); + proc.replayed('c1', 'c3'); + await proc.seed('c2'); expect((await owners(engine)).c2).toBeNull(); await proc.seedSettles(); @@ -264,7 +283,8 @@ describe('seed-ownership claim on a WARM boot — an admin exists, the seed repl await proc.init(); // AppPlugin.start() runs first: replay, settle — this plugin's start() has // not run yet. - await seedCase(engine, 'c2'); + proc.replayed('c1', 'c3'); + await proc.seed('c2'); const heard = await proc.seedSettles(); expect(heard).toBe(1); expect(await owners(engine)).toEqual(SETTLED); @@ -284,7 +304,8 @@ describe('seed-ownership claim on a WARM boot — an admin exists, the seed repl await proc.init(); await proc.start(); await proc.fire('kernel:ready'); - await seedCase(engine, 'c2'); + proc.replayed('c1', 'c3'); + await proc.seed('c2'); await proc.seedSettles(); expect(await owners(engine)).toEqual(SETTLED); @@ -319,7 +340,7 @@ describe('the claim target is the existing platform admin, by the bootstrap rule const proc = securityProcess(engine); await proc.init(); await proc.start(); - await seedCase(engine, 'c1'); + await proc.seed('c1'); await proc.seedSettles(); expect(await owners(engine)).toEqual({ c1: 'usr_zed' }); diff --git a/packages/plugins/plugin-security/src/claim-seed-ownership.test.ts b/packages/plugins/plugin-security/src/claim-seed-ownership.test.ts index 36e7c9f28d5..1822e523e59 100644 --- a/packages/plugins/plugin-security/src/claim-seed-ownership.test.ts +++ b/packages/plugins/plugin-security/src/claim-seed-ownership.test.ts @@ -178,6 +178,19 @@ function unownedIds(rows: any[]): string[] { .map((r) => r.id); } +/** + * [#23354] The seed's own report of the rows it landed on, as the + * `seed-settlement` contract's `seededRecordIds` hands it to the claim. + */ +function seededIds(byObject: Record): (object: string) => readonly string[] { + return (object) => byObject[object] ?? []; +} + +/** The NULL-owner rows of a fixture: the rows a seed that wrote all of them reports. */ +function nullOwnerIds(rows: any[]): string[] { + return rows.filter((r) => (r.owner_id ?? null) === null).map((r) => r.id); +} + describe('claimSeedOwnership', () => { it('returns [] when registry is unavailable', async () => { const ql: any = { find: vi.fn(), update: vi.fn() }; @@ -240,7 +253,7 @@ describe('claimSeedOwnership', () => { { id: 'l3', owner_id: 'usr_someone' },// untouched (already human-owned) ]; const { ql, writes } = makeQL(schemas, { crm_lead: rows }); - const result = await claimSeedOwnership(ql, ADMIN); + const result = await claimSeedOwnership(ql, ADMIN, { seededRecordIds: seededIds({ crm_lead: ['l1'] }) }); expect(result).toEqual([{ object: 'crm_lead', count: 2 }]); expect(writes.flatMap((w) => w.matched).sort()).toEqual(['l1', 'l2']); @@ -252,8 +265,11 @@ describe('claimSeedOwnership', () => { it('is idempotent — a second run claims nothing', async () => { const schemas = [{ name: 'crm_lead', fields: [{ name: 'owner_id' }] }]; const { ql } = makeQL(schemas, { crm_lead: [{ id: 'l1', owner_id: null }] }); - await claimSeedOwnership(ql, ADMIN); - const second = await claimSeedOwnership(ql, ADMIN); + const seededRecordIds = seededIds({ crm_lead: ['l1'] }); + // Positive control: the first pass does claim, so the empty second pass + // reads as idempotence rather than as a claim that never ran. + expect(await claimSeedOwnership(ql, ADMIN, { seededRecordIds })).toEqual([{ object: 'crm_lead', count: 1 }]); + const second = await claimSeedOwnership(ql, ADMIN, { seededRecordIds }); expect(second).toEqual([]); }); @@ -269,26 +285,34 @@ describe('claimSeedOwnership', () => { owner_id: i % 2 === 0 ? null : SYSTEM, })); const { ql, writes } = makeQL(schemas, { crm_lead: rows }); + const seeded = nullOwnerIds(rows); - const result = await claimSeedOwnership(ql, ADMIN); + const result = await claimSeedOwnership(ql, ADMIN, { seededRecordIds: seededIds({ crm_lead: seeded }) }); expect(result).toEqual([{ object: 'crm_lead', count: 500 }]); expect(writes).toHaveLength(2); expect(writes.every((w) => w.multi)).toBe(true); // The set fits in one write per predicate, so the paged fallback never // engages and this pass issues NO read at all — the predicate write IS the - // scan. Paging unconditionally was measured 13x slower on these sizes. + // scan. expect(ql.find).not.toHaveBeenCalled(); - expect(writes.map((w) => w.where)).toEqual([{ owner_id: null }, { owner_id: SYSTEM }]); + // [#23354] The NULL arm carries the seed's ids; the seed identity arm does not. + expect(writes.map((w) => w.where)).toEqual([ + { owner_id: null, id: { $in: seeded } }, + { owner_id: SYSTEM }, + ]); // Every write carries the payload only — no `id` in `data`, which is what // routes the engine down `updateMany` instead of the single-id door. expect(writes.every((w) => Object.keys(w.data).join() === 'owner_id')).toBe(true); expect(rows.every((r) => r.owner_id === ADMIN)).toBe(true); }); - it('claims exactly the id set the pre-#14530 single-id loop would have claimed', async () => { + it('claims exactly the id set the pre-#14530 single-id loop would have claimed, among the seeded rows', async () => { // The equivalence pin. The predicate write must not widen or narrow the // matched set by one row: same fixture, same answer, computed two ways. + // [#23354] Every NULL-ish row here is one the seed reported, so the old + // rule's answer is still the whole expected set; the rows the seed did NOT + // report are pinned apart, below. const fixture = [ { id: 'a', owner_id: null }, // author left it unset { id: 'b', owner_id: SYSTEM }, // seed identity @@ -305,7 +329,11 @@ describe('claimSeedOwnership', () => { const rows = fixture.map((r) => ({ ...r })); const { ql, writes } = makeQL(schemas, { crm_lead: rows }); - const result = await claimSeedOwnership(ql, ADMIN); + // The seed reported every row it wrote, owned or not — the claim's own + // predicate is what narrows that to the unowned ones. + const result = await claimSeedOwnership(ql, ADMIN, { + seededRecordIds: seededIds({ crm_lead: fixture.map((r) => r.id) }), + }); expect(writes.flatMap((w) => w.matched).sort()).toEqual([...expected].sort()); expect(result).toEqual([{ object: 'crm_lead', count: expected.length }]); @@ -329,7 +357,9 @@ describe('claimSeedOwnership', () => { ]; const { ql, writes } = makeQL(schemas, { crm_lead: rows }); - expect(await claimSeedOwnership(ql, ADMIN)).toEqual([{ object: 'crm_lead', count: 3 }]); + expect( + await claimSeedOwnership(ql, ADMIN, { seededRecordIds: seededIds({ crm_lead: ['l1', 'l2', 'l3'] }) }), + ).toEqual([{ object: 'crm_lead', count: 3 }]); expect(writes[0].matched).toEqual(['l1', 'l2']); expect(writes[1].matched).toEqual(['l3']); }); @@ -346,7 +376,9 @@ describe('claimSeedOwnership', () => { return call === 1 ? 7 : 11; }), }; - expect(await claimSeedOwnership(ql, ADMIN)).toEqual([{ object: 'crm_lead', count: 18 }]); + expect( + await claimSeedOwnership(ql, ADMIN, { seededRecordIds: seededIds({ crm_lead: ['l0'] }) }), + ).toEqual([{ object: 'crm_lead', count: 18 }]); }); it('says "unknown" rather than 0 when a driver resolves something that is not a count', async () => { @@ -364,7 +396,12 @@ describe('claimSeedOwnership', () => { return { id: 'l1' }; // a record — this driver did not meet the contract }), }; - expect(await claimSeedOwnership(ql, ADMIN, { logger: { info: vi.fn(), warn } })).toEqual([]); + expect( + await claimSeedOwnership(ql, ADMIN, { + logger: { info: vi.fn(), warn }, + seededRecordIds: seededIds({ crm_lead: ['l1'] }), + }), + ).toEqual([]); expect(warn).toHaveBeenCalledTimes(2); // once per predicate expect(warn.mock.calls[0][0]).toContain('could not read an affected-row count'); expect(ql.update).toHaveBeenCalledTimes(2); // one attempt each, no spin @@ -391,7 +428,10 @@ describe('claimSeedOwnership', () => { }, ); - const result = await claimSeedOwnership(ql, ADMIN, { logger: { info: vi.fn(), warn } }); + const result = await claimSeedOwnership(ql, ADMIN, { + logger: { info: vi.fn(), warn }, + seededRecordIds: seededIds({ crm_lead: ['l1', 'l2'], crm_case: ['c1'] }), + }); expect(result).toEqual([ { object: 'crm_lead', count: 1 }, // the usr_system predicate still landed @@ -414,32 +454,37 @@ describe('claimSeedOwnership', () => { // (objectql's audit stamp is registered on '*'). Unpaged, this object was // measured claiming ZERO of 21 000 rows while the pre-#14530 loop claimed // 10 000 of them: a permission-outcome regression, since `owner_id` is a - // record-access field. Paged, the answer is all 21 000. + // record-access field. Paged, the answer is all of them. + // + // [#23354] The NULL arm now writes in chunks of seeded ids, each under the + // ceiling by construction, so the whole-set write the engine refuses is the + // seed-identity arm's: it is still one predicate over every such row. const schemas = [{ name: 'crm_lead', fields: [{ name: 'owner_id' }] }]; - // 21 000 rows: 3 000 already human-owned, 3 000 on the seed identity, and - // 15 000 with no owner at all — so the NULL predicate ALONE is over the - // ceiling, which is the shape one unpaged write cannot survive. + // 21 000 rows: 3 000 already human-owned, 12 000 on the seed identity — + // over the ceiling ALONE, the shape one unpaged write cannot survive — and + // 6 000 with no owner, every one of them reported by the seed. const seed = () => Array.from({ length: 21_000 }, (_, i) => ({ id: `l${i}`, - owner_id: i % 7 === 0 ? 'usr_someone' : i % 7 === 1 ? SYSTEM : null, + owner_id: i % 7 === 0 ? 'usr_someone' : i % 7 <= 4 ? SYSTEM : null, })); const rows = seed(); const everyUnowned = unownedIds(seed()); expect(everyUnowned).toHaveLength(18_000); - expect(rows.filter((r) => r.owner_id === null)).toHaveLength(15_000); - expect(15_000).toBeGreaterThan(MAX_BULK_PER_ROW_HOOK_ROWS); + expect(rows.filter((r) => r.owner_id === SYSTEM)).toHaveLength(12_000); + expect(12_000).toBeGreaterThan(MAX_BULK_PER_ROW_HOOK_ROWS); const { ql, writes, reads } = makeQL(schemas, { crm_lead: rows }); - const result = await claimSeedOwnership(ql, ADMIN); + const result = await claimSeedOwnership(ql, ADMIN, { + seededRecordIds: seededIds({ crm_lead: nullOwnerIds(seed()) }), + }); // Not one row short. expect(writes.flatMap((w) => w.matched).sort()).toEqual([...everyUnowned].sort()); expect(result).toEqual([{ object: 'crm_lead', count: everyUnowned.length }]); expect(rows.filter((r) => r.owner_id === ADMIN)).toHaveLength(everyUnowned.length); - // …and it strictly beats the pre-#14530 rule, which capped its own scans. - // The pre-#14530 rule capped each of its own scans at 10 000, so it could - // only ever have reached 13 000 of these 18 000 rows. - expect(legacyClaimedIds(seed())).toHaveLength(13_000); + // …and it strictly beats the pre-#14530 rule, which capped each of its own + // scans at 10 000, so it could only ever have reached 16 000 of these rows. + expect(legacyClaimedIds(seed())).toHaveLength(16_000); expect(everyUnowned.length).toBeGreaterThan(legacyClaimedIds(seed()).length); // The paged fallback DID engage — a read was issued, which the under-ceiling @@ -464,7 +509,9 @@ describe('claimSeedOwnership', () => { const rows = Array.from({ length: 12_000 }, (_, i) => ({ id: `l${i}`, owner_id: null })); const { ql, writes } = makeQL(schemas, { crm_lead: rows }); - const result = await claimSeedOwnership(ql, ADMIN); + const result = await claimSeedOwnership(ql, ADMIN, { + seededRecordIds: seededIds({ crm_lead: nullOwnerIds(rows) }), + }); expect(writes.length).toBeGreaterThan(1); const perWrite = writes.map((w) => w.matched.length); @@ -487,13 +534,20 @@ describe('claimSeedOwnership', () => { find: vi.fn(async () => [{ id: 'l1' }, { id: 'l2' }]), update: vi.fn(async (_o: string, data: any, options: any) => { assertEngineUpdateDispatch(data, options); - if ((options.where as any)?.id) return 0; // the page moved nothing + // A page write names ids and nothing else; every whole-set attempt + // (the NULL arm's carries its seeded ids too) names an owner. + if (!('owner_id' in (options.where as any))) return 0; // the page moved nothing throw Object.assign(new Error('over the ceiling'), { code: BULK_PER_ROW_HOOK_LIMIT_ERROR_CODE, }); }), }; - expect(await claimSeedOwnership(ql, ADMIN, { logger: { info: vi.fn(), warn } })).toEqual([]); + expect( + await claimSeedOwnership(ql, ADMIN, { + logger: { info: vi.fn(), warn }, + seededRecordIds: seededIds({ crm_lead: ['l1', 'l2'] }), + }), + ).toEqual([]); // 2 predicates x (1 refused whole-set attempt + 1 page that moved nothing). expect(ql.update).toHaveBeenCalledTimes(4); expect(warn.mock.calls[0][0]).toContain('but re-owned none of them'); @@ -517,7 +571,12 @@ describe('claimSeedOwnership', () => { ); }), }; - expect(await claimSeedOwnership(ql, ADMIN, { logger: { info: vi.fn(), warn } })).toEqual([]); + expect( + await claimSeedOwnership(ql, ADMIN, { + logger: { info: vi.fn(), warn }, + seededRecordIds: seededIds({ crm_lead: ['l1'] }), + }), + ).toEqual([]); expect(ql.find).not.toHaveBeenCalled(); expect(ql.update).toHaveBeenCalledTimes(2); expect(warn).toHaveBeenCalledTimes(2); @@ -535,14 +594,20 @@ describe('claimSeedOwnership', () => { // write this function issues carries the flag, including the page writes // a large object takes and the whole-set attempts the engine refuses. // - // 12 000 unowned rows: over the per-row hook ceiling, so one fixture drives - // a refused whole-set attempt, the page writes, and the closing whole-set - // write that lands. + // 12 000 rows on the seed identity: over the per-row hook ceiling, so one + // fixture drives a refused whole-set attempt, the page writes, and the + // closing whole-set write that lands; 100 seeded NULL-owner rows drive the + // NULL arm's chunk write beside them. const schemas = [{ name: 'crm_lead', fields: [{ name: 'owner_id' }] }]; - const rows = Array.from({ length: 12_000 }, (_, i) => ({ id: `l${i}`, owner_id: null })); + const rows = [ + ...Array.from({ length: 12_000 }, (_, i) => ({ id: `s${i}`, owner_id: SYSTEM as string | null })), + ...Array.from({ length: 100 }, (_, i) => ({ id: `n${i}`, owner_id: null as string | null })), + ]; const { ql, writes, reads } = makeQL(schemas, { crm_lead: rows }); - expect(await claimSeedOwnership(ql, ADMIN)).toEqual([{ object: 'crm_lead', count: 12_000 }]); + expect( + await claimSeedOwnership(ql, ADMIN, { seededRecordIds: seededIds({ crm_lead: nullOwnerIds(rows) }) }), + ).toEqual([{ object: 'crm_lead', count: 12_100 }]); // Both write shapes were exercised, so the assertion below covers both. expect(writes.some((w) => (w.where as any)?.id)).toBe(true); @@ -561,3 +626,114 @@ describe('claimSeedOwnership', () => { for (const r of reads) expect(r.context?.isSystem).toBe(true); }); }); + +// ── [#23354] only the rows the seed wrote (ADR-0138 D1) ──────────────────── + +describe('claimSeedOwnership — the NULL arm re-owns only the rows the seed reported (#23354)', () => { + it('a NULL-owner row the seed did not report keeps no owner — a guest\'s, a form\'s', async () => { + // ADR-0138 D1 item 5: "the owner stays unset" when nothing in the scenario + // sets it. No stored column tells these rows from a seed row; the seed's + // own report is the only thing that does. + const schemas = [{ name: 'crm_case', fields: [{ name: 'owner_id' }] }]; + const rows = [ + { id: 'seed_1', owner_id: null }, + { id: 'guest_1', owner_id: null }, // written through the guest door + { id: 'form_1', owner_id: null }, // a public form's intake + { id: 'legacy_1', owner_id: SYSTEM }, + ]; + const { ql, writes } = makeQL(schemas, { crm_case: rows }); + + const result = await claimSeedOwnership(ql, ADMIN, { seededRecordIds: seededIds({ crm_case: ['seed_1'] }) }); + + expect(result).toEqual([{ object: 'crm_case', count: 2 }]); + expect(Object.fromEntries(rows.map((r) => [r.id, r.owner_id]))).toEqual({ + seed_1: ADMIN, + guest_1: null, + form_1: null, + legacy_1: ADMIN, + }); + expect(writes.map((w) => w.where)).toEqual([ + { owner_id: null, id: { $in: ['seed_1'] } }, + { owner_id: SYSTEM }, + ]); + }); + + it('a row a hook, flow or assignment rule owned keeps its owner even when the seed reported it', async () => { + // A preservation pin: no predicate of the claim — old or new — matches an + // owner other than NULL or the seed identity, so this holds by the + // predicate's shape and stays held. + const schemas = [{ name: 'crm_case', fields: [{ name: 'owner_id' }] }]; + const rows = [{ id: 'seed_hooked', owner_id: 'usr_set_by_hook' }]; + const { ql } = makeQL(schemas, { crm_case: rows }); + + expect( + await claimSeedOwnership(ql, ADMIN, { seededRecordIds: seededIds({ crm_case: ['seed_hooked'] }) }), + ).toEqual([]); + expect(rows[0].owner_id).toBe('usr_set_by_hook'); + }); + + it('without a seeded reading the NULL arm re-owns NOTHING — never every unowned row — and the pass says so', async () => { + const schemas = [{ name: 'crm_case', fields: [{ name: 'owner_id' }] }]; + const rows = [ + { id: 'unowned_1', owner_id: null }, + { id: 'legacy_1', owner_id: SYSTEM }, + ]; + const { ql, writes } = makeQL(schemas, { crm_case: rows }); + const info = vi.fn(); + + const result = await claimSeedOwnership(ql, ADMIN, { + logger: { info, warn: vi.fn() }, + seedSettlement: { pending: 0, inFlight: 0, suppressed: [] }, + }); + + // The seed identity arm still runs: it is its own selector. + expect(result).toEqual([{ object: 'crm_case', count: 1 }]); + expect(rows[0].owner_id).toBeNull(); + expect(writes.map((w) => w.where)).toEqual([{ owner_id: SYSTEM }]); + const [message, meta] = info.mock.calls[0]!; + expect(message).toContain('handed 1 seeded record(s)'); + expect(message).toContain('No seed host on this kernel publishes the rows its seed landed on'); + expect(meta.seededRows).toBeUndefined(); + }); + + it('an object the seed did not touch gets no NULL-arm write at all', async () => { + const schemas = [ + { name: 'crm_case', fields: [{ name: 'owner_id' }] }, + { name: 'crm_note', fields: [{ name: 'owner_id' }] }, + ]; + const { ql, writes } = makeQL(schemas, { + crm_case: [{ id: 'c1', owner_id: null }], + crm_note: [{ id: 'n1', owner_id: null }], + }); + const info = vi.fn(); + + await claimSeedOwnership(ql, ADMIN, { + logger: { info, warn: vi.fn() }, + seededRecordIds: seededIds({ crm_case: ['c1'] }), + }); + + expect(writes.filter((w) => w.object === 'crm_note').map((w) => w.where)).toEqual([{ owner_id: SYSTEM }]); + expect(info.mock.calls[0]![1].seededRows).toBe(1); + }); + + it('chunks the seeded ids, so no IN list is longer than half the per-row hook ceiling and none is refused', async () => { + // The chunk keeps the list inside a driver's bound-parameter limit and + // under the ceiling, so the engine never refuses a NULL-arm write whole + // and the arm issues no read. + const schemas = [{ name: 'crm_case', fields: [{ name: 'owner_id' }] }]; + const rows = Array.from({ length: 12_000 }, (_, i) => ({ id: `c${i}`, owner_id: null as string | null })); + const { ql, writes, reads } = makeQL(schemas, { crm_case: rows }); + + const result = await claimSeedOwnership(ql, ADMIN, { seededRecordIds: seededIds({ crm_case: nullOwnerIds(rows) }) }); + + expect(result).toEqual([{ object: 'crm_case', count: 12_000 }]); + const nullArm = writes.filter((w) => (w.where as any).owner_id === null); + expect(nullArm.map((w) => (w.where as any).id.$in.length)).toEqual([ + MAX_BULK_PER_ROW_HOOK_ROWS / 2, + MAX_BULK_PER_ROW_HOOK_ROWS / 2, + 12_000 - MAX_BULK_PER_ROW_HOOK_ROWS, + ]); + expect(reads).toEqual([]); + }); +}); + diff --git a/packages/plugins/plugin-security/src/claim-seed-ownership.ts b/packages/plugins/plugin-security/src/claim-seed-ownership.ts index ae4bfca26a3..420d36eaa8d 100644 --- a/packages/plugins/plugin-security/src/claim-seed-ownership.ts +++ b/packages/plugins/plugin-security/src/claim-seed-ownership.ts @@ -3,6 +3,31 @@ /** * claimSeedOwnership — hand seeded business records to the first platform admin. * + * ## [#23354] Only the rows the seed wrote (ADR-0138 D1) + * + * "Unowned" is not "seeded". A guest-written row and a public form's intake + * keep no owner on purpose — ADR-0138 D1 item 5: "the owner stays unset" when + * nothing in the scenario sets it — and no stored column tells such a row from + * a seed row. This pass used to re-own every NULL-owner row of every eligible + * object on every `app:seeded`, so it handed those rows to the platform admin + * at the next boot. The NULL arm now re-owns only the ids the seed loader + * reported landing on (`SeedLoadResult.landedIds`), read through the + * `seed-settlement` contract's `seededRecordIds` ({@link ClaimOwnershipOptions}): + * `owner_id IS NULL AND id IN (seeded ids)`. The loader records a row its key + * matched on a replay as well as one it wrote, so a later boot's replay names + * the rows an earlier boot seeded and the claim still reaches them. Every other + * unowned row is left as its writer left it. + * + * The `usr_system` arm is unchanged: only a seed (or a legacy install) writes + * that owner — the guest stamp and the form doors never do, and no runtime + * writer stamps it — so the seed identity is its own selector. + * + * Seed writers that emit no `app:seeded` publish no rows here and are outside + * this pass's purpose (handing the boot seed to the first admin): a package + * install's or a publish's seed apply, the dev hot-reload seeder, and the + * marketplace install's seed. A dataset they share with the boot bundle is + * re-reported by the next boot's replay. + * * Seed data is loaded during app-plugin `start()`, which runs BEFORE any human * user exists (the login admin is minted later, on `kernel:ready`). So seeded * rows land with `owner_id = NULL` (the author left it unset — the correct, @@ -44,8 +69,8 @@ * `owner_id`), and the platform — not the author — performs the handoff. There * is nothing to remember and nothing to mistype. * - * Idempotent: only NULL / `usr_system`-owned rows are touched, so once a real - * admin owns them a re-run is a no-op. `managedBy` and `sys_*` tables are + * Idempotent: only seeded NULL-owned rows and `usr_system`-owned rows are + * touched, so once a real admin owns them a re-run is a no-op. `managedBy` and `sys_*` tables are * skipped (their ownership, if any, is platform-controlled). * * ## [#22067] The claim dispatches no automation @@ -94,13 +119,12 @@ * this shape do its job — with no change to `plugin-sharing`. * * The unit of work is now the SET, not the row: one predicate write per unowned - * shape (`owner_id IS NULL`, then `owner_id = usr_system`), so the matched set - * is the same set the old two-scan rule resolved — row for row — while the - * write count stops scaling with N. The predicates stay two narrow writes rather - * than one `OR`/`IN` for the reason the old scans were two (driver - * portability), and they stay disjoint in this order, because the NULL pass - * lands `adminUserId` — never `usr_system`, that target is refused at the top of - * this function. + * shape (`owner_id IS NULL` — since #23354 `AND id IN (seeded ids)`, one write + * per chunk of them — then `owner_id = usr_system`), so the write count stops + * scaling with N. The predicates stay narrow writes rather than one `OR`/`IN` + * for the reason the old scans were two (driver portability), and they stay + * disjoint in this order, because the NULL pass lands `adminUserId` — never + * `usr_system`, that target is refused at the top of this function. * * ### …and a paged fallback, because one write cannot always carry the set * @@ -132,6 +156,20 @@ * whole-set versus 5 865 ms always-paged, same engine, same driver, same row * set. The page is therefore what the engine's refusal buys, not the default. * + * [#23354] The NULL arm now pays that shape on purpose: its `id IN (seeded + * ids)` is the selection itself, the only way to tell the seed's rows from a + * guest's or a form's. Measured on `InMemoryDriver` through a real `ObjectQL` + * (one object, one write, two runs each): 5 000 unowned seeded rows, the + * first claim 2 587 / 2 650 ms against 297 / 435 ms for the old bare + * `owner_id IS NULL`; 1 000 rows, 62 / 69 ms against 17 / 53 ms. Every later + * pass — every warm boot, once the rows are owned — 4 / 5 ms against 2 / 3 ms, + * because `owner_id IS NULL` fails first and the `IN` list is never consulted. + * So the quadratic cost is paid on the pass that claims the rows, not on every + * boot; a SQL driver hands `id IN` to the database, which this measurement does + * not cover. A chunk is {@link CLAIM_PAGE_ROWS} ids, half the ceiling, so a + * NULL-arm write is never refused and never pages; the fallback below still + * serves the `usr_system` arm, which is one predicate over every such row. + * * The page size is derived from that ceiling rather than chosen: half of it * leaves room for a driver's own bound-parameter limits and for the ceiling * being the engine's answer to a different question, while staying well above @@ -159,6 +197,18 @@ interface ClaimOwnershipOptions { * that reports success for the one failure it exists to catch. */ seedSettlement?: SeedSettlementSnapshot | undefined; + /** + * The ids of the rows this process's boot seed landed on, per object — the + * `seed-settlement` contract's `seededRecordIds`, bound by the caller and + * read per object as the pass walks. `undefined` when no seed host on this + * kernel publishes them. + * + * It is the NULL arm's whole selection: a NULL-owner row it does not name is + * not this pass's to re-own (ADR-0138 D1). So without it the NULL arm + * re-owns nothing — never the old every-unowned-row predicate — and the pass + * report says so. + */ + seededRecordIds?: ((objectName: string) => readonly string[]) | undefined; } /** The context of the claim's one READ (`readPage`): system-elevated, nothing more. */ @@ -187,7 +237,9 @@ const SYSTEM_CTX = { isSystem: true }; const CLAIM_WRITE_CTX = { isSystem: true, skipAutomations: true }; /** - * Rows a single fallback page takes off the top of an over-ceiling predicate. + * Rows a single fallback page takes off the top of an over-ceiling predicate — + * and [#23354] the seeded ids one NULL-arm write carries, for the same two + * reasons: it stays under the ceiling, and inside a driver's parameter limit. * * Derived from the engine's per-row hook ceiling, never a free literal: that * ceiling is what refuses an over-sized predicate write, so the page size has to @@ -225,16 +277,30 @@ function isPerRowHookBudgetRefusal(e: unknown): boolean { } /** - * "Unowned", as two driver-portable predicates rather than one `OR`/`IN`. + * The two "unowned" arms, as driver-portable predicates rather than one `OR`/`IN`. + * + * - **NULL** — `owner_id IS NULL AND id IN (one chunk of the seeded ids)`, one + * predicate per chunk of {@link CLAIM_PAGE_ROWS} ids. NULL alone is every + * unowned row — a guest's, a form's, a seed's — and ADR-0138 D1 leaves the + * first two unowned; the seed's own report is what names the third. The + * chunk keeps the `IN` list inside a driver's bound-parameter limit, and a + * chunk can never match more rows than the per-row hook ceiling allows, so + * the engine never refuses it whole. + * - **seed identity** — `owner_id = usr_system`, unchanged: no guest door, form + * door or runtime writer stamps it, so it names only seed or legacy rows. * * Order is load-bearing: the NULL pass lands `adminUserId` — which cannot be * `usr_system` (refused at the top of {@link claimSeedOwnership}) — so the two * matched sets stay disjoint and their counts sum without double-counting a row. */ -const UNOWNED_PREDICATES: readonly Record[] = [ - { owner_id: null }, - { owner_id: SystemUserId.SYSTEM }, -]; +function unownedPredicates(seededIds: readonly string[]): Record[] { + const predicates: Record[] = []; + for (let at = 0; at < seededIds.length; at += CLAIM_PAGE_ROWS) { + predicates.push({ owner_id: null, id: { $in: seededIds.slice(at, at + CLAIM_PAGE_ROWS) } }); + } + predicates.push({ owner_id: SystemUserId.SYSTEM }); + return predicates; +} function hasOwnerField(schema: ServiceObject): boolean { const fields: any = (schema as any)?.fields; @@ -263,6 +329,16 @@ function affectedRowCount(value: unknown): number | undefined { return value; } +/** + * A predicate as a log line carries it: an `id IN (…)` list — a chunk of up to + * {@link CLAIM_PAGE_ROWS} seeded ids — is reported by its size, never inlined. + */ +function whereForLog(where: Record): Record { + const id = where.id as { $in?: unknown } | undefined; + if (!id || !Array.isArray(id.$in)) return where; + return { ...where, id: { $in: `${id.$in.length} id(s)` } }; +} + /** * Ids from a `find` result. * @@ -327,7 +403,7 @@ async function claimPredicate( logger?.warn?.( `[security] claimSeedOwnership could not read an affected-row count for ${objectName} ` + '— the rows were re-owned but this run cannot say how many', - { object: objectName, where, page }, + { object: objectName, where: whereForLog(where), page }, ); return total; } @@ -346,7 +422,7 @@ async function claimPredicate( logger?.warn?.( `[security] claimSeedOwnership could not page ${objectName}: the write refused as ` + 'over-sized but the predicate matched no rows to page; those rows stay unowned', - { object: objectName, where, page }, + { object: objectName, where: whereForLog(where), page }, ); return total; } @@ -360,7 +436,7 @@ async function claimPredicate( logger?.warn?.( `[security] claimSeedOwnership could not read an affected-row count for ${objectName} ` + '— the page was re-owned but this run cannot say how many, and paging stops here', - { object: objectName, where, page, result: typeof affected }, + { object: objectName, where: whereForLog(where), page, result: typeof affected }, ); return total; } @@ -372,7 +448,7 @@ async function claimPredicate( logger?.warn?.( `[security] claimSeedOwnership matched ${ids.length} unowned row(s) on ${objectName} ` + 'but re-owned none of them; those rows stay unowned', - { object: objectName, where, page, matched: ids.length }, + { object: objectName, where: whereForLog(where), page, matched: ids.length }, ); return total; } @@ -382,7 +458,7 @@ async function claimPredicate( `[security] claimSeedOwnership stopped after ${MAX_CLAIM_PAGES} fallback page(s) on ${objectName}; ` + '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 }, + { object: objectName, where: whereForLog(where), pages: MAX_CLAIM_PAGES }, ); return total; } @@ -439,6 +515,13 @@ async function claimPredicate( * 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. + * + * ## [#23354] …and whether it could name the seed's rows at all + * + * The NULL arm re-owns only the ids the seed reported landing on. A pass with + * no such reading re-owns no NULL-owner row, which is "claimed 0" again — so a + * pass that could not tell says so, appended after the finality clause, and + * `meta.seededRows` carries how many seeded ids the walk held. */ function reportClaimPass( logger: ClaimOwnershipOptions['logger'], @@ -446,6 +529,7 @@ function reportClaimPass( results: { object: string; count: number }[], eligibleObjects: number, seedSettlement: SeedSettlementSnapshot | undefined, + seeded: { published: boolean; rows: number }, ): void { const total = results.reduce((s, r) => s + r.count, 0); const head = @@ -458,14 +542,19 @@ function reportClaimPass( breakdown: results, seedInFlight: seedSettlement?.inFlight, seedSuppressed: seedSettlement?.suppressed, + seededRows: seeded.published ? seeded.rows : undefined, }; + const unnamed = seeded.published + ? '' + : ' No seed host on this kernel publishes the rows its seed landed on, so no NULL-owner row was ' + + 're-owned: an unowned row this pass cannot name as seeded keeps no owner (ADR-0138 D1).'; 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. 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".', + `here is "nothing had landed yet", never "nothing to claim".${unnamed}`, meta, ); return; @@ -473,13 +562,13 @@ function reportClaimPass( if (!seedSettlement) { logger?.info?.( `${head} — unattested: no seed-settlement probe is registered on this kernel, so this pass ` + - 'cannot say whether a seed was still writing when it ran.', + `cannot say whether a seed was still writing when it ran.${unnamed}`, meta, ); return; } logger?.info?.( - `${head} — final: every seed source this boot writes has settled, so there was nothing left to claim.`, + `${head} — final: every seed source this boot writes has settled, so there was nothing left to claim.${unnamed}`, meta, ); } @@ -495,9 +584,10 @@ function reportClaimPass( * (d) declare an `owner_id` field, * and re-owns the unowned rows as `isSystem`, with metadata-bound automation * off ({@link CLAIM_WRITE_CTX}), in one predicate write per - * {@link UNOWNED_PREDICATES} entry, paging that write only when the engine - * refuses it for its per-row hook budget. Returns a per-object summary whose - * `count` is the sum of every write's affected-row count. + * {@link unownedPredicates} entry — the seeded ids' chunks under the NULL arm, + * then the seed identity — paging that write only when the engine refuses it + * for its per-row hook budget. Returns a per-object summary whose `count` is + * the sum of every write's affected-row count. */ export async function claimSeedOwnership( ql: any, @@ -521,6 +611,9 @@ export async function claimSeedOwnership( // "claimed 0" can be read against the size of what was examined — a pass over // zero eligible objects and a pass over forty are different facts. let eligibleObjects = 0; + // [#23354] The NULL arm's selection, read per object as the walk reaches it. + const seededRecordIds = options.seededRecordIds; + let seededRows = 0; for (const schema of schemas) { if (!schema?.name) continue; @@ -551,8 +644,11 @@ export async function claimSeedOwnership( ), }; + const seededIds = seededRecordIds?.(schema.name) ?? []; + seededRows += seededIds.length; + let updated = 0; - for (const where of UNOWNED_PREDICATES) { + for (const where of unownedPredicates(seededIds)) { try { updated += await claimPredicate(io, schema.name, where, logger); } catch (e) { @@ -568,13 +664,16 @@ export async function claimSeedOwnership( `[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 }, + { object: schema.name, where: whereForLog(where), error: (e as Error).message }, ); } } if (updated > 0) results.push({ object: schema.name, count: updated }); } - reportClaimPass(logger, adminUserId, results, eligibleObjects, options.seedSettlement); + reportClaimPass(logger, adminUserId, results, eligibleObjects, options.seedSettlement, { + published: typeof seededRecordIds === 'function', + rows: seededRows, + }); return results; } diff --git a/packages/plugins/plugin-security/src/engine-find-bare-array.pin.test.ts b/packages/plugins/plugin-security/src/engine-find-bare-array.pin.test.ts index d8683531edc..2a5f467b531 100644 --- a/packages/plugins/plugin-security/src/engine-find-bare-array.pin.test.ts +++ b/packages/plugins/plugin-security/src/engine-find-bare-array.pin.test.ts @@ -216,7 +216,11 @@ describe('[#15598] block 5 — claim-seed-ownership idsFrom (the PAGING fallback }, }); - await claimSeedOwnership(io, ADMIN, {} as any); + // [#23354] The seed reports d1, so the NULL arm reaches it (and is the + // write refused once, above). + await claimSeedOwnership(io, ADMIN, { + seededRecordIds: (object: string) => (object === 'probe_deal' ? ['d1'] : []), + } as any); assertBareArrayPage(seen, 'claim-seed-ownership readPage'); // Re-owned THROUGH the id page: an `idsFrom` that read nothing stops the diff --git a/packages/plugins/plugin-security/src/read-fault-distinguishability.test.ts b/packages/plugins/plugin-security/src/read-fault-distinguishability.test.ts index a271b4523af..be586c52800 100644 --- a/packages/plugins/plugin-security/src/read-fault-distinguishability.test.ts +++ b/packages/plugins/plugin-security/src/read-fault-distinguishability.test.ts @@ -447,6 +447,9 @@ function budgetRefusal(): Error { // names the object and states the consequence. That is a declared, in-file // disposition and it is already the right one. `claim-seed-ownership.ts` is not // touched by this PR; these three cases are kept verbatim as the proof. +/** [#23354] The seed's report: it landed on d1, the row each case below plants. */ +const SEEDED_D1 = (object: string): readonly string[] => (object === 'probe_deal' ? ['d1'] : []); + describe('[#15840] reading 3 — claim-seed-ownership idsFrom', () => { it('a control: the paging fallback DOES re-own the row when the page read answers', async () => { const engine = await boot(); @@ -462,7 +465,8 @@ describe('[#15840] reading 3 — claim-seed-ownership idsFrom', () => { return engine.update(o, d, opt); }, }), - ADMIN, { logger: logger as any }, + // [#23354] The seed reports d1, so the refused write is the NULL arm's. + ADMIN, { logger: logger as any, seededRecordIds: SEEDED_D1 }, ); expect(res).toEqual([{ object: 'probe_deal', count: 1 }]); @@ -487,7 +491,8 @@ describe('[#15840] reading 3 — claim-seed-ownership idsFrom', () => { return engine.update(o, d, opt); }, }), - ADMIN, { logger: logger as any }, + // [#23354] The seed reports d1, so the refused write is the NULL arm's. + ADMIN, { logger: logger as any, seededRecordIds: SEEDED_D1 }, ); // `idsFrom` is never reached: the throw leaves `readPage` and is caught by @@ -524,7 +529,8 @@ describe('[#15840] reading 3 — claim-seed-ownership idsFrom', () => { return engine.update(o, d, opt); }, }), - ADMIN, { logger: logger as any }, + // [#23354] The seed reports d1, so the refused write is the NULL arm's. + ADMIN, { logger: logger as any, seededRecordIds: SEEDED_D1 }, ); expect(res).toEqual([]); diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 4a198e6cdb8..f5dfe9fb042 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -1077,6 +1077,26 @@ function readSeedSettlementSnapshot(ctx: PluginContext): SeedSettlementSnapshot } } +/** + * [#23354] "Which rows did this process's boot seed write?", through the same + * published contract as {@link readSeedSettlementSnapshot} — the seed loader's + * own report of the rows it landed on, so the seed-ownership claim hands over + * the seed's rows and leaves every other unowned row unowned (ADR-0138 D1). + * `undefined` when no host publishes it; the claim then re-owns no NULL-owner + * row and its pass report says so. Bound per pass; the tally is read per call. + */ +function readSeededRecordIds(ctx: PluginContext): ((objectName: string) => readonly string[]) | undefined { + try { + const svc = (ctx as any).getService?.(SEED_SETTLEMENT_SERVICE) as + | ISeedSettlementService + | undefined; + if (!svc || typeof svc.seededRecordIds !== 'function') return undefined; + return (objectName: string) => svc.seededRecordIds!(objectName); + } catch { + return undefined; + } +} + /** * The identity objects this plugin's authorization store READS — declared here, * enforced at boot by {@link refuseMissingAuthzIdentityObjects}, and ⛔ never @@ -1609,14 +1629,17 @@ export class SecurityPlugin implements Plugin { * * `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 + * this handler sees its own signal already reflected in the tally — and the + * rows that source landed on, which it publishes before it settles. The claim + * is idempotent (only seeded NULL-owned and `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. + * predicate and cannot be touched here. A NULL-owner row is matched only when + * the seed reported landing on it ({@link readSeededRecordIds}): a guest's or + * a public form's unowned row keeps no owner on every boot (ADR-0138 D1). */ private async claimSeedOwnershipOnSettle( ctx: PluginContext, @@ -1637,6 +1660,7 @@ export class SecurityPlugin implements Plugin { await claimSeedOwnership(ql, adminUserId, { logger: ctx.logger, seedSettlement: readSeedSettlementSnapshot(ctx), + seededRecordIds: readSeededRecordIds(ctx), }); } catch (e) { // Best-effort, exactly like the promotion-time call: a failed claim @@ -4327,6 +4351,7 @@ export class SecurityPlugin implements Plugin { // after a later sign-up see different tallies, and it is exactly the // difference that decides whether that pass's claim is the last word. seedSettlement: readSeedSettlement(), + seededRecordIds: readSeededRecordIds(ctx), }); // Remember the claim's target for the `app:seeded` re-run // ({@link claimSeedOwnershipOnSettle}). Only ever overwritten with a diff --git a/packages/runtime/src/app-plugin.seed-landed.test.ts b/packages/runtime/src/app-plugin.seed-landed.test.ts new file mode 100644 index 00000000000..cbaad3c40d9 --- /dev/null +++ b/packages/runtime/src/app-plugin.seed-landed.test.ts @@ -0,0 +1,204 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { AppPlugin } from './app-plugin'; +import { SEED_SETTLEMENT_SERVICE, type ISeedSettlementService } from '@objectstack/spec/contracts'; +import { assertEngineFindOnePredicate, assertEngineUpdateDispatch } from '@objectstack/metadata-core'; +import type { PluginContext } from '@objectstack/core'; + +/** + * #23354 — AppPlugin publishes which rows its seed landed on. + * + * The seed-ownership claims hand to an owner only the unowned rows the SEED + * wrote (ADR-0138 D1: a guest-written or public-form row keeps no owner), and + * read which rows those are through the `seed-settlement` contract's + * `seededRecordIds`. This file pins the producer half on both of AppPlugin's + * seed paths: + * + * - the inline boot seed publishes its rows BEFORE it settles, so the claim + * that runs inside `app:seeded` already reads them — on the loader path and + * on the basic-insert fallback alike; + * - the per-organization replayer records the rows each replay landed on, + * under that organization, for the owner bind that follows the + * organization's creation. + */ + +/** + * `where` as the seed loader spells it here: field equality only. A combinator + * or a value operator is REFUSED rather than answered, so a shape this double + * does not implement can never read as a match or a miss. + */ +function rowMatches(row: any, where: Record = {}): boolean { + return Object.entries(where).every(([k, v]) => { + if (k.startsWith('$')) throw new Error(`engine double: unsupported combinator ${k}`); + if (v !== null && typeof v === 'object') { + throw new Error(`engine double: unsupported value operator on ${k}: ${JSON.stringify(v)}`); + } + return (row[k] ?? null) === (v ?? null); + }); +} + +/** A faithful in-memory engine: `where` filters, ids minted on insert. */ +function createEngine() { + const store: Record = {}; + let next = 0; + const engine: any = { + store, + async find(object: string, query?: any) { + let rows = (store[object] ?? []).filter((r) => rowMatches(r, query?.where)); + if (typeof query?.limit === 'number') rows = rows.slice(0, query.limit); + return rows.map((r) => ({ ...r })); + }, + async findOne(object: string, query?: any) { + assertEngineFindOnePredicate(object, query); + return (await engine.find(object, { ...query, limit: 1 }))[0] ?? null; + }, + async insert(object: string, data: any) { + const mint = (d: any) => ({ id: d.id ?? `gen_${++next}`, ...d }); + const rows = Array.isArray(data) ? data.map(mint) : [mint(data)]; + (store[object] ??= []).push(...rows); + return Array.isArray(data) ? rows.map((r) => ({ ...r })) : { ...rows[0] }; + }, + async update(object: string, data: any, options?: any) { + assertEngineUpdateDispatch(data, options); + const row = (store[object] ?? []).find((r) => r.id === data.id); + if (row) Object.assign(row, data); + return row ? { ...row } : null; + }, + async count(object: string, query?: any) { + return (await engine.find(object, query)).length; + }, + async aggregate() { + return []; + }, + }; + return engine; +} + +const OBJECTS: Record = { + crm_case: { name: 'crm_case', fields: { name: { type: 'text' }, owner_id: { type: 'text' } } }, +}; + +const metadataService = { + getObject: vi.fn(async (name: string) => OBJECTS[name]), + listObjects: vi.fn(async () => Object.values(OBJECTS)), + get: vi.fn(async (_type: string, name: string) => OBJECTS[name]), + list: vi.fn(async () => []), + register: vi.fn(async () => {}), + unregister: vi.fn(async () => {}), + exists: vi.fn(async () => false), + listNames: vi.fn(async () => []), +}; + +const bundle = () => ({ + id: 'seed-landed-app', + data: [ + { + object: 'crm_case', + externalId: 'name', + records: [{ name: 'CASE-1' }, { name: 'CASE-2' }], + }, + ], +}); + +describe('AppPlugin publishes the rows its seed landed on (#23354)', () => { + const OLD_BUDGET = process.env.OS_INLINE_SEED_BUDGET_MS; + const OLD_MULTI = process.env.OS_MULTI_ORG_ENABLED; + + beforeEach(() => { + delete process.env.OS_MULTI_ORG_ENABLED; + process.env.OS_INLINE_SEED_BUDGET_MS = '8000'; + }); + afterEach(() => { + if (OLD_BUDGET === undefined) delete process.env.OS_INLINE_SEED_BUDGET_MS; + else process.env.OS_INLINE_SEED_BUDGET_MS = OLD_BUDGET; + if (OLD_MULTI === undefined) delete process.env.OS_MULTI_ORG_ENABLED; + else process.env.OS_MULTI_ORG_ENABLED = OLD_MULTI; + }); + + /** + * A context with a real service map. `trigger` records what a consumer + * reading the contract INSIDE `app:seeded` would see at that moment. + */ + function makeContext(engine: any, opts: { metadata: boolean }) { + const services = new Map(); + const seenAtSettle: Array = []; + const read = () => services.get(SEED_SETTLEMENT_SERVICE) as ISeedSettlementService | undefined; + const ctx = { + logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() }, + registerService: vi.fn((name: string, svc: unknown) => { + if (services.has(name)) throw new Error(`service '${name}' already registered`); + services.set(name, svc); + }), + getService: vi.fn((name: string) => { + if (name === 'objectql') return engine; + if (name === 'metadata') return opts.metadata ? metadataService : undefined; + return services.get(name); + }), + getServices: vi.fn(() => new Map()), + hook: vi.fn(), + trigger: vi.fn((event: string) => { + if (event === 'app:seeded') seenAtSettle.push(read()?.seededRecordIds?.('crm_case') ?? []); + }), + } as unknown as PluginContext; + return { ctx, services, seenAtSettle, read }; + } + + it('the loader path: the claim inside app:seeded reads exactly the rows the seed wrote', async () => { + const engine = createEngine(); + const { ctx, seenAtSettle, read } = makeContext(engine, { metadata: true }); + + await new AppPlugin(bundle()).start(ctx); + + const seeded = (engine.store.crm_case ?? []).map((r: any) => r.id); + expect(seeded).toHaveLength(2); + expect(seenAtSettle).toEqual([seeded]); + expect(read()!.seededRecordIds!('crm_case')).toEqual(seeded); + }); + + it('a later boot replays the seed and re-reports the rows the earlier boot wrote', async () => { + const engine = createEngine(); + await new AppPlugin(bundle()).start(makeContext(engine, { metadata: true }).ctx); + const firstBoot = engine.store.crm_case.map((r: any) => r.id); + // A row someone else wrote between boots, unowned on purpose. + engine.store.crm_case.push({ id: 'guest_row', name: 'Guest enquiry', owner_id: null }); + + const second = makeContext(engine, { metadata: true }); + await new AppPlugin(bundle()).start(second.ctx); + + expect(second.seenAtSettle).toEqual([firstBoot]); + expect(second.read()!.seededRecordIds!('crm_case')).not.toContain('guest_row'); + }); + + it('the basic-insert fallback publishes its rows too', async () => { + const engine = createEngine(); + const { ctx, seenAtSettle } = makeContext(engine, { metadata: false }); + + await new AppPlugin(bundle()).start(ctx); + + const seeded = (engine.store.crm_case ?? []).map((r: any) => r.id); + expect(seeded).toHaveLength(2); + expect(seenAtSettle).toEqual([seeded]); + }); + + it('the per-organization replayer records each replay under its organization', async () => { + process.env.OS_MULTI_ORG_ENABLED = 'true'; + const engine = createEngine(); + const { ctx, services, seenAtSettle, read } = makeContext(engine, { metadata: true }); + + await new AppPlugin(bundle()).start(ctx); + // Multi-tenant: no inline seed, so nothing settles and nothing is published. + expect(seenAtSettle).toEqual([]); + expect(read()!.seededRecordIds!('crm_case')).toEqual([]); + + const replayer = services.get('seed-replayer') as (orgId: string) => Promise; + expect(typeof replayer).toBe('function'); + await replayer('org_a'); + + const orgRows = engine.store.crm_case.map((r: any) => r.id); + expect(orgRows).toHaveLength(2); + expect(read()!.seededRecordIds!('crm_case', 'org_a')).toEqual(orgRows); + // The boot reading is not the organization's. + expect(read()!.seededRecordIds!('crm_case')).toEqual([]); + }); +}); diff --git a/packages/runtime/src/app-plugin.ts b/packages/runtime/src/app-plugin.ts index 37b6656013d..29c2a35a738 100644 --- a/packages/runtime/src/app-plugin.ts +++ b/packages/runtime/src/app-plugin.ts @@ -25,7 +25,7 @@ import { SeedLoaderService } from './seed-loader.js'; import { recordSeedOutcome } from './seed-summary.js'; import { contributeCodeDatasourceNames } from './code-datasource-names.js'; import { mergeSeedDatasets, readSeedDatasets, registerSeedReplayerOnce } from './seed-datasets.js'; -import { declareSeedSource } from './seed-settlement.js'; +import { declareSeedSource, recordOrganizationReplay, type SeedLandingReport } from './seed-settlement.js'; import { loadDisabledPackageIds } from './package-state-store.js'; import type { IMetadataService, IObjectQLEngine, II18nService } from '@objectstack/spec/contracts'; import { normalizeFlowFunctionEntry, type NormalizedFlowFunction } from '@objectstack/spec/automation'; @@ -1699,6 +1699,11 @@ export class AppPlugin implements Plugin { }, }); const result = await seedLoader.load(request); + // [#23354] The rows this replay landed on, for the + // organization's owner-bind claim (`seededRecordIds` with + // this organization): it hands over these rows and no + // other unowned row of the organization (ADR-0138 D1). + recordOrganizationReplay(ctx, organizationId, result.results); return { inserted: result.summary.totalInserted, updated: result.summary.totalUpdated, @@ -1784,6 +1789,12 @@ export class AppPlugin implements Plugin { }, }); const result = await seedLoader.load(request); + // [#23354] Publish the rows this seed landed on BEFORE the + // source settles, so the seed-ownership claim that runs on + // `app:seeded` reads them: it hands over these rows and no + // other unowned row (ADR-0138 D1). A replay on a later + // boot reports the rows an earlier boot wrote, too. + seedSource.publishLanded(result.results); const { totalInserted, totalUpdated, totalSkipped, totalErrored } = result.summary; // "Wrote the row, lost the link" (#3932): a reference field // dropped from a row that WAS written moves none of the row @@ -1845,30 +1856,42 @@ export class AppPlugin implements Plugin { } else { // Fallback: basic insert when metadata service is not available ctx.logger.debug('[Seeder] No metadata service; using basic insert fallback'); + const landed: SeedLandingReport[number][] = []; for (const dataset of normalizedDatasets) { ctx.logger.info(`[Seeder] Seeding ${dataset.records.length} records for ${dataset.object}`); + const landedIds: string[] = []; for (const record of dataset.records) { try { - await ql.insert(dataset.object, record, SEED_WRITE_OPTIONS as any); + const row: any = await ql.insert(dataset.object, record, SEED_WRITE_OPTIONS as any); + if (row?.id != null) landedIds.push(String(row.id)); } catch (err: any) { ctx.logger.warn(`[Seeder] Failed to insert ${dataset.object} record:`, { error: err.message }); } } + landed.push({ object: dataset.object, landedIds }); } + // [#23354] Still this source's rows: published like the loader's. + seedSource.publishLanded(landed); ctx.logger.info('[Seeder] Data seeding complete.'); } } catch (err: any) { // If SeedLoaderService fails (e.g., metadata not available), fall back to basic insert ctx.logger.warn('[Seeder] SeedLoaderService failed, falling back to basic insert', { error: err.message }); + const landed: SeedLandingReport[number][] = []; for (const dataset of normalizedDatasets) { + const landedIds: string[] = []; for (const record of dataset.records) { try { - await ql.insert(dataset.object, record, SEED_WRITE_OPTIONS as any); + const row: any = await ql.insert(dataset.object, record, SEED_WRITE_OPTIONS as any); + if (row?.id != null) landedIds.push(String(row.id)); } catch (insertErr: any) { ctx.logger.warn(`[Seeder] Failed to insert ${dataset.object} record:`, { error: insertErr.message }); } } + landed.push({ object: dataset.object, landedIds }); } + // [#23354] Still this source's rows: published like the loader's. + seedSource.publishLanded(landed); ctx.logger.info('[Seeder] Data seeding complete (fallback).'); } })(); diff --git a/packages/runtime/src/seed-datasets.test.ts b/packages/runtime/src/seed-datasets.test.ts index 1c60c43006d..c10aec703bb 100644 --- a/packages/runtime/src/seed-datasets.test.ts +++ b/packages/runtime/src/seed-datasets.test.ts @@ -25,7 +25,20 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; // ── Part B plumbing: capture what the replayer feeds the seed loader ────────── let loadCalls: any[] = []; -let seedResult: any = { summary: { totalInserted: 0, totalUpdated: 0, totalSkipped: 0 }, errors: [] }; +/** + * What the real `SeedLoaderService.load()` resolves, reduced to the keys AppPlugin + * reads: `summary`, `errors` and `results`. `results` is REQUIRED by the contract + * (`SeedLoaderResultSchema`: one entry per dataset, `[]` when nothing loaded; the real + * loader returns it on every path, the empty one included), and the replayer hands it + * to the seed-settlement tracker (#23354) — a fake without it answers a shape the + * producer never does. + */ +const loaderResult = (summary: { totalInserted: number; totalUpdated: number; totalSkipped: number }) => ({ + summary, + errors: [] as unknown[], + results: [] as unknown[], +}); +let seedResult: any = loaderResult({ totalInserted: 0, totalUpdated: 0, totalSkipped: 0 }); vi.mock('./seed-loader.js', () => ({ SeedLoaderService: class { @@ -143,7 +156,7 @@ describe('AppPlugin per-org replayer replays the live union (#3453 seam)', () => beforeEach(() => { loadCalls = []; - seedResult = { summary: { totalInserted: 0, totalUpdated: 0, totalSkipped: 0 }, errors: [] }; + seedResult = loaderResult({ totalInserted: 0, totalUpdated: 0, totalSkipped: 0 }); process.env.OS_MULTI_ORG_ENABLED = 'true'; // per-org replay path; skip inline seed }); afterEach(() => { @@ -193,7 +206,7 @@ describe('AppPlugin per-org replayer replays the live union (#3453 seam)', () => expect(typeof replayer).toBe('function'); // A brand-new org is created → the middleware invokes the replayer. - seedResult = { summary: { totalInserted: 3, totalUpdated: 0, totalSkipped: 0 }, errors: [] }; + seedResult = loaderResult({ totalInserted: 3, totalUpdated: 0, totalSkipped: 0 }); const result = await replayer('org_fresh'); // The replayer fed the loader the FULL union in source order — the crux of diff --git a/packages/runtime/src/seed-settlement.test.ts b/packages/runtime/src/seed-settlement.test.ts index c54df1b2753..0a5560f3769 100644 --- a/packages/runtime/src/seed-settlement.test.ts +++ b/packages/runtime/src/seed-settlement.test.ts @@ -1,7 +1,8 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect } from 'vitest'; -import { declareSeedSource, readSeedSettlement } from './seed-settlement.js'; +import { declareSeedSource, readSeedSettlement, recordOrganizationReplay } from './seed-settlement.js'; +import { SEED_SETTLEMENT_SERVICE, type ISeedSettlementService } from '@objectstack/spec/contracts'; /** * #4795 — the settlement tally behind the `seed-settlement` contract. @@ -151,3 +152,111 @@ describe('seed settlement tally (#4795)', () => { expect(readSeedSettlement(kernel)?.pending).toBe(1); }); }); + +/** + * #23354 — which rows the seed wrote, through the same contract. + * + * The seed-ownership claims hand over only the unowned rows the seed landed on + * (ADR-0138 D1: a guest's or a form's unowned row keeps no owner), so the host + * publishes the loader's `landedIds` and the claims read them back through + * `seededRecordIds`. Two readings: the boot sources of this process, and the + * most recent per-organization replay. + */ +describe('seeded record ids (#23354)', () => { + function makeCtx() { + const services = new Map(); + return { + registerService(name: string, svc: unknown) { + if (services.has(name)) throw new Error(`service '${name}' already registered`); + services.set(name, svc); + }, + getService(name: string) { + if (!services.has(name)) throw new Error(`service '${name}' not registered`); + return services.get(name); + }, + }; + } + /** The consumer's view: the registered service, read through the spec contract. */ + const service = (ctx: ReturnType) => + ctx.getService(SEED_SETTLEMENT_SERVICE) as ISeedSettlementService; + + it('a source publishes its rows before it settles, so a reader inside app:seeded sees them', () => { + const ctx = makeCtx(); + const source = declareSeedSource(ctx); + + expect(service(ctx).seededRecordIds!('crm_case')).toEqual([]); + source.publishLanded([ + { object: 'crm_case', landedIds: ['c1', 'c2'] }, + { object: 'crm_team', landedIds: ['t1'] }, + ]); + // Published, still in flight: the reading does not wait for the settle. + expect(readSeedSettlement(ctx)?.inFlight).toBe(1); + expect(service(ctx).seededRecordIds!('crm_case')).toEqual(['c1', 'c2']); + source.settle(); + expect(service(ctx).seededRecordIds!('crm_case')).toEqual(['c1', 'c2']); + expect(service(ctx).seededRecordIds!('crm_team')).toEqual(['t1']); + expect(service(ctx).seededRecordIds!('crm_unseeded')).toEqual([]); + }); + + it('every boot source adds to one reading, and an id appears once', () => { + const ctx = makeCtx(); + const a = declareSeedSource(ctx); + const b = declareSeedSource(ctx); + + a.publishLanded([{ object: 'crm_case', landedIds: ['c1'] }]); + // Two datasets on one object, and a second pass over the same rows. + b.publishLanded([{ object: 'crm_case', landedIds: ['c2'] }, { object: 'crm_case', landedIds: ['c1'] }]); + + expect([...service(ctx).seededRecordIds!('crm_case')].sort()).toEqual(['c1', 'c2']); + }); + + it('a per-organization replay is its own reading — the boot reading never answers for an organization', () => { + const ctx = makeCtx(); + declareSeedSource(ctx).publishLanded([{ object: 'crm_case', landedIds: ['boot_1'] }]); + + recordOrganizationReplay(ctx, 'org_a', [{ object: 'crm_case', landedIds: ['a_1', 'a_2'] }]); + + expect(service(ctx).seededRecordIds!('crm_case', 'org_a')).toEqual(['a_1', 'a_2']); + expect(service(ctx).seededRecordIds!('crm_case')).toEqual(['boot_1']); + expect(service(ctx).seededRecordIds!('crm_case', 'org_b')).toEqual([]); + }); + + /** + * The bound is the design, not an accident: an organization is replayed + * once, when it is created, and a process creating thousands must not hold + * every replay's rows. The owner bind that reads it runs in the same call + * that created the organization. + */ + it('only the most recent per-organization replay is kept', () => { + const ctx = makeCtx(); + declareSeedSource(ctx).suppress('multi-tenant-replay'); + + recordOrganizationReplay(ctx, 'org_a', [{ object: 'crm_case', landedIds: ['a_1'] }]); + recordOrganizationReplay(ctx, 'org_b', [{ object: 'crm_case', landedIds: ['b_1'] }]); + + expect(service(ctx).seededRecordIds!('crm_case', 'org_a')).toEqual([]); + expect(service(ctx).seededRecordIds!('crm_case', 'org_b')).toEqual(['b_1']); + + // A second replay of the same organization replaces, never accumulates. + recordOrganizationReplay(ctx, 'org_b', [{ object: 'crm_case', landedIds: ['b_2'] }]); + expect(service(ctx).seededRecordIds!('crm_case', 'org_b')).toEqual(['b_2']); + }); + + it('hands back a copy, so a consumer cannot edit the reading', () => { + const ctx = makeCtx(); + declareSeedSource(ctx).publishLanded([{ object: 'crm_case', landedIds: ['c1'] }]); + + (service(ctx).seededRecordIds!('crm_case') as string[]).push('forged'); + + expect(service(ctx).seededRecordIds!('crm_case')).toEqual(['c1']); + }); + + it('ignores a result with no landed ids, and never throws without a tracker', () => { + const ctx = makeCtx(); + declareSeedSource(ctx).publishLanded([{ object: 'crm_case' }, { object: 'crm_team', landedIds: [] }]); + + expect(service(ctx).seededRecordIds!('crm_case')).toEqual([]); + expect(() => recordOrganizationReplay({}, 'org_a', [{ object: 'crm_case', landedIds: ['x'] }])).not.toThrow(); + expect(() => declareSeedSource({}).publishLanded([{ object: 'crm_case', landedIds: ['x'] }])).not.toThrow(); + }); +}); diff --git a/packages/runtime/src/seed-settlement.ts b/packages/runtime/src/seed-settlement.ts index e79f6a22b24..0ba6d23d68f 100644 --- a/packages/runtime/src/seed-settlement.ts +++ b/packages/runtime/src/seed-settlement.ts @@ -17,6 +17,14 @@ * tracker object is registered by the first source and read back by every * later one. * + * **Which rows a seed wrote travels the same way (#23354).** The loader reports + * the ids every dataset landed on (`SeedLoadResult.landedIds`); a source + * publishes them through its handle BEFORE it settles, and the per-organization + * replayer records its own through {@link recordOrganizationReplay}. The + * seed-ownership claims read them back through `seededRecordIds`, so they hand + * over the seed's rows and nothing else (ADR-0138 D1) without a second copy of + * the loader's key and reference rules. + * * **A source that is never settled is not a leak — it is the answer.** A * multi-tenant or `skipSeedData` boot suppresses its source on purpose and the * tally stays pending for the life of the process, which is what tells the @@ -34,6 +42,13 @@ import type { } from '@objectstack/spec/contracts'; import { SEED_SETTLEMENT_SERVICE } from '@objectstack/spec/contracts'; +/** + * What one seed load reports about the rows it landed on: its per-dataset + * results, of which only the object and `landedIds` are read here. Typed this + * narrowly so a host hands the loader's `results` array straight through. + */ +export type SeedLandingReport = readonly { readonly object: string; readonly landedIds?: readonly string[] }[]; + /** The subset of a kernel / plugin context this module pokes at. */ interface SeedSettlementHost { kernel?: { getService?: (n: string) => unknown; registerService?: (n: string, v: unknown) => unknown }; @@ -52,19 +67,49 @@ export interface SeedSourceHandle { settle(): void; /** This source will not run at boot; nothing it describes gets written. */ suppress(reason: SeedSuppressionReason): void; + /** + * Publish the rows this source's load landed on — call it BEFORE + * {@link settle}, so a consumer running inside `app:seeded` already reads + * them. Additive: a source that loads in several passes publishes each. + */ + publishLanded(report: SeedLandingReport): void; } /** The registered service object: the read-only contract plus its bookkeeping. */ interface SeedSettlementTracker extends ISeedSettlementService { __declare(): SeedSourceHandle; + __recordOrganizationReplay(organizationId: string, report: SeedLandingReport): void; } /** A no-op handle for hosts whose context cannot carry the service at all. */ -const INERT_HANDLE: SeedSourceHandle = { settle: () => {}, suppress: () => {} }; +const INERT_HANDLE: SeedSourceHandle = { settle: () => {}, suppress: () => {}, publishLanded: () => {} }; + +/** Fold one load's report into an object → ids index. */ +function addLanded(into: Map>, report: SeedLandingReport): void { + for (const result of report) { + if (!result?.object || !Array.isArray(result.landedIds) || result.landedIds.length === 0) continue; + let ids = into.get(result.object); + if (!ids) { + ids = new Set(); + into.set(result.object, ids); + } + for (const id of result.landedIds) if (typeof id === 'string' && id.length > 0) ids.add(id); + } +} function createTracker(): SeedSettlementTracker { let inFlight = 0; const suppressed: SeedSuppressionReason[] = []; + // The rows every boot-time source of this process landed on. Bounded by + // the seed itself: a boot loads its bundle once, and a source that loads in + // passes adds the same rows again. + const bootLanded = new Map>(); + // The rows of the MOST RECENT per-organization replay, and only that one. + // An organization is replayed once, when it is created, so a process that + // creates thousands would otherwise hold every replay's rows for its whole + // life; the reader that needs one (the default organization's owner bind) + // asks in the same call that created the organization. + let lastReplay: { organizationId: string; landed: Map> } | undefined; return { snapshot(): SeedSettlementSnapshot { @@ -75,6 +120,14 @@ function createTracker(): SeedSettlementTracker { suppressed: [...suppressed], }; }, + seededRecordIds(object: string, organizationId?: string): readonly string[] { + const index = organizationId === undefined + ? bootLanded + : lastReplay?.organizationId === organizationId ? lastReplay.landed : undefined; + const ids = index?.get(object); + // Copy, for the same reason as `suppressed` above. + return ids ? [...ids] : []; + }, __declare(): SeedSourceHandle { inFlight += 1; let closed = false; @@ -90,8 +143,16 @@ function createTracker(): SeedSettlementTracker { inFlight -= 1; suppressed.push(reason); }, + publishLanded(report: SeedLandingReport) { + addLanded(bootLanded, report); + }, }; }, + __recordOrganizationReplay(organizationId: string, report: SeedLandingReport): void { + const landed = new Map>(); + addLanded(landed, report); + lastReplay = { organizationId, landed }; + }, }; } @@ -146,3 +207,20 @@ export function declareSeedSource(ctx: unknown): SeedSourceHandle { export function readSeedSettlement(ctx: unknown): SeedSettlementSnapshot | undefined { return readTracker(ctx)?.snapshot(); } + +/** + * Record the rows one per-organization replay landed on, replacing the previous + * replay's — `seededRecordIds(object, organizationId)` answers for the most + * recent replay only (see the contract for why that bound is the design). + * + * Best-effort like the rest of the seed wiring: on a kernel with no tracker + * there is no reader either, so nothing is lost by doing nothing. + */ +export function recordOrganizationReplay( + ctx: unknown, + organizationId: string, + report: SeedLandingReport, +): void { + if (!organizationId) return; + readTracker(ctx)?.__recordOrganizationReplay(organizationId, report); +} diff --git a/packages/spec/authorable-defaults/data.json b/packages/spec/authorable-defaults/data.json index fc90cd51c59..d389ef57f0f 100644 --- a/packages/spec/authorable-defaults/data.json +++ b/packages/spec/authorable-defaults/data.json @@ -101,6 +101,7 @@ "data/Seed:externalId = \"name\"", "data/Seed:mode = \"upsert\"", "data/SeedLoadResult:errors = []", + "data/SeedLoadResult:landedIds = []", "data/SeedLoadResult:referencesDropped = 0", "data/SeedLoadResult:summariesStale = 0", "data/SeedLoaderConfig:batchSize = 1000", diff --git a/packages/spec/authorable-surface/data.json b/packages/spec/authorable-surface/data.json index 6f0a8bdf791..495553c5e89 100644 --- a/packages/spec/authorable-surface/data.json +++ b/packages/spec/authorable-surface/data.json @@ -909,6 +909,7 @@ "data/SeedLoadResult:errored", "data/SeedLoadResult:errors", "data/SeedLoadResult:inserted", + "data/SeedLoadResult:landedIds", "data/SeedLoadResult:mode", "data/SeedLoadResult:object", "data/SeedLoadResult:referencesDeferred", diff --git a/packages/spec/src/contracts/seed-settlement.ts b/packages/spec/src/contracts/seed-settlement.ts index 491c6047d77..8f31b5d6239 100644 --- a/packages/spec/src/contracts/seed-settlement.ts +++ b/packages/spec/src/contracts/seed-settlement.ts @@ -98,12 +98,43 @@ export interface SeedSettlementSnapshot { * * Deliberately read-only: declaring and settling sources is the seeding * host's own business, and a consumer that could mutate the tally could - * certify itself. The surface is one method because one question is all the - * ADR-0104 attestation needs. + * certify itself. {@link snapshot} answers the ADR-0104 attestation's one + * question; {@link seededRecordIds} answers the seed-ownership claim's. */ export interface ISeedSettlementService { /** Read the live tally. Never cached by the caller — re-read per use. */ snapshot(): SeedSettlementSnapshot; + + /** + * "Which rows of `object` did this process's seed write?" — the ids of the + * stored rows the seed loader reported landing on (`SeedLoadResult.landedIds`): + * inserted, updated, or matched by key on a replay. + * + * ## Two readings, chosen by `organizationId` + * + * - **absent** — every boot-time seed source of this process (the inline + * seed `app:seeded` announces). A source publishes its rows before it + * settles, so a consumer running inside `app:seeded` already sees them. + * A later boot replays its seed and reports the rows an earlier boot + * wrote, so the reading holds across boots. + * - **present** — the per-organization replay of that organization (the + * multi-tenant seed, run when the organization is inserted). The host keeps + * the rows of the most recent per-organization replay only, so the + * reading answers for that organization and is empty for every other one. + * Bounded on purpose: an organization is replayed once, when it is created, + * and a process that creates thousands must not hold every replay's rows. + * Ask right after the replay you need — the default organization's + * owner bind does, in the same call that created it. + * + * Empty when nothing seeded the object, and never a guess: a row this does + * not name may be a guest's, a public form's or a hook's, and ADR-0138 D1 + * leaves its owner as its writer left it. + * + * Optional, because a host may publish the tally without the rows. A + * consumer that finds it absent has no reading of which rows the seed wrote, + * and treats every unowned row as one it cannot place. + */ + seededRecordIds?(object: string, organizationId?: string): readonly string[]; } /** Service-registry slot for {@link ISeedSettlementService}. */ diff --git a/packages/spec/src/data/seed-loader.zod.ts b/packages/spec/src/data/seed-loader.zod.ts index 9b92135e2f7..d0b0b482b81 100644 --- a/packages/spec/src/data/seed-loader.zod.ts +++ b/packages/spec/src/data/seed-loader.zod.ts @@ -402,6 +402,27 @@ export const SeedLoadResultSchema = lazySchema(() => z.object({ summariesStale: z.number().int().min(0).default(0) .describe('Roll-up summary values left stale by writes for this dataset'), + /** + * The ids of the stored rows this dataset's records landed on: a row a + * record inserted, a row it updated, and a row its key matched on a replay + * and left as it was. Each id appears once, in record order. Empty on a + * dry run, which writes nothing and matches nothing. + * + * It is the loader's own answer to "which rows did this seed write?" — the + * reference and key resolution behind it (natural keys, references resolved + * to the target's id, per-organization re-identification) is the loader's, + * and a reader that wants the seed's rows reads them here rather than + * re-deriving them from the dataset. A replay reports the rows an earlier + * load wrote, because a matched row is recorded as well as a written one, + * so the answer holds across boots for every keyed dataset. + * + * The seed-ownership claim is the reader it exists for (ADR-0138 D1): it + * hands to the platform admin only the unowned rows the seed landed on, and + * leaves every other unowned row as its writer left it. + */ + landedIds: z.array(z.string()).default([]) + .describe('Ids of the stored rows this dataset landed on: inserted, updated, or matched on replay'), + /** Reference resolution errors for this object */ errors: z.array(ReferenceResolutionErrorSchema).default([]) .describe('Reference resolution errors'), diff --git a/scripts/adr-anchors/packages__plugins__plugin-security__src__security-plugin.ts.json b/scripts/adr-anchors/packages__plugins__plugin-security__src__security-plugin.ts.json index 44884320ffc..75565e3c22e 100644 --- a/scripts/adr-anchors/packages__plugins__plugin-security__src__security-plugin.ts.json +++ b/scripts/adr-anchors/packages__plugins__plugin-security__src__security-plugin.ts.json @@ -3,7 +3,8 @@ "adrs": [ "ADR-0055", "ADR-0106", - "ADR-0123" + "ADR-0123", + "ADR-0138" ], - "invariant": "ADR-0055 §1 — `resolveCbpRelation` derives a `controlled_by_parent` object's master from exactly one `master_detail` field declared `required: true` that names a reference, and from nothing else: zero such fields or two or more resolve `null`, which both halves treat as fail-closed (reads answer RLS_DENY_FILTER, writes throw MasterDetailRelationMissingError, 422 INVALID_METADATA), reported once per object per cache generation. Do NOT restore the retired fallback tiers (any `master_detail`, then a required `lookup`) or a first-by-field-order pick among two: they were never part of ADR-0055 §1, `ObjectSchema` refuses those shapes at parse, and a gate that derives access from a relation the parse refuses is the disagreement the step-18 semantic migration `cbp-master-relation-tiers-retired` closed. The reference-SPELLING tolerance in the same reader is a different, deliberate decision and stays.\n\nADR-0106 D7 — getMetadataReadableFields differs from getReadableFields in exactly one place, and the asymmetry is the decision. On the DATA plane a caller resolving to zero permission sets falls OPEN, mirroring the engine middleware, because reporting a narrowing the data path would not enforce is its own drift. On the METADATA plane the same caller resolves the configured fallback permission set (the two-step /auth/me/permissions performs), so a guest-facing deployment's schema exposure is a deliberate permission-set decision rather than an accidental everything-default. Converging the two methods in either direction reverses this.\n\nADR-0123 D2 — an authenticated session with NO active organization is a legal state with fail-closed semantics, and the two sides are deliberately ASYMMETRIC: tenant-scoped reads resolve to nothing (Layer 0's deny sentinel, silent, HTTP 200) while tenant-scoped insert/update are REFUSED with a 403 whose message names the missing active organization. Step 3.7's no-active-organization refusal is that write half. Deleting it, or 'simplifying' it into the sibling forge guard, restores the state the ruling closed: the forge guard validates SUPPLIED organization_id values only, so a payload naming no tenant written by a caller having no tenant walks past it, lands with organization_id NULL, and is then hidden by the read wall from every reader including its own author. `delete` is absent on purpose (it places no row; the Layer 0 row wall already resolves its target to nothing), and the refusal must stay ordered AHEAD of the forge guard so a caller with no organization scope is told that, not that the value they supplied is foreign." + "invariant": "ADR-0055 §1 — `resolveCbpRelation` derives a `controlled_by_parent` object's master from exactly one `master_detail` field declared `required: true` that names a reference, and from nothing else: zero such fields or two or more resolve `null`, which both halves treat as fail-closed (reads answer RLS_DENY_FILTER, writes throw MasterDetailRelationMissingError, 422 INVALID_METADATA), reported once per object per cache generation. Do NOT restore the retired fallback tiers (any `master_detail`, then a required `lookup`) or a first-by-field-order pick among two: they were never part of ADR-0055 §1, `ObjectSchema` refuses those shapes at parse, and a gate that derives access from a relation the parse refuses is the disagreement the step-18 semantic migration `cbp-master-relation-tiers-retired` closed. The reference-SPELLING tolerance in the same reader is a different, deliberate decision and stays.\n\nADR-0106 D7 — getMetadataReadableFields differs from getReadableFields in exactly one place, and the asymmetry is the decision. On the DATA plane a caller resolving to zero permission sets falls OPEN, mirroring the engine middleware, because reporting a narrowing the data path would not enforce is its own drift. On the METADATA plane the same caller resolves the configured fallback permission set (the two-step /auth/me/permissions performs), so a guest-facing deployment's schema exposure is a deliberate permission-set decision rather than an accidental everything-default. Converging the two methods in either direction reverses this.\n\nADR-0123 D2 — an authenticated session with NO active organization is a legal state with fail-closed semantics, and the two sides are deliberately ASYMMETRIC: tenant-scoped reads resolve to nothing (Layer 0's deny sentinel, silent, HTTP 200) while tenant-scoped insert/update are REFUSED with a 403 whose message names the missing active organization. Step 3.7's no-active-organization refusal is that write half. Deleting it, or 'simplifying' it into the sibling forge guard, restores the state the ruling closed: the forge guard validates SUPPLIED organization_id values only, so a payload naming no tenant written by a caller having no tenant walks past it, lands with organization_id NULL, and is then hidden by the read wall from every reader including its own author. `delete` is absent on purpose (it places no row; the Layer 0 row wall already resolves its target to nothing), and the refusal must stay ordered AHEAD of the forge guard so a caller with no organization scope is told that, not that the value they supplied is foreign.\n\nADR-0138 D1 — for the guest envelope the owner stays unset, and only the scenario's own writers (a hook, a flow, an assignment rule) set it; so the seed-ownership claim this file runs on every `app:seeded` (`claimSeedOwnershipOnSettle`) and at the first promotion re-owns a NULL-owner row only when the seed reported landing on it (`readSeededRecordIds`, the `seed-settlement` contract's `seededRecordIds`), and widening that arm back to every unowned row hands guest-written and public-form rows to the platform admin on the next boot." } diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 918e52dac27..9d94f7a49b4 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -1771,6 +1771,21 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/metadata-protocol/src/seed-loader-landed-ids.test.ts", + "verb": "delete", + "pinned": 1 + }, + { + "file": "packages/metadata-protocol/src/seed-loader-landed-ids.test.ts", + "verb": "findOne", + "pinned": 1 + }, + { + "file": "packages/metadata-protocol/src/seed-loader-landed-ids.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/metadata-protocol/src/seed-loader-locale-scope.test.ts", "verb": "delete", @@ -3911,6 +3926,16 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/runtime/src/app-plugin.seed-landed.test.ts", + "verb": "findOne", + "pinned": 1 + }, + { + "file": "packages/runtime/src/app-plugin.seed-landed.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/runtime/src/app-plugin.seed-locale-producer.test.ts", "verb": "findOne",