diff --git a/.changeset/25792-pre-install-dependency-check.md b/.changeset/25792-pre-install-dependency-check.md
new file mode 100644
index 00000000000..31554117eb3
--- /dev/null
+++ b/.changeset/25792-pre-install-dependency-check.md
@@ -0,0 +1,24 @@
+---
+'@objectstack/metadata-core': patch
+'@objectstack/metadata-protocol': patch
+'@objectstack/runtime': patch
+'@objectstack/cloud-connection': patch
+'@objectstack/spec': patch
+---
+
+feat(install): installing a package checks its declared `dependencies` first — every entry must name a package installed at a version inside its range, or one the same artifact delivers, or the install is refused `422 DEPENDENCY_MISSING` (ADR-0141 D2)
+
+Clause-②: yes (narrowing)
+Metadata-protocol: none
+
+
+
+**BREAKING** (an install-acceptance narrowing) for a package whose manifest declares a `dependencies` entry the target environment does not satisfy. The three install doors — `POST /api/v1/packages` (both its protocol arm and its no-protocol fallback), the protocol's `installPackage` called in-process, and `POST /api/v1/marketplace/install-local` — used to install such a package and let the gap surface at its first cross-app save. They now refuse the whole install, before anything is registered, written or synced, with `422 DEPENDENCY_MISSING`.
+
+An entry `(id, range)` is satisfied when `id` is installed at a `manifest.version` inside `range`, or is delivered by the same artifact (presence only: the packages of one artifact are one tested unit). Ranges are read at exact-version granularity in npm style: `^`, `~`, `>=`, `>`, `<=`, `<`, `=`, an exact version, `x` / `*` wildcards, hyphen ranges, space- or comma-joined intersections and `||` unions. A range outside that grammar (`latest` included) or an installed version that is not `MAJOR.MINOR.PATCH` does not satisfy — the check fails closed.
+
+The refusal names every unmet entry in one answer: `error.details.issues` carries one `AppCompatibilityCheck` issue per entry (`severity: 'error'`, `category: 'dependency_missing'`), and `error.details.unmet` the facts per entry — `packageId`, `dependency`, `range`, `found` (`absent`, `out-of-range`, `unreadable-range`, `unreadable-version` or `unreadable-declaration`) and the `installedVersion` when one is installed.
+
+Unchanged: a package that declares no dependencies installs exactly as before; a boot never runs the check (what an install admitted stays admitted); `PATCH /api/v1/packages/:id` still applies a `dependencies` edit as given.
+
+**What changes for you.** Install a package's dependencies, at versions inside its declared ranges, before the package itself; the refusal says which and at what range. `@objectstack/metadata-core` exports the check (`checkDeclaredDependencies`, `assertDeclaredDependencies`), its error (`DependencyMissingError`, `isDependencyMissingError`, `dependencyMissingAnswer`) and the range reader (`rangeAdmitsVersion`, `readExactVersion`, `DEPENDENCY_RANGE_GRAMMAR`). `DEPENDENCY_MISSING` is registered in the error-code ledger under `@objectstack/metadata-core`.
diff --git a/content/docs/deployment/app-dependencies.mdx b/content/docs/deployment/app-dependencies.mdx
new file mode 100644
index 00000000000..673beb9aaa6
--- /dev/null
+++ b/content/docs/deployment/app-dependencies.mdx
@@ -0,0 +1,155 @@
+---
+title: App dependencies — declare one, and what install checks
+navTitle: App Dependencies
+description: Declare that an app depends on another installed app in manifest.dependencies, the version-range grammar install reads, what the DEPENDENCY_MISSING refusal means, and what build-time validation cannot see.
+---
+
+An installable app may depend on another installed app and reference its
+objects ([ADR-0141](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0141-inter-app-dependency-declared-checked-before-install.md)).
+Nothing new is authored for it: the declaration is the `dependencies` key every
+manifest already has, and installing the app checks it once.
+
+## Declare a dependency
+
+`dependencies` maps the depended-on package's id to a version range:
+
+```ts
+import { defineStack } from '@objectstack/spec';
+
+export default defineStack({
+ manifest: {
+ id: 'com.acme.psa',
+ name: 'PSA',
+ version: '1.0.0',
+ type: 'app',
+ namespace: 'psa',
+ dependencies: {
+ 'com.acme.crm': '6.x', // "install CRM 6.x first"
+ },
+ },
+ // …
+});
+```
+
+The declaration does two things:
+
+- **It opens the reference.** The runtime save door judges a list view against
+ your package's own objects and those of the packages your manifest declares,
+ transitively; a view bound to an object outside that set is refused there,
+ `422`, and the fix it prints is this declaration. A dependency opens the
+ reference, not the data: users still see the other app's records under that
+ app's own permission sets and sharing rules.
+- **It is checked before install** — below.
+
+Every entry is required; there is no optional arm. Declare only the packages
+your app actually references.
+
+## The version-range grammar
+
+Install reads each range at exact-version granularity, npm-style:
+
+| You write | It admits |
+|:---|:---|
+| `6.x`, `6.*`, `6` | any `6.y.z` |
+| `6.1.x`, `6.1` | any `6.1.z` |
+| `^6.2.0` | `>=6.2.0 <7.0.0` (`^0.2.3` is `>=0.2.3 <0.3.0`) |
+| `~6.2.0` | `>=6.2.0 <6.3.0` |
+| `>=6.2.0`, `>6.2.0`, `<=6`, `<7` | the single bound (`<=6` is `<7.0.0`; `>6` is `>=7.0.0`) |
+| `6.2.0`, `=6.2.0` | exactly `6.2.0` |
+| `6.1.0 - 6.3` | `>=6.1.0 <6.4.0` |
+| `>=6.2.0 <7` (or `>=6.2.0, <7`) | both bounds — an intersection |
+| `^5.4.0 \|\| ^6.1.0` | either alternative |
+| `*`, `x` | any version |
+
+Anything else — `latest` or another dist-tag, a malformed version — is **not
+readable, and an unreadable range never satisfies**: the install is refused
+naming the range. The installed side is read as `MAJOR.MINOR.PATCH` (a leading
+`v` and `+build` metadata are accepted); a prerelease version such as
+`6.1.0-beta.1` is outside the grammar, and a range is never judged satisfied by
+one.
+
+## What install checks
+
+When you install a package — `POST /api/v1/packages`, `os package install`
+through `POST /api/v1/marketplace/install-local`, or an in-process call to the
+protocol's `installPackage` — every entry of **that package's own**
+`dependencies` must be one of:
+
+- **installed** in the target environment at a `manifest.version` inside the
+ declared range; or
+- **delivered by the same artifact.** The packages of one release artifact are
+ one tested unit, so a sibling package the artifact carries counts as present
+ whatever its version; an in-artifact range mismatch is a build-time concern,
+ not an install refusal.
+
+Otherwise the whole install is refused before anything is registered, written
+or synced:
+
+```json
+{
+ "success": false,
+ "error": {
+ "code": "DEPENDENCY_MISSING",
+ "httpStatus": 422,
+ "message": "Install refused: a declared dependency is not satisfied in this environment, and nothing was installed. …",
+ "details": {
+ "compatible": false,
+ "issues": [
+ { "severity": "error", "category": "dependency_missing", "message": "Package 'com.acme.psa' depends on 'com.acme.crm' 6.x, which is not installed. …" }
+ ],
+ "unmet": [
+ { "packageId": "com.acme.psa", "dependency": "com.acme.crm", "range": "6.x", "found": "absent" }
+ ]
+ }
+ }
+}
+```
+
+Every unmet entry is reported in that one answer. `found` says what was wrong
+with each:
+
+| `found` | Meaning | Fix |
+|:---|:---|:---|
+| `absent` | The dependency is not installed | Install it, at a version inside the range, first |
+| `out-of-range` | Installed at `installedVersion`, outside the range | Install a version inside the range, or widen the range and republish |
+| `unreadable-range` | The declared range is outside the grammar above | Rewrite the range and republish |
+| `unreadable-version` | The installed version is not `MAJOR.MINOR.PATCH` | Install a release version of the dependency |
+| `unreadable-declaration` | `dependencies` is not a `{ "PACKAGE_ID": "RANGE" }` record | Rewrite the declaration and republish |
+
+Only the package's own entries are judged: a dependency's dependencies were
+judged when it was installed. Whether the dependency is enabled is not judged.
+
+## What install does not check
+
+The check runs at those install acts only. It does not run:
+
+- **at boot.** A restart re-registers what an install already admitted, while
+ the registry is still filling; it never refuses a package for a dependency it
+ has not seen yet.
+- **at `PATCH /api/v1/packages/:id`.** Editing an authored package's
+ `dependencies` after install is applied as given; an entry added there is
+ judged when the package is next installed.
+- **at uninstall or upgrade of the dependency.** Removing or upgrading the app
+ others depend on is not refused today; the refusal at install is the one gate.
+
+## Sharing a namespace with the app you depend on
+
+Each package's namespace must still be its own across artifacts: a package that
+uses the namespace of the app it depends on is refused at install with
+`NAMESPACE_CONFLICT`, exactly as before. Inside one artifact, packages may share
+a namespace ([ADR-0130](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0130-release-artifact-as-co-ownership-boundary.md)).
+Dependency-declared namespace co-ownership across artifacts (ADR-0141 D5) is
+not delivered yet.
+
+## Build-time validation cannot see a dependency's objects
+
+`os validate`, `os build` and `os lint` judge one stack: they cannot see the
+objects of an app your manifest depends on, so a binding to one is neither
+admitted nor refused there. The runtime save door is where a cross-app
+reference is judged, against the declared dependencies.
+
+## Related
+
+- [Install modes](/docs/deployment/install-modes) — managed or an unmanaged copy
+- [Publish and preview](/docs/deployment/publish-and-preview) — how a package version reaches an environment
+- [Plugin manifest spec](/docs/protocol/kernel/plugin-spec) — the manifest's keys
diff --git a/content/docs/deployment/meta.json b/content/docs/deployment/meta.json
index 83f927e2ea2..cb2ca24f83d 100644
--- a/content/docs/deployment/meta.json
+++ b/content/docs/deployment/meta.json
@@ -9,6 +9,7 @@
"single-project-mode",
"publish-and-preview",
"install-modes",
+ "app-dependencies",
"cloud-connection",
"validating-metadata",
"cli",
diff --git a/content/docs/protocol/kernel/plugin-spec.mdx b/content/docs/protocol/kernel/plugin-spec.mdx
index 376673f84ca..83dbde93b14 100644
--- a/content/docs/protocol/kernel/plugin-spec.mdx
+++ b/content/docs/protocol/kernel/plugin-spec.mdx
@@ -468,17 +468,21 @@ Use for large dependencies (React, Vue) that should be shared across plugins.
### Version Constraints
-**The kernel does not check these range strings at plugin load.** `manifest.dependencies`
-has exactly two consumers today and both read `Object.keys(...)` only — the ids, never the
-ranges: `resolveArtifactPackageOrder` (`packages/core/src/artifact-packages.ts`) hands the
-ids to `resolvePluginOrder` as `optionalDependencies`, so an id naming a sibling package in
-the same artifact orders ahead of it and an id naming anything else is skipped; and
-`resolveWritePackageScope` (`packages/metadata-protocol/src/protocol.ts`) walks the ids
-transitively for the write-scope gate. `SemanticVersionManager` and `DependencyResolver`
-below are exported from `@objectstack/core` but have no call site outside their own unit
-test. So a range here is a **declaration for the installer and for human readers** — a
-stale or malformed one fails nothing at load. The forms below are the ones the parser in
-`dependency-resolver.ts` accepts, not a set the kernel enforces on you.
+**The kernel does not check these range strings at plugin load — installing a package
+does.** `manifest.dependencies` has three consumers. Two read `Object.keys(...)` only — the
+ids, never the ranges: `resolveArtifactPackageOrder` (`packages/core/src/artifact-packages.ts`)
+hands the ids to `resolvePluginOrder` as `optionalDependencies`, so an id naming a sibling
+package in the same artifact orders ahead of it and an id naming anything else is skipped;
+and `resolveWritePackageScope` (`packages/metadata-protocol/src/protocol.ts`) walks the ids
+transitively for the write-scope gate. The third is the pre-install dependency check every
+install door runs (ADR-0141 D2): each entry must name a package installed inside its range,
+or one the same artifact delivers, or the install is refused `422 DEPENDENCY_MISSING`. That
+check reads ranges with `rangeAdmitsVersion` (`packages/metadata-core/src/protocol-handshake.ts`),
+whose grammar is on [App dependencies](/docs/deployment/app-dependencies) — not with
+`SemanticVersionManager`. `SemanticVersionManager` and `DependencyResolver` below are
+exported from `@objectstack/core` but have no call site outside their own unit test; the
+forms below are the ones the parser in `dependency-resolver.ts` accepts, not the grammar an
+install enforces.
ObjectStack uses **semantic versioning (semver)**. `SemanticVersionManager.satisfies()`
diff --git a/content/docs/references/api/automation-api.mdx b/content/docs/references/api/automation-api.mdx
index 2c9a40096cc..7a8d5fa41e9 100644
--- a/content/docs/references/api/automation-api.mdx
+++ b/content/docs/references/api/automation-api.mdx
@@ -354,7 +354,7 @@ const result = AutomationApiErrorCode.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
-| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +343 more>` | ✅ | The inner refusal's code in its WIRE spelling - the ADR-0112 member a record save of the same write answers in `error.code` (e.g. `UNIQUE_VIOLATION`); the closed set `ApiErrorSchema.code` parses against |
+| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +344 more>` | ✅ | The inner refusal's code in its WIRE spelling - the ADR-0112 member a record save of the same write answers in `error.code` (e.g. `UNIQUE_VIOLATION`); the closed set `ApiErrorSchema.code` parses against |
| **message** | `string` | ✅ | The inner refusal's own diagnostic text, verbatim, without the node and operation prefixes the run's failure text adds |
### Allowed Values: `FlowInnerRefusal.code`
@@ -463,6 +463,7 @@ const result = AutomationApiErrorCode.parse(data);
* `DELETE_HOOK_RESULT_NOT_WRITE_SHAPE`
* `DELIVERY_NEVER_SENT`
* `DELIVERY_NOT_ELIGIBLE`
+* `DEPENDENCY_MISSING`
* `DESTRUCTIVE_CHANGE`
* `DEVICE_CODE_FAILED`
* `DOMAIN_VERIFICATION_DISABLED`
diff --git a/content/docs/references/api/contract.mdx b/content/docs/references/api/contract.mdx
index c0d1d505b8d..c1d6f119dfd 100644
--- a/content/docs/references/api/contract.mdx
+++ b/content/docs/references/api/contract.mdx
@@ -28,7 +28,7 @@ const result = ApiErrorSchema.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
-| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +343 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
+| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +344 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) |
| **message** | `string` | ✅ | Readable error message |
| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. |
@@ -144,6 +144,7 @@ const result = ApiErrorSchema.parse(data);
* `DELETE_HOOK_RESULT_NOT_WRITE_SHAPE`
* `DELIVERY_NEVER_SENT`
* `DELIVERY_NOT_ELIGIBLE`
+* `DEPENDENCY_MISSING`
* `DESTRUCTIVE_CHANGE`
* `DEVICE_CODE_FAILED`
* `DOMAIN_VERIFICATION_DISABLED`
diff --git a/content/docs/references/api/error-code-ledger.mdx b/content/docs/references/api/error-code-ledger.mdx
index 35335308779..806298c7c96 100644
--- a/content/docs/references/api/error-code-ledger.mdx
+++ b/content/docs/references/api/error-code-ledger.mdx
@@ -311,6 +311,7 @@ const result = ErrorCode.parse(data);
* `DELETE_HOOK_RESULT_NOT_WRITE_SHAPE`
* `DELIVERY_NEVER_SENT`
* `DELIVERY_NOT_ELIGIBLE`
+* `DEPENDENCY_MISSING`
* `DESTRUCTIVE_CHANGE`
* `DEVICE_CODE_FAILED`
* `DOMAIN_VERIFICATION_DISABLED`
diff --git a/packages/cloud-connection/src/marketplace-install-local-dependency-missing.test.ts b/packages/cloud-connection/src/marketplace-install-local-dependency-missing.test.ts
new file mode 100644
index 00000000000..70f33af4582
--- /dev/null
+++ b/packages/cloud-connection/src/marketplace-install-local-dependency-missing.test.ts
@@ -0,0 +1,218 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * ADR-0141 D2 at `POST /api/v1/marketplace/install-local`: an install whose
+ * delivered packages declare `dependencies` this runtime does not satisfy is
+ * refused `422 DEPENDENCY_MISSING`, with the answer the other package-install
+ * doors give, before the manifest service sees the artifact.
+ *
+ * ## What these cases pin
+ *
+ * - the refusal on both install branches (inline manifest, cloud snapshot):
+ * the declared envelope, the code and status, and the closed details shape
+ * (`compatible`, `issues`, `unmet`);
+ * - nothing is registered, no ledger file is written, no schema is synced;
+ * - a dependency installed inside its range is admitted (the lit control);
+ * - a multi-package artifact whose module declares its app — delivered by
+ * the same artifact — installs, judged for presence only, while an entry
+ * naming a package outside the artifact is still judged;
+ * - the `kernel:ready` rehydrate does NOT run the check: a ledger entry whose
+ * dependency is absent at boot still re-registers (ADR-0141 D2, "where it
+ * does not run").
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { mkdtempSync, rmSync, readdirSync } from 'node:fs';
+import { join } from 'node:path';
+import { tmpdir } from 'node:os';
+import { BaseResponseSchema, ApiErrorSchema, envelopeViolations } from '@objectstack/spec/api';
+// An install binds the package's handlers through `@objectstack/runtime` (a
+// lazy `import()` inside the plugin); its first load is paid here, at module
+// top, never inside a clocked `it`.
+import '@objectstack/runtime';
+import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js';
+import { LocalManifestSource } from './local-manifest-source.js';
+import { installerAuthService, withInstallerGrants } from './install-local-principal.fixtures.js';
+
+type Handler = (c: any) => Promise;
+
+const ROUTE = '/api/v1/marketplace/install-local';
+
+/** The dependent: 「请先安装 Sales 6.x」. */
+const PSA = {
+ id: 'com.example.psa', name: 'PSA', version: '1.0.0', scope: 'project', type: 'app',
+ dependencies: { 'com.example.sales': '6.x' },
+};
+
+function makeRawApp() {
+ const routes = new Map();
+ return {
+ routes,
+ get: (p: string, h: Handler) => routes.set(`GET ${p}`, h),
+ post: (p: string, h: Handler) => routes.set(`POST ${p}`, h),
+ delete: (p: string, h: Handler) => routes.set(`DELETE ${p}`, h),
+ };
+}
+
+/**
+ * Mount the plugin through its real lifecycle (`start()` + `kernel:ready`),
+ * over a registry that already holds `installed` (id → version).
+ */
+async function mount(dir: string, installed: Record = {}, opts: { controlPlaneUrl?: string } = {}) {
+ const rawApp = makeRawApp();
+ const hooks = new Map();
+ const registered: any[] = [];
+ const register = vi.fn((m: any) => { registered.push({ manifest: m }); });
+ const syncSchemas = vi.fn(async () => undefined);
+ const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
+ const registry = {
+ getAllPackages: () => registered,
+ getPackage: (id: string) => (id in installed ? { manifest: { id, version: installed[id] } } : undefined),
+ };
+ const services: Record = {
+ manifest: { register },
+ auth: installerAuthService(),
+ objectql: withInstallerGrants({ syncSchemas, registry }),
+ };
+ const ctx = {
+ hook: (e: string, h: any) => hooks.set(e, h),
+ getService: (name: string) => {
+ if (name === 'http-server') return { getRawApp: () => rawApp };
+ const svc = services[name];
+ if (svc === undefined) throw new Error(`no ${name}`);
+ return svc;
+ },
+ logger,
+ };
+ const plugin = new MarketplaceInstallLocalPlugin({ controlPlaneUrl: opts.controlPlaneUrl ?? 'off', storageDir: dir });
+ await plugin.start(ctx as any);
+ await hooks.get('kernel:ready')?.();
+ const registeredAtBoot = register.mock.calls.length;
+ const syncedAtBoot = syncSchemas.mock.calls.length;
+ return {
+ install: (body: unknown) => rawApp.routes.get(`POST ${ROUTE}`)!(makeC(body)),
+ installRegistrations: () => register.mock.calls.slice(registeredAtBoot).map((call) => call[0]),
+ installSyncs: () => syncSchemas.mock.calls.length - syncedAtBoot,
+ bootRegistrations: () => register.mock.calls.slice(0, registeredAtBoot).map((call) => call[0]),
+ };
+}
+
+function makeC(body: any) {
+ const json = vi.fn((payload: any, status?: number) => ({ payload, status: status ?? 200 }));
+ return {
+ req: {
+ url: `http://localhost:3000${ROUTE}`,
+ raw: new Request(`http://localhost:3000${ROUTE}`),
+ json: async () => body,
+ param: () => undefined,
+ },
+ json,
+ };
+}
+
+function expectDependencyRefusal(res: { payload: any; status: number }, unmet: unknown[]) {
+ expect(res.status, JSON.stringify(res.payload)).toBe(422);
+ expect(BaseResponseSchema.safeParse(res.payload).success).toBe(true);
+ expect(envelopeViolations(res.payload)).toEqual([]);
+ expect(ApiErrorSchema.safeParse(res.payload.error).success).toBe(true);
+ const { error } = res.payload;
+ expect(error.code).toBe('DEPENDENCY_MISSING');
+ expect(Object.keys(error.details).sort()).toEqual(['compatible', 'issues', 'unmet']);
+ expect(error.details.compatible).toBe(false);
+ expect(error.details.unmet).toEqual(unmet);
+ expect(error.details.issues.map((i: any) => i.category)).toEqual(unmet.map(() => 'dependency_missing'));
+}
+
+let dir: string;
+beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'mil-dependency-')); });
+afterEach(() => {
+ rmSync(dir, { recursive: true, force: true });
+ vi.unstubAllGlobals();
+ vi.unstubAllEnvs();
+ vi.restoreAllMocks();
+});
+
+function expectNothingWritten(h: { installRegistrations: () => unknown[]; installSyncs: () => number }) {
+ expect(h.installRegistrations()).toEqual([]);
+ expect(h.installSyncs()).toBe(0);
+ expect(readdirSync(dir)).toEqual([]);
+}
+
+describe('POST install-local: ADR-0141 D2 refuses an unsatisfied dependency, and installs nothing', () => {
+ it('refuses an absent dependency (inline manifest)', async () => {
+ const h = await mount(dir);
+ expectDependencyRefusal(await h.install({ manifest: PSA }), [
+ { packageId: 'com.example.psa', dependency: 'com.example.sales', range: '6.x', found: 'absent' },
+ ]);
+ expectNothingWritten(h);
+ });
+
+ it('refuses a dependency installed outside the range', async () => {
+ const h = await mount(dir, { 'com.example.sales': '5.4.0' });
+ expectDependencyRefusal(await h.install({ manifest: PSA }), [
+ { packageId: 'com.example.psa', dependency: 'com.example.sales', range: '6.x', found: 'out-of-range', installedVersion: '5.4.0' },
+ ]);
+ expectNothingWritten(h);
+ });
+
+ it('refuses a cloud snapshot the same way: 422, not the 502 of an upstream fault', async () => {
+ vi.stubEnv('OS_MARKETPLACE_PUBLIC_BASE_URL', 'off');
+ vi.stubEnv('OS_CLOUD_API_KEY', 'svc-test-key');
+ const fetchMock = vi.fn(async () => new Response(
+ JSON.stringify({ data: { manifest: PSA, version: '1.0.0', version_id: 'ver_1' } }),
+ { status: 200, headers: { 'Content-Type': 'application/json' } },
+ ));
+ vi.stubGlobal('fetch', fetchMock);
+ const h = await mount(dir, {}, { controlPlaneUrl: 'http://cloud.test' });
+ const res = await h.install({ packageId: 'pkg_psa' });
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ expectDependencyRefusal(res, [
+ { packageId: 'com.example.psa', dependency: 'com.example.sales', range: '6.x', found: 'absent' },
+ ]);
+ expectNothingWritten(h);
+ });
+
+ it('lit control: installs once the dependency is installed inside the range', async () => {
+ const h = await mount(dir, { 'com.example.sales': '6.2.0' });
+ const res = await h.install({ manifest: PSA });
+ expect(res.status, JSON.stringify(res.payload)).toBe(200);
+ expect(h.installRegistrations()).toHaveLength(1);
+ });
+});
+
+describe('POST install-local: a multi-package artifact judges each entry, presence-only inside the artifact', () => {
+ const artifact = (ordersDeps: Record) => ({
+ id: 'com.example.multi', name: 'Multi', version: '1.0.0', scope: 'project', type: 'app',
+ packages: [
+ { manifest: { id: 'com.example.multi.orders', name: 'Orders', version: '1.0.0', type: 'module', dependencies: ordersDeps } },
+ { manifest: { id: 'com.example.multi.core', name: 'Core', version: '2.0.0', type: 'app' } },
+ ],
+ });
+
+ it('admits a module that declares its app delivered beside it, whatever the range says', async () => {
+ const h = await mount(dir);
+ const res = await h.install({ manifest: artifact({ 'com.example.multi.core': '^1.0.0' }) });
+ expect(res.status, JSON.stringify(res.payload)).not.toBe(422);
+ expect(res.payload?.error?.code).not.toBe('DEPENDENCY_MISSING');
+ });
+
+ it('still judges an entry naming a package outside the artifact', async () => {
+ const h = await mount(dir);
+ expectDependencyRefusal(
+ await h.install({ manifest: artifact({ 'com.example.multi.core': '^1.0.0', 'com.example.sales': '^6' }) }),
+ [{ packageId: 'com.example.multi.orders', dependency: 'com.example.sales', range: '^6', found: 'absent' }],
+ );
+ expectNothingWritten(h);
+ });
+});
+
+describe('kernel:ready rehydrate: the check does not run at re-registration', () => {
+ it('re-registers a ledger entry whose dependency is absent at boot', async () => {
+ new LocalManifestSource(dir).write({
+ packageId: PSA.id, versionId: PSA.version, manifestId: PSA.id, version: PSA.version,
+ manifest: PSA, installedAt: '2026-01-01T00:00:00.000Z', installedBy: 'admin', withSampleData: false,
+ });
+ const h = await mount(dir);
+ expect(h.bootRegistrations().map((m: any) => m.id)).toContain(PSA.id);
+ });
+});
diff --git a/packages/cloud-connection/src/marketplace-install-local-plugin.ts b/packages/cloud-connection/src/marketplace-install-local-plugin.ts
index d5ba73aef27..72271f58175 100644
--- a/packages/cloud-connection/src/marketplace-install-local-plugin.ts
+++ b/packages/cloud-connection/src/marketplace-install-local-plugin.ts
@@ -182,6 +182,11 @@ import {
import {
assertProtocolCompat,
checkProtocolCompat,
+ // [ADR-0141 D2] The pre-install dependency check: the one predicate and
+ // the one answer every package-install door gives its refusal.
+ checkDeclaredDependencies,
+ DependencyMissingError,
+ dependencyMissingAnswer,
isProtocolIncompatibleError,
protocolIncompatibleAnswer,
ProtocolIncompatibleError,
@@ -1536,6 +1541,45 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
}, refusal.status);
}
+ // 1c'. [ADR-0141 D2] ⭐ THE PRE-INSTALL DEPENDENCY CHECK, beside the
+ // handshake and as early — before the manifest service sees the
+ // artifact, so a refused install registers, writes and syncs
+ // nothing. Every package this act delivers is judged on its OWN
+ // `manifest.dependencies`: the entries of `packages[]` for a
+ // multi-package artifact, the manifest itself otherwise. A
+ // dependency the same artifact delivers (an entry, or the
+ // artifact's own identity) is judged for presence only; any other
+ // must be installed in this runtime's registry at a version inside
+ // its range. The refusal is the other install doors' answer, from
+ // the one shared helper, 422 on both branches: a dependency this
+ // environment lacks is the environment's state against a body as
+ // delivered, never an upstream fault.
+ //
+ // ⛔ Not at the `kernel:ready` rehydrate, which re-registers what an
+ // install already admitted while the boot is still filling the
+ // registry (AGENTS.md, *Startup registry reads*).
+ {
+ const entries = Array.isArray(manifest.packages)
+ ? manifest.packages
+ .map((entry: unknown) => (isPlainObject(entry) && isPlainObject(entry.manifest) ? entry.manifest : undefined))
+ .filter((m: unknown): m is Record => m !== undefined)
+ : [manifest];
+ let registry: { getPackage?(id: string): any } | undefined;
+ try { registry = ctx.getService('objectql')?.registry; } catch { /* no data engine — nothing installed */ }
+ const unmet = checkDeclaredDependencies({
+ delivered: entries,
+ deliveredIds: [manifestId],
+ getInstalled: (id) => registry?.getPackage?.(id),
+ });
+ if (unmet.length > 0) {
+ const refusal = dependencyMissingAnswer(new DependencyMissingError(unmet));
+ return c.json({
+ success: false,
+ error: { code: refusal.code, message: refusal.message, details: refusal.details },
+ }, refusal.status);
+ }
+ }
+
// 1e. [ADR-0131 D6] ⭐ THE INSTALL MODE — after the payload, id, D3
// walled-catalog and handshake checks and before every other
// judgement of the package and any write, because nothing an
diff --git a/packages/metadata-core/src/protocol-handshake-dependencies.test.ts b/packages/metadata-core/src/protocol-handshake-dependencies.test.ts
new file mode 100644
index 00000000000..b2ef58fe512
--- /dev/null
+++ b/packages/metadata-core/src/protocol-handshake-dependencies.test.ts
@@ -0,0 +1,233 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * ADR-0141 D2 — the range reader beside the protocol handshake, and the one
+ * pre-install dependency predicate every install door calls.
+ *
+ * The reader answers at EXACT-VERSION granularity (`^6.2.0` refuses an
+ * installed `6.1.0`, which `rangeAdmitsMajor` admits), over the grammar the
+ * handshake family already reads. The predicate judges each declared entry
+ * of the delivered packages: present (installed, or delivered by the same
+ * act) and, for an installed record, in range. An unreadable range or version
+ * does not satisfy.
+ */
+
+import { describe, expect, it } from 'vitest';
+import {
+ assertDeclaredDependencies,
+ checkDeclaredDependencies,
+ dependencyMissingAnswer,
+ DependencyMissingError,
+ isDependencyMissingError,
+ rangeAdmitsMajor,
+ rangeAdmitsVersion,
+ readExactVersion,
+} from './protocol-handshake.js';
+
+describe('rangeAdmitsVersion — exact-version granularity over the handshake grammar', () => {
+ it('caret: the ruling\'s own 「HotCRM 6.x」 spellings, and the minor/patch floor the major reader cannot see', () => {
+ expect(rangeAdmitsVersion('^6.2.0', '6.2.0')).toBe(true);
+ expect(rangeAdmitsVersion('^6.2.0', '6.9.1')).toBe(true);
+ expect(rangeAdmitsVersion('^6.2.0', '7.0.0')).toBe(false);
+ // The reason this reader exists: the major reader admits it, D2 cannot.
+ expect(rangeAdmitsMajor('^6.2.0', 6)).toBe(true);
+ expect(rangeAdmitsVersion('^6.2.0', '6.1.0')).toBe(false);
+ expect(rangeAdmitsVersion('^6', '6.0.0')).toBe(true);
+ expect(rangeAdmitsVersion('^6', '5.9.9')).toBe(false);
+ // npm's zero-major caret narrows to the first non-zero part.
+ expect(rangeAdmitsVersion('^0.2.3', '0.2.9')).toBe(true);
+ expect(rangeAdmitsVersion('^0.2.3', '0.3.0')).toBe(false);
+ expect(rangeAdmitsVersion('^0.0.3', '0.0.4')).toBe(false);
+ expect(rangeAdmitsVersion('^0.0', '0.0.9')).toBe(true);
+ expect(rangeAdmitsVersion('^0.0', '0.1.0')).toBe(false);
+ });
+
+ it('tilde pins the minor when one is written, the major otherwise', () => {
+ expect(rangeAdmitsVersion('~6.1.2', '6.1.9')).toBe(true);
+ expect(rangeAdmitsVersion('~6.1.2', '6.1.1')).toBe(false);
+ expect(rangeAdmitsVersion('~6.1.2', '6.2.0')).toBe(false);
+ expect(rangeAdmitsVersion('~6', '6.9.0')).toBe(true);
+ expect(rangeAdmitsVersion('~6', '7.0.0')).toBe(false);
+ });
+
+ it('x and * wildcards, bare partials, and the full wildcard', () => {
+ expect(rangeAdmitsVersion('6.x', '6.4.2')).toBe(true);
+ expect(rangeAdmitsVersion('6.x', '7.0.0')).toBe(false);
+ expect(rangeAdmitsVersion('6.1.*', '6.1.7')).toBe(true);
+ expect(rangeAdmitsVersion('6.1.*', '6.2.0')).toBe(false);
+ expect(rangeAdmitsVersion('6', '6.3.0')).toBe(true);
+ expect(rangeAdmitsVersion('6.1', '6.2.0')).toBe(false);
+ expect(rangeAdmitsVersion('*', '0.0.1')).toBe(true);
+ expect(rangeAdmitsVersion('x', '12.0.0')).toBe(true);
+ });
+
+ it('an exact version admits that version only', () => {
+ expect(rangeAdmitsVersion('6.1.2', '6.1.2')).toBe(true);
+ expect(rangeAdmitsVersion('=6.1.2', '6.1.2')).toBe(true);
+ expect(rangeAdmitsVersion('6.1.2', '6.1.3')).toBe(false);
+ });
+
+ it('single comparators, with npm\'s partial-version desugaring', () => {
+ expect(rangeAdmitsVersion('>=6.2.0', '6.2.0')).toBe(true);
+ expect(rangeAdmitsVersion('>=6.2.0', '6.1.9')).toBe(false);
+ expect(rangeAdmitsVersion('>6.2.0', '6.2.0')).toBe(false);
+ expect(rangeAdmitsVersion('>6.2.0', '6.2.1')).toBe(true);
+ // `>6` is `>=7.0.0`; `<=6` is `<7.0.0`; `<6.2` is `<6.2.0`.
+ expect(rangeAdmitsVersion('>6', '6.9.9')).toBe(false);
+ expect(rangeAdmitsVersion('>6', '7.0.0')).toBe(true);
+ expect(rangeAdmitsVersion('<=6', '6.9.9')).toBe(true);
+ expect(rangeAdmitsVersion('<=6', '7.0.0')).toBe(false);
+ expect(rangeAdmitsVersion('<6.2', '6.1.9')).toBe(true);
+ expect(rangeAdmitsVersion('<6.2', '6.2.0')).toBe(false);
+ // An operator separated from its version by a space reads the same.
+ expect(rangeAdmitsVersion('>= 6.2.0', '6.2.0')).toBe(true);
+ });
+
+ it('space- and comma-joined intersections, hyphen ranges and || unions', () => {
+ expect(rangeAdmitsVersion('>=6.2.0 <7.0.0', '6.5.0')).toBe(true);
+ expect(rangeAdmitsVersion('>=6.2.0 <7.0.0', '7.0.0')).toBe(false);
+ expect(rangeAdmitsVersion('>=6.2.0, <7', '6.1.0')).toBe(false);
+ expect(rangeAdmitsVersion('6.1.0 - 6.3', '6.3.9')).toBe(true);
+ expect(rangeAdmitsVersion('6.1.0 - 6.3', '6.4.0')).toBe(false);
+ expect(rangeAdmitsVersion('6.1.0 - 6.3.0', '6.3.0')).toBe(true);
+ expect(rangeAdmitsVersion('^5.4.0 || ^6.1.0', '6.0.0')).toBe(false);
+ expect(rangeAdmitsVersion('^5.4.0 || ^6.1.0', '5.9.0')).toBe(true);
+ expect(rangeAdmitsVersion('^5.4.0 || ^6.1.0', '6.2.0')).toBe(true);
+ });
+
+ it('fails closed: a range it cannot read answers null, never true', () => {
+ for (const range of ['', ' ', 'latest', 'next', '|| ^5', '6.x.foo', '>=', '~>6.0', '^6.0.0.1', 'a'.repeat(200)]) {
+ expect(rangeAdmitsVersion(range, '6.0.0'), range).toBeNull();
+ }
+ // One unreadable alternative makes a union unreadable unless another admits.
+ expect(rangeAdmitsVersion('^6 || nonsense', '6.1.0')).toBe(true);
+ expect(rangeAdmitsVersion('^6 || nonsense', '7.0.0')).toBeNull();
+ // An empty alternative is an unreadable one.
+ expect(rangeAdmitsVersion('^6 ||', '7.0.0')).toBeNull();
+ });
+
+ it('answers null for a version outside MAJOR.MINOR.PATCH (a prerelease included)', () => {
+ expect(rangeAdmitsVersion('^6', '6')).toBeNull();
+ expect(rangeAdmitsVersion('^6', '6.1')).toBeNull();
+ expect(rangeAdmitsVersion('^6', '6.1.0-beta.1')).toBeNull();
+ expect(rangeAdmitsVersion('^6', 'banana')).toBeNull();
+ // A leading `v` and build metadata are read.
+ expect(readExactVersion('v6.1.0+build.7')).toEqual([6, 1, 0]);
+ expect(readExactVersion(6)).toBeNull();
+ });
+});
+
+const installed = (records: Record) => (id: string) =>
+ id in records ? { manifest: { version: records[id] } } : undefined;
+
+describe('checkDeclaredDependencies — ADR-0141 D2\'s one predicate', () => {
+ const psa = { id: 'com.acme.psa', dependencies: { 'com.hotcrm.sales': '^6.0.0' } };
+
+ it('admits a dependency installed at a version inside its range', () => {
+ expect(checkDeclaredDependencies({ delivered: [psa], getInstalled: installed({ 'com.hotcrm.sales': '6.3.1' }) })).toEqual([]);
+ });
+
+ it('reports an absent dependency and one installed outside its range, every entry in one answer', () => {
+ const pkg = { id: 'com.acme.psa', dependencies: { 'com.hotcrm.sales': '^6.0.0', 'com.acme.billing': '>=2.1.0' } };
+ expect(checkDeclaredDependencies({ delivered: [pkg], getInstalled: installed({ 'com.hotcrm.sales': '5.9.0' }) })).toEqual([
+ { packageId: 'com.acme.psa', dependency: 'com.hotcrm.sales', range: '^6.0.0', found: 'out-of-range', installedVersion: '5.9.0' },
+ { packageId: 'com.acme.psa', dependency: 'com.acme.billing', range: '>=2.1.0', found: 'absent' },
+ ]);
+ });
+
+ it('fails closed on an unreadable range and on an unreadable installed version, naming which side', () => {
+ const pkg = { id: 'com.acme.psa', dependencies: { 'com.hotcrm.sales': 'latest', 'com.acme.billing': '^2' } };
+ expect(checkDeclaredDependencies({
+ delivered: [pkg],
+ getInstalled: installed({ 'com.hotcrm.sales': '6.0.0', 'com.acme.billing': '2.0.0-rc.1' }),
+ })).toEqual([
+ { packageId: 'com.acme.psa', dependency: 'com.hotcrm.sales', range: 'latest', found: 'unreadable-range', installedVersion: '6.0.0' },
+ { packageId: 'com.acme.psa', dependency: 'com.acme.billing', range: '^2', found: 'unreadable-version', installedVersion: '2.0.0-rc.1' },
+ ]);
+ // An unreadable range is reported as such even when the dependency is
+ // absent: installing it would not make the entry satisfiable, so the
+ // range is the fact the author has to act on.
+ expect(checkDeclaredDependencies({ delivered: [pkg], getInstalled: installed({}) })[0]).toEqual(
+ { packageId: 'com.acme.psa', dependency: 'com.hotcrm.sales', range: 'latest', found: 'unreadable-range' },
+ );
+ });
+
+ it('judges a dependency the same act delivers for presence only, whatever its version', () => {
+ // ADR-0141 D2 arm 2: inside one artifact the packages are one tested unit.
+ const core = { id: 'com.example.multi.core', version: '2.0.0' };
+ const orders = { id: 'com.example.multi.orders', dependencies: { 'com.example.multi.core': '^1.0.0' } };
+ expect(checkDeclaredDependencies({ delivered: [orders, core], getInstalled: installed({}) })).toEqual([]);
+ // An id delivered by the act without being judged itself (an artifact's own identity).
+ expect(checkDeclaredDependencies({
+ delivered: [orders], deliveredIds: ['com.example.multi.core'], getInstalled: installed({}),
+ })).toEqual([]);
+ // Control: delivered by nobody, the same entry is absent.
+ expect(checkDeclaredDependencies({ delivered: [orders], getInstalled: installed({}) })).toHaveLength(1);
+ });
+
+ it('reads a declaration that is not a record as unreadable, never as "no dependencies"', () => {
+ const pkg = { id: 'com.acme.psa', dependencies: [{ packageId: 'com.hotcrm.sales', versionRange: '^6' }] };
+ expect(checkDeclaredDependencies({ delivered: [pkg], getInstalled: installed({}) })).toEqual([
+ { packageId: 'com.acme.psa', found: 'unreadable-declaration' },
+ ]);
+ // Absent and empty declarations judge nothing.
+ expect(checkDeclaredDependencies({ delivered: [{ id: 'a.b.c' }, { id: 'a.b.d', dependencies: {} }], getInstalled: installed({}) })).toEqual([]);
+ });
+});
+
+describe('assertDeclaredDependencies / DependencyMissingError — the ADR-0112 envelope and the one door answer', () => {
+ const psa = { id: 'com.acme.psa', dependencies: { 'com.hotcrm.sales': '^6.0.0', 'com.acme.billing': '>=2.1.0' } };
+
+ it('returns silently when every entry is satisfied', () => {
+ expect(() => assertDeclaredDependencies({
+ delivered: [psa], getInstalled: installed({ 'com.hotcrm.sales': '6.0.0', 'com.acme.billing': '2.1.0' }),
+ })).not.toThrow();
+ });
+
+ it('throws 422 DEPENDENCY_MISSING carrying one dependency_missing issue per unmet entry', () => {
+ let thrown: unknown;
+ try {
+ assertDeclaredDependencies({ delivered: [psa], getInstalled: installed({ 'com.hotcrm.sales': '5.2.0' }) });
+ } catch (e) {
+ thrown = e;
+ }
+ expect(thrown).toBeInstanceOf(DependencyMissingError);
+ const err = thrown as DependencyMissingError;
+ expect(err.code).toBe('DEPENDENCY_MISSING');
+ expect(err.status).toBe(422);
+ expect(err.statusCode).toBe(422);
+ expect(err.issues).toHaveLength(2);
+ for (const issue of err.issues) {
+ expect(issue).toEqual({ severity: 'error', category: 'dependency_missing', message: expect.any(String) });
+ }
+ // Each issue names the id, the range and what was found.
+ expect(err.issues[0]!.message).toContain("'com.hotcrm.sales'");
+ expect(err.issues[0]!.message).toContain('^6.0.0');
+ expect(err.issues[0]!.message).toContain('5.2.0');
+ expect(err.issues[1]!.message).toContain("'com.acme.billing'");
+ expect(err.issues[1]!.message).toContain('not installed');
+ expect(isDependencyMissingError(err)).toBe(true);
+ });
+
+ it('dependencyMissingAnswer: status, code, the error\'s own message, and a closed details shape', () => {
+ const err = new DependencyMissingError([
+ { packageId: 'com.acme.psa', dependency: 'com.hotcrm.sales', range: '^6.0.0', found: 'absent' },
+ ]);
+ const answer = dependencyMissingAnswer(err);
+ expect(answer.status).toBe(422);
+ expect(answer.code).toBe('DEPENDENCY_MISSING');
+ expect(answer.message).toBe(err.message);
+ expect(Object.keys(answer.details).sort()).toEqual(['compatible', 'issues', 'unmet']);
+ expect(answer.details.compatible).toBe(false);
+ expect(answer.details.issues).toEqual(err.issues);
+ expect(answer.details.unmet).toEqual([
+ { packageId: 'com.acme.psa', dependency: 'com.hotcrm.sales', range: '^6.0.0', found: 'absent' },
+ ]);
+ });
+
+ it('recognises the brand, not the field names', () => {
+ const lookalike = Object.assign(new Error('x'), { code: 'DEPENDENCY_MISSING', status: 422, unmet: [] });
+ expect(isDependencyMissingError(lookalike)).toBe(false);
+ expect(isDependencyMissingError(null)).toBe(false);
+ });
+});
diff --git a/packages/metadata-core/src/protocol-handshake.ts b/packages/metadata-core/src/protocol-handshake.ts
index 314243f3efa..e822aa3ddc5 100644
--- a/packages/metadata-core/src/protocol-handshake.ts
+++ b/packages/metadata-core/src/protocol-handshake.ts
@@ -20,6 +20,19 @@
* determination that the declared range excludes the runtime major. Absent and
* unrecognized ranges are admitted (the former grandfathered, the latter a
* parser-coverage gap) so the handshake can never cause a false rejection.
+ *
+ * ## The dependency check beside it (ADR-0141 D2)
+ *
+ * The same module hosts the pre-install dependency check: one predicate
+ * ({@link checkDeclaredDependencies}) that every install door runs right after
+ * {@link assertProtocolCompat}, and the range reader it needs
+ * ({@link rangeAdmitsVersion}). The reader is a SIBLING of the handshake's
+ * {@link rangeAdmitsMajor}, sharing its alternative split, its length bound and
+ * its comparator-operator peel, and differing in the two ways D2 requires: it
+ * answers at exact-version granularity, and its caller fails CLOSED on what it
+ * cannot read. The handshake's own verdicts do not move. ⛔ No fourth range
+ * reader: `parseRangeFloor` and `SemanticVersionManager` are the other two,
+ * and neither has both the grammar and the granularity.
*/
import { PROTOCOL_VERSION } from '@objectstack/spec/kernel';
@@ -228,6 +241,45 @@ function rangeAlternatives(range: string): string[] {
return range.split('||').map((alternative) => alternative.trim());
}
+/**
+ * The longest range either reader reads. A real SemVer range is short, and a
+ * manifest's range strings are externally authored, so bounding the length
+ * defuses any pathological input before it reaches a split or a regex: an
+ * overlong string is simply unrecognized, never a slow scan.
+ */
+const MAX_RANGE_LENGTH = 128;
+
+/** The intersection separator both readers accept: whitespace or a comma. */
+const COMPARATOR_SEPARATOR = /[\s,]+/;
+
+/**
+ * A hyphen range's two ends (`'11.0.0 - 12.0.0'`), or `null` when `r` is not
+ * one. Split on the whitespace-delimited hyphen (a fixed anchor between the two
+ * `\s+` runs — linear, no `.`-vs-`\s` backtracking) rather than a lazy
+ * `(.+?)…(.+)` match.
+ */
+function hyphenRangeEnds(r: string): [string, string] | null {
+ const ends = r.split(/\s+-\s+/);
+ return ends.length === 2 ? [ends[0]!, ends[1]!] : null;
+}
+
+type ComparatorOperator = '>=' | '<=' | '>' | '<';
+
+/**
+ * Peel a comparator's operator off by fixed prefix rather than a `\s*(.+)`
+ * match, whose whitespace/any-char overlap is a polynomial-ReDoS shape on
+ * untrusted input. `null` when `comparator` opens with no comparison operator.
+ */
+function peelComparatorOperator(comparator: string): { op: ComparatorOperator; rest: string } | null {
+ if (comparator.startsWith('>=') || comparator.startsWith('<=')) {
+ return { op: comparator.slice(0, 2) as ComparatorOperator, rest: comparator.slice(2) };
+ }
+ if (comparator.startsWith('>') || comparator.startsWith('<')) {
+ return { op: comparator.slice(0, 1) as ComparatorOperator, rest: comparator.slice(1) };
+ }
+ return null;
+}
+
/**
* Decide whether a SemVer-ish range admits `runtimeMajor`.
*
@@ -245,12 +297,9 @@ function rangeAlternatives(range: string): string[] {
*/
export function rangeAdmitsMajor(range: string, runtimeMajor: number): boolean | null {
const r = range.trim();
- // A real SemVer range is short. Bounding the length here defuses any
- // pathological input before it reaches a split or a regex (a manifest's
- // `engines` string is externally authored, so treat it as untrusted): an
- // overlong string is simply unrecognized (admit-with-warning), never a slow
- // scan.
- if (r.length > 128) return null;
+ // Bounded before any split or regex ({@link MAX_RANGE_LENGTH}): an overlong
+ // string is simply unrecognized (admit-with-warning), never a slow scan.
+ if (r.length > MAX_RANGE_LENGTH) return null;
let verdict: boolean | null = false;
for (const alternative of rangeAlternatives(r)) {
const admits = alternativeAdmitsMajor(alternative, runtimeMajor);
@@ -266,7 +315,7 @@ function alternativeAdmitsMajor(r: string, runtimeMajor: number): boolean | null
if (r === '*' || r === 'x' || r === 'latest') return true;
// Compound comparator range, e.g. ">=11.0.0 <13.0.0" (space- or comma-joined).
- const parts = r.split(/[\s,]+/).filter(Boolean);
+ const parts = r.split(COMPARATOR_SEPARATOR).filter(Boolean);
if (parts.length > 1 && parts.every((p) => /^[<>]=?/.test(p))) {
let ok = true;
for (const p of parts) {
@@ -277,13 +326,11 @@ function alternativeAdmitsMajor(r: string, runtimeMajor: number): boolean | null
return ok;
}
- // Hyphen range: "11.0.0 - 12.0.0". Split on the whitespace-delimited hyphen
- // (a fixed anchor between the two `\s+` runs — linear, no `.`-vs-`\s`
- // backtracking) rather than a lazy `(.+?)…(.+)` match.
- const hyphenParts = r.split(/\s+-\s+/);
- if (hyphenParts.length === 2) {
- const lo = leadingMajor(hyphenParts[0]!);
- const hi = leadingMajor(hyphenParts[1]!);
+ // Hyphen range: "11.0.0 - 12.0.0".
+ const hyphenParts = hyphenRangeEnds(r);
+ if (hyphenParts) {
+ const lo = leadingMajor(hyphenParts[0]);
+ const hi = leadingMajor(hyphenParts[1]);
if (lo === null || hi === null) return null;
return runtimeMajor >= lo && runtimeMajor <= hi;
}
@@ -333,19 +380,9 @@ function declaredFloorMajor(range: string): number | null {
}
function comparatorAdmitsMajor(comparator: string, runtimeMajor: number): boolean | null {
- // Peel the operator off by fixed prefix rather than a `\s*(.+)` match, whose
- // whitespace/any-char overlap is a polynomial-ReDoS shape on untrusted input.
- let op: string;
- let rest: string;
- if (comparator.startsWith('>=') || comparator.startsWith('<=')) {
- op = comparator.slice(0, 2);
- rest = comparator.slice(2);
- } else if (comparator.startsWith('>') || comparator.startsWith('<')) {
- op = comparator.slice(0, 1);
- rest = comparator.slice(1);
- } else {
- return null;
- }
+ const peeled = peelComparatorOperator(comparator);
+ if (peeled === null) return null;
+ const { op, rest } = peeled;
const bound = rest.trim().replace(/^v/i, '');
if (bound === '') return null;
const parts = bound.match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?$/);
@@ -468,3 +505,444 @@ export function assertProtocolCompat(
throw new ProtocolIncompatibleError(result.diagnostic);
}
}
+
+// ==========================================================================
+// ADR-0141 D2 — the range reader at exact-version granularity
+// ==========================================================================
+
+/** A full version, `[major, minor, patch]`. */
+export type ExactVersion = readonly [number, number, number];
+
+/**
+ * The version grammar the dependency check reads for an INSTALLED package:
+ * `MAJOR.MINOR.PATCH`, an optional leading `v`, an optional `+build` suffix
+ * (ignored, as SemVer orders it). `null` for anything else — a partial
+ * version (`6`, `6.1`), a prerelease (`6.1.0-beta.1`: outside the stated
+ * grammar, so the check fails closed on it), a non-string.
+ */
+export function readExactVersion(version: unknown): ExactVersion | null {
+ if (typeof version !== 'string' || version.length > MAX_RANGE_LENGTH) return null;
+ const m = version.trim().match(/^v?(\d+)\.(\d+)\.(\d+)(?:\+[0-9A-Za-z.-]+)?$/);
+ if (!m) return null;
+ return [Number.parseInt(m[1]!, 10), Number.parseInt(m[2]!, 10), Number.parseInt(m[3]!, 10)];
+}
+
+/**
+ * The range grammar {@link rangeAdmitsVersion} reads, as a refusal states it.
+ * The handshake family's grammar minus `latest` (a dist-tag, not a bound), at
+ * npm's exact-version semantics.
+ */
+export const DEPENDENCY_RANGE_GRAMMAR =
+ '`^`, `~`, `>=`, `>`, `<=`, `<` or `=` before a version; an exact version (`6.1.0`); '
+ + 'an `x` / `*` wildcard (`6.x`, `6.1.*`, `*`); a hyphen range (`6.1.0 - 6.3`); '
+ + 'comparators joined by a space or a comma (an intersection: `>=6.1.0 <7`); and `||` between alternatives';
+
+/** A version token as written: up to three numeric parts, stopping at the first `x` / `X` / `*`. */
+function readPartialVersion(token: string): number[] | null {
+ const m = token.match(/^(\d+|[xX*])(?:\.(\d+|[xX*]))?(?:\.(\d+|[xX*]))?$/);
+ if (!m) return null;
+ const parts: number[] = [];
+ for (const part of [m[1], m[2], m[3]]) {
+ if (part === undefined || !/^\d+$/.test(part)) break;
+ parts.push(Number.parseInt(part, 10));
+ }
+ return parts;
+}
+
+/** `parts` padded with zeros to a full version. */
+function floorOf(parts: readonly number[]): ExactVersion {
+ return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0];
+}
+
+/** The first version past every version `parts` names (`6` → `7.0.0`, `6.1` → `6.2.0`). */
+function ceilingOf(parts: readonly number[]): ExactVersion {
+ return parts.length === 1 ? [parts[0]! + 1, 0, 0] : [parts[0]!, parts[1]! + 1, 0];
+}
+
+function compareVersions(a: ExactVersion, b: ExactVersion): number {
+ return a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
+}
+
+/** One bound of a desugared comparator; a comparator is the AND of its bounds. */
+interface VersionBound { op: ComparatorOperator; v: ExactVersion }
+
+/** A bound nothing satisfies — `>*`, `<*`, `<0.0.0`. */
+const NOTHING: VersionBound = { op: '<', v: [0, 0, 0] };
+
+/**
+ * One comparator of an intersection, desugared to bounds the way npm does
+ * (`^1.2` → `>=1.2.0 <2.0.0`, `>6` → `>=7.0.0`, `<=6.1` → `<6.2.0`). `null`
+ * when the token is outside {@link DEPENDENCY_RANGE_GRAMMAR}; an empty list
+ * admits every version.
+ */
+function comparatorBounds(token: string): VersionBound[] | null {
+ const peeled = peelComparatorOperator(token);
+ let op: ComparatorOperator | '=' | '^' | '~' | '' = '';
+ let rest = token;
+ if (peeled) {
+ ({ op, rest } = peeled);
+ } else if (token.startsWith('=') || token.startsWith('^') || token.startsWith('~')) {
+ op = token[0] as '=' | '^' | '~';
+ rest = token.slice(1);
+ }
+ const parts = readPartialVersion(rest.replace(/^v/i, ''));
+ if (parts === null) return null;
+ const full = parts.length === 3;
+ const any = parts.length === 0;
+ const floor = floorOf(parts);
+ switch (op) {
+ case '':
+ case '=':
+ if (full) return [{ op: '>=', v: floor }, { op: '<=', v: floor }];
+ return any ? [] : [{ op: '>=', v: floor }, { op: '<', v: ceilingOf(parts) }];
+ case '>=':
+ return any ? [] : [{ op: '>=', v: floor }];
+ case '>':
+ if (any) return [NOTHING];
+ return full ? [{ op: '>', v: floor }] : [{ op: '>=', v: ceilingOf(parts) }];
+ case '<=':
+ if (any) return [];
+ return full ? [{ op: '<=', v: floor }] : [{ op: '<', v: ceilingOf(parts) }];
+ case '<':
+ return any ? [NOTHING] : [{ op: '<', v: floor }];
+ case '~':
+ if (any) return [];
+ return [{ op: '>=', v: floor }, { op: '<', v: ceilingOf(parts.slice(0, Math.min(parts.length, 2))) }];
+ case '^': {
+ if (any) return [];
+ // The first non-zero part is the one that may not move; a part left
+ // unwritten counts as the one that moves (`^0.x` → `<1.0.0`).
+ const upper: ExactVersion = parts[0]! > 0 || parts.length === 1
+ ? [parts[0]! + 1, 0, 0]
+ : parts[1]! > 0 || parts.length === 2
+ ? [0, parts[1]! + 1, 0]
+ : [0, 0, parts[2]! + 1];
+ return [{ op: '>=', v: floor }, { op: '<', v: upper }];
+ }
+ }
+}
+
+function boundAdmits(bound: VersionBound, version: ExactVersion): boolean {
+ const c = compareVersions(version, bound.v);
+ switch (bound.op) {
+ case '>=': return c >= 0;
+ case '>': return c > 0;
+ case '<=': return c <= 0;
+ case '<': return c < 0;
+ }
+}
+
+/** One `||` alternative at exact-version granularity: the AND of its comparators' bounds. */
+function alternativeAdmitsVersion(r: string, version: ExactVersion): boolean | null {
+ if (r === '') return null;
+ // An operator separated from its version by spaces (`>= 6.1.0`) reads as
+ // one token. The class is a fixed literal set, so the replace is linear.
+ const joined = r.replace(/(>=|<=|>|<|=|\^|~)\s+/g, '$1');
+ let bounds: VersionBound[] = [];
+ const hyphen = hyphenRangeEnds(joined);
+ if (hyphen) {
+ const lo = readPartialVersion(hyphen[0].replace(/^v/i, ''));
+ const hi = readPartialVersion(hyphen[1].replace(/^v/i, ''));
+ if (lo === null || hi === null) return null;
+ if (lo.length > 0) bounds.push({ op: '>=', v: floorOf(lo) });
+ if (hi.length === 3) bounds.push({ op: '<=', v: floorOf(hi) });
+ else if (hi.length > 0) bounds.push({ op: '<', v: ceilingOf(hi) });
+ } else {
+ for (const token of joined.split(COMPARATOR_SEPARATOR).filter(Boolean)) {
+ const tokenBounds = comparatorBounds(token);
+ if (tokenBounds === null) return null;
+ bounds = bounds.concat(tokenBounds);
+ }
+ }
+ return bounds.every((bound) => boundAdmits(bound, version));
+}
+
+/**
+ * Decide whether a range admits one exact version — the reader ADR-0141 D2's
+ * dependency check needs, a sibling of {@link rangeAdmitsMajor}.
+ *
+ * Returns `true` / `false` on a positive determination, or `null` when the
+ * range is outside {@link DEPENDENCY_RANGE_GRAMMAR} or `version` is outside
+ * {@link readExactVersion}'s grammar. A `||` union admits when ANY alternative
+ * admits; when none does and any alternative is unreadable, the union is
+ * unreadable too (`null`), exactly as {@link rangeAdmitsMajor} combines them.
+ *
+ * ⛔ The handshake reads `null` as "admit with a warning"; the dependency check
+ * reads it as "not satisfied". Each caller owns that policy, so the two
+ * readers can share their parsing without sharing a verdict.
+ */
+export function rangeAdmitsVersion(range: string, version: string): boolean | null {
+ const exact = readExactVersion(version);
+ if (exact === null) return null;
+ const r = range.trim();
+ if (r.length > MAX_RANGE_LENGTH) return null;
+ let verdict: boolean | null = false;
+ for (const alternative of rangeAlternatives(r)) {
+ const admits = alternativeAdmitsVersion(alternative, exact);
+ if (admits === true) return true;
+ if (admits === null) verdict = null;
+ }
+ return verdict;
+}
+
+// ==========================================================================
+// ADR-0141 D2 — the pre-install dependency check
+// ==========================================================================
+
+/** The manifest slice the dependency check reads. */
+export interface DependencyDeclaringManifest {
+ id?: unknown;
+ name?: unknown;
+ /** `ManifestSchema.dependencies`: `{ [packageId]: versionRange }`. */
+ dependencies?: unknown;
+}
+
+/** What the check needs of an installed package: the registry row's manifest version. */
+export interface InstalledDependencyRecord {
+ manifest?: { version?: unknown };
+}
+
+/** One declared entry the check found unsatisfied, as the refusal carries it. */
+export type UnmetDependency =
+ | {
+ /** The delivered package that declares the entry. */
+ packageId: string;
+ /** The declared dependency id. */
+ dependency: string;
+ /** The declared range, as written. */
+ range: string;
+ /**
+ * `absent` — not installed and not delivered by the same act;
+ * `out-of-range` — installed at `installedVersion`, outside `range`;
+ * `unreadable-range` — `range` is outside {@link DEPENDENCY_RANGE_GRAMMAR};
+ * `unreadable-version` — installed at a version outside {@link readExactVersion}'s grammar.
+ */
+ found: 'absent' | 'out-of-range' | 'unreadable-range' | 'unreadable-version';
+ /** The installed version, as stored, when there is an installed record. */
+ installedVersion?: string;
+ }
+ | {
+ packageId: string;
+ /** `dependencies` is present but is not a `{ packageId: versionRange }` record. */
+ found: 'unreadable-declaration';
+ };
+
+/** What one install act hands {@link checkDeclaredDependencies}. */
+export interface DeclaredDependencyCheck {
+ /**
+ * The packages this act installs, each judged on its OWN entries (never a
+ * dependency's: those were judged when it was installed). One manifest for a
+ * single-package door; every package of an artifact for an artifact door.
+ */
+ delivered: readonly DependencyDeclaringManifest[];
+ /**
+ * Further ids the same act delivers without being judged itself (an
+ * artifact's own identity). Every `delivered` id counts already.
+ */
+ deliveredIds?: readonly string[];
+ /** The installed package record for an id (`SchemaRegistry.getPackage`), or `undefined`. */
+ getInstalled: (id: string) => InstalledDependencyRecord | undefined;
+}
+
+/** The id a delivered manifest is installed under — `id || name`, as the registry keys it. */
+function deliveredId(manifest: DependencyDeclaringManifest): string | undefined {
+ const id = manifest.id || manifest.name;
+ return typeof id === 'string' && id !== '' ? id : undefined;
+}
+
+/**
+ * ADR-0141 D2's one predicate: every declared entry of the delivered packages
+ * that the environment does not satisfy, in declaration order — empty when the
+ * install may proceed.
+ *
+ * An entry `(id, range)` is satisfied when `id` is delivered by the same act
+ * (presence only: the packages of one artifact are one tested unit, and an
+ * in-artifact range mismatch is the author tooling's to report, not the
+ * install's), or when `id` is installed at a `manifest.version` inside `range`.
+ * Fail closed: an unreadable range or installed version does not satisfy, and
+ * a `dependencies` that is present but not a record is reported, never read as
+ * "no dependencies". Every record entry is required; the record has no
+ * optional arm. Enablement is not judged.
+ *
+ * Pure: no throwing, no I/O. Doors call {@link assertDeclaredDependencies}.
+ */
+export function checkDeclaredDependencies(check: DeclaredDependencyCheck): UnmetDependency[] {
+ const delivered = new Set(check.deliveredIds ?? []);
+ for (const manifest of check.delivered) {
+ const id = deliveredId(manifest);
+ if (id !== undefined) delivered.add(id);
+ }
+ const unmet: UnmetDependency[] = [];
+ for (const manifest of check.delivered) {
+ const declared = manifest.dependencies;
+ if (declared === undefined || declared === null) continue;
+ const packageId = deliveredId(manifest) ?? '';
+ if (typeof declared !== 'object' || Array.isArray(declared)) {
+ unmet.push({ packageId, found: 'unreadable-declaration' });
+ continue;
+ }
+ for (const [dependency, declaredRange] of Object.entries(declared as Record)) {
+ if (delivered.has(dependency)) continue;
+ const range = typeof declaredRange === 'string' ? declaredRange : String(declaredRange);
+ const record = check.getInstalled(dependency);
+ const stored = record?.manifest?.version;
+ const installedVersion = record === undefined || stored === undefined || stored === null
+ ? undefined
+ : String(stored);
+ const withVersion = installedVersion === undefined ? {} : { installedVersion };
+ // Range first: an unreadable range cannot be satisfied by installing
+ // anything, so it is the fact the author acts on, present or not.
+ const rangeReadable = typeof declaredRange === 'string'
+ && rangeAdmitsVersion(declaredRange, '0.0.0') !== null;
+ if (!rangeReadable) {
+ unmet.push({ packageId, dependency, range, found: 'unreadable-range', ...withVersion });
+ continue;
+ }
+ if (record === undefined) {
+ unmet.push({ packageId, dependency, range, found: 'absent' });
+ continue;
+ }
+ const admits = typeof stored === 'string' ? rangeAdmitsVersion(range, stored) : null;
+ if (admits === null) {
+ unmet.push({ packageId, dependency, range, found: 'unreadable-version', ...withVersion });
+ } else if (!admits) {
+ unmet.push({ packageId, dependency, range, found: 'out-of-range', ...withVersion });
+ }
+ }
+ }
+ return unmet;
+}
+
+/** One `AppCompatibilityCheck` issue (`@objectstack/spec/system`) — the shape the refusal carries per unmet entry. */
+export interface DependencyMissingIssue {
+ severity: 'error';
+ category: 'dependency_missing';
+ message: string;
+}
+
+function unmetIssueMessage(entry: UnmetDependency): string {
+ if (entry.found === 'unreadable-declaration') {
+ return `Package '${entry.packageId}' declares \`dependencies\` in a shape that is not a `
+ + '{ "PACKAGE_ID": "VERSION_RANGE" } record, so what it depends on cannot be read.';
+ }
+ const head = `Package '${entry.packageId}' depends on '${entry.dependency}' ${entry.range}`;
+ switch (entry.found) {
+ case 'absent':
+ return `${head}, which is not installed. Install '${entry.dependency}' at a version inside ${entry.range} first.`;
+ case 'out-of-range':
+ return `${head}, which is installed at version ${entry.installedVersion}, outside that range. `
+ + `Install a version of '${entry.dependency}' inside ${entry.range} first.`;
+ case 'unreadable-range':
+ return `Package '${entry.packageId}' depends on '${entry.dependency}' with the range '${entry.range}', `
+ + `which is not a readable version range, so no installed version can satisfy it. Supported: ${DEPENDENCY_RANGE_GRAMMAR}.`;
+ case 'unreadable-version':
+ return `${head}, which is installed with ${entry.installedVersion === undefined ? 'no version' : `the version '${entry.installedVersion}'`}`
+ + ' — not a readable MAJOR.MINOR.PATCH version (a prerelease is outside the grammar), so it cannot be judged against the range.';
+ }
+}
+
+/**
+ * Brand for {@link DependencyMissingError}; `Symbol.for`, for the reason
+ * {@link PROTOCOL_INCOMPATIBLE_BRAND} gives.
+ */
+const DEPENDENCY_MISSING_BRAND = Symbol.for('objectstack.metadata-core.dependencyMissingError');
+
+/**
+ * ADR-0141 D2's refusal: an install whose delivered packages declare
+ * dependencies the environment does not satisfy. Nothing was registered,
+ * written or persisted.
+ *
+ * ## The status it declares: 422
+ *
+ * The status the sibling refusals at the same gate answer for a delivered
+ * manifest judged against the environment ahead of registration
+ * (`NAMESPACE_CONFLICT`, `DUPLICATE_ARTIFACT_OBJECT_NAME`); ADR-0141 merge
+ * question 5. `code` is redeclared as a literal so
+ * `check:error-status-conformance` derives this class as the producer of
+ * `DEPENDENCY_MISSING` at 422, and both status spellings are declared because
+ * doors read either one.
+ *
+ * ## What it carries
+ *
+ * `issues` — one `AppCompatibilityCheck` issue per unmet entry, the shape
+ * `AppCompatibilityCheckSchema` reserves the `dependency_missing` category in.
+ * The shared resolver (`resolveThrownHttpError`) passes a thrown `.issues`
+ * through into `error.details`, so any door answers them. `unmet` — the facts
+ * per entry (the dependent, the dependency, the range, what was found), which
+ * the resolver's closed `details` list drops; a door that recognises the error
+ * ({@link isDependencyMissingError}) answers them through
+ * {@link dependencyMissingAnswer}, and a Console renders its text from them.
+ */
+export class DependencyMissingError extends MetadataError {
+ readonly code = 'DEPENDENCY_MISSING' as const;
+ readonly status = 422;
+ readonly statusCode = 422;
+ readonly issues: readonly DependencyMissingIssue[];
+
+ constructor(public readonly unmet: readonly UnmetDependency[]) {
+ const issues = unmet.map((entry): DependencyMissingIssue => ({
+ severity: 'error',
+ category: 'dependency_missing',
+ message: unmetIssueMessage(entry),
+ }));
+ super(
+ 'DEPENDENCY_MISSING',
+ `Install refused: ${unmet.length === 1 ? 'a declared dependency is' : `${unmet.length} declared dependencies are`} `
+ + `not satisfied in this environment, and nothing was installed. ${issues.map((i) => i.message).join(' ')}`
+ + ' A package installs only after every package its manifest declares in `dependencies` is installed'
+ + ' at a version inside the declared range (ADR-0141 D2).',
+ );
+ this.issues = issues;
+ (this as Record)[DEPENDENCY_MISSING_BRAND] = true;
+ }
+}
+
+/** Recognition predicate for {@link DependencyMissingError}, by brand — a lookalike is not recognised. */
+export function isDependencyMissingError(e: unknown): e is DependencyMissingError {
+ return typeof e === 'object' && e !== null
+ && (e as Record)[DEPENDENCY_MISSING_BRAND] === true;
+}
+
+/**
+ * Run ADR-0141 D2's check at an install door: returns silently when every
+ * declared entry is satisfied, throws {@link DependencyMissingError} naming
+ * EVERY unmet entry otherwise. Doors call it right after
+ * {@link assertProtocolCompat}, before anything registers.
+ */
+export function assertDeclaredDependencies(check: DeclaredDependencyCheck): void {
+ const unmet = checkDeclaredDependencies(check);
+ if (unmet.length > 0) throw new DependencyMissingError(unmet);
+}
+
+/** What an HTTP door answers for a {@link DependencyMissingError}. */
+export interface DependencyMissingAnswer {
+ status: DependencyMissingError['status'];
+ code: DependencyMissingError['code'];
+ /** The error's own prose, unchanged. */
+ message: string;
+ /**
+ * A CLOSED shape: the `AppCompatibilityCheck` verdict (`compatible` and its
+ * `issues`) and the per-entry facts, named one by one — ⛔ not a spread of
+ * the error, so a member later added to it does not reach the wire without
+ * a decision here.
+ */
+ details: {
+ compatible: false;
+ issues: readonly DependencyMissingIssue[];
+ unmet: readonly UnmetDependency[];
+ };
+}
+
+/**
+ * The ONE answer every HTTP install door gives ADR-0141 D2's refusal: a door
+ * asks {@link isDependencyMissingError} ahead of its generic catch, then
+ * answers through this. ⛔ No door restates the shape.
+ */
+export function dependencyMissingAnswer(err: DependencyMissingError): DependencyMissingAnswer {
+ return {
+ status: err.status,
+ code: err.code,
+ message: err.message,
+ details: { compatible: false, issues: err.issues, unmet: err.unmet },
+ };
+}
diff --git a/packages/metadata-protocol/src/protocol-package-install-dependencies.test.ts b/packages/metadata-protocol/src/protocol-package-install-dependencies.test.ts
new file mode 100644
index 00000000000..349b6a68559
--- /dev/null
+++ b/packages/metadata-protocol/src/protocol-package-install-dependencies.test.ts
@@ -0,0 +1,96 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * ADR-0141 D2 at the protocol install primitive: `installPackage` refuses a
+ * package whose declared `dependencies` the environment does not satisfy,
+ * before it registers or persists anything.
+ *
+ * Before this check the primitive installed such a package and the gap
+ * surfaced later, at the dependent's first cross-app save. The refusal is the
+ * ADR-0112 envelope — `code` + `status` — with one `dependency_missing` issue
+ * per unmet entry, every entry in one answer.
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+import { DependencyMissingError } from '@objectstack/metadata-core';
+import { ObjectStackProtocolImplementation } from './index.js';
+
+function makeImpl(installed: Record = {}) {
+ const registryCalls: Array<{ manifest: any }> = [];
+ const engine = {
+ registry: {
+ getPackage: (id: string) => (id in installed ? { manifest: { id, ...installed[id] } } : undefined),
+ installPackage: (manifest: any) => {
+ registryCalls.push({ manifest });
+ return { manifest, status: 'installed', enabled: true };
+ },
+ },
+ find: async () => [],
+ };
+ const publish = vi.fn(async () => ({ success: true }));
+ const services = new Map([['package', { publish }]]);
+ const impl = new ObjectStackProtocolImplementation(engine as any, () => services);
+ return { impl, registryCalls, publish };
+}
+
+const PSA = {
+ id: 'com.acme.psa', name: 'PSA', type: 'app', version: '1.0.0',
+ dependencies: { 'com.hotcrm.sales': '^6.0.0', 'com.acme.billing': '>=2.1.0' },
+};
+
+async function refusal(impl: ObjectStackProtocolImplementation, manifest: unknown): Promise {
+ try {
+ await (impl as any).installPackage({ manifest });
+ } catch (e) {
+ return e as DependencyMissingError;
+ }
+ throw new Error('expected installPackage to refuse');
+}
+
+describe('installPackage — ADR-0141 D2 pre-install dependency check', () => {
+ it('refuses a package whose dependencies are absent or out of range: 422 DEPENDENCY_MISSING, every entry, nothing written', async () => {
+ const { impl, registryCalls, publish } = makeImpl({ 'com.hotcrm.sales': { version: '5.4.0' } });
+ const err = await refusal(impl, PSA);
+ expect(err).toBeInstanceOf(DependencyMissingError);
+ expect(err.code).toBe('DEPENDENCY_MISSING');
+ expect(err.status).toBe(422);
+ expect(err.unmet).toEqual([
+ { packageId: 'com.acme.psa', dependency: 'com.hotcrm.sales', range: '^6.0.0', found: 'out-of-range', installedVersion: '5.4.0' },
+ { packageId: 'com.acme.psa', dependency: 'com.acme.billing', range: '>=2.1.0', found: 'absent' },
+ ]);
+ expect(err.issues.map((i) => i.category)).toEqual(['dependency_missing', 'dependency_missing']);
+ // Refused BEFORE any registry write or durable persist.
+ expect(registryCalls).toHaveLength(0);
+ expect(publish).not.toHaveBeenCalled();
+ });
+
+ it('installs once every declared dependency is installed inside its range', async () => {
+ const { impl, registryCalls, publish } = makeImpl({
+ 'com.hotcrm.sales': { version: '6.2.0' },
+ 'com.acme.billing': { version: '2.1.0' },
+ });
+ const res: any = await (impl as any).installPackage({ manifest: PSA });
+ expect(res.package.status).toBe('installed');
+ expect(registryCalls).toHaveLength(1);
+ expect(publish).toHaveBeenCalledTimes(1);
+ });
+
+ it('fails closed on a range outside the stated grammar', async () => {
+ const { impl, registryCalls } = makeImpl({ 'com.hotcrm.sales': { version: '6.2.0' } });
+ const err = await refusal(impl, { ...PSA, dependencies: { 'com.hotcrm.sales': 'latest' } });
+ expect(err.code).toBe('DEPENDENCY_MISSING');
+ expect(err.unmet).toEqual([
+ { packageId: 'com.acme.psa', dependency: 'com.hotcrm.sales', range: 'latest', found: 'unreadable-range', installedVersion: '6.2.0' },
+ ]);
+ expect(registryCalls).toHaveLength(0);
+ });
+
+ it('leaves a package that declares nothing exactly as it installed before', async () => {
+ const { impl, registryCalls } = makeImpl();
+ const res: any = await (impl as any).installPackage({
+ manifest: { id: 'com.acme.solo', name: 'Solo', type: 'app', version: '1.0.0' },
+ });
+ expect(res.package.status).toBe('installed');
+ expect(registryCalls).toHaveLength(1);
+ });
+});
diff --git a/packages/metadata-protocol/src/protocol-package-install.ts b/packages/metadata-protocol/src/protocol-package-install.ts
index f4612b590be..a0c718cb6a6 100644
--- a/packages/metadata-protocol/src/protocol-package-install.ts
+++ b/packages/metadata-protocol/src/protocol-package-install.ts
@@ -45,7 +45,7 @@
import type { ObjectStackProtocolImplementation, PackagePersistService } from './protocol.js';
import { packagePersistFailureError } from './protocol.js';
-import { assertProtocolCompat } from '@objectstack/metadata-core';
+import { assertDeclaredDependencies, assertProtocolCompat } from '@objectstack/metadata-core';
// [#24086] The install door's own manifest declaration — `ManifestSchema` with
// `scope` narrowed to `project` — judged whole at the top of `installPackage`.
import { PackageInstallManifestSchema } from '@objectstack/spec/api';
@@ -258,6 +258,19 @@ export async function installPackage(
// only); an unparsed range never causes a false rejection.
assertProtocolCompat(manifest);
+ // ADR-0141 D2 — the pre-install dependency check, beside the handshake and
+ // as early: every `manifest.dependencies` entry must name a package
+ // installed at a version inside its range (this primitive delivers one
+ // package, so nothing else is co-delivered), or the install is refused
+ // `422 DEPENDENCY_MISSING` naming every unmet entry, before the registry
+ // write and the persist below. One predicate for every install door
+ // (`@objectstack/metadata-core`); ⛔ it does not run at the manifest edit
+ // (`updatePackage`) or at the boot rehydrate, which are not install acts.
+ assertDeclaredDependencies({
+ delivered: [manifest],
+ getInstalled: (id) => this.engine.registry.getPackage?.(id),
+ });
+
// [#21243] What the registry holds for this id BEFORE the write, so a
// failed persist below can put exactly that back. Read here because the
// install REPLACES the row object (and may register the namespace).
diff --git a/packages/runtime/src/domains/packages-install-dependency-missing.test.ts b/packages/runtime/src/domains/packages-install-dependency-missing.test.ts
new file mode 100644
index 00000000000..93235be32c0
--- /dev/null
+++ b/packages/runtime/src/domains/packages-install-dependency-missing.test.ts
@@ -0,0 +1,139 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * ADR-0141 D2 at `POST /api/v1/packages`: a package whose declared
+ * `dependencies` the environment does not satisfy is refused `422
+ * DEPENDENCY_MISSING` with its facts, on both arms of the door, and nothing is
+ * installed.
+ *
+ * ## The harness
+ *
+ * The protocol primitive and the registry are REAL
+ * (`ObjectStackProtocolImplementation` over a `SchemaRegistry`), so the
+ * refusal under test is the one production throws, the dependency is
+ * installed through the same door the dependent is, and "not installed" is
+ * read back through the door's `GET`. Each refusal asserts the ADR-0112
+ * envelope (`code` and `status`), never a bare throw. `OS_HOME` is redirected
+ * because the lit controls really install.
+ */
+
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import { mkdtempSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { SchemaRegistry } from '@objectstack/objectql';
+import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
+import { ApiErrorSchema } from '@objectstack/spec/api';
+import { HttpDispatcher } from '../http-dispatcher.js';
+
+let home: string;
+let priorHome: string | undefined;
+
+beforeAll(() => {
+ priorHome = process.env.OS_HOME;
+ home = mkdtempSync(join(tmpdir(), 'os-install-dependency-'));
+ process.env.OS_HOME = home;
+});
+
+afterAll(() => {
+ if (priorHome === undefined) delete process.env.OS_HOME;
+ else process.env.OS_HOME = priorHome;
+ rmSync(home, { recursive: true, force: true });
+});
+
+const ADMIN = () => ({
+ request: {},
+ environmentId: 'pkg-install-dependency-test',
+ executionContext: {
+ userId: 'u_pkg_admin',
+ systemPermissions: ['manage_metadata', 'studio.access', 'setup.access'],
+ },
+}) as any;
+
+/** The depended-on app, at a version the dependent's range refuses and one it admits. */
+const SALES_5 = { id: 'com.example.sales', name: 'Sales', version: '5.4.0', scope: 'project', type: 'app' };
+const SALES_6 = { ...SALES_5, version: '6.2.0' };
+/** The dependent: 「请先安装 Sales 6.x」. */
+const PSA = {
+ id: 'com.example.psa', name: 'PSA', version: '1.0.0', scope: 'project', type: 'app',
+ dependencies: { 'com.example.sales': '6.x' },
+};
+
+function silentRegistry(): SchemaRegistry {
+ const registry = new SchemaRegistry({ multiTenant: false, collisionPolicy: 'error' });
+ (registry as unknown as { logLevel: string }).logLevel = 'silent';
+ return registry;
+}
+
+/** The composed door; `protocol: null` takes the door's no-protocol fallback arm. */
+function door(options: { protocol?: null } = {}) {
+ const registry = silentRegistry();
+ const engine: any = { registry, find: async () => [], manifests: new Map() };
+ const protocol = options.protocol === null ? null : new ObjectStackProtocolImplementation(engine, () => new Map());
+ const ql: any = { registry, manifests: new Map() };
+ const kernel: any = {
+ getService: (name: string) => {
+ if (name === 'protocol') return protocol ? Promise.resolve(protocol) : null;
+ if (name === 'objectql') return Promise.resolve(ql);
+ return null;
+ },
+ context: { getService: (name: string) => (name === 'objectql' ? ql : null) },
+ };
+ return { dispatcher: new HttpDispatcher(kernel), registry };
+}
+
+const install = (d: ReturnType, manifest: unknown) =>
+ d.dispatcher.handlePackages('', 'POST', { manifest }, {}, ADMIN());
+const read = (d: ReturnType, id: string) =>
+ d.dispatcher.handlePackages(`/${id}`, 'GET', undefined, {}, ADMIN());
+
+function expectDependencyRefusal(r: any, unmet: unknown[]) {
+ expect(r.handled).toBe(true);
+ expect(r.response.status, JSON.stringify(r.response.body)).toBe(422);
+ const error = r.response.body.error;
+ expect(r.response.body.success).toBe(false);
+ expect(error.code).toBe('DEPENDENCY_MISSING');
+ expect(error.httpStatus).toBe(422);
+ expect(ApiErrorSchema.safeParse(error).success).toBe(true);
+ // The closed details shape: the compatibility verdict and the facts.
+ expect(Object.keys(error.details).sort()).toEqual(['compatible', 'issues', 'unmet']);
+ expect(error.details.compatible).toBe(false);
+ expect(error.details.unmet).toEqual(unmet);
+ expect(error.details.issues).toHaveLength(unmet.length);
+ for (const issue of error.details.issues) {
+ expect(issue).toEqual({ severity: 'error', category: 'dependency_missing', message: expect.any(String) });
+ expect(error.message).toContain(issue.message);
+ }
+}
+
+for (const arm of ['composed', 'fallback'] as const) {
+ const make = () => (arm === 'composed' ? door() : door({ protocol: null }));
+
+ describe(`POST /api/v1/packages (${arm} arm): ADR-0141 D2 refuses an unsatisfied dependency`, () => {
+ it('refuses when the dependency is not installed: 422 with the facts, nothing installed', async () => {
+ const d = make();
+ expectDependencyRefusal(await install(d, PSA), [
+ { packageId: 'com.example.psa', dependency: 'com.example.sales', range: '6.x', found: 'absent' },
+ ]);
+ expect(d.registry.getPackage(PSA.id)).toBeUndefined();
+ expect((await read(d, PSA.id) as any).response.status).toBe(404);
+ });
+
+ it('refuses when the dependency is installed outside the range, naming the version found', async () => {
+ const d = make();
+ expect((await install(d, SALES_5) as any).response.status).toBe(201);
+ expectDependencyRefusal(await install(d, PSA), [
+ { packageId: 'com.example.psa', dependency: 'com.example.sales', range: '6.x', found: 'out-of-range', installedVersion: '5.4.0' },
+ ]);
+ expect(d.registry.getPackage(PSA.id)).toBeUndefined();
+ });
+
+ it('lit control: installs once the dependency is installed inside the range', async () => {
+ const d = make();
+ expect((await install(d, SALES_6) as any).response.status).toBe(201);
+ const r: any = await install(d, PSA);
+ expect(r.response.status, JSON.stringify(r.response.body)).toBe(201);
+ expect((await read(d, PSA.id) as any).response.status).toBe(200);
+ });
+ });
+}
diff --git a/packages/runtime/src/domains/packages.ts b/packages/runtime/src/domains/packages.ts
index 7d8498c3c98..6311687ba31 100644
--- a/packages/runtime/src/domains/packages.ts
+++ b/packages/runtime/src/domains/packages.ts
@@ -41,7 +41,15 @@ import { readEffectiveTenancyPosture } from './activation-gate.js';
// and its no-protocol fallback runs the same handshake.
// [#21762] The refusal's answer is the shared one, which the install-local door
// (`@objectstack/cloud-connection`) gives too: imported, never restated.
-import { assertProtocolCompat, isProtocolIncompatibleError, protocolIncompatibleAnswer } from '@objectstack/metadata-core';
+import {
+ assertDeclaredDependencies,
+ assertProtocolCompat,
+ dependencyMissingAnswer,
+ isDependencyMissingError,
+ isProtocolIncompatibleError,
+ protocolIncompatibleAnswer,
+ type InstalledDependencyRecord,
+} from '@objectstack/metadata-core';
// [#7560] ADR-0070's read-only-package rule — the SAME predicate the metadata
// authoring path asks before refusing a write INTO a platform package
// (`saveMetaItem` → `WRITABLE_PACKAGE_REQUIRED`). Imported, never re-spelled:
@@ -1462,6 +1470,13 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin
// the primitive's, so no-range and unparsed ranges warn the
// same way on both arms.
assertProtocolCompat(manifest);
+ // ADR-0141 D2 — and the SAME pre-install dependency check,
+ // through the one predicate, before the registry write:
+ // this arm is an install act exactly as the primitive is.
+ assertDeclaredDependencies({
+ delivered: [manifest],
+ getInstalled: (id) => registry.getPackage(id) as InstalledDependencyRecord | undefined,
+ });
// [#23972] The same origin the protocol primitive stamps:
// a package installed through this door is authored.
pkg = registry.installPackage(manifest, 'authored', settings);
@@ -1486,6 +1501,18 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin
response: deps.error(refusal.message, refusal.status, { code: refusal.code, ...refusal.details }),
};
}
+ // ADR-0141 D2's refusal, recognised the same way and for the
+ // same reason: the shared resolver would pass its `issues`
+ // through but drop the per-entry facts (`unmet`) a Console
+ // renders its text from. Both arms above reach it — the
+ // primitive throws it, and the fallback runs the same check.
+ if (isDependencyMissingError(err)) {
+ const refusal = dependencyMissingAnswer(err);
+ return {
+ handled: true,
+ response: deps.error(refusal.message, refusal.status, { code: refusal.code, ...refusal.details }),
+ };
+ }
throw err;
}
// [#18058 → #18877] HONOUR `enableOnInstall`, which this door declared
diff --git a/packages/spec/src/api/error-code-ledger.zod.ts b/packages/spec/src/api/error-code-ledger.zod.ts
index 3ddf8644369..d49f4f9e4c2 100644
--- a/packages/spec/src/api/error-code-ledger.zod.ts
+++ b/packages/spec/src/api/error-code-ledger.zod.ts
@@ -721,6 +721,20 @@ export const ERROR_CODE_LEDGER = {
'WRITABLE_PACKAGE_REQUIRED',
],
'@objectstack/metadata-core': [
+ // [ADR-0141 D2] The pre-install dependency check refused an install whose
+ // delivered package declares a `manifest.dependencies` entry the
+ // environment does not satisfy: not installed (nor delivered by the same
+ // act), installed outside the declared range, or a range or installed
+ // version outside the stated grammar (`DependencyMissingError`,
+ // `protocol-handshake.ts`). Answered 422, the status the sibling refusals
+ // at the same gate answer for a delivered manifest judged against the
+ // environment ahead of registration (`NAMESPACE_CONFLICT`). The three
+ // install doors — the protocol install primitive, the no-protocol fallback
+ // of `POST /api/v1/packages` and `POST /api/v1/marketplace/install-local`
+ // — answer it through the shared `dependencyMissingAnswer`, carrying one
+ // `dependency_missing` `AppCompatibilityCheck` issue and one fact row per
+ // unmet entry in `error.details`.
+ 'DEPENDENCY_MISSING',
// [ADR-0131 D6/D7, #23429] `readLegacyOrganizationRows`
// (`legacy-organization-row.ts`, `LegacyOrganizationRowsUnreadable`) could
// not tell a `sys_metadata`-family table's legacy organization rows from
diff --git a/scripts/adr-anchors/packages__metadata-core__src__protocol-handshake.ts.json b/scripts/adr-anchors/packages__metadata-core__src__protocol-handshake.ts.json
new file mode 100644
index 00000000000..d567b340b34
--- /dev/null
+++ b/scripts/adr-anchors/packages__metadata-core__src__protocol-handshake.ts.json
@@ -0,0 +1,8 @@
+{
+ "file": "packages/metadata-core/src/protocol-handshake.ts",
+ "adrs": [
+ "ADR-0141",
+ "ADR-0087"
+ ],
+ "invariant": "ADR-0141 D2's pre-install dependency check is ONE predicate (`checkDeclaredDependencies`) every install door runs right after the ADR-0087 D1 handshake and before anything registers: the protocol `installPackage` primitive, the no-protocol fallback of `POST /api/v1/packages`, and `POST /api/v1/marketplace/install-local` — never a boot rehydrate and never `PATCH /api/v1/packages/:id`. An entry is satisfied when its id is delivered by the same act (presence only) or installed at a version inside its range; an unreadable range or installed version does NOT satisfy (fail closed), the opposite of the handshake beside it, which admits an unrecognised range with a warning. `rangeAdmitsVersion` is a sibling of `rangeAdmitsMajor` sharing its alternative split, length bound and operator peel; ⛔ no fourth range reader, and the handshake's verdicts do not move."
+}
diff --git a/scripts/docs-audit/handwritten-docs.json b/scripts/docs-audit/handwritten-docs.json
index 8114abce703..a9a2e16b8fc 100644
--- a/scripts/docs-audit/handwritten-docs.json
+++ b/scripts/docs-audit/handwritten-docs.json
@@ -90,6 +90,7 @@
"content/docs/data-modeling/seed-data.mdx",
"content/docs/data-modeling/validation-rules.mdx",
"content/docs/data-modeling/validation.mdx",
+ "content/docs/deployment/app-dependencies.mdx",
"content/docs/deployment/backup-restore.mdx",
"content/docs/deployment/cli.mdx",
"content/docs/deployment/cloud-connection.mdx",