diff --git a/.changeset/15196-declared-positions-catalog-read.md b/.changeset/15196-declared-positions-catalog-read.md new file mode 100644 index 0000000000..f3a9433d17 --- /dev/null +++ b/.changeset/15196-declared-positions-catalog-read.md @@ -0,0 +1,17 @@ +--- +'@objectstack/plugin-security': patch +--- + +A position saved through the metadata door no longer stops an app's declared positions from being seeded + +Clause-②: no + +Every seeding pass copies the app's declared positions into the `sys_position` catalog: the boot's pass and, on a walled deployment, the pass each new organization gets. That seeder read one source or the other: the engine registry alone whenever it held any position besides the six built-ins, otherwise the metadata service. A position a platform administrator saves with `PUT /api/v1/meta/position/:name` lives in the registry. So after one such save, every later pass seeded that position and none of the app's own. Measured on the showcase, walled: an organization created after the save held 7 positions (the six built-ins and the saved one) instead of 17. + +The seeder now reads through the security catalog read (`createSecurityCatalogReader`). It reads both sources on every pass, and for a name both hold, the registry answers first. + +- **New organizations** get the built-ins, every declared position and every door-saved one. Measured on the same walled showcase: 17 positions. +- **Rows are written as before.** The door-saved position's row is unchanged: same name, label and description, `active: true`, `is_default: false`, no provenance stamp. For a name both sources hold, the registry's definition is still the one written. +- **The six built-in positions** are still written only by the built-in seeder, with `managed_by: 'platform'`. Some deployments saved a definition under a built-in name before the six were declared, and that definition still shadows the declaration when the catalog is read. This seeder skips it by name, so it never reaches a row. +- **A catalog read that fails** now writes nothing for that pass, and the boot reports it once as `[security] declared-position seeding failed`. That covers a source that cannot be read and a metadata list that lost a loader. Before, the seeder seeded whatever part it could read, without saying so. +- **No grant changes.** A principal's permissions move only if it holds a position that is newly seeded into its organization. diff --git a/packages/plugins/plugin-security/src/bootstrap-declared-positions.test.ts b/packages/plugins/plugin-security/src/bootstrap-declared-positions.test.ts index c2460a0aa3..5f822d73ae 100644 --- a/packages/plugins/plugin-security/src/bootstrap-declared-positions.test.ts +++ b/packages/plugins/plugin-security/src/bootstrap-declared-positions.test.ts @@ -13,17 +13,47 @@ */ import { describe, it, expect } from 'vitest'; +import { SchemaRegistry } from '@objectstack/objectql'; import { bootstrapDeclaredPositions } from './bootstrap-declared-positions.js'; -import { securityBuiltinPositions } from './builtin-positions.js'; +import { registerBuiltinPositions, securityBuiltinPositions } from './builtin-positions.js'; +import { SECURITY_PLUGIN_ID } from './manifest.js'; -/** Minimal in-memory ql for sys_position seeding. */ -function makeQl(declared: any[] = []) { +/** + * The metadata service of a deployment that declares no position in it — the + * catalog read takes both sources and refuses to be built over a missing one. + */ +const NO_METADATA_POSITIONS = { get: async () => undefined, list: async () => [] }; + +/** + * A registry over a fixed list, as the catalog read uses one: its list, its + * by-name read (first match), and no disabled package. + */ +function listRegistry(items: () => any[]) { + return { + listItems: (type: string) => (type === 'position' ? [...items()] : []), + getItem: (type: string, name: string) => (type === 'position' ? items().find((i) => i?.name === name) : undefined), + isPackageDisabled: () => false, + }; +} + +/** A metadata service holding `items` as its declared positions. */ +const metadataListing = (items: any[]) => ({ + get: async (type: string, name: string) => (type === 'position' ? items.find((i) => i.name === name) : undefined), + list: async (type: string) => (type === 'position' ? items.map((i) => ({ ...i })) : []), +}); + +/** + * Minimal in-memory ql for sys_position seeding. `registry` replaces the + * list-backed one when a case needs the real engine registry's by-name + * precedence. + */ +function makeQl(declared: any[] = [], registry?: unknown) { const rows: any[] = []; return { rows, // [#8378] Items are surfaced as the real engine surfaces them — the // document itself, not a `{ content: }` box that nothing produces. - registry: { listItems: (type: string) => (type === 'position' ? [...declared] : []) }, + registry: registry ?? listRegistry(() => declared), async find(object: string, q: any) { if (object !== 'sys_position') return []; const where = q?.where ?? {}; @@ -57,7 +87,7 @@ function makeQl(declared: any[] = []) { describe('bootstrapDeclaredPositions (#2909 T2 — seed-only semantics locked)', () => { it('inserts new declared positions with identity + display fields only', async () => { const ql = makeQl([{ name: 'contributor', label: 'Contributor', description: 'Does work' }]); - const r = await bootstrapDeclaredPositions(ql, null); + const r = await bootstrapDeclaredPositions(ql, NO_METADATA_POSITIONS); expect(r.seeded).toBe(1); const row = ql.rows[0]; expect(row).toMatchObject({ name: 'contributor', label: 'Contributor', active: true, is_default: false }); @@ -72,7 +102,7 @@ describe('bootstrapDeclaredPositions (#2909 T2 — seed-only semantics locked)', id: 'pos_1', name: 'contributor', label: 'Contributor', description: 'old', active: true, is_default: false, }); - const r = await bootstrapDeclaredPositions(ql, null); + const r = await bootstrapDeclaredPositions(ql, NO_METADATA_POSITIONS); expect(r.updated).toBe(1); expect(ql.rows[0].label).toBe('Contributor v2'); expect(ql.rows[0].description).toBe('new text'); @@ -87,7 +117,7 @@ describe('bootstrapDeclaredPositions (#2909 T2 — seed-only semantics locked)', active: false, is_default: true, delegatable: true, managed_by: 'package', permissions: ['something_admin_set'], }); - await bootstrapDeclaredPositions(ql, null); + await bootstrapDeclaredPositions(ql, NO_METADATA_POSITIONS); const row = ql.rows[0]; expect(row.active).toBe(false); expect(row.is_default).toBe(true); @@ -100,32 +130,23 @@ describe('bootstrapDeclaredPositions (#2909 T2 — seed-only semantics locked)', it('is idempotent — a re-run inserts nothing new', async () => { const ql = makeQl([{ name: 'a', label: 'A' }, { name: 'b', label: 'B' }]); - const r1 = await bootstrapDeclaredPositions(ql, null); + const r1 = await bootstrapDeclaredPositions(ql, NO_METADATA_POSITIONS); expect(r1.seeded).toBe(2); - const r2 = await bootstrapDeclaredPositions(ql, null); + const r2 = await bootstrapDeclaredPositions(ql, NO_METADATA_POSITIONS); expect(r2.seeded).toBe(0); expect(ql.rows).toHaveLength(2); }); }); /** - * [ADR-0131 C2 S2] What the seeder reads now that the six built-in positions - * are declared metadata: the same registry-first two-step as before, with the - * six taken out of both its decision and its result. - * - * The plugin registers the six with the engine registry on every boot. Two - * things must survive that: the stack-declared positions the metadata service - * holds still seed (six registered names must not make the registry "hold a - * position" and silence them), and the six stay `bootstrapBuiltinRoles`'s rows - * — this seeder writes no copy of them and refreshes none of their columns. + * [ADR-0131 C2 S2] The six built-in positions are declared metadata, which the + * catalog read lists; they stay `bootstrapBuiltinRoles`'s rows — this seeder + * writes no copy of them and refreshes none of their columns. */ describe('bootstrapDeclaredPositions — the six built-in positions are not this seeder’s', () => { const builtins = () => securityBuiltinPositions.map((p) => ({ ...p })); - const metadataListing = (items: any[]) => ({ - list: async (type: string) => (type === 'position' ? items.map((i) => ({ ...i })) : []), - }); - it('reads the metadata service when the registry holds the six and nothing else', async () => { + it('seeds the metadata service’s positions while the registry holds the six', async () => { const ql = makeQl(builtins()); const r = await bootstrapDeclaredPositions( ql, @@ -135,13 +156,6 @@ describe('bootstrapDeclaredPositions — the six built-in positions are not this expect(r).toEqual({ seeded: 2, updated: 0, unchanged: 0, unreadable: 0 }); }); - it('keeps the two-step otherwise: a registry holding another position answers alone, minus the six', async () => { - const ql = makeQl([...builtins(), { name: 'door_authored', label: 'Door Authored' }]); - const r = await bootstrapDeclaredPositions(ql, metadataListing([{ name: 'field_rep', label: 'Field Rep' }])); - expect(ql.rows.map((row) => row.name)).toEqual(['door_authored']); - expect(r).toEqual({ seeded: 1, updated: 0, unchanged: 0, unreadable: 0 }); - }); - it('writes nothing for the six on a fresh organization — no copy ahead of the built-in pass', async () => { const ql = makeQl(builtins()); const r = await bootstrapDeclaredPositions(ql, metadataListing(builtins()), { organizationId: 'org_a' }); @@ -164,3 +178,122 @@ describe('bootstrapDeclaredPositions — the six built-in positions are not this expect(r).toEqual({ seeded: 1, updated: 0, unchanged: 0, unreadable: 0 }); }); }); + +/** + * [ADR-0131 C2 S2b] The seeder reads through the security catalog read — the + * engine registry and the metadata service, in its one read order — where it + * used to take the registry ALONE whenever the registry held any position + * besides the six. + * + * The registry here is the real `SchemaRegistry`, so a name's answer is the + * registry's own by-name precedence, not a fixture's: the six registered the + * way the plugin registers them (packaged, composite keys), and a definition a + * metadata author saved hydrated the way the door hydrates one (no package, the + * bare slot). + */ +describe('bootstrapDeclaredPositions — one catalog read, both sources (ADR-0131 C2 S2b)', () => { + const declaredRegistry = (...authored: Array>) => { + const registry = new SchemaRegistry(); + for (const item of authored) registry.registerItem('position', { ...item }, 'name'); + registerBuiltinPositions(registry, SECURITY_PLUGIN_ID); + return registry; + }; + const byName = (rows: any[]) => Object.fromEntries(rows.map((row) => [row.name, row])); + + it('a door-authored position no longer silences the stack’s declared positions', async () => { + const ql = makeQl([], declaredRegistry({ name: 'door_authored', label: 'Door Authored' })); + const r = await bootstrapDeclaredPositions( + ql, + metadataListing([{ name: 'field_rep', label: 'Field Rep' }, { name: 'auditor', label: 'Auditor' }]), + { organizationId: 'org_late' }, + ); + expect(ql.rows.map((row) => row.name).sort()).toEqual(['auditor', 'door_authored', 'field_rep']); + expect(r).toEqual({ seeded: 3, updated: 0, unchanged: 0, unreadable: 0 }); + }); + + it('writes the door-authored position’s row as the registry-only read wrote it', async () => { + const ql = makeQl([], declaredRegistry({ name: 'door_authored', label: 'Door Authored' })); + await bootstrapDeclaredPositions(ql, metadataListing([{ name: 'field_rep', label: 'Field Rep' }])); + const { id, ...row } = byName(ql.rows).door_authored; + expect(id).toMatch(/^position_/); + expect(row).toEqual({ name: 'door_authored', label: 'Door Authored', description: null, active: true, is_default: false }); + }); + + // The read order, measured: for a name BOTH sources hold, the registry's body + // is the one written — what the either-or wrote too, because the registry + // holding that name was itself what made the registry answer. + it('a name both sources hold is written from the registry’s body', async () => { + const ql = makeQl([], declaredRegistry({ name: 'field_rep', label: 'Field Rep (saved at the door)', description: 'Door text' })); + const r = await bootstrapDeclaredPositions( + ql, + metadataListing([{ name: 'field_rep', label: 'Field Rep', description: 'Declared text' }]), + ); + expect(ql.rows.map((row) => [row.name, row.label, row.description])).toEqual([ + ['field_rep', 'Field Rep (saved at the door)', 'Door text'], + ]); + expect(r).toEqual({ seeded: 1, updated: 0, unchanged: 0, unreadable: 0 }); + }); + + // A definition saved under a built-in name before the six were declared is + // hydrated into the bare slot and shadows the declaration at read. Positive + // control first: the registry really answers the stored body for the name. + it('a stored definition shadowing a built-in name is neither seeded nor restamped', async () => { + // Both hydration shapes: stated tenant-authored only (hydrated before the + // six were registered), and with the declaration's envelope grafted on + // (hydrated after) — which names THIS plugin's package. + const shadows = [ + { name: 'org_admin', label: 'Repurposed Org Admin', description: 'Saved at the door', _provenance: 'org' }, + { name: 'everyone', label: 'Repurposed Everyone', description: 'Saved at the door', _provenance: 'org', _packageId: SECURITY_PLUGIN_ID }, + ]; + const registry = declaredRegistry(...shadows); + expect((registry.getItem('position', 'org_admin') as any)?.label).toBe('Repurposed Org Admin'); + expect((registry.getItem('position', 'everyone') as any)?.label).toBe('Repurposed Everyone'); + + const seeded = securityBuiltinPositions.map((p, i) => ({ + id: `pos_builtin_${i}`, name: p.name, label: p.label, description: p.description, + managed_by: 'platform', active: true, is_default: false, + })); + const fresh = makeQl([], registry); + const onFresh = await bootstrapDeclaredPositions(fresh, NO_METADATA_POSITIONS, { organizationId: 'org_a' }); + expect(fresh.rows).toEqual([]); + expect(onFresh).toEqual({ seeded: 0, updated: 0, unchanged: 0, unreadable: 0 }); + + const existing = makeQl([], registry); + existing.rows.push(...seeded.map((row) => ({ ...row }))); + const onExisting = await bootstrapDeclaredPositions(existing, NO_METADATA_POSITIONS); + expect(existing.rows).toEqual(seeded); + expect(onExisting).toEqual({ seeded: 0, updated: 0, unchanged: 0, unreadable: 0 }); + }); + + // A read that did not happen is not "nothing declared": the seeder writes + // nothing and the failure reaches its caller, which reports it. + it('writes nothing when a catalog source cannot be read, and says so', async () => { + const ql = makeQl([{ name: 'field_rep', label: 'Field Rep' }]); + (ql.registry as any).listItems = () => { throw new Error('registry unreachable'); }; + const failure = await bootstrapDeclaredPositions(ql, NO_METADATA_POSITIONS).then( + () => undefined, + (e: any) => ({ code: e?.code, status: e?.status }), + ); + expect(failure).toEqual({ code: 'SERVICE_UNAVAILABLE', status: 503 }); + expect(ql.rows).toEqual([]); + + const degraded = makeQl([{ name: 'field_rep', label: 'Field Rep' }]); + const lostLoader = { + ...NO_METADATA_POSITIONS, + listDiagnosed: async () => ({ items: [{ name: 'auditor', label: 'Auditor' }], degraded: true, errors: ['loader down'] }), + }; + const partial = await bootstrapDeclaredPositions(degraded, lostLoader).then( + () => undefined, + (e: any) => ({ code: e?.code, status: e?.status }), + ); + expect(partial).toEqual({ code: 'SERVICE_UNAVAILABLE', status: 503 }); + expect(degraded.rows).toEqual([]); + }); + + it('refuses a composition with no metadata service rather than reading the registry alone', async () => { + const ql = makeQl([{ name: 'field_rep', label: 'Field Rep' }]); + const failure = await bootstrapDeclaredPositions(ql, null).then(() => undefined, (e: unknown) => e); + expect(failure).toBeInstanceOf(TypeError); + expect(ql.rows).toEqual([]); + }); +}); diff --git a/packages/plugins/plugin-security/src/bootstrap-declared-positions.ts b/packages/plugins/plugin-security/src/bootstrap-declared-positions.ts index b01a3963f7..186c393ec6 100644 --- a/packages/plugins/plugin-security/src/bootstrap-declared-positions.ts +++ b/packages/plugins/plugin-security/src/bootstrap-declared-positions.ts @@ -4,8 +4,10 @@ * bootstrapDeclaredPositions — seed stack-declared `positions` into `sys_position` * (ADR-0057 D6, closes #2077). * - * Reads the validated `position` metadata (registered from the stack's `positions: []` - * via `metadataService.list('position')`) and idempotently upserts each into + * Reads the declared `position` metadata through the security catalog read + * (`createSecurityCatalogReader`, `@objectstack/core` — the engine registry and + * the metadata service, in its one read order; see {@link readDeclaredPositions}) + * and idempotently upserts each into * `sys_position` by `(name, organization_id)`, so the runtime position→permission-set resolution * (`resolveExecutionContext` → `sys_position` → `sys_position_permission_set`) and * sharing-rule position recipients stop being decorative. Runs on `kernel:ready` @@ -25,6 +27,7 @@ * loud guard that stands in place of a reap: `per-organization-catalog.ts`. */ +import { createSecurityCatalogReader } from '@objectstack/core'; import { buildExistingByName } from './seed-name-lookup.js'; import { isBuiltinPositionName } from './builtin-positions.js'; import { @@ -83,66 +86,65 @@ interface SeedOptions { organizationId?: string; } -/** - * Read declared metadata items of a type from the engine's SchemaRegistry. - * - * [#8378] No `{ name, content }` unwrap: the registered item IS the authoring - * document. `PositionSchema` declares no `content` key and rejects one as - * unrecognized, so the unwrap could only ever have destroyed a document — - * see `bootstrap-declared-permissions.ts` for the full measurement. - */ -function readDeclared(engine: any, type: string): any[] { - try { - const reg = engine?.registry; - if (reg?.listItems) { - return (reg.listItems(type) ?? []).filter(Boolean); - } - } catch { /* fall through */ } - return []; -} - /** A declared position this seeder owns: any name but the six built-ins. */ -function isSeededHere(item: any): boolean { +function isSeededHere(item: { name?: unknown } | undefined): boolean { return !isBuiltinPositionName(item?.name); } /** - * The positions this seeder projects into rows: the engine registry's, else - * the metadata service's — the same two-step as before — with the six built-in - * positions taken out of both the decision and the result. + * The positions this seeder projects into rows: every position the security + * catalog read lists, minus the six built-ins (ADR-0131 C2 stage S2b). + * + * ## One read, both sources (ADR-0131 D3) + * + * The catalog read (`createSecurityCatalogReader`, `@objectstack/core`) is the + * union of the engine registry and the metadata service, in its one read + * order: a name the registry serves is answered by the registry's own by-name + * precedence (a stored definition in the bare slot first, else the + * first-registered package's), and the metadata service answers only the names + * the registry does not serve. Neither source holds the whole catalog — the + * stack-declared positions live in the metadata service, a definition a + * metadata author saved through the door is hydrated into the registry — so + * this seeder reads both, every pass. * - * ## Why the six are taken out (ADR-0131 C2 stage S2) + * It replaces an either-or: the registry alone whenever it held any position + * besides the six, else the metadata service. One door-authored position was + * then enough to make the registry answer alone, and every organization + * created afterwards was seeded with that position and none of the stack's. + * For a name both sources hold, the answer is unchanged: the either-or took + * the registry's body for it, and so does the catalog read. * - * The six are declared position metadata of this plugin now - * (`builtin-positions.ts`), registered with the engine registry on every boot. - * Left in, they would change this read twice over: + * ## Why the six are taken out * - * - **the decision.** The registry is read first and the metadata service only - * when the registry holds no position, so six registered names would make - * the registry answer every boot — and the stack-declared positions, which - * only the metadata service holds, would never be read again. - * - **the result.** Their rows are `bootstrapBuiltinRoles`'s, seeded from the - * same list with the `platform` provenance this seeder never writes. Taking - * them here would put a copy without that provenance ahead of the built-in - * pass on a fresh organization (refused outright for a reserved identity - * name), which the built-in pass then restamps: a second writer for six rows - * that have one. + * Their rows are `bootstrapBuiltinRoles`'s, seeded from the declaration list + * (`builtin-positions.ts`) with the `platform` provenance this seeder never + * writes. The catalog read lists them — the plugin registers them with the + * engine registry on every boot — so taking them here would put a copy without + * that provenance ahead of the built-in pass on a fresh organization (refused + * outright for a reserved identity name), which the built-in pass then + * restamps: a second writer for six rows that have one. The exclusion is by + * NAME, never by package: an environment-stored definition saved under a + * built-in name before the six were declared is hydrated into the registry's + * bare slot with no package of this plugin's, and shadows the declaration at + * read. It is skipped here like the declaration it shadows, so its body never + * reaches a row. * - * So the registry "holds a position" only when it holds one besides the six, - * and the six never reach the loop. With the six out, both the decision and the - * result are exactly what they were before the six were declared — that is the - * whole of this change; the two-step itself is not touched. + * ## A read that did not happen is not "nothing declared" + * + * The catalog read refuses to be built over a source that lacks a member it + * calls (`TypeError`) and raises `AuthzStoreUnavailableError` for a source that + * threw or a metadata list that lost a loader. Both propagate: this seeder + * writes nothing for the pass rather than seeding a partial catalog as a whole + * one, and the caller reports the failure. + * + * [#8378] No `{ name, content }` unwrap: the catalog entry's `definition` IS the + * authoring document — see `bootstrap-declared-permissions.ts`. It is the + * reader's own object, shared with every other reader, and is only read here. */ async function readDeclaredPositions(engine: any, metadataService: any): Promise { - const registered = readDeclared(engine, 'position'); - let positions: unknown = registered; - if (!registered.some(isSeededHere)) { - try { - const listed = metadataService?.list?.('position'); - positions = typeof (listed as any)?.then === 'function' ? await listed : (listed ?? []); - } catch { positions = []; } - } - return Array.isArray(positions) ? positions.filter(isSeededHere) : []; + const catalog = createSecurityCatalogReader({ registry: engine?.registry, metadata: metadataService }); + const listed = await catalog.list('position'); + return listed.filter(isSeededHere).map((entry) => entry.definition); } /** diff --git a/packages/plugins/plugin-security/src/bootstrap-seed-round-trips.test.ts b/packages/plugins/plugin-security/src/bootstrap-seed-round-trips.test.ts index e8a43fd4ec..efe84a4d28 100644 --- a/packages/plugins/plugin-security/src/bootstrap-seed-round-trips.test.ts +++ b/packages/plugins/plugin-security/src/bootstrap-seed-round-trips.test.ts @@ -47,6 +47,22 @@ import { bootstrapDeclaredPositions } from './bootstrap-declared-positions.js'; import { bootstrapDeclaredCapabilities } from './bootstrap-declared-capabilities.js'; import { bootstrapSystemCapabilities, KNOWN_CAPABILITIES } from './bootstrap-system-capabilities.js'; +/** + * The engine registry as the security catalog read uses one — its list, its + * by-name read and its disabled-package question — over a fixed list. The + * declared-positions seeder reads through that catalog read (ADR-0131 C2 S2b), + * which also takes the metadata service: {@link NO_METADATA_POSITIONS} is one + * that declares nothing, so every position here comes from the registry. + */ +function catalogRegistry(type: string, items: () => any[]) { + return { + listItems: (t: string) => (t === type ? [...items()] : []), + getItem: (t: string, name: string) => (t === type ? items().find((i) => i?.name === name) : undefined), + isPackageDisabled: () => false, + }; +} +const NO_METADATA_POSITIONS = { get: async () => undefined, list: async () => [] }; + interface CountingQl { rows: any[]; calls: { find: number; insert: number; update: number }; @@ -56,7 +72,7 @@ interface CountingQl { wheres: any[]; roundTrips(): number; reset(): void; - registry: { listItems: (type: string) => any[] }; + registry: ReturnType; find(object: string, q: any, opts?: any): Promise; insert(object: string, data: any, opts?: any): Promise; update(object: string, data: any, options?: any): Promise; @@ -106,7 +122,7 @@ function makeCountingQl( wheres: [], roundTrips() { return this.calls.find + this.calls.insert + this.calls.update; }, reset() { this.calls = { find: 0, insert: 0, update: 0 }; this.log = []; this.wheres = []; }, - registry: { listItems: (type: string) => (type === metadataType ? [...declared] : []) }, + registry: catalogRegistry(metadataType, () => declared), async find(obj: string, q: any) { if (obj !== object) return []; ql.calls.find += 1; @@ -217,9 +233,9 @@ describe('#10946 — steady-state rebuild is O(1) round trips (positions)', () = it('does not grow the rebuild round-trip count with the number of declared positions', async () => { const measure = async (n: number) => { const ql = positionQl(declaredPositions(n)); - await bootstrapDeclaredPositions(ql, null); + await bootstrapDeclaredPositions(ql, NO_METADATA_POSITIONS); ql.reset(); - const r = await bootstrapDeclaredPositions(ql, null); + const r = await bootstrapDeclaredPositions(ql, NO_METADATA_POSITIONS); expect(r.seeded).toBe(0); expect(r.updated).toBe(0); expect(r.unchanged).toBe(n); @@ -232,9 +248,9 @@ describe('#10946 — steady-state rebuild is O(1) round trips (positions)', () = it('issues ONE batched `$in` existence read for the whole declaration', async () => { const ql = positionQl(declaredPositions(12)); - await bootstrapDeclaredPositions(ql, null); + await bootstrapDeclaredPositions(ql, NO_METADATA_POSITIONS); ql.reset(); - await bootstrapDeclaredPositions(ql, null); + await bootstrapDeclaredPositions(ql, NO_METADATA_POSITIONS); expect(ql.calls.find).toBe(1); expect(ql.wheres[0]).toEqual({ name: { $in: declaredPositions(12).map((p) => p.name) } }); }); @@ -277,14 +293,14 @@ describe('#10946 — drift STILL reconciles', () => { it('a position row whose stored label/description differ still gets its UPDATE', async () => { const ql = positionQl(declaredPositions(20)); - await bootstrapDeclaredPositions(ql, null); + await bootstrapDeclaredPositions(ql, NO_METADATA_POSITIONS); const upgraded = declaredPositions(20); upgraded[3] = { ...upgraded[3], label: 'Renamed', description: 'new text' }; - (ql as any).registry = { listItems: (t: string) => (t === 'position' ? upgraded : []) }; + (ql as any).registry = catalogRegistry('position', () => upgraded); ql.reset(); - const r = await bootstrapDeclaredPositions(ql, null); + const r = await bootstrapDeclaredPositions(ql, NO_METADATA_POSITIONS); expect(r.updated).toBe(1); expect(r.unchanged).toBe(19); expect(ql.calls.update).toBe(1); @@ -299,7 +315,7 @@ describe('#10946 — drift STILL reconciles', () => { id: 'pos_1', name: 'contributor', label: 'Contributor', description: 'old', active: false, is_default: true, delegatable: true, managed_by: 'package', }); - await bootstrapDeclaredPositions(ql, null); + await bootstrapDeclaredPositions(ql, NO_METADATA_POSITIONS); const row = ql.rows[0]; expect(row.label).toBe('Contributor v2'); expect(row.active).toBe(false); @@ -330,13 +346,13 @@ describe('#10946 — a genuinely NEW declaration is still created', () => { it('the batched read does not turn "absent" into "present" (positions)', async () => { const ql = positionQl(declaredPositions(5)); - await bootstrapDeclaredPositions(ql, null); + await bootstrapDeclaredPositions(ql, NO_METADATA_POSITIONS); const grown = [...declaredPositions(5), { name: 'pkg_pos_new', label: 'New', description: null }]; - (ql as any).registry = { listItems: (t: string) => (t === 'position' ? grown : []) }; + (ql as any).registry = catalogRegistry('position', () => grown); ql.reset(); - const r = await bootstrapDeclaredPositions(ql, null); + const r = await bootstrapDeclaredPositions(ql, NO_METADATA_POSITIONS); expect(r.seeded).toBe(1); expect(r.unchanged).toBe(5); expect(ql.roundTrips()).toBe(2); @@ -393,11 +409,11 @@ describe('#10946 — a read that CANNOT ANSWER is not the answer "none exist"', it('a throwing read does NOT re-create rows that are already seeded (positions)', async () => { const seeded = positionQl(declaredPositions(4)); - await bootstrapDeclaredPositions(seeded, null); + await bootstrapDeclaredPositions(seeded, NO_METADATA_POSITIONS); const broken = positionQl(declaredPositions(4), { findThrows: true }); broken.rows.push(...seeded.rows.map((r) => ({ ...r }))); - const r = await bootstrapDeclaredPositions(broken, null); + const r = await bootstrapDeclaredPositions(broken, NO_METADATA_POSITIONS); expect(r.seeded).toBe(0); expect(r.unreadable).toBe(4); expect(broken.calls.insert).toBe(0); diff --git a/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts b/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts index a852d2828d..5deaa48890 100644 --- a/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts +++ b/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts @@ -1,10 +1,11 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * [ADR-0131 D2, C2 stage S2] The six built-in positions as declared metadata, - * read off a REAL boot of this plugin: what the catalog read lists, what the - * boot writes into `sys_position`, what every principal is granted, and which - * position writes are still refused. + * [ADR-0131 D2, C2 stages S2 and S2b] The six built-in positions as declared + * metadata, and the declared-positions seeder reading through the security + * catalog read, read off a REAL boot of this plugin: what the catalog read + * lists, what the boot writes into `sys_position`, what every principal is + * granted, and which position writes are still refused. * * The stage moves where the six come from — one declaration list, registered * with the engine registry and read by the built-in seeder — and keeps the @@ -19,26 +20,40 @@ * * ## The scenarios * + * Three postures: + * * - `single` — the organization-less pass, no organization at boot; * - `single + organization` — the same pass with an organization and its * memberships present (the shape a `single` deployment with the Default * Organization has); * - `walled` — one pass per organization at boot, then one more organization * created after it (the organization-creation path); - * - `walled, a door-authored position in the registry` — the same, with one - * position already registered the way a metadata author's saved definition - * is hydrated (no package). It is the state in which the declared-positions - * seeder's registry-first two-step takes the registry, so it is where a - * seeder that took the six would show it. + * + * each booted three ways: + * + * - as it is; + * - with `a door-authored position in the registry` — one position registered + * the way a metadata author's saved definition is hydrated (no package); + * - with `a stored definition under a built-in name` — `org_admin` and + * `everyone` saved the same way before the six were declared, so each + * shadows its declaration at read (the registry's bare slot answers first). * * ## The goldens * - * The row census, the `sys_position` write ledger and the grant envelopes - * were recorded from the tree BEFORE the declarations existed (objectstack - * `51290bca2c`, this file run against that tree's sources; the PR record - * carries the run) and are unchanged after them. The catalog listing is the - * one reading that changes: before, the catalog read listed the stack's - * position and nothing else. + * The row census, the `sys_position` write ledger and the grant envelopes of + * the three postures as they are were recorded from the tree BEFORE the + * declarations existed (objectstack `51290bca2c`, this file run against that + * tree's sources; S2's PR record carries the run) and are unchanged since. + * + * S2b changes the census and the ledger in one place, the door-authored + * scenarios: the declared-positions seeder took the registry ALONE whenever it + * held a position besides the six, so the door-authored position silenced the + * stack's `field_rep` in every pass — and in every organization created later. + * It now reads both sources, so `field_rep` is seeded beside it. The + * door-authored position's own rows are what the registry-only read wrote + * (a separate pin, green on both sides of S2b), the stored definitions under + * a built-in name change no row and no write, and no principal's grants move + * in any scenario: none of them holds a position S2b newly seeds. */ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; @@ -94,7 +109,10 @@ const APP_DEFAULT = { _packageId: 'com.example.field', } as unknown as PermissionSet; -type ScenarioName = 'single' | 'single + organization' | 'walled' | 'walled, a door-authored position in the registry'; +type PostureName = 'single' | 'single + organization' | 'walled'; +const DOOR = 'a door-authored position in the registry'; +const SHADOW = 'a stored definition under a built-in name'; +type ScenarioName = PostureName | `${PostureName}, ${typeof DOOR}` | `${PostureName}, ${typeof SHADOW}`; interface Scenario { readonly posture: 'single' | 'isolated'; /** Organizations (with the memberships) present before `kernel:ready`. */ @@ -103,15 +121,32 @@ interface Scenario { readonly lateOrganization: boolean; /** A package-less position in the registry before the boot (a hydrated door-authored definition). */ readonly authoredPosition: boolean; + /** Package-less definitions under built-in names in the registry before the boot ({@link SHADOWING_DEFINITIONS}). */ + readonly shadowedBuiltins: boolean; } -const SCENARIOS: Record = { - single: { posture: 'single', organizationAtBoot: false, lateOrganization: false, authoredPosition: false }, - 'single + organization': { posture: 'single', organizationAtBoot: true, lateOrganization: false, authoredPosition: false }, - walled: { posture: 'isolated', organizationAtBoot: true, lateOrganization: true, authoredPosition: false }, - 'walled, a door-authored position in the registry': { - posture: 'isolated', organizationAtBoot: true, lateOrganization: true, authoredPosition: true, - }, +const POSTURES: Record> = { + single: { posture: 'single', organizationAtBoot: false, lateOrganization: false }, + 'single + organization': { posture: 'single', organizationAtBoot: true, lateOrganization: false }, + walled: { posture: 'isolated', organizationAtBoot: true, lateOrganization: true }, }; +const SCENARIOS = Object.fromEntries( + (Object.keys(POSTURES) as PostureName[]).flatMap((name) => [ + [name, { ...POSTURES[name], authoredPosition: false, shadowedBuiltins: false }], + [`${name}, ${DOOR}`, { ...POSTURES[name], authoredPosition: true, shadowedBuiltins: false }], + [`${name}, ${SHADOW}`, { ...POSTURES[name], authoredPosition: false, shadowedBuiltins: true }], + ]), +) as Record; + +/** + * Environment-wide definitions a metadata author saved under two built-in + * names before the six were declared — one identity name, one audience + * anchor — stated tenant-authored, as the door's hydration states every stored + * body. In the registry's bare slot they shadow the declarations at read. + */ +const SHADOWING_DEFINITIONS = [ + { name: 'org_admin', label: 'Repurposed Org Admin', description: 'Saved at the door', _provenance: 'org' }, + { name: 'everyone', label: 'Repurposed Everyone', description: 'Saved at the door', _provenance: 'org' }, +]; const sortDeep = (value: unknown): unknown => { if (Array.isArray(value)) return value.map(sortDeep).sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))); @@ -176,6 +211,11 @@ async function boot(scenario: Scenario): Promise { // How a metadata author's saved definition is hydrated: no package. (engine as any).registry.registerItem('position', { name: 'door_authored', label: 'Door Authored' }, 'name'); } + if (scenario.shadowedBuiltins) { + for (const definition of SHADOWING_DEFINITIONS) { + (engine as any).registry.registerItem('position', { ...definition }, 'name'); + } + } // The write ledger: every `sys_position` insert and update, refused ones // included, named by the row they write. @@ -394,20 +434,42 @@ for (const name of Object.keys(SCENARIOS) as ScenarioName[]) { const { engine, metadata } = booted.get(name)!; const reader = createSecurityCatalogReader({ registry: engine.registry as any, metadata: metadata as any }); const listed = (await reader.list('position')).map((e) => `${e.name}@${e.source}${e.packageId ? `:${e.packageId}` : ''}`).sort(); + const shadowed = new Set(scenario.shadowedBuiltins ? SHADOWING_DEFINITIONS.map((d) => d.name) : []); expect(listed).toEqual([ ...(scenario.authoredPosition ? ['door_authored@registry'] : []), - ...BUILTIN_NAMES.map((n) => `${n}@registry:${SECURITY_PLUGIN_ID}`), + ...BUILTIN_NAMES.map((n) => (shadowed.has(n) ? `${n}@registry` : `${n}@registry:${SECURITY_PLUGIN_ID}`)), 'field_rep@metadata', ].sort()); + // The shadowing state is real: the catalog read answers the stored body + // for the two names, not the declaration. + if (scenario.shadowedBuiltins) { + for (const definition of SHADOWING_DEFINITIONS) { + expect((await reader.resolve('position', definition.name))?.definition.label).toBe(definition.label); + } + } }); - // P2.2 — the rows, and who wrote them, are what they were before. - it('seeds the same sys_position rows, written by the same seeders, as before the declarations', () => { + // P2.2 — the rows, and who wrote them: the built-ins, every stack-declared + // position and every door-authored one, in every organization (S2b). + it('seeds the built-ins, the stack-declared positions and the door-authored ones', () => { const { census, ledger } = booted.get(name)!; expect({ census, ledger }).toEqual(CATALOG_GOLDEN[name]); }); - if (scenario.organizationAtBoot && !scenario.authoredPosition) { + if (scenario.authoredPosition) { + // S2b moves which positions seed beside the door-authored one, never + // that one's own rows: the same row, written once per pass, with what the + // registry-only read wrote. + it('writes the door-authored position’s rows as the registry-only read wrote them', () => { + const { census, ledger } = booted.get(name)!; + expect({ + census: census.filter((line) => line.startsWith('door_authored ')), + ledger: ledger.filter((line) => line.includes(' door_authored@')), + }).toEqual(DOOR_AUTHORED_ROWS[scenario.lateOrganization ? 'walled' : 'single']); + }); + } + + if (scenario.organizationAtBoot) { it('grants every principal what it was granted before the declarations', async () => { const { engine } = booted.get(name)!; expect(await grantsByPrincipal(engine, scenario.posture)).toEqual(GRANT_GOLDEN[scenario.posture]); @@ -474,8 +536,8 @@ const TEXT: Record = { 'Door Authored | -', }; -/** Recorded before the declarations (module doc). */ -const CATALOG_GOLDEN: Record = { +/** The three postures as they are: recorded before the declarations (module doc), unchanged by S2b. */ +const PLAIN_GOLDEN: Record = { single: { census: [ `everyone | - | platform | true | false | ${TEXT.everyone}`, @@ -550,25 +612,43 @@ const CATALOG_GOLDEN: Record = { + single: { + census: [ + `door_authored | - | admin | true | false | ${TEXT.door_authored}`, + ...PLAIN_GOLDEN.single.census, + ], + ledger: [ + 'insert door_authored@-', + ...PLAIN_GOLDEN.single.ledger, + ], + }, + 'single + organization': { + census: [ + `door_authored | - | admin | true | false | ${TEXT.door_authored}`, + ...PLAIN_GOLDEN['single + organization'].census, + ], + ledger: [ + 'insert door_authored@-', + ...PLAIN_GOLDEN['single + organization'].ledger, + ], + }, + walled: { census: [ `door_authored | org_eq | admin | true | false | ${TEXT.door_authored}`, `door_authored | org_late | admin | true | false | ${TEXT.door_authored}`, - `everyone | org_eq | platform | true | false | ${TEXT.everyone}`, - `everyone | org_late | platform | true | false | ${TEXT.everyone}`, - `guest | org_eq | platform | true | false | ${TEXT.guest}`, - `guest | org_late | platform | true | false | ${TEXT.guest}`, - `org_admin | org_eq | platform | true | false | ${TEXT.org_admin}`, - `org_admin | org_late | platform | true | false | ${TEXT.org_admin}`, - `org_member | org_eq | platform | true | false | ${TEXT.org_member}`, - `org_member | org_late | platform | true | false | ${TEXT.org_member}`, - `org_owner | org_eq | platform | true | false | ${TEXT.org_owner}`, - `org_owner | org_late | platform | true | false | ${TEXT.org_owner}`, - `platform_admin | org_eq | platform | true | false | ${TEXT.platform_admin}`, - `platform_admin | org_late | platform | true | false | ${TEXT.platform_admin}`, + ...PLAIN_GOLDEN.walled.census, ], ledger: [ 'insert door_authored@org_eq', + 'insert field_rep@org_eq', 'insert platform_admin@org_eq managed_by=platform', 'insert org_owner@org_eq managed_by=platform', 'insert org_admin@org_eq managed_by=platform', @@ -576,6 +656,7 @@ const CATALOG_GOLDEN: Record [ + [name, PLAIN_GOLDEN[name]], + [`${name}, ${DOOR}`, DOOR_GOLDEN[name]], + [`${name}, ${SHADOW}`, PLAIN_GOLDEN[name]], + ]), +) as Record; + +/** + * The door-authored position's own rows and writes — the same before S2b + * (the registry-only read) and after it. + */ +const DOOR_AUTHORED_ROWS: Record<'single' | 'walled', { census: string[]; ledger: string[] }> = { + single: { + census: [`door_authored | - | admin | true | false | ${TEXT.door_authored}`], + ledger: ['insert door_authored@-'], + }, + walled: { + census: [ + `door_authored | org_eq | admin | true | false | ${TEXT.door_authored}`, + `door_authored | org_late | admin | true | false | ${TEXT.door_authored}`, + ], + ledger: ['insert door_authored@org_eq', 'insert door_authored@org_late'], + }, +}; + /** * Recorded before the declarations (module doc). `field_default` on every * human principal is the `everyone` binding; `single` also binds the diff --git a/packages/plugins/plugin-security/src/builtin-positions.ts b/packages/plugins/plugin-security/src/builtin-positions.ts index 74018460e5..4e6064abba 100644 --- a/packages/plugins/plugin-security/src/builtin-positions.ts +++ b/packages/plugins/plugin-security/src/builtin-positions.ts @@ -22,12 +22,11 @@ * `is_default: false`. * * And one exclusion: `bootstrapDeclaredPositions` skips every name here - * ({@link isBuiltinPositionName}), in its registry-first decision and in its - * result. The six are declared now, so they appear among the registry's - * positions; counted there, they would make the registry answer alone and - * silence the stack-declared positions only the metadata service holds, and - * taken there, they would get a copy without the `platform` provenance ahead - * of the built-in pass, which then restamps it. + * ({@link isBuiltinPositionName}). It seeds from the security catalog read, + * which lists the six now that they are declared; taken there, they would get + * a copy without the `platform` provenance ahead of the built-in pass, which + * then restamps it. The exclusion is by name, so an environment-stored + * definition that shadows one of the six at read is skipped as well. * * ## Why the engine registry, and not the manifest's `positions` key * diff --git a/packages/plugins/plugin-security/src/claim-seed-ownership.ts b/packages/plugins/plugin-security/src/claim-seed-ownership.ts index fa63cffb37..c4898cbfcc 100644 --- a/packages/plugins/plugin-security/src/claim-seed-ownership.ts +++ b/packages/plugins/plugin-security/src/claim-seed-ownership.ts @@ -66,10 +66,10 @@ * Hooks that plugins register in code carry no metadata binding and still run * on every claimed row: plugin-audit's writer, capability gates and * plugin-sharing's rule projection. The opt-out can never bypass audit or - * sharing (#2922). ObjectQL's own `sys_stamp_audit_*` builtins are bound - * through the hook binder, so they carry metadata and are skipped as well; for - * this write that changes nothing — the claim has no user to stamp into - * `updated_by`, and the drivers stamp `updated_at` themselves. + * sharing (#2922). ObjectQL's own `sys_stamp_audit_*` builtins are registered + * in code too (#22070), so they also run on every claimed row; for this write + * they stamp `updated_at` only, because the claim has no user to stamp into + * `updated_by`. * * Measured before this on hotcrm `56d98f7e` (17.7.0, a 354-row seed): the * first sign-up waited ~45 s while the claim fired 1 254 app hooks, ran 8 flows, diff --git a/packages/plugins/plugin-security/src/per-organization-catalog.test.ts b/packages/plugins/plugin-security/src/per-organization-catalog.test.ts index c380624c48..26efad2b17 100644 --- a/packages/plugins/plugin-security/src/per-organization-catalog.test.ts +++ b/packages/plugins/plugin-security/src/per-organization-catalog.test.ts @@ -63,14 +63,22 @@ afterEach(async () => { } }); -/** One declared position and one packaged permission set to seed. */ +/** + * One declared position and one packaged permission set to seed. The by-name + * read and the disabled-package question are the members the security catalog + * read adds to `listItems` — the declared-positions seeder reads through it + * (ADR-0131 C2 S2b), with {@link NO_METADATA_POSITIONS} as its metadata service. + */ const STUB_REGISTRY = { listItems: (type: string) => { if (type === 'position') return [{ name: 'sales_manager', label: 'Sales Manager' }]; if (type === 'permission') return [{ name: 'sales_readonly', label: 'Sales RO', _packageId: 'com.acme.crm', objects: {} }]; return []; }, + getItem: (type: string, name: string) => STUB_REGISTRY.listItems(type).find((item) => item.name === name), + isPackageDisabled: () => false, }; +const NO_METADATA_POSITIONS = { get: async () => undefined, list: async () => [] }; /** A logger that records what the seeders said, so a LOUD guard can be asserted. */ function recordingLogger() { @@ -153,7 +161,7 @@ async function stored(engine: ObjectQL, table: string): Promise { /** Run the three catalog seeders for one organization (walled), or none (single). */ async function seedCatalog(engine: ObjectQL, logger: any, organizationId?: string): Promise { const ql = withRegistry(engine); - await bootstrapDeclaredPositions(ql, null, { logger, organizationId }); + await bootstrapDeclaredPositions(ql, NO_METADATA_POSITIONS, { logger, organizationId }); await bootstrapDeclaredPermissions(ql, null, { logger, organizationId }); await bootstrapBuiltinRoles(ql, { logger, organizationId }); } diff --git a/packages/plugins/plugin-security/src/seed-write-refusal.test.ts b/packages/plugins/plugin-security/src/seed-write-refusal.test.ts index ba870adb35..b59e8e313c 100644 --- a/packages/plugins/plugin-security/src/seed-write-refusal.test.ts +++ b/packages/plugins/plugin-security/src/seed-write-refusal.test.ts @@ -142,6 +142,9 @@ function makeWarnOnlyLogger() { }; } +/** A metadata service that declares no position: every position here comes from the registry. */ +const NO_METADATA_POSITIONS = { get: async () => undefined, list: async () => [] }; + /** * `sys_position` double whose INSERT is vetoed the way a legacy platform-wide * unique index vetoes it: the row never lands and the driver throws. @@ -158,7 +161,14 @@ function makeQl( const rows: any[] = []; return { rows, - registry: { listItems: (type: string) => (type === 'position' ? [...declared] : []) }, + // The members the security catalog read takes from the engine registry — + // the declared-positions seeder reads through it (ADR-0131 C2 S2b), with + // `NO_METADATA_POSITIONS` as the metadata service. + registry: { + listItems: (type: string) => (type === 'position' ? [...declared] : []), + getItem: (type: string, name: string) => (type === 'position' ? declared.find((d) => d?.name === name) : undefined), + isPackageDisabled: () => false, + }, async find(object: string, q: any) { if (object !== 'sys_position') return []; const where = q?.where ?? {}; @@ -229,7 +239,7 @@ describe('a unique-violation refusal during catalog seeding is boot-visible', () // ⭐ Resolves rather than rejects. A rethrow would turn a silent // degradation into a boot failure on every deployment carrying the legacy // index — a behaviour change this repair deliberately does not make. - const r = await bootstrapDeclaredPositions(ql, null, { logger, organizationId: 'org_1' }); + const r = await bootstrapDeclaredPositions(ql, NO_METADATA_POSITIONS, { logger, organizationId: 'org_1' }); // The seed really did land nothing — the defect's precondition holds. expect(r.seeded).toBe(0); @@ -276,7 +286,7 @@ describe('a unique-violation refusal during catalog seeding is boot-visible', () ); const ql = makeQl(THREE_POSITIONS, { insertThrows: () => leaky }); - await bootstrapDeclaredPositions(ql, null, { logger, organizationId: 'org_1' }); + await bootstrapDeclaredPositions(ql, NO_METADATA_POSITIONS, { logger, organizationId: 'org_1' }); const serialized = JSON.stringify(refusalLines(warns, errors)); // [#8682] This is a server LOG, which is exactly the boundary the bound- @@ -298,7 +308,7 @@ describe('a unique-violation refusal during catalog seeding is boot-visible', () organization_id: 'org_1', }); - const r = await bootstrapDeclaredPositions(ql, null, { logger, organizationId: 'org_1' }); + const r = await bootstrapDeclaredPositions(ql, NO_METADATA_POSITIONS, { logger, organizationId: 'org_1' }); expect(r.updated).toBe(0); const refusals = refusalLines(warns, errors); @@ -321,7 +331,7 @@ describe('the refusal warning is aggregated, not one line per refused row', () = })); const ql = makeQl(many, { insertThrows: mysqlDuplicateEntry }); - const r = await bootstrapDeclaredPositions(ql, null, { logger, organizationId: 'org_1' }); + const r = await bootstrapDeclaredPositions(ql, NO_METADATA_POSITIONS, { logger, organizationId: 'org_1' }); expect(r.seeded).toBe(0); const refusals = refusalLines(warns, errors); @@ -357,7 +367,7 @@ describe('a refusal that is not a unique violation keeps its own class', () => { const { logger, warns, errors } = makeLogger(); const ql = makeQl(THREE_POSITIONS, { insertThrows: connectionFailure }); - await bootstrapDeclaredPositions(ql, null, { logger, organizationId: 'org_1' }); + await bootstrapDeclaredPositions(ql, NO_METADATA_POSITIONS, { logger, organizationId: 'org_1' }); // ⭐ FUNCTIONAL channel, deliberately. Escalating a retrying outage to // `error` is the over-application that trains everyone to skim `error`, @@ -521,7 +531,7 @@ describe('the two classes take different log levels (AGENTS.md degradation rule) const { logger, warns, errors } = makeLogger(); const ql = makeQl(THREE_POSITIONS, { insertThrows: postgresUniqueViolation }); - await bootstrapDeclaredPositions(ql, null, { logger, organizationId: 'org_1' }); + await bootstrapDeclaredPositions(ql, NO_METADATA_POSITIONS, { logger, organizationId: 'org_1' }); expect(errors).toHaveLength(1); expect(refusalLines(warns)).toHaveLength(0); @@ -537,7 +547,7 @@ describe('a pass that is not refused reports exactly what it did before', () => const { logger, warns, errors } = makeLogger(); const ql = makeQl(THREE_POSITIONS); - const r = await bootstrapDeclaredPositions(ql, null, { logger, organizationId: 'org_1' }); + const r = await bootstrapDeclaredPositions(ql, NO_METADATA_POSITIONS, { logger, organizationId: 'org_1' }); expect(r).toMatchObject({ seeded: 3, updated: 0, unchanged: 0, unreadable: 0 }); expect(ql.rows).toHaveLength(3); diff --git a/packages/plugins/plugin-security/vitest.config.ts b/packages/plugins/plugin-security/vitest.config.ts index f04dc185ea..c4174dff68 100644 --- a/packages/plugins/plugin-security/vitest.config.ts +++ b/packages/plugins/plugin-security/vitest.config.ts @@ -14,6 +14,12 @@ export default defineConfig({ disableConsoleIntercept: true, globals: true, environment: 'node', + // #13517: quiet the registry's per-item registration chatter — the + // engine's own `OS_REGISTRY_LOG` seam, not a change to its shipped + // default. Enforced by scripts/check-registry-log-declared.mjs: the + // declared-positions seeder's suite constructs a `SchemaRegistry` to read + // the registry's own by-name precedence. + env: { OS_REGISTRY_LOG: 'warn' }, }, resolve: { // [#8577] Both entries exist for `suggested-audience-bindings-install-path.test.ts`,