From 983ca39dc77230e609ed6290f25f0ad021fbcb84 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 01:59:55 +0000 Subject: [PATCH 1/6] feat(metadata): run the versioned ADR-0087 forward conversion at the artifact-ingestion door MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Artifacts built by released 17.x tooling carry then-legal keys (allowRestore/ allowPurge, retired in spec 17.2.0) and were refused by the strict parse in MetadataPlugin._parseAndRegisterArtifact — the retiredKey tombstone fired with no operator remedy, since 'os migrate meta' targets sources, not built artifacts. New policy in @objectstack/metadata-core (applyArtifactForwardConversions): replay the full ADR-0087 conversion chain — retired entries included — over an artifact whose declared engines.protocol floor predates the running spec version; an artifact authored at the current (or newer) surface converts nothing and still answers to the tombstone. Versioned, not a blanket strip: the retired keys return with M2 (#1883), and artifacts authored against that surface must never be stripped by history. The door (MetadataPlugin) applies it before every strict parse — bare definitions and environment-artifact envelopes — and surfaces notices operator-visibly, deduped per conversion per artifact, modeled on the stored-row pass. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01SVYmuhHW6qZmNBqciaS7BN --- .../src/artifact-forward-conversion.test.ts | 179 +++ .../src/artifact-forward-conversion.ts | 251 ++++ packages/metadata-core/src/index.ts | 5 + .../metadata-core/src/protocol-handshake.ts | 11 +- ...otcrm-17.1-built-permissions.artifact.json | 1044 +++++++++++++++++ ...plugin-artifact-forward-conversion.test.ts | 189 +++ packages/metadata/src/plugin.ts | 77 +- 7 files changed, 1751 insertions(+), 5 deletions(-) create mode 100644 packages/metadata-core/src/artifact-forward-conversion.test.ts create mode 100644 packages/metadata-core/src/artifact-forward-conversion.ts create mode 100644 packages/metadata/src/__fixtures__/hotcrm-17.1-built-permissions.artifact.json create mode 100644 packages/metadata/src/plugin-artifact-forward-conversion.test.ts diff --git a/packages/metadata-core/src/artifact-forward-conversion.test.ts b/packages/metadata-core/src/artifact-forward-conversion.test.ts new file mode 100644 index 0000000000..6cc3a26fe2 --- /dev/null +++ b/packages/metadata-core/src/artifact-forward-conversion.test.ts @@ -0,0 +1,179 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Versioned artifact forward conversion (#12772) — the policy, both directions. + * + * The measured incident: an artifact built by released 17.1.0 tooling carries + * `allowRestore`/`allowPurge` permission bits (legal when it was built, retired + * in spec 17.2.0), and the 17.2 runtime's strict parse refuses the boot. The + * ADR-0087 registry already declares the strip conversion + * (`permission-allow-restore-purge-removed`, `retiredFromLoadPath: true`); + * what was missing is a door that opens the retired window for artifacts whose + * declared `engines.protocol` floor predates the running spec — and ONLY for + * those. Both directions are pinned here: the amnesty (older floor converts + * forward) and its boundary (current-or-newer floor does not — the tombstone + * stays the authority), because an unconditional strip becomes wrong the day + * the keys return to the spec (roadmap M2, #1883). + */ + +import { describe, it, expect } from 'vitest'; +import { + applyArtifactForwardConversions, + parseRangeFloor, + resolveInstalledSpecVersion, +} from './artifact-forward-conversion'; + +/** The measured 17.1-built shape: full CRUD plus the two retired lifecycle bits. */ +function legacyPermissionDefinition(protocolRange: string | undefined) { + return { + manifest: { + id: 'app.example.crm', + name: 'crm', + version: '3.0.0', + type: 'app', + ...(protocolRange ? { engines: { protocol: protocolRange } } : {}), + }, + permissions: [ + { + name: 'support_agent', + label: 'Support Agent', + objects: { + crm_ticket: { + allowRead: true, + allowCreate: true, + allowEdit: true, + allowDelete: true, + allowRestore: true, + allowPurge: false, + }, + crm_note: { allowRead: true }, + }, + }, + ], + }; +} + +describe('applyArtifactForwardConversions — the versioned window (#12772)', () => { + it('converts a 17.1-authored artifact forward on a 17.2 runtime: retired keys stripped, everything else byte-preserved', () => { + const def = legacyPermissionDefinition('^17.1.0'); + const result = applyArtifactForwardConversions(def, { runtimeSpecVersion: '17.2.0' }); + + expect(result.verdict).toBe('converted-forward'); + expect(result.authoredFloor).toBe('17.1.0'); + + const converted = result.definition as typeof def; + const grant = converted.permissions[0]!.objects.crm_ticket as Record; + expect(grant).not.toHaveProperty('allowRestore'); + expect(grant).not.toHaveProperty('allowPurge'); + // Everything else byte-preserved: same keys, same values, and the + // untouched sibling object rides through by reference (copy-on-write). + expect(grant).toEqual({ allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true }); + expect(converted.permissions[0]!.objects.crm_note).toBe(def.permissions[0]!.objects.crm_note); + expect(converted.manifest).toBe(def.manifest); + + // Loud, not silent: one notice per stripped key. + const stripNotices = result.notices.filter( + (n) => n.conversionId === 'permission-allow-restore-purge-removed', + ); + expect(stripNotices).toHaveLength(2); + expect(stripNotices.map((n) => n.path)).toEqual([ + 'permissions[0].objects.crm_ticket.allowRestore', + 'permissions[0].objects.crm_ticket.allowPurge', + ]); + }); + + it('REFUSES the amnesty for an artifact authored at the current spec version — no blanket strip', () => { + const def = legacyPermissionDefinition('^17.2.0'); + const result = applyArtifactForwardConversions(def, { runtimeSpecVersion: '17.2.0' }); + + expect(result.verdict).toBe('authored-current'); + expect(result.notices).toEqual([]); + // The definition comes back by reference, retired keys still present — + // the strict parse downstream is what answers, with the tombstone. + expect(result.definition).toBe(def); + expect(def.permissions[0]!.objects.crm_ticket).toHaveProperty('allowPurge'); + }); + + it('REFUSES the amnesty for an artifact authored at a NEWER spec than the runtime', () => { + const def = legacyPermissionDefinition('^18.0.0'); + const result = applyArtifactForwardConversions(def, { runtimeSpecVersion: '17.2.0' }); + expect(result.verdict).toBe('authored-current'); + expect(result.definition).toBe(def); + }); + + it('treats a bare-major range (`^17`, the init scaffold default) as floor 17.0.0 — older than 17.2, so it converts', () => { + const def = legacyPermissionDefinition('^17'); + const result = applyArtifactForwardConversions(def, { runtimeSpecVersion: '17.2.0' }); + expect(result.verdict).toBe('converted-forward'); + expect(result.authoredFloor).toBe('17.0.0'); + const grant = (result.definition as typeof def).permissions[0]!.objects.crm_ticket; + expect(grant).not.toHaveProperty('allowPurge'); + }); + + it('replays the full chain for an artifact with NO declared range — the stored-row posture for data of unknown age', () => { + const def = legacyPermissionDefinition(undefined); + const result = applyArtifactForwardConversions(def, { runtimeSpecVersion: '17.2.0' }); + expect(result.verdict).toBe('converted-undeclared'); + expect(result.authoredFloor).toBeNull(); + const grant = (result.definition as typeof def).permissions[0]!.objects.crm_ticket; + expect(grant).not.toHaveProperty('allowRestore'); + }); + + it('closes the window when the runtime spec version cannot be resolved — amnesty needs positive version evidence', () => { + const def = legacyPermissionDefinition('^17.1.0'); + const result = applyArtifactForwardConversions(def, { runtimeSpecVersion: null }); + expect(result.verdict).toBe('runtime-version-unknown'); + expect(result.definition).toBe(def); + expect(def.permissions[0]!.objects.crm_ticket).toHaveProperty('allowPurge'); + }); + + it('is idempotent: a definition already canonical for its floor comes back by reference', () => { + const def = { + manifest: { id: 'app.example.clean', name: 'clean', version: '1.0.0', type: 'app', engines: { protocol: '^17.1.0' } }, + permissions: [ + { name: 'reader', label: 'Reader', objects: { crm_note: { allowRead: true } } }, + ], + }; + const result = applyArtifactForwardConversions(def, { runtimeSpecVersion: '17.2.0' }); + expect(result.verdict).toBe('converted-forward'); + expect(result.notices).toEqual([]); + // applyConversions is copy-on-write, so "nothing recognized" is provable + // by identity, not just equality. + expect(result.definition).toBe(def); + }); + + it('passes non-object input through untouched', () => { + expect(applyArtifactForwardConversions(null, { runtimeSpecVersion: '17.2.0' }).verdict).toBe('not-an-object'); + expect(applyArtifactForwardConversions([1], { runtimeSpecVersion: '17.2.0' }).verdict).toBe('not-an-object'); + }); + + it('defaults the runtime version to the installed @objectstack/spec version', () => { + const installed = resolveInstalledSpecVersion(); + // In this workspace spec is always resolvable; the default path must find + // the same answer an explicit resolution finds. + expect(installed).toMatch(/^\d+\.\d+\.\d+/); + const def = legacyPermissionDefinition('^0.0.1'); + const result = applyArtifactForwardConversions(def); + expect(result.runtimeSpecVersion).toBe(installed); + expect(result.verdict).toBe('converted-forward'); + }); +}); + +describe('parseRangeFloor — the range spellings artifacts actually carry', () => { + it.each([ + ['^17.1.0', [17, 1, 0]], + ['^17', [17, 0, 0]], + ['~17.2.1', [17, 2, 1]], + ['>=17.1 <18', [17, 1, 0]], + ['17.1.0', [17, 1, 0]], + ['v17.1.0', [17, 1, 0]], + ] as const)('%s → %j', (range, expected) => { + expect(parseRangeFloor(range)).toEqual(expected); + }); + + it('answers null for unreadable ranges (treated like undeclared by the policy)', () => { + expect(parseRangeFloor('')).toBeNull(); + expect(parseRangeFloor('latest')).toBeNull(); + expect(parseRangeFloor('x'.repeat(200))).toBeNull(); + }); +}); diff --git a/packages/metadata-core/src/artifact-forward-conversion.ts b/packages/metadata-core/src/artifact-forward-conversion.ts new file mode 100644 index 0000000000..b57408a0d9 --- /dev/null +++ b/packages/metadata-core/src/artifact-forward-conversion.ts @@ -0,0 +1,251 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Versioned forward conversion for compiled artifacts (#12772; ADR-0087 D2). + * + * ## The gap this closes + * + * A compiled artifact (`objectstack build` → `dist/objectstack.json`) is + * **data at rest with a version stamp**: its manifest declares the protocol + * range it was authored against (`engines.protocol`, ADR-0025 §3.2), and the + * bytes then sit unchanged while the platform moves on. When a spec release + * retires an authorable key *inside* a protocol line (an ADR-0049 + * enforce-or-remove narrowing riding a minor release), every already-built + * artifact that carries the key becomes unbootable the moment a runtime + * crosses that minor — the strict parse at the artifact-ingestion door fires + * the `retiredKey()` tombstone, and `os migrate meta` cannot help because it + * targets *sources*, not built artifacts. Measured on a real deployment: an + * artifact built by released 17.1.0 tooling (which injected the then-legal + * `allowRestore`/`allowPurge` permission bits) was refused by the 17.2.0 + * runtime with no operator remedy short of hand-editing the JSON. + * + * The stored-row read path already solves the same problem for `sys_metadata` + * rows: every rehydration seam replays the full ADR-0087 conversion chain — + * retired entries included — via `applyConversionsToStoredItem`, because a row + * at rest has no author for a tombstone to teach. This module extends that + * policy to artifacts, with the one refinement an artifact affords and a row + * does not: **the artifact says what surface it was authored against**, so the + * replay is *versioned* rather than unconditional. + * + * ## The policy + * + * Let `floor` be the lowest version the artifact's declared protocol range + * admits (the leading version token of `engines.protocol`), and `runtime` the + * `@objectstack/spec` version this process actually runs. + * + * - **`floor < runtime`** → the artifact predates this runtime's authoring + * surface. Replay the full conversion chain (retired entries included) + * before the strict parse — the artifact is the "consumer arriving late" + * ADR-0087 D3 keeps every conversion around for. + * - **`floor >= runtime`** → the artifact claims the current (or a newer) + * surface. Nothing is replayed; the strict parse — tombstones included — + * is the authority. This is what keeps the conversion **versioned rather + * than a blanket amnesty**: a key retired at version V stays a loud refusal + * for anything authored at ≥ V, and when a retired key later returns to the + * spec (the roadmap-M2 shape: `allowRestore`/`allowPurge` come back with the + * lifecycle operations they gate), artifacts authored against that surface + * are never stripped by history. + * - **No declared range** → replay the full chain. Same posture as the + * protocol handshake (which grandfathers range-less packages with a warning, + * ADR-0087 "never false-reject") and as the stored-row pass (whose rows + * carry no version at all): an artifact of unknown age is treated as old + * data at rest, and conversions only rewrite shapes they positively + * recognize, so a genuinely-current artifact loses nothing. + * - **`runtime` unresolvable** → nothing is replayed. Amnesty rests on + * positive version evidence; without a runtime version to compare against, + * the strict parse stays the authority (the refusal still carries the + * tombstone's prescription). Unreachable in practice — `@objectstack/spec` + * is a hard dependency — and injectable for tests either way. + * + * The comparison uses the full `x.y.z`, not the major: within-line + * retirements (17.1 → 17.2) are exactly the case that created this module. + * Cross-major gaps are the protocol *handshake*'s jurisdiction + * (`checkProtocolCompat`) and refuse before conversion could matter. + * + * ## What this deliberately is NOT + * + * - Not a second conversion table: the ADR-0087 registry in + * `@objectstack/spec` stays the single authority on *what* converts; this + * module only decides *whether the retired window opens* for one artifact. + * - Not a validator: like `applyConversions` itself, this never throws and + * never gates. Gating stays at the caller's schema parse. + * - Not the flow-specific seam: flows convert here too (context-less, exactly + * like the `defineStack` build seam — the open-namespace conflict guard + * needs a live executor registry no ingestion door has), and + * `AutomationEngine.registerFlow` re-canonicalizes with the guard where the + * registry exists. Conversions are idempotent by construction, so the seams + * stack safely. + */ + +import { createRequire } from 'node:module'; +import { + applyConversions, + type ConversionNotice, +} from '@objectstack/spec'; +import { resolveDeclaredRange, type ProtocolHandshakeManifest } from './protocol-handshake.js'; + +/** Why the retired conversion window did or did not open for an artifact. */ +export type ArtifactForwardConversionVerdict = + /** Declared floor predates the runtime spec — full chain replayed. */ + | 'converted-forward' + /** No declared range — treated as old data at rest, full chain replayed. */ + | 'converted-undeclared' + /** Declared floor is current-or-newer — nothing replayed, the strict parse decides. */ + | 'authored-current' + /** Runtime spec version unresolvable — nothing replayed (see module doc). */ + | 'runtime-version-unknown' + /** Input is not an object — nothing to do. */ + | 'not-an-object'; + +export interface ArtifactForwardConversionOptions { + /** + * Sink for each structured notice the replay emits. Same contract as + * `applyConversions`: converting is the point; *surfacing* is the caller's + * choice (the ingestion door logs them operator-visibly, deduped). + */ + onNotice?: (notice: ConversionNotice) => void; + /** + * The `@objectstack/spec` version this runtime executes. Injectable for + * tests; defaults to the installed spec package's own version. `null` + * means "could not resolve" and closes the retired window. + */ + runtimeSpecVersion?: string | null; +} + +export interface ArtifactForwardConversionResult { + /** + * The definition with the conversion outcome applied. Copy-on-write: the + * original reference comes back untouched when nothing converted. + */ + definition: T; + verdict: ArtifactForwardConversionVerdict; + /** The declared range's floor as `x.y.z`, when one was declared and parsed. */ + authoredFloor: string | null; + /** The runtime spec version the floor was compared against. */ + runtimeSpecVersion: string | null; + /** Every notice the replay emitted (empty when nothing converted). */ + notices: ConversionNotice[]; +} + +/** + * Lowest version a conventional artifact range admits, as `[major, minor, + * patch]` — the leading version token of the range (`^17.1.0` → 17.1.0, + * `>=17.1 <18` → 17.1.0, `^17` → 17.0.0). + * + * Deliberately the same "leading token" school as the handshake's + * `rangeAdmitsMajor` rather than a full semver engine: every range the + * tooling stamps or the docs teach leads with its floor. A range this cannot + * read returns `null`, and the caller treats an unreadable floor like an + * undeclared one — the never-false-reject direction. + */ +export function parseRangeFloor(range: string): [number, number, number] | null { + const r = range.trim(); + if (r === '' || r.length > 128) return null; + // Strip a leading range operator (`^`, `~`, `>=`, `>`, `=`, `v`). + const m = r.match(/^[\^~>=<\s]*v?(\d+)(?:\.(\d+))?(?:\.(\d+))?/); + if (!m) return null; + const major = Number.parseInt(m[1]!, 10); + const minor = m[2] !== undefined ? Number.parseInt(m[2], 10) : 0; + const patch = m[3] !== undefined ? Number.parseInt(m[3], 10) : 0; + if (!Number.isFinite(major)) return null; + return [major, minor, patch]; +} + +/** Parse a concrete `x.y.z` version (prerelease/build suffixes tolerated). */ +function parseVersion(version: string): [number, number, number] | null { + const m = version.trim().match(/^v?(\d+)\.(\d+)\.(\d+)/); + if (!m) return null; + return [Number.parseInt(m[1]!, 10), Number.parseInt(m[2]!, 10), Number.parseInt(m[3]!, 10)]; +} + +function compareTriples(a: [number, number, number], b: [number, number, number]): number { + for (let i = 0; i < 3; i++) { + if (a[i]! !== b[i]!) return a[i]! < b[i]! ? -1 : 1; + } + return 0; +} + +/** + * Resolve the installed `@objectstack/spec` version, from either module + * system this dual-build package ships as. Returns `null` when unresolvable + * (which closes the retired window — see the module doc's failure posture). + */ +export function resolveInstalledSpecVersion(): string | null { + // CJS build: the ambient `require` resolves from this package's own + // dependency chain — the right anchor for a published install. + try { + if (typeof require === 'function') { + const pkg = require('@objectstack/spec/package.json') as { version?: string }; + if (typeof pkg?.version === 'string') return pkg.version; + } + } catch { + // fall through to the ESM anchor + } + // ESM build: anchor a require at this module's own URL. In the CJS build + // this branch is only reachable when the branch above already failed, and + // its transformed `import.meta.url` is `undefined` there — `createRequire` + // then throws and the catch below answers `null`, the documented posture. + try { + const req = createRequire(import.meta.url); + const pkg = req('@objectstack/spec/package.json') as { version?: string }; + if (typeof pkg?.version === 'string') return pkg.version; + } catch { + // unresolvable — the caller's failure posture applies + } + return null; +} + +/** + * Apply the versioned forward conversion to one compiled-artifact definition. + * + * Pure and copy-on-write; never throws, never validates. See the module doc + * for the whole policy. `definition` is the *stack definition* (the shape + * with `manifest`, `objects`, `permissions`, … at the top) — for an + * environment-artifact envelope, pass the envelope's `metadata` block. + */ +export function applyArtifactForwardConversions( + definition: T, + options: ArtifactForwardConversionOptions = {}, +): ArtifactForwardConversionResult { + const runtimeSpecVersion = + options.runtimeSpecVersion !== undefined + ? options.runtimeSpecVersion + : resolveInstalledSpecVersion(); + + if (definition === null || typeof definition !== 'object' || Array.isArray(definition)) { + return { definition, verdict: 'not-an-object', authoredFloor: null, runtimeSpecVersion, notices: [] }; + } + + const manifest = (definition as { manifest?: unknown }).manifest; + const declared = + manifest && typeof manifest === 'object' + ? resolveDeclaredRange(manifest as ProtocolHandshakeManifest) + : null; + const floor = declared ? parseRangeFloor(declared.range) : null; + const authoredFloor = floor ? floor.join('.') : null; + + const runtime = runtimeSpecVersion ? parseVersion(runtimeSpecVersion) : null; + if (!runtime) { + return { definition, verdict: 'runtime-version-unknown', authoredFloor, runtimeSpecVersion, notices: [] }; + } + + let verdict: ArtifactForwardConversionVerdict; + if (!floor) { + verdict = 'converted-undeclared'; + } else if (compareTriples(floor, runtime) < 0) { + verdict = 'converted-forward'; + } else { + return { definition, verdict: 'authored-current', authoredFloor, runtimeSpecVersion, notices: [] }; + } + + const notices: ConversionNotice[] = []; + const converted = applyConversions(definition as Record, { + includeRetired: true, + onNotice: (n) => { + notices.push(n); + options.onNotice?.(n); + }, + }) as T; + + return { definition: converted, verdict, authoredFloor, runtimeSpecVersion, notices }; +} diff --git a/packages/metadata-core/src/index.ts b/packages/metadata-core/src/index.ts index 1746905f05..284b471e34 100644 --- a/packages/metadata-core/src/index.ts +++ b/packages/metadata-core/src/index.ts @@ -14,6 +14,11 @@ export * from './in-memory-repository.js'; export * from './cache.js'; export * from './layered-repository.js'; export * from './protocol-handshake.js'; +// #12772 — versioned forward conversion for compiled artifacts at an +// ingestion door: replays the ADR-0087 chain (retired entries included) over +// an artifact whose declared `engines.protocol` floor predates the running +// spec, so within-line key retirements do not brick already-built artifacts. +export * from './artifact-forward-conversion.js'; export * from './objects/index.js'; // [#5619] The ObjectQL WRITE-VERB dispatch predicates (#4550 delete / #5480 diff --git a/packages/metadata-core/src/protocol-handshake.ts b/packages/metadata-core/src/protocol-handshake.ts index 6a78938586..3dd5cbc3a5 100644 --- a/packages/metadata-core/src/protocol-handshake.ts +++ b/packages/metadata-core/src/protocol-handshake.ts @@ -76,8 +76,15 @@ export class ProtocolIncompatibleError extends MetadataError { } } -/** First declared range, protocol-first (ADR-0025 §3.10 #3). */ -function resolveDeclaredRange( +/** + * First declared range, protocol-first (ADR-0025 §3.10 #3). + * + * Exported (#12772) so the artifact forward-conversion policy reads the + * declared range from the same source priority as the handshake — two readers + * of `engines.protocol` with two priority orders would be the "two opinions" + * defect, one layer down. + */ +export function resolveDeclaredRange( manifest: ProtocolHandshakeManifest, ): { range: string; source: RangeSource } | null { const protocol = manifest.engines?.protocol?.trim(); diff --git a/packages/metadata/src/__fixtures__/hotcrm-17.1-built-permissions.artifact.json b/packages/metadata/src/__fixtures__/hotcrm-17.1-built-permissions.artifact.json new file mode 100644 index 0000000000..95b5a7d1fc --- /dev/null +++ b/packages/metadata/src/__fixtures__/hotcrm-17.1-built-permissions.artifact.json @@ -0,0 +1,1044 @@ +{ + "manifest": { + "id": "app.objectstack.hotcrm", + "namespace": "crm", + "defaultDatasource": "default", + "version": "3.0.0", + "type": "app", + "scope": "project", + "name": "HotCRM", + "description": "AI-Native CRM for the ObjectStack marketplace — Accounts, Contacts, Leads, Opportunities, Cases, Knowledge, Forecasts, Campaigns, Contracts.", + "engines": { + "protocol": "^17.1.0" + } + }, + "permissions": [ + { + "name": "guest_portal", + "label": "Guest (Public Forms)", + "description": "Anonymous visitors submitting public Web-to-Lead / Web-to-Case forms. INSERT-only on lead and case; no read/edit/delete on any object.", + "isDefault": false, + "objects": { + "crm_lead": { + "allowCreate": true, + "allowRead": false, + "allowEdit": false, + "allowDelete": false, + "allowTransfer": false, + "allowRestore": false, + "allowPurge": false, + "viewAllRecords": false, + "modifyAllRecords": false + }, + "crm_case": { + "allowCreate": true, + "allowRead": false, + "allowEdit": false, + "allowDelete": false, + "allowTransfer": false, + "allowRestore": false, + "allowPurge": false, + "viewAllRecords": false, + "modifyAllRecords": false + } + } + }, + { + "name": "marketing_user", + "label": "Marketing User", + "isDefault": false, + "objects": { + "crm_lead": { + "allowCreate": true, + "allowRead": true, + "allowEdit": true, + "allowDelete": false, + "allowExport": true, + "allowTransfer": false, + "allowRestore": false, + "allowPurge": false, + "viewAllRecords": true, + "modifyAllRecords": false + }, + "crm_account": { + "allowCreate": false, + "allowRead": true, + "allowEdit": false, + "allowDelete": false, + "allowExport": true, + "allowTransfer": false, + "allowRestore": false, + "allowPurge": false, + "viewAllRecords": true, + "modifyAllRecords": false + }, + "crm_contact": { + "allowCreate": true, + "allowRead": true, + "allowEdit": true, + "allowDelete": false, + "allowExport": true, + "allowTransfer": false, + "allowRestore": false, + "allowPurge": false, + "viewAllRecords": true, + "modifyAllRecords": false + }, + "crm_campaign": { + "allowCreate": true, + "allowRead": true, + "allowEdit": true, + "allowDelete": false, + "allowTransfer": false, + "allowRestore": false, + "allowPurge": false, + "viewAllRecords": true, + "modifyAllRecords": false + }, + "crm_opportunity": { + "allowCreate": false, + "allowRead": true, + "allowEdit": false, + "allowDelete": false, + "allowExport": true, + "allowTransfer": false, + "allowRestore": false, + "allowPurge": false, + "viewAllRecords": true, + "modifyAllRecords": false + }, + "crm_campaign_member": { + "allowCreate": true, + "allowRead": true, + "allowEdit": true, + "allowDelete": false, + "allowTransfer": false, + "allowRestore": false, + "allowPurge": false, + "viewAllRecords": false, + "modifyAllRecords": false + }, + "crm_article_feedback": { + "allowCreate": true, + "allowRead": true, + "allowEdit": true, + "allowDelete": false, + "allowTransfer": false, + "allowRestore": false, + "allowPurge": false, + "viewAllRecords": true, + "modifyAllRecords": false + }, + "crm_knowledge_article": { + "allowCreate": false, + "allowRead": true, + "allowEdit": false, + "allowDelete": false, + "allowTransfer": false, + "allowRestore": false, + "allowPurge": false, + "viewAllRecords": true, + "modifyAllRecords": false + } + }, + "fields": { + "crm_opportunity.amount": { + "readable": true, + "editable": false + }, + "crm_account.health_score": { + "readable": true, + "editable": false + } + }, + "rowLevelSecurity": [ + { + "name": "opportunity_private_owner_only_marketing", + "label": "Private opportunities stay with their owner", + "description": "A deal flagged Private is visible only to its owner, even to holders of org-wide opportunity read.", + "object": "crm_opportunity", + "operation": "select", + "using": "is_private == false || owner_id == current_user.id", + "enabled": true + }, + { + "name": "marketing_campaign_updates", + "label": "Marketing works any campaign", + "description": "Marketing users edit any campaign (and thereby enrol members into it), not only campaigns they created.", + "object": "crm_campaign", + "operation": "update", + "using": "id != null", + "enabled": true + }, + { + "name": "marketing_campaign_member_updates", + "label": "Marketing updates any campaign member", + "description": "Marketing users update member response state on rows they did not personally create.", + "object": "crm_campaign_member", + "operation": "update", + "using": "id != null", + "enabled": true + } + ] + }, + { + "name": "sales_manager", + "label": "Sales Manager", + "isDefault": false, + "objects": { + "crm_lead": { + "allowCreate": true, + "allowRead": true, + "allowEdit": true, + "allowDelete": true, + "allowExport": true, + "allowTransfer": true, + "allowRestore": false, + "allowPurge": false, + "viewAllRecords": true, + "modifyAllRecords": true + }, + "crm_account": { + "allowCreate": true, + "allowRead": true, + "allowEdit": true, + "allowDelete": true, + "allowExport": true, + "allowTransfer": true, + "allowRestore": false, + "allowPurge": false, + "viewAllRecords": true, + "modifyAllRecords": true + }, + "crm_contact": { + "allowCreate": true, + "allowRead": true, + "allowEdit": true, + "allowDelete": true, + "allowExport": true, + "allowTransfer": true, + "allowRestore": false, + "allowPurge": false, + "viewAllRecords": true, + "modifyAllRecords": true + }, + "crm_opportunity": { + "allowCreate": true, + "allowRead": true, + "allowEdit": true, + "allowDelete": true, + "allowExport": true, + "allowTransfer": true, + "allowRestore": false, + "allowPurge": false, + "viewAllRecords": true, + "modifyAllRecords": true + }, + "crm_quote": { + "allowCreate": true, + "allowRead": true, + "allowEdit": true, + "allowDelete": true, + "allowTransfer": true, + "allowRestore": false, + "allowPurge": false, + "viewAllRecords": true, + "modifyAllRecords": true + }, + "crm_contract": { + "allowCreate": true, + "allowRead": true, + "allowEdit": true, + "allowDelete": false, + "allowTransfer": false, + "allowRestore": false, + "allowPurge": false, + "viewAllRecords": true, + "modifyAllRecords": false, + "writeScope": "own_and_reports" + }, + "crm_product": { + "allowCreate": true, + "allowRead": true, + "allowEdit": true, + "allowDelete": false, + "allowTransfer": false, + "allowRestore": false, + "allowPurge": false, + "viewAllRecords": true, + "modifyAllRecords": false + }, + "crm_campaign": { + "allowCreate": true, + "allowRead": true, + "allowEdit": true, + "allowDelete": false, + "allowTransfer": false, + "allowRestore": false, + "allowPurge": false, + "viewAllRecords": true, + "modifyAllRecords": false + }, + "crm_case": { + "allowCreate": false, + "allowRead": true, + "allowEdit": false, + "allowDelete": false, + "allowExport": true, + "allowTransfer": false, + "allowRestore": false, + "allowPurge": false, + "viewAllRecords": true, + "modifyAllRecords": false + }, + "crm_task": { + "allowCreate": true, + "allowRead": true, + "allowEdit": true, + "allowDelete": true, + "allowTransfer": true, + "allowRestore": false, + "allowPurge": false, + "viewAllRecords": true, + "modifyAllRecords": true + }, + "crm_event": { + "allowCreate": true, + "allowRead": true, + "allowEdit": true, + "allowDelete": true, + "allowTransfer": true, + "allowRestore": false, + "allowPurge": false, + "viewAllRecords": true, + "modifyAllRecords": true + }, + "crm_event_attendee": { + "allowCreate": true, + "allowRead": true, + "allowEdit": true, + "allowDelete": true, + "allowTransfer": false, + "allowRestore": false, + "allowPurge": false, + "viewAllRecords": false, + "modifyAllRecords": false + }, + "crm_forecast": { + "allowCreate": true, + "allowRead": true, + "allowEdit": true, + "allowDelete": false, + "allowTransfer": true, + "allowRestore": false, + "allowPurge": false, + "viewAllRecords": true, + "modifyAllRecords": true + }, + "crm_article_feedback": { + "allowCreate": true, + "allowRead": true, + "allowEdit": true, + "allowDelete": false, + "allowTransfer": false, + "allowRestore": false, + "allowPurge": false, + "viewAllRecords": true, + "modifyAllRecords": false + }, + "crm_knowledge_article": { + "allowCreate": false, + "allowRead": true, + "allowEdit": false, + "allowDelete": false, + "allowTransfer": false, + "allowRestore": false, + "allowPurge": false, + "viewAllRecords": true, + "modifyAllRecords": false + }, + "crm_campaign_member": { + "allowCreate": false, + "allowRead": true, + "allowEdit": false, + "allowDelete": false, + "allowTransfer": false, + "allowRestore": false, + "allowPurge": false, + "viewAllRecords": false, + "modifyAllRecords": false + }, + "crm_opportunity_line_item": { + "allowCreate": true, + "allowRead": true, + "allowEdit": true, + "allowDelete": true, + "allowTransfer": false, + "allowRestore": false, + "allowPurge": false, + "viewAllRecords": false, + "modifyAllRecords": false + }, + "crm_quote_line_item": { + "allowCreate": true, + "allowRead": true, + "allowEdit": true, + "allowDelete": true, + "allowTransfer": false, + "allowRestore": false, + "allowPurge": false, + "viewAllRecords": false, + "modifyAllRecords": false + } + }, + "fields": { + "crm_opportunity.amount": { + "readable": true, + "editable": true + }, + "crm_account.health_score": { + "readable": true, + "editable": true + }, + "crm_quote.internal_notes": { + "readable": true, + "editable": true + }, + "crm_case.internal_notes": { + "readable": true, + "editable": false + } + }, + "rowLevelSecurity": [ + { + "name": "opportunity_private_owner_only", + "label": "Private opportunities stay with their owner", + "description": "A deal flagged Private is visible only to its owner, even to holders of org-wide opportunity read.", + "object": "crm_opportunity", + "operation": "select", + "using": "is_private == false || owner_id == current_user.id", + "enabled": true + } + ] + }, + { + "name": "sales_rep", + "label": "Sales Representative", + "isDefault": false, + "objects": { + "crm_lead": { + "allowCreate": true, + "allowRead": true, + "allowEdit": true, + "allowDelete": false, + "allowExport": true, + "allowTransfer": false, + "allowRestore": false, + "allowPurge": false, + "viewAllRecords": false, + "modifyAllRecords": false, + "readScope": "own" + }, + "crm_account": { + "allowCreate": true, + "allowRead": true, + "allowEdit": true, + "allowDelete": false, + "allowExport": true, + "allowTransfer": false, + "allowRestore": false, + "allowPurge": false, + "viewAllRecords": false, + "modifyAllRecords": false, + "readScope": "own" + }, + "crm_contact": { + "allowCreate": true, + "allowRead": true, + "allowEdit": true, + "allowDelete": false, + "allowExport": true, + "allowTransfer": false, + "allowRestore": false, + "allowPurge": false, + "viewAllRecords": false, + "modifyAllRecords": false + }, + "crm_opportunity": { + "allowCreate": true, + "allowRead": true, + "allowEdit": true, + "allowDelete": false, + "allowExport": true, + "allowTransfer": false, + "allowRestore": false, + "allowPurge": false, + "viewAllRecords": false, + "modifyAllRecords": false, + "readScope": "own" + }, + "crm_quote": { + "allowCreate": true, + "allowRead": true, + "allowEdit": true, + "allowDelete": false, + "allowTransfer": false, + "allowRestore": false, + "allowPurge": false, + "viewAllRecords": false, + "modifyAllRecords": false, + "readScope": "own" + }, + "crm_contract": { + "allowCreate": false, + "allowRead": true, + "allowEdit": false, + "allowDelete": false, + "allowTransfer": false, + "allowRestore": false, + "allowPurge": false, + "viewAllRecords": false, + "modifyAllRecords": false, + "readScope": "own" + }, + "crm_product": { + "allowCreate": false, + "allowRead": true, + "allowEdit": false, + "allowDelete": false, + "allowTransfer": false, + "allowRestore": false, + "allowPurge": false, + "viewAllRecords": true, + "modifyAllRecords": false + }, + "crm_campaign": { + "allowCreate": false, + "allowRead": true, + "allowEdit": false, + "allowDelete": false, + "allowTransfer": false, + "allowRestore": false, + "allowPurge": false, + "viewAllRecords": true, + "modifyAllRecords": false + }, + "crm_case": { + "allowCreate": false, + "allowRead": true, + "allowEdit": false, + "allowDelete": false, + "allowExport": true, + "allowTransfer": false, + "allowRestore": false, + "allowPurge": false, + "viewAllRecords": false, + "modifyAllRecords": false, + "readScope": "own" + }, + "crm_task": { + "allowCreate": true, + "allowRead": true, + "allowEdit": true, + "allowDelete": true, + "allowTransfer": false, + "allowRestore": false, + "allowPurge": false, + "viewAllRecords": false, + "modifyAllRecords": false, + "readScope": "own" + }, + "crm_event": { + "allowCreate": true, + "allowRead": true, + "allowEdit": true, + "allowDelete": true, + "allowTransfer": false, + "allowRestore": false, + "allowPurge": false, + "viewAllRecords": false, + "modifyAllRecords": false, + "readScope": "own" + }, + "crm_event_attendee": { + "allowCreate": true, + "allowRead": true, + "allowEdit": true, + "allowDelete": true, + "allowTransfer": false, + "allowRestore": false, + "allowPurge": false, + "viewAllRecords": false, + "modifyAllRecords": false + }, + "crm_article_feedback": { + "allowCreate": true, + "allowRead": true, + "allowEdit": true, + "allowDelete": false, + "allowTransfer": false, + "allowRestore": false, + "allowPurge": false, + "viewAllRecords": true, + "modifyAllRecords": false + }, + "crm_knowledge_article": { + "allowCreate": false, + "allowRead": true, + "allowEdit": false, + "allowDelete": false, + "allowTransfer": false, + "allowRestore": false, + "allowPurge": false, + "viewAllRecords": true, + "modifyAllRecords": false + }, + "crm_forecast": { + "allowCreate": false, + "allowRead": true, + "allowEdit": false, + "allowDelete": false, + "allowTransfer": false, + "allowRestore": false, + "allowPurge": false, + "viewAllRecords": false, + "modifyAllRecords": false, + "readScope": "own" + }, + "crm_opportunity_line_item": { + "allowCreate": true, + "allowRead": true, + "allowEdit": true, + "allowDelete": true, + "allowTransfer": false, + "allowRestore": false, + "allowPurge": false, + "viewAllRecords": false, + "modifyAllRecords": false + }, + "crm_quote_line_item": { + "allowCreate": true, + "allowRead": true, + "allowEdit": true, + "allowDelete": true, + "allowTransfer": false, + "allowRestore": false, + "allowPurge": false, + "viewAllRecords": false, + "modifyAllRecords": false + }, + "crm_campaign_member": { + "allowCreate": false, + "allowRead": true, + "allowEdit": false, + "allowDelete": false, + "allowTransfer": false, + "allowRestore": false, + "allowPurge": false, + "viewAllRecords": false, + "modifyAllRecords": false + } + }, + "fields": { + "crm_account.annual_revenue": { + "readable": true, + "editable": false + }, + "crm_account.description": { + "readable": true, + "editable": true + }, + "crm_opportunity.amount": { + "readable": true, + "editable": true + }, + "crm_opportunity.probability": { + "readable": true, + "editable": true + }, + "crm_account.health_score": { + "readable": true, + "editable": false + }, + "crm_quote.internal_notes": { + "readable": true, + "editable": true + }, + "crm_case.internal_notes": { + "readable": false, + "editable": false + } + } + }, + { + "name": "service_agent", + "label": "Service Agent", + "isDefault": false, + "objects": { + "crm_lead": { + "allowCreate": false, + "allowRead": true, + "allowEdit": false, + "allowDelete": false, + "allowExport": true, + "allowTransfer": false, + "allowRestore": false, + "allowPurge": false, + "viewAllRecords": true, + "modifyAllRecords": false + }, + "crm_account": { + "allowCreate": false, + "allowRead": true, + "allowEdit": false, + "allowDelete": false, + "allowExport": true, + "allowTransfer": false, + "allowRestore": false, + "allowPurge": false, + "viewAllRecords": true, + "modifyAllRecords": false + }, + "crm_contact": { + "allowCreate": false, + "allowRead": true, + "allowEdit": true, + "allowDelete": false, + "allowExport": true, + "allowTransfer": false, + "allowRestore": false, + "allowPurge": false, + "viewAllRecords": true, + "modifyAllRecords": false + }, + "crm_opportunity": { + "allowCreate": false, + "allowRead": false, + "allowEdit": false, + "allowDelete": false, + "allowTransfer": false, + "allowRestore": false, + "allowPurge": false, + "viewAllRecords": false, + "modifyAllRecords": false + }, + "crm_case": { + "allowCreate": true, + "allowRead": true, + "allowEdit": true, + "allowDelete": false, + "allowExport": true, + "allowTransfer": false, + "allowRestore": false, + "allowPurge": false, + "viewAllRecords": false, + "modifyAllRecords": false, + "readScope": "own" + }, + "crm_task": { + "allowCreate": true, + "allowRead": true, + "allowEdit": true, + "allowDelete": true, + "allowTransfer": true, + "allowRestore": false, + "allowPurge": false, + "viewAllRecords": false, + "modifyAllRecords": false, + "readScope": "own" + }, + "crm_event": { + "allowCreate": true, + "allowRead": true, + "allowEdit": true, + "allowDelete": true, + "allowTransfer": false, + "allowRestore": false, + "allowPurge": false, + "viewAllRecords": false, + "modifyAllRecords": false, + "readScope": "own" + }, + "crm_event_attendee": { + "allowCreate": true, + "allowRead": true, + "allowEdit": true, + "allowDelete": true, + "allowTransfer": false, + "allowRestore": false, + "allowPurge": false, + "viewAllRecords": false, + "modifyAllRecords": false + }, + "crm_product": { + "allowCreate": false, + "allowRead": true, + "allowEdit": false, + "allowDelete": false, + "allowTransfer": false, + "allowRestore": false, + "allowPurge": false, + "viewAllRecords": true, + "modifyAllRecords": false + }, + "crm_article_feedback": { + "allowCreate": true, + "allowRead": true, + "allowEdit": true, + "allowDelete": false, + "allowTransfer": false, + "allowRestore": false, + "allowPurge": false, + "viewAllRecords": true, + "modifyAllRecords": false + }, + "crm_knowledge_article": { + "allowCreate": true, + "allowRead": true, + "allowEdit": true, + "allowDelete": false, + "allowTransfer": false, + "allowRestore": false, + "allowPurge": false, + "viewAllRecords": true, + "modifyAllRecords": false + } + }, + "fields": { + "crm_case.is_sla_violated": { + "readable": true, + "editable": false + }, + "crm_case.resolution_time_hours": { + "readable": true, + "editable": false + }, + "crm_case.internal_notes": { + "readable": true, + "editable": true + }, + "crm_account.health_score": { + "readable": true, + "editable": false + } + } + }, + { + "name": "tenant_admin", + "label": "Tenant Administrator", + "isDefault": false, + "objects": { + "crm_lead": { + "allowCreate": true, + "allowRead": true, + "allowEdit": true, + "allowDelete": true, + "allowExport": true, + "allowTransfer": true, + "allowRestore": false, + "allowPurge": false, + "viewAllRecords": true, + "modifyAllRecords": true + }, + "crm_account": { + "allowCreate": true, + "allowRead": true, + "allowEdit": true, + "allowDelete": true, + "allowExport": true, + "allowTransfer": true, + "allowRestore": false, + "allowPurge": false, + "viewAllRecords": true, + "modifyAllRecords": true + }, + "crm_contact": { + "allowCreate": true, + "allowRead": true, + "allowEdit": true, + "allowDelete": true, + "allowExport": true, + "allowTransfer": true, + "allowRestore": false, + "allowPurge": false, + "viewAllRecords": true, + "modifyAllRecords": true + }, + "crm_opportunity": { + "allowCreate": true, + "allowRead": true, + "allowEdit": true, + "allowDelete": true, + "allowExport": true, + "allowTransfer": true, + "allowRestore": false, + "allowPurge": false, + "viewAllRecords": true, + "modifyAllRecords": true + }, + "crm_quote": { + "allowCreate": true, + "allowRead": true, + "allowEdit": true, + "allowDelete": true, + "allowTransfer": true, + "allowRestore": false, + "allowPurge": false, + "viewAllRecords": true, + "modifyAllRecords": true + }, + "crm_contract": { + "allowCreate": true, + "allowRead": true, + "allowEdit": true, + "allowDelete": true, + "allowTransfer": true, + "allowRestore": false, + "allowPurge": false, + "viewAllRecords": true, + "modifyAllRecords": true + }, + "crm_product": { + "allowCreate": true, + "allowRead": true, + "allowEdit": true, + "allowDelete": true, + "allowTransfer": true, + "allowRestore": false, + "allowPurge": false, + "viewAllRecords": true, + "modifyAllRecords": true + }, + "crm_campaign": { + "allowCreate": true, + "allowRead": true, + "allowEdit": true, + "allowDelete": true, + "allowTransfer": true, + "allowRestore": false, + "allowPurge": false, + "viewAllRecords": true, + "modifyAllRecords": true + }, + "crm_case": { + "allowCreate": true, + "allowRead": true, + "allowEdit": true, + "allowDelete": true, + "allowExport": true, + "allowTransfer": true, + "allowRestore": false, + "allowPurge": false, + "viewAllRecords": true, + "modifyAllRecords": true + }, + "crm_task": { + "allowCreate": true, + "allowRead": true, + "allowEdit": true, + "allowDelete": true, + "allowTransfer": true, + "allowRestore": false, + "allowPurge": false, + "viewAllRecords": true, + "modifyAllRecords": true + }, + "crm_event": { + "allowCreate": true, + "allowRead": true, + "allowEdit": true, + "allowDelete": true, + "allowTransfer": true, + "allowRestore": false, + "allowPurge": false, + "viewAllRecords": true, + "modifyAllRecords": true + }, + "crm_event_attendee": { + "allowCreate": true, + "allowRead": true, + "allowEdit": true, + "allowDelete": true, + "allowTransfer": true, + "allowRestore": false, + "allowPurge": false, + "viewAllRecords": true, + "modifyAllRecords": true + }, + "crm_forecast": { + "allowCreate": true, + "allowRead": true, + "allowEdit": true, + "allowDelete": true, + "allowTransfer": true, + "allowRestore": false, + "allowPurge": false, + "viewAllRecords": true, + "modifyAllRecords": true + }, + "crm_article_feedback": { + "allowCreate": true, + "allowRead": true, + "allowEdit": true, + "allowDelete": true, + "allowTransfer": true, + "allowRestore": false, + "allowPurge": false, + "viewAllRecords": true, + "modifyAllRecords": true + }, + "crm_knowledge_article": { + "allowCreate": true, + "allowRead": true, + "allowEdit": true, + "allowDelete": true, + "allowTransfer": true, + "allowRestore": false, + "allowPurge": false, + "viewAllRecords": true, + "modifyAllRecords": true + }, + "crm_opportunity_line_item": { + "allowCreate": true, + "allowRead": true, + "allowEdit": true, + "allowDelete": true, + "allowTransfer": true, + "allowRestore": false, + "allowPurge": false, + "viewAllRecords": true, + "modifyAllRecords": true + }, + "crm_quote_line_item": { + "allowCreate": true, + "allowRead": true, + "allowEdit": true, + "allowDelete": true, + "allowTransfer": true, + "allowRestore": false, + "allowPurge": false, + "viewAllRecords": true, + "modifyAllRecords": true + }, + "crm_campaign_member": { + "allowCreate": true, + "allowRead": true, + "allowEdit": true, + "allowDelete": true, + "allowTransfer": true, + "allowRestore": false, + "allowPurge": false, + "viewAllRecords": true, + "modifyAllRecords": true + } + }, + "systemPermissions": [ + "view_setup", + "manage_org_users", + "view_all_data", + "modify_all_data", + "manage_sharing" + ] + } + ] +} diff --git a/packages/metadata/src/plugin-artifact-forward-conversion.test.ts b/packages/metadata/src/plugin-artifact-forward-conversion.test.ts new file mode 100644 index 0000000000..c9918b7739 --- /dev/null +++ b/packages/metadata/src/plugin-artifact-forward-conversion.test.ts @@ -0,0 +1,189 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The artifact-ingestion door runs the versioned ADR-0087 forward conversion + * (#12772) — pinned on the REAL incident fixture, both directions. + * + * `__fixtures__/hotcrm-17.1-built-permissions.artifact.json` is the manifest + * and permissions blocks, verbatim, of `dist/objectstack.json` as built by + * released `@objectstack/cli` 17.1.0 from a source tree containing ZERO + * `allowPurge`/`allowRestore` occurrences — the released builder injected the + * then-legal bits (75 of each), and spec 17.2.0 retired the keys with a + * `retiredKey()` tombstone. On main before this fix, booting that artifact + * through any framework artifact door (`OS_ARTIFACT_URL`, `OS_ARTIFACT_PATH`, + * `/dist/objectstack.json`, the HMR reload) failed here, in + * `_parseAndRegisterArtifact`'s strict parse: + * + * Plugin startup failed: com.objectstack.metadata … "expected": "never", + * "code": "invalid_type", "path": ["permissions", 0, "objects", + * "crm_lead", "allowRestore"] … + * + * Direction one pins the fix: the 17.1-authored artifact converts forward and + * registers. Direction two pins the boundary that keeps the conversion + * *versioned*: the same permission shape claiming the CURRENT spec version + * still refuses with the tombstone — the retired window opens on version + * evidence, never as a blanket amnesty (the keys return with M2, #1883). + */ + +import { describe, it, expect, vi } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { resolveInstalledSpecVersion } from '@objectstack/metadata-core'; +import { MetadataPlugin } from './plugin'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const FIXTURE_PATH = join(HERE, '__fixtures__/hotcrm-17.1-built-permissions.artifact.json'); + +/** Fresh parse per test — `_parseAndRegisterArtifact` mutates items in place. */ +function loadFixture(): any { + return JSON.parse(readFileSync(FIXTURE_PATH, 'utf8')); +} + +function fakeCtx() { + return { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + registerService: vi.fn(), + getService: vi.fn(() => undefined), + trigger: vi.fn(), + } as any; +} + +function newPlugin(): any { + return new MetadataPlugin({ watch: false, config: { bootstrap: 'lazy' } }); +} + +describe('artifact door — 17.1-built artifact converts forward and registers (#12772)', () => { + it('the real fixture carries the incident shape (premise guard)', () => { + const raw = readFileSync(FIXTURE_PATH, 'utf8'); + // The measured numbers from the repro artifact — if a regeneration + // ever launders these away, the accept-direction test below stops + // testing the incident. + expect((raw.match(/allowPurge/g) ?? []).length).toBe(75); + expect((raw.match(/allowRestore/g) ?? []).length).toBe(75); + expect(loadFixture().manifest.engines.protocol).toBe('^17.1.0'); + }); + + it('parses, strips the retired keys, preserves every other grant bit, and registers all permission sets', async () => { + const plugin = newPlugin(); + const ctx = fakeCtx(); + const fixture = loadFixture(); + const before = loadFixture(); // pristine copy for the preservation diff + + const total = await plugin._parseAndRegisterArtifact(ctx, fixture, 'fixture-17.1'); + expect(total).toBe(before.permissions.length); + + for (const pristine of before.permissions) { + const registered = await plugin.manager.get('permission', pristine.name); + expect(registered, `permission '${pristine.name}' should register`).toBeDefined(); + for (const [objName, grantRaw] of Object.entries(pristine.objects ?? {})) { + const grant = (registered as any).objects[objName]; + expect(grant, `${pristine.name}.objects.${objName}`).toBeDefined(); + expect(grant).not.toHaveProperty('allowPurge'); + expect(grant).not.toHaveProperty('allowRestore'); + // Everything else preserved: every bit the built artifact + // authored (minus exactly the two retired keys) survives with + // its authored value. `toMatchObject`, not `toEqual` — the + // strict parse has always applied schema defaults for omitted + // bits, and that pre-existing door behaviour is not under test + // here (the conversion's own byte-preservation is pinned by + // reference-identity in metadata-core's unit suite). + const { allowPurge: _p, allowRestore: _r, ...rest } = grantRaw; + expect(grant).toMatchObject(rest); + } + } + }); + + it('surfaces the conversion operator-visibly, deduped per artifact — not silently, not 150 lines', async () => { + const plugin = newPlugin(); + const ctx = fakeCtx(); + + await plugin._parseAndRegisterArtifact(ctx, loadFixture(), 'fixture-17.1'); + + const conversionWarns = (ctx.logger.warn.mock.calls as any[]) + .map((c) => String(c[0])) + .filter((m) => m.includes('permission-allow-restore-purge-removed')); + expect(conversionWarns).toHaveLength(1); + // The summary names the version evidence, the volume, and the remedy. + expect(conversionWarns[0]).toContain('17.1.0'); + expect(conversionWarns[0]).toContain('150 site(s)'); + expect(conversionWarns[0]).toContain("'os build'"); + + // The HMR watcher replays the same artifact — the summary must not. + await plugin._parseAndRegisterArtifact(ctx, loadFixture(), 'fixture-17.1'); + const after = (ctx.logger.warn.mock.calls as any[]) + .map((c) => String(c[0])) + .filter((m) => m.includes('permission-allow-restore-purge-removed')); + expect(after).toHaveLength(1); + }); +}); + +describe('artifact door — the conversion is versioned, not a blanket amnesty (#12772)', () => { + it('an artifact claiming the CURRENT spec version with the same keys still refuses with the tombstone', async () => { + const installed = resolveInstalledSpecVersion(); + expect(installed).toMatch(/^\d+\.\d+\.\d+/); // spec is always resolvable here + + const plugin = newPlugin(); + const fixture = loadFixture(); + // Same permission bodies, but the manifest now claims the running + // spec's own surface — the retired window must stay closed. `^X.Y.Z` + // floors at exactly the installed version, so this pin survives every + // future spec release without edits. + fixture.manifest.engines.protocol = `^${installed}`; + + // The refusal must reach the operator carrying the prescription — the + // tombstone's FROM → TO payload and the standardized migrate sentence + // — not a generic "unrecognized key". (This door answers no HTTP + // request — every refusal here fires before a server binds — so the + // pin is on the tombstone message, not an ADR-0112 envelope.) + try { + await plugin._parseAndRegisterArtifact(fakeCtx(), fixture, 'fixture-current'); + expect.unreachable('the strict parse must refuse'); + } catch (e: any) { + const message = String(e?.message ?? e); + expect(message).toMatch(/allowRestore|allowPurge/); + expect(message).toContain('was removed in @objectstack/spec 17 (#12497, ADR-0049)'); + expect(message).toContain('Run `os migrate meta --from 17`'); + } + }); +}); + +describe('artifact door — environment-artifact envelope takes the same policy (#12772)', () => { + it('converts the envelope `metadata` block forward when its manifest floor predates the runtime', async () => { + const plugin = newPlugin(); + const ctx = fakeCtx(); + const envelope = { + schemaVersion: '0.1', + environmentId: 'proj_test', + commitId: 'commit-1', + checksum: 'a'.repeat(64), + metadata: { + manifest: { + id: 'app.example.mini', + name: 'mini', + version: '1.0.0', + type: 'app', + engines: { protocol: '^17.1.0' }, + }, + permissions: [ + { + name: 'agent', + label: 'Agent', + objects: { crm_ticket: { allowRead: true, allowRestore: true, allowPurge: false } }, + }, + ], + }, + }; + + const total = await plugin._parseAndRegisterArtifact(ctx, envelope, 'envelope-17.1'); + expect(total).toBe(1); + const registered = await plugin.manager.get('permission', 'agent'); + const grant = (registered as any).objects.crm_ticket; + // The retired keys are converted away (an unconverted envelope would + // have refused at the tombstone); the authored bit survives. Schema + // defaults for omitted bits are the parse's pre-existing behaviour. + expect(grant).not.toHaveProperty('allowRestore'); + expect(grant).not.toHaveProperty('allowPurge'); + expect(grant).toMatchObject({ allowRead: true }); + }); +}); diff --git a/packages/metadata/src/plugin.ts b/packages/metadata/src/plugin.ts index b0e1633441..6c74ef4633 100644 --- a/packages/metadata/src/plugin.ts +++ b/packages/metadata/src/plugin.ts @@ -14,6 +14,7 @@ import { SysMetadataCommitObject, SysMetadataAuditObject, SysViewDefinitionObject, + applyArtifactForwardConversions, } from '@objectstack/metadata-core'; // `SysMetadataObject` + `SysMetadataHistoryObject` are the customer overlay @@ -254,6 +255,16 @@ export class MetadataPlugin implements Plugin { */ private lastParsedMetadata?: Record; + /** + * Once-per-process dedupe for artifact forward-conversion summaries + * (`conversionId|label`, #12772). The artifact watcher replays + * `_parseAndRegisterArtifact` on every file change, so without this a dev + * loop over a legacy artifact would re-announce the same conversions on + * every reload — the same shape `Protocol.storedConversionWarned` guards + * on the stored-row pass, which this surfacing is modeled on. + */ + private artifactConversionWarned = new Set(); + constructor(options: MetadataPluginOptions = {}) { // Documented default: `watch: false` (see {@link MetadataPluginOptions.watch}). // Normalized here rather than spelled `{ watch: false, ...options }` because a @@ -666,6 +677,60 @@ export class MetadataPlugin implements Plugin { } } + /** + * Versioned ADR-0087 forward conversion at the artifact-ingestion door + * (#12772) — runs BEFORE the strict schema parse below, because the parse + * is the refusal point. + * + * A compiled artifact is data at rest with a version stamp: built by + * released tooling, then unchanged while the platform moves on. When a + * spec release retires an authorable key inside a protocol line (spec + * 17.1 → 17.2 retired the `allowRestore`/`allowPurge` permission bits), + * every already-built artifact carrying the key becomes unbootable at the + * tombstone — with no operator remedy, since `os migrate meta` targets + * sources, not built artifacts. The stored-row read path already replays + * the conversion chain for exactly this reason + * (`applyConversionsToStoredItem`, ADR-0087 addendum); this is the same + * policy at the artifact door, **keyed off the artifact's own declared + * `engines.protocol` floor**: an artifact authored below the running spec + * version converts forward, an artifact authored at the current (or a + * newer) surface converts nothing and answers to the strict parse, + * tombstones included. The version key is what keeps this a conversion + * rather than an amnesty — the retired keys return with the M2 lifecycle + * batch (#1883), and artifacts authored against that surface must never + * have them stripped by history. + * + * Notices surface the way the stored-row pass's do — operator-visible and + * deduped — as one summary line per conversion per artifact rather than + * one per rewritten path (a real 17.1 artifact carried 150 strips of the + * same two keys; 150 identical warn lines would bury the boot log). + */ + private _convertArtifactForward(ctx: PluginContext, definition: unknown, label: string): unknown { + const result = applyArtifactForwardConversions(definition); + if (result.notices.length === 0) return result.definition; + + const byConversion = new Map(); + for (const n of result.notices) { + const existing = byConversion.get(n.conversionId); + if (existing) existing.count += 1; + else byConversion.set(n.conversionId, { count: 1, firstPath: n.path, message: n.message }); + } + for (const [conversionId, agg] of byConversion) { + const key = `${conversionId}|${label}`; + if (this.artifactConversionWarned.has(key)) continue; + this.artifactConversionWarned.add(key); + ctx.logger.warn( + `[MetadataPlugin] artifact '${label}' predates this runtime's spec ` + + `(authored engines.protocol floor ${result.authoredFloor ?? ''}, runtime spec ` + + `${result.runtimeSpecVersion}) — converted ${agg.count} site(s) forward via ADR-0087 ` + + `conversion '${conversionId}' (first at ${agg.firstPath}). ${agg.message} ` + + `The artifact file itself is unchanged — rebuild it with current tooling ` + + `('os build') to persist the canonical shape.`, + ); + } + return result.definition; + } + /** * Parse raw artifact JSON (envelope or bare definition) and register all * metadata items into the MetadataManager. @@ -686,14 +751,20 @@ export class MetadataPlugin implements Plugin { const obj = raw as any; if (obj?.schemaVersion && obj?.commitId && obj?.metadata !== undefined) { - const artifact = EnvironmentArtifactSchema.parse(obj); + const artifact = EnvironmentArtifactSchema.parse({ + ...obj, + metadata: this._convertArtifactForward(ctx, obj.metadata, label), + }); metadata = artifact.metadata as Record; } else if (obj?.success && obj?.data?.metadata) { // Unwrap cloud API envelope: { success: true, data: { metadata: {...} } } - const artifact = EnvironmentArtifactSchema.parse(obj.data); + const artifact = EnvironmentArtifactSchema.parse({ + ...obj.data, + metadata: this._convertArtifactForward(ctx, obj.data.metadata, label), + }); metadata = artifact.metadata as Record; } else { - const def = ObjectStackDefinitionSchema.parse(obj); + const def = ObjectStackDefinitionSchema.parse(this._convertArtifactForward(ctx, obj, label)); const canonical = JSON.stringify(def, Object.keys(def).sort()); const checksum = createHash('sha256').update(canonical).digest('hex'); const environmentId = this.options.environmentId ?? 'proj_local'; From 6d097a604bebe93040e6632f924db29eb9606dd8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 02:29:45 +0000 Subject: [PATCH 2/6] test(metadata): NodeNext-safe imports in the new suites; changeset Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01SVYmuhHW6qZmNBqciaS7BN --- .changeset/artifact-forward-conversion-door.md | 10 ++++++++++ .../src/artifact-forward-conversion.test.ts | 2 +- .../src/plugin-artifact-forward-conversion.test.ts | 2 +- 3 files changed, 12 insertions(+), 2 deletions(-) create mode 100644 .changeset/artifact-forward-conversion-door.md diff --git a/.changeset/artifact-forward-conversion-door.md b/.changeset/artifact-forward-conversion-door.md new file mode 100644 index 0000000000..f371df268b --- /dev/null +++ b/.changeset/artifact-forward-conversion-door.md @@ -0,0 +1,10 @@ +--- +'@objectstack/metadata-core': patch +'@objectstack/metadata': patch +--- + +Artifacts built by released 17.x tooling boot again on ≥17.2 runtimes: the artifact-ingestion door now runs a versioned ADR-0087 forward conversion before the strict parse (#12772). + +A compiled artifact whose declared `engines.protocol` floor predates the running `@objectstack/spec` version replays the full conversion chain — retired entries included — before validation, exactly the policy the stored-row read path already applies to `sys_metadata` rows. Measured incident: `dist/objectstack.json` built by `@objectstack/cli` 17.1.0 carries the then-legal `allowRestore`/`allowPurge` permission bits (75 of each, injected by the released builder), and spec 17.2.0's `retiredKey` tombstone refused the boot with no operator remedy (`os migrate meta` targets sources, not built artifacts). + +The conversion is versioned, not a blanket amnesty: an artifact authored at the current (or a newer) spec version converts nothing and still refuses at the tombstone — the retired keys return with the M2 lifecycle initiative (#1883), and artifacts authored against that surface are never stripped by history. Conversion notices surface operator-visibly and deduped, one summary line per conversion per artifact. New exports from `@objectstack/metadata-core`: `applyArtifactForwardConversions`, `resolveInstalledSpecVersion`, `parseRangeFloor`, `resolveDeclaredRange`. diff --git a/packages/metadata-core/src/artifact-forward-conversion.test.ts b/packages/metadata-core/src/artifact-forward-conversion.test.ts index 6cc3a26fe2..5f3e10f234 100644 --- a/packages/metadata-core/src/artifact-forward-conversion.test.ts +++ b/packages/metadata-core/src/artifact-forward-conversion.test.ts @@ -21,7 +21,7 @@ import { applyArtifactForwardConversions, parseRangeFloor, resolveInstalledSpecVersion, -} from './artifact-forward-conversion'; +} from './artifact-forward-conversion.js'; /** The measured 17.1-built shape: full CRUD plus the two retired lifecycle bits. */ function legacyPermissionDefinition(protocolRange: string | undefined) { diff --git a/packages/metadata/src/plugin-artifact-forward-conversion.test.ts b/packages/metadata/src/plugin-artifact-forward-conversion.test.ts index c9918b7739..35d6ab9d2e 100644 --- a/packages/metadata/src/plugin-artifact-forward-conversion.test.ts +++ b/packages/metadata/src/plugin-artifact-forward-conversion.test.ts @@ -30,7 +30,7 @@ import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { resolveInstalledSpecVersion } from '@objectstack/metadata-core'; -import { MetadataPlugin } from './plugin'; +import { MetadataPlugin } from './plugin.js'; const HERE = dirname(fileURLToPath(import.meta.url)); const FIXTURE_PATH = join(HERE, '__fixtures__/hotcrm-17.1-built-permissions.artifact.json'); From 00714b60a4af0ccd155c26efa7bf51997fdf3869 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 03:22:48 +0000 Subject: [PATCH 3/6] fix(metadata-core): keep the spec ROOT out of the policy module's declaration surface The artifact-forward-conversion module's public types referenced ConversionNotice from the @objectstack/spec root, so the emitted dist/index.d.ts imported the root entry - and every downstream type program reading metadata-core's declarations began loading the ~2MB spec root d.mts BESIDE the d.ts flavor it already read. Measured: the TEST_DEBT re-measure of packages/qa/http-conformance crossed CI's ~4GB tsc heap ceiling and OOM'd (listFiles diff between merge-base and branch: the only additions were spec/dist/index.d.mts and its chunk; the capped re-measure passes at the merge base and fails on the branch, same box, same command). Public surface now speaks ArtifactConversionNotice, a structural mirror pinned in both assignability directions in the module's test; the runtime applyConversions import stays and no longer reaches declaration emit. The rebuilt d.ts carries only the pre-existing spec/data subpath imports, and the capped (4096MB) http-conformance re-measure is green on this tree. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01SVYmuhHW6qZmNBqciaS7BN --- .../src/artifact-forward-conversion.test.ts | 15 ++++++ .../src/artifact-forward-conversion.ts | 54 ++++++++++++++++--- 2 files changed, 62 insertions(+), 7 deletions(-) diff --git a/packages/metadata-core/src/artifact-forward-conversion.test.ts b/packages/metadata-core/src/artifact-forward-conversion.test.ts index 5f3e10f234..5f6742ee59 100644 --- a/packages/metadata-core/src/artifact-forward-conversion.test.ts +++ b/packages/metadata-core/src/artifact-forward-conversion.test.ts @@ -17,12 +17,27 @@ */ import { describe, it, expect } from 'vitest'; +import type { ConversionNotice } from '@objectstack/spec'; import { applyArtifactForwardConversions, parseRangeFloor, resolveInstalledSpecVersion, + type ArtifactConversionNotice, } from './artifact-forward-conversion.js'; +// ── Mirror pin ─────────────────────────────────────────────────────────────── +// `ArtifactConversionNotice` is a structural mirror of the spec root's +// `ConversionNotice`, kept so the module's PUBLIC declarations never import +// the ~2MB spec root (the import made every downstream type program load the +// root twice — d.ts and d.mts flavors — and pushed the http-conformance +// TEST_DEBT re-measure over CI's ~4GB tsc heap ceiling; #12772 patch round). +// The TEST may reference the root freely — tests never ship declarations. +// Both assignability directions, so EITHER side drifting reds this suite: +type _SpecToMirror = ConversionNotice extends ArtifactConversionNotice ? true : never; +type _MirrorToSpec = ArtifactConversionNotice extends ConversionNotice ? true : never; +const _mirrorPin: [_SpecToMirror, _MirrorToSpec] = [true, true]; +void _mirrorPin; + /** The measured 17.1-built shape: full CRUD plus the two retired lifecycle bits. */ function legacyPermissionDefinition(protocolRange: string | undefined) { return { diff --git a/packages/metadata-core/src/artifact-forward-conversion.ts b/packages/metadata-core/src/artifact-forward-conversion.ts index b57408a0d9..128dd9621d 100644 --- a/packages/metadata-core/src/artifact-forward-conversion.ts +++ b/packages/metadata-core/src/artifact-forward-conversion.ts @@ -78,12 +78,52 @@ */ import { createRequire } from 'node:module'; -import { - applyConversions, - type ConversionNotice, -} from '@objectstack/spec'; +// ⚠ VALUE import only, and deliberately no `type` import beside it: this module +// must keep the `@objectstack/spec` ROOT entry out of this package's PUBLIC +// declaration surface. When an exported signature here referenced a root spec +// type, the emitted `dist/index.d.ts` gained `import ... from '@objectstack/spec'`, +// and every DOWNSTREAM type program reading this package's declarations began +// loading the ~2MB spec root d.mts BESIDE the d.ts flavor it already read — +// the whole spec surface instantiated twice. Measured cost: the TEST_DEBT +// re-measure of `packages/qa/http-conformance` (671-file program) crossed CI's +// ~4GB tsc heap ceiling and OOM'd, red on a PR whose diff never touched that +// package (#12772 patch round; `--listFiles` diff: the only additions were +// `spec/dist/index.d.mts` + its chunk). The runtime import below is invisible +// to declaration emit once no exported type references the root — the public +// surface speaks {@link ArtifactConversionNotice}, a structural mirror pinned +// against the real thing in this module's test. +import { applyConversions } from '@objectstack/spec'; import { resolveDeclaredRange, type ProtocolHandshakeManifest } from './protocol-handshake.js'; +/** + * Structural mirror of `ConversionNotice` (`@objectstack/spec`, + * `src/conversions/types.ts`) — field-for-field, including the literal `code`. + * + * A mirror rather than a re-export, for the declaration-surface reason on the + * import above; its exactness is pinned BOTH assignability directions in + * `artifact-forward-conversion.test.ts`, so a drift in either declaration + * fails the suite rather than silently forking the contract. + */ +export interface ArtifactConversionNotice { + code: 'OS_METADATA_CONVERTED'; + /** The conversion id that fired (`MetadataConversion.id`). */ + conversionId: string; + /** Dotted surface the conversion governs, e.g. `flow.node.type`. */ + surface: string; + /** The protocol major that introduced the canonical shape. */ + toMajor: number; + /** The protocol major in which this conversion retires from the load path. */ + retiresIn: number; + /** The off-spec token/shape actually seen in the source. */ + from: string; + /** The canonical token/shape it was converted to. */ + to: string; + /** Where in the stack it applied, e.g. `permissions[0].objects.crm_ticket.allowPurge`. */ + path: string; + /** Derived, human-facing one-liner. */ + message: string; +} + /** Why the retired conversion window did or did not open for an artifact. */ export type ArtifactForwardConversionVerdict = /** Declared floor predates the runtime spec — full chain replayed. */ @@ -103,7 +143,7 @@ export interface ArtifactForwardConversionOptions { * `applyConversions`: converting is the point; *surfacing* is the caller's * choice (the ingestion door logs them operator-visibly, deduped). */ - onNotice?: (notice: ConversionNotice) => void; + onNotice?: (notice: ArtifactConversionNotice) => void; /** * The `@objectstack/spec` version this runtime executes. Injectable for * tests; defaults to the installed spec package's own version. `null` @@ -124,7 +164,7 @@ export interface ArtifactForwardConversionResult { /** The runtime spec version the floor was compared against. */ runtimeSpecVersion: string | null; /** Every notice the replay emitted (empty when nothing converted). */ - notices: ConversionNotice[]; + notices: ArtifactConversionNotice[]; } /** @@ -238,7 +278,7 @@ export function applyArtifactForwardConversions( return { definition, verdict: 'authored-current', authoredFloor, runtimeSpecVersion, notices: [] }; } - const notices: ConversionNotice[] = []; + const notices: ArtifactConversionNotice[] = []; const converted = applyConversions(definition as Record, { includeRetired: true, onNotice: (n) => { From b02ec8148505540caadee6566f13b11a065776fd Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 03:35:18 +0000 Subject: [PATCH 4/6] chore(runtime): classify the metadata-core mirror's notice-code literal in the dispatcher vocabulary check:dispatcher-error-vocabulary flagged the new ArtifactConversionNotice.code literal in packages/metadata-core/src/artifact-forward-conversion.ts as an unclassified code-stamping site. Classified foreign-vocabulary beside the existing OS_METADATA_CONVERTED row for spec's apply.ts: the literal sits in a TYPE position of the structural mirror (declared to keep the spec ROOT import out of the package's public declaration surface), stamps nothing at runtime, and the notices flow to an onNotice callback exactly as in the classified spec site - nothing thrown, no envelope built. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01SVYmuhHW6qZmNBqciaS7BN --- .../runtime/src/dispatcher-error-vocabulary.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/packages/runtime/src/dispatcher-error-vocabulary.ts b/packages/runtime/src/dispatcher-error-vocabulary.ts index cd15cec640..d069e922db 100644 --- a/packages/runtime/src/dispatcher-error-vocabulary.ts +++ b/packages/runtime/src/dispatcher-error-vocabulary.ts @@ -418,6 +418,23 @@ export const UNREGISTERED_CODE_SITES: readonly UnregisteredCodeSite[] = [ 'shape. Nothing is thrown and no envelope is built. Same class as the INVALID_SCREEN_INPUT ' + 'row — a result envelope that merely spells itself `code`.', }, + { + code: 'OS_METADATA_CONVERTED', + file: 'packages/metadata-core/src/artifact-forward-conversion.ts', + shape: 'objlit', + door: 'none', + verdict: 'foreign-vocabulary', + why: + 'The same ADR-0087 conversion-notice vocabulary as the apply.ts row above, met at a TYPE ' + + 'position: `ArtifactConversionNotice.code` is the literal in a structural mirror of ' + + "ConversionNotice, declared so the artifact-ingestion forward-conversion policy (#12772) " + + 'keeps the spec ROOT import out of its public declaration surface (the root reference made ' + + "every downstream type program load the 2MB root twice and pushed a TEST_DEBT re-measure " + + "over CI's tsc heap ceiling). A literal type stamps nothing at runtime — notices flow to an " + + '`onNotice` callback exactly as in the row above, nothing is thrown and no envelope is ' + + 'built; the mirror is pinned against the real declaration in both assignability directions ' + + 'by `artifact-forward-conversion.test.ts`.', + }, { code: 'OS_METADATA_CONVERSION_CONFLICT', file: 'packages/spec/src/conversions/apply.ts', From 7eff8966c224309fa6ba68e2b0143397cae9f2f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 04:26:31 +0000 Subject: [PATCH 5/6] test(metadata): re-aim the refuse-direction pin at the surviving refusal surface under the #12845 residue ruling Main landed acceptRetiredDefaultResidue (maintainer ruling 2026-08-28): a retired DEFAULTED key's emitted default parses as inert residue and strips silently at the schema layer; only a NON-default value keeps the #12497 tombstone. The real 17.1-built fixture carries exclusively the emitted default (150 x false, premise-guarded), so the old current-version refusal pin now describes a shape the ruled contract accepts - the merge queue measured exactly that on its merge tree. The pin is re-aimed, not weakened: current-version + NON-default value (true, authorable on the 17.1 surface, never emitted by a default) still refuses with the tombstone and prescription; and a new pin holds the other half of the ruling at the door - current-version + pure residue parses clean with the keys stripped by the SCHEMA layer while this door's versioned window stays closed (no conversion notice), keeping the door's amnesty versioned. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01SVYmuhHW6qZmNBqciaS7BN --- ...plugin-artifact-forward-conversion.test.ts | 73 ++++++++++++++++--- 1 file changed, 64 insertions(+), 9 deletions(-) diff --git a/packages/metadata/src/plugin-artifact-forward-conversion.test.ts b/packages/metadata/src/plugin-artifact-forward-conversion.test.ts index 35d6ab9d2e..e646d3da67 100644 --- a/packages/metadata/src/plugin-artifact-forward-conversion.test.ts +++ b/packages/metadata/src/plugin-artifact-forward-conversion.test.ts @@ -20,9 +20,13 @@ * * Direction one pins the fix: the 17.1-authored artifact converts forward and * registers. Direction two pins the boundary that keeps the conversion - * *versioned*: the same permission shape claiming the CURRENT spec version - * still refuses with the tombstone — the retired window opens on version - * evidence, never as a blanket amnesty (the keys return with M2, #1883). + * *versioned* — the retired window opens on version evidence, never as a + * blanket amnesty (the keys return with M2, #1883) — as re-shaped by the + * #12845 ruling (maintainer 2026-08-28): at the CURRENT spec version a + * NON-default retired-key value still refuses with the tombstone, while the + * emitted default parses as inert residue stripped silently at the SCHEMA + * layer (`acceptRetiredDefaultResidue`), with this door's versioned window + * staying closed (no conversion notice) — both halves pinned below. */ import { describe, it, expect, vi } from 'vitest'; @@ -119,17 +123,37 @@ describe('artifact door — 17.1-built artifact converts forward and registers ( }); describe('artifact door — the conversion is versioned, not a blanket amnesty (#12772)', () => { - it('an artifact claiming the CURRENT spec version with the same keys still refuses with the tombstone', async () => { + // Direction two split in half by the #12845 ruling (maintainer 2026-08-28, + // recorded on the cloud#1685 thread; `acceptRetiredDefaultResidue` in + // `packages/spec/src/shared/retired-key.ts`): a retired DEFAULTED key is + // refused only when it carries a NON-default value — the emitted default + // (`false` for both bits, materialized by the released 17.x builder into + // every entry) parses as inert residue and is silently stripped at the + // SCHEMA layer. So the refusal surface this door must keep pinned is the + // non-default value; the pure-residue artifact at the current version now + // parses clean WITHOUT this door's conversion opening (and that + // distinction — schema-layer silent strip vs the door's noticed, + // version-gated strip — is itself pinned below). + + it('an artifact claiming the CURRENT spec version with a NON-default retired-key value still refuses with the tombstone', async () => { const installed = resolveInstalledSpecVersion(); expect(installed).toMatch(/^\d+\.\d+\.\d+/); // spec is always resolvable here const plugin = newPlugin(); const fixture = loadFixture(); - // Same permission bodies, but the manifest now claims the running - // spec's own surface — the retired window must stay closed. `^X.Y.Z` - // floors at exactly the installed version, so this pin survives every - // future spec release without edits. + // The manifest claims the running spec's own surface — the retired + // window must stay closed. `^X.Y.Z` floors at exactly the installed + // version, so this pin survives every future spec release without + // edits. fixture.manifest.engines.protocol = `^${installed}`; + // The real 17.1-built artifact carries only the emitted default + // (150 × `false` — the premise guard above pins that), which is the + // residue class #12845 now tolerates. The surviving refusal is the + // NON-default value — legal to author on the 17.1 surface, never + // emitted by a default — so construct that probe from the fixture: + const firstGrant = Object.values(fixture.permissions[0].objects)[0]; + expect(firstGrant.allowRestore).toBe(false); // was the residue value + firstGrant.allowRestore = true; // The refusal must reach the operator carrying the prescription — the // tombstone's FROM → TO payload and the standardized migrate sentence @@ -141,11 +165,42 @@ describe('artifact door — the conversion is versioned, not a blanket amnesty ( expect.unreachable('the strict parse must refuse'); } catch (e: any) { const message = String(e?.message ?? e); - expect(message).toMatch(/allowRestore|allowPurge/); + expect(message).toContain('allowRestore'); expect(message).toContain('was removed in @objectstack/spec 17 (#12497, ADR-0049)'); expect(message).toContain('Run `os migrate meta --from 17`'); } }); + + it('an artifact claiming the CURRENT spec version whose retired keys carry only the emitted default parses clean — the #12845 residue tolerance, at the schema layer, not this door', async () => { + const installed = resolveInstalledSpecVersion(); + const plugin = newPlugin(); + const ctx = fakeCtx(); + const fixture = loadFixture(); // untouched: 150 × the emitted default + const before = loadFixture(); + fixture.manifest.engines.protocol = `^${installed}`; + + const total = await plugin._parseAndRegisterArtifact(ctx, fixture, 'fixture-current-residue'); + expect(total).toBe(before.permissions.length); + + // The keys are gone from what registered — stripped by the schema's + // residue tolerance… + for (const pristine of before.permissions) { + const registered = await plugin.manager.get('permission', pristine.name); + for (const objName of Object.keys(pristine.objects ?? {})) { + const grant = (registered as any).objects[objName]; + expect(grant).not.toHaveProperty('allowRestore'); + expect(grant).not.toHaveProperty('allowPurge'); + } + } + // …and NOT by this door's conversion: the authored floor is current, + // so the versioned window stayed closed and no conversion summary was + // emitted. This is the boundary that keeps the door's amnesty + // versioned even now that the schema tolerates pure residue. + const conversionWarns = (ctx.logger.warn.mock.calls as any[]) + .map((c) => String(c[0])) + .filter((m) => m.includes('permission-allow-restore-purge-removed')); + expect(conversionWarns).toHaveLength(0); + }); }); describe('artifact door — environment-artifact envelope takes the same policy (#12772)', () => { From 3788bb5e91e07d8c5f9456c30cfcfa70d5da2e3d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 05:54:14 +0000 Subject: [PATCH 6/6] fix(runtime): AppPlugin's bundle path consumes the artifact door's ADR-0087 forward conversion (#12844) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The artifact boot reads the same bytes through two independent readers. `MetadataPlugin._parseAndRegisterArtifact` replays the versioned ADR-0087 forward conversion before its strict parse; `AppPlugin`'s ADR-0057 block received the same JSON from `loadArtifactBundle` and registered it RAW, so the two in-memory copies of the same permission set / sharing rule / position differed and which one a reader saw depended on registration order. AppPlugin now applies the door's own policy function to the same definition — one import, one call site, no conversion-specific knowledge on this side. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd --- .../app-plugin-artifact-forward-conversion.md | 16 + ...plugin-artifact-forward-conversion.test.ts | 375 ++++++++++++++++++ packages/runtime/src/app-plugin.ts | 47 ++- 3 files changed, 436 insertions(+), 2 deletions(-) create mode 100644 .changeset/app-plugin-artifact-forward-conversion.md create mode 100644 packages/runtime/src/app-plugin-artifact-forward-conversion.test.ts diff --git a/.changeset/app-plugin-artifact-forward-conversion.md b/.changeset/app-plugin-artifact-forward-conversion.md new file mode 100644 index 0000000000..935005a2c9 --- /dev/null +++ b/.changeset/app-plugin-artifact-forward-conversion.md @@ -0,0 +1,16 @@ +--- +'@objectstack/runtime': patch +--- + +AppPlugin's bundle path runs the same ADR-0087 forward conversion as the artifact door + +On an artifact boot the stack-declared security metadata (`positions`, +`permissions`, `capabilities`, `sharingRules`) reached the metadata registry +through two independent readers: the artifact door +(`MetadataPlugin._parseAndRegisterArtifact`), which replays the versioned +ADR-0087 forward conversion before its strict parse, and `AppPlugin`'s ADR-0057 +block, which registered the bundle from `loadArtifactBundle` raw. The two copies +of the same item therefore differed, and which one a consumer saw depended on +registration order. `AppPlugin` now consumes the door's own +`applyArtifactForwardConversions` policy, so both copies carry the canonical +shape for every key the conversion layer governs. diff --git a/packages/runtime/src/app-plugin-artifact-forward-conversion.test.ts b/packages/runtime/src/app-plugin-artifact-forward-conversion.test.ts new file mode 100644 index 0000000000..7a450ae2b3 --- /dev/null +++ b/packages/runtime/src/app-plugin-artifact-forward-conversion.test.ts @@ -0,0 +1,375 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The artifact boot has TWO readers of the same bytes (#12844). + * + * 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` / + * `policies` through `metadata.registerInMemory`. + * + * 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. + * + * These tests drive BOTH REAL readers over one artifact and pin what the card + * asked to be falsified rather than asserted: + * + * - 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. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { MetadataPlugin } from '@objectstack/metadata'; +import { ObjectStackDefinitionSchema } from '@objectstack/spec'; +import { AppPlugin } from './app-plugin.js'; + +/** + * One artifact carrying a legacy/retired shape in every security collection + * the ADR-0087 registry can reach. `engines.protocol: '^17.1.0'` is the real + * incident's declared floor — below the installed `@objectstack/spec`, so the + * door's versioned window opens (the same evidence the 17.1-built hotcrm + * artifact carries). + */ +const ARTIFACT = { + manifest: { + id: 'com.test.issue-12844', + name: 'Two-Reader Probe', + type: 'app', + version: '1.0.0', + engines: { protocol: '^17.1.0' }, + }, + // `roles` → `positions` is a COLLECTION-KEY rename (`stack-roles-to-positions`, + // ADR-0090 D3). The raw reader looks for `positions` and finds nothing. + roles: [{ name: 'sales_rep', label: 'Sales Rep' }], + permissions: [ + { + name: 'support_agent', + label: 'Support Agent', + objects: { + // NON-default retired bits — stripped only by the conversion. + crm_ticket: { + allowRead: true, + allowCreate: true, + allowEdit: true, + allowDelete: true, + allowRestore: true, + allowPurge: false, + }, + // The shape the released 17.1 builder actually emitted + // (every grant bit present, the two retired ones at their + // default `false`). + crm_lead: { + allowCreate: true, + allowRead: false, + allowEdit: false, + allowDelete: false, + allowRestore: false, + allowPurge: false, + }, + }, + // `priority` is a `retiredKey()` tombstone on the RLS policy + // (`permission-rls-priority-removed`). + 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', + // Both legacy spellings are REJECTED by the current schema: + // `accessLevel: 'full'` (→ 'edit') and the recipient type + // `'role'` (→ 'position'). A raw copy of this item is not merely + // stale — it is unparseable at the next re-validating seam. + accessLevel: 'full', + condition: 'record.status == "open"', + sharedWith: { type: 'role', value: 'sales_mgr' }, + }, + ], +}; + +/** Fresh bytes per reader — both readers mutate/normalize in place. */ +function bytes(): any { + return JSON.parse(JSON.stringify(ARTIFACT)); +} + +function fakeCtx(metadataService?: unknown) { + return { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + registerService: vi.fn(), + getService: vi.fn((name: string) => { + if (name === 'metadata') return metadataService; + if (name === 'objectql') return {} as any; + return undefined; + }), + getServices: vi.fn(() => []), + hook: vi.fn(), + trigger: vi.fn(), + } as any; +} + +type Registration = { type: string; name: string; item: any }; + +/** Reader 1 — the real artifact door, into its own manager. */ +async function readerDoor(): Promise { + const plugin: any = new MetadataPlugin({ watch: false, config: { bootstrap: 'lazy' } }); + await plugin._parseAndRegisterArtifact(fakeCtx(), bytes(), 'issue-12844-probe'); + const out: Registration[] = []; + for (const type of ['position', 'permission', 'capability', 'sharing_rule', 'policy']) { + for (const item of await plugin.manager.list(type)) { + out.push({ type, name: (item as any)?.name, item }); + } + } + return out; +} + +/** Reader 2 — the real `AppPlugin` ADR-0057 block, capturing its writes in order. */ +async function readerBundle(): Promise { + const captured: Registration[] = []; + const plugin = new AppPlugin(bytes()); + await plugin.start!( + fakeCtx({ + registerInMemory: (type: string, name: string, item: unknown) => { + captured.push({ type, name, item }); + }, + }), + ); + return captured; +} + +/** `type:name` → item, in registration order (last write wins, as the registry does). */ +function collapse(regs: Registration[]): Map { + const m = new Map(); + for (const r of regs) m.set(`${r.type}:${r.name}`, r.item); + return m; +} + +/** Every dotted path at which two registered copies differ. */ +function diffPaths(a: any, b: any, at = ''): string[] { + if (a === b) return []; + const aObj = a !== null && typeof a === 'object'; + const bObj = b !== null && typeof b === 'object'; + if (!aObj || !bObj) return [at]; + const keys = [...new Set([...Object.keys(a), ...Object.keys(b)])].sort(); + return keys.flatMap((k) => diffPaths(a[k], b[k], at ? `${at}.${k}` : k)); +} + +/** + * The keys the ADR-0087 conversion layer governs on these collections — the + * axis this card is 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 + * collections. + */ +const CONVERSION_GOVERNED_PATHS = [ + 'objects.crm_ticket.allowRestore', + 'objects.crm_ticket.allowPurge', + 'objects.crm_lead.allowRestore', + 'objects.crm_lead.allowPurge', + 'rowLevelSecurity.0.priority', + 'accessLevel', + 'sharedWith.type', +]; + +describe('#12844 — the artifact boot\'s two readers register the same bytes', () => { + 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. + const raw = bytes(); + expect(raw.permissions[0].objects.crm_ticket.allowRestore).toBe(true); + expect(raw.permissions[0].rowLevelSecurity[0].priority).toBe(10); + expect(raw.sharingRules[0].accessLevel).toBe('full'); + expect(raw.sharingRules[0].sharedWith.type).toBe('role'); + expect(raw.positions).toBeUndefined(); + expect(raw.roles).toHaveLength(1); + + // And the raw bytes are genuinely unparseable as authored. + expect(ObjectStackDefinitionSchema.safeParse(raw).success).toBe(false); + }); + + it('permissions: the bundle reader no longer registers the retired grant bits', async () => { + const bundle = collapse(await readerBundle()); + const perm = bundle.get('permission:support_agent'); + expect(perm, 'AppPlugin must still register the permission set').toBeDefined(); + expect(perm.objects.crm_ticket).not.toHaveProperty('allowRestore'); + expect(perm.objects.crm_ticket).not.toHaveProperty('allowPurge'); + 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, + }); + expect(perm.rowLevelSecurity[0]).toMatchObject({ + name: 'own_tasks', object: 'crm_task', operation: 'select', enabled: true, + }); + }); + + it('sharingRules: the bundle reader registers 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(); + expect(rule.accessLevel).toBe('edit'); + expect(rule.sharedWith.type).toBe('position'); + }); + + it('positions: the collection-key rename reaches the bundle reader too', async () => { + const bundle = collapse(await readerBundle()); + // Before the fix 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', + label: 'Sales Rep', + }); + }); + + it('the two readers agree on every ADR-0087 CONVERSION-governed key', 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. + expect(shared).toEqual([ + 'permission:support_agent', + 'position:sales_rep', + 'sharing_rule:share_open_deals', + ]); + + for (const key of shared) { + const differing = diffPaths(door.get(key), bundle.get(key)); + for (const governed of CONVERSION_GOVERNED_PATHS) { + expect( + differing, + `${key}: '${governed}' is governed by the ADR-0087 conversion layer — ` + + 'the two copies of the same bytes must not differ there', + ).not.toContain(governed); + } + } + + // …and the value they agree ON is the canonical one, on BOTH copies — + // "equal" would also be satisfied by both being wrong. + for (const copy of [door, bundle]) { + const perm = copy.get('permission:support_agent'); + expect(perm.objects.crm_ticket).not.toHaveProperty('allowRestore'); + expect(perm.objects.crm_ticket).not.toHaveProperty('allowPurge'); + expect(perm.objects.crm_lead).not.toHaveProperty('allowRestore'); + expect(perm.objects.crm_lead).not.toHaveProperty('allowPurge'); + expect(perm.rowLevelSecurity[0]).not.toHaveProperty('priority'); + const rule = copy.get('sharing_rule:share_open_deals'); + expect(rule.accessLevel).toBe('edit'); + expect(rule.sharedWith.type).toBe('position'); + expect(copy.get('position:sales_rep')).toMatchObject({ name: 'sales_rep' }); + } + }); + + 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 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()) + .toEqual(['_packageId', '_packageVersion', '_provenance', 'active', 'condition']); + expect(diffPaths(doorFirst.get('position:sales_rep'), bundleFirst.get('position:sales_rep')).sort()) + .toEqual(['_packageId', '_packageVersion', '_provenance', 'delegatable']); + expect(diffPaths(doorFirst.get('permission:support_agent'), bundleFirst.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'); + }); + + // ── The two collections that have no second copy to diverge ────────── + // + // Recorded as measurements, not omissions: the card names five security + // collections, and two of them never travel this path in a way that could + // produce two copies. Neither is a reason to skip the collection — it is + // what "covered" means for them. + + it('capabilities: only ONE reader exists — the door never registers them', async () => { + const door = collapse(await readerDoor()); + const bundle = collapse(await readerBundle()); + // `capabilities` is an authorable stack collection (ADR-0066 D1) that + // `ARTIFACT_FIELD_TO_TYPE` (`packages/metadata/src/plugin.ts`) does not + // map, so the artifact door registers nothing under `capability` and + // AppPlugin is the sole registrar. No divergence is constructible. + expect(bundle.get('capability:crm.export')).toBeDefined(); + expect(door.has('capability:crm.export')).toBe(false); + expect([...door.keys()].filter((k) => k.startsWith('capability:'))).toEqual([]); + }); + + it('policies: not an authorable stack collection at all — neither reader can see one', async () => { + // `AppPlugin`'s SECURITY_FIELDS and `ARTIFACT_FIELD_TO_TYPE` both carry + // a `policies` → `policy` entry, but `ObjectStackDefinitionSchema` is a + // strictObject with no `policies` key: on the permission set `policies` + // is an ALIAS for `rowLevelSecurity`. A top-level `policies` collection + // is refused by the door outright, so it can never reach either + // registry — both entries are dead pointers. + const withPolicies = { ...bytes(), policies: [{ name: 'p1', label: 'P1' }] }; + const parsed = ObjectStackDefinitionSchema.safeParse(withPolicies); + expect(parsed.success).toBe(false); + const codes = parsed.success ? [] : parsed.error.issues.map((i) => i.code); + expect(codes).toContain('unrecognized_keys'); + + const door = collapse(await readerDoor()); + const bundle = collapse(await readerBundle()); + expect([...door.keys()].filter((k) => k.startsWith('policy:'))).toEqual([]); + expect([...bundle.keys()].filter((k) => k.startsWith('policy:'))).toEqual([]); + }); +}); diff --git a/packages/runtime/src/app-plugin.ts b/packages/runtime/src/app-plugin.ts index 62c9cfb99b..2561f5e0b2 100644 --- a/packages/runtime/src/app-plugin.ts +++ b/packages/runtime/src/app-plugin.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { Plugin, PluginContext, wireAuthoredTranslationSync } from '@objectstack/core'; -import { assertProtocolCompat } from '@objectstack/metadata-core'; +import { applyArtifactForwardConversions, assertProtocolCompat } from '@objectstack/metadata-core'; import { resolveTenancyPosture } from '@objectstack/types'; import { postureEnforcesWall, type TenancyPosture } from '@objectstack/spec/security'; import { SeedLoaderService } from './seed-loader.js'; @@ -645,9 +645,52 @@ export class AppPlugin implements Plugin { | { registerInMemory?: (t: string, n: string, d: unknown) => void } | undefined; if (typeof metadata?.registerInMemory === 'function') { - const securityBundle: any = this.bundle.manifest + 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'],