Skip to content

Commit be78977

Browse files
feat(install): a declared dependency co-owns its parent's namespace, and both boot hydrates sort by it (ADR-0141 D5) (#26054)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XJ5XLtgWVaBQg2nkK9T9j9j)_ Part of #25792 Clause-②: yes (narrowing) Metadata-protocol: none ## Summary ADR-0141 D5, card items 4 and 5, plus the two review notes from #25986. **Item 4: a declared dependency is a second co-owner source** (`packages/objectql/src/registry.ts`). `SchemaRegistry.installPackage` builds one co-owner map, `coOwnersOf`, and both halves of the ADR-0130 D3 pair read it: the namespace gate and `refuseCoOwnedObjectNameCollision`. For a non-shareable namespace N used by the incoming package P, the map holds: - `artifact`: the artifact's other packages, exactly as before; - `dependency`: an installed X that P declares and that holds N, or a sitting owner of N that declares P. This is the same relation read from the other end, so an upgrade or reinstall of the app beside its modules, or a boot that hydrated a module first, is admitted; - `sibling`: a sitting owner S of N, when S and P each directly declare one installed package that holds N. Only direct declarations count. The shared parent's namespace is read from its installed record, so two packages that declare one common package co-own nothing unless that package is installed and holds N. `ArtifactObjectNameConflictError` keeps `DUPLICATE_ARTIFACT_OBJECT_NAME` / 422. Its message is now worded per source (same artifact, declared dependency, sibling), so a cross-artifact refusal never claims one artifact, and it carries `coOwnership`. `NAMESPACE_CONFLICT` now names the declaration as the fix. **Item 5: hydrate order** (`packages/core/src/hydration-order.ts`, new). `orderHydrationBatch` sorts a batch with `resolvePluginOrder`, using declared ids as optional edges. This is the reading `resolveArtifactPackageEntries` already gives the key; there is no second sorter. A node lists every package id it carries. A cycle's internal edges are dropped before sorting, found with Tarjan's algorithm, so its members keep source order while still following their acyclic dependencies. The cycles are returned for the caller to log, and nothing throws. Both hydrates use it: - the `sys_packages` hydrate in `packages/services/service-package/src/index.ts`, where one row is one node; - the install-local `kernel:ready` rehydrate in `packages/cloud-connection/src/marketplace-install-local-plugin.ts#inDependencyOrder`, where one ledger entry is one node. A declared id resolves to the entry that carries that package, and an id declared between packages of one entry is no edge. `declaredDependencyIds` (`packages/core/src/artifact-packages.ts`) is the one record-only reading of `manifest.dependencies` that the registry and both hydrates share. **Review notes from #25986:** - An undecided union whose installed version is readable (`<5 || latest` against `7.0.0`) now reports `found: 'unreadable-range'` instead of naming the version. - Install-local no longer counts the artifact wrapper's own id as delivered. `DeclaredDependencyCheck.deliveredIds`, which had no other caller, is removed. **Docs:** - `content/docs/deployment/app-dependencies.mdx` now says that two modules of one app need not declare each other. It also explains namespace co-ownership by declaration and its object-name rule, the companion precondition, and the boot order with its cycle log. - The `plugin-spec.mdx` consumer callout now names five consumers. ## Declarations `Clause-②: yes (narrowing)`. The widening: install acceptance (packages previously refused `NAMESPACE_CONFLICT` now install) and new `@objectstack/core` exports (`orderHydrationBatch`, `declaredDependencyIds`, `HydrationNode`, `HydrationOrder`). The narrowing: install-local refuses a module that declares only its artifact wrapper's id, and the `deliveredIds` option is removed. Both of those belong to the D2 check, which #25986 landed and which is still unreleased (its changeset is pending on main). The changeset is `patch`, with a BREAKING banner and an ADR-0087 `not-required (no-migration-prescription)` marker. `Metadata-protocol: none`. No key, accepted value or metadata type changes in what an author can write. ## Tests Red first: each new suite failed on main before its wiring. | Suite | Before the fix | After the fix | |:---|:---|:---| | `packages/objectql/src/registry-artifact-co-ownership.test.ts`, new describe extending the ADR-0130 D3 gate test, real load path through `manifest.register()`; the sixth case is a negative control | 5 failed, 10 passed | 15 passed | | `packages/services/service-package/src/hydration-dependency-order.test.ts`, real `SchemaRegistry` | 2 failed | 2 passed | | `packages/cloud-connection/src/marketplace-install-local-rehydrate-order.test.ts` | 2 failed | 2 passed | | `packages/core/src/hydration-order.test.ts` | new sorter | 6 passed | | the review-note cases in `protocol-handshake-dependencies.test.ts` and `marketplace-install-local-dependency-missing.test.ts` | 1 failed each | passed | The new objectql cases cover: - the cross-artifact pair: the dependent is admitted, and a dependent defining `crm_account` is refused; - the sibling pair: the sibling is admitted, and a sibling defining the other sibling's name is refused; - the parent arriving second; - the upgrade case: the parent is admitted, and a parent newly defining the module's name is refused naming the module; - an undeclared sharer, which is still refused; - siblings whose parent is absent or holds another namespace, which are refused. Every refusal asserts `code` and `status`. Package suites on this branch after a full build (119/119 tasks), all typecheck exit 0: | Package | Test files | Tests | |:---|:---|:---| | core | 120 | 2774 | | objectql | 470 | 8748 | | service-package | 10 | 127 | | cloud-connection | 58 | 732 | | metadata-core | 20 | 818 | Ablations (`scripts/ablation-replace.mjs`, each restore checked against HEAD): | Mutation | Failures | |:---|:---| | parent arm removed | 3 | | reverse arm disabled | 2 (either end, upgrade) | | sibling arm removed | 1 | | sibling arm reading any shared declared id instead of an installed parent holding N | 1 | | `sys_packages` hydrate in list order | 1 | | install-local rehydrate in listed order | 2 | | cycle edges kept | 1 | | found-label fix reverted | 1 | Gates: `node scripts/pm/dispatch-gates.mjs --ran` accounts for all 106 derived families, all exit 0. One found a real miss: `check-registry-log-declared` flagged the service-package suite that now builds a `SchemaRegistry`, and `vitest.config.ts` now declares `OS_REGISTRY_LOG`. Scoped `eslint` on the touched files exits 0, and `check:nul-bytes` exits 0. Full dogfood on `968c670094`: 2522 passed, 18 skipped, 1 failed (VERDICT command-exit 1). The head `10c01a4181` adds only the service-package `vitest.config.ts` env line. The failure is `authz-probe-blind-spot.test.ts`, the `packages/runtime/src/domains/i18n.ts` row (`handleI18nRequest` expected 3, got 4). That is main's red since #25956, reported on #25986 too; this PR touches neither file. ## Acceptance notes - With #25986, this completes card items 1, 2, 4, 5 and 7. Item 3 has no `ui/**` edit, and item 6 is #26000. - HotCRM precondition (ADR-0141 D5, Open items 7): at hotcrm `d91c7b0`, every `crm` companion (service, marketing, revenue) declares `app.objectstack.hotcrm`, so the sibling arm admits each of them beside the app and beside each other. - First reading 2, from the code and not instrumented on a booted stack: ObjectQLPlugin's `kernel:ready` seals and the ledger rehydrate both run on `kernel:ready`, in hook-registration order. The install-local plugin declares no ordering edge against ObjectQLPlugin. One cross-path case stays unordered by design (ADR-0141 Open items 2): two siblings in `sys_packages` whose parent hydrates later from the ledger. - `Fixes #25792` is for the seat to judge. This body says `Part of` because item 6 is filed separately. --- 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01NndsH9Qm7k6AS2i2fWbjNA _Generated by [Claude Code](https://claude.ai/code/session_01NndsH9Qm7k6AS2i2fWbjNA)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b4199c9 commit be78977

17 files changed

Lines changed: 885 additions & 41 deletions
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
'@objectstack/core': patch
3+
'@objectstack/objectql': patch
4+
'@objectstack/service-package': patch
5+
'@objectstack/cloud-connection': patch
6+
'@objectstack/metadata-core': patch
7+
---
8+
9+
feat(install): a package that declares an installed app in `manifest.dependencies` may share that app's namespace, and a boot hydrates installed packages in dependency order (ADR-0141 D5)
10+
11+
Clause-②: yes (narrowing)
12+
Metadata-protocol: none
13+
14+
<!-- adr-0087: not-required (no-migration-prescription) No key of any metadata schema is added, removed, renamed or re-shaped: `manifest.dependencies` keeps its record shape and meaning. The one narrowing is an install-local acceptance detail of the not-yet-released ADR-0141 D2 check (an artifact wrapper's own id no longer counts as a delivered package), plus the matching removal of the unreleased `DeclaredDependencyCheck.deliveredIds` option; there is nothing for `objectstack migrate meta` to rewrite and no tombstone. -->
15+
16+
**Namespace co-ownership by declaration.** The install gate (`SchemaRegistry.installPackage`) used to admit packages into one namespace only when one release artifact delivered them together. A module shipped as its own artifact that uses its app's namespace was refused `422 NAMESPACE_CONFLICT` beside the installed app, whatever it declared. Now an installed package P co-owns namespace N with:
17+
18+
- an installed X that P declares in `manifest.dependencies`, when both use N;
19+
- a sitting owner of N that declares P, which is the same relation read from the other end, so an upgrade or reinstall of the app beside its modules is admitted;
20+
- a sibling S, when S and P each declare one installed package that holds N (two modules of one app, neither declaring the other).
21+
22+
Only direct declarations count. The shared parent must be installed and must hold N itself.
23+
24+
The object-name check reads the same co-owner set. A co-owner by declaration that defines an object name another co-owner already owns is refused `422 DUPLICATE_ARTIFACT_OBJECT_NAME` before any DDL. Examples: a module redefining its app's object, a sibling redefining the other sibling's object, or an app upgrade newly defining a name one of its modules owns. The message is worded per source (same artifact, declared dependency, sibling). `NAMESPACE_CONFLICT` now names the declaration as the fix for a package that extends the app holding the namespace.
25+
26+
**Hydrate order.** The `sys_packages` hydrate and the install-local `kernel:ready` rehydrate used to register in source-listing order. Now they sort their batch by `manifest.dependencies`, with declared ids as optional edges, through `resolvePluginOrder`. Before this, a module that hydrated before the app whose namespace it shares could be refused and stay absent for the process lifetime. In the install-local rehydrate, each ledger entry is one node, so a declared id resolves to the entry that carries that package. A dependency cycle never stops a hydrate: its members keep source order and an error names them.
27+
28+
**BREAKING** (an install-acceptance narrowing of the unreleased ADR-0141 D2 check) at `POST /api/v1/marketplace/install-local`. A multi-package artifact's own wrapper id no longer counts as delivered unless one of its packages carries it. A module declaring the wrapper id is judged like any other entry, because it would be `absent` on its first standalone install. `DeclaredDependencyCheck.deliveredIds` is removed with it. Also, an undecided range union whose installed version is readable is now reported `found: 'unreadable-range'` instead of `'unreadable-version'`; for example, `<5 || latest` against `7.0.0`.
29+
30+
**What changes for you.**
31+
32+
- A module that extends an app and uses its namespace declares the app in `manifest.dependencies`, and then installs beside it as its own artifact. Two modules of one app need not declare each other.
33+
- Every package of the app's own artifact that uses the namespace should declare the app too.
34+
- New exports: `@objectstack/core` exports `orderHydrationBatch` and `declaredDependencyIds`.

‎content/docs/deployment/app-dependencies.mdx‎

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,12 @@ The declaration does two things:
4040
reference, not the data: users still see the other app's records under that
4141
app's own permission sets and sharing rules.
4242
- **It is checked before install** — below.
43+
- **It lets your package share the depended-on app's namespace** — below.
4344

4445
Every entry is required; there is no optional arm. Declare only the packages
45-
your app actually references.
46+
your app actually references. Two modules of one app need not declare each
47+
other: each declares the app, and that is enough for both to install, in either
48+
order, and to share the app's namespace.
4649

4750
## The version-range grammar
4851

@@ -125,7 +128,11 @@ The check runs at those install acts only. It does not run:
125128

126129
- **at boot.** A restart re-registers what an install already admitted, while
127130
the registry is still filling; it never refuses a package for a dependency it
128-
has not seen yet.
131+
has not seen yet. It does register them in dependency order: the stored
132+
packages, and the apps installed through `os package install`, are each sorted
133+
so a package registers after the packages it declares. Two packages that
134+
declare each other are a cycle: they register in stored order for that boot,
135+
and the server logs an error naming them.
129136
- **at `PATCH /api/v1/packages/:id`.** Editing an authored package's
130137
`dependencies` after install is applied as given; an entry added there is
131138
judged when the package is next installed.
@@ -134,12 +141,35 @@ The check runs at those install acts only. It does not run:
134141

135142
## Sharing a namespace with the app you depend on
136143

137-
Each package's namespace must still be its own across artifacts: a package that
138-
uses the namespace of the app it depends on is refused at install with
139-
`NAMESPACE_CONFLICT`, exactly as before. Inside one artifact, packages may share
140-
a namespace ([ADR-0130](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0130-release-artifact-as-co-ownership-boundary.md)).
141-
Dependency-declared namespace co-ownership across artifacts (ADR-0141 D5) is
142-
not delivered yet.
144+
A namespace is otherwise one package's: a package using a namespace another
145+
installed package holds is refused at install with `NAMESPACE_CONFLICT`. Two
146+
relations make packages co-owners of one namespace instead:
147+
148+
- **the same artifact** delivers them ([ADR-0130](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0130-release-artifact-as-co-ownership-boundary.md)); or
149+
- **a declared dependency**: your package declares an installed app that uses the
150+
same namespace — or another installed package that declares that same app
151+
uses it too (two modules of one app).
152+
153+
So a module that ships as its own artifact, declares `app.objectstack.hotcrm`
154+
and uses `crm` installs beside HotCRM, and a second such module installs beside
155+
both. The relation is one-way — the app declares nothing — and it is read from
156+
either side, so upgrading or reinstalling the app beside its modules is
157+
admitted too. A package that shares the namespace without declaring the app is
158+
still refused, and the fix the error names is the declaration.
159+
160+
Sharing a namespace never means sharing an object name. A package defining an
161+
object another co-owner already defines — your module redefining
162+
`crm_account`, two modules both defining `crm_invoice`, or an app upgrade newly
163+
defining a name one of its modules owns — is refused at install, before any
164+
table is touched, with `422 DUPLICATE_ARTIFACT_OBJECT_NAME` naming both
165+
packages and the object. Rename the object, or `extend` the other package's
166+
object instead of owning a second one.
167+
168+
Every package of the depended-on app's own artifact that uses the namespace
169+
should declare the app too (inside the artifact it costs nothing: it is judged
170+
for presence only). The platform does not record which artifact an installed
171+
package came from, so an undeclared companion is a sitting owner your module
172+
cannot co-own with.
143173

144174
## Build-time validation cannot see a dependency's objects
145175

‎content/docs/protocol/kernel/plugin-spec.mdx‎

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -469,12 +469,16 @@ Use for large dependencies (React, Vue) that should be shared across plugins.
469469

470470
<Callout type="warn">
471471
**The kernel does not check these range strings at plugin load — installing a package
472-
does.** `manifest.dependencies` has three consumers. Two read `Object.keys(...)` only — the
473-
ids, never the ranges: `resolveArtifactPackageOrder` (`packages/core/src/artifact-packages.ts`)
474-
hands the ids to `resolvePluginOrder` as `optionalDependencies`, so an id naming a sibling
475-
package in the same artifact orders ahead of it and an id naming anything else is skipped;
476-
and `resolveWritePackageScope` (`packages/metadata-protocol/src/protocol.ts`) walks the ids
477-
transitively for the write-scope gate. The third is the pre-install dependency check every
472+
does.** `manifest.dependencies` has five consumers. Four read the ids only, never the ranges:
473+
`resolveArtifactPackageOrder` (`packages/core/src/artifact-packages.ts`) hands the ids to
474+
`resolvePluginOrder` as `optionalDependencies`, so an id naming a sibling package in the same
475+
artifact orders ahead of it and an id naming anything else is skipped;
476+
`resolveWritePackageScope` (`packages/metadata-protocol/src/protocol.ts`) walks the ids
477+
transitively for the write-scope gate; the install gate (`SchemaRegistry.installPackage`,
478+
ADR-0141 D5) admits a package that declares an installed app into that app's namespace, and
479+
two packages declaring one such app into it beside each other; and both boot hydrates sort
480+
their batch with the same optional edges (`orderHydrationBatch`,
481+
`packages/core/src/hydration-order.ts`). The fifth is the pre-install dependency check every
478482
install door runs (ADR-0141 D2): each entry must name a package installed inside its range,
479483
or one the same artifact delivers, or the install is refused `422 DEPENDENCY_MISSING`. That
480484
check reads ranges with `rangeAdmitsVersion` (`packages/metadata-core/src/protocol-handshake.ts`),

‎packages/cloud-connection/src/marketplace-install-local-dependency-missing.test.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,18 @@ describe('POST install-local: a multi-package artifact judges each entry, presen
196196
expect(res.payload?.error?.code).not.toBe('DEPENDENCY_MISSING');
197197
});
198198

199+
it('does not count the artifact wrapper\'s own id as delivered when it names no package', async () => {
200+
// `com.example.multi` is the wrapper's id, not a package the artifact
201+
// carries: a module declaring it would pass here and be `absent` on a
202+
// later standalone install, so it is judged like any other entry.
203+
const h = await mount(dir);
204+
expectDependencyRefusal(
205+
await h.install({ manifest: artifact({ 'com.example.multi': '^1.0.0' }) }),
206+
[{ packageId: 'com.example.multi.orders', dependency: 'com.example.multi', range: '^1.0.0', found: 'absent' }],
207+
);
208+
expectNothingWritten(h);
209+
});
210+
199211
it('still judges an entry naming a package outside the artifact', async () => {
200212
const h = await mount(dir);
201213
expectDependencyRefusal(

‎packages/cloud-connection/src/marketplace-install-local-plugin.ts‎

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,11 @@ import {
150150
// branded and quiet, every other rejection is the outage it is.
151151
classifyAdmissionTenancyPosture,
152152
type TenancyPostureSource,
153+
// [ADR-0141 D5] The rehydrate's dependency order — the sorter, and the
154+
// readings of a package's id and its declared dependencies it sorts by.
155+
orderHydrationBatch,
156+
artifactPackageId,
157+
declaredDependencyIds,
153158
} from '@objectstack/core';
154159
import {
155160
resolveTenancyPosture,
@@ -913,7 +918,7 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
913918
return;
914919
}
915920

916-
for (const entry of entries) {
921+
for (const entry of this.inDependencyOrder(ctx, entries)) {
917922
try {
918923
// [#21762] ADR-0087 D1: the handshake runs "before loading a
919924
// package's metadata", and a rehydrate IS a load. An entry whose
@@ -983,6 +988,39 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
983988
}
984989
};
985990

991+
/**
992+
* [ADR-0141 D5] The ledger's entries in `manifest.dependencies` order, not
993+
* the order the directory lists them in: a standalone module read before
994+
* the artifact carrying its parent met the namespace gate — and the
995+
* `kernel:ready` seals — with that parent absent. Each entry is one node
996+
* (an artifact may carry several packages), so a declared id resolves to
997+
* the entry that carries that package and an id declared between packages
998+
* of one entry is no edge. A cycle between entries keeps listed order for
999+
* its members and is reported by name; it never stops the rehydrate.
1000+
*/
1001+
private inDependencyOrder(ctx: PluginContext, entries: InstalledManifestEntry[]): InstalledManifestEntry[] {
1002+
const { ordered, cycles } = orderHydrationBatch(entries.map((entry) => {
1003+
const bodies: unknown[] = Array.isArray(entry.manifest?.packages)
1004+
? entry.manifest.packages.map((p: { manifest?: unknown } | null) => p?.manifest)
1005+
: [entry.manifest];
1006+
return {
1007+
item: entry,
1008+
label: entry.manifestId,
1009+
packageIds: bodies.map((body) => artifactPackageId(body)).filter((id): id is string => id !== undefined),
1010+
declaredIds: bodies.flatMap((body) => declaredDependencyIds(body)),
1011+
};
1012+
}));
1013+
for (const members of cycles) {
1014+
ctx.logger?.error?.(
1015+
`[MarketplaceInstallLocal] installed apps ${members.map((m) => `'${m}'`).join(', ')} declare each other in a `
1016+
+ 'dependency cycle (manifest.dependencies), so no order puts each after the packages it declares. They '
1017+
+ 'rehydrate in listed order for this boot (ADR-0141 D5); a namespace they share may be refused. Remove the '
1018+
+ 'declaration that closes the cycle and reinstall.',
1019+
);
1020+
}
1021+
return ordered;
1022+
}
1023+
9861024
/**
9871025
* Rehydrate-time sample-data healing (the "installed app, empty database"
9881026
* repair). Runs the bundled seed datasets iff:
@@ -1547,8 +1585,9 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
15471585
// nothing. Every package this act delivers is judged on its OWN
15481586
// `manifest.dependencies`: the entries of `packages[]` for a
15491587
// multi-package artifact, the manifest itself otherwise. A
1550-
// dependency the same artifact delivers (an entry, or the
1551-
// artifact's own identity) is judged for presence only; any other
1588+
// dependency the same artifact delivers — one of those packages;
1589+
// never the wrapper's own id, which names no package unless one of
1590+
// them carries it — is judged for presence only; any other
15521591
// must be installed in this runtime's registry at a version inside
15531592
// its range. The refusal is the other install doors' answer, from
15541593
// the one shared helper, 422 on both branches: a dependency this
@@ -1568,7 +1607,6 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
15681607
try { registry = ctx.getService<IObjectQLEngine>('objectql')?.registry; } catch { /* no data engine — nothing installed */ }
15691608
const unmet = checkDeclaredDependencies({
15701609
delivered: entries,
1571-
deliveredIds: [manifestId],
15721610
getInstalled: (id) => registry?.getPackage?.(id),
15731611
});
15741612
if (unmet.length > 0) {

0 commit comments

Comments
 (0)