Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
37f7b1f
feat(spec,metadata-core): retiredAfter on every retired conversion; t…
claude Sep 28, 2026
e621fa0
test(metadata,metadata-core): pin the per-entry window on a real 17.4…
claude Sep 28, 2026
404f17b
test(metadata): rebuild the 17.4.0 probe artifact with a reverse-doma…
claude Sep 28, 2026
2e1fd0e
chore(spec): regenerate api-surface and export-origins — MetadataConv…
claude Sep 28, 2026
ed521aa
chore(changeset): spec + metadata-core minor, Clause-② yes, runtime-i…
claude Sep 28, 2026
9c92487
Merge remote-tracking branch 'origin/main' into claude/issue-20390-fo…
claude Sep 28, 2026
4cc1b3b
Merge remote-tracking branch 'origin/main' into claude/issue-20390-fo…
claude Sep 28, 2026
07572aa
feat(spec): stamp flow-decision-mode-inclusive-explicit retiredAfter …
claude Sep 28, 2026
b9a07ea
chore(changeset): census counts after the #15429 merge
claude Sep 28, 2026
87da6b8
Merge remote-tracking branch 'origin/main' into claude/issue-20390-fo…
claude Sep 28, 2026
df46d5b
Merge remote-tracking branch 'origin/main' into claude/issue-20390-fo…
claude Sep 28, 2026
874a4e7
fix(metadata): the #12915 notice and the conversion summary follow th…
claude Sep 28, 2026
a1dc7d3
test(metadata): index the last triple without Array.at — the package …
claude Sep 28, 2026
2c537b7
Merge remote-tracking branch 'origin/main' into claude/issue-20390-fo…
claude Sep 28, 2026
9cfc312
Merge remote-tracking branch 'origin/main' into claude/issue-20390-fo…
claude Sep 28, 2026
59687e3
feat(spec): stamp view-list-tabs-removed retiredAfter 17.4.0
claude Sep 28, 2026
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
23 changes: 23 additions & 0 deletions .changeset/20390-conversion-retired-after-window.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
'@objectstack/spec': minor
'@objectstack/metadata-core': minor
'@objectstack/metadata': patch
---

feat(spec,metadata-core)!: every retired ADR-0087 conversion carries `retiredAfter`, and the artifact door opens its window per entry (#20390)

Clause-②: yes

<!-- adr-0087: not-required (runtime-interface-only packages/spec/src/conversions/types.ts#MetadataConversion) a TypeScript type with no Zod schema, no stored row and no authorable key; the compiler reports the missing member to every implementer, and no metadata shape changes -->

**BREAKING** for code that implements `MetadataConversion` itself — shipped as `minor` under the launch-window convention (`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by this banner and the ADR-0087 disposition above). `MetadataConversion` is now a type alias of a live-or-retired union: an entry with `retiredFromLoadPath: true` must also carry `retiredAfter`, a stable `x.y.z` string, and a live entry carries neither. tsc names the missing member (`Property 'retiredAfter' is missing`). No in-repo conversion is left unstamped, and no metadata an author writes changes.

**What the field means.** `retiredAfter` is the last published `@objectstack/spec` version whose authoring surface still accepted the entry's old shape. It is a fact when the entry lands: the package's own version label at that moment, because `main` carries the last release's label until the next release is cut. Every published retired entry is stamped from the published tarballs — the stable release just before the first tarball that carries it retired — and each entry not yet in any published tarball carries the current label, `17.4.0`.

**Why the artifact door needed it.** Between two releases, `main` refuses keys that the next release retires while its label still reads the last release. The artifact-ingestion door (`applyArtifactForwardConversions`) compared an artifact's `engines.protocol` floor with that label alone, so an artifact built by the last published CLI — floor `^17.4.0`, dashboard `chartConfig.type`/`xAxis`/`yAxis` and page `assignedProfiles` — read as "authored current": nothing was converted and the strict parse refused the boot. The door now replays a registry entry when the floor is below the runtime label, **or** at or below that entry's `retiredAfter`. After a release the rule reduces to the old one, and an artifact whose floor is above an entry's `retiredAfter` still meets that entry's tombstone — a floor of `^17.5.0` on a 17.5.0 runtime is refused, not converted. `DEFAULT_FLIPS_NOT_REPLAYED_HERE` is still read first.

**`@objectstack/metadata-core`.** `ArtifactForwardConversionVerdict` gains `'converted-retired-after'`: the floor is at or above the runtime label, but at or below the `retiredAfter` of at least one retired entry, and only those entries are replayed. `ArtifactForwardConversionResult` gains `replayedRetirements` (exported element type `ArtifactReplayedRetirement`): under that verdict, each retirement this runtime enforces past the artifact's floor, with its `retiredAfter`; empty for every other verdict. A consumer that switches exhaustively over the verdict adds that arm.

**`@objectstack/metadata`, the artifact door — the arm added.** `MetadataPlugin` now reads which verdicts open the window from one total table over `ArtifactForwardConversionVerdict`, with `'converted-retired-after'` on the open side. The #12915 unbound form-predicate notice rides that same reading, so a 17.4.0-built artifact carrying a bare-root form predicate on `main` is announced now, rather than only once the package label moves past 17.4.0. A verdict added later fails to compile until it is placed on one side of the window. Under the new verdict the conversion summary no longer says the artifact "predates this runtime's spec" beside a runtime version equal to its floor: it names the retirement this runtime enforces past the artifact's floor, with the release that last accepted the shape, and says the artifact converts again on every boot until it is rebuilt with tooling from a release that ships the retirement. Summaries are still one per conversion per artifact, naming the site count.

**Census.** 94 retired entries when this landed: 73 published (first retired in 15.1.0: 5, 17.0.0: 45, 17.1.0: 5, 17.2.0: 2, 17.3.0: 8, 17.4.0: 8) and 21 unpublished. `packages/spec/src/conversions/retired-after.census.json` holds the raw per-release facts, and `retired-after.census.test.ts` pins every value against it, offline. `packages/spec/scripts/build-retired-after-census.ts` re-derives the census from the npm registry (tarball integrity checked). Run it after each stable publish; `docs/releases-maintenance.md` lists that step in the GA release flow.
2 changes: 2 additions & 0 deletions docs/releases-maintenance.md
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,8 @@ Wait for the refreshed PR's CI, then merge it. That merge is still the decision
release, and the `release` environment approval is still the authorisation — neither
is changed by where the refresh came from.

**After a stable `@objectstack/spec` publish, refresh the retired-after census** (#20390): run `pnpm --filter @objectstack/spec exec tsx scripts/build-retired-after-census.ts` (prefix `NODE_USE_ENV_PROXY=1` behind a proxy) and commit the rewritten `packages/spec/src/conversions/retired-after.census.json` in an ordinary PR — until it lands, the census test holds an unpublished entry's `retiredAfter` only to the range from the last censused release to the label, not to the label exactly.

## Drift guard

`scripts/check-release-notes.mjs` (run in CI as `pnpm check:release-notes`) fails the
Expand Down
173 changes: 171 additions & 2 deletions packages/metadata-core/src/artifact-forward-conversion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,12 @@ describe('applyArtifactForwardConversions — the versioned window (#12772)', ()
});

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' });
// "Current" for THIS registry: every retirement it carries is stamped
// `retiredAfter` 17.4.0 or earlier, so a 17.5.0 floor on a 17.5.0 runtime
// predates none of them. (A floor at the label that DOES predate one opens
// the per-entry window instead — the #20390 block below.)
const def = legacyPermissionDefinition('^17.5.0');
const result = applyArtifactForwardConversions(def, { runtimeSpecVersion: '17.5.0' });

expect(result.verdict).toBe('authored-current');
expect(result.notices).toEqual([]);
Expand Down Expand Up @@ -401,6 +405,155 @@ describe('the artifact door never turns an authored `hidden: true` into an unpub
});
});

/**
* [#20390] The per-entry window — `retiredAfter` (ruling 5865890672, letter A).
*
* Between two releases `main` refuses keys the NEXT release retires while its
* package label still reads the LAST release. A label-only window therefore
* read an artifact built by that last release as "authored current" and let
* the strict parse refuse it — the measured cloud re-cut: a 17.4.0-built
* artifact with dashboard charts and page `assignedProfiles` could not boot on
* a runtime built from `main` (label 17.4.0, retirements stamped for 17.5.0).
*
* The rule: entry E replays when `floor < runtime` OR `floor <= E.retiredAfter`.
* The runtime label is injected so each leg names the release it models; the
* registry is always this tree's real one, whose 17.5.0 retirements carry
* `retiredAfter: '17.4.0'` (pinned against the tarballs in spec's census test).
*/
describe('[#20390] the per-entry window — an artifact built by the last release boots on unreleased main', () => {
/** The shape the published 17.4.0 CLI emits for a chart widget and an assigned page. */
const builtBy174 = (protocolRange: string) => ({
manifest: {
id: 'com.example.forward-probe', namespace: 'fwd', name: 'forward_probe', version: '1.0.0', type: 'app',
engines: { protocol: protocolRange },
},
objects: [{
name: 'fwd_deal', label: 'Deal', sharingModel: 'private',
fields: { stage: { type: 'text', label: 'Stage' }, amount: { type: 'number', label: 'Amount' } },
}],
datasets: [{
name: 'fwd_deal_metrics', label: 'Deal metrics', object: 'fwd_deal',
dimensions: [{ name: 'stage', field: 'stage' }],
measures: [{ name: 'amount', aggregate: 'sum', field: 'amount' }],
}],
dashboards: [{
name: 'fwd_pipeline', label: 'Pipeline',
widgets: [{
id: 'amount_by_stage', title: 'Amount by stage', type: 'bar',
dataset: 'fwd_deal_metrics', dimensions: ['stage'], values: ['amount'],
chartConfig: {
type: 'bar',
xAxis: { field: 'stage', showGridLines: true, logarithmic: false },
yAxis: [{ field: 'amount', showGridLines: true, logarithmic: false }],
showLegend: true, showDataLabels: false,
},
layout: { x: 0, y: 0, w: 6, h: 4 },
}],
}],
pages: [{
name: 'fwd_deal_desk', label: 'Deal Desk', type: 'app', template: 'default', regions: [],
isDefault: false, assignedProfiles: ['sales_manager'], kind: 'full',
}],
});

/** The retired-key sites the 17.5.0 cohort refuses in {@link builtBy174}. */
const RETIRED_SITES = [
'dashboards.0.widgets.0.chartConfig.type',
'dashboards.0.widgets.0.chartConfig.xAxis',
'dashboards.0.widgets.0.chartConfig.yAxis',
'pages.0.assignedProfiles',
];

const issuePaths = (value: unknown): string[] => {
const parsed = ObjectStackDefinitionSchema.safeParse(value);
return parsed.success ? [] : parsed.error.issues.map((i) => i.path.join('.')).sort();
};

const byConversion = (notices: readonly ArtifactConversionNotice[]) => {
const counts: Record<string, number> = {};
for (const n of notices) counts[n.conversionId] = (counts[n.conversionId] ?? 0) + 1;
return counts;
};

it('premise: unconverted, this tree refuses the 17.4.0-built shape at exactly the retired sites', () => {
expect(issuePaths(builtBy174('^17.4.0'))).toEqual(RETIRED_SITES);
});

// Pin (4): the regression case from the card's acceptance.
it('unreleased main (label 17.4.0), artifact at the last release (^17.4.0): the 17.5.0 retirements replay and the parse passes', () => {
const def = builtBy174('^17.4.0');
const result = applyArtifactForwardConversions(def, { runtimeSpecVersion: '17.4.0' });

expect(result.verdict).toBe('converted-retired-after');
expect(result.authoredFloor).toBe('17.4.0');
expect(byConversion(result.notices)).toEqual({
'page-assigned-profiles-removed': 1,
'dashboard-widget-chart-config-structure-removed': 3,
});
expect(result.notices.map((n) => n.path).sort()).toEqual([
'dashboards[0].widgets[0].chartConfig.type',
'dashboards[0].widgets[0].chartConfig.xAxis',
'dashboards[0].widgets[0].chartConfig.yAxis',
'pages[0].assignedProfiles',
]);
// What the door hands the strict parse now boots.
expect(issuePaths(result.definition)).toEqual([]);
// The door names what opened it: each retirement this runtime enforces past
// the floor, with the release it retired after — never a default flip.
const replayed = new Map(result.replayedRetirements.map((r) => [r.conversionId, r.retiredAfter]));
expect(replayed.get('page-assigned-profiles-removed')).toBe('17.4.0');
expect(replayed.get('dashboard-widget-chart-config-structure-removed')).toBe('17.4.0');
expect(replayed.has('flow-decision-mode-inclusive-explicit')).toBe(false);
expect([...new Set(replayed.values())]).toEqual(['17.4.0']);
});

// Pin (3): the boundary the per-entry rule must keep.
it('an artifact whose floor is exactly 17.5.0 on a 17.5.0-labelled runtime is refused, not converted', () => {
const def = builtBy174('^17.5.0');
const result = applyArtifactForwardConversions(def, { runtimeSpecVersion: '17.5.0' });

expect(result.verdict).toBe('authored-current');
expect(result.notices).toEqual([]);
expect(result.replayedRetirements).toEqual([]);
expect(result.definition).toBe(def);
// The strict parse the door feeds refuses every retired site, tombstones included.
expect(issuePaths(result.definition)).toEqual(RETIRED_SITES);
});

it('after the release (label 17.5.0) the same ^17.4.0 artifact converts through the label half — the rule reduces to the old one', () => {
const result = applyArtifactForwardConversions(builtBy174('^17.4.0'), { runtimeSpecVersion: '17.5.0' });
expect(result.verdict).toBe('converted-forward');
// The label half names no per-entry reason: the whole chain replays on one.
expect(result.replayedRetirements).toEqual([]);
expect(byConversion(result.notices)).toEqual({
'page-assigned-profiles-removed': 1,
'dashboard-widget-chart-config-structure-removed': 3,
});
expect(issuePaths(result.definition)).toEqual([]);
});

/**
* Inside the open per-entry window, an entry the floor post-dates still
* refuses: `permission-allow-restore-purge-removed` shipped retired in 17.2.0
* (`retiredAfter` 17.1.0), so a ^17.4.0 artifact carrying `allowRestore: true`
* meets its tombstone although the 17.5.0 entries replay beside it. A key
* retired at V stays a loud refusal for anything authored at >= V.
*/
it('replays only the entries the floor predates — an older retirement still meets its tombstone', () => {
const def = {
...builtBy174('^17.4.0'),
permissions: [{ name: 'fwd_agent', label: 'Agent', objects: { fwd_deal: { allowRead: true, allowRestore: true } } }],
};
const result = applyArtifactForwardConversions(def, { runtimeSpecVersion: '17.4.0' });

expect(result.verdict).toBe('converted-retired-after');
expect(result.notices.map((n) => n.conversionId)).not.toContain('permission-allow-restore-purge-removed');
const grant = (result.definition as typeof def).permissions[0]!.objects.fwd_deal;
expect(grant.allowRestore, 'the 17.2.0 retirement is not replayed for a 17.4.0 floor').toBe(true);
expect(issuePaths(result.definition)).toEqual(['permissions.0.objects.fwd_deal.allowRestore']);
});
});

/**
* #15429 — the second member of the DEFAULT-FLIP class this door refuses.
*
Expand Down Expand Up @@ -476,6 +629,22 @@ describe('the artifact door never writes `mode: inclusive` onto an authored excl
expect(Object.keys(registered.config ?? {}), 'what registration receives').not.toContain('mode');
});

/**
* [#20390] The per-entry window does not reopen it either. The entry is
* stamped `retiredAfter: '17.4.0'`, so a ^17.4.0 floor on a runtime still
* labelled 17.4.0 is inside ITS per-entry window — and the door's refusal
* list is still read first, before any version is.
*/
it('stays refused inside the per-entry window too — the refusal list is read before retiredAfter', () => {
const def = twoBranchDecisionDefinition('^17.4.0');
const result = applyArtifactForwardConversions(def, { runtimeSpecVersion: '17.4.0' });

// ⭐ ANTI-VACUITY: the per-entry window really is open on this input.
expect(result.verdict).toBe('converted-retired-after');
expect(verdictNodeOf(result.definition).config).toBeUndefined();
expect(result.notices.map((n) => n.conversionId)).not.toContain(ID);
});

it('floor ^99.0.0 — the window is shut and nothing is replayed at all', () => {
const def = twoBranchDecisionDefinition('^99.0.0');
const result = applyArtifactForwardConversions(def, { runtimeSpecVersion: '17.4.0' });
Expand Down
Loading
Loading