Uh oh!
There was an error while loading. Please reload this page.
fix(metadata): register a packages[] artifact per package at the metadata door so every object has one owner across every door (#14599) - #14643
Conversation
…data door
The metadata artifact door iterated the flattened top level and stamped every
item with the artifact's own `manifest.id`. For an artifact composed with
`composeStacks(…, { manifest: 'preserve' })` that id is one arbitrary member's
(`selectManifest`'s 'last' pick), so a two-package artifact registered the
module's object under the App package's identity while the ObjectQL load path,
reading the same artifact's `packages[]`, owned it under the module's — two
answers to "who owns this object", one per door.
`_parseAndRegisterArtifact` now reads both shapes (ADR-0130 D4): `packages`
present → register each assembled package body's collections stamped with THAT
body's id; `packages` absent → the single-`manifest` branch runs exactly as
before (D7). Ordering and the entry gate are reused from
`resolveArtifactPackageOrder`, the same call the ObjectQL load path makes (D5),
so the two readers of one `packages[]` cannot disagree.
That function and `artifactPackageId` moved from `@objectstack/objectql` to
`@objectstack/core`: objectql depends on metadata, so the door could not import
them from where they lived, and core already owns `resolvePluginOrder` and is
already a dependency of both readers — no package edge added. objectql
re-exports both under their existing names, so its published surface is
unchanged.
A residual sweep keeps any top-level definition no package body repeats
(`packages` composes by `concat`), registered once under the artifact's own
identity and logged, so nothing a booted instance sees today disappears.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m📓 Docs Drift CheckThis PR changes 4 package(s): 11 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 48 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 b849f950bf08389cc9003d42b6a3a6b001425dbc && git checkout b849f950bf08389cc9003d42b6a3a6b001425dbc
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ca48cf37724d85363cfcbd2e7c85abbb5550b5bb 423074c998138bcddec0a84b8c9bd855b09de0ce && git checkout -B drift-repro ca48cf37724d85363cfcbd2e7c85abbb5550b5bb && git merge --no-ff 423074c998138bcddec0a84b8c9bd855b09de0ce
node scripts/docs-audit/affected-docs.mjs --json ca48cf37724d85363cfcbd2e7c85abbb5550b5bb
|
hotlong
commented
Sep 2, 2026
Review — PASS (reader half of ADR-0130 D4; Clause-② NO)Disclosure: reviewer and dispatcher are the same session (
Non-blocking, recorded: the residual sweep's warn is the only signal when an artifact's two halves disagree; if #14512 lands B (producer stops emitting the flattened half for multi-package artifacts) the sweep becomes dead code and should go with it. Flipping ready, arming auto-merge (MERGE). Studio re-verification on the merged head follows. Generated by Claude Code |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#14599
Part of #14122
The reader half of ADR-0130 D4. The producer (
composeStacks/os build) is untouched and keeps emitting the flattened top level alongsidepackages[]— that is #14512's decision, not this PR's.The defect
MetadataPlugin._parseAndRegisterArtifactiterated the flattened top level and stamped every item with the artifact's ownmanifest.id. For an artifact composed withcomposeStacks(…, { manifest: 'preserve' })that id is one arbitrary member's —selectManifest's'last'pick — so a two-package artifact registered the module's object under the App package's identity, while the ObjectQL load path, reading the same artifact'spackages[], owned it under the module's. Two answers to "who ownscrm_order", one per door.What changed
_parseAndRegisterArtifactnow reads both shapes and attributes every item to the body it was found in:packagespresent → each assembled package body's collections are registered stamped with that body's id and version;packagesabsent → the single-manifestbranch runs exactly as before (D7).Three properties are load-bearing and each is stated in the source:
packages[i]body carry the same definitions #14512's triage rejected by name (its option C) for failing silently when it dangles.resolveArtifactPackageOrderthe ObjectQL load path calls, so two readers of onepackages[]cannot disagree about the order or about which artifacts are loadable at all.ARTIFACT_FIELD_TO_TYPEkey is a member ofAssembledPackageBodySchema, so iterating bodies loses no collection. And becausepackagescomposes byconcat, a residual sweep keeps any top-level definition no package body repeats — registered once, attributed to the artifact's own identity, and logged, because it means the artifact's two halves disagree about what it ships.Scope note:
resolveArtifactPackageOrdermoved to@objectstack/coreReusing it was not optional (D5, and the module's own header forbids a second sort "in any form"), and importing it where it lived was impossible: it was in
@objectstack/objectql, which depends on@objectstack/metadata.@objectstack/corealready ownsresolvePluginOrderand is already a dependency of both readers, so hosting it there adds no edge to the package graph.@objectstack/objectqlre-exportsresolveArtifactPackageOrder/artifactPackageId/ArtifactPackageErrorunder their existing names — its published surface is unchanged.packages/runtime/src/dispatcher-error-vocabulary.tshas its threefile:anchors repointed;pnpm check:dispatcher-error-vocabularyis green.Acceptance — measured on a real boot
examples/app-multi-package,objectstack dev --seed-admin -p 4599 -d file:SCRATCH/data.db, signed in asadmin@objectos.aiviaPOST /api/v1/auth/sign-in/email→ Bearer.1.
GET /api/v1/meta/object— one row per object, each with its own owner[{"name":"crm_account","_packageId":"com.example.multi.core","_packageVersion":null,"_provenance":"package"}, {"name":"crm_order","_packageId":"com.example.multi.orders","_packageVersion":null,"_provenance":"package"}]On the ablated tree (same boot, same DB path, door change reverted) the same read answers with the card's three rows:
[{"name":"crm_order","_packageId":"com.example.multi.orders","_packageVersion":"1.0.0","_provenance":"package"}, {"name":"crm_order","_packageId":"com.example.multi.orders","_packageVersion":null,"_provenance":"package"}, {"name":"crm_account","_packageId":"com.example.multi.core","_packageVersion":null,"_provenance":"package"}]2.
?package=returns exactly what that package owns{"?package=com.example.multi.core": [{"name":"crm_account","_packageId":"com.example.multi.core"}], "?package=com.example.multi.orders": [{"name":"crm_order","_packageId":"com.example.multi.orders"}]}Matching
GET /api/v1/packages, whose rows carrycore.manifest.objects = [crm_account]andorders.manifest.objects = [crm_order].3.
GET /api/v1/meta/object/crm_order/layers— and the item door agrees{"code._packageId":"com.example.multi.orders","code._packageVersion":"1.0.0","overlay":null, "effective._packageId":"com.example.multi.orders","lock":"none","provenance":"package", "packageId":"com.example.multi.orders"}{"item._packageId":"com.example.multi.orders","item._packageVersion":"1.0.0","packageId":"com.example.multi.orders"}(the item door was asked under
?package=com.example.multi.coreon purpose — the exact request the card shows disagreeing with the layers door.)4.
GET /api/v1/meta/app[{"name":"multi_crm","_packageId":"com.example.multi.core"}, {"name":"setup","_packageId":"com.objectstack.setup"}, {"name":"account","_packageId":"com.objectstack.account"}]The fixture's other collections:
orderscarries no views/flows/permission sets andcorecarries none beyond the app, somulti_crmis the whole of what acceptance 4 has to show.GET /api/v1/meta/viewanswers{"count":0}on both legs.5.
GET /api/v1/packages— unchanged[{"id":"com.example.multi.core","objects":["crm_account"],"writable":false}, {"id":"com.example.multi.orders","objects":["crm_order"],"writable":false}]6. Single-package control —
examples/app-todo, real boot, both legsCaptured through the same script on the fixed tree and on the ablated tree;
diffof the two recordings is empty:{"objectList":[{"name":"todo_task","_packageId":"com.example.todo","_packageVersion":null,"_provenance":"package"}], "appList":[{"name":"todo_app","_packageId":"com.example.todo"},{"name":"setup","_packageId":"com.objectstack.setup"},{"name":"account","_packageId":"com.objectstack.account"}], "viewCount":{"count":3,"byPackage":["com.example.todo"]}}Pins
packages/metadata/src/plugin-artifact-packages-attribution.test.ts(new, 7 cases) — a two-package fixture built by calling the real producercomposeStacks(…, { manifest: 'preserve' }), so it tracks the producer rather than pinning this door against a shape nothing emits. Asserts per-packagepackageId/packageVersionon registered items, that no slot is registered twice, topological (not array) order, the residual-sweep case, and the D7 control: the whole single-manifestmanager.registersequence as a literal.packages/qa/dogfood/test/multi-package-artifact.dogfood.test.ts(extended, +3 cases) — acceptance 1–3 over real HTTP.bootStackregisters noMetadataPlugin, so the existing block measures the ObjectQL registry alone; the new block boots the same stack with the artifact door mounted over a build-shaped artifact (artifactSource), the shapeshowcase-object-extension-meta-read.dogfood.test.tsestablished.Ablation
Predicted before running: direction RED. Unit pin 4 red / 3 green — red on per-package stamping, no-double-registration, topological order and the residual case; green on the premise guard, the "no warning" control and the D7 sequence. Dogfood 3 red / 5 green.
Mutation:
git checkout 2a2653619 -- packages/metadata/src/plugin.ts(the door change and nothing else; the module move and the tests stay).Proved on disk — blob hash
ac95906e…→0b22c5f2…; the three new markers went_registerArtifactBodyCollections3→0,resolveArtifactPackageOrder3→0,carriesPackages4→0, and the deleted textpackageId: manifestPackageIdcame back at 3 occurrences.Rebuilt on both legs — the dogfood suite resolves
@objectstack/metadatathroughdist/.node scripts/ablation-dist-preflight.mjs @objectstack/metadata '_registerArtifactBodyCollections' --absenton the mutate leg:✓ dist/: marker absent from all 24 built files. Same command without--absenton the restore leg:✓ dist/: marker present in 6 built files.Observed — exactly the prediction:
Restored by bytes —
git checkout HEAD -- ABSOLUTE_PATH,git hash-objectback toac95906ed193bcaf1adf2eb112fa01ea91a8b622(equal to the HEAD blob), whole-treegit status --porcelainempty,git diff HEADempty. Both pins re-run green afterwards (7/7 and 8/8).Gates
Union re-derived from the actual diff at
423074c99(clean tree, nothing uncommitted):node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands→ 43 commands, all run, exit codes captured before any pipe.check:dispatcher-error-vocabulary,check-system-context-census(anchors did not move — no--fixneeded),check:stack-collection-maps,check:test-source-alias,check:undeclared-dep-imports,check:type-source-resolution,check:published-files,check:empty-changeset,check:changeset-no-major.check-test-completenessandcheck-half-statesrefuse without an input CI supplies;check:dual-build-cjs-loadsneeds a whole-repopnpm build.pnpm check:nul-bytes→ OK (7980 files, no raw control bytes).Package suites, all on this tree:
@objectstack/metadata44 files / 684 tests,@objectstack/core47 / 1156,@objectstack/objectql262 / 4518,@objectstack/runtime209 / 3079 — all passed.pnpm --filter @objectstack/objectql --filter @objectstack/runtime run typecheckgreen (objectql'scheck:test-typecheckincluded).Clause-② assessment: NO
No accept/reject behaviour on any public contract changes. The artifact schema is untouched;
resolveArtifactPackageOrderalready gatedpackages[]entries on the ObjectQL path and now gates them identically at this door, so no artifact that loaded before is refused now and none that was refused is accepted. What changes is the attribution of registered items — and only for artifacts carryingpackages[], of which none exists in the field yet (#14512 records this explicitly: no customer artifact carries the key). For every artifact that does exist — single-manifest— the door's behaviour is byte-for-byte what it was, pinned as a literal register sequence and measured on a realexamples/app-todoboot on both ablation legs.🤖 Generated with Claude Code
https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m
Generated by Claude Code