Repository navigation
fix(metadata-core): the artifact forward conversion walks every packages[].manifest body of an option-B artifact (ADR-0130 D4) - #23045
Conversation
…ges[].manifest body An option-B artifact (ADR-0130 D4) carries its collections in package bodies, which the strict parse gates entry by entry. The ADR-0087 conversion walked only the definition's own collections, so a retired key inside a body refused the boot. The same walk (same registry, same window, decided once from the artifact's own engines.protocol) now runs over each body as a second root, and a body's notices are located under its package. Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude <noreply@anthropic.com>
…ce path doc names the body form Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude <noreply@anthropic.com>
…ckage-body-conversion
📓 Docs Drift CheckThis PR changes 2 package(s): 10 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 27 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 9173b6cb975ff32c0ebc76d1e27ec90e7bf9e0eb && git checkout 9173b6cb975ff32c0ebc76d1e27ec90e7bf9e0eb
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6a6a4d3e9c777116708486b48e0fc985cd788667 20a5c41cfb130c7a36778b85f8d5701af92c63c6 && git checkout -B drift-repro 6a6a4d3e9c777116708486b48e0fc985cd788667 && git merge --no-ff 20a5c41cfb130c7a36778b85f8d5701af92c63c6
node scripts/docs-audit/affected-docs.mjs --json 6a6a4d3e9c777116708486b48e0fc985cd788667
|
…ckage-body-conversion
… once, before mergeRuntimeModule and every reader An option-B artifact built before a retirement was refused by the runtime's first reader of packages[] (createStandaloneStack, through resolveArtifactCollections), before the metadata door's own conversion ran. The loader now runs applyArtifactForwardConversions once on the loaded bundle, so every runtime reader sees the converted package bodies. The AppPlugin comment that said no conversion reaches the bodies is corrected. Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude <noreply@anthropic.com>
…ugh the loader's function; door test for option-B boots readConfigDeclaredDefault reads the artifact file itself. With the loader now converting, a body carrying a retired key was declined there and booted by the loader against the unified default database instead of the declared one; the resolver now calls convertArtifactAtLoad on what it read. The runtime door test pins the option-B boot (single and multi-package), the definition-collection and window-shut controls, the conversion's idempotence on the loader's output, and the resolver. Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude <noreply@anthropic.com>
…s at os dev -a and os start Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude <noreply@anthropic.com>
…ckage-body-conversion
… test layer compiles under tsconfig.test.json Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Inputs read, and nothing else: card #23027 (body; triage 6122964017; claim 6123839199; report 6125201870; seat answer and claim amendment 6125260474; report 6127009648), PR #23045 (body, the 8-file list, the net diff against ① Derived judgments
② Semver level
③ Boundary flags
Adopted by Implemented-by: VERDICT: PASS |
Fixes #23027
Clause-②: no
What this changes
Two halves, one policy. Both use the one ADR-0087 conversion function, under one window.
1.
@objectstack/metadata-core, the conversion's reach.applyArtifactForwardConversions(artifact-forward-conversion.ts) ran the conversion over the definition's own collections only. A multi-package artifact (ADR-0130 D4, option B) carries its collections inpackages[i].manifest, and the strict parse gates each body. Now ONE walk runs over every root: the definition first, then each body. It is the sameapplyConversionscall, with the same registry, the sameincludeRetiredand the sameexcludeConversionIds. There is no second walk and no entry list of its own. Copy-on-write holds at both levels. A malformedpackagesor entry comes back as given: this never gates, andresolveArtifactPackageOrderstays the refusal. A body's notices are located from the root:path, and the one placemessagequotes it, readpackages[1].manifest.dashboards[0].refreshIntervalSeconds.2.
@objectstack/runtime, converting once at load. The runtime read the bundle raw before any conversion. Onos dev -aandos startthe first reader to refuse wascreateStandaloneStack, throughresolveArtifactCollectionsandresolveArtifactPackageOrder.loadArtifactBundlenow callsconvertArtifactAtLoadonce on the loaded bundle, beforemergeRuntimeModuleand before any reader. Measured, every boot-path reader goes through it:createStandaloneStack's surfaced stack,AppPlugin'scollectionsgetter,AppPlugin.init'spackages[]branch (and from there themanifestservice),mergeRuntimeModuleandos verify's loader. One reader reads the FILE by another path,readConfigDeclaredDefaultinresolve-project-database.ts, and it now calls the same function on what it read. That is one function at each of the boot's two disk reads; no reader converts a bundle it was handed (option C, refused).MetadataPlugin's own conversion of the same file stays and is idempotent; the idempotence is pinned once. The onlyapp-plugin.tschange is the one comment sentence that said package bodies were what "no conversion reaches".convertArtifactAtLoadis a module export, not a package export.packages/runtime/src/index.tsandpackages/metadata-core/src/index.tsare unchanged, anddist/index.d.tscarries no such name, so no exported member changes (Clause-②: no, re-checked on the real diff).Which window governs a body: the artifact's (measured; accepted by the seat)
Measured on
os buildofexamples/app-multi-package: the artifact's ownmanifestis its LAST package's, and each body carries its ownengines.protocol. The window is decided once from the artifact's ownengines.protocol, and a body's own range is not read. Why:os buildserializes every body (ADR-0130 D1).manifest.Both directions are pinned.
The public door, before and after
0ede0f8ab3os dev -a optionb.json --fresh(two-packageos buildoutput,refreshInterval: 300in a body)000: "packages[1]is not a package entry (ADR-0130 D4): manifest.dashboards.0.refreshInterval…"200;GET /api/v1/meta/dashboard/crm_overviewand the list serverefreshIntervalSeconds: 300,valid: true,_packageId: com.example.multi.coreos start -a optionb-multi.json(both bodies carry the key, 300 and 120)createStandaloneStackreader200; both are served converted, each under its own packageAppPluginfinds nothing left to convert.packages[])200, served300valid200, item and list300validThe database resolver, measured at the door. Without
--fresh, take an option-B artifact whose body declares its default datasource (datasourceMappingdefault: trueand a sqlitedatasourcesentry) and still carriesrefreshInterval. With only the loader converting, it booted against the unified default database, because the resolver's own read refused the body and declined. The same artifact with the canonical key opened the declaredcrm.db. After the resolver called the loader's function, both opencrm.db. This is why the resolver is routed: converting at load alone would have turned a loud refusal into a silent wrong-database boot.Config boot (
composeStacks) of the same shape:os devexits before the server starts, atdefineStack, with the tombstone prescription.composeStackstakes onlydefineStackinputs, and that is the authoring funnel, by design. No fix is needed.Tests, at the final head
Final head
20a5c41cfb. All of it went throughos-verify-lock.sh, and the VERDICT lines were read.pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2at6a01653b53: 361 files passed; 5898 tests passed, 19 skipped. The one later commit types a literal in the new test file. That file re-ran at20a5c41cfb: 6/6.pnpm --filter @objectstack/runtime typecheckat20a5c41cfb: exit 0, "check:test-typecheck: OK … 27 file(s) / 190 error(s) / 68 pinned signature(s) held". At6a01653b53this was the CI red: the new test file had one TS2345 (an untyped union literal) in a file the ledger does not cover. Fixed in20a5c41cfb.Type Check · workspace, reproduced locally as a pull_request event at20a5c41cfb(the step's own selector,--concurrency=2added locally): 65 affected packages, "Tasks: 130 successful, 130 total". On GitHub at20a5c41cfb,Type Check · workspaceandTypeScript Type Checkboth read success.pnpm --filter @objectstack/metadata-core test: 18 files, 681 tests passed.pnpm --filter @objectstack/metadata exec vitest run: 60 files, 879 tests passed. Both typechecks exit 0.packages/runtime/src/standalone-stack-option-b-forward-conversion.test.ts(6 tests, realcreateStandaloneStackplus kernel boot, read throughgetMetaItems/getMetaItemas the HTTP door does):packages[0].manifest.dashboards[0].refreshInterval: 300is listed and served asrefreshIntervalSeconds: 300, valid, under the orders package;INVALID_ARTIFACT_PACKAGE_ENTRY/422, namingpackages[0]andmanifest.dashboards.0.refreshInterval;artifact-forward-conversion-package-body.test.ts(metadata-core, 142 tests, including all 66 retired step-18 entries over a body, both directions) andplugin-artifact-forward-conversion-package-body.test.ts(the metadata door, 5 tests).eslint --print-configgives 6/5/5/6/6/6/5 rules in effect. Count:--format jsonreports 7 files, 0 errors, 0 warnings. Invariance: everyparserOptionsis{ ecmaVersion, sourceType }with noproject, so there is no type-aware linting.pnpm lintis CI's to run.Ablations
Every leg went through
scripts/ablation-replace.mjsin wrap mode. The direction was predicted before each run. Each restore was proven by blob == HEAD withgit diff HEADempty.bundle = convertArtifactAtLoad(bundle)becamevoid convertArtifactAtLoad; blobe49e71c00328→b2afed453050). The runtime door file went red: the loader leg failed, and the subject and multi-package suites failed inbeforeAllwith the public door's own refusal, "Release artifactpackages[0]is not a package entry (ADR-0130 D4): manifest.dashboards.0.refreshInterval…", which skipped their 2 tests. The CONTROL, window-shut and resolver legs stayed green, as did all ofapp-plugin-converted-collections.test.ts(Tests 1 failed, 11 passed, 2 skipped, out of 14). The metadata-core pins stayed green: 324 passed out of 324.a970de19be5c→41d80e491454). Only the resolver leg went red: "expected [ 'unified-default', … ] to deeply equal [ 'config-datasource', … ]". The other 5 stayed green.dist/gave 3 red and 27 green out of 30, with a rebuild andablation-dist-preflighton both legs (--absenton the restore).Gates
dispatch-gates --commandson the real diff at20a5c41cfb(8 paths, merge basedf7a13792) gives 65 commands, a superset of the lead list's 64. All 65 exit 0 at20a5c41cfb.dispatch-gates --ranreads "65 derived famil(ies) accounted for — 65 run, 0 NOT-MEASURED". New since the first half:check:swallow-census-controls(exit 0).check:dual-build-cjs-loadsmeasured on the first run this time (full build), with exit 0.Acceptance notes
detectUnboundFormViewPredicateRoots(form-predicate-root-policy.ts) reads only the definition's ownviews, so the bare-root form-predicate notice does not see an option-B body's views. It was read, not measured at a door, and it stays a note.origin/mainwas merged once before the gates (6a01653b53). It moved by two more commits during the gate run (6a6a4d3e9c); the derivation namescheck-durability-degradation-log-level.mjsandmeasure-durability-swallow-family.mjsas changed there. CI reads the merge ref.Generated by Claude Code