Skip to content

Commit 655b106

Browse files
hotlongclaude
andauthored
fix(metadata): register a packages[] artifact per package at the metadata door (#14643)
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. Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8ed9c54 commit 655b106

11 files changed

Lines changed: 786 additions & 20 deletions
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
---
2+
"@objectstack/metadata": patch
3+
"@objectstack/core": minor
4+
"@objectstack/objectql": patch
5+
"@objectstack/runtime": patch
6+
---
7+
8+
fix(metadata): register a `packages[]` artifact per package at the metadata door so every object has one owner across every door (#14599)
9+
10+
A release artifact carrying `packages[]` (ADR-0130 D4) was read at the metadata
11+
door as if it carried one package: `MetadataPlugin._parseAndRegisterArtifact`
12+
iterated the **flattened top level** and stamped every item with the artifact's
13+
own `manifest.id`. For an artifact composed with `composeStacks(…, { manifest:
14+
'preserve' })` that id is one arbitrary member's — `selectManifest`'s `'last'`
15+
pick — so a two-package artifact registered the **module's** object under the
16+
**App** package's identity, while the ObjectQL load path, reading the same
17+
artifact's `packages[]`, owned it under the module's.
18+
19+
The platform then held two answers to "who owns this object", and which one a
20+
consumer saw depended on the door it went through. Measured on a real boot of
21+
`examples/app-multi-package`:
22+
23+
- `GET /api/v1/meta/object` served `crm_order` **twice** — the list merge keys
24+
slots by `${packageId}${name}`, so the two differently-attributed copies
25+
landed in two slots;
26+
- `GET /api/v1/meta/object?package=<the App package>` returned the **module's**
27+
object, because the App-stamped copy was re-ingested into the registry as that
28+
package's contribution;
29+
- the layers door named the App package while the item door and
30+
`GET /api/v1/packages` named the module;
31+
- Studio's Data pillar for the App package listed the module's object — ADR-0130
32+
Consequences §1.3a ("Studio's scope is the package") did not hold.
33+
34+
**The door now reads both shapes, and attributes every item to the body it was
35+
found in.** `packages` present → each assembled package body's collections are
36+
registered stamped with **that body's** id; `packages` absent → the single
37+
`manifest` branch runs exactly as before (D7). The owner is read off the body an
38+
item was found in — never reverse-derived by matching a top-level item's name
39+
against a name-to-package index, which would be the second metadata-identity
40+
resolution path #14512's triage rejected by name.
41+
42+
**Ordering and the entry gate are reused, not re-derived (D5).** The door calls
43+
the same `resolveArtifactPackageOrder` the ObjectQL load path calls, so the two
44+
readers of one `packages[]` cannot disagree about the registration order **or**
45+
about which artifacts are loadable at all.
46+
47+
⚠️ **`resolveArtifactPackageOrder` / `artifactPackageId` moved to
48+
`@objectstack/core`** — hence the `minor` there. They were in
49+
`@objectstack/objectql`, which **depends on** `@objectstack/metadata`, so the
50+
metadata door could not import them from where they lived; `@objectstack/core`
51+
already owns `resolvePluginOrder` and is already a dependency of both readers,
52+
so hosting them there adds **no edge** to the package graph. `@objectstack/objectql`
53+
re-exports both under their existing names — its published surface is unchanged,
54+
which is why it is graded `patch`. `@objectstack/runtime` is `patch` for the
55+
dispatcher error vocabulary's `file:` anchors, repointed at the new path.
56+
57+
**Single-package artifacts are byte-for-byte unaffected (D7)**, measured rather
58+
than asserted: the whole `manager.register` sequence for a single-`manifest`
59+
artifact — every call, in order, with the id and version each item was stamped
60+
with — is pinned as a literal in
61+
`packages/metadata/src/plugin-artifact-packages-attribution.test.ts` and was
62+
recorded identically on both legs of the ablation. A real boot of
63+
`examples/app-todo` answers every door identically before and after.
64+
65+
**Nothing a booted instance can see today disappears.** Every live
66+
`ARTIFACT_FIELD_TO_TYPE` key is a member of `AssembledPackageBodySchema`
67+
(measured, not assumed), so iterating bodies loses no collection; and because
68+
`packages` composes by `concat`, an artifact whose top level carries a
69+
definition no package body repeats keeps it — registered once, attributed to the
70+
artifact's own identity, and logged, because it means the artifact's two halves
71+
disagree about what it ships.
72+
73+
⛔ The **producer** half is untouched: `composeStacks` and `os build` keep
74+
emitting the flattened top level alongside `packages[]`. Whether they should is
75+
#14512's decision, not this door's.

packages/objectql/src/artifact-packages.ts renamed to packages/core/src/artifact-packages.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,26 @@
99
* turns an artifact — either shape — into the ordered list of manifests the
1010
* load path registers.
1111
*
12+
* ## Why this lives in `@objectstack/core` and not next to one of its readers
13+
*
14+
* There are TWO readers of `packages[]`, not one, and they sit in packages that
15+
* cannot import each other: `ObjectQLPlugin` (`@objectstack/objectql`, which
16+
* calls `registerApp` per package) and `MetadataPlugin`
17+
* (`@objectstack/metadata`, whose artifact door registers each package body's
18+
* collections stamped with that body's id). `@objectstack/objectql` depends on
19+
* `@objectstack/metadata`, so the metadata door physically cannot import this
20+
* module from where it started life.
21+
*
22+
* The alternative — a second read of `packages[]` inside the metadata door —
23+
* would have split more than the sort: this function is also the GATE (it
24+
* parses each entry against `ArtifactPackageSchema` and refuses a duplicate
25+
* package id), so two readers would have disagreed about which artifacts are
26+
* loadable, not just about what order to load them in. `@objectstack/core`
27+
* already owns `resolvePluginOrder` and is already a dependency of both
28+
* readers, so hosting it here adds NO package edge to the graph. ⛔ Do not
29+
* re-home this next to either reader; the next reader will have the same
30+
* problem.
31+
*
1232
* ## Both shapes are read (D4), and the fallback is the compatibility mechanism
1333
*
1434
* - `packages` present → iterate it.
@@ -102,7 +122,7 @@
102122
* missing-dependency semantics are re-adjudicated here.
103123
*/
104124

105-
import { resolvePluginOrder, type OrderablePlugin } from '@objectstack/core';
125+
import { resolvePluginOrder, type OrderablePlugin } from './plugin-order.js';
106126
import { ArtifactPackageSchema } from '@objectstack/spec';
107127

108128
/**

packages/core/src/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,13 @@
1010
export * from './kernel-base.js';
1111
export * from './kernel.js';
1212
export * from './plugin-order.js';
13+
// ADR-0130 D4/D5 — the ONE reader of a release artifact's `packages[]`, and the
14+
// ONE place it is ordered. It lives here rather than beside a reader because it
15+
// has two of them in packages that cannot import each other (`@objectstack/
16+
// objectql`'s load path and `@objectstack/metadata`'s artifact door), and
17+
// because the ordering it performs is `resolvePluginOrder` directly above.
18+
// `@objectstack/objectql` re-exports it, so its published surface is unchanged.
19+
export * from './artifact-packages.js';
1320
export * from './lite-kernel.js';
1421
export * from './types.js';
1522
export * from './logger.js';

0 commit comments

Comments
 (0)