Repository navigation
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
Conversation
… step M's retired conversions replayed (#23001) The artifact door opened the retired window per entry by comparing the artifact's engines.protocol floor with each entry's retiredAfter. A ^18 artifact (floor 18.0.0) built during the v18 prerelease sat above every 17.x stamp, so it got none of the step-18 retirements replayed and failed to boot on any later runtime that enforced one. A floor of M.0.0 now also opens every retired entry whose toMajor is M (verdict 'converted-major-step'), safe because each entry rewrites only its own old shape. The metadata plugin places the verdict on the open side of its window table and names it in the boot-log clause. Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude <noreply@anthropic.com>
…ired step-18 entry (#23001) Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude <noreply@anthropic.com>
…nd names the conversion (#23001) Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude <noreply@anthropic.com>
…ator sees when a ^18 prerelease artifact boots (#23001) Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude <noreply@anthropic.com>
…erelease-artifact-window
📓 Docs Drift CheckThis PR changes 2 package(s): ⛔ 1 release-owned page(s) name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 17 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 47a2be3ccc9e0aa683251d0f764f440476778b0e && git checkout 47a2be3ccc9e0aa683251d0f764f440476778b0e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 9432bac077ecbfa703c09409f3fed416edd41d99 fa9ec1d75754ff456e83347ee018c27023b054cd && git checkout -B drift-repro 9432bac077ecbfa703c09409f3fed416edd41d99 && git merge --no-ff fa9ec1d75754ff456e83347ee018c27023b054cd
node scripts/docs-audit/affected-docs.mjs --json 9432bac077ecbfa703c09409f3fed416edd41d99
|
…ported verdict union gains a member (#23001) Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: ① Derived judgmentsInputs read: card #23001's body and its four comments (claim 6121333983 as corrected, os-dev-report 6122615428, seat answer 6122658288, patch round 1 6122690936); PR #23023's body, its one comment (docs-drift bot 6122548820), its five-file list and the net diff of Accept-set and public-surface changes the diff implies, each judged:
② Semver level
③ Boundary flagsRound-0 report (6122615428):
Round-1 report (6122690936):
Escalations for the seat (none blocks the verdict):
Check-runs on the head at the final read — green: Implemented-by: VERDICT: PASS Adopted by |
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'sengines.protocolfloor with each entry'sretiredAfter. A^18artifact 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^18artifact built before a prerelease retirement met the tombstone at the strict parse. The boot rolled back.The window now opens on a third condition:
A floor of
M.0.0at or above the runtime label replays every retired entry of the conversion step into protocol M (CONVERSIONS_BY_MAJOR[M], the stepobjectstack migrate meta --from M-1replays). The door's default flips are still refused by id, read first. The new verdict is'converted-major-step'.replayedRetirementskeeps listing only the entries the per-entry rule opened, each with theretiredAfterthe 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.
os buildwritesfinalBundle = { ...result.data }(packages/cli/src/commands/compile.ts:995), addsdocs/packages(:1015-:1020) and an optionalruntimeModule(:1076), and writes that (:1095). Nothing in it names the spec or CLI that built it. The environment envelope's optionalbuiltAt/builtWith(packages/spec/src/system/environment-artifact.zod.ts:122,:125) are written by the control plane, and the door receives only the envelope'smetadatablock (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.^18floor. 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
^18manifest, on a runtime labelled 17.7.0. These are permanent pins inartifact-forward-conversion.test.ts:beforefixture into exactly itsafter, with exactlyexpectedNotices, all from that entry. The one default flip in step 18,flow-decision-mode-inclusive-explicit, is never replayed and comes back by reference.afterfixture, 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
^18stacks ofexamples/app-crm,examples/app-todoandexamples/app-showcase(each loaded throughdefineStack). Each run replays 65 entries, emits 0 notices and returns the same reference. Across all 133 registryafterfixtures, the only fixture the step fires on isfield-mapping-transform-removed. That is a step-17 entry whose canonical shape still carriesconnectors[].fieldMappings, which step 18 retires: the supersessionSUPERSEDED_FIXTURESdeclares in spec'sconversions.test.ts.The card's acceptance
^18artifact built before a prerelease retirement boots, with the entry replayed and named in the boot log. The subject isdeclared-index-unique-scope, a step-18 retirement stamped at the prerelease label. The strict parse refuses its old shape, bareunique: trueon 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 inpackages/metadata/src/plugin-artifact-forward-conversion-major-step.test.ts. There the object registers withunique: '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) ….^18artifact built after the retirement is unchanged. The same artifact withunique: '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.checkProtocolCompatstill refuses^17withOS_PROTOCOL_INCOMPATIBLEandobjectstack migrate meta --from 17, and admits^18(pinned).protocol-handshake.tsis not in the diff.^17.7.0on a 17.7.0 label answers'converted-retired-after'with exactly the entries stamped at or above 17.7.0. The older step-18 retirementpermission-allow-restore-purge-removedis not replayed for it. The existing per-entry block passes unchanged.^18.0.1floor (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.mjsreplacedconst firstReleaseOfMajor = floor[1] === 0 && floor[2] === 0;withfalse(anchor 1 -> 0, blob3082b968->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: blob3082b968== HEAD,git diff HEADempty, marker count 0. The subject is imported fromsrc(relative import), so nodist/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 runtimemodelled "newer" as^18.0.0on 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^18artifact 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 everyretiredAfterthat 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_OPENEDis a totalRecordoverArtifactForwardConversionVerdict, 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.artifactWindowClausegains 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
^18prerelease artifacts are refused again.Tests (at
335aaf9ad7, the head after mergingorigin/main4e6b270999)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 listsartifact-forward-conversion.test.ts.pnpm --filter @objectstack/metadata exec vitest run: 59 files, 874 passed.typecheck: exit 0, and--listFilesincludes the new test.pnpm --filter '@objectstack/metadata^...' build, exit 0.pnpm lint(CI's):eslint --no-inline-config --format jsonover 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 readsverdictonly into a debug log object):vitest run src/app-plugin-artifact-forward-conversion.test.ts src/standalone-stack-seeder-declaration-copy.test.tsagainst the builtmetadata-coredist/(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
6a7eca6868plus the re-derivation on this diff (node scripts/pm/dispatch-gates.mjs --commands) addcheck:durability-log-levelandcheck:stack-collection-maps, for 64 derived. All 64 exited 0.check:dual-build-cjs-loadsandcheck:lean-entry-closurefirst exited 3 (PREREQUISITE NOT MET: nodist/). Oncecheck:type-check-debt's re-measure had built the closure, both were re-run and exited 0.--ranreconciliation: 64 derived, 64 run, 0 NOT-MEASURED, 0 UNRUN.Acceptance notes
allowRestore/allowPurgereturn) 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.defineStackaccepted and keptobject-gridproperties.defaultFilters. The carrier isz.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