feat(spec,metadata-core)!: every retired ADR-0087 conversion carries retiredAfter; the artifact door opens its window per entry (#20390) - #20435
Conversation
…he artifact window decides per entry MetadataConversion is now live-or-retired: a retired entry must carry retiredAfter, the last published spec version whose authoring surface still accepted its old shape. The 73 published retired entries are backfilled from the published tarballs (census committed beside the registry, re-derivable by scripts/build-retired-after-census.ts); the 20 unreleased ones carry 17.4.0. applyArtifactForwardConversions replays entry E when the artifact floor is below the runtime label OR at or below E.retiredAfter, so an artifact built by the last release boots on a main that enforces the next release's retirements. Claude-Session: https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH Co-authored-by: Claude <noreply@anthropic.com>
….0-built artifact Claude-Session: https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH Co-authored-by: Claude <noreply@anthropic.com>
…in manifest id Claude-Session: https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH Co-authored-by: Claude <noreply@anthropic.com>
…ersion is now a type alias Claude-Session: https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH Co-authored-by: Claude <noreply@anthropic.com>
…nterface-only disposition Claude-Session: https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH Co-authored-by: Claude <noreply@anthropic.com>
…rward-conversion-window
…rward-conversion-window # Conflicts: # packages/metadata-core/src/artifact-forward-conversion.test.ts
…17.4.0; pin the refusal list read before it The entry landed with #15429 after this branch forked; it is in no published tarball, so it carries the package label. The door still refuses it inside its own per-entry window, because DEFAULT_FLIPS_NOT_REPLAYED_HERE is read first. Claude-Session: https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH Co-authored-by: Claude <noreply@anthropic.com>
…rward-conversion-window
📓 Docs Drift CheckThis PR changes 3 package(s): 5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 140 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 705d94ad0b0f455f92817bcc5fc0430702546d14 && git checkout 705d94ad0b0f455f92817bcc5fc0430702546d14
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3cf64493899458632f87e661fff1b130bd3a8273 59687e3f2fbd435cbbab9b2df698cc3f88c6fc59 && git checkout -B drift-repro 3cf64493899458632f87e661fff1b130bd3a8273 && git merge --no-ff 59687e3f2fbd435cbbab9b2df698cc3f88c6fc59
node scripts/docs-audit/affected-docs.mjs --json 3cf64493899458632f87e661fff1b130bd3a8273
|
Contract reviewServed-tier: Inputs, and nothing else: card #20390 body and all nine comments ( ① Derived judgmentsThe ruling's seven execution parameters (
Accept-set and public-surface changes the diff implies:
Check-runs on the head, read at 2026-09-28T12:11Z: 27 success — ② Semver level
③ Boundary flags
Implemented-by: VERDICT: FAIL |
…rward-conversion-window
…e per-entry window MetadataPlugin reads which forward-conversion verdicts open the window from one total table over ArtifactForwardConversionVerdict, with 'converted-retired-after' on the open side, so the unbound form-predicate notice no longer waits for the package label to move. Under that verdict the conversion summary names the retirement this runtime enforces past the artifact's floor, carried on the result as replayedRetirements. The census refresh joins the GA release flow in docs/releases-maintenance.md. Claude-Session: https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH Co-authored-by: Claude <noreply@anthropic.com>
…lib predates ES2022 Claude-Session: https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH Co-authored-by: Claude <noreply@anthropic.com>
…rward-conversion-window
…rward-conversion-window
The entry landed with #20357 after this branch's last merge. It is in no published tarball, so it carries the package label (17.4.0, also npm latest). Claude-Session: https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Inputs, and nothing else: card #20390 body and all twelve comments (the nine the previous record read, plus What moved since the FAIL head ① Derived judgmentsRework order
The changed silence pin ("says NOTHING about an artifact authored against the current surface") — intent kept, guard not weakened. The old spelling, a caret on the installed label, is The stamp lap (
Carried from Accept-set and public-surface changes the diff implies, each named: (a) Check-runs on ② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
Fixes #20390
Clause-②: yes
Implements ruling
5865890672(batch #235 item 1, letter A, maintainer 「同意 A」; maintainer record5865873150, route5866178043): every retired entry in the ADR-0087 conversion registry carries a REQUIREDretiredAfter, and the artifact forward-conversion window decides per entry. It is one vertical PR acrosspackages/spec,packages/metadata-coreand the artifact door inpackages/metadata.Spec half
MetadataConversionis a live-or-retired union (packages/spec/src/conversions/types.ts). An entry withretiredFromLoadPath: truemust also carryretiredAfter, typed as a stablex.y.ztemplate-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, sogen:api-surfaceandgen:export-originseach rewrite one row:MetadataConversion (interface)becomesMetadataConversion (type).package.jsonlabel,17.4.0.src/conversions/retired-after.census.jsonholds raw facts per stable release since the registry first shipped (14.8.0 through 17.4.0): the tarball integrity and the ids itsALL_CONVERSIONSmarks retired.src/conversions/retired-after.census.test.tspins every entry's value against it, offline, in thelocaltier. 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.tsre-derives the census from registry.npmjs.org. It checks each tarball's integrity, imports each release'sdist/index.mjs, and writes the census, or compares it with--check.metadata-core half
applyArtifactForwardConversionsreplays entry E when the artifact's floor is below the runtime label OR at or belowE.retiredAfter.DEFAULT_FLIPS_NOT_REPLAYED_HEREis still read first. Its membership is unchanged;flow-decision-mode-inclusive-explicitcame 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 existingexcludeConversionIdsseam, computed per entry from the registry. There is no second table.ArtifactForwardConversionVerdictgains'converted-retired-after'for that case.ArtifactForwardConversionResultgainsreplayedRetirements(element typeArtifactReplayedRetirement): under that verdict, each retirement this runtime enforces past the artifact's floor, with itsretiredAfter; 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.
FORWARD_WINDOW_OPENED(a readonlyRecordkeyed by everyArtifactForwardConversionVerdictmember, valuedboolean), with'converted-retired-after'on the open side._warnUnboundFormPredicateRoots(the 17.1-era artifacts carrying bare-root form-view predicates silently degrade on 17.2: unbound root faults open, conditionally-hidden fields render and theirrequired: truedead-ends console record creation — no ADR-0087 conversion covers the predicate-root move #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. Onmaintoday, a 17.4.0-built artifact with a bare-root form predicate is announced now, not once the label reaches 17.5.0.'authored-current'/'runtime-version-unknown') defaults it to "open", and it would also admit'not-an-object'. A totalRecordover 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.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 17.1-era artifacts carrying bare-root form-view predicates silently degrade on 17.2: unbound root faults open, conditionally-hidden fields render and theirrequired: truedead-ends console record creation — no ADR-0087 conversion covers the predicate-root move #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: onmainit names an artifact built BY the last release. It now derives the firstx.y.zpast both the label and everyretiredAfter.The four pins
assignedProfilesboots onmainand logs the noticespackages/metadata/src/plugin-artifact-forward-conversion-retired-after.test.ts, on a REAL fixture:dist/objectstack.jsonbuilt verbatim by the published@objectstack/cli17.4.0chartConfig.type/xAxis/yAxisandassignedProfilesconverted away; one warn line each fordashboard-widget-chart-config-structure-removed(3 sites) andpage-assigned-profiles-removed(1 site)defineStackrefuses withcode: 'STACK_SCHEMA_INVALID',status: 422, and one issue per retired site (4 paths)packages/metadata-core/src/artifact-forward-conversion.test.tsauthored-current, zero notices, and the strict parse refuses the same 4 pathsmain(label 17.4.0), artifact at the last release (^17.4.0)converted-retired-after, notices by id and path, and the strict parse passesBeside pin (1), the #12915 pin (
plugin-artifact-forward-conversion-retired-after.test.ts, "announces a bare-root form predicate once"): the^17.4.0fixture 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
replayedRetirementsempty. A 17.2.0 retirement still meets its tombstone inside the open per-entry window.flow-decision-mode-inclusive-explicitstays refused inside its own per-entry window. Pin (4) also assertsreplayedRetirements: both retirements at17.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) andflow-decision-mode-inclusive-explicit(#20344, merged into this branch).retiredAfterThe 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 them15.0.0.Verification (final HEAD
2c537b7e)node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackgave 90 commands at2c537b7e(16 files, +1667/−72), and all 90 exit 0 on that head. The--ranreconciliation (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/spectest(--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-coretest: Test Files 16 passed (16), Tests 295 passed (295).typecheckexit 0.@objectstack/metadatatest: Test Files 55 passed (55), Tests 826 passed (826).typecheckexit 0.--no-inline-config --format jsonon the 10 changed source files: 10 files linted, 0 errors, 0 warnings.eslint.config.mjsnever enables type-aware linting, so this diff cannot move the verdict on any untouched file.scripts/pm/os-regen-merge.sh. None of this round's incoming commits touchpackages/spec/src/conversions,packages/metadata-coreorpackages/metadata, and none adds a retired entry: every one of the 94 carriesretiredAfter.Ablation and reverse verification (from committed state, through
scripts/ablation-replace.mjs)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 17.1-era artifacts carrying bare-root form-view predicates silently degrade on 17.2: unbound root faults open, conditionally-hidden fields render and theirrequired: truedead-ends console record creation — no ADR-0087 conversion covers the predicate-root move #12915 pin ("announces a bare-root form predicate once"). The rest stayed green, including the updated current-surface silence pin. Restore: blob8f43972cequals HEAD,git diff HEADis empty,git status --porcelainhas 0 lines, and all 21 tests pass again. The suites importplugin.tsfrom source, so no build sits between the mutation and the run.'converted-retired-after': truerow removed fromFORWARD_WINDOW_OPENED,tsc --noEmitinpackages/metadataexits 2 witherror TS2741: Property '"converted-retired-after"' is missing. Restored to the HEAD blob.87da6b88). The per-entry branch was replaced with the old label-only verdict, andmetadata-corewas 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).retiredAfterremoved frompage-assigned-profiles-removed, spectsc --noEmitexits 2 with exactly oneerror TS2322.build-retired-after-census.ts --checkpasses against npm (11 releases), and exits 1 on a tampered census.Deviations from the ruling text, and why
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 runscripts/build-retired-after-census.tsafter a stable@objectstack/specpublish and commit the refreshed census. The seat answered the refresh question with A; no workflow and no gate are added.5869635456).vitest.repo-tests.jsonis 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, andscripts/build-retired-after-census.tsre-derives it. The script refuses loudly when offline; it never skips. It is not apackage.jsonscript and not wired into CI, so no gate is added.-rcversions: a caret floor never names a prerelease, and the door comparesx.y.ztriples.Acceptance notes
packages/metadatanow carries apatchchangeset entry for the door change. It changes no public API; the 17.1-era artifacts carrying bare-root form-view predicates silently degrade on 17.2: unbound root faults open, conditionally-hidden fields render and theirrequired: truedead-ends console record creation — no ADR-0087 conversion covers the predicate-root move #12915 notice and the conversion summary wording follow the per-entry window.field-required-notnull-explicitappears retired in the 17.0.0 through 17.3.0 tarballs and is gone from 17.4.0 andmain, withdrawn by ADR-0087'sfield-required-notnull-explicitconversion asserts an implication ADR-0113 abolished — the boot calls it a forward conversion, but its output and the source it prescribes disagree at the storage layer #16693. The census test ignores ids not onmain.5869635456): ADR-0087's Artifacts built by released 17.x tooling are REFUSED by the 17.2 runtime: retired-key tombstones fire at artifact parse, and no artifact-ingestion door runs the ADR-0087 conversion that exists for exactly this #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.retiredAfter. That is the designed loud direction.mainnarrowedmanifest.id(underscores refused, [finding]ManifestSchema.idis a barez.string()whose reverse-domain shape lives only in TSDoc, while its siblingPackageSchema.manifestIdenforces that shape with a regex — one identifier, two declarations, only one of them machine-readable #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