Skip to content

Commit 54e2d36

Browse files
os-zhuangclaude
andauthored
feat(metadata): versioned ADR-0087 forward conversion at the artifact-ingestion door (#12843)
* feat(metadata): run the versioned ADR-0087 forward conversion at the artifact-ingestion door 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SVYmuhHW6qZmNBqciaS7BN * test(metadata): NodeNext-safe imports in the new suites; changeset Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SVYmuhHW6qZmNBqciaS7BN * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SVYmuhHW6qZmNBqciaS7BN * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SVYmuhHW6qZmNBqciaS7BN * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SVYmuhHW6qZmNBqciaS7BN --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 28a5c3e commit 54e2d36

9 files changed

Lines changed: 1888 additions & 5 deletions
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/metadata-core': patch
3+
'@objectstack/metadata': patch
4+
---
5+
6+
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).
7+
8+
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).
9+
10+
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`.
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Versioned artifact forward conversion (#12772) — the policy, both directions.
5+
*
6+
* The measured incident: an artifact built by released 17.1.0 tooling carries
7+
* `allowRestore`/`allowPurge` permission bits (legal when it was built, retired
8+
* in spec 17.2.0), and the 17.2 runtime's strict parse refuses the boot. The
9+
* ADR-0087 registry already declares the strip conversion
10+
* (`permission-allow-restore-purge-removed`, `retiredFromLoadPath: true`);
11+
* what was missing is a door that opens the retired window for artifacts whose
12+
* declared `engines.protocol` floor predates the running spec — and ONLY for
13+
* those. Both directions are pinned here: the amnesty (older floor converts
14+
* forward) and its boundary (current-or-newer floor does not — the tombstone
15+
* stays the authority), because an unconditional strip becomes wrong the day
16+
* the keys return to the spec (roadmap M2, #1883).
17+
*/
18+
19+
import { describe, it, expect } from 'vitest';
20+
import type { ConversionNotice } from '@objectstack/spec';
21+
import {
22+
applyArtifactForwardConversions,
23+
parseRangeFloor,
24+
resolveInstalledSpecVersion,
25+
type ArtifactConversionNotice,
26+
} from './artifact-forward-conversion.js';
27+
28+
// ── Mirror pin ───────────────────────────────────────────────────────────────
29+
// `ArtifactConversionNotice` is a structural mirror of the spec root's
30+
// `ConversionNotice`, kept so the module's PUBLIC declarations never import
31+
// the ~2MB spec root (the import made every downstream type program load the
32+
// root twice — d.ts and d.mts flavors — and pushed the http-conformance
33+
// TEST_DEBT re-measure over CI's ~4GB tsc heap ceiling; #12772 patch round).
34+
// The TEST may reference the root freely — tests never ship declarations.
35+
// Both assignability directions, so EITHER side drifting reds this suite:
36+
type _SpecToMirror = ConversionNotice extends ArtifactConversionNotice ? true : never;
37+
type _MirrorToSpec = ArtifactConversionNotice extends ConversionNotice ? true : never;
38+
const _mirrorPin: [_SpecToMirror, _MirrorToSpec] = [true, true];
39+
void _mirrorPin;
40+
41+
/** The measured 17.1-built shape: full CRUD plus the two retired lifecycle bits. */
42+
function legacyPermissionDefinition(protocolRange: string | undefined) {
43+
return {
44+
manifest: {
45+
id: 'app.example.crm',
46+
name: 'crm',
47+
version: '3.0.0',
48+
type: 'app',
49+
...(protocolRange ? { engines: { protocol: protocolRange } } : {}),
50+
},
51+
permissions: [
52+
{
53+
name: 'support_agent',
54+
label: 'Support Agent',
55+
objects: {
56+
crm_ticket: {
57+
allowRead: true,
58+
allowCreate: true,
59+
allowEdit: true,
60+
allowDelete: true,
61+
allowRestore: true,
62+
allowPurge: false,
63+
},
64+
crm_note: { allowRead: true },
65+
},
66+
},
67+
],
68+
};
69+
}
70+
71+
describe('applyArtifactForwardConversions — the versioned window (#12772)', () => {
72+
it('converts a 17.1-authored artifact forward on a 17.2 runtime: retired keys stripped, everything else byte-preserved', () => {
73+
const def = legacyPermissionDefinition('^17.1.0');
74+
const result = applyArtifactForwardConversions(def, { runtimeSpecVersion: '17.2.0' });
75+
76+
expect(result.verdict).toBe('converted-forward');
77+
expect(result.authoredFloor).toBe('17.1.0');
78+
79+
const converted = result.definition as typeof def;
80+
const grant = converted.permissions[0]!.objects.crm_ticket as Record<string, unknown>;
81+
expect(grant).not.toHaveProperty('allowRestore');
82+
expect(grant).not.toHaveProperty('allowPurge');
83+
// Everything else byte-preserved: same keys, same values, and the
84+
// untouched sibling object rides through by reference (copy-on-write).
85+
expect(grant).toEqual({ allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true });
86+
expect(converted.permissions[0]!.objects.crm_note).toBe(def.permissions[0]!.objects.crm_note);
87+
expect(converted.manifest).toBe(def.manifest);
88+
89+
// Loud, not silent: one notice per stripped key.
90+
const stripNotices = result.notices.filter(
91+
(n) => n.conversionId === 'permission-allow-restore-purge-removed',
92+
);
93+
expect(stripNotices).toHaveLength(2);
94+
expect(stripNotices.map((n) => n.path)).toEqual([
95+
'permissions[0].objects.crm_ticket.allowRestore',
96+
'permissions[0].objects.crm_ticket.allowPurge',
97+
]);
98+
});
99+
100+
it('REFUSES the amnesty for an artifact authored at the current spec version — no blanket strip', () => {
101+
const def = legacyPermissionDefinition('^17.2.0');
102+
const result = applyArtifactForwardConversions(def, { runtimeSpecVersion: '17.2.0' });
103+
104+
expect(result.verdict).toBe('authored-current');
105+
expect(result.notices).toEqual([]);
106+
// The definition comes back by reference, retired keys still present —
107+
// the strict parse downstream is what answers, with the tombstone.
108+
expect(result.definition).toBe(def);
109+
expect(def.permissions[0]!.objects.crm_ticket).toHaveProperty('allowPurge');
110+
});
111+
112+
it('REFUSES the amnesty for an artifact authored at a NEWER spec than the runtime', () => {
113+
const def = legacyPermissionDefinition('^18.0.0');
114+
const result = applyArtifactForwardConversions(def, { runtimeSpecVersion: '17.2.0' });
115+
expect(result.verdict).toBe('authored-current');
116+
expect(result.definition).toBe(def);
117+
});
118+
119+
it('treats a bare-major range (`^17`, the init scaffold default) as floor 17.0.0 — older than 17.2, so it converts', () => {
120+
const def = legacyPermissionDefinition('^17');
121+
const result = applyArtifactForwardConversions(def, { runtimeSpecVersion: '17.2.0' });
122+
expect(result.verdict).toBe('converted-forward');
123+
expect(result.authoredFloor).toBe('17.0.0');
124+
const grant = (result.definition as typeof def).permissions[0]!.objects.crm_ticket;
125+
expect(grant).not.toHaveProperty('allowPurge');
126+
});
127+
128+
it('replays the full chain for an artifact with NO declared range — the stored-row posture for data of unknown age', () => {
129+
const def = legacyPermissionDefinition(undefined);
130+
const result = applyArtifactForwardConversions(def, { runtimeSpecVersion: '17.2.0' });
131+
expect(result.verdict).toBe('converted-undeclared');
132+
expect(result.authoredFloor).toBeNull();
133+
const grant = (result.definition as typeof def).permissions[0]!.objects.crm_ticket;
134+
expect(grant).not.toHaveProperty('allowRestore');
135+
});
136+
137+
it('closes the window when the runtime spec version cannot be resolved — amnesty needs positive version evidence', () => {
138+
const def = legacyPermissionDefinition('^17.1.0');
139+
const result = applyArtifactForwardConversions(def, { runtimeSpecVersion: null });
140+
expect(result.verdict).toBe('runtime-version-unknown');
141+
expect(result.definition).toBe(def);
142+
expect(def.permissions[0]!.objects.crm_ticket).toHaveProperty('allowPurge');
143+
});
144+
145+
it('is idempotent: a definition already canonical for its floor comes back by reference', () => {
146+
const def = {
147+
manifest: { id: 'app.example.clean', name: 'clean', version: '1.0.0', type: 'app', engines: { protocol: '^17.1.0' } },
148+
permissions: [
149+
{ name: 'reader', label: 'Reader', objects: { crm_note: { allowRead: true } } },
150+
],
151+
};
152+
const result = applyArtifactForwardConversions(def, { runtimeSpecVersion: '17.2.0' });
153+
expect(result.verdict).toBe('converted-forward');
154+
expect(result.notices).toEqual([]);
155+
// applyConversions is copy-on-write, so "nothing recognized" is provable
156+
// by identity, not just equality.
157+
expect(result.definition).toBe(def);
158+
});
159+
160+
it('passes non-object input through untouched', () => {
161+
expect(applyArtifactForwardConversions(null, { runtimeSpecVersion: '17.2.0' }).verdict).toBe('not-an-object');
162+
expect(applyArtifactForwardConversions([1], { runtimeSpecVersion: '17.2.0' }).verdict).toBe('not-an-object');
163+
});
164+
165+
it('defaults the runtime version to the installed @objectstack/spec version', () => {
166+
const installed = resolveInstalledSpecVersion();
167+
// In this workspace spec is always resolvable; the default path must find
168+
// the same answer an explicit resolution finds.
169+
expect(installed).toMatch(/^\d+\.\d+\.\d+/);
170+
const def = legacyPermissionDefinition('^0.0.1');
171+
const result = applyArtifactForwardConversions(def);
172+
expect(result.runtimeSpecVersion).toBe(installed);
173+
expect(result.verdict).toBe('converted-forward');
174+
});
175+
});
176+
177+
describe('parseRangeFloor — the range spellings artifacts actually carry', () => {
178+
it.each([
179+
['^17.1.0', [17, 1, 0]],
180+
['^17', [17, 0, 0]],
181+
['~17.2.1', [17, 2, 1]],
182+
['>=17.1 <18', [17, 1, 0]],
183+
['17.1.0', [17, 1, 0]],
184+
['v17.1.0', [17, 1, 0]],
185+
] as const)('%s → %j', (range, expected) => {
186+
expect(parseRangeFloor(range)).toEqual(expected);
187+
});
188+
189+
it('answers null for unreadable ranges (treated like undeclared by the policy)', () => {
190+
expect(parseRangeFloor('')).toBeNull();
191+
expect(parseRangeFloor('latest')).toBeNull();
192+
expect(parseRangeFloor('x'.repeat(200))).toBeNull();
193+
});
194+
});

0 commit comments

Comments
 (0)