diff --git a/.changeset/artifact-boot-single-security-registrar.md b/.changeset/artifact-boot-single-security-registrar.md new file mode 100644 index 0000000000..2253d26691 --- /dev/null +++ b/.changeset/artifact-boot-single-security-registrar.md @@ -0,0 +1,38 @@ +--- +"@objectstack/runtime": minor +--- + +fix(runtime): one registrar for the artifact boot's security collections — the artifact door, not `AppPlugin` (#12892 step 2) + +On an artifact boot (`createStandaloneStack`: `os serve` / `os dev` / `os start` / +`os migrate` booting from `dist/objectstack.json`) the stack-declared +`positions` / `permissions` / `capabilities` / `sharingRules` used to reach the +metadata service through TWO registrars over the same bytes: the artifact door +(`MetadataPlugin`, which forward-converts, strict-parses and stamps ADR-0010 +provenance) and `AppPlugin`'s ADR-0057 block (which does none of that). Because +`AppPlugin` started last its copy won, so a consumer of +`metadata.list('sharing_rule')` read a sharing rule whose `condition` was a bare +STRING where the door's copy carries `{ dialect, source }` (reading +`.condition.source` gave `undefined`), and a capability with no `scope` default +and no `_packageVersion` / `_provenance`. Which shape a reader saw depended on +plugin start order. + +Maintainer ruling on #12892 (2026-08-29): the door owns the route. Now: + +- `AppPlugin` takes `securityMetadataRegistrar: 'app-plugin' | 'artifact-door'` + (constructor `opts`, default `'app-plugin'`). Under `'artifact-door'` the + ADR-0057 block registers nothing and logs a `debug` line naming the + collections left to the door; an unknown value is refused loudly. +- `createStandaloneStack` — the one composition that runs the door over the + same artifact — declares `'artifact-door'` on the `AppPlugin` it composes. On + that boot the metadata service holds exactly ONE copy of every security item: + the door's, parsed, defaulted and provenance-stamped, in either start order. +- Every door-less composition (`new AppPlugin(config)` over a `defineStack()` + module in `os serve` / `os migrate`, `DevPlugin`, `@objectstack/verify`'s + `bootStack`, embedders) is unchanged: the default keeps registering all four + collections exactly as before. + +`minor` rather than `patch`: a public constructor option is added, and what the +metadata service serves for these four kinds on an artifact boot changes shape +(door copy instead of raw copy) — a fix, but one a reader of `GET /meta/` +or `metadata.list()` on an artifact boot can observe. diff --git a/packages/runtime/src/app-plugin-artifact-forward-conversion.test.ts b/packages/runtime/src/app-plugin-artifact-forward-conversion.test.ts index 7ce9c9be50..55682824d9 100644 --- a/packages/runtime/src/app-plugin-artifact-forward-conversion.test.ts +++ b/packages/runtime/src/app-plugin-artifact-forward-conversion.test.ts @@ -1,31 +1,63 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * The artifact boot has TWO readers of the same bytes (#12844). + * The artifact boot's security collections have ONE registrar — the artifact + * door — and this file is where that is MEASURED (#12892 step 2). + * + * Two real readers of the same artifact bytes exist in this monorepo: * * 1. `MetadataPlugin._parseAndRegisterArtifact` (`@objectstack/metadata`) — - * re-reads the artifact named by `artifactSource`, replays the versioned - * ADR-0087 forward conversion (#12772), then strict-parses. Canonical. - * 2. `AppPlugin`'s ADR-0057 block (this package) — receives the same JSON - * from `loadArtifactBundle` (no validation, no conversion) and registers - * `positions` / `permissions` / `capabilities` / `sharingRules` through - * `metadata.registerInMemory`. (It also carried a `policies` entry until - * #12894 retired it as a dead pointer — the test below is what stays.) + * the ARTIFACT DOOR. Replays the versioned ADR-0087 forward conversion + * (#12772), STRICT-PARSES the definition (schema defaults, ADR-0122 input + * transforms), stamps the ADR-0010 provenance envelope, and since #12892 + * step 1 (PR #13125) maps all four security collections in + * `ARTIFACT_FIELD_TO_TYPE`. Canonical. + * 2. `AppPlugin`'s ADR-0057 block (this package) — receives the bundle (from + * `loadArtifactBundle` on an artifact boot, from a `defineStack()` module + * on every other boot), forward-converts it through the door's OWN policy + * function (#12844) and registers `positions` / `permissions` / + * `capabilities` / `sharingRules` through `metadata.registerInMemory`. No + * strict parse, no defaults, no provenance. + * + * The history, because each step left a pin here: + * + * - #12844 made reader 2 apply the conversion, so the two copies agreed on + * every CONVERSION-governed key — and measured that they still differed on + * the PARSE axis (a sharing rule's `condition` was a bare STRING on reader + * 2's copy and `{ dialect, source }` on the door's). It pinned that + * residual key by key "to go red the day the routes unify". + * - #12892 step 1 put `capabilities` in the door's map: two writers on that + * collection as well, diverging on exactly four keys — pinned likewise. + * - #12892 step 2 (the shape this file has now): `createStandaloneStack` + * declares `securityMetadataRegistrar: 'artifact-door'` on the AppPlugin + * it composes beside the door, and under that declaration the ADR-0057 + * block registers NONE of the four. Driving reader 2 as the artifact boot + * now constructs it turned 6 of the 8 cases red on the branch (measured: + * "expected [] to deeply equal [ '_packageId', …(4) ]", "AppPlugin must + * still register the capability: expected undefined to be defined") — the + * pins did the job they were written for, and were then REWRITTEN below to + * pin the unified state. ⛔ Never skipped, relaxed or deleted: that is the + * one repair that would let the route silently keep two writers. * - * Before this fix reader 2 registered the RAW bytes, so the two copies of the - * same item differed and which one a consumer saw depended on registration - * order and read path. No consumer read the difference when the card was - * filed — but that is a property of the two retired keys involved - * (`allowRestore`/`allowPurge` gate nothing BY THE DEFINITION of their - * retirement, #12497), not of this path. + * What is pinned now: * - * These tests drive BOTH REAL readers over one artifact and pin what the card - * asked to be falsified rather than asserted: + * - ARTIFACT boot: reader 2 writes nothing under the four kinds; the door's + * copy is the only one, in BOTH start orders; its shape is asserted key by + * key — the four keys the raw copy lacked on a capability, the object-typed + * sharing-rule predicate, every schema default. + * - NON-artifact boot (positive control): the default AppPlugin still + * registers all four collections, forward-converted, exactly as before. + * - The parse-axis difference between the door's copy and the non-artifact + * copy is still real and still measured — as a difference between BOOT + * SHAPES now, never between two writers on one route. + * - `policies`: no reader can see one (a schema fact). * - * - the two copies AGREE, per collection, for every collection that has two - * readers at all (and the ones that do not are pinned as such); - * - registration ORDER stops changing what a reader sees; - * - the difference the fix removes is real and measurable in the raw bytes. + * The discriminator is a composition-site DECLARATION, not a property of the + * bytes: a default-constructed `AppPlugin(bytes)` IS the door-less boot and is + * meant to stay green here. Only reader 2 constructed the way + * `createStandaloneStack` constructs it models the artifact boot — + * standalone-stack.test.ts pins that the factory really passes the option, and + * standalone-stack-security-registrar.test.ts drives the real kernel. */ import { describe, it, expect, vi } from 'vitest'; @@ -130,6 +162,16 @@ function fakeCtx(metadataService?: unknown) { type Registration = { type: string; name: string; item: any }; +const SECURITY_TYPES: readonly string[] = ['position', 'permission', 'capability', 'sharing_rule']; + +/** Every `type:name` the artifact above registers, sorted. */ +const SECURITY_KEYS = [ + 'capability:crm.export', + 'permission:support_agent', + 'position:sales_rep', + 'sharing_rule:share_open_deals', +]; + /** Reader 1 — the real artifact door, into its own manager. */ async function readerDoor(): Promise { const plugin: any = new MetadataPlugin({ watch: false, config: { bootstrap: 'lazy' } }); @@ -143,10 +185,9 @@ async function readerDoor(): Promise { return out; } -/** Reader 2 — the real `AppPlugin` ADR-0057 block, capturing its writes in order. */ -async function readerBundle(): Promise { +/** Drive the real `AppPlugin` ADR-0057 block, capturing its writes in order. */ +async function driveAppPlugin(plugin: AppPlugin): Promise { const captured: Registration[] = []; - const plugin = new AppPlugin(bytes()); await plugin.start!( fakeCtx({ registerInMemory: (type: string, name: string, item: unknown) => { @@ -157,6 +198,21 @@ async function readerBundle(): Promise { return captured; } +/** + * Reader 2 as the ARTIFACT boot composes it — `createStandaloneStack`, beside a + * `MetadataPlugin({ artifactSource })` reading the same file. The declaration + * is the only difference from the reader below; everything else the block does + * is identical. + */ +function readerBundleOnArtifactBoot(): Promise { + return driveAppPlugin(new AppPlugin(bytes(), undefined, { securityMetadataRegistrar: 'artifact-door' })); +} + +/** Reader 2 as every door-less composition constructs it — the default. */ +function readerBundle(): Promise { + return driveAppPlugin(new AppPlugin(bytes())); +} + /** `type:name` → item, in registration order (last write wins, as the registry does). */ function collapse(regs: Registration[]): Map { const m = new Map(); @@ -176,10 +232,10 @@ function diffPaths(a: any, b: any, at = ''): string[] { /** * The keys the ADR-0087 conversion layer governs on these collections — the - * axis this card is about, enumerated from the registry + * axis #12844 was about, enumerated from the registry * (`packages/spec/src/conversions/registry.ts`): the two `permissions` * entries, the two `sharingRules` entries, and the `roles` -> `positions` - * collection rename. Nothing else in that registry reaches the five security + * collection rename. Nothing else in that registry reaches the four security * collections. */ const CONVERSION_GOVERNED_PATHS = [ @@ -192,11 +248,57 @@ const CONVERSION_GOVERNED_PATHS = [ 'sharedWith.type', ]; -describe('#12844 — the artifact boot\'s two readers register the same bytes', () => { +/** The ADR-0010 envelope `applyProtection` stamps from the manifest. */ +const PROVENANCE = { _packageId: 'com.test.issue-12844', _packageVersion: '1.0.0', _provenance: 'package' }; + +/** + * The door's copy of every item, WHOLE — measured by running the artifact + * through the door's own pipeline (forward conversion, then + * `ObjectStackDefinitionSchema.parse`, then `applyProtection`) and written down + * here so a drift in any key fails by name. Every key the authored bytes did + * not carry is a schema default (`delegatable`, `isDefault`, the three grant + * bits, `active`, `scope`), an ADR-0122 input transform (`condition`), or the + * provenance envelope. + */ +const DOOR_COPY: Record = { + 'position:sales_rep': { name: 'sales_rep', label: 'Sales Rep', delegatable: false, ...PROVENANCE }, + 'permission:support_agent': { + name: 'support_agent', + label: 'Support Agent', + isDefault: false, + objects: { + crm_ticket: { + allowCreate: true, allowRead: true, allowEdit: true, allowDelete: true, + allowTransfer: false, viewAllRecords: false, modifyAllRecords: false, + }, + crm_lead: { + allowCreate: true, allowRead: false, allowEdit: false, allowDelete: false, + allowTransfer: false, viewAllRecords: false, modifyAllRecords: false, + }, + }, + rowLevelSecurity: [ + { name: 'own_tasks', object: 'crm_task', operation: 'select', using: 'assignee == current_user.email', enabled: true }, + ], + ...PROVENANCE, + }, + 'capability:crm.export': { name: 'crm.export', label: 'Export CRM data', scope: 'platform', ...PROVENANCE }, + 'sharing_rule:share_open_deals': { + name: 'share_open_deals', + type: 'criteria', + object: 'crm_deal', + active: true, + accessLevel: 'edit', + sharedWith: { type: 'position', value: 'sales_mgr' }, + condition: { dialect: 'cel', source: 'record.status == "open"' }, + ...PROVENANCE, + }, +}; + +describe('#12892 step 2 — the artifact boot has ONE registrar for its security collections', () => { it('premise: the raw bundle really does carry a shape the current schema refuses', () => { - // Not a tautology — this is the difference the fix removes. Each of - // these is measured against the schema that any re-validating seam - // (Studio re-save through `saveMetaItem`) would apply. + // Not a tautology — this is the difference the door's parse removes. + // Each of these is measured against the schema that any re-validating + // seam (Studio re-save through `saveMetaItem`) would apply. const raw = bytes(); expect(raw.permissions[0].objects.crm_ticket.allowRestore).toBe(true); expect(raw.permissions[0].rowLevelSecurity[0].priority).toBe(10); @@ -209,7 +311,79 @@ describe('#12844 — the artifact boot\'s two readers register the same bytes', expect(ObjectStackDefinitionSchema.safeParse(raw).success).toBe(false); }); - it('permissions: the bundle reader no longer registers the retired grant bits', async () => { + // ── ARTIFACT boot: one registrar ────────────────────────────────────── + + it('artifact boot: AppPlugin registers NOTHING under the four security kinds — and the door reaches every one of them', async () => { + const bundle = await readerBundleOnArtifactBoot(); + expect( + bundle.filter((r) => SECURITY_TYPES.includes(r.type)), + 'under the artifact-door declaration the ADR-0057 block must write no security item', + ).toEqual([]); + + // The other half, without which the line above is a HOLE rather than a + // fix: the door registers an item under every kind `SECURITY_FIELDS` + // enumerates. A declared collection with zero registrars boots green + // and logs nothing (measured on #12892 step 1), so removing this + // reader is safe exactly as long as this stays true. Whole set, sorted + // — a collection the door stops reaching fails here by name. + const door = collapse(await readerDoor()); + expect([...door.keys()].sort()).toEqual(SECURITY_KEYS); + }); + + it('artifact boot: in BOTH start orders the registry holds exactly the door\'s copy of every item, key by key', async () => { + const door = await readerDoor(); + // The kernel's real order is door first, AppPlugin last (measured on + // #12892 step 1) — the order in which the raw copy used to win. + const doorFirst = collapse([...door, ...(await readerBundleOnArtifactBoot())]); + const bundleFirst = collapse([...(await readerBundleOnArtifactBoot()), ...door]); + + expect([...doorFirst.keys()].sort()).toEqual(SECURITY_KEYS); + expect([...bundleFirst.keys()].sort()).toEqual(SECURITY_KEYS); + for (const key of SECURITY_KEYS) { + expect(doorFirst.get(key), `${key}, door started first`).toEqual(DOOR_COPY[key]); + expect(bundleFirst.get(key), `${key}, AppPlugin started first`).toEqual(DOOR_COPY[key]); + expect(diffPaths(doorFirst.get(key), bundleFirst.get(key)), `${key} must not depend on start order`).toEqual([]); + } + + // The read a consumer can make TODAY, named explicitly on the only + // copy there is, whichever plugin started first: the sharing-rule + // predicate is an OBJECT whose `.source` is the authored expression, + // and a capability carries the schema default and the full envelope. + for (const registry of [doorFirst, bundleFirst]) { + const rule = registry.get('sharing_rule:share_open_deals') as any; + expect(typeof rule.condition).toBe('object'); + expect(rule.condition.source).toBe('record.status == "open"'); + const cap = registry.get('capability:crm.export') as any; + expect(cap.scope).toBe('platform'); + expect(cap._packageVersion).toBe('1.0.0'); + } + }); + + it('the registrar declaration defaults to the registering branch and refuses a misspelling instead of guessing', () => { + expect(new AppPlugin(bytes()).securityMetadataRegistrar).toBe('app-plugin'); + expect( + new AppPlugin(bytes(), undefined, { securityMetadataRegistrar: 'artifact-door' }).securityMetadataRegistrar, + ).toBe('artifact-door'); + // A typo must not silently land in either branch — both are quiet + // about what they did not do. + expect(() => new AppPlugin(bytes(), undefined, { securityMetadataRegistrar: 'door' as any })) + .toThrow(/securityMetadataRegistrar 'door' is not one of 'app-plugin' \| 'artifact-door'/); + }); + + // ── NON-artifact boot: the positive control ─────────────────────────── + // + // The default AppPlugin is every door-less composition (`new + // AppPlugin(config)` over a `defineStack()` module, `DevPlugin`, + // `@objectstack/verify`'s `bootStack`). It must keep registering all four + // collections, forward-converted, exactly as #12844 left it — a green here + // is what makes the artifact-boot cases above a re-routing and not a loss. + + it('non-artifact boot: AppPlugin still registers all four collections', async () => { + const bundle = collapse(await readerBundle()); + expect([...bundle.keys()].sort()).toEqual(SECURITY_KEYS); + }); + + it('non-artifact boot: permissions — the retired grant bits are gone, every other authored bit survives', async () => { const bundle = collapse(await readerBundle()); const perm = bundle.get('permission:support_agent'); expect(perm, 'AppPlugin must still register the permission set').toBeDefined(); @@ -218,7 +392,6 @@ describe('#12844 — the artifact boot\'s two readers register the same bytes', expect(perm.objects.crm_lead).not.toHaveProperty('allowRestore'); expect(perm.objects.crm_lead).not.toHaveProperty('allowPurge'); expect(perm.rowLevelSecurity[0]).not.toHaveProperty('priority'); - // Every other authored bit survives untouched. expect(perm.objects.crm_ticket).toMatchObject({ allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, }); @@ -227,7 +400,7 @@ describe('#12844 — the artifact boot\'s two readers register the same bytes', }); }); - it('sharingRules: the bundle reader registers the canonical recipient type and access level', async () => { + it('non-artifact boot: sharingRules — the canonical recipient type and access level', async () => { const bundle = collapse(await readerBundle()); const rule = bundle.get('sharing_rule:share_open_deals'); expect(rule, 'AppPlugin must still register the sharing rule').toBeDefined(); @@ -235,9 +408,9 @@ describe('#12844 — the artifact boot\'s two readers register the same bytes', expect(rule.sharedWith.type).toBe('position'); }); - it('positions: the collection-key rename reaches the bundle reader too', async () => { + it('non-artifact boot: positions — the collection-key rename reaches AppPlugin', async () => { const bundle = collapse(await readerBundle()); - // Before the fix this reader looked for `positions` on bytes that + // Before #12844 this reader looked for `positions` on bytes that // spelled the collection `roles`, and registered NOTHING. expect(bundle.get('position:sales_rep')).toMatchObject({ name: 'sales_rep', @@ -245,22 +418,17 @@ describe('#12844 — the artifact boot\'s two readers register the same bytes', }); }); - it('the two readers agree on every ADR-0087 CONVERSION-governed key', async () => { + it('non-artifact boot: agrees with the door on every ADR-0087 CONVERSION-governed key, and on the canonical value', async () => { const door = collapse(await readerDoor()); const bundle = collapse(await readerBundle()); const shared = [...bundle.keys()].filter((k) => door.has(k)).sort(); - // Guard the comparison against being vacuously green. - // `capability:crm.export` joined this list at #12892 step 1: the door's - // `ARTIFACT_FIELD_TO_TYPE` now maps `capabilities`, so that collection - // has TWO readers here for the first time. Measured, not predicted — - // and the only edit this list took. - expect(shared).toEqual([ - 'capability:crm.export', - 'permission:support_agent', - 'position:sales_rep', - 'sharing_rule:share_open_deals', - ]); + // Guard the comparison against being vacuously green: every security + // item has a copy on each side of THIS comparison (door vs door-less + // boot). On the artifact boot itself the shared set is empty by + // construction — see the cases above — so this list is about the + // control, not the route. + expect(shared).toEqual(SECURITY_KEYS); for (const key of shared) { const differing = diffPaths(door.get(key), bundle.get(key)); @@ -289,134 +457,41 @@ describe('#12844 — the artifact boot\'s two readers register the same bytes', } }); - it('registration ORDER no longer changes any conversion-governed value — but the two copies are STILL not interchangeable', async () => { - // The card's inference was that once the copies agree, order stops - // mattering. Measured, not assumed — and the measurement says the - // inference holds only on the conversion axis. - const door = await readerDoor(); - const bundleFirst = collapse([...(await readerBundle()), ...door]); - const doorFirst = collapse([...door, ...(await readerBundle())]); - - for (const key of [...doorFirst.keys()].filter((k) => bundleFirst.has(k))) { - const differing = diffPaths(doorFirst.get(key), bundleFirst.get(key)); - for (const governed of CONVERSION_GOVERNED_PATHS) { - expect( - differing, - `${key}: '${governed}' must not depend on which reader ran last`, - ).not.toContain(governed); - } - } + /** + * The parse-axis residual #12844 recorded, still measured key by key — but + * it is now a difference between BOOT SHAPES (a door-less boot serves the + * AppPlugin copy; an artifact boot serves the door's), never between two + * writers on one route. If a future change makes the door-less copy match + * the door's (a strict parse in AppPlugin, say), this case reports it by + * name; if the door's copy drifts, DOOR_COPY above does. + */ + it('the parse-axis difference is between boot shapes now — measured, not reconciled', async () => { + const door = collapse(await readerDoor()); + const bundle = collapse(await readerBundle()); - // ⚠️ The residual, recorded rather than reconciled (#12844 report). - // - // (a) makes the two copies agree on what the ADR-0087 conversion layer - // governs. It does NOT make them the same document: the door also - // strict-PARSES (schema defaults + ADR-0122 input transforms) and - // stamps the ADR-0010 provenance envelope, and the bundle reader does - // neither. So which copy survives still depends on registration order - // — on three axes that have nothing to do with conversion. The - // sharpest is `sharing_rule.condition`: a STRING on the bundle copy - // and `{ dialect, source }` on the door copy, so a consumer reading - // `.condition.source` reads `undefined` from one of them TODAY, with - // no future retired key required. - // - // Closing that is (b) — "one route, one owner" — which the card and - // the triage both put outside this scope. This pin is the evidence for - // it, and turns red the day the routes are unified. - expect(diffPaths(doorFirst.get('sharing_rule:share_open_deals'), bundleFirst.get('sharing_rule:share_open_deals')).sort()) + expect(diffPaths(door.get('sharing_rule:share_open_deals'), bundle.get('sharing_rule:share_open_deals')).sort()) .toEqual(['_packageId', '_packageVersion', '_provenance', 'active', 'condition']); - expect(diffPaths(doorFirst.get('position:sales_rep'), bundleFirst.get('position:sales_rep')).sort()) + expect(diffPaths(door.get('position:sales_rep'), bundle.get('position:sales_rep')).sort()) .toEqual(['_packageId', '_packageVersion', '_provenance', 'delegatable']); - expect(diffPaths(doorFirst.get('permission:support_agent'), bundleFirst.get('permission:support_agent')).sort()) + expect(diffPaths(door.get('permission:support_agent'), bundle.get('permission:support_agent')).sort()) .toEqual([ '_packageId', '_packageVersion', '_provenance', 'isDefault', 'objects.crm_lead.allowTransfer', 'objects.crm_lead.modifyAllRecords', 'objects.crm_lead.viewAllRecords', 'objects.crm_ticket.allowTransfer', 'objects.crm_ticket.modifyAllRecords', 'objects.crm_ticket.viewAllRecords', ]); - // The one a consumer can read today, named explicitly and in the - // direction the order actually produces: last write wins, so - // `doorFirst` leaves the BUNDLE copy standing and `bundleFirst` leaves - // the DOOR copy standing. - expect(typeof (doorFirst.get('sharing_rule:share_open_deals') as any).condition).toBe('string'); - expect(typeof (bundleFirst.get('sharing_rule:share_open_deals') as any).condition).toBe('object'); - }); - - // ── `capabilities`: TWO readers since #12892 step 1 · `policies`: none ──── - // - // Recorded as measurements, not omissions. `policies` still never travels - // this path in a way that could produce two copies. `capabilities` did not - // either until #12892 step 1 put it in the door's map — the case below used - // to assert that ABSENCE, and an assertion of an absence stops being a - // guard the moment the absence is deliberately removed. It is REWRITTEN - // here rather than relaxed, and rewritten UPWARD: it now pins the interim - // divergence key by key. - - /** - * ⚠️ THIS CASE EXISTS TO GO RED WHEN STEP 2 LANDS. That is its job, not a - * regression. - * - * The maintainer's 2026-08-29 ruling on #12892 is two ordered steps: - * - * step 1 (landed) — the door's `ARTIFACT_FIELD_TO_TYPE` maps - * `capabilities`, so BOTH readers now register the collection. Two - * writers on one route is the INTERIM state the ruling permits, and - * what this case measures is exactly how the two copies differ while - * it lasts. - * step 2 (not landed) — `AppPlugin`'s ADR-0057 `SECURITY_FIELDS` block - * stops registering these five on the ARTIFACT path (it must keep - * registering on non-artifact boots), leaving the door's parsed, - * defaulted, provenance-stamped copy as the only one. - * - * The day step 2 lands, `readerBundle()` stops producing - * `capability:crm.export`, and EVERY assertion below goes red — the - * membership pin, the key-by-key divergence set, and the four named-key - * pins alike. Whoever lands step 2 rewrites this case to assert the single - * remaining copy; ⛔ never by deleting, skipping or weakening it, which is - * the one repair that would let the route silently keep two writers. - * - * Two seams, two answers, both real — do not read one as refuting the other: - * HERE the two copies differ on FOUR keys, because `readerBundle()` drives - * `AppPlugin` against a bare `registerInMemory` capture. On a full kernel - * boot the ObjectQL SchemaRegistry stamps `_packageId` / `_provenance` onto - * that same object during package install, so the end-to-end divergence - * narrows to the TWO the registry cannot supply: `scope` (the schema - * default) and `_packageVersion`. Those two are the seam-invariant core and - * are pinned by name below in addition to the set. - */ - it('capabilities: BOTH readers register them since #12892 step 1, and the two copies diverge on exactly four keys', async () => { - const door = collapse(await readerDoor()); - const bundle = collapse(await readerBundle()); - - // Membership: two readers, not one. (Before step 1 the door registered - // nothing under `capability` and this collection had a single writer.) - expect(bundle.get('capability:crm.export'), 'AppPlugin must still register the capability').toBeDefined(); - expect(door.get('capability:crm.export'), 'the door must now register it too').toBeDefined(); - expect([...door.keys()].filter((k) => k.startsWith('capability:'))).toEqual(['capability:crm.export']); - - // The divergence, key by key — the whole set, so a key that appears or - // disappears fails here rather than passing under a looser shape. expect(diffPaths(door.get('capability:crm.export'), bundle.get('capability:crm.export')).sort()) .toEqual(['_packageId', '_packageVersion', '_provenance', 'scope']); - // …and the two that survive every seam, pinned BY NAME with the value - // each side actually carries. `scope` is the `CapabilitySchema` - // default, `_packageVersion` half of the ADR-0010 envelope; the authored - // bytes declare neither, so only the copy that met the schema has them. - const doorCopy = door.get('capability:crm.export') as any; - const bundleCopy = bundle.get('capability:crm.export') as any; - expect(doorCopy.scope).toBe('platform'); - expect(bundleCopy.scope).toBeUndefined(); - expect(doorCopy._packageVersion).toBe('1.0.0'); - expect(bundleCopy._packageVersion).toBeUndefined(); - - // The authored fields agree — "they differ" must not be satisfiable by - // the two copies being different documents altogether. - for (const copy of [doorCopy, bundleCopy]) { - expect(copy).toMatchObject({ name: 'crm.export', label: 'Export CRM data' }); - } + // The sharpest one, by type: a consumer reading `.condition.source` + // gets a value from the door's copy and `undefined` from the door-less + // copy — which is why the artifact boot must serve only the former. + expect(typeof (bundle.get('sharing_rule:share_open_deals') as any).condition).toBe('string'); + expect(typeof (door.get('sharing_rule:share_open_deals') as any).condition).toBe('object'); + expect((door.get('capability:crm.export') as any).scope).toBe('platform'); + expect((bundle.get('capability:crm.export') as any).scope).toBeUndefined(); }); - it('policies: not an authorable stack collection at all — neither reader can see one', async () => { + it('policies: not an authorable stack collection at all — no reader can see one', async () => { // `AppPlugin`'s SECURITY_FIELDS and `ARTIFACT_FIELD_TO_TYPE` each // carried a `policies` → `policy` entry until #12894 removed both: // `ObjectStackDefinitionSchema` is a strictObject with no `policies` @@ -424,12 +499,11 @@ describe('#12844 — the artifact boot\'s two readers register the same bytes', // outright and neither entry could ever match. On the permission set // `policies` is an ALIAS for `rowLevelSecurity` — a key on an ITEM. // - // This case is unchanged by that removal, and deliberately so: it pins - // the SCHEMA fact the removal rests on, which is what makes the entries - // dead. What stops them being re-added is `check:stack-collection-maps`, - // which now reconciles both maps (`ARTIFACT_FIELD_TO_TYPE` and - // `SECURITY_FIELDS`) against this schema — a green run of THIS test is - // not evidence the pointers are gone. + // This case pins the SCHEMA fact the removal rests on, which is what + // makes the entries dead. What stops them being re-added is + // `check:stack-collection-maps`, which reconciles both maps + // (`ARTIFACT_FIELD_TO_TYPE` and `SECURITY_FIELDS`) against this schema + // — a green run of THIS test is not evidence the pointers are gone. const withPolicies = { ...bytes(), policies: [{ name: 'p1', label: 'P1' }] }; const parsed = ObjectStackDefinitionSchema.safeParse(withPolicies); expect(parsed.success).toBe(false); @@ -438,7 +512,9 @@ describe('#12844 — the artifact boot\'s two readers register the same bytes', const door = collapse(await readerDoor()); const bundle = collapse(await readerBundle()); + const bundleOnArtifactBoot = collapse(await readerBundleOnArtifactBoot()); expect([...door.keys()].filter((k) => k.startsWith('policy:'))).toEqual([]); expect([...bundle.keys()].filter((k) => k.startsWith('policy:'))).toEqual([]); + expect([...bundleOnArtifactBoot.keys()].filter((k) => k.startsWith('policy:'))).toEqual([]); }); }); diff --git a/packages/runtime/src/app-plugin.ts b/packages/runtime/src/app-plugin.ts index fa5b2bf6bf..f7d7d53e3e 100644 --- a/packages/runtime/src/app-plugin.ts +++ b/packages/runtime/src/app-plugin.ts @@ -49,9 +49,35 @@ export interface AppPluginProjectContext { source?: 'package' | 'user'; } +/** + * Who registers the stack-declared SECURITY collections (`positions`, + * `permissions`, `capabilities`, `sharingRules`) into the metadata service on + * this boot — the ADR-0057 block in `AppPlugin.start()`. + * + * - `'app-plugin'` (default): this plugin registers them itself. Every + * composition that has no artifact door is this shape — `new AppPlugin(config)` + * over a `defineStack()` module (`os serve` / `os migrate` host configs, the + * plain stacks in a config's `plugins[]`), `DevPlugin`, `@objectstack/verify`'s + * `bootStack`, an embedder. + * - `'artifact-door'`: the composition ALSO runs `MetadataPlugin` with + * `artifactSource` over the same artifact this bundle was read from, so the + * artifact door (`packages/metadata/src/plugin.ts`, `ARTIFACT_FIELD_TO_TYPE`) + * registers the strict-parsed, forward-converted, ADR-0010-stamped copy of + * every item and this plugin leaves those four collections to it (#12892 + * step 2 — maintainer ruling 2026-08-29: the door owns the route). + * `createStandaloneStack` is the composition that says so. + * + * ⛔ Declare `'artifact-door'` ONLY beside a `MetadataPlugin({ artifactSource })` + * reading the same artifact: a declared collection with zero registrars boots + * green and logs nothing (measured on #12892 step 1), and the default is the + * registering branch precisely so a composition that never heard of this option + * cannot lose a registrar. + */ +export type AppPluginSecurityMetadataRegistrar = 'app-plugin' | 'artifact-door'; + /** * AppPlugin - * + * * Adapts a generic App Bundle (Manifest + Runtime Code) into a Kernel Plugin. * * Responsibilities: @@ -107,15 +133,31 @@ export class AppPlugin implements Plugin { * it only writes when something calls it. */ private readonly skipSeedData: boolean; + /** + * See {@link AppPluginSecurityMetadataRegistrar}. Public and readonly so a + * composition test can pin which registrar a boot shape declared. + */ + readonly securityMetadataRegistrar: AppPluginSecurityMetadataRegistrar; constructor( bundle: any, projectContext?: AppPluginProjectContext, - opts: { skipSeedData?: boolean } = {}, + opts: { skipSeedData?: boolean; securityMetadataRegistrar?: AppPluginSecurityMetadataRegistrar } = {}, ) { this.bundle = bundle; this.projectContext = projectContext; this.skipSeedData = opts.skipSeedData ?? false; + // Refused loudly rather than defaulted: a misspelt registrar would + // otherwise fall through to whichever branch the typo happened to + // miss, and both branches are silent about what they did not do. + const registrar = opts.securityMetadataRegistrar ?? 'app-plugin'; + if (registrar !== 'app-plugin' && registrar !== 'artifact-door') { + throw new Error( + `[AppPlugin] securityMetadataRegistrar '${String(registrar)}' is not one of ` + + `'app-plugin' | 'artifact-door'`, + ); + } + this.securityMetadataRegistrar = registrar; // Support both direct manifest (legacy) and Stack Definition (nested manifest) const sys = bundle?.manifest || bundle; const appId = sys?.id || sys?.name; @@ -638,60 +680,46 @@ export class AppPlugin implements Plugin { // metadata registry so the boot seeders (plugin-security / // plugin-sharing) and runtime resolvers can read them via // `list('position'|'permission'|'capability'|'sharing_rule')`. - // Without this, bootStack's metadata service holds only objects (the - // artifact loader that registers these runs only in compiled serve.ts), - // leaving the declarations decorative. + // Without this, a code-defined stack's metadata service holds only + // objects, leaving the declarations decorative. + // + // [#12892 step 2] ONE registrar per boot path. On an ARTIFACT boot + // (`createStandaloneStack`, which composes `MetadataPlugin` with + // `artifactSource` over the very artifact this bundle was read from) + // the artifact door — `MetadataPlugin._parseAndRegisterArtifact`, + // `ARTIFACT_FIELD_TO_TYPE` in packages/metadata/src/plugin.ts — reads + // the same bytes, replays the versioned ADR-0087 forward conversion, + // STRICT-PARSES the definition (schema defaults, ADR-0122 input + // transforms) and stamps the ADR-0010 provenance envelope, and since + // #12892 step 1 it reaches all four of these collections. This block + // used to register a second, unparsed copy of every item on that same + // boot, and because it ran LAST its copy won: a sharing rule's + // `condition` was a STRING here and `{ dialect, source }` on the + // door's copy, a capability carried no `scope` default and no + // `_packageVersion`, and which shape a consumer read depended on + // registration order (pinned in + // app-plugin-artifact-forward-conversion.test.ts). Maintainer ruling + // (2026-08-29, #12892): the door owns the route; two permanent writers + // with matched shapes was refused as an end state. So the composition + // that runs the door declares it — `securityMetadataRegistrar: + // 'artifact-door'` — and this block leaves the four collections to it. + // Every composition without a door (`new AppPlugin(config)` over a + // `defineStack()` module in `os serve` / `os migrate`, `DevPlugin`, + // `@objectstack/verify`'s `bootStack`, an embedder) keeps registering + // exactly as before under the default `'app-plugin'`. + // + // The discriminator is DECLARED at the composition site rather than + // inferred here — from a marker on the bundle, or from whether the + // metadata service already holds the door's items — on purpose: a + // parsed bundle carries no provenance, and a registry read during + // `start()` would turn on start ORDER, the very dependence this + // removes. The default is the registering branch, so a composition + // that never heard of the option cannot lose a registrar. try { const metadata = ctx.getService('metadata') as | { registerInMemory?: (t: string, n: string, d: unknown) => void } | undefined; if (typeof metadata?.registerInMemory === 'function') { - const rawSecurityBundle: any = this.bundle.manifest - ? { ...this.bundle.manifest, ...this.bundle } - : this.bundle; - // [#12844] Same bytes, same conversion policy — one funnel. - // - // On an artifact boot these declarations reach the metadata - // registry through TWO independent readers: the artifact door - // (`MetadataPlugin._parseAndRegisterArtifact`), which since - // #12772 replays the versioned ADR-0087 forward conversion - // over the definition before its strict parse, and this block, - // which received the same JSON from `loadArtifactBundle` (no - // validation, no conversion). Reading it raw here made - // "artifact metadata is converted at ingestion" only half - // true: the two copies of the same permission set differed, - // and which one a consumer saw depended on registration order - // and read path. Nothing read the difference when this was - // filed — the retired keys involved gate nothing BY THE - // DEFINITION of their retirement — but that is a property of - // those keys, not of this path: the next retired key whose - // value a consumer does read would diverge silently at - // registration and explode at whatever seam re-validates - // (e.g. a Studio re-save through `saveMetaItem`, which rejects - // with the current schema). - // - // So this reader consumes the door's OWN policy function - // rather than a second opinion about it — the whole - // definition, exactly as the door converts it, so no - // conversion-specific knowledge leaks in here (the - // `roles` -> `positions` entry rewrites a COLLECTION KEY, not - // an item, and a projection would silently miss it). - // - // Not surfaced operator-visibly: on an artifact boot the door - // already prints one deduped summary per conversion for these - // very bytes, and a second copy of it would double the boot - // log without adding a fact. `debug` keeps it diagnosable. - const forwardConverted = applyArtifactForwardConversions(rawSecurityBundle); - if (forwardConverted.notices.length > 0) { - ctx.logger.debug('[AppPlugin] applied ADR-0087 forward conversion to stack-declared security metadata', { - appId, - verdict: forwardConverted.verdict, - authoredFloor: forwardConverted.authoredFloor, - runtimeSpecVersion: forwardConverted.runtimeSpecVersion, - notices: forwardConverted.notices.length, - }); - } - const securityBundle: any = forwardConverted.definition; const SECURITY_FIELDS: Array<[string, string]> = [ ['positions', 'position'], ['permissions', 'permission'], @@ -714,18 +742,68 @@ export class AppPlugin implements Plugin { // fourth attempt at a key the schema does not declare fails // in CI instead of sitting here inert. ]; - let count = 0; - for (const [field, type] of SECURITY_FIELDS) { - const arr = securityBundle?.[field]; - if (!Array.isArray(arr)) continue; - for (const item of arr) { - if (!item?.name) continue; - metadata.registerInMemory(type, item.name, item); - count += 1; + if (this.securityMetadataRegistrar === 'artifact-door') { + // Not surfaced operator-visibly: the door prints its own + // `Artifact metadata loaded` summary for these very bytes, + // and a second line per boot would add no fact. `debug` + // keeps "who registered this collection?" answerable from + // the boot log. + ctx.logger.debug( + '[AppPlugin] artifact boot — stack-declared security metadata is registered by the artifact door (MetadataPlugin), not here', + { + appId, + registrar: this.securityMetadataRegistrar, + collections: SECURITY_FIELDS.map(([field]) => field), + }, + ); + } else { + const rawSecurityBundle: any = this.bundle.manifest + ? { ...this.bundle.manifest, ...this.bundle } + : this.bundle; + // [#12844] Same bytes, same conversion policy — one funnel. + // + // A `defineStack()` module can declare an `engines.protocol` + // floor below the installed spec exactly as a compiled + // artifact can, so this reader consumes the door's OWN + // policy function rather than a second opinion about it — + // the whole definition, exactly as the door converts it, so + // no conversion-specific knowledge leaks in here (the + // `roles` -> `positions` entry rewrites a COLLECTION KEY, + // not an item, and a projection would silently miss it). + // Reading the bytes raw here is what made "metadata is + // converted at ingestion" only half true: a retired key + // whose value a consumer reads would diverge silently at + // registration and explode at whatever seam re-validates + // (a Studio re-save through `saveMetaItem`, which rejects + // with the current schema). + // + // `debug`, not `info`: boot logs are long already, and the + // conversion notice is a fact about the AUTHORED floor, not + // a degradation. + const forwardConverted = applyArtifactForwardConversions(rawSecurityBundle); + if (forwardConverted.notices.length > 0) { + ctx.logger.debug('[AppPlugin] applied ADR-0087 forward conversion to stack-declared security metadata', { + appId, + verdict: forwardConverted.verdict, + authoredFloor: forwardConverted.authoredFloor, + runtimeSpecVersion: forwardConverted.runtimeSpecVersion, + notices: forwardConverted.notices.length, + }); + } + const securityBundle: any = forwardConverted.definition; + let count = 0; + for (const [field, type] of SECURITY_FIELDS) { + const arr = securityBundle?.[field]; + if (!Array.isArray(arr)) continue; + for (const item of arr) { + if (!item?.name) continue; + metadata.registerInMemory(type, item.name, item); + count += 1; + } + } + if (count > 0) { + ctx.logger.info('Registered stack-declared security metadata', { appId, count }); } - } - if (count > 0) { - ctx.logger.info('Registered stack-declared security metadata', { appId, count }); } } } catch (err) { diff --git a/packages/runtime/src/standalone-stack-security-registrar.test.ts b/packages/runtime/src/standalone-stack-security-registrar.test.ts new file mode 100644 index 0000000000..17f550cb6d --- /dev/null +++ b/packages/runtime/src/standalone-stack-security-registrar.test.ts @@ -0,0 +1,189 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #12892 step 2, driven on the REAL boot. `createStandaloneStack` composes the +// artifact door (`MetadataPlugin({ artifactSource })`) and the artifact's +// `AppPlugin` in one plugin list; the kernel orders their `start()`s; and the +// metadata service is what every consumer then reads — `GET /meta/`, the +// permission evaluator's `list('permission')`, the seeders' fallback reads. +// The two-reader harness (app-plugin-artifact-forward-conversion.test.ts) +// proves the mechanism per reader. Only a kernel boot can prove the +// COMPOSITION, because "which copy is in the registry" is a question about the +// booted service, not about either plugin alone — and it was answered wrongly +// before: the door registered the parsed copy first, `AppPlugin` overwrote it +// with the raw one last, and the boot was green. +// +// The CONTROL is the same kernel with the pre-step-2 composition: the door plus +// an `AppPlugin` constructed WITHOUT the declaration (the default — byte for +// byte what `createStandaloneStack` composed before this change). It has to +// reproduce the defect, or the fixed leg proves nothing. + +import { describe, it, expect, afterEach } from 'vitest'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Runtime } from './runtime.js'; +import { AppPlugin } from './app-plugin.js'; +import { createStandaloneStack } from './standalone-stack.js'; + +// [#10126] Pay the first transform of these dist-resolved workspace deps at MODULE +// LOAD. Each is reached below through a dynamic `import()` inside an `it()` body +// (the factory and the kernel boot both import lazily) -- vitest clocks those, +// while collection is clocked against nothing. See +// `scripts/check-test-source-alias.mjs` (the clocked-window rule). +import '@objectstack/metadata'; +import '@objectstack/objectql'; +import '@objectstack/service-datasource'; + +/** + * Same probe artifact as the two-reader harness: a legacy shape in every + * security collection, an `engines.protocol` floor that opens the door's + * conversion window. The values asserted below are the door's measured output + * for these bytes (DOOR_COPY in the harness). + */ +const ARTIFACT = { + manifest: { + id: 'com.test.issue-12892-boot', + name: 'Single Registrar Boot', + type: 'app', + version: '2.0.0', + engines: { protocol: '^17.1.0' }, + }, + roles: [{ name: 'sales_rep', label: 'Sales Rep' }], + permissions: [ + { + name: 'support_agent', + label: 'Support Agent', + objects: { + crm_ticket: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, allowRestore: true, allowPurge: false }, + }, + rowLevelSecurity: [ + { name: 'own_tasks', object: 'crm_task', operation: 'select', using: 'assignee == current_user.email', enabled: true, priority: 10 }, + ], + }, + ], + capabilities: [{ name: 'crm.export', label: 'Export CRM data' }], + sharingRules: [ + { + name: 'share_open_deals', + type: 'criteria', + object: 'crm_deal', + accessLevel: 'full', + condition: 'record.status == "open"', + sharedWith: { type: 'role', value: 'sales_mgr' }, + }, + ], +}; + +const KINDS = ['position', 'permission', 'capability', 'sharing_rule'] as const; +const BOOT_TIMEOUT = 90_000; + +async function boot(plugins: readonly unknown[]) { + const runtime = new Runtime({ cluster: false }); + const kernel = runtime.getKernel(); + for (const p of plugins) await kernel.use(p as any); + await kernel.bootstrap(); + return kernel; +} + +/** What the booted metadata service serves for each security kind. */ +async function readSecurity(kernel: any): Promise> { + const metadata = kernel.getService('metadata'); + const out: any = {}; + for (const kind of KINDS) { + const listed = metadata.list(kind); + out[kind] = typeof listed?.then === 'function' ? await listed : listed; + } + return out; +} + +describe('createStandaloneStack — the artifact boot serves ONE copy of each security item, the door\'s (#12892 step 2)', () => { + const dirs: string[] = []; + const kernels: any[] = []; + + afterEach(async () => { + for (const k of kernels.splice(0)) { + try { await k.shutdown(); } catch { /* noop */ } + } + for (const d of dirs.splice(0)) { + try { rmSync(d, { recursive: true, force: true }); } catch { /* noop */ } + } + }); + + function writeArtifact(tag: string): { dir: string; artifactPath: string } { + const dir = mkdtempSync(join(tmpdir(), `os-12892-${tag}-`)); + dirs.push(dir); + const artifactPath = join(dir, 'objectstack.json'); + writeFileSync(artifactPath, JSON.stringify(ARTIFACT), 'utf-8'); + return { dir, artifactPath }; + } + + it('fixed: after a real boot the metadata service holds the parsed, defaulted, provenance-stamped copy of every item', async () => { + const { dir, artifactPath } = writeArtifact('fixed'); + const stack = await createStandaloneStack({ + artifactPath, + projectRoot: dir, + databaseUrl: 'memory://issue-12892-fixed', + skipSeedData: true, + runPlatformMigrations: false, + }); + const app = stack.plugins.find((p: any) => p?.type === 'app') as AppPlugin; + expect(app.securityMetadataRegistrar).toBe('artifact-door'); + + const kernel = await boot(stack.plugins); + kernels.push(kernel); + const got = await readSecurity(kernel); + + // One item per kind — the door's, and nothing shadowing it. + for (const kind of KINDS) expect(got[kind].map((i: any) => i.name), kind).toHaveLength(1); + + // The read a consumer can make today: the predicate is an OBJECT. + expect(got.sharing_rule[0].condition).toEqual({ dialect: 'cel', source: 'record.status == "open"' }); + expect(got.sharing_rule[0]).toMatchObject({ + name: 'share_open_deals', active: true, accessLevel: 'edit', + sharedWith: { type: 'position', value: 'sales_mgr' }, + _packageId: 'com.test.issue-12892-boot', _packageVersion: '2.0.0', _provenance: 'package', + }); + // The four keys the raw copy lacked on a capability (#12892 step 1). + expect(got.capability[0]).toMatchObject({ + name: 'crm.export', scope: 'platform', + _packageId: 'com.test.issue-12892-boot', _packageVersion: '2.0.0', _provenance: 'package', + }); + expect(got.position[0]).toMatchObject({ name: 'sales_rep', delegatable: false, _packageVersion: '2.0.0' }); + expect(got.permission[0]).toMatchObject({ name: 'support_agent', isDefault: false, _packageVersion: '2.0.0' }); + expect(got.permission[0].objects.crm_ticket).not.toHaveProperty('allowRestore'); + expect(got.permission[0].objects.crm_ticket).toMatchObject({ allowTransfer: false, viewAllRecords: false, modifyAllRecords: false }); + expect(got.permission[0].rowLevelSecurity[0]).not.toHaveProperty('priority'); + }, BOOT_TIMEOUT); + + it('control: the pre-step-2 composition (door + a default AppPlugin) reproduces the raw copy winning', async () => { + const { dir, artifactPath } = writeArtifact('control'); + const stack = await createStandaloneStack({ + artifactPath, + projectRoot: dir, + databaseUrl: 'memory://issue-12892-control', + skipSeedData: true, + runPlatformMigrations: false, + }); + // Swap in the AppPlugin every door-less composition constructs — over the + // same bytes `loadArtifactBundle` handed the factory — and keep the door. + const plugins = stack.plugins.map((p: any) => + p?.type === 'app' ? new AppPlugin(JSON.parse(readFileSync(artifactPath, 'utf-8'))) : p, + ); + expect((plugins.find((p: any) => p?.type === 'app') as AppPlugin).securityMetadataRegistrar).toBe('app-plugin'); + + const kernel = await boot(plugins); + kernels.push(kernel); + const got = await readSecurity(kernel); + + // Still one item per kind — two WRITERS, not two items; the last one wins. + for (const kind of KINDS) expect(got[kind].map((i: any) => i.name), kind).toHaveLength(1); + // …and the survivor is the raw copy: the defect this change removes, by + // type. `_packageVersion` and `scope` are the two keys no other seam can + // supply (the ObjectQL registry stamps `_packageId`/`_provenance` on the + // same object during package install, which is why only these two are + // asserted absent). + expect(typeof got.sharing_rule[0].condition).toBe('string'); + expect(got.capability[0].scope).toBeUndefined(); + expect(got.capability[0]._packageVersion).toBeUndefined(); + }, BOOT_TIMEOUT); +}); diff --git a/packages/runtime/src/standalone-stack.test.ts b/packages/runtime/src/standalone-stack.test.ts index d148d3f139..7410f58e02 100644 --- a/packages/runtime/src/standalone-stack.test.ts +++ b/packages/runtime/src/standalone-stack.test.ts @@ -206,6 +206,25 @@ describe('createStandaloneStack — surfaces app RBAC from the artifact (ADR-005 expect(appDefaultPermissionSetName(config.permissions)).toBe('app_member_default'); }); + // [#12892 step 2] The composition invariant behind the single-registrar rule: + // the AppPlugin this factory composes DECLARES that the artifact door + // registers its security collections, and the MetadataPlugin composed in the + // same list reads the SAME artifact through that door. Either half alone is + // wrong — the declaration without the door leaves four collections with no + // registrar (a boot that stays green and logs nothing, measured at step 1); + // the door without the declaration is the two-writer state the ruling + // refused. Pinned here, on the real factory output, because the two lines + // that make it true sit 20 lines apart in standalone-stack.ts and nothing + // else ties them together. + it('[#12892 step 2] composes the artifact AppPlugin as a NON-registrar of security metadata, beside the MetadataPlugin that reads the same artifact', () => { + const app = result.plugins.find((p: any) => p?.type === 'app') as any; + expect(app, 'the factory must compose an AppPlugin for a present artifact').toBeDefined(); + expect(app.securityMetadataRegistrar).toBe('artifact-door'); + const door = result.plugins.find((p: any) => p?.name === 'com.objectstack.metadata') as any; + expect(door, 'the factory must compose the MetadataPlugin (the artifact door)').toBeDefined(); + expect(door.options?.artifactSource).toEqual({ mode: 'local-file', path: artifactPath }); + }); + it('createDefaultHostConfig (the actual serve artifact-fallback) surfaces the same', async () => { const r = await createDefaultHostConfig({ requireArtifact: true, diff --git a/packages/runtime/src/standalone-stack.ts b/packages/runtime/src/standalone-stack.ts index 625f281d5f..6c224f5e24 100644 --- a/packages/runtime/src/standalone-stack.ts +++ b/packages/runtime/src/standalone-stack.ts @@ -757,7 +757,19 @@ export async function createStandaloneStack(config?: StandaloneStackConfig): Pro new ObjectQLPlugin({ environmentId, runPlatformMigrations: cfg.runPlatformMigrations ?? true }), ]; if (artifactBundle) { - plugins.push(new AppPlugin(artifactBundle, undefined, { skipSeedData: cfg.skipSeedData ?? false })); + plugins.push(new AppPlugin(artifactBundle, undefined, { + skipSeedData: cfg.skipSeedData ?? false, + // [#12892 step 2] The MetadataPlugin composed above reads this SAME + // artifact through the artifact door (`artifactSource.path` is + // `artifactPath`), which strict-parses, forward-converts and + // ADR-0010-stamps every item — so it, not AppPlugin's ADR-0057 + // block, registers `positions` / `permissions` / `capabilities` / + // `sharingRules` on this boot. One route, one owner (maintainer + // ruling 2026-08-29 on #12892). The option is a DECLARATION that + // the door runs here, which is why it is set in this function and + // nowhere else: the two lines that make it true are 20 lines apart. + securityMetadataRegistrar: 'artifact-door', + })); } // Surface artifact-declared metadata so a caller using this result