Repository navigation
Commit 58f5c90
fix(metadata-core): a ^18 artifact built during the v18 prerelease boots on a later runtime — the door replays step 18 for a floor of 18.0.0 (#23023)
Fixes #23001
Clause-②: yes
## What this changes
The artifact door (`applyArtifactForwardConversions`,
`packages/metadata-core/src/artifact-forward-conversion.ts`) opened its
retired window per entry by comparing the artifact's `engines.protocol`
floor with each entry's `retiredAfter`. A `^18` artifact has floor
18.0.0. During the v18 prerelease the package label is still 17.7.0, and
every retirement that lands then is stamped at or below that label. So
18.0.0 sat above every stamp, the door answered `'authored-current'`,
nothing was replayed, and a `^18` artifact built before a prerelease
retirement met the tombstone at the strict parse. The boot rolled back.
The window now opens on a third condition:
```text
floor below runtime OR floor at or below E.retiredAfter OR (floor = M.0.0 AND E.toMajor = M)
```
A floor of `M.0.0` at or above the runtime label replays every retired
entry of the conversion step into protocol M (`CONVERSIONS_BY_MAJOR[M]`,
the step `objectstack migrate meta --from M-1` replays). The door's
default flips are still refused by id, read first. The new verdict is
`'converted-major-step'`. `replayedRetirements` keeps listing only the
entries the per-entry rule opened, each with the `retiredAfter` the
floor is at or below, so that list's invariant holds.
## Which reading: (ii), idempotence. (i) was measured absent.
The card asks the PR to say which reading holds, with a test for each
direction.
- **(i) A build stamp: absent.** `os build` writes `finalBundle = {
...result.data }` (`packages/cli/src/commands/compile.ts:995`), adds
`docs`/`packages` (`:1015`-`:1020`) and an optional `runtimeModule`
(`:1076`), and writes that (`:1095`). Nothing in it names the spec or
CLI that built it. The environment envelope's optional
`builtAt`/`builtWith`
(`packages/spec/src/system/environment-artifact.zod.ts:122`, `:125`) are
written by the control plane, and the door receives only the envelope's
`metadata` block (`packages/metadata/src/plugin.ts:1304`, `:1311`). Even
a version stamp could not order a prerelease build against a prerelease
retirement: both carry the same 17.7.0 label. So reading (i) would need
a new artifact field, which is a contract change, and this PR does not
take it.
- **(ii) Idempotence: holds over every retired step-18 entry, both
directions.** Each entry rewrites only its own old shape. The door
therefore replays the whole step for a `^18` floor. A prerelease build
from before a retirement boots, and one built after it comes back by
reference.
Measured on this tree, both directions, over all 66 retired step-18
entries rather than a sample, each with its own fixture pair carried in
a `^18` manifest, on a runtime labelled 17.7.0. These are permanent pins
in `artifact-forward-conversion.test.ts`:
- **Built before.** For each entry, the door turns its `before` fixture
into exactly its `after`, with exactly `expectedNotices`, all from that
entry. The one default flip in step 18,
`flow-decision-mode-inclusive-explicit`, is never replayed and comes
back by reference.
- **Built after.** For each entry's `after` fixture, the door replays
all 65 non-flip entries over it. The result is the same reference with
zero notices: the full 65 x 66 cross product.
One-off corroboration, not kept as tests. On HEAD the whole replayed
step goes over the real authored `^18` stacks of `examples/app-crm`,
`examples/app-todo` and `examples/app-showcase` (each loaded through
`defineStack`). Each run replays 65 entries, emits 0 notices and returns
the same reference. Across all 133 registry `after` fixtures, the only
fixture the step fires on is `field-mapping-transform-removed`. That is
a step-17 entry whose canonical shape still carries
`connectors[].fieldMappings`, which step 18 retires: the supersession
`SUPERSEDED_FIXTURES` declares in spec's `conversions.test.ts`.
## The card's acceptance
- **A `^18` artifact built before a prerelease retirement boots, with
the entry replayed and named in the boot log.** The subject is
`declared-index-unique-scope`, a step-18 retirement stamped at the
prerelease label. The strict parse refuses its old shape, bare `unique:
true` on a declared index, so without the replay the artifact does not
boot. Pinned in metadata-core at the labels 17.7.0, 18.0.0-next.0 and
18.0.0, and on the real door in
`packages/metadata/src/plugin-artifact-forward-conversion-major-step.test.ts`.
There the object registers with `unique: 'global'`, and one warning
reads `[MetadataPlugin] artifact 'prerelease-probe' declares the first
release of protocol 18 (authored engines.protocol floor 18.0.0), which a
build from any 18.0.0 prerelease declares too, so it may predate
retirements in the conversion step into protocol 18 that this runtime
enforces (runtime spec 17.7.0) — converted 1 site(s) forward via
ADR-0087 conversion 'declared-index-unique-scope' (first at
objects[0].indexes[0].unique) …`.
- **A `^18` artifact built after the retirement is unchanged.** The same
artifact with `unique: 'global'` gets verdict `'converted-major-step'`
(anti-vacuity: the window is open), zero notices, the same reference and
identical serialized bytes at all three labels. The real door logs
nothing for it.
- **The email-template keys #22994 measured.** #22990's entry, once it
lands in step 18 with its fixture pair, is covered by the whole-step
pins without edits here.
- **After 18.0.0 GA**, the case the card left unmeasured, is now
measured. With the label at 18.0.0 the floor equals the label, the
major-step half opens, and the prerelease-built artifact boots. This is
pinned beside the prerelease labels. From 18.0.1 on, the label half
replays the whole chain for it, as before (pinned).
- **CONTROL: the protocol-18 handshake is untouched.**
`checkProtocolCompat` still refuses `^17` with
`OS_PROTOCOL_INCOMPATIBLE` and `objectstack migrate meta --from 17`, and
admits `^18` (pinned). `protocol-handshake.ts` is not in the diff.
- **CONTROL: a 17.x floor keeps the per-entry verdict.** `^17.7.0` on a
17.7.0 label answers `'converted-retired-after'` with exactly the
entries stamped at or above 17.7.0. The older step-18 retirement
`permission-allow-restore-purge-removed` is not replayed for it. The
existing per-entry block passes unchanged.
- **Boundary:** a `^18.0.1` floor (not a first release) on a prerelease
runtime still meets the tombstone.
## Ablation (reverse verification, run once, not kept)
Run at commit `1de05c1aaf`, after the implementation and its tests were
committed. `node scripts/ablation-replace.mjs` replaced `const
firstReleaseOfMajor = floor[1] === 0 && floor[2] === 0;` with `false`
(anchor 1 -> 0, blob `3082b968` -> `62829437`). The suite ran 137 failed
/ 43 passed of 180. The first pin went red: `runtime labelled 17.7.0:
the subject replays …`. Both whole-step directions and the #15429
major-step leg went red as well. Restore: blob `3082b968` == HEAD, `git
diff HEAD` empty, marker count 0. The subject is imported from `src`
(relative import), so no `dist/` leg applies. Direction observed: red,
the expected one.
## One existing pin changed, and why
`REFUSES the amnesty for an artifact authored at a NEWER spec than the
runtime` modelled "newer" as `^18.0.0` on a 17.2.0 runtime and asserted
`'authored-current'`. When it was written, 18 was hypothetical. On this
tree 18 is the runtime's own protocol, and a `^18` artifact on a runtime
labelled below 18.0.0 is exactly #23001's case, so the pin was pinning
the defect. It now models "newer" as a floor past the label and every
`retiredAfter` that is not a first release
(`currentSurfaceFloor('17.2.0')`, today 17.7.1), and asserts that. The
assertion is unchanged.
## Beyond the claim's file surface
The claim named `artifact-forward-conversion.ts`, its tests and one
changeset. Two more files changed, both forced by the verdict:
- `packages/metadata/src/plugin.ts`. `FORWARD_WINDOW_OPENED` is a total
`Record` over `ArtifactForwardConversionVerdict`, so a new verdict does
not compile until it is placed on one side. It is placed on the open
side, so the bare-root form-predicate notice reads the same window as
the replay, which is that table's stated invariant.
`artifactWindowClause` gains the verdict's own clause. Under the
existing clauses the line would print either "predates this runtime's
spec (floor 18.0.0, runtime spec 17.7.0)" or "floor 18.0.0, at or below
the last release that accepted them (17.7.0)", and both are false.
-
`packages/metadata/src/plugin-artifact-forward-conversion-major-step.test.ts`
(new): the boot-log pin above.
Rollback: revert the PR. The window returns to two halves, and `^18`
prerelease artifacts are refused again.
## Tests (at `335aaf9ad7`, the head after merging `origin/main`
`4e6b270999`)
- `pnpm --filter @objectstack/metadata-core test`: 17 files, 537 passed.
`typecheck` (`tsc --noEmit && tsc --noEmit -p tsconfig.test.json`): exit
0, and the test program lists `artifact-forward-conversion.test.ts`.
- `pnpm --filter @objectstack/metadata exec vitest run`: 59 files, 874
passed. `typecheck`: exit 0, and `--listFiles` includes the new test.
- Dependency closure built first: `pnpm --filter
'@objectstack/metadata^...' build`, exit 0.
- Narrowed lint, a measurement rather than the full `pnpm lint` (CI's):
`eslint --no-inline-config --format json` over the 4 touched TS files
reports 4 files, 0 errors, 0 warnings, each file inside the config's
population (none reported as ignored). The config enables no type-aware
linting, so the diff cannot move a verdict on an untouched file.
- `@objectstack/runtime` (the door's other caller, which reads `verdict`
only into a debug log object): `vitest run
src/app-plugin-artifact-forward-conversion.test.ts
src/standalone-stack-seeder-declaration-copy.test.ts` against the built
`metadata-core` `dist/` (which carries the new verdict): 2 files, 24
passed. The rest of runtime's suite is left to CI.
## Gates (at `335aaf9ad7`)
The lead list of 62 at `6a7eca6868` plus the re-derivation on this diff
(`node scripts/pm/dispatch-gates.mjs --commands`) add
`check:durability-log-level` and `check:stack-collection-maps`, for 64
derived. All 64 exited 0. `check:dual-build-cjs-loads` and
`check:lean-entry-closure` first exited 3 (PREREQUISITE NOT MET: no
`dist/`). Once `check:type-check-debt`'s re-measure had built the
closure, both were re-run and exited 0. `--ran` reconciliation: 64
derived, 64 run, 0 NOT-MEASURED, 0 UNRUN.
## Acceptance notes
- **ADR-0087's 2026-09-13 addendum** (amended 2026-09-30) still states
the window as two disjuncts. It names the module docblock as the rule's
authority, and that docblock now carries the third, including the M2
caveat below. The ADR is a governed surface outside this card and is not
edited here.
- **M2 caveat, recorded in the docblock.** A retired key that later
becomes legal again (the `allowRestore`/`allowPurge` return) cannot be
protected for a bare-major floor by this half, nor by the label half,
which strips it for the same artifact one release later. The change that
re-legalizes a key decides that entry's fate at this door. The
default-flip refusal is the existing mechanism.
- **Component-prop retirements are not refused by the door's strict
parse.** On this tree `defineStack` accepted and kept `object-grid`
`properties.defaultFilters`. The carrier is `z.record`, and the per-type
props gate lives in `@objectstack/lint`. For those step-18 entries, a
pre-retirement artifact boots even without this change, but the retired
prop reaches registration and no renderer reads it. The replay restores
the canonical prop. `os validate`'s component-props gate is NOT MEASURED
here. This is noted, not filed (carrier: none).
---
_Generated by [Claude
Code](https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent edecd44 commit 58f5c90
5 files changed
Lines changed: 448 additions & 26 deletions
File tree
- .changeset
- packages
- metadata-core/src
- metadata/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
24 | 24 | | |
25 | 25 | | |
26 | 26 | | |
| 27 | + | |
27 | 28 | | |
28 | 29 | | |
29 | 30 | | |
| |||
191 | 192 | | |
192 | 193 | | |
193 | 194 | | |
194 | | - | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
195 | 203 | | |
196 | 204 | | |
197 | 205 | | |
| |||
665 | 673 | | |
666 | 674 | | |
667 | 675 | | |
| 676 | + | |
| 677 | + | |
| 678 | + | |
| 679 | + | |
| 680 | + | |
| 681 | + | |
| 682 | + | |
| 683 | + | |
| 684 | + | |
| 685 | + | |
| 686 | + | |
| 687 | + | |
| 688 | + | |
| 689 | + | |
| 690 | + | |
| 691 | + | |
| 692 | + | |
| 693 | + | |
| 694 | + | |
| 695 | + | |
| 696 | + | |
| 697 | + | |
| 698 | + | |
| 699 | + | |
| 700 | + | |
| 701 | + | |
| 702 | + | |
| 703 | + | |
| 704 | + | |
| 705 | + | |
| 706 | + | |
| 707 | + | |
| 708 | + | |
| 709 | + | |
| 710 | + | |
| 711 | + | |
| 712 | + | |
| 713 | + | |
| 714 | + | |
| 715 | + | |
| 716 | + | |
| 717 | + | |
| 718 | + | |
| 719 | + | |
| 720 | + | |
| 721 | + | |
| 722 | + | |
| 723 | + | |
| 724 | + | |
| 725 | + | |
| 726 | + | |
| 727 | + | |
| 728 | + | |
| 729 | + | |
| 730 | + | |
| 731 | + | |
| 732 | + | |
| 733 | + | |
| 734 | + | |
| 735 | + | |
| 736 | + | |
| 737 | + | |
| 738 | + | |
| 739 | + | |
| 740 | + | |
| 741 | + | |
| 742 | + | |
| 743 | + | |
| 744 | + | |
| 745 | + | |
| 746 | + | |
| 747 | + | |
| 748 | + | |
| 749 | + | |
| 750 | + | |
| 751 | + | |
| 752 | + | |
| 753 | + | |
| 754 | + | |
| 755 | + | |
| 756 | + | |
| 757 | + | |
| 758 | + | |
| 759 | + | |
| 760 | + | |
| 761 | + | |
| 762 | + | |
| 763 | + | |
| 764 | + | |
| 765 | + | |
| 766 | + | |
| 767 | + | |
| 768 | + | |
| 769 | + | |
| 770 | + | |
| 771 | + | |
| 772 | + | |
| 773 | + | |
| 774 | + | |
| 775 | + | |
| 776 | + | |
| 777 | + | |
| 778 | + | |
| 779 | + | |
| 780 | + | |
| 781 | + | |
| 782 | + | |
| 783 | + | |
| 784 | + | |
| 785 | + | |
| 786 | + | |
| 787 | + | |
| 788 | + | |
| 789 | + | |
| 790 | + | |
| 791 | + | |
| 792 | + | |
| 793 | + | |
| 794 | + | |
| 795 | + | |
| 796 | + | |
| 797 | + | |
| 798 | + | |
| 799 | + | |
| 800 | + | |
| 801 | + | |
| 802 | + | |
| 803 | + | |
| 804 | + | |
| 805 | + | |
| 806 | + | |
| 807 | + | |
| 808 | + | |
| 809 | + | |
| 810 | + | |
| 811 | + | |
| 812 | + | |
| 813 | + | |
| 814 | + | |
| 815 | + | |
| 816 | + | |
| 817 | + | |
| 818 | + | |
| 819 | + | |
| 820 | + | |
| 821 | + | |
| 822 | + | |
| 823 | + | |
| 824 | + | |
| 825 | + | |
| 826 | + | |
| 827 | + | |
| 828 | + | |
| 829 | + | |
| 830 | + | |
| 831 | + | |
| 832 | + | |
| 833 | + | |
| 834 | + | |
| 835 | + | |
| 836 | + | |
| 837 | + | |
| 838 | + | |
| 839 | + | |
| 840 | + | |
| 841 | + | |
| 842 | + | |
| 843 | + | |
| 844 | + | |
| 845 | + | |
| 846 | + | |
| 847 | + | |
| 848 | + | |
| 849 | + | |
| 850 | + | |
| 851 | + | |
| 852 | + | |
| 853 | + | |
| 854 | + | |
| 855 | + | |
| 856 | + | |
| 857 | + | |
| 858 | + | |
| 859 | + | |
668 | 860 | | |
669 | 861 | | |
670 | 862 | | |
| |||
756 | 948 | | |
757 | 949 | | |
758 | 950 | | |
| 951 | + | |
| 952 | + | |
| 953 | + | |
| 954 | + | |
| 955 | + | |
| 956 | + | |
| 957 | + | |
| 958 | + | |
| 959 | + | |
| 960 | + | |
| 961 | + | |
| 962 | + | |
| 963 | + | |
| 964 | + | |
| 965 | + | |
| 966 | + | |
| 967 | + | |
| 968 | + | |
759 | 969 | | |
760 | 970 | | |
761 | 971 | | |
| |||
0 commit comments