From 80d39094d2ae7be972c180c5c74d5a821f04b058 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 01:43:51 +0000 Subject: [PATCH 1/8] wip(spec,runtime,plugin-security,organizations): seed-ownership claims re-own only the rows the seed landed on Claude-Session: https://claude.ai/code/session_01LFqdRsuM5nkqoJdHd5ztVk Co-authored-by: Claude --- packages/metadata-protocol/src/seed-loader.ts | 14 ++ .../src/claim-org-seed-ownership.ts | 85 +++++++++--- .../src/ensure-default-organization.ts | 6 + .../organizations/src/organizations-plugin.ts | 30 ++++- .../src/bootstrap-platform-admin.ts | 16 ++- .../src/claim-seed-ownership.ts | 126 +++++++++++++++--- .../plugin-security/src/security-plugin.ts | 31 ++++- packages/runtime/src/app-plugin.ts | 29 +++- packages/runtime/src/seed-settlement.ts | 80 ++++++++++- .../spec/src/contracts/seed-settlement.ts | 35 ++++- packages/spec/src/data/seed-loader.zod.ts | 21 +++ ...gin-security__src__security-plugin.ts.json | 5 +- 12 files changed, 426 insertions(+), 52 deletions(-) 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.ts b/packages/plugins/organizations/src/claim-org-seed-ownership.ts index d60e2ef023e..350a97b028b 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_PAGE_ROWS} 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 @@ -52,11 +71,12 @@ * `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. + * `{ organization_id: organizationId, owner_id: null, id: { $in: chunk } }`, + * as `multi: true` — one write per chunk of {@link CLAIM_PAGE_ROWS} seeded + * ids (#23354; see "Only the rows the org's seed replay wrote" above). The + * predicate is the claim's whole selection rule, so the write matches + * exactly the rows it selects, without a read and without a cap. That is the + * path every organization takes in practice, 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} @@ -68,8 +88,9 @@ * ({@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 + * The page write keeps the whole predicate and narrows its `id IN` to the page + * (a subset of the chunk the page was read under), 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 @@ -94,6 +115,14 @@ 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; } /** @@ -194,8 +223,8 @@ function idsFrom(rows: unknown): string[] { 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; + /** At most one page of ids the claim's predicate `where` still matches. */ + readPage: (where: Record) => Promise; } /** @@ -234,7 +263,7 @@ async function claimUnowned( } // Refused whole: more rows than one write may fan per-row hooks over. - const ids = idsFrom(await io.readPage()); + const ids = idsFrom(await io.readPage(unowned)); if (ids.length === 0) { logger?.warn?.( `[org-scoping] claimOrgSeedOwnership could not page ${objectName} in org ${organizationId}: the write ` + @@ -296,9 +325,10 @@ 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. + * (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 seeded ids, 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. */ @@ -319,6 +349,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 +368,33 @@ 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( + readPage: (where) => ql.find( schema.name, - { where: unowned, limit: CLAIM_PAGE_ROWS, fields: ['id'] }, + { where, limit: CLAIM_PAGE_ROWS, fields: ['id'] }, { context: SYSTEM_CTX }, ), }; - const updated = await claimUnowned(io, schema.name, organizationId, unowned, logger); + let updated = 0; + for (let at = 0; at < seededIds.length; at += CLAIM_PAGE_ROWS) { + const unowned = { + organization_id: organizationId, + owner_id: null, + id: { $in: seededIds.slice(at, at + CLAIM_PAGE_ROWS) }, + }; + updated += await claimUnowned(io, schema.name, organizationId, unowned, 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/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.ts b/packages/plugins/plugin-security/src/claim-seed-ownership.ts index ae4bfca26a3..62e286fc2c7 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 @@ -159,6 +184,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. */ @@ -225,16 +262,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 +314,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 +388,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 +407,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 +421,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 +433,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 +443,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 +500,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 +514,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 +527,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 +547,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 +569,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 +596,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 +629,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 +649,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/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.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-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/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." } From 2bed785f84eccaf774e25b9c3ffcc6c0f860b29d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 01:54:03 +0000 Subject: [PATCH 2/8] chore(spec): regenerate artifacts for SeedLoadResult.landedIds Claude-Session: https://claude.ai/code/session_01LFqdRsuM5nkqoJdHd5ztVk Co-authored-by: Claude --- content/docs/references/data/seed-loader.mdx | 2 ++ packages/spec/authorable-defaults/data.json | 1 + packages/spec/authorable-surface/data.json | 1 + 3 files changed, 4 insertions(+) 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/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", From 130fb163db80f17e745bb00640d5af8032cf3d46 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 02:09:11 +0000 Subject: [PATCH 3/8] test(metadata-protocol,runtime): pin landedIds and the seed tracker's seeded-row readings Claude-Session: https://claude.ai/code/session_01LFqdRsuM5nkqoJdHd5ztVk Co-authored-by: Claude --- .../src/seed-loader-landed-ids.test.ts | 278 ++++++++++++++++++ .../src/app-plugin.seed-landed.test.ts | 194 ++++++++++++ packages/runtime/src/seed-settlement.test.ts | 111 ++++++- 3 files changed, 582 insertions(+), 1 deletion(-) create mode 100644 packages/metadata-protocol/src/seed-loader-landed-ids.test.ts create mode 100644 packages/runtime/src/app-plugin.seed-landed.test.ts 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/runtime/src/app-plugin.seed-landed.test.ts b/packages/runtime/src/app-plugin.seed-landed.test.ts new file mode 100644 index 00000000000..882022d9e52 --- /dev/null +++ b/packages/runtime/src/app-plugin.seed-landed.test.ts @@ -0,0 +1,194 @@ +// 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. + */ + +/** A faithful in-memory engine: `where` filters, ids minted on insert. */ +function createEngine() { + const store: Record = {}; + let next = 0; + const matches = (row: any, where: Record = {}) => + Object.entries(where).every(([k, v]) => { + if (k.startsWith('$')) throw new Error(`engine double: unsupported operator ${k}`); + return (row[k] ?? null) === (v ?? null); + }); + const engine: any = { + store, + async find(object: string, query?: any) { + let rows = (store[object] ?? []).filter((r) => matches(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/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(); + }); +}); From 8e435b3fd24af0989460aca9c49c9308c797187e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 02:13:20 +0000 Subject: [PATCH 4/8] test(plugin-security): pin the claim to the seed's reported rows across boots Claude-Session: https://claude.ai/code/session_01LFqdRsuM5nkqoJdHd5ztVk Co-authored-by: Claude --- ...latform-admin-authenticable-target.test.ts | 11 +- .../claim-seed-ownership-dispatch.pin.test.ts | 49 +++- ...m-seed-ownership-seed-settle-rerun.test.ts | 92 ++++++- .../claim-seed-ownership-warm-boot.test.ts | 33 ++- .../src/claim-seed-ownership.test.ts | 242 +++++++++++++++--- .../src/engine-find-bare-array.pin.test.ts | 6 +- .../src/read-fault-distinguishability.test.ts | 12 +- 7 files changed, 385 insertions(+), 60 deletions(-) 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/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/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([]); From f84a79c2be07bf838f42bf83b84d88bca5744c41 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 02:20:59 +0000 Subject: [PATCH 5/8] fix(organizations): the org seed-ownership claim re-owns only the rows its seed replay reported Claude-Session: https://claude.ai/code/session_01LFqdRsuM5nkqoJdHd5ztVk Co-authored-by: Claude --- ...im-org-seed-ownership-dispatch.pin.test.ts | 60 ++-- .../src/claim-org-seed-ownership.test.ts | 219 ++++++++------ .../src/claim-org-seed-ownership.ts | 268 +++++------------- 3 files changed, 233 insertions(+), 314 deletions(-) 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 350a97b028b..9557b70aaa7 100644 --- a/packages/plugins/organizations/src/claim-org-seed-ownership.ts +++ b/packages/plugins/organizations/src/claim-org-seed-ownership.ts @@ -29,7 +29,7 @@ * `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_PAGE_ROWS} ids. It is the selector its single-tenant + * 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 @@ -54,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 @@ -65,48 +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, id: { $in: chunk } }`, - * as `multi: true` — one write per chunk of {@link CLAIM_PAGE_ROWS} seeded - * ids (#23354; see "Only the rows the org's seed replay wrote" above). The - * predicate is the claim's whole selection rule, so the write matches - * exactly the rows it selects, without a read and without a cap. That is the - * path every organization takes in practice, 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 narrows its `id IN` to the page - * (a subset of the chunk the page was read under), 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'; @@ -125,18 +109,6 @@ interface ClaimOwnershipOptions { 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" @@ -147,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. - */ -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. + * 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 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 @@ -204,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. + * Re-own the rows one chunk's predicate matches, in one write. Returns the + * affected-row count it resolved. * - * 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. + * 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. */ -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 `where` still matches. */ - readPage: (where: Record) => 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. - * - * 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. - */ -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(unowned)); - 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; } } @@ -327,10 +209,9 @@ function hasField(schema: ServiceObject, field: string): boolean { * (c) declare BOTH `owner_id` and `organization_id`, * (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 seeded ids, 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. + * `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, @@ -340,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'); @@ -371,29 +252,24 @@ export async function claimOrgSeedOwnership( 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 io: ObjectClaimIo = { - reown: (where) => ql.update( - schema.name, - { owner_id: ownerUserId }, - { where, multi: true, context: CLAIM_WRITE_CTX }, - ), - readPage: (where) => ql.find( - schema.name, - { where, 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 }, + ); let updated = 0; - for (let at = 0; at < seededIds.length; at += CLAIM_PAGE_ROWS) { - const unowned = { + 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_PAGE_ROWS) }, + id: { $in: seededIds.slice(at, at + CLAIM_CHUNK_IDS) }, }; - updated += await claimUnowned(io, schema.name, organizationId, unowned, logger); + updated += await claimChunk(reown, schema.name, organizationId, where, logger); } if (updated > 0) results.push({ object: schema.name, count: updated }); } From 4820aef3e9b2bdeaf78b091a8365f67168120a8e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 02:23:55 +0000 Subject: [PATCH 6/8] docs(plugin-security): measured id-IN claim cost; changesets; engine-double ledger rows Claude-Session: https://claude.ai/code/session_01LFqdRsuM5nkqoJdHd5ztVk Co-authored-by: Claude --- .changeset/23354-seed-claim-seed-rows.md | 16 ++++++++++ .changeset/23354-seed-landed-rows.md | 14 +++++++++ .../src/claim-seed-ownership.ts | 31 ++++++++++++++----- scripts/engine-double-contract.pinned.json | 25 +++++++++++++++ 4 files changed, 78 insertions(+), 8 deletions(-) create mode 100644 .changeset/23354-seed-claim-seed-rows.md create mode 100644 .changeset/23354-seed-landed-rows.md 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/packages/plugins/plugin-security/src/claim-seed-ownership.ts b/packages/plugins/plugin-security/src/claim-seed-ownership.ts index 62e286fc2c7..420d36eaa8d 100644 --- a/packages/plugins/plugin-security/src/claim-seed-ownership.ts +++ b/packages/plugins/plugin-security/src/claim-seed-ownership.ts @@ -119,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 * @@ -157,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 @@ -224,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 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", From 70a9ae6008bb5dd324dd6acb9f20e28cf7ff5529 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 03:13:20 +0000 Subject: [PATCH 7/8] test(runtime,organizations): module-scope refusing matcher; alias spec/contracts to source Claude-Session: https://claude.ai/code/session_01LFqdRsuM5nkqoJdHd5ztVk Co-authored-by: Claude --- .../plugins/organizations/vitest.config.ts | 4 ++++ .../src/app-plugin.seed-landed.test.ts | 22 ++++++++++++++----- 2 files changed, 20 insertions(+), 6 deletions(-) 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/runtime/src/app-plugin.seed-landed.test.ts b/packages/runtime/src/app-plugin.seed-landed.test.ts index 882022d9e52..cbaad3c40d9 100644 --- a/packages/runtime/src/app-plugin.seed-landed.test.ts +++ b/packages/runtime/src/app-plugin.seed-landed.test.ts @@ -23,19 +23,29 @@ import type { PluginContext } from '@objectstack/core'; * 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 matches = (row: any, where: Record = {}) => - Object.entries(where).every(([k, v]) => { - if (k.startsWith('$')) throw new Error(`engine double: unsupported operator ${k}`); - return (row[k] ?? null) === (v ?? null); - }); const engine: any = { store, async find(object: string, query?: any) { - let rows = (store[object] ?? []).filter((r) => matches(r, query?.where)); + 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 })); }, From 2c1dd397bfc7c161eb1f110638e3ef73e6533c3d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 04:29:47 +0000 Subject: [PATCH 8/8] test(runtime): the replayer test's fake loader returns the contract's results array SeedLoaderResultSchema requires results (one entry per dataset, empty when nothing loaded) and the real loader returns it on every path. The fake answered without it, a shape the producer never produces, so the replayer's hand-off of result.results to the seed-settlement tracker threw on it. Claude-Session: https://claude.ai/code/session_01LFqdRsuM5nkqoJdHd5ztVk Co-authored-by: Claude --- packages/runtime/src/seed-datasets.test.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) 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