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 new file mode 100644 index 0000000000..5f6742ee59 --- /dev/null +++ b/packages/metadata-core/src/artifact-forward-conversion.test.ts @@ -0,0 +1,194 @@ +// 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 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 { + 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..128dd9621d --- /dev/null +++ b/packages/metadata-core/src/artifact-forward-conversion.ts @@ -0,0 +1,291 @@ +// 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'; +// ⚠ 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. */ + | '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: ArtifactConversionNotice) => 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: ArtifactConversionNotice[]; +} + +/** + * 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: ArtifactConversionNotice[] = []; + 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..e646d3da67 --- /dev/null +++ b/packages/metadata/src/plugin-artifact-forward-conversion.test.ts @@ -0,0 +1,244 @@ +// 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 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'; +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.js'; + +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)', () => { + // 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(); + // 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 + // — 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).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)', () => { + 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'; 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',