From 983ca39dc77230e609ed6290f25f0ad021fbcb84 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 01:59:55 +0000 Subject: [PATCH 1/5] 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/5] 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/5] 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/5] 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/5] 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)', () => {