Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/artifact-forward-conversion-door.md
Original file line number Diff line number Diff line change
@@ -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`.
194 changes: 194 additions & 0 deletions packages/metadata-core/src/artifact-forward-conversion.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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();
});
});
Loading
Loading