Skip to content

Commit 17e4425

Browse files
fix(plugin-security): the declared-positions seeder reads through the security catalog read (ADR-0131 C2 stage S2b) (#22210)
Part of #15196 Clause-②: no **Stage S2b of ADR-0131 C2: the declared-positions seeder reads through S1's catalog read.** `bootstrapDeclaredPositions` used to read one source or the other. It took the engine registry alone whenever the registry held any position besides the six built-ins, and the metadata service otherwise. A position a platform administrator saves with `PUT /api/v1/meta/position/:name` is hydrated into the registry. So one such save made every later seeding pass write that position and none of the stack's declared ones, including the pass each new organization gets on a walled deployment. The seeder now reads `createSecurityCatalogReader(...).list('position')` (`@objectstack/core`, from PR #22091). That read takes the engine registry and the metadata service in the catalog's one read order, every pass. The six built-ins are still excluded, by name, so `bootstrapBuiltinRoles` stays their only writer. Measured on a booted showcase, walled. A platform administrator saves `zz_s2b_door` at the door, then a new organization is created. Before, that organization's `sys_position` catalog held 7 rows: the six built-ins and `zz_s2b_door`, with none of the showcase's ten declared positions. After, it holds 17 rows: the six, the ten and `zz_s2b_door`. The `zz_s2b_door` row reads `admin | true | false | S2b Door` on both sides. The rest of #15196 stays open. No reader moves to the catalog read here (S8), no Setup write changes (S7), `bootstrapBuiltinRoles`' rows do not change, and `packages/spec` and `security-plugin.ts` are untouched. ## The read order, measured before any row write changed For a name both sources hold, S1's read answers the registry's body, through the registry's own by-name precedence: a stored definition in the bare slot first, else the first-registered package's. The old seeder answered the same, because a non-built-in name in the registry was itself what made the registry answer alone. The pin `a name both sources hold is written from the registry's body` uses the real `SchemaRegistry` with a door-saved `field_rep` beside a declared `field_rep`. It writes the door-saved label and description on both sides of the change: green at base `959c209d56` with the old seeder restored by blob, and green at this head. One boundary was read and not pinned. The old read iterated `listItems`, so a name the registry held twice had both entries processed, the last one's label winning. S1 answers one entry per name. For a non-built-in position name this state is unreachable today: the manifest's `positions` key reaches no registry (measured in S2), so only the six carry a composite package entry, and the six are excluded. ## What changed - **`bootstrap-declared-positions.ts`**: `readDeclaredPositions` is the catalog read's list minus the six (`isBuiltinPositionName`, an exact name match), mapped to each entry's `definition`. The row writes are unchanged: the same columns, the same three-outcome existence read and the same refusal reporting. - **A read that did not happen is not "nothing declared".** The catalog read refuses to be built over a source missing a member it calls (`TypeError`). It raises `AuthzStoreUnavailableError` (`SERVICE_UNAVAILABLE`, 503) for a source that threw or a metadata list that lost a loader. The seeder lets both reach its caller, which already catches and logs `[security] declared-position seeding failed` at `warn`. Before, the seeder swallowed both and seeded whatever part it could read, silently. This is the only behaviour change outside the door-authored state, and it is in a failure path. In 26 booted dogfood files it never fired (zero such lines). - **Comment-only:** `claim-seed-ownership.ts` now carries the `domain:engine` seat's proposed sentence on the `sys_stamp_audit_*` builtins (note 6050244606). The note on `builtin-positions.ts` describing the declared seeder's exclusion said "registry-first decision"; this stage made that false, so it now describes the catalog read. - **Test doubles:** three sibling suites seed positions from a stub registry (`bootstrap-seed-round-trips`, `per-organization-catalog`, `seed-write-refusal`). Each stub gains the two members the catalog read calls (`getItem`, `isPackageDisabled`), and each suite passes a metadata service that declares nothing instead of `null`. What each suite asserts is unchanged. - **`vitest.config.ts`** declares `OS_REGISTRY_LOG: 'warn'`. The seeder's unit suite now constructs a `SchemaRegistry`, which puts this package in `check:registry-log-declared`'s population; the gate went red without it and prescribes exactly this line. Side effect, measured: the full suite's log went from 10,908 `[Registry]` lines to 7. - **Changeset:** `@objectstack/plugin-security` `patch`, carrying the same `Clause-②: no` line. **Surface.** The claim amendment (6051984337) named `bootstrap-declared-positions.ts`, its tests, the S2 boot harness and the `claim-seed-ownership.ts` comment. Every other file above is in `plugin-security` and is either a test double of this seeder, a comment this stage made false, or the line a gate demanded. Apart from the changeset, no file outside `plugin-security` is touched. ## Pins **The real-boot harness (`builtin-positions.boot.test.ts`)** boots this plugin for real: `init`, `start`, then its `kernel:ready` handlers in order, over ObjectQL on SQLite, with `field_rep` declared in the metadata service. It now runs 9 scenarios: three postures (`single`, `single + organization`, `walled` with an organization at boot plus one created after), each booted as it is, with a door-authored position in the registry, and with a stored definition under a built-in name. - **The census and the write ledger.** The census reads name, organization, `managed_by`, `active`, `is_default`, label and description. The ledger holds every `sys_position` insert and update, refused ones included. In each scenario they equal the built-ins, every stack-declared position and the door-authored one. The three postures as they are keep S2's goldens unchanged, as recorded before the declarations. The door-authored goldens now include `field_rep` in every pass. - **The door-authored position's own rows** (`single`, then walled) are pinned separately to what the registry-only read wrote, and the pin is green on both sides of the change. - **The built-in shadow state.** The reviewer flagged it in 6051555790 ③: `org_admin` and `everyone` saved as package-less, tenant-authored definitions. Positive control: the catalog read answers the stored body for both names. Pinned: the census and ledger equal the plain posture's, so the declared seeder neither seeds nor restamps either name, and the built-in rows are exactly what `bootstrapBuiltinRoles` writes. - **Grant equivalence.** The resolver's whole envelope for the platform administrator, the organization administrator, a member and an agent equals S2's golden. It runs in all 6 scenarios with an organization (`single + organization` and walled, times the three states), where S2 ran it in 2. No principal holds a newly seeded position. - **Position write refusals** match S2's golden in the same 6 scenarios: a dangling name, deleting `everyone`, relabelling `platform_admin`, plus the two controls. **Unit pins (`bootstrap-declared-positions.test.ts`)** run over the real `SchemaRegistry`, the six registered the way the plugin registers them: - a door-authored position no longer silences the declared ones; - the door-authored row is written as before; - the read order pin above; - the shadow pin, covering both hydration shapes: tenant-authored only, and with this plugin's envelope grafted on; - a source that throws, or a metadata list that lost a loader, writes nothing and rejects with `SERVICE_UNAVAILABLE` / 503; - a composition with no metadata service rejects (`TypeError`) instead of reading the registry alone. **Base leg** (`bootstrap-declared-positions.ts` restored from `959c209d56` by blob, then restored to `HEAD`: blob equal, `git diff HEAD` empty). The two files ran 6 red and 40 green. The 6 red are the three door-authored census/ledger scenarios (the only difference is the missing `field_rep` rows and inserts), the unit trap pin and the two failure-path pins. Every grant, refusal, shadow, read-order and door-authored-row pin is green at base. **Ablations.** Each went through `scripts/ablation-replace.mjs` (anchor 1 to 0, blob changed). Each was restored by `git checkout HEAD`, blob equal to `HEAD` and `git diff HEAD` empty, and re-checked by a separate blob comparison. The run covers five suites, 118 tests. | ablation | red | |---|---| | A1: restore the either-or read | 6: the three door-authored scenarios (census and ledger), the unit trap pin, the two failure-path pins | | A2: the seeder takes built-in names (no exclusion) | 15: all nine boot scenarios' census and ledger, and six unit pins, the shadow pin among them | | A3: exclude by package instead of by name | 7: exactly the three shadow scenarios, the unit shadow pin, and the three S2 unit pins whose stub carries no package | | A4: read the metadata service alone | 30, the door-authored rows pin among them (all three scenarios) | | A5: metadata service first for a shared name | 5: the read-order pin, the three door-authored ledgers (order), a failure-path pin | | A6: grant sensitivity (built-in names taken and inserted inactive) | 26, the grant golden among them in all 6 scenarios with an organization | ## Measured on a booted showcase, walled The flow is a platform administrator's `PUT /api/v1/meta/position/zz_s2b_door` (200), then `sys_organization` insert `org_s2b_gamma`, then a read of its `sys_position` rows. Before is `plugin-security` rebuilt from the base seeder source; the dist preflight found the base marker in 2 built files, and after the run the source was restored by blob, rebuilt, and the marker was absent again. After is this branch, pre-merge `2d41056b54` and again at `5c4e58def7`. | reading | before | after | |---|---|---| | an organization present before the save | 16 rows | 16 rows | | the organization created after the save | 7 rows: the six and `zz_s2b_door` | 17 rows: the six, the ten declared, `zz_s2b_door` | | the `zz_s2b_door` row | `admin`, active, not default, `S2b Door` | identical | In `single`, the seeder runs once per boot, so a door-saved position reaches it at the next boot as a hydrated registry item. That is the harness's `single` door-authored scenario: `insert door_authored@-`, written identically before and after. ## Verification All at `5c4e58def7`, the head of this PR: `origin/main` `c6fe02d7ac` merged in, then `pnpm install --frozen-lockfile` and the closures rebuilt. - **`plugin-security`:** `typecheck` exit 0, test layer 0 errors. `vitest run`: 175 files, 3715 passed, 45 skipped. - **`core`:** `typecheck` exit 0, test layer at its 4 pinned debt signatures. `vitest run --project local`: 78 files, 2192 passed. Measured before the merge, at `e593e1ab2a`. The merge brought no change to `packages/core`. - **Closures built:** `@objectstack/plugin-security...` (18 packages) and `@objectstack/dogfood^...` (63 packages). - **Dogfood:** 27 files, the 21 that boot a walled posture plus six position and baseline files (`showcase-declarative-rbac-seeding`, `me-apps-and-everyone-baseline`, `membership-role-vocabulary`, `showcase-d7-default-profile`, `delegation-of-duty`, `org-scoped-sharing-rule-listing`). 26 passed, with 220 tests passed and 3 skipped. `rls-multitenant` skipped itself through its own organizations probe. No boot logged `declared-position seeding failed`. The same 27 files read the same before the merge. - **Gates:** the 65 commands `dispatch-gates --commands` derives for this diff, run at `5c4e58def7`, each exit captured before any pipe: 65 of 65 exit 0. `--ran` reconciled them as 65 derived, 65 run, 0 NOT-MEASURED (a derived zero), 0 UNRUN. - On the first pass, at `2d41056b54`, two commands did not exit 0. `check:registry-log-declared` went red because the seeder's suite now constructs a `SchemaRegistry`; the line it prescribes is in `e593e1ab2a`. `check:dual-build-cjs-loads` answered PREREQUISITE NOT MET because eight packages outside this diff's closure were unbuilt; it passed once they were built. - `check:query-options-erasure` reads 236 on the test surface, unchanged. - **Lint (scoped):** the 9 changed `.ts` files, `eslint --no-inline-config --format json`: 9 files linted, 0 errors, 0 warnings. The population is `eslint.config.mjs`'s own: every `packages/**` TypeScript file, and none of the 9 was ignored. The file count is read from the JSON. The config enables no type-aware linting (no `parserOptions.project`), so this diff cannot change the verdict on any file it does not touch. The repo-wide `pnpm lint` is CI's. ## Acceptance notes - **The failure-path change above is deliberate and declared.** Before, a degraded metadata list seeded a partial catalog with no word. Now the pass seeds nothing and the boot says so once at `warn`. The caller's level is `security-plugin.ts`'s, which this stage does not touch. - **The read-order boundary (a name held twice in the registry)** is described above. It is unreachable for non-built-in positions today, so it is noted and not pinned. - **S1's module doc** in `security-catalog.ts` still carries a measured table from before S2 (`position`: engine registry 0). That table is already carried to S8a, and nothing here changes it. - **No `scripts/adr-anchors/` entry** is added; the ADR-0131 D2 anchor is carried to S8a (seat ACCEPT 6051569578). --- _Generated by [Claude Code](https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7ebbc01 commit 17e4425

10 files changed

Lines changed: 461 additions & 157 deletions
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
---
4+
5+
A position saved through the metadata door no longer stops an app's declared positions from being seeded
6+
7+
Clause-②: no
8+
9+
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.
10+
11+
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.
12+
13+
- **New organizations** get the built-ins, every declared position and every door-saved one. Measured on the same walled showcase: 17 positions.
14+
- **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.
15+
- **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.
16+
- **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.
17+
- **No grant changes.** A principal's permissions move only if it holds a position that is newly seeded into its organization.

‎packages/plugins/plugin-security/src/bootstrap-declared-positions.test.ts‎

Lines changed: 162 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,47 @@
1313
*/
1414

1515
import { describe, it, expect } from 'vitest';
16+
import { SchemaRegistry } from '@objectstack/objectql';
1617
import { bootstrapDeclaredPositions } from './bootstrap-declared-positions.js';
17-
import { securityBuiltinPositions } from './builtin-positions.js';
18+
import { registerBuiltinPositions, securityBuiltinPositions } from './builtin-positions.js';
19+
import { SECURITY_PLUGIN_ID } from './manifest.js';
1820

19-
/** Minimal in-memory ql for sys_position seeding. */
20-
function makeQl(declared: any[] = []) {
21+
/**
22+
* The metadata service of a deployment that declares no position in it — the
23+
* catalog read takes both sources and refuses to be built over a missing one.
24+
*/
25+
const NO_METADATA_POSITIONS = { get: async () => undefined, list: async () => [] };
26+
27+
/**
28+
* A registry over a fixed list, as the catalog read uses one: its list, its
29+
* by-name read (first match), and no disabled package.
30+
*/
31+
function listRegistry(items: () => any[]) {
32+
return {
33+
listItems: (type: string) => (type === 'position' ? [...items()] : []),
34+
getItem: (type: string, name: string) => (type === 'position' ? items().find((i) => i?.name === name) : undefined),
35+
isPackageDisabled: () => false,
36+
};
37+
}
38+
39+
/** A metadata service holding `items` as its declared positions. */
40+
const metadataListing = (items: any[]) => ({
41+
get: async (type: string, name: string) => (type === 'position' ? items.find((i) => i.name === name) : undefined),
42+
list: async (type: string) => (type === 'position' ? items.map((i) => ({ ...i })) : []),
43+
});
44+
45+
/**
46+
* Minimal in-memory ql for sys_position seeding. `registry` replaces the
47+
* list-backed one when a case needs the real engine registry's by-name
48+
* precedence.
49+
*/
50+
function makeQl(declared: any[] = [], registry?: unknown) {
2151
const rows: any[] = [];
2252
return {
2353
rows,
2454
// [#8378] Items are surfaced as the real engine surfaces them — the
2555
// document itself, not a `{ content: <item> }` box that nothing produces.
26-
registry: { listItems: (type: string) => (type === 'position' ? [...declared] : []) },
56+
registry: registry ?? listRegistry(() => declared),
2757
async find(object: string, q: any) {
2858
if (object !== 'sys_position') return [];
2959
const where = q?.where ?? {};
@@ -57,7 +87,7 @@ function makeQl(declared: any[] = []) {
5787
describe('bootstrapDeclaredPositions (#2909 T2 — seed-only semantics locked)', () => {
5888
it('inserts new declared positions with identity + display fields only', async () => {
5989
const ql = makeQl([{ name: 'contributor', label: 'Contributor', description: 'Does work' }]);
60-
const r = await bootstrapDeclaredPositions(ql, null);
90+
const r = await bootstrapDeclaredPositions(ql, NO_METADATA_POSITIONS);
6191
expect(r.seeded).toBe(1);
6292
const row = ql.rows[0];
6393
expect(row).toMatchObject({ name: 'contributor', label: 'Contributor', active: true, is_default: false });
@@ -72,7 +102,7 @@ describe('bootstrapDeclaredPositions (#2909 T2 — seed-only semantics locked)',
72102
id: 'pos_1', name: 'contributor', label: 'Contributor', description: 'old',
73103
active: true, is_default: false,
74104
});
75-
const r = await bootstrapDeclaredPositions(ql, null);
105+
const r = await bootstrapDeclaredPositions(ql, NO_METADATA_POSITIONS);
76106
expect(r.updated).toBe(1);
77107
expect(ql.rows[0].label).toBe('Contributor v2');
78108
expect(ql.rows[0].description).toBe('new text');
@@ -87,7 +117,7 @@ describe('bootstrapDeclaredPositions (#2909 T2 — seed-only semantics locked)',
87117
active: false, is_default: true, delegatable: true, managed_by: 'package',
88118
permissions: ['something_admin_set'],
89119
});
90-
await bootstrapDeclaredPositions(ql, null);
120+
await bootstrapDeclaredPositions(ql, NO_METADATA_POSITIONS);
91121
const row = ql.rows[0];
92122
expect(row.active).toBe(false);
93123
expect(row.is_default).toBe(true);
@@ -100,32 +130,23 @@ describe('bootstrapDeclaredPositions (#2909 T2 — seed-only semantics locked)',
100130

101131
it('is idempotent — a re-run inserts nothing new', async () => {
102132
const ql = makeQl([{ name: 'a', label: 'A' }, { name: 'b', label: 'B' }]);
103-
const r1 = await bootstrapDeclaredPositions(ql, null);
133+
const r1 = await bootstrapDeclaredPositions(ql, NO_METADATA_POSITIONS);
104134
expect(r1.seeded).toBe(2);
105-
const r2 = await bootstrapDeclaredPositions(ql, null);
135+
const r2 = await bootstrapDeclaredPositions(ql, NO_METADATA_POSITIONS);
106136
expect(r2.seeded).toBe(0);
107137
expect(ql.rows).toHaveLength(2);
108138
});
109139
});
110140

111141
/**
112-
* [ADR-0131 C2 S2] What the seeder reads now that the six built-in positions
113-
* are declared metadata: the same registry-first two-step as before, with the
114-
* six taken out of both its decision and its result.
115-
*
116-
* The plugin registers the six with the engine registry on every boot. Two
117-
* things must survive that: the stack-declared positions the metadata service
118-
* holds still seed (six registered names must not make the registry "hold a
119-
* position" and silence them), and the six stay `bootstrapBuiltinRoles`'s rows
120-
* — this seeder writes no copy of them and refreshes none of their columns.
142+
* [ADR-0131 C2 S2] The six built-in positions are declared metadata, which the
143+
* catalog read lists; they stay `bootstrapBuiltinRoles`'s rows — this seeder
144+
* writes no copy of them and refreshes none of their columns.
121145
*/
122146
describe('bootstrapDeclaredPositions — the six built-in positions are not this seeder’s', () => {
123147
const builtins = () => securityBuiltinPositions.map((p) => ({ ...p }));
124-
const metadataListing = (items: any[]) => ({
125-
list: async (type: string) => (type === 'position' ? items.map((i) => ({ ...i })) : []),
126-
});
127148

128-
it('reads the metadata service when the registry holds the six and nothing else', async () => {
149+
it('seeds the metadata service’s positions while the registry holds the six', async () => {
129150
const ql = makeQl(builtins());
130151
const r = await bootstrapDeclaredPositions(
131152
ql,
@@ -135,13 +156,6 @@ describe('bootstrapDeclaredPositions — the six built-in positions are not this
135156
expect(r).toEqual({ seeded: 2, updated: 0, unchanged: 0, unreadable: 0 });
136157
});
137158

138-
it('keeps the two-step otherwise: a registry holding another position answers alone, minus the six', async () => {
139-
const ql = makeQl([...builtins(), { name: 'door_authored', label: 'Door Authored' }]);
140-
const r = await bootstrapDeclaredPositions(ql, metadataListing([{ name: 'field_rep', label: 'Field Rep' }]));
141-
expect(ql.rows.map((row) => row.name)).toEqual(['door_authored']);
142-
expect(r).toEqual({ seeded: 1, updated: 0, unchanged: 0, unreadable: 0 });
143-
});
144-
145159
it('writes nothing for the six on a fresh organization — no copy ahead of the built-in pass', async () => {
146160
const ql = makeQl(builtins());
147161
const r = await bootstrapDeclaredPositions(ql, metadataListing(builtins()), { organizationId: 'org_a' });
@@ -164,3 +178,122 @@ describe('bootstrapDeclaredPositions — the six built-in positions are not this
164178
expect(r).toEqual({ seeded: 1, updated: 0, unchanged: 0, unreadable: 0 });
165179
});
166180
});
181+
182+
/**
183+
* [ADR-0131 C2 S2b] The seeder reads through the security catalog read — the
184+
* engine registry and the metadata service, in its one read order — where it
185+
* used to take the registry ALONE whenever the registry held any position
186+
* besides the six.
187+
*
188+
* The registry here is the real `SchemaRegistry`, so a name's answer is the
189+
* registry's own by-name precedence, not a fixture's: the six registered the
190+
* way the plugin registers them (packaged, composite keys), and a definition a
191+
* metadata author saved hydrated the way the door hydrates one (no package, the
192+
* bare slot).
193+
*/
194+
describe('bootstrapDeclaredPositions — one catalog read, both sources (ADR-0131 C2 S2b)', () => {
195+
const declaredRegistry = (...authored: Array<Record<string, unknown>>) => {
196+
const registry = new SchemaRegistry();
197+
for (const item of authored) registry.registerItem('position', { ...item }, 'name');
198+
registerBuiltinPositions(registry, SECURITY_PLUGIN_ID);
199+
return registry;
200+
};
201+
const byName = (rows: any[]) => Object.fromEntries(rows.map((row) => [row.name, row]));
202+
203+
it('a door-authored position no longer silences the stack’s declared positions', async () => {
204+
const ql = makeQl([], declaredRegistry({ name: 'door_authored', label: 'Door Authored' }));
205+
const r = await bootstrapDeclaredPositions(
206+
ql,
207+
metadataListing([{ name: 'field_rep', label: 'Field Rep' }, { name: 'auditor', label: 'Auditor' }]),
208+
{ organizationId: 'org_late' },
209+
);
210+
expect(ql.rows.map((row) => row.name).sort()).toEqual(['auditor', 'door_authored', 'field_rep']);
211+
expect(r).toEqual({ seeded: 3, updated: 0, unchanged: 0, unreadable: 0 });
212+
});
213+
214+
it('writes the door-authored position’s row as the registry-only read wrote it', async () => {
215+
const ql = makeQl([], declaredRegistry({ name: 'door_authored', label: 'Door Authored' }));
216+
await bootstrapDeclaredPositions(ql, metadataListing([{ name: 'field_rep', label: 'Field Rep' }]));
217+
const { id, ...row } = byName(ql.rows).door_authored;
218+
expect(id).toMatch(/^position_/);
219+
expect(row).toEqual({ name: 'door_authored', label: 'Door Authored', description: null, active: true, is_default: false });
220+
});
221+
222+
// The read order, measured: for a name BOTH sources hold, the registry's body
223+
// is the one written — what the either-or wrote too, because the registry
224+
// holding that name was itself what made the registry answer.
225+
it('a name both sources hold is written from the registry’s body', async () => {
226+
const ql = makeQl([], declaredRegistry({ name: 'field_rep', label: 'Field Rep (saved at the door)', description: 'Door text' }));
227+
const r = await bootstrapDeclaredPositions(
228+
ql,
229+
metadataListing([{ name: 'field_rep', label: 'Field Rep', description: 'Declared text' }]),
230+
);
231+
expect(ql.rows.map((row) => [row.name, row.label, row.description])).toEqual([
232+
['field_rep', 'Field Rep (saved at the door)', 'Door text'],
233+
]);
234+
expect(r).toEqual({ seeded: 1, updated: 0, unchanged: 0, unreadable: 0 });
235+
});
236+
237+
// A definition saved under a built-in name before the six were declared is
238+
// hydrated into the bare slot and shadows the declaration at read. Positive
239+
// control first: the registry really answers the stored body for the name.
240+
it('a stored definition shadowing a built-in name is neither seeded nor restamped', async () => {
241+
// Both hydration shapes: stated tenant-authored only (hydrated before the
242+
// six were registered), and with the declaration's envelope grafted on
243+
// (hydrated after) — which names THIS plugin's package.
244+
const shadows = [
245+
{ name: 'org_admin', label: 'Repurposed Org Admin', description: 'Saved at the door', _provenance: 'org' },
246+
{ name: 'everyone', label: 'Repurposed Everyone', description: 'Saved at the door', _provenance: 'org', _packageId: SECURITY_PLUGIN_ID },
247+
];
248+
const registry = declaredRegistry(...shadows);
249+
expect((registry.getItem('position', 'org_admin') as any)?.label).toBe('Repurposed Org Admin');
250+
expect((registry.getItem('position', 'everyone') as any)?.label).toBe('Repurposed Everyone');
251+
252+
const seeded = securityBuiltinPositions.map((p, i) => ({
253+
id: `pos_builtin_${i}`, name: p.name, label: p.label, description: p.description,
254+
managed_by: 'platform', active: true, is_default: false,
255+
}));
256+
const fresh = makeQl([], registry);
257+
const onFresh = await bootstrapDeclaredPositions(fresh, NO_METADATA_POSITIONS, { organizationId: 'org_a' });
258+
expect(fresh.rows).toEqual([]);
259+
expect(onFresh).toEqual({ seeded: 0, updated: 0, unchanged: 0, unreadable: 0 });
260+
261+
const existing = makeQl([], registry);
262+
existing.rows.push(...seeded.map((row) => ({ ...row })));
263+
const onExisting = await bootstrapDeclaredPositions(existing, NO_METADATA_POSITIONS);
264+
expect(existing.rows).toEqual(seeded);
265+
expect(onExisting).toEqual({ seeded: 0, updated: 0, unchanged: 0, unreadable: 0 });
266+
});
267+
268+
// A read that did not happen is not "nothing declared": the seeder writes
269+
// nothing and the failure reaches its caller, which reports it.
270+
it('writes nothing when a catalog source cannot be read, and says so', async () => {
271+
const ql = makeQl([{ name: 'field_rep', label: 'Field Rep' }]);
272+
(ql.registry as any).listItems = () => { throw new Error('registry unreachable'); };
273+
const failure = await bootstrapDeclaredPositions(ql, NO_METADATA_POSITIONS).then(
274+
() => undefined,
275+
(e: any) => ({ code: e?.code, status: e?.status }),
276+
);
277+
expect(failure).toEqual({ code: 'SERVICE_UNAVAILABLE', status: 503 });
278+
expect(ql.rows).toEqual([]);
279+
280+
const degraded = makeQl([{ name: 'field_rep', label: 'Field Rep' }]);
281+
const lostLoader = {
282+
...NO_METADATA_POSITIONS,
283+
listDiagnosed: async () => ({ items: [{ name: 'auditor', label: 'Auditor' }], degraded: true, errors: ['loader down'] }),
284+
};
285+
const partial = await bootstrapDeclaredPositions(degraded, lostLoader).then(
286+
() => undefined,
287+
(e: any) => ({ code: e?.code, status: e?.status }),
288+
);
289+
expect(partial).toEqual({ code: 'SERVICE_UNAVAILABLE', status: 503 });
290+
expect(degraded.rows).toEqual([]);
291+
});
292+
293+
it('refuses a composition with no metadata service rather than reading the registry alone', async () => {
294+
const ql = makeQl([{ name: 'field_rep', label: 'Field Rep' }]);
295+
const failure = await bootstrapDeclaredPositions(ql, null).then(() => undefined, (e: unknown) => e);
296+
expect(failure).toBeInstanceOf(TypeError);
297+
expect(ql.rows).toEqual([]);
298+
});
299+
});

0 commit comments

Comments
 (0)