Skip to content

Commit e956924

Browse files
feat(spec,metadata-core)!: every retired ADR-0087 conversion carries retiredAfter; the artifact door opens its window per entry (#20390) (#20435)
Fixes #20390 Clause-②: yes Implements ruling `5865890672` (batch #235 item 1, letter **A**, maintainer 「同意 A」; maintainer record `5865873150`, route `5866178043`): every retired entry in the ADR-0087 conversion registry carries a REQUIRED `retiredAfter`, and the artifact forward-conversion window decides per entry. It is one vertical PR across `packages/spec`, `packages/metadata-core` and the artifact door in `packages/metadata`. ## Spec half - **`MetadataConversion` is a live-or-retired union** (`packages/spec/src/conversions/types.ts`). An entry with `retiredFromLoadPath: true` must also carry `retiredAfter`, typed as a stable `x.y.z` template-literal string; a live entry carries neither. tsc refuses an unstamped retirement (the reverse verification is below). The type moves from an interface to a type alias, so `gen:api-surface` and `gen:export-origins` each rewrite one row: `MetadataConversion (interface)` becomes `MetadataConversion (type)`. - **Backfill, from the published tarballs.** Each published entry's value is the stable release just before the first tarball that carries it retired. Each entry in no published tarball carries the current `package.json` label, `17.4.0`. - **Census test.** `src/conversions/retired-after.census.json` holds raw facts per stable release since the registry first shipped (14.8.0 through 17.4.0): the tarball integrity and the ids its `ALL_CONVERSIONS` marks retired. `src/conversions/retired-after.census.test.ts` pins every entry's value against it, offline, in the `local` tier. It pins that every entry absent from the last published tarball carries the label, and that no value is malformed or above the label. `scripts/build-retired-after-census.ts` re-derives the census from registry.npmjs.org. It checks each tarball's integrity, imports each release's `dist/index.mjs`, and writes the census, or compares it with `--check`. ## metadata-core half `applyArtifactForwardConversions` replays entry E when the artifact's floor is below the runtime label OR at or below `E.retiredAfter`. `DEFAULT_FLIPS_NOT_REPLAYED_HERE` is still read first. Its membership is unchanged; `flow-decision-mode-inclusive-explicit` came in with the merge of #20344. When the floor is at or above the label, only the entries the floor predates are replayed. The rest reach the strict parse and their tombstones through the existing `excludeConversionIds` seam, computed per entry from the registry. There is no second table. `ArtifactForwardConversionVerdict` gains `'converted-retired-after'` for that case. `ArtifactForwardConversionResult` gains `replayedRetirements` (element type `ArtifactReplayedRetirement`): under that verdict, each retirement this runtime enforces past the artifact's floor, with its `retiredAfter`; it is empty for every other verdict. The module docblock's two policy sentences still hold: "a key retired at version V stays a loud refusal for anything authored at ≥ V" (the floor-at-or-above-label bullet), and "Not a second conversion table". ## The door's consumer arm (`packages/metadata/src/plugin.ts`) The verdict has one in-tree consumer that branches on it, and the new arm is added there. - **Which verdicts open the window** is now one total table, `FORWARD_WINDOW_OPENED` (a readonly `Record` keyed by every `ArtifactForwardConversionVerdict` member, valued `boolean`), with `'converted-retired-after'` on the open side. `_warnUnboundFormPredicateRoots` (the #12915 scope-C notice) returns on `!FORWARD_WINDOW_OPENED[result.verdict]`. That makes its docblock sentence true again: the notice is "read off that pass's own verdict rather than recomputed, so the two can never disagree", and it no longer depends on the label. On `main` today, a 17.4.0-built artifact with a bare-root form predicate is announced now, not once the label reaches 17.5.0. - **Why the table, not the inverted guard.** The two forms the order offered have opposite defaults for a verdict that does not exist yet. Adding the arm to the old hand-written guard defaults a future verdict to "closed", which is how this defect arose. Inverting the guard (return only on `'authored-current'` / `'runtime-version-unknown'`) defaults it to "open", and it would also admit `'not-an-object'`. A total `Record` over the verdict union has no default: a new member is a compile error until someone places it. This is the "add the arm" route, spelled so that tsc forces the next decision. Reverse-verified below. - **The warn lines under the new verdict** no longer say the artifact "predates this runtime's spec" beside a runtime version equal to its floor. The conversion summary names the retirement this runtime enforces past the artifact's floor, with the release that last accepted the shape (from `replayedRetirements`). It then says the artifact converts again on every boot until it is rebuilt with tooling from a release that ships the retirement. The #12915 notice opens with the same verdict-aware clause. Every other verdict keeps its existing wording. - `plugin-unbound-form-predicate-roots.test.ts`'s "current surface" silence pin had derived that surface as a caret range on the installed label. That spelling is itself the label-dependence this change removes: on `main` it names an artifact built BY the last release. It now derives the first `x.y.z` past both the label and every `retiredAfter`. ## The four pins | Pin | Where | Asserts | |:--|:--|:--| | (1) a 17.4.0-CLI-built artifact with dashboard charts and page `assignedProfiles` boots on `main` and logs the notices | `packages/metadata/src/plugin-artifact-forward-conversion-retired-after.test.ts`, on a REAL fixture: `dist/objectstack.json` built verbatim by the published `@objectstack/cli` 17.4.0 | the dashboard and page register with `chartConfig.type`/`xAxis`/`yAxis` and `assignedProfiles` converted away; one warn line each for `dashboard-widget-chart-config-structure-removed` (3 sites) and `page-assigned-profiles-removed` (1 site) | | (2) newly authored sources using the retired keys are still refused loudly | same file | `defineStack` refuses with `code: 'STACK_SCHEMA_INVALID'`, `status: 422`, and one issue per retired site (4 paths) | | (3) floor exactly 17.5.0 on a 17.5.0-labelled runtime is refused, not converted | `packages/metadata-core/src/artifact-forward-conversion.test.ts` | verdict `authored-current`, zero notices, and the strict parse refuses the same 4 paths | | (4) unreleased `main` (label 17.4.0), artifact at the last release (`^17.4.0`) | same file | verdict `converted-retired-after`, notices by id and path, and the strict parse passes | Beside pin (1), **the #12915 pin** (`plugin-artifact-forward-conversion-retired-after.test.ts`, "announces a bare-root form predicate once"): the `^17.4.0` fixture with one bare-root form predicate (`stage == "won"`) on the 17.4.0 runtime logs the unbound-root line exactly once, including across a second ingestion. It is red under the old guard and green now (below). Three companions sit beside the pins. After the release (label 17.5.0) the same artifact converts through the label half, with `replayedRetirements` empty. A 17.2.0 retirement still meets its tombstone inside the open per-entry window. `flow-decision-mode-inclusive-explicit` stays refused inside its own per-entry window. Pin (4) also asserts `replayedRetirements`: both retirements at `17.4.0`, and never the default flip. ## Census (re-derived on this tree, npm `latest` = `17.4.0`, label = `17.4.0`) 94 retired entries: **73 published** and **21 unpublished**. The ruling counted 91 retired with 18 unpublished at `df3ba164`. Three unpublished entries landed since then: `action-aria-removed`, `connector-resilience-keys-removed` (#20350) and `flow-decision-mode-inclusive-explicit` (#20344, merged into this branch). | first published retirement | entries | `retiredAfter` | |:--|--:|:--| | 15.1.0 | 5 | 15.0.0 | | 17.0.0 | 45 | 16.1.0 | | 17.1.0 | 5 | 17.0.0 | | 17.2.0 | 2 | 17.1.0 | | 17.3.0 | 8 | 17.2.0 | | 17.4.0 | 8 | 17.3.0 | | none (unpublished) | 21 | 17.4.0 | The ruling's census bucket of 50 entries "first retired in 17.0.0" is 45 + 5. The engine seat's census started at the 17.0.0 tarball. Those 5 entries (`object-compactLayout-to-highlightFields`, `stack-roles-to-positions`, `owd-legacy-read-aliases`, `sharing-recipient-role-to-position`, `book-audience-profile-to-permission-set`) are already retired in the 15.1.0, 15.1.1, 16.0.0 and 16.1.0 tarballs, so the ruling's own principle gives them `15.0.0`. ## Verification (final HEAD `2c537b7e`) - Derived gates: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` gave 90 commands at `2c537b7e` (16 files, +1667/−72), and all 90 exit 0 on that head. The `--ran` reconciliation (each line carrying its exit code) reads: "90 derived, 90 run, 0 NOT-MEASURED, 0 UNRUN". The full package closure was rebuilt first (turbo 71/71). - `@objectstack/spec` `test` (`--project local`): Test Files 565 passed (565), Tests 16645 passed, 1 todo. `test:repo` (`--project repo`, run in two halves of 18 files each to fit the foreground cap): 18 files / 460 tests and 18 files / 195 tests, together Test Files 36 passed (36), Tests 655 passed. - `@objectstack/metadata-core` `test`: Test Files 16 passed (16), Tests 295 passed (295). `typecheck` exit 0. - `@objectstack/metadata` `test`: Test Files 55 passed (55), Tests 826 passed (826). `typecheck` exit 0. - eslint `--no-inline-config --format json` on the 10 changed source files: 10 files linted, 0 errors, 0 warnings. `eslint.config.mjs` never enables type-aware linting, so this diff cannot move the verdict on any untouched file. - Main was merged three times, all through `scripts/pm/os-regen-merge.sh`. None of this round's incoming commits touch `packages/spec/src/conversions`, `packages/metadata-core` or `packages/metadata`, and none adds a retired entry: every one of the 94 carries `retiredAfter`. ## Ablation and reverse verification (from committed state, through `scripts/ablation-replace.mjs`) - **Guard ablation (this round).** In `plugin.ts`, `if (!FORWARD_WINDOW_OPENED[result.verdict]) return;` was put back to the old guard, `if (result.verdict !== 'converted-forward' && result.verdict !== 'converted-undeclared') return;`, with a marker comment. On-disk count: marker 1, new guard 0. Across the three door suites (21 tests), exactly one went red, the #12915 pin ("announces a bare-root form predicate once"). The rest stayed green, including the updated current-surface silence pin. Restore: blob `8f43972c` equals HEAD, `git diff HEAD` is empty, `git status --porcelain` has 0 lines, and all 21 tests pass again. The suites import `plugin.ts` from source, so no build sits between the mutation and the run. - **tsc forces the next verdict decision.** With the `'converted-retired-after': true` row removed from `FORWARD_WINDOW_OPENED`, `tsc --noEmit` in `packages/metadata` exits 2 with `error TS2741: Property '"converted-retired-after"' is missing`. Restored to the HEAD blob. - **Window ablation (round 0, at `87da6b88`).** The per-entry branch was replaced with the old label-only verdict, and `metadata-core` was rebuilt, with the marker present in 2 built files. Pin (4), pin (1) boot and pin (1) notices went red, along with both per-entry companions. Pins (2) and (3) stayed green. The restore was proven (blob equals HEAD, 0 porcelain lines, and the marker absent from the rebuilt dist). - **tsc refuses an unstamped retirement.** With `retiredAfter` removed from `page-assigned-profiles-removed`, spec `tsc --noEmit` exits 2 with exactly one `error TS2322`. - **The census test fails when it should.** A published entry stamped low reds the PUBLISHED test, and an unpublished entry stamped low reds the UNPUBLISHED test. `build-retired-after-census.ts --check` passes against npm (11 releases), and exits 1 on a tampered census. ## Deviations from the ruling text, and why 1. **The rule for unpublished entries has one tolerance.** While the label is AHEAD of the census's last release, an unpublished entry may carry any version from that release up to the label. Taken literally ("carries the current label"), the rule turns the Version Packages PR red. That PR bumps the label to 17.5.0 before 17.5.0 is published, while the 17.5.0 entries correctly carry 17.4.0. The tolerance closes again once the census records the new tarball. The seat confirmed this reading (`5869635456`). The refresh is now a written step of the GA release flow: `docs/releases-maintenance.md`, under "Cutting a GA release — the Version Packages PR flow", says to run `scripts/build-retired-after-census.ts` after a stable `@objectstack/spec` publish and commit the refreshed census. The seat answered the refresh question with A; no workflow and no gate are added. 2. **The network half is a script, not a repo-tier test** (accepted by the seat, `5869635456`). `vitest.repo-tests.json` is held equal to the set of tests that read outside the package (`check:cross-package-test-inputs`), so a network-only test cannot be listed there. Reading the tarballs means downloading every stable release since 14.8.0 (about 11 tarballs, over 250 MB), so no per-run suite does it. So CI pins the committed census offline, and `scripts/build-retired-after-census.ts` re-derives it. The script refuses loudly when offline; it never skips. It is not a `package.json` script and not wired into CI, so no gate is added. 3. **Stable releases only.** The census and the rule skip `-rc` versions: a caret floor never names a prerelease, and the door compares `x.y.z` triples. 4. **Counts.** See the Census section: 73 published, 21 unpublished, and a 15.1.0 bucket the ruling's counts did not have. ## Acceptance notes - `packages/metadata` now carries a `patch` changeset entry for the door change. It changes no public API; the #12915 notice and the conversion summary wording follow the per-entry window. - `field-required-notnull-explicit` appears retired in the 17.0.0 through 17.3.0 tarballs and is gone from 17.4.0 and `main`, withdrawn by #16693. The census test ignores ids not on `main`. - The seat files two follow-ups at landing, as governed surfaces outside this PR (per `5869635456`): ADR-0087's #12772 addendum sentence that a floor at or above the runtime "replays nothing", and the retirement kit in `.claude/skills/spec-property-retirement/SKILL.md`. - Once this lands, any open PR that adds a retired entry fails typecheck until it stamps `retiredAfter`. That is the designed loud direction. - `main` narrowed `manifest.id` (underscores refused, #17534). So a 17.4.0-built artifact whose id has an underscore is refused whatever this window does. The pin fixture uses a reverse-domain id for that reason. --- _Generated by [Claude Code](https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent dc0ab6a commit e956924

16 files changed

Lines changed: 1668 additions & 72 deletions
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/metadata-core': minor
4+
'@objectstack/metadata': patch
5+
---
6+
7+
feat(spec,metadata-core)!: every retired ADR-0087 conversion carries `retiredAfter`, and the artifact door opens its window per entry (#20390)
8+
9+
Clause-②: yes
10+
11+
<!-- 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 -->
12+
13+
**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.
14+
15+
**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`.
16+
17+
**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.
18+
19+
**`@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.
20+
21+
**`@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.
22+
23+
**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.

‎docs/releases-maintenance.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,8 @@ Wait for the refreshed PR's CI, then merge it. That merge is still the decision
447447
release, and the `release` environment approval is still the authorisation — neither
448448
is changed by where the refresh came from.
449449

450+
**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.
451+
450452
## Drift guard
451453

452454
`scripts/check-release-notes.mjs` (run in CI as `pnpm check:release-notes`) fails the

‎packages/metadata-core/src/artifact-forward-conversion.test.ts‎

Lines changed: 171 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,8 +98,12 @@ describe('applyArtifactForwardConversions — the versioned window (#12772)', ()
9898
});
9999

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

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

408+
/**
409+
* [#20390] The per-entry window — `retiredAfter` (ruling 5865890672, letter A).
410+
*
411+
* Between two releases `main` refuses keys the NEXT release retires while its
412+
* package label still reads the LAST release. A label-only window therefore
413+
* read an artifact built by that last release as "authored current" and let
414+
* the strict parse refuse it — the measured cloud re-cut: a 17.4.0-built
415+
* artifact with dashboard charts and page `assignedProfiles` could not boot on
416+
* a runtime built from `main` (label 17.4.0, retirements stamped for 17.5.0).
417+
*
418+
* The rule: entry E replays when `floor < runtime` OR `floor <= E.retiredAfter`.
419+
* The runtime label is injected so each leg names the release it models; the
420+
* registry is always this tree's real one, whose 17.5.0 retirements carry
421+
* `retiredAfter: '17.4.0'` (pinned against the tarballs in spec's census test).
422+
*/
423+
describe('[#20390] the per-entry window — an artifact built by the last release boots on unreleased main', () => {
424+
/** The shape the published 17.4.0 CLI emits for a chart widget and an assigned page. */
425+
const builtBy174 = (protocolRange: string) => ({
426+
manifest: {
427+
id: 'com.example.forward-probe', namespace: 'fwd', name: 'forward_probe', version: '1.0.0', type: 'app',
428+
engines: { protocol: protocolRange },
429+
},
430+
objects: [{
431+
name: 'fwd_deal', label: 'Deal', sharingModel: 'private',
432+
fields: { stage: { type: 'text', label: 'Stage' }, amount: { type: 'number', label: 'Amount' } },
433+
}],
434+
datasets: [{
435+
name: 'fwd_deal_metrics', label: 'Deal metrics', object: 'fwd_deal',
436+
dimensions: [{ name: 'stage', field: 'stage' }],
437+
measures: [{ name: 'amount', aggregate: 'sum', field: 'amount' }],
438+
}],
439+
dashboards: [{
440+
name: 'fwd_pipeline', label: 'Pipeline',
441+
widgets: [{
442+
id: 'amount_by_stage', title: 'Amount by stage', type: 'bar',
443+
dataset: 'fwd_deal_metrics', dimensions: ['stage'], values: ['amount'],
444+
chartConfig: {
445+
type: 'bar',
446+
xAxis: { field: 'stage', showGridLines: true, logarithmic: false },
447+
yAxis: [{ field: 'amount', showGridLines: true, logarithmic: false }],
448+
showLegend: true, showDataLabels: false,
449+
},
450+
layout: { x: 0, y: 0, w: 6, h: 4 },
451+
}],
452+
}],
453+
pages: [{
454+
name: 'fwd_deal_desk', label: 'Deal Desk', type: 'app', template: 'default', regions: [],
455+
isDefault: false, assignedProfiles: ['sales_manager'], kind: 'full',
456+
}],
457+
});
458+
459+
/** The retired-key sites the 17.5.0 cohort refuses in {@link builtBy174}. */
460+
const RETIRED_SITES = [
461+
'dashboards.0.widgets.0.chartConfig.type',
462+
'dashboards.0.widgets.0.chartConfig.xAxis',
463+
'dashboards.0.widgets.0.chartConfig.yAxis',
464+
'pages.0.assignedProfiles',
465+
];
466+
467+
const issuePaths = (value: unknown): string[] => {
468+
const parsed = ObjectStackDefinitionSchema.safeParse(value);
469+
return parsed.success ? [] : parsed.error.issues.map((i) => i.path.join('.')).sort();
470+
};
471+
472+
const byConversion = (notices: readonly ArtifactConversionNotice[]) => {
473+
const counts: Record<string, number> = {};
474+
for (const n of notices) counts[n.conversionId] = (counts[n.conversionId] ?? 0) + 1;
475+
return counts;
476+
};
477+
478+
it('premise: unconverted, this tree refuses the 17.4.0-built shape at exactly the retired sites', () => {
479+
expect(issuePaths(builtBy174('^17.4.0'))).toEqual(RETIRED_SITES);
480+
});
481+
482+
// Pin (4): the regression case from the card's acceptance.
483+
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', () => {
484+
const def = builtBy174('^17.4.0');
485+
const result = applyArtifactForwardConversions(def, { runtimeSpecVersion: '17.4.0' });
486+
487+
expect(result.verdict).toBe('converted-retired-after');
488+
expect(result.authoredFloor).toBe('17.4.0');
489+
expect(byConversion(result.notices)).toEqual({
490+
'page-assigned-profiles-removed': 1,
491+
'dashboard-widget-chart-config-structure-removed': 3,
492+
});
493+
expect(result.notices.map((n) => n.path).sort()).toEqual([
494+
'dashboards[0].widgets[0].chartConfig.type',
495+
'dashboards[0].widgets[0].chartConfig.xAxis',
496+
'dashboards[0].widgets[0].chartConfig.yAxis',
497+
'pages[0].assignedProfiles',
498+
]);
499+
// What the door hands the strict parse now boots.
500+
expect(issuePaths(result.definition)).toEqual([]);
501+
// The door names what opened it: each retirement this runtime enforces past
502+
// the floor, with the release it retired after — never a default flip.
503+
const replayed = new Map(result.replayedRetirements.map((r) => [r.conversionId, r.retiredAfter]));
504+
expect(replayed.get('page-assigned-profiles-removed')).toBe('17.4.0');
505+
expect(replayed.get('dashboard-widget-chart-config-structure-removed')).toBe('17.4.0');
506+
expect(replayed.has('flow-decision-mode-inclusive-explicit')).toBe(false);
507+
expect([...new Set(replayed.values())]).toEqual(['17.4.0']);
508+
});
509+
510+
// Pin (3): the boundary the per-entry rule must keep.
511+
it('an artifact whose floor is exactly 17.5.0 on a 17.5.0-labelled runtime is refused, not converted', () => {
512+
const def = builtBy174('^17.5.0');
513+
const result = applyArtifactForwardConversions(def, { runtimeSpecVersion: '17.5.0' });
514+
515+
expect(result.verdict).toBe('authored-current');
516+
expect(result.notices).toEqual([]);
517+
expect(result.replayedRetirements).toEqual([]);
518+
expect(result.definition).toBe(def);
519+
// The strict parse the door feeds refuses every retired site, tombstones included.
520+
expect(issuePaths(result.definition)).toEqual(RETIRED_SITES);
521+
});
522+
523+
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', () => {
524+
const result = applyArtifactForwardConversions(builtBy174('^17.4.0'), { runtimeSpecVersion: '17.5.0' });
525+
expect(result.verdict).toBe('converted-forward');
526+
// The label half names no per-entry reason: the whole chain replays on one.
527+
expect(result.replayedRetirements).toEqual([]);
528+
expect(byConversion(result.notices)).toEqual({
529+
'page-assigned-profiles-removed': 1,
530+
'dashboard-widget-chart-config-structure-removed': 3,
531+
});
532+
expect(issuePaths(result.definition)).toEqual([]);
533+
});
534+
535+
/**
536+
* Inside the open per-entry window, an entry the floor post-dates still
537+
* refuses: `permission-allow-restore-purge-removed` shipped retired in 17.2.0
538+
* (`retiredAfter` 17.1.0), so a ^17.4.0 artifact carrying `allowRestore: true`
539+
* meets its tombstone although the 17.5.0 entries replay beside it. A key
540+
* retired at V stays a loud refusal for anything authored at >= V.
541+
*/
542+
it('replays only the entries the floor predates — an older retirement still meets its tombstone', () => {
543+
const def = {
544+
...builtBy174('^17.4.0'),
545+
permissions: [{ name: 'fwd_agent', label: 'Agent', objects: { fwd_deal: { allowRead: true, allowRestore: true } } }],
546+
};
547+
const result = applyArtifactForwardConversions(def, { runtimeSpecVersion: '17.4.0' });
548+
549+
expect(result.verdict).toBe('converted-retired-after');
550+
expect(result.notices.map((n) => n.conversionId)).not.toContain('permission-allow-restore-purge-removed');
551+
const grant = (result.definition as typeof def).permissions[0]!.objects.fwd_deal;
552+
expect(grant.allowRestore, 'the 17.2.0 retirement is not replayed for a 17.4.0 floor').toBe(true);
553+
expect(issuePaths(result.definition)).toEqual(['permissions.0.objects.fwd_deal.allowRestore']);
554+
});
555+
});
556+
404557
/**
405558
* #15429 — the second member of the DEFAULT-FLIP class this door refuses.
406559
*
@@ -476,6 +629,22 @@ describe('the artifact door never writes `mode: inclusive` onto an authored excl
476629
expect(Object.keys(registered.config ?? {}), 'what registration receives').not.toContain('mode');
477630
});
478631

632+
/**
633+
* [#20390] The per-entry window does not reopen it either. The entry is
634+
* stamped `retiredAfter: '17.4.0'`, so a ^17.4.0 floor on a runtime still
635+
* labelled 17.4.0 is inside ITS per-entry window — and the door's refusal
636+
* list is still read first, before any version is.
637+
*/
638+
it('stays refused inside the per-entry window too — the refusal list is read before retiredAfter', () => {
639+
const def = twoBranchDecisionDefinition('^17.4.0');
640+
const result = applyArtifactForwardConversions(def, { runtimeSpecVersion: '17.4.0' });
641+
642+
// ⭐ ANTI-VACUITY: the per-entry window really is open on this input.
643+
expect(result.verdict).toBe('converted-retired-after');
644+
expect(verdictNodeOf(result.definition).config).toBeUndefined();
645+
expect(result.notices.map((n) => n.conversionId)).not.toContain(ID);
646+
});
647+
479648
it('floor ^99.0.0 — the window is shut and nothing is replayed at all', () => {
480649
const def = twoBranchDecisionDefinition('^99.0.0');
481650
const result = applyArtifactForwardConversions(def, { runtimeSpecVersion: '17.4.0' });

0 commit comments

Comments
 (0)