Found during the post-landing verification of #14439 (PR #14513) on main7085f9053, booting examples/app-multi-package with objectstack dev --seed-admin. Verification-only finding — nothing fixed here. This is the "correctness edge … nothing exercises it today" that #14512 names, exercised: the two copies of one definition are attributed to different packages, and which owner a consumer sees depends on which door it went through.
Fixture
examples/app-multi-package — com.example.multi.core (type app, owns crm_account + app multi_crm) and com.example.multi.orders (type module, owns crm_order, lookup → crm_account), composed with composeStacks([ordersStack, coreStack], { manifest: 'preserve' }). The artifact's top-level manifest is core (the 'last' pick), packages[] carries both.
What the server answers (all authenticated as the seeded admin)
GET /api/v1/meta/object — crm_order appears twice, crm_account once. The two crm_order rows are not byte-identical (one carries _packageVersion, the other does not — two producers):
[{"name":"crm_order","label":"Order","_packageId":"com.example.multi.orders","_packageVersion":"1.0.0","_provenance":"package"},
{"name":"crm_order","label":"Order","_packageId":"com.example.multi.orders","_packageVersion":null,"_provenance":"package"},
{"name":"crm_account","label":"Account","_packageId":"com.example.multi.core","_packageVersion":null,"_provenance":"package"}]GET /api/v1/meta/object?package=com.example.multi.core — returns the module's object as well as its own:
[{"name":"crm_order","_packageId":"com.example.multi.orders"},{"name":"crm_account","_packageId":"com.example.multi.core"}]GET /api/v1/meta/object?package=com.example.multi.orders — correct: [{"name":"crm_order","_packageId":"com.example.multi.orders"}].
GET /api/v1/meta/object/crm_order/layers (same answer with ?package= either value) — the metadata service's copy says the owner is core:
{"type":"object","name":"crm_order","code":{"name":"crm_order","_packageId":"com.example.multi.core","_packageVersion":"1.0.0","_provenance":"package",…},"overlay":null,"effective":{…"_packageId":"com.example.multi.core"…},"lock":"none","provenance":"package","packageId":"com.example.multi.core"}GET /api/v1/meta/object/crm_order?package=com.example.multi.core — the single-item door says the owner is orders: {"item":{"name":"crm_order","_packageId":"com.example.multi.orders","_packageVersion":"1.0.0",…},"packageId":"com.example.multi.orders",…}.
So the platform holds two answers to "who owns crm_order": the layers door says com.example.multi.core, the item door and the list rows say com.example.multi.orders. GET /api/v1/packages itself is right (core.objects = [crm_account], orders.objects = [crm_order], both writable: false).
Both boots of the same DB answered identically (rows do not accumulate; this is in-memory attribution, no sys_metadata rows exist for either object).
What Studio shows (both the vendored console at the pinned objectui d8ec8d6d and objectui main7dedec6f)
The Data pillar for com.example.multi.core lists Order and Account (rail text Objects Order Account Read-only); the pillar for com.example.multi.orders lists only Order. ADR-0130 Consequences ("Studio's scope is the package, so package boundaries are the grouping Studio has never had (§1.3a)") does not hold for the App package: it shows the whole artifact. Screenshots are attached to the verification comment on #14439 (hmr-03-core-data.png, vendored-03-core-data.png).
Mechanism (read, not guessed)
MetadataPlugin._parseAndRegisterArtifact iterates the flattened top level and stamps every item with the artifact's manifest.id — packages/metadata/src/plugin.ts:929 (manifestPackageId = metadata.manifest.id), :1005 (applyProtection(item, { packageId: manifestPackageId, … })), :1010 (manager.register(metaType, name, item, { notify: false })). For this artifact that id is com.example.multi.core, so the metadata service's crm_order is stamped core. This is the copy the layers door serves.- The ObjectQL load path registers
packages[] per package in topological order — packages/objectql/src/plugin.ts:453 (ql.registerApp(manifest, scope)) — so the registry's owner of crm_order is com.example.multi.orders (registry.ts:2695 stamps _packageId from getObjectOwner). This is the copy the item door and one of the list rows serve. registry.getAllObjects(packageId) filters by contributors (registry.ts:2688), and the metadata-service copy stamped core is re-ingested into the registry as core's contribution to crm_order (an overlay contribution — objectContributionKind, owner ≠ packageId), which is why ?package=com.example.multi.core returns crm_order while the row still reports the owner as orders.- The list merge keys slots by
${packageId}${name} (packages/metadata-protocol/src/protocol.ts:1199), so the core-attributed copy and the orders-attributed copy land in two slots — the duplicate row.
getMetaItems for the list is runtime/src/domains/meta.ts → protocol.getMetaItems({ type, packageId }).
Expected
One owner per object across every door (com.example.multi.orders for crm_order), one row per object on GET /api/v1/meta/object, ?package=<id> returning exactly the objects GET /api/v1/packages says that package owns, and Studio's per-package Data rail matching that. #14512's option B (teach the metadata artifact door to read packages[] when present, and stop emitting the flattened copy) removes the cause at the root; a narrower fix is for the top-level iteration to stamp each item with its owning package (resolvable from packages[]) instead of the artifact's identity when packages[] is present. Single-package artifacts are unaffected either way (manifest.idis the owner there — D7).
Repro
pnpm exec turbo run build --filter='./examples/app-multi-package...'
cd examples/app-multi-package && ./node_modules/.bin/objectstack dev --seed-admin -p 4310 -d file:/tmp/mp/data.db
# sign in: POST /api/v1/auth/sign-in/email {"email":"admin@objectos.ai","password":"admin123"} → set-auth-token → Bearer
curl -H "Authorization: Bearer $T" 'http://localhost:4310/api/v1/meta/object' | jq '.items | map(select(.name|startswith("crm_"))) | map({name,_packageId,_packageVersion})'
curl -H "Authorization: Bearer $T" 'http://localhost:4310/api/v1/meta/object?package=com.example.multi.core' | jq '.items|map(.name)'
curl -H "Authorization: Bearer $T" 'http://localhost:4310/api/v1/meta/object/crm_order/layers' | jq '{code:.code._packageId, packageId}'
Related: #14512 (the format decision), #14439 / #14513 (the producer), #14122 (epic), ADR-0130 D4/D5 and Consequences §1.3a.
Found during the post-landing verification of #14439 (PR #14513) on
main7085f9053, bootingexamples/app-multi-packagewithobjectstack dev --seed-admin. Verification-only finding — nothing fixed here. This is the "correctness edge … nothing exercises it today" that #14512 names, exercised: the two copies of one definition are attributed to different packages, and which owner a consumer sees depends on which door it went through.Fixture
examples/app-multi-package—com.example.multi.core(typeapp, ownscrm_account+ appmulti_crm) andcom.example.multi.orders(typemodule, ownscrm_order, lookup →crm_account), composed withcomposeStacks([ordersStack, coreStack], { manifest: 'preserve' }). The artifact's top-level manifest is core (the'last'pick),packages[]carries both.What the server answers (all authenticated as the seeded admin)
GET /api/v1/meta/object—crm_orderappears twice,crm_accountonce. The twocrm_orderrows are not byte-identical (one carries_packageVersion, the other does not — two producers):[{"name":"crm_order","label":"Order","_packageId":"com.example.multi.orders","_packageVersion":"1.0.0","_provenance":"package"}, {"name":"crm_order","label":"Order","_packageId":"com.example.multi.orders","_packageVersion":null,"_provenance":"package"}, {"name":"crm_account","label":"Account","_packageId":"com.example.multi.core","_packageVersion":null,"_provenance":"package"}]GET /api/v1/meta/object?package=com.example.multi.core— returns the module's object as well as its own:[{"name":"crm_order","_packageId":"com.example.multi.orders"},{"name":"crm_account","_packageId":"com.example.multi.core"}]GET /api/v1/meta/object?package=com.example.multi.orders— correct:[{"name":"crm_order","_packageId":"com.example.multi.orders"}].GET /api/v1/meta/object/crm_order/layers(same answer with?package=either value) — the metadata service's copy says the owner is core:{"type":"object","name":"crm_order","code":{"name":"crm_order","_packageId":"com.example.multi.core","_packageVersion":"1.0.0","_provenance":"package",…},"overlay":null,"effective":{…"_packageId":"com.example.multi.core"…},"lock":"none","provenance":"package","packageId":"com.example.multi.core"}GET /api/v1/meta/object/crm_order?package=com.example.multi.core— the single-item door says the owner is orders:{"item":{"name":"crm_order","_packageId":"com.example.multi.orders","_packageVersion":"1.0.0",…},"packageId":"com.example.multi.orders",…}.So the platform holds two answers to "who owns
crm_order": the layers door sayscom.example.multi.core, the item door and the list rows saycom.example.multi.orders.GET /api/v1/packagesitself is right (core.objects = [crm_account],orders.objects = [crm_order], bothwritable: false).Both boots of the same DB answered identically (rows do not accumulate; this is in-memory attribution, no
sys_metadatarows exist for either object).What Studio shows (both the vendored console at the pinned objectui
d8ec8d6dand objectuimain7dedec6f)The Data pillar for
com.example.multi.corelists Order and Account (rail textObjects Order Account Read-only); the pillar forcom.example.multi.orderslists only Order. ADR-0130 Consequences ("Studio's scope is the package, so package boundaries are the grouping Studio has never had (§1.3a)") does not hold for the App package: it shows the whole artifact. Screenshots are attached to the verification comment on #14439 (hmr-03-core-data.png,vendored-03-core-data.png).Mechanism (read, not guessed)
MetadataPlugin._parseAndRegisterArtifactiterates the flattened top level and stamps every item with the artifact'smanifest.id—packages/metadata/src/plugin.ts:929(manifestPackageId = metadata.manifest.id),:1005(applyProtection(item, { packageId: manifestPackageId, … })),:1010(manager.register(metaType, name, item, { notify: false })). For this artifact that id iscom.example.multi.core, so the metadata service'scrm_orderis stamped core. This is the copy the layers door serves.packages[]per package in topological order —packages/objectql/src/plugin.ts:453(ql.registerApp(manifest, scope)) — so the registry's owner ofcrm_orderiscom.example.multi.orders(registry.ts:2695stamps_packageIdfromgetObjectOwner). This is the copy the item door and one of the list rows serve.registry.getAllObjects(packageId)filters by contributors (registry.ts:2688), and the metadata-service copy stamped core is re-ingested into the registry as core's contribution tocrm_order(anoverlaycontribution —objectContributionKind, owner ≠ packageId), which is why?package=com.example.multi.corereturnscrm_orderwhile the row still reports the owner as orders.${packageId}${name}(packages/metadata-protocol/src/protocol.ts:1199), so the core-attributed copy and the orders-attributed copy land in two slots — the duplicate row.getMetaItemsfor the list isruntime/src/domains/meta.ts→protocol.getMetaItems({ type, packageId }).Expected
One owner per object across every door (
com.example.multi.ordersforcrm_order), one row per object onGET /api/v1/meta/object,?package=<id>returning exactly the objectsGET /api/v1/packagessays that package owns, and Studio's per-package Data rail matching that. #14512's option B (teach the metadata artifact door to readpackages[]when present, and stop emitting the flattened copy) removes the cause at the root; a narrower fix is for the top-level iteration to stamp each item with its owning package (resolvable frompackages[]) instead of the artifact's identity whenpackages[]is present. Single-package artifacts are unaffected either way (manifest.idis the owner there — D7).Repro
Related: #14512 (the format decision), #14439 / #14513 (the producer), #14122 (epic), ADR-0130 D4/D5 and Consequences §1.3a.