diff --git a/.changeset/25792-console-dependency-missing-refusal.md b/.changeset/25792-console-dependency-missing-refusal.md
new file mode 100644
index 00000000000..0866bc047a9
--- /dev/null
+++ b/.changeset/25792-console-dependency-missing-refusal.md
@@ -0,0 +1,16 @@
+---
+'@object-ui/app-shell': patch
+'@object-ui/i18n': patch
+---
+
+feat(app-shell,i18n): the Console draws the install refusal `DEPENDENCY_MISSING` in the author's terms, one line per unmet dependency
+
+Clause-②: no
+Metadata-protocol: none
+
+ADR-0141 D2 refuses to install a package whose declared `dependencies` the environment does not satisfy (`422 DEPENDENCY_MISSING`, `details.unmet[]` naming each entry's `dependency`, `range`, what was `found` and the `installedVersion`). The Console showed the envelope's English sentence, the same in every locale and without the ranges.
+
+- **`@object-ui/app-shell`:** the three install surfaces that go through `install-local` (the package page's Install, the catalog page's organization install on a self-hosted runtime, and Setup → Packages → Import) draw the refusal from its facts: a heading saying nothing was installed, then one line per unmet entry, such as 「请先安装 app.objectstack.hotcrm 6.x。」 or "Install com.acme.billing ^2.1.0 first. Version 1.4.0 is installed." `installLocal()` now carries the envelope's `details` on the error it throws, and the Packages page's request helper carries `code` and `details`. A refusal whose facts the Console cannot read in full (an older runtime, or a `found` value it does not know) keeps the server's message.
+- **`@object-ui/i18n`:** `marketplace.install.dependencyMissing.*` (a heading and one line for each `found` value) in all ten locale packs.
+
+What to do: nothing.
diff --git a/scripts/sdui-manifest.sources/i18n.json b/scripts/sdui-manifest.sources/i18n.json
index 6fe92078785..9acdc472ef8 100644
--- a/scripts/sdui-manifest.sources/i18n.json
+++ b/scripts/sdui-manifest.sources/i18n.json
@@ -1 +1 @@
-{"package":"ui/packages/i18n","sources":31,"sha256":"ae5a36bc2327b985cb909eed35e099d1b2d953b7c4cb7dda463e4d8d3ef5cc86","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
+{"package":"ui/packages/i18n","sources":31,"sha256":"7e7907af7823796538ad5a70e56fb172dc533ec3988a2318c8aeddf7069af14a","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
diff --git a/ui/packages/app-shell/src/console/marketplace/MarketplacePackagePage.tsx b/ui/packages/app-shell/src/console/marketplace/MarketplacePackagePage.tsx
index e8d061329e7..8db13123180 100644
--- a/ui/packages/app-shell/src/console/marketplace/MarketplacePackagePage.tsx
+++ b/ui/packages/app-shell/src/console/marketplace/MarketplacePackagePage.tsx
@@ -80,6 +80,7 @@ import {
type LocalInstallStrippedLock,
type CloudInstallationInfo,
} from './marketplaceApi.js';
+import { useDependencyMissingText } from './dependencyMissing.js';
import { getCloudBase, getRuntimeConfig, isMarketplaceEnabled } from '../../runtime-config.js';
import { emitMetadataRefresh } from '../../assistant/assistantBus.js';
import { useMetadata } from '../../providers/MetadataProvider.js';
@@ -144,6 +145,7 @@ export function MarketplacePackagePage() {
const { packageId, appName } = useParams<{ packageId?: string; appName?: string }>();
const { isAdmin, isResolved } = useWorkspaceAdminStatus();
const { t, language } = useObjectTranslation();
+ const dependencyMissingText = useDependencyMissingText();
// Dates and numbers on this surface read the display locale; a bare
// `toLocale*()` call used the MACHINE's locale (objectui#9909).
const displayLocale = useDisplayLocale();
@@ -637,7 +639,13 @@ export function MarketplacePackagePage() {
// remedy, and the page has nothing to add to it.
const code = e?.code;
let msg = e?.message ?? String(e);
- if (errorCodeIs(e, 'MANIFEST_CONFLICT')) {
+ // ADR-0141 D2 — an unsatisfied declared dependency, drawn from the
+ // refusal's facts in the reader's locale; without facts the envelope's
+ // message stands.
+ const dependencyMissing = dependencyMissingText(e);
+ if (dependencyMissing) {
+ msg = dependencyMissing;
+ } else if (errorCodeIs(e, 'MANIFEST_CONFLICT')) {
msg = t('marketplace.install.localManifestConflict', { message: msg });
} else if (errorCodeIs(e, 'UNAUTHENTICATED')) {
msg = t('marketplace.install.localUnauthorized');
diff --git a/ui/packages/app-shell/src/console/marketplace/MarketplacePage.tsx b/ui/packages/app-shell/src/console/marketplace/MarketplacePage.tsx
index a65c3701b4b..8f182d19fa5 100644
--- a/ui/packages/app-shell/src/console/marketplace/MarketplacePage.tsx
+++ b/ui/packages/app-shell/src/console/marketplace/MarketplacePage.tsx
@@ -45,6 +45,7 @@ import { emitMetadataRefresh } from '../../assistant/assistantBus.js';
import { appRouteSegment, filterActiveApps, resolveKeyedI18nLabel } from '../../utils/index.js';
import { waitForServedApp, type ServedApp } from './waitForServedApp.js';
import { splitPackageSuffix } from './middleTruncate.js';
+import { useDependencyMissingText } from './dependencyMissing.js';
import { packagesHref } from '../../views/metadata-admin/packageSource.js';
/**
@@ -214,6 +215,7 @@ export function MarketplacePage() {
const { appName } = useParams();
const { isAdmin, isResolved } = useWorkspaceAdminStatus();
const { t, language } = useObjectTranslation();
+ const dependencyMissingText = useDependencyMissingText();
const { appLabel } = useObjectLabel();
// The adapter `MetadataProvider` reads through: the deploy wait asks the
// same `GET /meta/app` the cache would, without the cache persisting it.
@@ -412,7 +414,9 @@ export function MarketplacePage() {
await load();
}
} catch (e: any) {
- setOrgMsg({ ok: false, text: e?.message ?? String(e) });
+ // ADR-0141 D2 — a `DEPENDENCY_MISSING` refusal reads one line per unmet
+ // dependency, from its own facts; anything else keeps its message.
+ setOrgMsg({ ok: false, text: dependencyMissingText(e) ?? e?.message ?? String(e) });
} finally {
setOrgInstalling(null);
}
@@ -623,7 +627,7 @@ export function MarketplacePage() {
{orgDeployNote}
{orgMsg && (
-
+
{orgMsg.text}
)}
diff --git a/ui/packages/app-shell/src/console/marketplace/__tests__/MarketplacePackagePage.dependencyMissing-25792.test.tsx b/ui/packages/app-shell/src/console/marketplace/__tests__/MarketplacePackagePage.dependencyMissing-25792.test.tsx
new file mode 100644
index 00000000000..79da87e5cb2
--- /dev/null
+++ b/ui/packages/app-shell/src/console/marketplace/__tests__/MarketplacePackagePage.dependencyMissing-25792.test.tsx
@@ -0,0 +1,142 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * The package page draws the `422 DEPENDENCY_MISSING` install refusal
+ * (ADR-0141 D2) in the author's terms — 「请先安装 HotCRM 6.x」 — one line per
+ * unmet entry, from the refusal's own facts (objectstack#25792 item 3).
+ *
+ * `marketplaceApi` is NOT mocked: one stubbed `fetch` answers by path, so the
+ * facts the page draws are the ones that crossed the wire. `t` echoes the KEY
+ * and the interpolated facts: the claim is WHICH line each entry gets and that
+ * it carries that entry's id, range and version.
+ */
+
+import '@testing-library/jest-dom/vitest';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
+
+vi.mock('react-router-dom', () => ({
+ useNavigate: () => vi.fn(),
+ useParams: () => ({ packageId: 'pkg_psa' }),
+}));
+
+vi.mock('@object-ui/auth', async (importOriginal) => ({
+ ...(await importOriginal
>()),
+ useWorkspaceAdminStatus: () => ({ isAdmin: true, isResolved: true }),
+}));
+
+vi.mock('@object-ui/i18n', async (importOriginal) => ({
+ ...(await importOriginal>()),
+ useObjectTranslation: () => ({
+ t: (key: string, o?: Record) => {
+ const facts = ['dependency', 'range', 'installedVersion'].filter((k) => o?.[k] !== undefined).map((k) => `${k}=${String(o?.[k])}`);
+ return facts.length > 0 ? `«${key}|${facts.join('|')}»` : `«${key}»`;
+ },
+ language: 'en',
+ }),
+}));
+
+vi.mock('../../../assistant/assistantBus', () => ({ emitMetadataRefresh: () => {} }));
+vi.mock('../../../providers/MetadataProvider', () => ({ useMetadata: () => ({ refresh: async () => {} }) }));
+vi.mock('../../../components/SuggestedBindingsPanel', () => ({
+ SuggestedBindingsPanel: () => ,
+}));
+
+import { initRuntimeConfig, resetRuntimeConfigForTesting } from '../../../runtime-config';
+import { MarketplacePackagePage } from '../MarketplacePackagePage';
+
+const INSTALL_PATH = '/api/v1/marketplace/install-local';
+const MANIFEST_PATH = '/api/v1/marketplace/packages/pkg_psa/versions/latest/manifest';
+const CATALOG_PACKAGE = {
+ package: { id: 'pkg_psa', manifest_id: 'com.acme.psa', display_name: 'PSA', latest_version: { id: 'ver_1', version: '1.0.0' } },
+ versions: [{ id: 'ver_1', version: '1.0.0' }],
+};
+const SERVER_MESSAGE = 'SERVER-ENGLISH sentinel: Install refused: a declared dependency is not satisfied in this environment.';
+
+let installBody: unknown;
+
+const answer = (status: number, body: unknown) => ({
+ ok: status >= 200 && status < 300,
+ status,
+ statusText: '',
+ headers: new Headers({ 'content-type': 'application/json' }),
+ json: async () => body,
+ text: async () => JSON.stringify(body),
+});
+
+async function boot() {
+ resetRuntimeConfigForTesting();
+ vi.stubGlobal('fetch', vi.fn(async (input: unknown, init?: { method?: string }) => {
+ const path = new URL(String(input), 'http://console.test').pathname;
+ const method = (init?.method ?? 'GET').toUpperCase();
+ if (path === '/api/v1/runtime/config') {
+ return answer(200, {
+ cloudUrl: '', singleEnvironment: true,
+ features: { installLocal: true, marketplace: true, aiStudio: true, autoPublishAiBuilds: true },
+ branding: { productName: 'ObjectOS', productShortName: 'ObjectOS' },
+ });
+ }
+ if (path === INSTALL_PATH && method === 'GET') return answer(200, { success: true, data: { items: [], total: 0 } });
+ if (path === INSTALL_PATH && method === 'POST') return answer(422, installBody);
+ if (path === MANIFEST_PATH) {
+ return answer(200, { success: true, data: { manifest: { id: 'com.acme.psa', version: '1.0.0', type: 'app' }, version: '1.0.0', version_id: 'ver_1' } });
+ }
+ if (path === '/api/v1/marketplace/packages/pkg_psa') return answer(200, { success: true, data: CATALOG_PACKAGE });
+ return answer(404, { success: false, error: { code: 'NOT_FOUND', message: `no route ${method} ${path}` } });
+ }));
+ await initRuntimeConfig();
+}
+
+async function install() {
+ render();
+ const button = await screen.findByRole('button', { name: '«marketplace.action.install»' });
+ await act(async () => {});
+ fireEvent.click(button);
+}
+
+const refusal = (details: unknown) => ({
+ success: false,
+ error: { code: 'DEPENDENCY_MISSING', httpStatus: 422, message: SERVER_MESSAGE, details },
+});
+
+beforeEach(() => { installBody = undefined; });
+afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+ resetRuntimeConfigForTesting();
+});
+
+describe('the package page draws DEPENDENCY_MISSING from its facts (objectstack#25792)', () => {
+ it('one line per unmet entry under the heading, in place of the server\'s English sentence', async () => {
+ installBody = refusal({
+ compatible: false,
+ issues: [
+ { severity: 'error', category: 'dependency_missing', message: 'not installed' },
+ { severity: 'error', category: 'dependency_missing', message: 'out of range' },
+ ],
+ unmet: [
+ { packageId: 'com.acme.psa', dependency: 'app.objectstack.hotcrm', range: '6.x', found: 'absent' },
+ { packageId: 'com.acme.psa', dependency: 'com.acme.billing', range: '^2.1.0', found: 'out-of-range', installedVersion: '1.4.0' },
+ ],
+ });
+ await boot();
+ await install();
+
+ const note = await screen.findByText(/marketplace\.install\.dependencyMissing\.heading/);
+ expect(note.textContent!.split('\n')).toEqual([
+ '«marketplace.install.dependencyMissing.heading»',
+ '• «marketplace.install.dependencyMissing.absent|dependency=app.objectstack.hotcrm|range=6.x|installedVersion=—»',
+ '• «marketplace.install.dependencyMissing.outOfRange|dependency=com.acme.billing|range=^2.1.0|installedVersion=1.4.0»',
+ ]);
+ expect(screen.queryByText(SERVER_MESSAGE)).not.toBeInTheDocument();
+ });
+
+ it('CONTROL: a refusal without facts it can read keeps the envelope\'s own message', async () => {
+ installBody = refusal({ compatible: false, issues: [] });
+ await boot();
+ await install();
+
+ expect(await screen.findByText(SERVER_MESSAGE)).toBeInTheDocument();
+ expect(screen.queryByText(/dependencyMissing/)).not.toBeInTheDocument();
+ });
+});
diff --git a/ui/packages/app-shell/src/console/marketplace/__tests__/MarketplacePage.orgInstallDependencyMissing-25792.test.tsx b/ui/packages/app-shell/src/console/marketplace/__tests__/MarketplacePage.orgInstallDependencyMissing-25792.test.tsx
new file mode 100644
index 00000000000..5d728c34f52
--- /dev/null
+++ b/ui/packages/app-shell/src/console/marketplace/__tests__/MarketplacePage.orgInstallDependencyMissing-25792.test.tsx
@@ -0,0 +1,146 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * The catalog page's org install on a self-hosted runtime (`installLocal`)
+ * draws the `422 DEPENDENCY_MISSING` refusal (ADR-0141 D2) in the author's
+ * terms, one line per unmet entry, from the refusal's own facts
+ * (objectstack#25792 item 3).
+ *
+ * The catalog lists are stubbed; `installLocal` is the REAL client, so the
+ * facts the strip draws are the ones that crossed the wire. `t` echoes the KEY
+ * and the interpolated facts: the claim is WHICH line each entry gets and that
+ * it carries that entry's id, range and version.
+ */
+
+import '@testing-library/jest-dom/vitest';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
+
+vi.mock('react-router-dom', () => ({
+ useNavigate: () => vi.fn(),
+ useParams: () => ({}),
+}));
+
+vi.mock('@object-ui/auth', async (importOriginal) => ({
+ ...(await importOriginal>()),
+ useWorkspaceAdminStatus: () => ({ isAdmin: true, isResolved: true }),
+ useAuth: () => ({ activeOrganization: { id: 'org_1' } }),
+}));
+
+vi.mock('@object-ui/i18n', async (importOriginal) => ({
+ ...(await importOriginal>()),
+ useObjectTranslation: () => ({
+ t: (key: string, o?: Record) => {
+ const facts = ['dependency', 'range', 'installedVersion'].filter((k) => o?.[k] !== undefined).map((k) => `${k}=${String(o?.[k])}`);
+ return facts.length > 0 ? `«${key}|${facts.join('|')}»` : `«${key}»`;
+ },
+ language: 'en',
+ }),
+}));
+
+const PKG = 'com.acme.psa';
+const ORG_ROW = {
+ id: 'pkg_psa',
+ manifest_id: PKG,
+ display_name: 'Acme PSA',
+ description: 'A PSA.',
+ category: null,
+ icon_url: null,
+ visibility: 'org',
+ latest_version: '1.0.0',
+};
+
+vi.mock('../marketplaceApi', async (importOriginal) => ({
+ ...(await importOriginal>()),
+ listMarketplacePackages: async () => ({ items: [] }),
+ listLocalInstalls: async () => [],
+ listOrgPackages: async () => ({ connected: true, items: [ORG_ROW] }),
+ listInstalledPackages: async () => ({ connected: true, items: [] }),
+}));
+
+import { ActiveOrganizationStorage } from '@object-ui/auth';
+import { initRuntimeConfig, resetRuntimeConfigForTesting } from '../../../runtime-config';
+import { MarketplacePage } from '../MarketplacePage';
+
+const INSTALL_PATH = '/api/v1/marketplace/install-local';
+const SERVER_MESSAGE = 'SERVER-ENGLISH sentinel: Install refused: a declared dependency is not satisfied in this environment.';
+
+const answer = (status: number, body: unknown) => ({
+ ok: status >= 200 && status < 300,
+ status,
+ statusText: '',
+ headers: new Headers({ 'content-type': 'application/json' }),
+ json: async () => body,
+ text: async () => JSON.stringify(body),
+});
+
+async function boot(installBody: unknown) {
+ resetRuntimeConfigForTesting();
+ ActiveOrganizationStorage.set('org_1');
+ vi.stubGlobal('fetch', vi.fn(async (input: unknown, init?: { method?: string }) => {
+ const path = new URL(String(input), 'http://console.test').pathname;
+ const method = (init?.method ?? 'GET').toUpperCase();
+ if (path === INSTALL_PATH && method === 'POST') return answer(422, installBody);
+ if (path === '/api/v1/runtime/config') {
+ return answer(200, {
+ cloudUrl: 'https://cloud.example', singleEnvironment: true, defaultEnvironmentId: 'env_1',
+ features: { installLocal: true, marketplace: true, aiStudio: true, autoPublishAiBuilds: true },
+ branding: { productName: 'ObjectOS', productShortName: 'ObjectOS' },
+ });
+ }
+ return answer(404, { success: false, error: { code: 'NOT_FOUND', message: `no route ${method} ${path}` } });
+ }));
+ await initRuntimeConfig();
+}
+
+async function installFromOrgCard() {
+ render();
+ const card = await screen.findByTestId(`org-card-${PKG}`);
+ fireEvent.click(within(card).getByRole('button', { name: '«marketplace.org.install»' }));
+}
+
+const refusal = (details: unknown) => ({
+ success: false,
+ error: { code: 'DEPENDENCY_MISSING', httpStatus: 422, message: SERVER_MESSAGE, details },
+});
+
+afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+ resetRuntimeConfigForTesting();
+});
+
+describe('the catalog page draws DEPENDENCY_MISSING from its facts (objectstack#25792)', () => {
+ it('one line per unmet entry under the heading, in place of the server\'s English sentence', async () => {
+ await boot(refusal({
+ compatible: false,
+ issues: [
+ { severity: 'error', category: 'dependency_missing', message: 'not installed' },
+ { severity: 'error', category: 'dependency_missing', message: 'version unreadable' },
+ ],
+ unmet: [
+ { packageId: PKG, dependency: 'app.objectstack.hotcrm', range: '6.x', found: 'absent' },
+ { packageId: PKG, dependency: 'com.acme.billing', range: '^2.1.0', found: 'unreadable-version', installedVersion: 'nightly' },
+ ],
+ }));
+ await installFromOrgCard();
+
+ const strip = await screen.findByText(/marketplace\.install\.dependencyMissing\.heading/);
+ expect(strip.textContent!.split('\n')).toEqual([
+ '«marketplace.install.dependencyMissing.heading»',
+ '• «marketplace.install.dependencyMissing.absent|dependency=app.objectstack.hotcrm|range=6.x|installedVersion=—»',
+ '• «marketplace.install.dependencyMissing.unreadableVersion|dependency=com.acme.billing|range=^2.1.0|installedVersion=nightly»',
+ ]);
+ // The lines stay lines on screen.
+ expect(strip).toHaveClass('whitespace-pre-wrap');
+ expect(screen.queryByText(SERVER_MESSAGE)).not.toBeInTheDocument();
+ });
+
+ it('CONTROL: another refusal keeps the envelope\'s own message', async () => {
+ await boot({ success: false, error: { code: 'MANIFEST_CONFLICT', httpStatus: 409, message: SERVER_MESSAGE } });
+ await installFromOrgCard();
+
+ expect(await screen.findByText(SERVER_MESSAGE)).toBeInTheDocument();
+ expect(screen.queryByText(/dependencyMissing/)).not.toBeInTheDocument();
+ });
+});
diff --git a/ui/packages/app-shell/src/console/marketplace/__tests__/dependencyMissing-25792.test.ts b/ui/packages/app-shell/src/console/marketplace/__tests__/dependencyMissing-25792.test.ts
new file mode 100644
index 00000000000..639bb518b51
--- /dev/null
+++ b/ui/packages/app-shell/src/console/marketplace/__tests__/dependencyMissing-25792.test.ts
@@ -0,0 +1,80 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * The `422 DEPENDENCY_MISSING` install refusal (ADR-0141 D2) read from its own
+ * facts (objectstack#25792 item 3): the reader, the text it draws, and the
+ * install call that carries the facts to the page.
+ *
+ * `t` echoes the KEY and the interpolated facts: the claim is WHICH line is
+ * drawn for each entry and that it carries the entry's own id, range and
+ * version — the words are each locale pack's.
+ */
+
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { dependencyMissingText, readDependencyMissing } from '../dependencyMissing';
+import { installLocal } from '../marketplaceApi';
+
+const t = (key: string, o?: Record) =>
+ (o ? `${key}(${['packageId', 'dependency', 'range', 'installedVersion'].map((k) => String(o[k])).join('|')})` : key);
+
+/** The details the three install doors answer, as #25986 ships them. */
+const DETAILS = {
+ compatible: false,
+ issues: [
+ { severity: 'error', category: 'dependency_missing', message: "Package 'com.acme.psa' depends on 'app.objectstack.hotcrm' 6.x, which is not installed." },
+ { severity: 'error', category: 'dependency_missing', message: "Package 'com.acme.psa' depends on 'com.acme.billing' ^2.1.0, installed at 1.4.0." },
+ ],
+ unmet: [
+ { packageId: 'com.acme.psa', dependency: 'app.objectstack.hotcrm', range: '6.x', found: 'absent' },
+ { packageId: 'com.acme.psa', dependency: 'com.acme.billing', range: '^2.1.0', found: 'out-of-range', installedVersion: '1.4.0' },
+ ],
+};
+
+describe('readDependencyMissing', () => {
+ it('reads one fact per unmet entry, in the refusal\'s order', () => {
+ expect(readDependencyMissing(DETAILS)).toEqual(DETAILS.unmet);
+ });
+
+ it('reads every `found` the doors answer, the declaration-level one without a dependency', () => {
+ const facts = readDependencyMissing({ unmet: [
+ { packageId: 'p', dependency: 'd', range: 'latest', found: 'unreadable-range' },
+ { packageId: 'p', dependency: 'd', range: '^6', found: 'unreadable-version', installedVersion: '6.1.0-beta.1' },
+ { packageId: 'p', found: 'unreadable-declaration' },
+ ] });
+ expect(facts?.map((f) => f.found)).toEqual(['unreadable-range', 'unreadable-version', 'unreadable-declaration']);
+ });
+
+ it('draws nothing of its own from details it cannot read whole — the envelope message stands', () => {
+ expect(readDependencyMissing(undefined)).toBeNull();
+ expect(readDependencyMissing({ issues: DETAILS.issues })).toBeNull();
+ expect(readDependencyMissing({ unmet: [] })).toBeNull();
+ // One entry with a `found` this reader does not know: not a shorter list.
+ expect(readDependencyMissing({ unmet: [DETAILS.unmet[0], { packageId: 'p', dependency: 'd', range: '1', found: 'disabled' }] })).toBeNull();
+ expect(readDependencyMissing({ unmet: [{ packageId: 'p', range: '1', found: 'absent' }] })).toBeNull();
+ });
+});
+
+describe('dependencyMissingText', () => {
+ it('is the heading, then one line per entry built from that entry\'s facts', () => {
+ expect(dependencyMissingText(readDependencyMissing(DETAILS)!, t).split('\n')).toEqual([
+ 'marketplace.install.dependencyMissing.heading',
+ '• marketplace.install.dependencyMissing.absent(com.acme.psa|app.objectstack.hotcrm|6.x|—)',
+ '• marketplace.install.dependencyMissing.outOfRange(com.acme.psa|com.acme.billing|^2.1.0|1.4.0)',
+ ]);
+ });
+});
+
+describe('installLocal carries the refusal\'s facts', () => {
+ afterEach(() => vi.unstubAllGlobals());
+
+ it('rejects with the envelope\'s code, status and details', async () => {
+ vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({
+ success: false,
+ error: { code: 'DEPENDENCY_MISSING', httpStatus: 422, message: 'Install refused: a declared dependency is not satisfied.', details: DETAILS },
+ }), { status: 422, headers: { 'content-type': 'application/json' } })));
+ const err = await installLocal({ packageId: 'pkg_psa' }).catch((e: unknown) => e) as Error & { code?: string; status?: number; details?: unknown };
+ expect(err.code).toBe('DEPENDENCY_MISSING');
+ expect(err.status).toBe(422);
+ expect(err.details).toEqual(DETAILS);
+ });
+});
diff --git a/ui/packages/app-shell/src/console/marketplace/dependencyMissing.ts b/ui/packages/app-shell/src/console/marketplace/dependencyMissing.ts
new file mode 100644
index 00000000000..6ae0fdacdee
--- /dev/null
+++ b/ui/packages/app-shell/src/console/marketplace/dependencyMissing.ts
@@ -0,0 +1,104 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * The install refusal `422 DEPENDENCY_MISSING` (ADR-0141 D2), in the author's
+ * terms — 「请先安装 HotCRM 6.x」 — built from the refusal's own facts.
+ *
+ * The three install doors refuse a package whose declared `dependencies` the
+ * environment does not satisfy with one envelope:
+ *
+ * { code: 'DEPENDENCY_MISSING', message, details: { compatible: false, issues, unmet } }
+ *
+ * `issues` is one `dependency_missing` issue per unmet entry, its message in
+ * the server's English; `unmet` carries the same entries as facts — the
+ * declaring `packageId`, the `dependency`, the declared `range`, what was
+ * `found` and the `installedVersion` when one is installed. The Console draws
+ * one line per entry from those facts in the reader's locale, under one
+ * heading, instead of the server's English sentence. ⛔ No rule of its own: a
+ * `found` value this reader does not know draws nothing of its own, and the
+ * envelope's message stands.
+ */
+
+import { useCallback } from 'react';
+import { useObjectTranslation } from '@object-ui/i18n';
+import { errorCodeIs } from '@object-ui/types';
+
+/** What the refusal says was found for one declared entry (`details.unmet[].found`). */
+export type DependencyMissingFound =
+ | 'absent'
+ | 'out-of-range'
+ | 'unreadable-range'
+ | 'unreadable-version'
+ | 'unreadable-declaration';
+
+/** One unmet declared entry, as `details.unmet[]` carries it. */
+export interface DependencyMissingFact {
+ packageId: string;
+ found: DependencyMissingFound;
+ dependency?: string;
+ range?: string;
+ installedVersion?: string;
+}
+
+const LINE_KEY: Record = {
+ absent: 'marketplace.install.dependencyMissing.absent',
+ 'out-of-range': 'marketplace.install.dependencyMissing.outOfRange',
+ 'unreadable-range': 'marketplace.install.dependencyMissing.unreadableRange',
+ 'unreadable-version': 'marketplace.install.dependencyMissing.unreadableVersion',
+ 'unreadable-declaration': 'marketplace.install.dependencyMissing.unreadableDeclaration',
+};
+
+const isText = (v: unknown): v is string => typeof v === 'string' && v !== '';
+
+/**
+ * The refusal's facts, or `null` when `details` carries none this reader can
+ * draw — an older runtime, another refusal, or an entry with an unknown
+ * `found`. All or nothing: a list that dropped an entry would tell the author
+ * fewer things to install than the server refused for.
+ */
+export function readDependencyMissing(details: unknown): DependencyMissingFact[] | null {
+ const unmet = (details as { unmet?: unknown } | null | undefined)?.unmet;
+ if (!Array.isArray(unmet) || unmet.length === 0) return null;
+ const facts: DependencyMissingFact[] = [];
+ for (const entry of unmet) {
+ const e = entry as Record | null;
+ if (!e || !isText(e.packageId) || typeof e.found !== 'string' || !(e.found in LINE_KEY)) return null;
+ const found = e.found as DependencyMissingFound;
+ if (found !== 'unreadable-declaration' && (!isText(e.dependency) || typeof e.range !== 'string')) return null;
+ facts.push({
+ packageId: e.packageId,
+ found,
+ ...(isText(e.dependency) ? { dependency: e.dependency } : {}),
+ ...(typeof e.range === 'string' ? { range: e.range } : {}),
+ ...(isText(e.installedVersion) ? { installedVersion: e.installedVersion } : {}),
+ });
+ }
+ return facts;
+}
+
+type Translate = (key: string, options?: Record) => string;
+
+/** The heading and one line per fact, newline-joined, in the translator's locale. */
+export function dependencyMissingText(facts: readonly DependencyMissingFact[], t: Translate): string {
+ const lines = facts.map((fact) => t(LINE_KEY[fact.found], {
+ packageId: fact.packageId,
+ dependency: fact.dependency ?? '',
+ range: fact.range ?? '',
+ installedVersion: fact.installedVersion ?? '—',
+ }));
+ return [t('marketplace.install.dependencyMissing.heading'), ...lines.map((line) => `• ${line}`)].join('\n');
+}
+
+/**
+ * The text for a caught install error: the refusal drawn from its facts when
+ * it is `DEPENDENCY_MISSING` and carries them (`err.details`), else `null` and
+ * the caller keeps its own message.
+ */
+export function useDependencyMissingText(): (err: unknown) => string | null {
+ const { t } = useObjectTranslation();
+ return useCallback((err: unknown) => {
+ if (!errorCodeIs(err, 'DEPENDENCY_MISSING')) return null;
+ const facts = readDependencyMissing((err as { details?: unknown } | null | undefined)?.details);
+ return facts ? dependencyMissingText(facts, t as Translate) : null;
+ }, [t]);
+}
diff --git a/ui/packages/app-shell/src/console/marketplace/marketplaceApi.ts b/ui/packages/app-shell/src/console/marketplace/marketplaceApi.ts
index dd8f1abfff8..abf3d59b91f 100644
--- a/ui/packages/app-shell/src/console/marketplace/marketplaceApi.ts
+++ b/ui/packages/app-shell/src/console/marketplace/marketplaceApi.ts
@@ -833,9 +833,10 @@ export async function listOrgPackages(): Promise {
/**
* `POST /install-local`. `installMode` is sent only when the caller names one
* (ADR-0131 D6); absent, the door applies its own default. A refusal rejects
- * with the envelope's own `message`, its `code` and `status` beside it — the
- * install-mode refusals included (`INSTALL_MODE_NOT_PERMITTED`,
- * `UNMANAGED_INSTALL_WALLED_POSTURE`, `RESOURCE_CONFLICT`).
+ * with the envelope's own `message`, its `code`, `status` and `details` beside
+ * it — the install-mode refusals included (`INSTALL_MODE_NOT_PERMITTED`,
+ * `UNMANAGED_INSTALL_WALLED_POSTURE`, `RESOURCE_CONFLICT`), and
+ * `DEPENDENCY_MISSING`, whose `details` the page draws.
*
* The first overload is the door's own rule for a request that names no mode:
* it installs managed and answers the managed shape, as every install did
@@ -873,6 +874,9 @@ export async function installLocal(input: {
const err = new Error(typeof message === 'string' ? message : `${code}`);
(err as any).code = code;
(err as any).status = res.status;
+ // The refusal's facts ride along for a page that draws them — the
+ // `DEPENDENCY_MISSING` entries (ADR-0141 D2, `dependencyMissing.ts`).
+ if (payload?.error?.details !== undefined) (err as any).details = payload.error.details;
throw err;
}
return (payload?.data ?? payload) as LocalInstallResult;
diff --git a/ui/packages/app-shell/src/views/metadata-admin/PackagesPage.importDependencyMissing-25792.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/PackagesPage.importDependencyMissing-25792.test.tsx
new file mode 100644
index 00000000000..a510a587bdf
--- /dev/null
+++ b/ui/packages/app-shell/src/views/metadata-admin/PackagesPage.importDependencyMissing-25792.test.tsx
@@ -0,0 +1,114 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * Packages → Import draws the `422 DEPENDENCY_MISSING` install refusal
+ * (ADR-0141 D2) in the author's terms — 「请先安装 HotCRM 6.x」 — one line per
+ * unmet entry, from the refusal's own facts (objectstack#25792 item 3).
+ *
+ * Import is `POST /api/v1/marketplace/install-local` with the file's manifest.
+ * The REAL locale packs are mounted (`I18nProvider`), so the zh case asserts
+ * the sentence an author reads, not a key.
+ */
+
+import '@testing-library/jest-dom/vitest';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { cleanup, fireEvent, render, screen } from '@testing-library/react';
+import { MemoryRouter } from 'react-router-dom';
+import { I18nProvider } from '@object-ui/i18n';
+
+vi.mock('@object-ui/auth', async (importOriginal) => ({
+ ...(await importOriginal>()),
+ useWorkspaceAdminStatus: () => ({ isAdmin: true, isResolved: true }),
+}));
+
+import { PackagesPage } from './PackagesPage';
+import { initRuntimeConfig, resetRuntimeConfigForTesting } from '../../runtime-config';
+
+const reply = (status: number, body: unknown) =>
+ new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
+
+const SERVER_MESSAGE = 'SERVER-ENGLISH sentinel: Install refused: a declared dependency is not satisfied in this environment.';
+const PSA = { id: 'com.acme.psa', name: 'PSA', version: '1.0.0', type: 'app', dependencies: { 'app.objectstack.hotcrm': '6.x', 'com.acme.billing': '^2.1.0' } };
+
+async function serve(installAnswer: { status: number; body: unknown }) {
+ resetRuntimeConfigForTesting();
+ vi.stubGlobal('fetch', vi.fn(async () => reply(200, { features: { installLocal: true } })));
+ await initRuntimeConfig();
+ vi.stubGlobal('fetch', vi.fn(async (input: unknown, init?: { method?: string }) => {
+ const path = new URL(String(input), 'http://console.test').pathname;
+ const method = (init?.method ?? 'GET').toUpperCase();
+ if (path === '/api/v1/packages') return reply(200, { success: true, data: { packages: [] } });
+ if (path === '/api/v1/cloud-connection/installed') return reply(404, {});
+ if (path === '/api/v1/marketplace/install-local' && method === 'GET') return reply(200, { success: true, data: { items: [] } });
+ if (path === '/api/v1/marketplace/install-local' && method === 'POST') return reply(installAnswer.status, installAnswer.body);
+ return reply(404, { success: false, error: { code: 'NOT_FOUND', message: `no route ${method} ${path}` } });
+ }));
+}
+
+const refusal = (details: unknown) => ({
+ status: 422,
+ body: { success: false, error: { code: 'DEPENDENCY_MISSING', httpStatus: 422, message: SERVER_MESSAGE, details } },
+});
+
+const UNMET = [
+ { packageId: 'com.acme.psa', dependency: 'app.objectstack.hotcrm', range: '6.x', found: 'absent' },
+ { packageId: 'com.acme.psa', dependency: 'com.acme.billing', range: '^2.1.0', found: 'out-of-range', installedVersion: '1.4.0' },
+];
+
+async function importFile(lang: 'en' | 'zh') {
+ render(
+
+
+
+
+ ,
+ );
+ const input = await vi.waitFor(() => {
+ const el = document.querySelector('input[type="file"]');
+ if (!el) throw new Error('no import input yet');
+ return el as HTMLInputElement;
+ });
+ const file = new File([JSON.stringify(PSA)], 'psa.json', { type: 'application/json' });
+ fireEvent.change(input, { target: { files: [file] } });
+}
+
+afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+ resetRuntimeConfigForTesting();
+});
+
+describe('Packages → Import draws DEPENDENCY_MISSING from its facts (objectstack#25792)', () => {
+ it('zh: 「请先安装 …」, one line per unmet entry, in place of the server\'s English sentence', async () => {
+ await serve(refusal({ compatible: false, issues: [], unmet: UNMET }));
+ await importFile('zh');
+
+ const note = await screen.findByText(/安装被拒绝/);
+ expect(note.textContent!.split('\n')).toEqual([
+ '安装被拒绝:此软件包声明的依赖在当前环境中未满足,未安装任何内容。',
+ '• 请先安装 app.objectstack.hotcrm 6.x。',
+ '• 请先安装 com.acme.billing ^2.1.0(当前安装的是 1.4.0)。',
+ ]);
+ expect(screen.queryByText(SERVER_MESSAGE)).not.toBeInTheDocument();
+ });
+
+ it('en: the same facts in English', async () => {
+ await serve(refusal({ compatible: false, issues: [], unmet: UNMET }));
+ await importFile('en');
+
+ const note = await screen.findByText(/Install refused/);
+ expect(note.textContent!.split('\n').slice(1)).toEqual([
+ '• Install app.objectstack.hotcrm 6.x first.',
+ '• Install com.acme.billing ^2.1.0 first. Version 1.4.0 is installed.',
+ ]);
+ });
+
+ it('CONTROL: a refusal without facts it can read keeps the envelope\'s own message', async () => {
+ await serve(refusal({ compatible: false, issues: [] }));
+ await importFile('en');
+
+ // As this page has always drawn a refusal: the envelope's message, its code behind it.
+ expect(await screen.findByText(`${SERVER_MESSAGE} (DEPENDENCY_MISSING)`)).toBeInTheDocument();
+ expect(screen.queryByText(/安装被拒绝|Install refused: a dependency this package declares/)).not.toBeInTheDocument();
+ });
+});
diff --git a/ui/packages/app-shell/src/views/metadata-admin/PackagesPage.tsx b/ui/packages/app-shell/src/views/metadata-admin/PackagesPage.tsx
index 8641a609f33..e5852c68b6e 100644
--- a/ui/packages/app-shell/src/views/metadata-admin/PackagesPage.tsx
+++ b/ui/packages/app-shell/src/views/metadata-admin/PackagesPage.tsx
@@ -89,6 +89,7 @@ import { useWorkspaceAdminStatus } from '@object-ui/auth';
import { getRuntimeConfig } from '../../runtime-config.js';
import { readMarketplaceInstalls, type LocalInstallEntry } from '../../console/marketplace/marketplaceApi.js';
import { MarketplaceNotLoaded, MarketplaceRowActions } from '../../console/marketplace/MarketplaceRowActions.js';
+import { useDependencyMissingText } from '../../console/marketplace/dependencyMissing.js';
import type { InstalledPackageAtEitherStage } from '@objectstack/spec/api-assembled';
import type { PackagePublishResult } from '@objectstack/spec/system';
import type { ReassignOrphanedMetadataResponse } from '@objectstack/spec/api';
@@ -204,7 +205,16 @@ async function apiJson(path: string, init?: RequestInit): Promise {
payload?.error ||
payload?.message ||
`Request failed (${res.status})`;
- throw new Error(typeof msg === 'string' ? msg : `Request failed (${res.status})`);
+ const err = new Error(typeof msg === 'string' ? msg : `Request failed (${res.status})`) as Error & {
+ code?: string;
+ details?: unknown;
+ };
+ // ADR-0141 D2 — the envelope's `code` and `details` ride on the error, so a
+ // caller that draws a refusal from its facts (`DEPENDENCY_MISSING` at the
+ // import door) can; every other caller reads the message as before.
+ if (typeof payload?.error?.code === 'string') err.code = payload.error.code;
+ if (payload?.error?.details !== undefined) err.details = payload.error.details;
+ throw err;
}
// Runtime wraps successful payloads in { data, ... } or returns the object directly.
return (payload?.data ?? payload) as T;
@@ -1298,6 +1308,9 @@ export function PackageDetailSheet({
export function PackagesPage() {
const locale = useMetadataLocale();
+ // ADR-0141 D2 — the import door's `DEPENDENCY_MISSING` refusal, one line per
+ // unmet dependency, from the locale packs (the `engine.*` table is en/zh only).
+ const dependencyMissingText = useDependencyMissingText();
const { pathname } = useLocation();
// App base = path up to (and excluding) `/component/...`, so links to
// `/apps/:app/metadata/...` work regardless of nesting.
@@ -1488,7 +1501,10 @@ export function PackagesPage() {
});
await load();
} catch (err: any) {
- setImportMsg({ kind: 'err', text: err?.message ?? t('engine.packages.import.failed', locale) });
+ setImportMsg({
+ kind: 'err',
+ text: dependencyMissingText(err) ?? err?.message ?? t('engine.packages.import.failed', locale),
+ });
} finally {
setImporting(false);
}
@@ -1571,7 +1587,7 @@ export function PackagesPage() {
{importMsg && (