From 2fdffeed03f7e442469b71af45ca2abc56c63bce Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 09:49:42 +0000 Subject: [PATCH 1/4] fix(runtime): AppPlugin registers the forward-converted copy of every collection AppPlugin handed the manifest service the RAW bundle, so the ObjectQL registry (the code layer GET /meta/ lists and the email loader reads) held items with retired keys while the artifact door's item read served the converted copy. The boot now runs ONE ADR-0087 forward conversion over { ...manifest, ...collections } after the D1 handshake, memoized, and both the manifest registration and the security block take it. Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude --- packages/runtime/src/app-plugin.ts | 100 +++++++++++++++++++++++------ 1 file changed, 79 insertions(+), 21 deletions(-) diff --git a/packages/runtime/src/app-plugin.ts b/packages/runtime/src/app-plugin.ts index 7e9b07d8b1d..39d6bb3b695 100644 --- a/packages/runtime/src/app-plugin.ts +++ b/packages/runtime/src/app-plugin.ts @@ -14,7 +14,11 @@ import { registerArtifactGrantedPermissions, type ArtifactGrantBinding, } from './security/artifact-granted-permissions.js'; -import { applyArtifactForwardConversions, assertProtocolCompat } from '@objectstack/metadata-core'; +import { + applyArtifactForwardConversions, + assertProtocolCompat, + type ArtifactForwardConversionResult, +} from '@objectstack/metadata-core'; import { resolveTenancyPosture } from '@objectstack/types'; import { postureEnforcesWall, type TenancyPosture } from '@objectstack/spec/security'; import { SeedLoaderService } from './seed-loader.js'; @@ -173,6 +177,8 @@ export class AppPlugin implements Plugin { * the residual-owner warning prints once, at whichever read comes first. */ private codeDatasourceOwners?: Array<{ datasource: any; owner: { packageId?: string; packageVersion?: string } }>; + /** Memoized backing store for {@link forwardConverted} — the boot's one conversion. */ + private bootConversion?: ArtifactForwardConversionResult; /** When true, init/start become no-ops — env has no app payload. */ private readonly empty: boolean = false; /** @@ -239,6 +245,54 @@ export class AppPlugin implements Plugin { return (this.resolvedCollections ??= resolveArtifactCollections(this.bundle)); } + /** + * [#22994] The ONE ADR-0087 forward conversion this boot runs over its + * definition, and the copy every registrar in this plugin takes — the + * `manifest` service's decomposition in `init()` (so the ObjectQL + * registry, which `GET /meta/` lists and every reader of the + * flattened list — the email loader among them — reads as its code layer) + * and the security block in `start()`. + * + * The input is `{ ...manifest, ...collections }`, the definition the + * security block has converted since #12844, and for a bundle carrying no + * `packages[]` it is byte for byte the payload `init()` has always + * registered. So the registry and the security registrar read one + * conversion of one input, and neither reader converts again. The door's + * own policy function decides the window, as at the artifact door: the + * same bytes, the same verdict. + * + * Only the converted copy goes forward. Which SOURCE a collection came + * from (the manifest or the stack, #18034) is still decided on the RAW + * inputs, at the security block, because the conversion can rewrite a + * collection KEY (`roles` -> `positions`). + * + * Memoized on first read. `init()` reads it only when the bundle carries + * no `packages[]`: resolving that list belongs to the `manifest` service's + * `register()` (#15292), and `collections` would resolve it here. + * + * `debug`, not `info`: on an artifact boot the door prints its own deduped + * operator summary for these bytes, and the notice is a fact about the + * authored floor, not a degradation. + */ + private forwardConverted(ctx: PluginContext): ArtifactForwardConversionResult { + if (this.bootConversion === undefined) { + const collections = this.collections; + const manifest: any = collections?.manifest; + const conversion = applyArtifactForwardConversions(manifest ? { ...manifest, ...collections } : collections); + this.bootConversion = conversion; + if (conversion.notices.length > 0) { + ctx.logger.debug('[AppPlugin] applied ADR-0087 forward conversion to the stack definition', { + pluginName: this.name, + verdict: conversion.verdict, + authoredFloor: conversion.authoredFloor, + runtimeSpecVersion: conversion.runtimeSpecVersion, + notices: conversion.notices.length, + }); + } + } + return this.bootConversion; + } + /** * The enforcer this artifact's consent records were registered on, or * `undefined` when the artifact declared no `grantedPermissions` key. @@ -435,9 +489,25 @@ export class AppPlugin implements Plugin { // Register the app manifest directly via the manifest service. // This immediately decomposes the manifest into SchemaRegistry entries. - const servicePayload = this.bundle.manifest - ? { ...this.bundle.manifest, ...this.bundle } - : this.bundle; + // + // [#22994] The CONVERTED copy, AFTER the handshake above (ADR-0087 D1 + // before D2), and the same conversion the security block in `start()` + // registers from — see `forwardConverted`. Registering the raw bundle + // here is what made `GET /meta/` list an item with its retired + // key (`_diagnostics.valid: false`) while `GET /meta//NAME` + // served the artifact door's converted copy of it, and what handed the + // email loader a template it refuses to send. + // + // A bundle carrying `packages[]` is handed over as authored, as before: + // the `manifest` service registers its package BODIES, which no + // conversion reaches (the ADR-0087 pass walks the definition's own + // collections, at the artifact door too), and `forwardConverted` would + // resolve `packages[]` here, ahead of the `manifest` service (#15292). + const servicePayload = (this.bundle as { packages?: unknown }).packages === undefined + ? this.forwardConverted(ctx).definition + : this.bundle.manifest + ? { ...this.bundle.manifest, ...this.bundle } + : this.bundle; ctx.getService<{ register(m: any): void }>('manifest').register(servicePayload); @@ -1116,9 +1186,6 @@ export class AppPlugin implements Plugin { ); } else { const manifestSource: any = this.collections.manifest; - const rawSecurityBundle: any = manifestSource - ? { ...manifestSource, ...this.collections } - : this.collections; // [#12844] Same bytes, same conversion policy — one funnel. // // A `defineStack()` module can declare an `engines.protocol` @@ -1136,20 +1203,11 @@ export class AppPlugin implements Plugin { // (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; + // [#22994] Not converted HERE any more: this is the boot's + // one conversion (`forwardConverted`), the copy `init()` + // already handed the `manifest` service, so the registry and + // this registrar cannot read two conversions of one bundle. + const securityBundle: any = this.forwardConverted(ctx).definition; let count = 0; for (const [field, type] of SECURITY_FIELDS) { const arr = securityBundle?.[field]; From 1870d3f3fe0b43b7246cd99c511186852f90fca0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 10:00:40 +0000 Subject: [PATCH 2/4] test(runtime): pin the converted registry copy on the artifact boot, beside a current-spelling control The list read, the item read and the execution face of a real createStandaloneStack boot over an undeclared-engines artifact, for a dashboard (dashboard-refresh-interval-to-refresh-interval-seconds) and an email template, plus a control artifact in the current spellings and three unit legs: one conversion shared by both registrars, a current bundle handed over by reference, and a packages[] bundle handed over as authored. Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude --- .../22994-app-plugin-converted-collections.md | 13 + .../app-plugin-converted-collections.test.ts | 314 ++++++++++++++++++ 2 files changed, 327 insertions(+) create mode 100644 .changeset/22994-app-plugin-converted-collections.md create mode 100644 packages/runtime/src/app-plugin-converted-collections.test.ts diff --git a/.changeset/22994-app-plugin-converted-collections.md b/.changeset/22994-app-plugin-converted-collections.md new file mode 100644 index 00000000000..97f8eadb22e --- /dev/null +++ b/.changeset/22994-app-plugin-converted-collections.md @@ -0,0 +1,13 @@ +--- +'@objectstack/runtime': patch +--- + +`GET /api/v1/meta/` now lists an artifact's items in their converted shape, the shape `GET /api/v1/meta//` serves + +Clause-②: no + +A compiled artifact built before a spec retirement still carries the retired key, and the ADR-0087 forward conversion rewrites it when the artifact boots. The artifact door converted it, so the item read served the converted copy. `AppPlugin` handed the RAW bundle to the engine registry, which is what the list read, `getMetaItemsForExecution` and the email template loader read. So the list served the retired key with `_diagnostics.valid: false`, and a reader of the list (the email loader is one) was handed an item the current schema refuses. Measured with `os dev -a dist/objectstack.json --fresh` on an artifact that declares no `engines.protocol` and carries a dashboard with `refreshInterval: 300`: the item read served `refreshIntervalSeconds: 300`, valid, and the list served `refreshInterval: 300`, invalid. + +`AppPlugin` now runs one forward conversion per boot, after the protocol handshake, over the same definition its security registrar already converted. The registry and that registrar both register from its result. An artifact authored in the current spellings registers exactly what it did before, because a conversion that changes nothing returns its input. + +One case is unchanged: a bundle that carries `packages[]` still hands its package bodies over as authored. No conversion reaches those bodies, at the artifact door either. diff --git a/packages/runtime/src/app-plugin-converted-collections.test.ts b/packages/runtime/src/app-plugin-converted-collections.test.ts new file mode 100644 index 00000000000..f7709eb06a7 --- /dev/null +++ b/packages/runtime/src/app-plugin-converted-collections.test.ts @@ -0,0 +1,314 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #22994: `AppPlugin` registers the ADR-0087 forward-converted copy of EVERY + * collection, not only the security ones (#12844), and it converts once. + * + * The seam is `AppPlugin.init()`'s `manifest.register(...)`. It decomposes the + * bundle into the ObjectQL registry over `METADATA_ARRAY_KEYS`, and that + * registry is the code layer of the flattened list, the list + * `GET /meta/` serves and that `getMetaItemsForExecution` hands to the + * email loader. It used to be handed the RAW bundle. On the artifact boot + * (`createStandaloneStack`, the composition `os dev -a` runs) the door, + * `MetadataPlugin`, registers the converted copy into the metadata service, so + * `GET /meta//NAME` served that copy while the list served the raw one. + * Measured before this change, on `origin/main`, for the artifact below: + * + * list dashboard/ops_board refreshInterval: 300 _diagnostics.valid: false + * item dashboard/ops_board refreshIntervalSeconds: 300 _diagnostics.valid: true + * execution face refreshInterval: 300 + * + * The second type is `dashboard`, converted by the major-18 retired entry + * `dashboard-refresh-interval-to-refresh-interval-seconds` (retiredAfter + * 17.3.0). It sits beside `emailTemplates`, the card's own type. That type has + * no conversion on this tree: its `category` / `isSystem` retirement is a + * separate change. So its legs below assert the contract (the registry + * holds the conversion's OUTPUT, and that output passes the email loader's own + * parse). That contract has no retired key to bite on here, and it does the + * day a retirement of that type lands. + * + * Out of scope, on purpose: the PARSE axis. The door strict-parses (schema + * defaults, ADR-0122 transforms), and the registry copy does not. So the item + * read can carry keys the listed copy lacks (an email template's + * `variables: []` / `active: true`). The list pins below therefore assert + * "every key the list serves is the key the item read serves", never "the two + * are one document". Unifying them is a route-ownership question (#12892), + * not this conversion. + */ + +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { applyArtifactForwardConversions } from '@objectstack/metadata-core'; +import { stripReadDecorations } from '@objectstack/spec/kernel'; +import { EmailTemplateDefinitionSchema } from '@objectstack/spec/system'; +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: the factory and the kernel boot below import them lazily, inside +// a clocked hook (`scripts/check-test-source-alias.mjs`, the clocked-window rule). +import '@objectstack/metadata'; +import '@objectstack/objectql'; +import '@objectstack/service-datasource'; + +const APP_ID = 'com.test.issue-22994'; +const STAMPS = { _packageId: APP_ID, _packageVersion: '1.0.0', _provenance: 'package' } as const; + +/** A dashboard carrying the retired `refreshInterval` spelling. */ +const RETIRED_DASHBOARD = { name: 'ops_board', label: 'Ops Board', widgets: [], refreshInterval: 300 }; +/** What a 17.x parse emitted for an email template: both defaulted keys present. */ +const EMAIL_TEMPLATE = { + name: 'probe.welcome', + label: 'Welcome', + locale: 'en-US', + subject: 'Welcome', + bodyHtml: '

Hi

', + category: 'custom', + isSystem: false, +}; + +/** + * The card's reproduction shape: an UNDECLARED-engines artifact, so the + * window opens (`converted-undeclared`) at the door and here alike. + */ +const RETIRED_ARTIFACT = { + manifest: { id: APP_ID, name: 'Converted Collections Probe', type: 'app', version: '1.0.0' }, + dashboards: [RETIRED_DASHBOARD], + emailTemplates: [EMAIL_TEMPLATE], +}; + +/** The CONTROL: the same app authored in the current spellings. Nothing to convert. */ +const CURRENT_DASHBOARD = { name: 'ops_board', label: 'Ops Board', widgets: [], refreshIntervalSeconds: 300 }; +const CURRENT_EMAIL_TEMPLATE = { name: 'probe.welcome', label: 'Welcome', locale: 'en-US', subject: 'Welcome', bodyHtml: '

Hi

' }; +const CURRENT_ARTIFACT = { + manifest: { id: APP_ID, name: 'Converted Collections Probe', type: 'app', version: '1.0.0' }, + dashboards: [CURRENT_DASHBOARD], + emailTemplates: [CURRENT_EMAIL_TEMPLATE], +}; + +const BOOT_TIMEOUT = 120_000; + +/** Fresh bytes per reader: readers stamp and normalize what they are handed. */ +const copy = (value: T): T => JSON.parse(JSON.stringify(value)); + +/** `item` minus the read-side `_diagnostics` badge, which only the served reads add. */ +function undiagnosed(item: Record): Record { + const { _diagnostics: _ignored, ...rest } = item; + return rest; +} + +/** The authored item as the conversion rewrites it: the policy function's own answer. */ +function convertedAuthored(collection: 'dashboards' | 'emailTemplates', name: string): Record { + const result = applyArtifactForwardConversions(copy(RETIRED_ARTIFACT) as Record); + const items = (result.definition as Record>>)[collection]; + const item = items.find((i) => i.name === name); + if (!item) throw new Error(`the conversion lost ${collection}/${name}`); + return item; +} + +interface BootedArtifact { + kernel: any; + dir: string; +} + +/** Boot the card's composition over `artifact`, the way `os dev -a` composes it. */ +async function bootArtifact(tag: string, artifact: unknown): Promise { + const dir = mkdtempSync(join(tmpdir(), `os-22994-${tag}-`)); + const artifactPath = join(dir, 'objectstack.json'); + writeFileSync(artifactPath, JSON.stringify(artifact), 'utf-8'); + const stack = await createStandaloneStack({ + artifactPath, + projectRoot: dir, + databaseUrl: ':memory:', + skipSeedData: true, + runPlatformMigrations: false, + }); + const runtime = new Runtime({ cluster: false }); + const kernel = runtime.getKernel(); + for (const p of stack.plugins) await kernel.use(p as any); + await kernel.bootstrap(); + return { kernel, dir }; +} + +async function teardown(booted: BootedArtifact | undefined): Promise { + if (!booted) return; + try { await booted.kernel.shutdown(); } catch { /* noop */ } + rmSync(booted.dir, { recursive: true, force: true }); +} + +/** The one item named `name` in the list `getMetaItems({ type })` serves, the `GET /meta/` read. */ +async function listed(kernel: any, type: string, name: string): Promise> { + const answer = await kernel.getService('protocol').getMetaItems({ type }); + const hits = (answer?.items as Array> | undefined)?.filter((i) => i?.name === name) ?? []; + expect(hits, `GET /meta/${type} must list exactly one ${name}`).toHaveLength(1); + return hits[0]!; +} + +/** The item `getMetaItem({ type, name })` serves, the `GET /meta//NAME` read. */ +async function served(kernel: any, type: string, name: string): Promise> { + const answer = await kernel.getService('protocol').getMetaItem({ type, name }); + expect(answer?.item, `GET /meta/${type}/${name} must serve an item`).toBeDefined(); + return answer.item as Record; +} + +/** The one item named `name` on the execution face, the list the email loader reads. */ +async function executed(kernel: any, type: string, name: string): Promise> { + const answer = await kernel.getService('protocol').getMetaItemsForExecution({ type }); + const hits = (answer?.items as Array> | undefined)?.filter((i) => i?.name === name) ?? []; + expect(hits, `the execution face must carry exactly one ${type}/${name}`).toHaveLength(1); + return hits[0]!; +} + +describe('#22994: the artifact boot lists the converted copy the item read serves', () => { + let booted: BootedArtifact | undefined; + + beforeAll(async () => { + booted = await bootArtifact('retired', RETIRED_ARTIFACT); + }, BOOT_TIMEOUT); + + afterAll(async () => { + await teardown(booted); + }); + + it('dashboard: GET /meta/dashboard lists the converted copy, valid, the very document GET /meta/dashboard/NAME serves', async () => { + const list = await listed(booted!.kernel, 'dashboard', 'ops_board'); + const item = await served(booted!.kernel, 'dashboard', 'ops_board'); + + expect(list.refreshIntervalSeconds).toBe(300); + expect(list).not.toHaveProperty('refreshInterval'); + expect(list._diagnostics).toEqual({ valid: true }); + // The dashboard schema adds no default here, so the two reads are one + // document. That is the card's whole defect, stated directly. + expect(undiagnosed(list)).toEqual(undiagnosed(item)); + }); + + it('dashboard: the execution face carries the converted copy, the conversion\'s own output', async () => { + const exec = await executed(booted!.kernel, 'dashboard', 'ops_board'); + expect(exec).toEqual({ ...convertedAuthored('dashboards', 'ops_board'), ...STAMPS }); + }); + + it('email_template: every key the list serves is the key the item read serves, and the list is valid', async () => { + const list = await listed(booted!.kernel, 'email_template', 'probe.welcome'); + const item = await served(booted!.kernel, 'email_template', 'probe.welcome'); + + expect(list._diagnostics).toEqual({ valid: true }); + // Subset, not equality: the item read is the door's strict-parsed copy + // and carries schema defaults the registry copy never had (the parse + // axis, #12892). A retired key the conversion strips is in neither, + // and one the registry kept would fail here. + for (const [key, value] of Object.entries(undiagnosed(list))) { + expect(item[key], `GET /meta/email_template/probe.welcome serves ${key}`).toEqual(value); + } + }); + + it('email_template: the email loader is handed the conversion\'s output, and it parses as the loader parses it', async () => { + const exec = await executed(booted!.kernel, 'email_template', 'probe.welcome'); + expect(exec).toEqual({ ...convertedAuthored('emailTemplates', 'probe.welcome'), ...STAMPS }); + // `createRegistryEmailTemplateLoader`'s own refusal test, verbatim: + // `EmailTemplateDefinitionSchema.safeParse(stripReadDecorations(item))`, + // which refuses with "is not a valid email template, so it cannot be sent". + const parsed = EmailTemplateDefinitionSchema.safeParse(stripReadDecorations(exec)); + expect(parsed.success, parsed.success ? '' : JSON.stringify(parsed.error.issues)).toBe(true); + }); +}); + +describe('#22994 control: an artifact authored in the current spellings registers exactly what it authored', () => { + let booted: BootedArtifact | undefined; + + beforeAll(async () => { + booted = await bootArtifact('current', CURRENT_ARTIFACT); + }, BOOT_TIMEOUT); + + afterAll(async () => { + await teardown(booted); + }); + + it('the registry holds the authored items, stamped, and nothing rewritten', () => { + const registry = booted!.kernel.getService('objectql').registry; + const pick = (type: string, name: string) => registry.listItems(type).filter((i: any) => i?.name === name); + expect(pick('dashboard', 'ops_board')).toEqual([{ ...CURRENT_DASHBOARD, ...STAMPS }]); + expect(pick('email_template', 'probe.welcome')).toEqual([{ ...CURRENT_EMAIL_TEMPLATE, ...STAMPS }]); + }); +}); + +/** A context whose `manifest` service captures the payload and whose metadata service captures writes. */ +function capturingCtx() { + const payloads: any[] = []; + const registrations: Array<{ type: string; name: string; item: any }> = []; + const services: Record = { + manifest: { register: (payload: unknown) => { payloads.push(payload); } }, + metadata: { + registerInMemory: (type: string, name: string, item: unknown) => { + registrations.push({ type, name, item }); + }, + }, + objectql: {}, + }; + const ctx = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + registerService: vi.fn(), + // Unknown services answer `undefined`, as in the two-reader harness + // (app-plugin-artifact-forward-conversion.test.ts): every other read + // `start()` makes is an optional one it degrades past. + getService: vi.fn((name: string) => services[name]), + getServices: vi.fn(() => new Map()), + hook: vi.fn(), + trigger: vi.fn(), + } as any; + return { ctx, payloads, registrations }; +} + +describe('#22994: one conversion per boot, and every registrar takes its result', () => { + it('the config boot hands the manifest service the converted copy, and the security registrar registers the SAME converted objects', async () => { + const bundle = { + manifest: { id: APP_ID, name: 'Converted Collections Probe', type: 'app', version: '1.0.0' }, + dashboards: [RETIRED_DASHBOARD], + // `allowRestore` is retired (`permission-allow-restore-purge-removed`), + // so the security registrar's copy is a converted one too. + permissions: [{ name: 'support_agent', label: 'Support Agent', objects: { crm_ticket: { allowRead: true, allowRestore: true } } }], + }; + const { ctx, payloads, registrations } = capturingCtx(); + const plugin = new AppPlugin(copy(bundle)); + await plugin.init!(ctx); + await plugin.start!(ctx); + + expect(payloads).toHaveLength(1); + const payload = payloads[0]; + expect(payload.dashboards[0]).toEqual({ name: 'ops_board', label: 'Ops Board', widgets: [], refreshIntervalSeconds: 300 }); + expect(payload.permissions[0].objects.crm_ticket).toEqual({ allowRead: true }); + + // ONE conversion: a second pass would hand the registrar a second, + // equal-looking copy. Identity is what tells the two apart. + const permission = registrations.find((r) => r.type === 'permission' && r.name === 'support_agent'); + expect(permission?.item).toBe(payload.permissions[0]); + }); + + it('a current bundle is handed over unchanged: the authored collections, the same references', async () => { + const bundle = copy(CURRENT_ARTIFACT); + const { ctx, payloads } = capturingCtx(); + await new AppPlugin(bundle).init!(ctx); + + expect(payloads).toHaveLength(1); + expect(payloads[0]).toStrictEqual({ ...bundle.manifest, ...bundle }); + // Copy-on-write: a conversion that changed nothing returns what it was + // given, so nothing the boot registers is a rewritten copy. + expect(payloads[0].dashboards).toBe(bundle.dashboards); + expect(payloads[0].emailTemplates).toBe(bundle.emailTemplates); + }); + + it('a bundle carrying packages[] is handed over as authored: init() never resolves the package list', async () => { + const bundle = { + manifest: { id: APP_ID, name: 'Converted Collections Probe', type: 'app', version: '1.0.0' }, + packages: [{ manifest: { id: `${APP_ID}.core`, name: 'core', version: '1.0.0', type: 'app', dashboards: [RETIRED_DASHBOARD] } }], + }; + const { ctx, payloads } = capturingCtx(); + await new AppPlugin(bundle).init!(ctx); + + expect(payloads).toHaveLength(1); + expect(payloads[0]).toStrictEqual({ ...bundle.manifest, ...bundle }); + expect(payloads[0].packages).toBe(bundle.packages); + }); +}); From 86d2782f0998b82d4f120c9f5d957732051a9af7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 10:01:09 +0000 Subject: [PATCH 3/4] docs(changeset): name the seeding consequence of the converted registry copy Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude --- .changeset/22994-app-plugin-converted-collections.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.changeset/22994-app-plugin-converted-collections.md b/.changeset/22994-app-plugin-converted-collections.md index 97f8eadb22e..66d4c31b2ac 100644 --- a/.changeset/22994-app-plugin-converted-collections.md +++ b/.changeset/22994-app-plugin-converted-collections.md @@ -10,4 +10,6 @@ A compiled artifact built before a spec retirement still carries the retired key `AppPlugin` now runs one forward conversion per boot, after the protocol handshake, over the same definition its security registrar already converted. The registry and that registrar both register from its result. An artifact authored in the current spellings registers exactly what it did before, because a conversion that changes nothing returns its input. +The security seeders read the same registry copy first, so a boot with an engine now seeds what the conversion produced. Measured on `createStandaloneStack` with a `>=17.1.0` artifact: a sharing rule declared with the legacy recipient type `role` is now seeded as `position`, where it used to be skipped as an unmappable recipient. A seeded permission set no longer stores the retired `allowRestore` / `allowPurge` or the row-level-security `priority`. + One case is unchanged: a bundle that carries `packages[]` still hands its package bodies over as authored. No conversion reaches those bodies, at the artifact door either. From 0f6556d1c5c1cc53f73896f494dff140be5a0180 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 10:49:47 +0000 Subject: [PATCH 4/4] test(runtime): the seeder-copy measurement pins the converged conversion axis The five pins that measured the registry copy against the door's on the ADR-0087 conversion axis now pin the converged state the forward-converted registry copy produces: the grant map and RLS entry without the retired keys, the converted sharing-rule recipient and access level, and the legacy role share seeded once as a position share. The parse-axis pins are unchanged. Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude --- ...lone-stack-seeder-declaration-copy.test.ts | 139 +++++++++++++----- 1 file changed, 106 insertions(+), 33 deletions(-) diff --git a/packages/runtime/src/standalone-stack-seeder-declaration-copy.test.ts b/packages/runtime/src/standalone-stack-seeder-declaration-copy.test.ts index a33e457181a..4912ea96e7a 100644 --- a/packages/runtime/src/standalone-stack-seeder-declaration-copy.test.ts +++ b/packages/runtime/src/standalone-stack-seeder-declaration-copy.test.ts @@ -5,11 +5,11 @@ // After #12892 step 2 the artifact door owns the METADATA SERVICE copy of // `positions` / `permissions` / `capabilities` / `sharingRules`. It does not own // the ObjectQL SchemaRegistry copy: `AppPlugin.init()` still calls -// `getService('manifest').register(bundle)` unconditionally (app-plugin.ts, the +// `getService('manifest').register(...)` unconditionally (app-plugin.ts, the // `servicePayload` line — `securityMetadataRegistrar` gates only the // `registerInMemory` block further down), and `registerMetadataCollections` -// (objectql `engine.ts`, `METADATA_ARRAY_KEYS`) decomposes the RAW artifact -// bytes into the registry. So an artifact boot with an engine holds THREE +// (objectql `engine.ts`, `METADATA_ARRAY_KEYS`) decomposes that payload into +// the registry, un-parsed. So an artifact boot with an engine holds THREE // copies, and the plugin-security / plugin-sharing seeders read the registry // one FIRST: // @@ -31,6 +31,29 @@ // `standalone-stack-security-registrar.test.ts`: one real boot, both copies // read at the same moment, key by key, beside the row that survived. // +// ## Two axes, and the one #22994 closed +// +// The two copies used to differ on two independent axes: +// +// CONVERSION the ADR-0087 forward conversion: the retired `allowRestore` / +// `allowPurge` grant bits, the RLS `priority`, the legacy +// `sharedWith.type: 'role'` and `accessLevel: 'full'`. +// PARSE the door's strict parse: schema defaults the registry copy has +// never carried (`allowTransfer` / `viewAllRecords` / +// `modifyAllRecords`, `isDefault`, a sharing rule's `active`, a +// capability's `scope`) and the ADR-0122 `condition` transform. +// +// #22994 CLOSED THE CONVERSION AXIS. `AppPlugin` now hands the `manifest` +// service the result of the boot's one forward conversion +// (`AppPlugin.forwardConverted`), so the registry copy carries the door's +// conversion. The five pins on that axis had been measured against a RAW +// registry copy; they were REWRITTEN to the converged state, never skipped, +// relaxed or deleted (the #12892 step's move in +// app-plugin-artifact-forward-conversion.test.ts). One of them is an +// authorization outcome: the legacy `role` share is now seeded as a position +// share, where it used to be dropped. The PARSE axis is untouched and stays +// #14491's: every pin on it below is as it was. +// // ## What is REAL here, and the one thing that is DECLARED // // REAL: `createStandaloneStack` and every plugin it composes (the artifact door @@ -97,8 +120,10 @@ import '@objectstack/service-datasource'; * * - `permissions[0]` — `allowRestore` / `allowPurge` are keys the current * `PermissionSet` schema does not carry, and `rowLevelSecurity[].priority` is - * another; the door drops all three and defaults three more in. Nothing - * converts them, so they are the cleanest "which copy landed" discriminator. + * another; the forward conversion strips all three (on both copies since + * #22994), and the door's parse defaults three more grant bits in (on its + * copy only). So the first three read the CONVERSION axis and the defaults + * read the PARSE axis. * - `capabilities[0]` — no `scope`, so the door's schema default is observable * (triage question 3). * - `sharingRules[0]` `share_legacy_deals` — BOTH legacy spellings at once @@ -361,18 +386,32 @@ describe('#14491 Q3 — whether a `capability` arrives without its `scope` defau }); describe('#14491 Q1 + Q4 — which copy the seeders consume, and which copy wins in the persisted row', () => { - it('`permission`: the registry copy diverges from the door copy on six keys', () => { + it('`permission`: the registry copy carries the door\'s conversion, and diverges from it only on the door\'s four parse defaults', () => { const reg = byName(registryCopy.permission, 'probe_agent'); const door = byName(doorCopy.permission, 'probe_agent'); - // Keys the raw copy carries and the door's strict parse DROPS. - expect(reg.objects.crm_ticket).toMatchObject({ allowRestore: true, allowPurge: false }); + // [#22994] CONVERGED on the conversion axis. The forward conversion strips + // the retired grant bits and the RLS `priority` from BOTH copies now, so the + // registry's grant map is the authored map less those two keys, and its RLS + // entry is the door's, whole. (Measured raw before #22994: + // `allowRestore: true, allowPurge: false` and `priority: 10` on the registry + // copy only.) + expect(reg.objects.crm_ticket).toEqual({ + allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, + }); + expect(reg.objects.crm_ticket).not.toHaveProperty('allowRestore'); + expect(reg.objects.crm_ticket).not.toHaveProperty('allowPurge'); expect(door.objects.crm_ticket).not.toHaveProperty('allowRestore'); expect(door.objects.crm_ticket).not.toHaveProperty('allowPurge'); - expect(reg.rowLevelSecurity[0]).toMatchObject({ priority: 10 }); + expect(reg.rowLevelSecurity).toEqual([ + { name: 'own_tasks', object: 'crm_task', operation: 'select', using: 'assignee == current_user.email', enabled: true }, + ]); + expect(reg.rowLevelSecurity).toEqual(door.rowLevelSecurity); + expect(reg.rowLevelSecurity[0]).not.toHaveProperty('priority'); expect(door.rowLevelSecurity[0]).not.toHaveProperty('priority'); - // Keys the door DEFAULTS IN and the raw copy has never had. + // Keys the door DEFAULTS IN and the registry copy has never had (the parse + // axis, #14491's, unchanged). expect(door.objects.crm_ticket).toMatchObject({ allowTransfer: false, viewAllRecords: false, modifyAllRecords: false, }); @@ -383,7 +422,7 @@ describe('#14491 Q1 + Q4 — which copy the seeders consume, and which copy wins expect(door).toMatchObject({ isDefault: false, _packageVersion: '3.0.0' }); }); - it('⚠️ NOT ABSORBED — `sys_permission_set` persists the UN-PARSED registry copy, byte for byte', () => { + it('⚠️ NOT ABSORBED (parse axis only) — `sys_permission_set` persists the registry copy byte for byte: forward-converted since #22994, still UN-PARSED', () => { const reg = byName(registryCopy.permission, 'probe_agent'); const door = byName(doorCopy.permission, 'probe_agent'); const row = byName(rows.sys_permission_set, 'probe_agent'); @@ -391,46 +430,65 @@ describe('#14491 Q1 + Q4 — which copy the seeders consume, and which copy wins // The decisive read: the stored authorization map is deep-equal to the // registry copy's and NOT to the door's. Nothing between `readDeclared` and // `tryInsert` re-parses it — `permissionSetRowFields` serialises what it - // was handed. + // was handed. What still separates it from the door's is the parse axis + // alone (the three defaulted grant bits). const storedObjects = JSON.parse(row.object_permissions); expect(storedObjects).toEqual(reg.objects); expect(storedObjects).not.toEqual(door.objects); + // [#22994] CONVERGED: the RLS entries carry nothing the parse adds, so once + // the conversion strips `priority` from the registry copy the stored RLS IS + // the door's. (Measured raw before #22994: `priority: 10` stored, and the + // two unequal.) const storedRls = JSON.parse(row.row_level_security); expect(storedRls).toEqual(reg.rowLevelSecurity); - expect(storedRls).not.toEqual(door.rowLevelSecurity); + expect(storedRls).toEqual(door.rowLevelSecurity); // Spelled out key by key, so a future change to either side names itself. - expect(storedObjects.crm_ticket).toMatchObject({ allowRestore: true, allowPurge: false }); + expect(storedObjects.crm_ticket).toEqual({ + allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, + }); + expect(storedObjects.crm_ticket).not.toHaveProperty('allowRestore'); + expect(storedObjects.crm_ticket).not.toHaveProperty('allowPurge'); expect(storedObjects.crm_ticket).not.toHaveProperty('allowTransfer'); expect(storedObjects.crm_ticket).not.toHaveProperty('viewAllRecords'); expect(storedObjects.crm_ticket).not.toHaveProperty('modifyAllRecords'); - expect(storedRls[0]).toMatchObject({ priority: 10 }); + expect(storedRls).toEqual([ + { name: 'own_tasks', object: 'crm_task', operation: 'select', using: 'assignee == current_user.email', enabled: true }, + ]); expect(row).toMatchObject({ managed_by: 'package', package_id: 'com.test.issue-14491', active: true, customized: false, }); }); - it('`sharing_rule`: the registry copy keeps the legacy `sharedWith.type` and `accessLevel` the door forward-converts', () => { + it('`sharing_rule`: the registry copy carries the forward-converted `sharedWith.type` and `accessLevel` the door carries', () => { const regLegacy = byName(registryCopy.sharing_rule, 'share_legacy_deals'); const doorLegacy = byName(doorCopy.sharing_rule, 'share_legacy_deals'); - expect(regLegacy.sharedWith).toEqual({ type: 'role', value: 'sales_mgr' }); + // [#22994] CONVERGED: both copies carry the conversion of the legacy + // `role` / `full` spellings. (Measured raw before #22994: the registry copy + // kept `{ type: 'role' }` and `'full'`.) + expect(regLegacy.sharedWith).toEqual({ type: 'position', value: 'sales_mgr' }); expect(doorLegacy.sharedWith).toEqual({ type: 'position', value: 'sales_mgr' }); - expect(regLegacy.accessLevel).toBe('full'); + expect(regLegacy.sharedWith).toEqual(doorLegacy.sharedWith); + expect(regLegacy.accessLevel).toBe('edit'); expect(doorLegacy.accessLevel).toBe('edit'); - // `active` is defaulted by the door and absent from the raw copy. + // `active` is defaulted by the door and absent from the registry copy (the + // parse axis, #14491's, unchanged). expect(regLegacy).not.toHaveProperty('active'); expect(doorLegacy).toMatchObject({ active: true, _packageVersion: '3.0.0' }); }); - it('ABSORBED: a legacy `accessLevel` alone survives — `defineRule` runs it through `normalizeAccessLevel`', () => { + it('a legacy `accessLevel` alone arrives at the seeder already converted, on both copies, and lands `edit`', () => { // `share_legacy_level` declares `full` over a recipient type the seeder // accepts, so the level axis is not masked by the recipient axis. - // `normalizeAccessLevel(input.accessLevel, 'read')` maps the retired - // spelling, landing the same value the door's copy carries. - expect(byName(registryCopy.sharing_rule, 'share_legacy_level').accessLevel).toBe('full'); + // [#22994] CONVERGED: the forward conversion maps the retired spelling on + // the registry copy too, so the seeder is handed `edit` and + // `normalizeAccessLevel`'s own mapping of `full` is no longer what lands it. + // (Measured raw before #22994: `'full'` on the registry copy, absorbed by + // `defineRule` → `normalizeAccessLevel(input.accessLevel, 'read')`.) + expect(byName(registryCopy.sharing_rule, 'share_legacy_level').accessLevel).toBe('edit'); expect(byName(doorCopy.sharing_rule, 'share_legacy_level').accessLevel).toBe('edit'); expect(byName(rows.sys_sharing_rule, 'share_legacy_level')).toMatchObject({ access_level: 'edit', @@ -441,19 +499,34 @@ describe('#14491 Q1 + Q4 — which copy the seeders consume, and which copy wins }); }); - it('⚠️ NOT ABSORBED — the rule whose recipient the door converts is DROPPED, and no `sys_sharing_rule` row exists for it', () => { - // `mapRecipientType` REFUSES `'role'` (it converts nothing; the ADR-0087 - // conversion lives at the door, which this read bypasses), so the seeder - // takes its `skipped (unmappable recipient)` branch. The door's copy of the - // very same declaration says `position`, which `mapRecipientType` accepts — - // so the copy chosen decides whether this rule exists at all. + it('the rule whose recipient the door converts is SEEDED, as a position share, exactly once', () => { + // AN AUTHORIZATION OUTCOME. `mapRecipientType` still refuses `'role'`, and + // used to: before #22994 the registry copy kept the legacy recipient, the + // seeder took its `skipped (unmappable recipient)` branch, and this rule had + // NO row (three declared, two seeded). [#22994] The registry copy now + // carries the forward conversion, so the seeder is handed the recipient the + // door's copy says, `position`, which `mapRecipientType` accepts. expect(byName(doorCopy.sharing_rule, 'share_legacy_deals').sharedWith.type).toBe('position'); - expect(byName(rows.sys_sharing_rule, 'share_legacy_deals')).toBeUndefined(); + expect(byName(registryCopy.sharing_rule, 'share_legacy_deals').sharedWith.type).toBe('position'); + expect(byName(rows.sys_sharing_rule, 'share_legacy_deals')).toMatchObject({ + name: 'share_legacy_deals', + object_name: 'crm_deal', + recipient_type: 'position', + recipient_id: 'sales_mgr', + access_level: 'edit', + active: true, + managed_by: 'package', + customized: false, + organization_id: null, + }); + expect(JSON.parse(byName(rows.sys_sharing_rule, 'share_legacy_deals').criteria_json)) + .toEqual({ status: 'open' }); - // Three declared rules in, two rows out — and the missing one is exactly - // the one the two copies disagree about. + // Three declared rules in, three rows out, each exactly once: the legacy + // rule is seeded ONE time, not a second time beside a modern spelling. + expect(rows.sys_sharing_rule.filter((r: any) => r.name === 'share_legacy_deals')).toHaveLength(1); expect(rows.sys_sharing_rule.map((r: any) => r.name).sort()) - .toEqual(['share_legacy_level', 'share_modern_deals']); + .toEqual(['share_legacy_deals', 'share_legacy_level', 'share_modern_deals']); }); it('the rows that DID land carry the seeder-derived shape, organization-less under the `single` posture', () => {