feat(metadata): versioned ADR-0087 forward conversion at the artifact-ingestion door - #12843
Conversation
…artifact-ingestion door Artifacts built by released 17.x tooling carry then-legal keys (allowRestore/ allowPurge, retired in spec 17.2.0) and were refused by the strict parse in MetadataPlugin._parseAndRegisterArtifact — the retiredKey tombstone fired with no operator remedy, since 'os migrate meta' targets sources, not built artifacts. New policy in @objectstack/metadata-core (applyArtifactForwardConversions): replay the full ADR-0087 conversion chain — retired entries included — over an artifact whose declared engines.protocol floor predates the running spec version; an artifact authored at the current (or newer) surface converts nothing and still answers to the tombstone. Versioned, not a blanket strip: the retired keys return with M2 (#1883), and artifacts authored against that surface must never be stripped by history. The door (MetadataPlugin) applies it before every strict parse — bare definitions and environment-artifact envelopes — and surfaces notices operator-visibly, deduped per conversion per artifact, modeled on the stored-row pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SVYmuhHW6qZmNBqciaS7BN
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SVYmuhHW6qZmNBqciaS7BN
📓 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 — 29 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 783c4ae68a35a13950b951345965f9540d990ef7 && git checkout 783c4ae68a35a13950b951345965f9540d990ef7
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b489d3c725e86583410c20a91e59e0c5eb0ba255 7eff8966c224309fa6ba68e2b0143397cae9f2f5 && git checkout -B drift-repro b489d3c725e86583410c20a91e59e0c5eb0ba255 && git merge --no-ff 7eff8966c224309fa6ba68e2b0143397cae9f2f5
node scripts/docs-audit/affected-docs.mjs --json b489d3c725e86583410c20a91e59e0c5eb0ba255
|
…laration surface The artifact-forward-conversion module's public types referenced ConversionNotice from the @objectstack/spec root, so the emitted dist/index.d.ts imported the root entry - and every downstream type program reading metadata-core's declarations began loading the ~2MB spec root d.mts BESIDE the d.ts flavor it already read. Measured: the TEST_DEBT re-measure of packages/qa/http-conformance crossed CI's ~4GB tsc heap ceiling and OOM'd (listFiles diff between merge-base and branch: the only additions were spec/dist/index.d.mts and its chunk; the capped re-measure passes at the merge base and fails on the branch, same box, same command). Public surface now speaks ArtifactConversionNotice, a structural mirror pinned in both assignability directions in the module's test; the runtime applyConversions import stays and no longer reaches declaration emit. The rebuilt d.ts carries only the pre-existing spec/data subpath imports, and the capped (4096MB) http-conformance re-measure is green on this tree. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SVYmuhHW6qZmNBqciaS7BN
…al in the dispatcher vocabulary check:dispatcher-error-vocabulary flagged the new ArtifactConversionNotice.code literal in packages/metadata-core/src/artifact-forward-conversion.ts as an unclassified code-stamping site. Classified foreign-vocabulary beside the existing OS_METADATA_CONVERTED row for spec's apply.ts: the literal sits in a TYPE position of the structural mirror (declared to keep the spec ROOT import out of the package's public declaration surface), stamps nothing at runtime, and the notices flow to an onNotice callback exactly as in the classified spec site - nothing thrown, no envelope built. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SVYmuhHW6qZmNBqciaS7BN
|
Patch rounds on this PR (the body's Verification section describes the original union at
Union at head Generated by Claude Code Generated by Claude Code |
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 33140380738 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
…tifact-conversion-door
…sal surface under the #12845 residue ruling Main landed acceptRetiredDefaultResidue (maintainer ruling 2026-08-28): a retired DEFAULTED key's emitted default parses as inert residue and strips silently at the schema layer; only a NON-default value keeps the #12497 tombstone. The real 17.1-built fixture carries exclusively the emitted default (150 x false, premise-guarded), so the old current-version refusal pin now describes a shape the ruled contract accepts - the merge queue measured exactly that on its merge tree. The pin is re-aimed, not weakened: current-version + NON-default value (true, authorable on the 17.1 surface, never emitted by a default) still refuses with the tombstone and prescription; and a new pin holds the other half of the ruling at the door - current-version + pure residue parses clean with the keys stripped by the SCHEMA layer while this door's versioned window stays closed (no conversion notice), keeping the door's amnesty versioned. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SVYmuhHW6qZmNBqciaS7BN
|
Patch round 3 (merge-queue dequeue, CI_FAILURE on the queue's merge tree): the refuse-direction pin failed against latest main because main landed Fixed in
The door policy itself is unchanged and still load-bearing: the schema tolerance covers only the emitted default, while pre-17.2 artifacts may legally carry Union on the merged tree at head Generated by Claude Code Generated by Claude Code |
|
🤝 Handover pointer — this PR is code-complete and waiting on a tier-gated review, not on more work.
The incoming PM's step-by-step — tier proof, the exact delta to review (one test file; everything else in Generated by Claude Code |
`applyArtifactForwardConversions` — the function this card's fix consumes — does not exist on origin/main; it is added by PR #12843 (open, unmerged). Merged here so the AppPlugin change can be written and MEASURED against the same artifact door it must agree with. This merge disappears when #12843 lands.
Fixes #12772
What broke
Artifacts built by released 17.x tooling are refused by the 17.2 runtime. Measured through the framework's own artifact door on current main (
OS_ARTIFACT_URLboot of the real hotcrmdist/objectstack.json, built by released@objectstack/cli17.1.0, carrying 75allowRestore+ 75allowPurgeoccurrences its source contains zero of):The parse point is NOT
packages/cli(the triage's located neighbour,artifact-boot-migration.ts, is physical-schema drift policy only): every framework artifact door —OS_ARTIFACT_URL,OS_ARTIFACT_PATH, thedist/objectstack.jsonconvention, the HMR artifact reload, and the environment-artifact envelope — funnels toMetadataPlugin._parseAndRegisterArtifactinpackages/metadata, whose strictObjectStackDefinitionSchema.parsefires the tombstone.os migrate metatargets sources, not built artifacts, so an operator had no remedy.The fix — a versioned forward conversion, not an unconditional strip
Per the triage ruling: #12497 retired the keys with the M2 anchor open (#1883 remains open — the keys RETURN with M2), so an unconditional strip becomes wrong the day they are legal again. The conversion is keyed off the artifact's authored protocol declaration:
packages/metadata-core/src/artifact-forward-conversion.ts(applyArtifactForwardConversions, exported beside the protocol handshake it reads the declared range through): when the artifact's declaredengines.protocolfloor (leading version token, e.g.^17.1.0→ 17.1.0) is below the running@objectstack/specversion, replay the full ADR-0087 conversion chain — retired entries included — over the definition before the strict parse. This is exactly the stored-row policy (applyConversionsToStoredItem, ADR-0087 addendum), refined by the version evidence an artifact carries and asys_metadatarow does not.packages/specstays the single authority on WHAT converts — this PR consumesapplyConversionswithincludeRetired: trueand adds nothing to the registry. Nopackages/specorpackages/metadata-protocolchanges (the declared stop boundaries were not crossed;resolveDeclaredRangeinmetadata-coregained anexportkeyword so the policy reads the declared range through the handshake's own source-priority rather than a second opinion).packages/metadata/src/plugin.ts):_parseAndRegisterArtifactapplies the policy before every strict parse — bare definitions and both environment-artifact envelope shapes — and surfaces notices operator-visibly and deduped, modeled on the stored-row pass's surfacing: one summary line per conversion per artifact (the real artifact converts 150 sites; 150 identical warn lines would bury the boot log), naming the version evidence, the volume, the first path, the prescription, and the rebuild remedy.Both directions pinned
packages/metadata/src/plugin-artifact-forward-conversion.test.ts, on a real fixture — the manifest + permissions blocks verbatim from the 17.1-built repro artifact, 75+75 occurrences,engines.protocol^17.1.0, with a premise-guard test pinning those counts): parses, registers all six permission sets, strips exactly the two retired keys, preserves every other authored grant bit; the operator summary appears once and does not repeat on an HMR-style re-ingest.was removed in @objectstack/spec 17 (#12497, ADR-0049)and the standardized migrate sentence. This door answers no HTTP request (every refusal fires before a server binds), so the pin is on the tombstone message reaching the operator, not an ADR-0112 envelope.packages/metadata-core/src/artifact-forward-conversion.test.ts, 16 tests): both directions, newer-authored refusal, bare-major^17floor, undeclared range, unresolvable runtime version (window stays closed — amnesty needs positive version evidence), byte-preservation by reference identity (copy-on-write), idempotence, range-floor spellings.End-to-end positive control
Before (main, f75a38a):
OS_ARTIFACT_URL=file:///.../hotcrm/dist/objectstack.jsonboot refuses with the tombstone. After (this branch, rebuilt dist): the same boot prints the one-line conversion summary and completes —Migration complete (37 plugins started against configured DB), exit 0.Scope notes
OS_COMPOSED_ARTIFACT_URL) is the second consumer and is out of scope for this PR per triage; the accepting seat files the cloud follow-up card with a Blocked-by link here. The policy was landed in@objectstack/metadata-core(spec-only dependency, already the handshake's home) so that door can consume it rather than reimplement.AppPluginalso registers the raw bundle's security metadata in memory (registerInMemory), bypassing this conversion. Nothing strict-parses those copies (measured: the full boot is green, and the retired bits gate nothing by definition of the retirement), so no defect is reachable today; noted in the report for triage.Verification
Verification union ran at head
6d097a6(this PR's tree, clean): derived gate families vianode scripts/pm/dispatch-gates.mjs(no paths — change set derived from merge-base f75a38a), all 24 runnable derived + convention gates green at their own verdict lines;check:type-check-debt --re-measuregreen ("none above its recorded number") after building the full packages closure; full-repopnpm lint(eslint with--no-inline-config) exit 0;@objectstack/metadata-coretypecheck (src + test programs, new files confirmed present via--listFiles) and both package suites green (14 + 36 test files).scripts/pm/check-half-states.mjsis NOT MEASURED locally (exit 3, PREREQUISITE NOT MET — the container token is the proxy placeholder, not a GitHub credential); CI owns it.Generated by Claude Code
Generated by Claude Code