diff --git a/.changeset/15213-install-modes.md b/.changeset/15213-install-modes.md new file mode 100644 index 00000000000..bbf20f8eb98 --- /dev/null +++ b/.changeset/15213-install-modes.md @@ -0,0 +1,26 @@ +--- +'@objectstack/spec': minor +'@objectstack/cloud-connection': minor +'@objectstack/cli': minor +'@objectstack/metadata-protocol': minor +--- + +A package declares how it may be installed — `installModes: ['managed', 'unmanaged']` — and `os package install --install-mode unmanaged` copies it once into the environment as the environment's own metadata (ADR-0131 D6) + +Clause-②: yes (widening: the new optional manifest key `installModes`; the new exports `INSTALL_MODES`, `InstallModeSchema`, `InstallMode`, `DEFAULT_INSTALL_MODES`, `resolveInstallModes`, `UNMANAGED_INSTALL_KEY_DISPOSITIONS`, `UNMANAGED_INSTALL_REFUSING_DISPOSITIONS`, `UnmanagedInstallKeyDisposition`, `unmanagedInstallValueCarries` and `unmanagedInstallPostureRefusal`; the install-local body field `installMode`; the CLI option `--install-mode`; the error codes `STACK_UNMANAGED_INSTALL_UNCOPYABLE` and `INSTALL_MODE_NOT_PERMITTED`) + +A package installs one of two ways. **Managed**: it is registered as code, stays the authority, upgrades, and nothing it ships is edited in place. **Unmanaged**: its metadata is copied once into this environment under a new writable base; the copy's items are this environment's own metadata, the next save edits them, and it follows no upgrade. One exception is carried: an item the package ships with an authored protection lock (`_lock`) keeps that lock on the copy as written today, and a follow-up on #15213 strips it per #22971's ruling A. This entry adds the manifest key that declares the modes a package permits, and the install door that honours it; the copy itself is the companion entry `15213-unmanaged-import-verb`. + +**This entry supersedes parts of that earlier entry, `15213-unmanaged-import-verb`, in this same release.** It says of the import "no door calls it yet, and no manifest key declares the mode yet". What holds now: the install door calls it for `installMode: 'unmanaged'`, and the manifest key `installModes` declares the modes a package permits. It says seed `data` and `translations` are "left to the install door, which applies them after the copy as it does for a managed install". What holds now: the import copies `translations` itself, as `translation` items, and an unmanaged install loads no seed `data` — neither the import nor the door applies it, and the door's answer says so under `seeded`. It also calls the copy "editable from then on" and says each item "is fully editable"; what holds is the sentence above: the copy's items are this environment's own metadata and the next save edits them, except an item shipped with an authored `_lock`, which keeps that lock. Where the two entries disagree, this one is current. + +- **`installModes`** (`@objectstack/spec`, `ManifestSchema`). Optional, a non-empty list of `'managed'` / `'unmanaged'`. Absent means `['managed']`, read through one resolver, `resolveInstallModes(manifest)`; there is no parse-time default, so no existing manifest changes by a byte. A package may permit `unmanaged` alone. +- **Refused at authoring.** `defineStack` — so `os validate` and `os build` — refuses a package that permits `unmanaged` while it carries what the copy cannot write as environment metadata: a non-empty collection of a type the metadata-type registry declares runtime-uncreatable (`agents`, `apis`, `capabilities`, `jobs`, `picklists`), code (`functions`, `onEnable`, `plugins`, a plugin artifact's `main` / `runtime` / `packaging` / `integrity`), or an extension of another package's items (`objectExtensions`, `picklistExtensions`, `navigationContributions`, `contributes`). The refusal is `STACK_UNMANAGED_INSTALL_UNCOPYABLE` (422), one `issues` entry per key. Fix: remove `'unmanaged'` from `manifest.installModes`, or move what cannot be copied out of the package. The install door judges the same body with the same key classification (`UNMANAGED_INSTALL_KEY_DISPOSITIONS`), so a package that passes `os validate` is not refused at install for a key nobody named. +- **The install door** (`@objectstack/cloud-connection`, `POST /api/v1/marketplace/install-local`). The body takes `installMode: 'managed' | 'unmanaged'`; absent means `managed`, so every existing caller is unchanged. Before anything is written, on the catalog and the air-gapped source alike: + - a mode the package's `installModes` does not permit answers `422 INSTALL_MODE_NOT_PERMITTED`, with `details.permitted`; + - `unmanaged` on a deployment whose requested tenancy posture walls organizations (`group`, `isolated`, or an unrecognised value) answers `409 UNMANAGED_INSTALL_WALLED_POSTURE`, naming the posture; + - a managed install of a package whose namespace a writable base holds — its unmanaged copy — answers `409 RESOURCE_CONFLICT`, naming that base: a managed install would otherwise take the namespace and stop serving the edited copies. + An unmanaged install writes no install-ledger entry, registers nothing under the package's id and does not load its sample data (the answer says so under `seeded`). The answer names the new base, the items copied and `installedFrom` (the source package id and version, for information only). The package's translation bundles are copied as `translation` items, so they reload at every boot with the environment's own translations. +- **`os package install --install-mode managed|unmanaged`** (`@objectstack/cli`). Without the option, nothing is sent and the runtime installs managed. An unmanaged answer prints the base, the item count and the source. A runtime that predates the install mode ignores the option and installs managed; the command then exits 1 and says so. The command's help now calls the catalog and the air-gapped artifact the two *sources*, so "mode" means one thing. +- **Copy semantics** (`@objectstack/metadata-protocol`). `importUnmanagedPackage` copies `translations` (each locale of each bundle as one `translation` item named `_`, bundles merged in order), reads `installModes` without writing it onto the base, and no longer refuses an empty code container (`functions: {}`, `plugins: []`). `unmanagedInstallPostureRefusal` is exported, so the door and the import answer the walled posture with one sentence. + +Nothing existing is removed: every new key, field and option is optional, and an install that does not name a mode is managed, as before, with one stricter answer. That answer is the refusal above: a managed install of a package whose namespace a writable base holds now answers `409 RESOURCE_CONFLICT` before anything is written, where a catalog install of it answered `200`. diff --git a/content/docs/references/api/contract.mdx b/content/docs/references/api/contract.mdx index 3264f781e3f..04c91fa6845 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' \| … +323 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' \| … +325 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`. | @@ -199,6 +199,7 @@ const result = ApiErrorSchema.parse(data); * `HOOK_UNSCOPED_DATA_ACCESS` * `IMPORT_JOB_CREATE_FAILED` * `IMPORT_ROW_FAILED` +* `INSTALL_MODE_NOT_PERMITTED` * `INTERNAL` * `INVALID_ARTIFACT_PACKAGES` * `INVALID_ARTIFACT_PACKAGE_ENTRY` @@ -339,6 +340,7 @@ const result = ApiErrorSchema.parse(data); * `STACK_SCHEMA_INVALID` * `STACK_SINGLE_APP_VIOLATION` * `STACK_TRIGGER_CAPABILITY_REQUIRED` +* `STACK_UNMANAGED_INSTALL_UNCOPYABLE` * `STORED_TYPE_NOT_CANONICAL` * `STORE_UNAVAILABLE` * `SUGGESTION_CONFIRM_FAILED` diff --git a/content/docs/references/api/error-code-ledger.mdx b/content/docs/references/api/error-code-ledger.mdx index 3fd3df9ce25..4a0e45b7ad0 100644 --- a/content/docs/references/api/error-code-ledger.mdx +++ b/content/docs/references/api/error-code-ledger.mdx @@ -366,6 +366,7 @@ const result = ErrorCode.parse(data); * `HOOK_UNSCOPED_DATA_ACCESS` * `IMPORT_JOB_CREATE_FAILED` * `IMPORT_ROW_FAILED` +* `INSTALL_MODE_NOT_PERMITTED` * `INTERNAL` * `INVALID_ARTIFACT_PACKAGES` * `INVALID_ARTIFACT_PACKAGE_ENTRY` @@ -506,6 +507,7 @@ const result = ErrorCode.parse(data); * `STACK_SCHEMA_INVALID` * `STACK_SINGLE_APP_VIOLATION` * `STACK_TRIGGER_CAPABILITY_REQUIRED` +* `STACK_UNMANAGED_INSTALL_UNCOPYABLE` * `STORED_TYPE_NOT_CANONICAL` * `STORE_UNAVAILABLE` * `SUGGESTION_CONFIRM_FAILED` diff --git a/content/docs/references/api/package-api-assembled.mdx b/content/docs/references/api/package-api-assembled.mdx index cc5ac42cb2f..335ca4fe67f 100644 --- a/content/docs/references/api/package-api-assembled.mdx +++ b/content/docs/references/api/package-api-assembled.mdx @@ -89,6 +89,7 @@ Installed package row whose manifest is the assembled package body | **version** | `string` | ✅ | Package version (SemVer 2.0.0 — e.g. 1.2.3, 2.0.0-beta.1, 1.0.0+20230101) | | **type** | `Enum<'plugin' \| 'ui' \| 'driver' \| 'server' \| 'app' \| 'theme' \| 'agent' \| 'objectql' \| …>` | ✅ | Type of package | | **scope** | `Enum<'cloud' \| 'system' \| 'project'>` | optional (default: `"project"`) | Deployment scope: cloud \| system \| project | +| **installModes** | `Enum<'managed' \| 'unmanaged'>[]` | optional | Install modes this package permits (ADR-0131 D6): managed \| unmanaged. Absent means ['managed']. | | **name** | `string` | ✅ | Human-readable package name | | **description** | `string` | optional | Package description | | **permissions** | `{ name: string; label?: string; description?: string; packageId?: string; … }[]` | optional | Permission Sets — the ADR-0090 collection half of `permissions`; at the manifest/authoring stage the same key is the ADR-0025 capability grant instead (`ManifestSchema.permissions`) | @@ -270,6 +271,7 @@ Installed package with runtime lifecycle state | **version** | `string` | ✅ | Package version (SemVer 2.0.0 — e.g. 1.2.3, 2.0.0-beta.1, 1.0.0+20230101) | | **type** | `Enum<'plugin' \| 'ui' \| 'driver' \| 'server' \| 'app' \| 'theme' \| 'agent' \| 'objectql' \| …>` | ✅ | Type of package | | **scope** | `Enum<'cloud' \| 'system' \| 'project'>` | optional (default: `"project"`) | Deployment scope: cloud \| system \| project | +| **installModes** | `Enum<'managed' \| 'unmanaged'>[]` | optional | Install modes this package permits (ADR-0131 D6): managed \| unmanaged. Absent means ['managed']. | | **name** | `string` | ✅ | Human-readable package name | | **description** | `string` | optional | Package description | | **permissions** | `{ services?: string[]; hooks?: string[]; network?: string[]; fs?: string[] }` | optional | Required permissions at the AUTHORING stage: the structured plugin block `{ services, hooks, network, fs }` (ADR-0025 §3.2) — the legacy flat string list is retired — and at the assembled stage the same key is the ADR-0090 `PermissionSet[]` collection instead (`AssembledPackageBodySchema`) | @@ -333,6 +335,7 @@ Installed package row whose manifest is the assembled package body | **version** | `string` | ✅ | Package version (SemVer 2.0.0 — e.g. 1.2.3, 2.0.0-beta.1, 1.0.0+20230101) | | **type** | `Enum<'plugin' \| 'ui' \| 'driver' \| 'server' \| 'app' \| 'theme' \| 'agent' \| 'objectql' \| …>` | ✅ | Type of package | | **scope** | `Enum<'cloud' \| 'system' \| 'project'>` | optional (default: `"project"`) | Deployment scope: cloud \| system \| project | +| **installModes** | `Enum<'managed' \| 'unmanaged'>[]` | optional | Install modes this package permits (ADR-0131 D6): managed \| unmanaged. Absent means ['managed']. | | **name** | `string` | ✅ | Human-readable package name | | **description** | `string` | optional | Package description | | **permissions** | `{ name: string; label?: string; description?: string; packageId?: string; … }[]` | optional | Permission Sets — the ADR-0090 collection half of `permissions`; at the manifest/authoring stage the same key is the ADR-0025 capability grant instead (`ManifestSchema.permissions`) | diff --git a/content/docs/references/api/package-api.mdx b/content/docs/references/api/package-api.mdx index e532941486e..b541fc0d0da 100644 --- a/content/docs/references/api/package-api.mdx +++ b/content/docs/references/api/package-api.mdx @@ -146,6 +146,7 @@ Install package request | **version** | `string` | ✅ | Package version (SemVer 2.0.0 — e.g. 1.2.3, 2.0.0-beta.1, 1.0.0+20230101) | | **type** | `Enum<'plugin' \| 'ui' \| 'driver' \| 'server' \| 'app' \| 'theme' \| 'agent' \| 'objectql' \| …>` | ✅ | Type of package | | **scope** | `Enum<'cloud' \| 'system' \| 'project'>` | optional (default: `"project"`) | Deployment scope: cloud \| system \| project | +| **installModes** | `Enum<'managed' \| 'unmanaged'>[]` | optional | Install modes this package permits (ADR-0131 D6): managed \| unmanaged. Absent means ['managed']. | | **name** | `string` | ✅ | Human-readable package name | | **description** | `string` | optional | Package description | | **permissions** | `{ services?: string[]; hooks?: string[]; network?: string[]; fs?: string[] }` | optional | Required permissions at the AUTHORING stage: the structured plugin block `{ services, hooks, network, fs }` (ADR-0025 §3.2) — the legacy flat string list is retired — and at the assembled stage the same key is the ADR-0090 `PermissionSet[]` collection instead (`AssembledPackageBodySchema`) | @@ -190,6 +191,7 @@ Install package request | **version** | `string` | ✅ | Package version (SemVer 2.0.0 — e.g. 1.2.3, 2.0.0-beta.1, 1.0.0+20230101) | | **type** | `Enum<'plugin' \| 'ui' \| 'driver' \| 'server' \| 'app' \| 'theme' \| 'agent' \| 'objectql' \| 'module' \| 'gateway' \| 'adapter'>` | ✅ | Type of package | | **scope** | `Enum<'cloud' \| 'system' \| 'project'>` | optional (default: `"project"`) | Deployment scope: cloud \| system \| project | +| **installModes** | `Enum<'managed' \| 'unmanaged'>[]` | optional | Install modes this package permits (ADR-0131 D6): managed \| unmanaged. Absent means ['managed']. | | **name** | `string` | ✅ | Human-readable package name | | **description** | `string` | optional | Package description | | **permissions** | `{ services?: string[]; hooks?: string[]; network?: string[]; fs?: string[] }` | optional | Required permissions at the AUTHORING stage: the structured plugin block `{ services, hooks, network, fs }` (ADR-0025 §3.2) — the legacy flat string list is retired — and at the assembled stage the same key is the ADR-0090 `PermissionSet[]` collection instead (`AssembledPackageBodySchema`) | @@ -307,6 +309,7 @@ Install package request | **version** | `string` | ✅ | Package version (SemVer 2.0.0 — e.g. 1.2.3, 2.0.0-beta.1, 1.0.0+20230101) | | **type** | `Enum<'plugin' \| 'ui' \| 'driver' \| 'server' \| 'app' \| 'theme' \| 'agent' \| 'objectql' \| …>` | ✅ | Type of package | | **scope** | `Enum<'cloud' \| 'system' \| 'project'>` | optional (default: `"project"`) | Deployment scope: cloud \| system \| project | +| **installModes** | `Enum<'managed' \| 'unmanaged'>[]` | optional | Install modes this package permits (ADR-0131 D6): managed \| unmanaged. Absent means ['managed']. | | **name** | `string` | ✅ | Human-readable package name | | **description** | `string` | optional | Package description | | **permissions** | `{ services?: string[]; hooks?: string[]; network?: string[]; fs?: string[] }` | optional | Required permissions at the AUTHORING stage: the structured plugin block `{ services, hooks, network, fs }` (ADR-0025 §3.2) — the legacy flat string list is retired — and at the assembled stage the same key is the ADR-0090 `PermissionSet[]` collection instead (`AssembledPackageBodySchema`) | @@ -440,6 +443,7 @@ Upgrade package request | **version** | `string` | ✅ | Package version (SemVer 2.0.0 — e.g. 1.2.3, 2.0.0-beta.1, 1.0.0+20230101) | | **type** | `Enum<'plugin' \| 'ui' \| 'driver' \| 'server' \| 'app' \| 'theme' \| 'agent' \| 'objectql' \| …>` | ✅ | Type of package | | **scope** | `Enum<'cloud' \| 'system' \| 'project'>` | optional (default: `"project"`) | Deployment scope: cloud \| system \| project | +| **installModes** | `Enum<'managed' \| 'unmanaged'>[]` | optional | Install modes this package permits (ADR-0131 D6): managed \| unmanaged. Absent means ['managed']. | | **name** | `string` | ✅ | Human-readable package name | | **description** | `string` | optional | Package description | | **permissions** | `{ services?: string[]; hooks?: string[]; network?: string[]; fs?: string[] }` | optional | Required permissions at the AUTHORING stage: the structured plugin block `{ services, hooks, network, fs }` (ADR-0025 §3.2) — the legacy flat string list is retired — and at the assembled stage the same key is the ADR-0090 `PermissionSet[]` collection instead (`AssembledPackageBodySchema`) | @@ -535,6 +539,7 @@ Resolve dependencies request | **version** | `string` | ✅ | Package version (SemVer 2.0.0 — e.g. 1.2.3, 2.0.0-beta.1, 1.0.0+20230101) | | **type** | `Enum<'plugin' \| 'ui' \| 'driver' \| 'server' \| 'app' \| 'theme' \| 'agent' \| 'objectql' \| …>` | ✅ | Type of package | | **scope** | `Enum<'cloud' \| 'system' \| 'project'>` | optional (default: `"project"`) | Deployment scope: cloud \| system \| project | +| **installModes** | `Enum<'managed' \| 'unmanaged'>[]` | optional | Install modes this package permits (ADR-0131 D6): managed \| unmanaged. Absent means ['managed']. | | **name** | `string` | ✅ | Human-readable package name | | **description** | `string` | optional | Package description | | **permissions** | `{ services?: string[]; hooks?: string[]; network?: string[]; fs?: string[] }` | optional | Required permissions at the AUTHORING stage: the structured plugin block `{ services, hooks, network, fs }` (ADR-0025 §3.2) — the legacy flat string list is retired — and at the assembled stage the same key is the ADR-0090 `PermissionSet[]` collection instead (`AssembledPackageBodySchema`) | diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 31095cf9522..200f3cdb4f3 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1950,6 +1950,7 @@ Install package request | **version** | `string` | ✅ | Package version (SemVer 2.0.0 — e.g. 1.2.3, 2.0.0-beta.1, 1.0.0+20230101) | | **type** | `Enum<'plugin' \| 'ui' \| 'driver' \| 'server' \| 'app' \| 'theme' \| 'agent' \| 'objectql' \| …>` | ✅ | Type of package | | **scope** | `Enum<'cloud' \| 'system' \| 'project'>` | optional (default: `"project"`) | Deployment scope: cloud \| system \| project | +| **installModes** | `Enum<'managed' \| 'unmanaged'>[]` | optional | Install modes this package permits (ADR-0131 D6): managed \| unmanaged. Absent means ['managed']. | | **name** | `string` | ✅ | Human-readable package name | | **description** | `string` | optional | Package description | | **permissions** | `{ services?: string[]; hooks?: string[]; network?: string[]; fs?: string[] }` | optional | Required permissions at the AUTHORING stage: the structured plugin block `{ services, hooks, network, fs }` (ADR-0025 §3.2) — the legacy flat string list is retired — and at the assembled stage the same key is the ADR-0090 `PermissionSet[]` collection instead (`AssembledPackageBodySchema`) | diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index 27664f0b8cb..1a67850a0ce 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,7 +1,7 @@ --- title: Protocol reference — every schema by module navTitle: Protocol Reference -description: Every schema published by @objectstack/spec — 1523 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1524 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -26,7 +26,7 @@ counts are sums of the rows they head. Regenerate with | [Data Protocol](/docs/references/data) | 29 | 176 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. | | [Identity Protocol](/docs/references/identity) | 5 | 27 | Users and accounts, organizations, positions, SCIM provisioning. | | [Integration Protocol](/docs/references/integration) | 1 | 12 | The single connector protocol (ADR-0097) — catalog descriptors and provider-bound instances. | -| [Kernel Protocol](/docs/references/kernel) | 30 | 157 | Plugin lifecycle and manifests, capabilities and security, metadata loading, service registry. | +| [Kernel Protocol](/docs/references/kernel) | 30 | 158 | Plugin lifecycle and manifests, capabilities and security, metadata loading, service registry. | | [Marketplace Protocol](/docs/references/marketplace) | 4 | 30 | The package & marketplace format — package identity and versions, listing, publish, review, search, install, template manifests. | | [QA Protocol](/docs/references/qa) | 1 | 8 | Declarative test suites — scenarios, steps, actions and assertions. | | [Security Protocol](/docs/references/security) | 5 | 30 | Permission sets, row-level security, sharing rules, tenancy posture. | @@ -34,7 +34,7 @@ counts are sums of the rows they head. Regenerate with | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 35 | 278 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 166 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **196** | **1523** | 14 protocol modules | +| **Total** | **196** | **1524** | 14 protocol modules | --- @@ -198,7 +198,7 @@ The single connector protocol (ADR-0097) — catalog descriptors and provider-bo ## Kernel Protocol -**Source:** `packages/spec/src/kernel/` · **Import:** `@objectstack/spec/kernel` · **30 pages, 157 schemas** +**Source:** `packages/spec/src/kernel/` · **Import:** `@objectstack/spec/kernel` · **30 pages, 158 schemas** Plugin lifecycle and manifests, capabilities and security, metadata loading, service registry. @@ -215,7 +215,7 @@ Plugin lifecycle and manifests, capabilities and security, metadata loading, ser | [`events/integrations.zod.ts`](/docs/references/kernel/events-integrations) | `EventMessageQueueConfig`, `EventWebhookConfig`, `RealTimeNotificationConfig` | | [`events/queue.zod.ts`](/docs/references/kernel/events-queue) | `EventQueueConfig`, `EventReplayConfig`, `EventSourcingConfig` | | [`execution-context.zod.ts`](/docs/references/kernel/execution-context) | `ExecutionContext` | -| [`manifest.zod.ts`](/docs/references/kernel/manifest) | `Manifest`, `ManifestPermissions`, `PluginEngines`, `PluginIntegrity`, `PluginPackaging`, `PluginPermissions`, `PluginRuntime` | +| [`manifest.zod.ts`](/docs/references/kernel/manifest) | `InstallMode`, `Manifest`, `ManifestPermissions`, `PluginEngines`, `PluginIntegrity`, `PluginPackaging`, `PluginPermissions`, `PluginRuntime` | | [`metadata-loader.zod.ts`](/docs/references/kernel/metadata-loader) | `MetadataFallbackStrategy`, `MetadataManagerConfig` | | [`metadata-plugin.zod.ts`](/docs/references/kernel/metadata-plugin) | `MetadataBulkResult`, `MetadataDependency`, `MetadataPluginConfig`, `MetadataPluginManifest`, `MetadataQuery`, `MetadataQueryResult`, `MetadataType`, `MetadataTypeRegistryEntry`, `MetadataValidationResult` | | [`metadata-protection.zod.ts`](/docs/references/kernel/metadata-protection) | `MetadataLock`, `MetadataLockSource`, `MetadataProvenance` | diff --git a/content/docs/references/kernel/manifest.mdx b/content/docs/references/kernel/manifest.mdx index 884269800ad..5eb1aae8ea2 100644 --- a/content/docs/references/kernel/manifest.mdx +++ b/content/docs/references/kernel/manifest.mdx @@ -1,7 +1,7 @@ --- title: Manifest schema — Kernel Protocol reference navTitle: Manifest -description: "Manifest schemas of the ObjectStack Kernel Protocol: Manifest, ManifestPermissions and 5 more — each property with its type, default and a TypeScript example." +description: "Manifest schemas of the ObjectStack Kernel Protocol: InstallMode, Manifest and 6 more — each property with its type, default and a TypeScript example." --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -13,13 +13,25 @@ description: "Manifest schemas of the ObjectStack Kernel Protocol: Manifest, Man ## TypeScript Usage ```typescript -import { ManifestSchema, ManifestPermissionsSchema, PluginEnginesSchema, PluginIntegritySchema, PluginPackagingSchema, PluginPermissionsSchema, PluginRuntimeSchema } from '@objectstack/spec/kernel'; -import type { ManifestPermissions, PluginEngines, PluginIntegrity, PluginPackaging, PluginPermissions, PluginRuntime } from '@objectstack/spec/kernel'; +import { InstallModeSchema, ManifestSchema, ManifestPermissionsSchema, PluginEnginesSchema, PluginIntegritySchema, PluginPackagingSchema, PluginPermissionsSchema, PluginRuntimeSchema } from '@objectstack/spec/kernel'; +import type { InstallMode, ManifestPermissions, PluginEngines, PluginIntegrity, PluginPackaging, PluginPermissions, PluginRuntime } from '@objectstack/spec/kernel'; // Validate data -const result = ManifestSchema.parse(data); +const result = InstallModeSchema.parse(data); ``` +--- + +## InstallMode + +Install mode: managed (registered as code, upgradeable, sealed) | unmanaged (copied once into the environment as its own metadata, no upgrade) + +### Allowed Values + +* `managed` +* `unmanaged` + + --- ## Manifest @@ -34,6 +46,7 @@ const result = ManifestSchema.parse(data); | **version** | `string` | ✅ | Package version (SemVer 2.0.0 — e.g. 1.2.3, 2.0.0-beta.1, 1.0.0+20230101) | | **type** | `Enum<'plugin' \| 'ui' \| 'driver' \| 'server' \| 'app' \| 'theme' \| 'agent' \| 'objectql' \| 'module' \| 'gateway' \| 'adapter'>` | ✅ | Type of package | | **scope** | `Enum<'cloud' \| 'system' \| 'project'>` | optional (default: `"project"`) | Deployment scope: cloud \| system \| project | +| **installModes** | `Enum<'managed' \| 'unmanaged'>[]` | optional | Install modes this package permits (ADR-0131 D6): managed \| unmanaged. Absent means ['managed']. | | **name** | `string` | ✅ | Human-readable package name | | **description** | `string` | optional | Package description | | **permissions** | `{ services?: string[]; hooks?: string[]; network?: string[]; fs?: string[] }` | optional | Required permissions at the AUTHORING stage: the structured plugin block `{ services, hooks, network, fs }` (ADR-0025 §3.2) — the legacy flat string list is retired — and at the assembled stage the same key is the ADR-0090 `PermissionSet[]` collection instead (`AssembledPackageBodySchema`) | diff --git a/content/docs/references/kernel/package-registry.mdx b/content/docs/references/kernel/package-registry.mdx index 7a2344cffe7..d88a0b885f3 100644 --- a/content/docs/references/kernel/package-registry.mdx +++ b/content/docs/references/kernel/package-registry.mdx @@ -198,6 +198,7 @@ Install package request | **version** | `string` | ✅ | Package version (SemVer 2.0.0 — e.g. 1.2.3, 2.0.0-beta.1, 1.0.0+20230101) | | **type** | `Enum<'plugin' \| 'ui' \| 'driver' \| 'server' \| 'app' \| 'theme' \| 'agent' \| 'objectql' \| …>` | ✅ | Type of package | | **scope** | `Enum<'cloud' \| 'system' \| 'project'>` | optional (default: `"project"`) | Deployment scope: cloud \| system \| project | +| **installModes** | `Enum<'managed' \| 'unmanaged'>[]` | optional | Install modes this package permits (ADR-0131 D6): managed \| unmanaged. Absent means ['managed']. | | **name** | `string` | ✅ | Human-readable package name | | **description** | `string` | optional | Package description | | **permissions** | `{ services?: string[]; hooks?: string[]; network?: string[]; fs?: string[] }` | optional | Required permissions at the AUTHORING stage: the structured plugin block `{ services, hooks, network, fs }` (ADR-0025 §3.2) — the legacy flat string list is retired — and at the assembled stage the same key is the ADR-0090 `PermissionSet[]` collection instead (`AssembledPackageBodySchema`) | @@ -294,6 +295,7 @@ Installed package with runtime lifecycle state | **version** | `string` | ✅ | Package version (SemVer 2.0.0 — e.g. 1.2.3, 2.0.0-beta.1, 1.0.0+20230101) | | **type** | `Enum<'plugin' \| 'ui' \| 'driver' \| 'server' \| 'app' \| 'theme' \| 'agent' \| 'objectql' \| …>` | ✅ | Type of package | | **scope** | `Enum<'cloud' \| 'system' \| 'project'>` | optional (default: `"project"`) | Deployment scope: cloud \| system \| project | +| **installModes** | `Enum<'managed' \| 'unmanaged'>[]` | optional | Install modes this package permits (ADR-0131 D6): managed \| unmanaged. Absent means ['managed']. | | **name** | `string` | ✅ | Human-readable package name | | **description** | `string` | optional | Package description | | **permissions** | `{ services?: string[]; hooks?: string[]; network?: string[]; fs?: string[] }` | optional | Required permissions at the AUTHORING stage: the structured plugin block `{ services, hooks, network, fs }` (ADR-0025 §3.2) — the legacy flat string list is retired — and at the assembled stage the same key is the ADR-0090 `PermissionSet[]` collection instead (`AssembledPackageBodySchema`) | diff --git a/content/docs/references/kernel/package-upgrade.mdx b/content/docs/references/kernel/package-upgrade.mdx index 13f90e44a8b..7ba57ff6eff 100644 --- a/content/docs/references/kernel/package-upgrade.mdx +++ b/content/docs/references/kernel/package-upgrade.mdx @@ -147,6 +147,7 @@ Upgrade package request | **version** | `string` | ✅ | Package version (SemVer 2.0.0 — e.g. 1.2.3, 2.0.0-beta.1, 1.0.0+20230101) | | **type** | `Enum<'plugin' \| 'ui' \| 'driver' \| 'server' \| 'app' \| 'theme' \| 'agent' \| 'objectql' \| …>` | ✅ | Type of package | | **scope** | `Enum<'cloud' \| 'system' \| 'project'>` | optional (default: `"project"`) | Deployment scope: cloud \| system \| project | +| **installModes** | `Enum<'managed' \| 'unmanaged'>[]` | optional | Install modes this package permits (ADR-0131 D6): managed \| unmanaged. Absent means ['managed']. | | **name** | `string` | ✅ | Human-readable package name | | **description** | `string` | optional | Package description | | **permissions** | `{ services?: string[]; hooks?: string[]; network?: string[]; fs?: string[] }` | optional | Required permissions at the AUTHORING stage: the structured plugin block `{ services, hooks, network, fs }` (ADR-0025 §3.2) — the legacy flat string list is retired — and at the assembled stage the same key is the ADR-0090 `PermissionSet[]` collection instead (`AssembledPackageBodySchema`) | @@ -292,6 +293,7 @@ Pre-upgrade state snapshot for rollback capability | **version** | `string` | ✅ | Package version (SemVer 2.0.0 — e.g. 1.2.3, 2.0.0-beta.1, 1.0.0+20230101) | | **type** | `Enum<'plugin' \| 'ui' \| 'driver' \| 'server' \| 'app' \| 'theme' \| 'agent' \| 'objectql' \| …>` | ✅ | Type of package | | **scope** | `Enum<'cloud' \| 'system' \| 'project'>` | optional (default: `"project"`) | Deployment scope: cloud \| system \| project | +| **installModes** | `Enum<'managed' \| 'unmanaged'>[]` | optional | Install modes this package permits (ADR-0131 D6): managed \| unmanaged. Absent means ['managed']. | | **name** | `string` | ✅ | Human-readable package name | | **description** | `string` | optional | Package description | | **permissions** | `{ services?: string[]; hooks?: string[]; network?: string[]; fs?: string[] }` | optional | Required permissions at the AUTHORING stage: the structured plugin block `{ services, hooks, network, fs }` (ADR-0025 §3.2) — the legacy flat string list is retired — and at the assembled stage the same key is the ADR-0090 `PermissionSet[]` collection instead (`AssembledPackageBodySchema`) | diff --git a/packages/cli/src/commands/package/install.ts b/packages/cli/src/commands/package/install.ts index 470f883d239..1b9b3b9d1e9 100644 --- a/packages/cli/src/commands/package/install.ts +++ b/packages/cli/src/commands/package/install.ts @@ -4,17 +4,31 @@ * `objectstack package install` — install a package into a RUNNING ObjectStack * runtime through its install-local endpoint (ADR-0008 Phase 3). * - * Two modes, by argument shape: + * Two sources, by argument shape: * * os package install com.acme.crm [--version 1.2.0] - * Catalog mode: the runtime fetches the manifest snapshot from ITS + * Catalog source: the runtime fetches the manifest snapshot from ITS * configured catalog (`OS_CLOUD_URL` of the runtime, public R2 - * fast-path first) and registers it into the live kernel. + * fast-path first) and installs it. * * os package install ./dist/objectstack.json - * Air-gapped mode: the compiled artifact is read locally and sent + * Air-gapped source: the compiled artifact is read locally and sent * inline — no catalog round-trip, works fully offline. * + * Two install MODES, by `--install-mode` (ADR-0131 D6), either source: + * + * managed (the default) — the package is registered into the live kernel as + * code and stays the authority: it upgrades, and nothing it ships is + * edited in place. + * unmanaged — the package's metadata is copied ONCE into the environment as + * a new writable base: the environment's own metadata, followed by no + * upgrade, and + * refused by a runtime whose tenancy posture walls organizations. Only for + * a package whose manifest's `installModes` permits it. + * + * "Source" and "mode" are two words for two questions on purpose: where the + * package comes from, and how it is installed. + * * This complements `os package publish` (which uploads to the CLOUD): the * pairing is publish-to-cloud / install-into-runtime. The target runtime * authenticates the call with its own better-auth session — pass @@ -26,6 +40,7 @@ import { readFile } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { resolve as resolvePath } from 'node:path'; import { Args, Command, Flags } from '@oclif/core'; +import { INSTALL_MODES, type InstallMode } from '@objectstack/spec/kernel'; import { printHeader, printKV, printSuccess, printWarning, printError, printStep, isExitSignal } from '../../utils/format.js'; export default class PackageInstall extends Command { @@ -36,6 +51,7 @@ export default class PackageInstall extends Command { '$ os package install com.acme.crm', '$ os package install com.acme.crm --version 1.2.0 --runtime http://localhost:3000', '$ os package install ./dist/objectstack.json # air-gapped, no catalog', + '$ os package install ./dist/objectstack.json --install-mode unmanaged # copy into the environment as its own metadata', '$ OS_RUNTIME_EMAIL=admin@local.test OS_RUNTIME_PASSWORD=… os package install com.acme.crm', ]; @@ -55,7 +71,7 @@ export default class PackageInstall extends Command { }), version: Flags.string({ char: 'v', - description: "Version to install in catalog mode (default: 'latest')", + description: "Version to install from the catalog (default: 'latest')", default: 'latest', }), email: Flags.string({ @@ -80,6 +96,18 @@ export default class PackageInstall extends Command { // for (`unconfirmedGlobalUniques` in `@objectstack/types`) — an attested // install does not become the recurring nag the gate exists to avoid. It // does not display who affirmed it or when. + // [ADR-0131 D6] How the package is installed — the install-local body's + // `installMode`, spelled by the manifest key's own enum. Not defaulted + // here: an absent flag sends no `installMode`, which the runtime reads as + // `managed`, so a runtime that predates the key receives the body it + // always did. + 'install-mode': Flags.string({ + description: + "How to install: 'managed' (default — registered as code, upgradeable) or 'unmanaged' (copied once into the " + + "environment as its own metadata, no upgrade; the package's `installModes` must permit it, and a runtime " + + 'that walls organizations refuses it)', + options: [...INSTALL_MODES], + }), 'confirm-global-uniques': Flags.boolean({ description: "Confirm this app's installation-wide (`unique: 'global'`) constraints are genuinely platform-wide when " @@ -95,7 +123,7 @@ export default class PackageInstall extends Command { const runtime = flags.runtime.replace(/\/+$/, ''); try { - // ---- Resolve mode: inline artifact file vs catalog id --------------- + // ---- Resolve the source: inline artifact file vs catalog id --------- const looksLikeFile = args.package.endsWith('.json') || args.package.startsWith('./') || args.package.startsWith('../') @@ -167,6 +195,8 @@ export default class PackageInstall extends Command { // ---- Install --------------------------------------------------------- printStep(`Installing ${label} into ${runtime}...`); if (flags['confirm-global-uniques']) body.confirmGlobalUniques = true; + const installMode = flags['install-mode'] as InstallMode | undefined; + if (installMode !== undefined) body.installMode = installMode; const res = await this.request(`${runtime}/api/v1/marketplace/install-local`, { method: 'POST', headers: { @@ -220,6 +250,41 @@ export default class PackageInstall extends Command { } const data = res.body?.data ?? res.body ?? {}; + + // [ADR-0131 D6] An unmanaged install answers its own shape: what was + // copied and where, never a kernel registration — so none of the + // managed lines below (hot-load, the cached-manifest directory that + // "re-registers on every boot") is true of it, and none is printed. + if (data.installMode === 'unmanaged') { + console.log(''); + printSuccess('Package copied into the environment (unmanaged) — its items are this environment\'s own metadata, and it follows no upgrade'); + printKV(' Package', String(data.manifestId ?? label)); + printKV(' Version', String(data.version ?? flags.version)); + printKV(' Runtime', runtime); + if (typeof data.targetPackageId === 'string') printKV(' Base', data.targetPackageId); + if (typeof data.copiedCount === 'number') printKV(' Items copied', String(data.copiedCount)); + const from = data.installedFrom; + if (from && typeof from.packageId === 'string') { + printKV(' Installed from', typeof from.version === 'string' ? `${from.packageId}@${from.version}` : from.packageId); + } + if (data.seeded?.mode === 'skipped' && typeof data.seeded.reason === 'string') { + printKV(' Sample data', `not loaded — ${data.seeded.reason}`); + } + console.log(''); + return; + } + // Asked for `unmanaged`, answered without it: the runtime predates the + // install mode, read no `installMode`, and installed the package + // MANAGED. Said out loud — the opposite of what was asked happened. + if (installMode === 'unmanaged') { + printError( + `The runtime at ${runtime} does not support unmanaged installs: it ignored \`--install-mode unmanaged\` ` + + `and installed ${label} MANAGED (registered as code, not copied). Upgrade the runtime, then uninstall ` + + 'the package and install it again with `--install-mode unmanaged`.', + ); + this.exit(1); + return; + } // [#22729] A `200` is not by itself a hot install. install-local's // LENIENT path — a cloud-fetched manifest whose `manifest.register` // throws — writes the ledger entry and answers `200` with diff --git a/packages/cli/test/package-install-mode.test.ts b/packages/cli/test/package-install-mode.test.ts new file mode 100644 index 00000000000..0c5bd9b0d73 --- /dev/null +++ b/packages/cli/test/package-install-mode.test.ts @@ -0,0 +1,157 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D6] `os package install --install-mode managed|unmanaged` — the + * install-local body's `installMode`, and what the command says about each + * answer. + * + * Pinned: + * - the flag reaches the body as `installMode`, spelled by the manifest key's + * enum; an ABSENT flag sends no `installMode` (the runtime reads `managed`, + * and a runtime that predates the key receives the body it always did); + * - a value outside the enum is refused by the command before any request; + * - an unmanaged answer prints what was copied, where and from what — and + * none of the managed install's lines (hot-load, the cached manifest that + * "re-registers on every boot"), which are not true of a copy; + * - asking for `unmanaged` and getting an answer without it (a runtime that + * predates the mode installed the package MANAGED) exits 1, saying so; + * - a refusal prints its code beside its status, as every refusal does. + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import PackageInstall from '../src/commands/package/install.js'; + +type Answer = { ok: boolean; status: number; body: unknown }; + +/** Stub the runtime's install POST; record every body the command sends. */ +function stubRuntime(answer: Answer): { sent: Array> } { + const sent: Array> = []; + vi.stubGlobal('fetch', vi.fn(async (_url: string, init?: { body?: string }) => { + if (init?.body) sent.push(JSON.parse(init.body)); + return { + ok: answer.ok, + status: answer.status, + statusText: answer.ok ? 'OK' : 'Refused', + headers: { get: () => null }, + json: async () => answer.body, + } as any; + })); + return { sent }; +} + +async function runInstall(args: string[]): Promise<{ out: string; exit: number | undefined }> { + const lines: string[] = []; + const capture = (...a: unknown[]) => { lines.push(a.map(String).join(' ')); }; + vi.spyOn(console, 'log').mockImplementation(capture); + vi.spyOn(console, 'error').mockImplementation(capture); + vi.spyOn(process.stderr, 'write').mockImplementation(((chunk: unknown) => { lines.push(String(chunk)); return true; }) as any); + let exit: number | undefined; + try { + await PackageInstall.run(['com.acme.crm', '--runtime', 'http://runtime.test', ...args]); + } catch (err: any) { + exit = err?.oclif?.exit ?? err?.code ?? 1; + } + return { out: lines.join('\n'), exit }; +} + +const MANAGED_ANSWER = { + manifestId: 'com.acme.crm', version: '1.0.0', versionId: 'ver_1', installedAt: '2026-10-11T00:00:00.000Z', + hotLoaded: true, storageDir: '/srv/runtime/.objectstack/installed-packages', +}; + +const UNMANAGED_ANSWER = { + manifestId: 'com.acme.crm', + version: '1.0.0', + versionId: 'ver_1', + installMode: 'unmanaged', + installedAt: '2026-10-11T00:00:00.000Z', + installedFrom: { packageId: 'com.acme.crm', version: '1.0.0' }, + targetPackageId: 'com.acme.crm-copy', + namespace: 'crm', + copiedCount: 12, + copied: [], + seeded: { mode: 'skipped', reason: "an unmanaged install copies metadata, not data: the package's sample data was not loaded" }, +}; + +describe('os package install --install-mode (ADR-0131 D6)', () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('sends `installMode: \'unmanaged\'` when asked', async () => { + const { sent } = stubRuntime({ ok: true, status: 200, body: { success: true, data: UNMANAGED_ANSWER } }); + await runInstall(['--install-mode', 'unmanaged']); + expect(sent).toHaveLength(1); + expect(sent[0]).toMatchObject({ packageId: 'com.acme.crm', installMode: 'unmanaged' }); + }); + + it('sends `installMode: \'managed\'` when asked', async () => { + const { sent } = stubRuntime({ ok: true, status: 200, body: { success: true, data: MANAGED_ANSWER } }); + await runInstall(['--install-mode', 'managed']); + expect(sent[0]).toMatchObject({ installMode: 'managed' }); + }); + + it('sends NO `installMode` without the flag — the runtime reads managed, an older runtime the body it always got', async () => { + const { sent } = stubRuntime({ ok: true, status: 200, body: { success: true, data: MANAGED_ANSWER } }); + await runInstall([]); + expect(sent).toHaveLength(1); + expect(sent[0]).not.toHaveProperty('installMode'); + }); + + it('refuses a mode outside the enum before sending anything', async () => { + const { sent } = stubRuntime({ ok: true, status: 200, body: { success: true, data: MANAGED_ANSWER } }); + const { exit } = await runInstall(['--install-mode', 'template']); + expect(exit).not.toBeUndefined(); + expect(sent).toHaveLength(0); + }); + + it('prints an unmanaged answer as a copy — base, items, source — and none of the managed lines', async () => { + stubRuntime({ ok: true, status: 200, body: { success: true, data: UNMANAGED_ANSWER } }); + const { out, exit } = await runInstall(['--install-mode', 'unmanaged']); + expect(exit).toBeUndefined(); + expect(out).toMatch(/Package copied into the environment \(unmanaged\)/); + expect(out).toContain('com.acme.crm-copy'); + expect(out).toMatch(/Items copied:\s+12/); + expect(out).toContain('com.acme.crm@1.0.0'); + expect(out).toContain('sample data was not loaded'); + expect(out).not.toContain('Package installed into the running kernel'); + expect(out).not.toContain('re-registers on every'); + }); + + it('CONTROL: a managed answer prints as it always did', async () => { + stubRuntime({ ok: true, status: 200, body: { success: true, data: MANAGED_ANSWER } }); + const { out, exit } = await runInstall(['--install-mode', 'managed']); + expect(exit).toBeUndefined(); + expect(out).toContain('Package installed into the running kernel'); + expect(out).toContain('re-registers on every'); + expect(out).not.toContain('unmanaged'); + }); + + it('asked for unmanaged, answered without it: says the runtime installed it MANAGED, and exits 1', async () => { + stubRuntime({ ok: true, status: 200, body: { success: true, data: MANAGED_ANSWER } }); + const { out, exit } = await runInstall(['--install-mode', 'unmanaged']); + expect(exit).toBe(1); + expect(out).toMatch(/does not support unmanaged installs/); + expect(out).toContain('MANAGED'); + expect(out).not.toContain('Package installed into the running kernel'); + }); + + it('prints a refusal with its code beside its status', async () => { + stubRuntime({ + ok: false, + status: 409, + body: { + success: false, + error: { + code: 'UNMANAGED_INSTALL_WALLED_POSTURE', + message: "Package 'com.acme.crm' cannot be installed unmanaged on this deployment: it runs the 'isolated' tenancy posture.", + }, + }, + }); + const { out, exit } = await runInstall(['--install-mode', 'unmanaged']); + expect(exit).toBe(1); + expect(out).toContain('Install failed (409 UNMANAGED_INSTALL_WALLED_POSTURE)'); + expect(out).toContain("'isolated' tenancy posture"); + }); +}); diff --git a/packages/cloud-connection/src/marketplace-install-local-install-modes.test.ts b/packages/cloud-connection/src/marketplace-install-local-install-modes.test.ts new file mode 100644 index 00000000000..093e7809f11 --- /dev/null +++ b/packages/cloud-connection/src/marketplace-install-local-install-modes.test.ts @@ -0,0 +1,411 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D6] install-local's `installMode` — the door of the manifest key + * `installModes`, at the real install seam (the plugin's own route, mounted + * through `start()` + `kernel:ready`). + * + * Pinned, each with the zero-write evidence a refusal owes (no register, no + * schema sync, no ledger entry, no import): + * - `installMode` is parsed against the key's enum; absent means `managed`; + * - the package must PERMIT the requested mode (`installModes`, absent meaning + * `['managed']`): `422 INSTALL_MODE_NOT_PERMITTED`, naming what it permits; + * an `installModes` its declaration refuses answers `PLUGIN_MANIFEST_INVALID`; + * - `unmanaged` under a walled REQUESTED posture: `409 + * UNMANAGED_INSTALL_WALLED_POSTURE`, the import never called — with the lit + * control of a managed install on the same posture; + * - the `unmanaged` branch calls the import with ONE flat package body and the + * installer as actor, and runs none of the managed install's steps + * (register, ledger, sync); the answer names the base and `installedFrom`; + * the import's refusals are relayed with their own code, status and + * `details`, a 5xx's message withheld; + * - a managed install of a package whose namespace a WRITABLE base holds is + * refused `409 RESOURCE_CONFLICT` naming the base, on BOTH sources; a + * namespace held by a code package is not this refusal's. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +// The runtime binder the door's step 1d loads lazily — paid here, at module +// top, never inside a clocked `it` (`scripts/check-test-source-alias.mjs`). +import '@objectstack/runtime'; +import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; +import { installerAuthService, withInstallerGrants } from './install-local-principal.fixtures.js'; +import { LocalManifestSource } from './local-manifest-source.js'; + +const ROUTE = '/api/v1/marketplace/install-local'; +const INSTALLER = 'usr_installer'; + +type Handler = (c: any) => Promise; + +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, + }; +} + +/** A namespace's holders, and the packages behind them, as the registry answers. */ +interface RegistryState { + owners?: Record; + packages?: Record }>; + /** Booted code packages — `engine.manifests`. */ + codePackages?: string[]; +} + +async function mount(dir: string, opts: { + controlPlaneUrl?: string; + importer?: ((request: any) => Promise) | null; + registry?: RegistryState; +} = {}) { + const routes = new Map(); + const rawApp = { + 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), + }; + const hooks = new Map(); + const register = vi.fn(async () => undefined); + const syncSchemas = vi.fn(async () => undefined); + const importUnmanagedPackage = opts.importer === null ? undefined : vi.fn(opts.importer ?? (async (request: any) => ({ + sourcePackageId: request.manifest.id, + sourceVersion: request.manifest.version, + targetPackageId: `${request.manifest.id}-copy`, + namespace: request.manifest.namespace, + copiedCount: 2, + copied: [{ type: 'object', name: 'crm_lead' }, { type: 'view', name: 'crm_lead_open' }], + }))); + const state = opts.registry ?? {}; + const services: Record = { + manifest: { register }, + auth: installerAuthService(INSTALLER), + objectql: withInstallerGrants({ + syncSchemas, + manifests: new Map((state.codePackages ?? []).map((id) => [id, { id }])), + registry: { + getAllPackages: () => [], + getNamespaceOwners: (ns: string) => [...(state.owners?.[ns] ?? [])], + getPackage: (id: string) => state.packages?.[id], + }, + }, INSTALLER), + ...(importUnmanagedPackage ? { protocol: { importUnmanagedPackage } } : {}), + }; + const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; + 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')?.(); + register.mockClear(); + return { + install: (body: unknown) => routes.get(`POST ${ROUTE}`)!(makeC(body)), + register, + syncSchemas, + importUnmanagedPackage, + logger, + ledgerHas: (id: string) => new LocalManifestSource(dir).read(id).entry !== null, + }; +} + +/** A flat package body — its manifest fields beside its collections. */ +function crm(extra: Record = {}): Record { + return { + id: 'com.example.crm', + namespace: 'crm', + version: '4.0.0', + type: 'app', + name: 'CRM', + objects: [{ name: 'crm_lead', label: 'Lead', fields: { title: { type: 'text', label: 'Title' } } }], + ...extra, + }; +} + +/** + * The compiled artifact `os package install ` sends: the manifest NESTED + * under `manifest:`, the collections beside it, `id` / `version` hoisted. + */ +function compiledArtifact(manifestExtra: Record = {}): Record { + const manifest = { id: 'com.example.crm', namespace: 'crm', version: '4.0.0', type: 'app', name: 'CRM', ...manifestExtra }; + return { + manifest, + id: manifest.id, + version: manifest.version, + objects: [{ name: 'crm_lead', label: 'Lead', fields: { title: { type: 'text', label: 'Title' } } }], + data: [{ object: 'crm_lead', records: [{ title: 'First' }] }], + i18n: { defaultLocale: 'en' }, + }; +} + +/** Serve one manifest snapshot from the cloud leg; the public fast-path is off. */ +function serveSnapshot(manifest: unknown) { + vi.stubEnv('OS_MARKETPLACE_PUBLIC_BASE_URL', 'off'); + vi.stubEnv('OS_CLOUD_API_KEY', 'svc-test-key'); + vi.stubGlobal('fetch', vi.fn(async () => new Response( + JSON.stringify({ data: { manifest, version: '4.0.1', version_id: 'ver_2' } }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ))); +} + +const BOTH = ['managed', 'unmanaged']; + +let dir: string; +let savedPosture: string | undefined; +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'mil-install-modes-')); + savedPosture = process.env.OS_TENANCY_POSTURE; + delete process.env.OS_TENANCY_POSTURE; +}); +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + if (savedPosture === undefined) delete process.env.OS_TENANCY_POSTURE; + else process.env.OS_TENANCY_POSTURE = savedPosture; + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + vi.restoreAllMocks(); +}); + +describe('install-local — the requested `installMode`', () => { + it('refuses a mode outside the key\'s enum with 400 INVALID_REQUEST, before anything else', async () => { + const h = await mount(dir); + const res = await h.install({ manifest: crm({ installModes: BOTH }), installMode: 'template' }); + expect(res.status).toBe(400); + expect(res.payload.error.code).toBe('INVALID_REQUEST'); + expect(res.payload.error.message).toContain("'managed' or 'unmanaged'"); + expect(h.register).not.toHaveBeenCalled(); + expect(h.importUnmanagedPackage).not.toHaveBeenCalled(); + }); + + it('an absent `installMode` is a managed install, exactly as before', async () => { + const h = await mount(dir); + const res = await h.install({ manifest: crm() }); + expect(res.status).toBe(200); + expect(res.payload.data).not.toHaveProperty('installMode'); + expect(h.register).toHaveBeenCalledTimes(1); + expect(h.importUnmanagedPackage).not.toHaveBeenCalled(); + expect(h.ledgerHas('com.example.crm')).toBe(true); + }); +}); + +describe('install-local — the package must permit the mode (`installModes`)', () => { + it('refuses `unmanaged` for a package that declares no `installModes` (managed only) — 422, nothing written', async () => { + const h = await mount(dir); + const res = await h.install({ manifest: crm(), installMode: 'unmanaged' }); + expect(res.status).toBe(422); + expect(res.payload.error.code).toBe('INSTALL_MODE_NOT_PERMITTED'); + expect(res.payload.error.details).toEqual({ installMode: 'unmanaged', permitted: ['managed'] }); + expect(res.payload.error.message).toContain("absent, which means ['managed']"); + expect(h.importUnmanagedPackage).not.toHaveBeenCalled(); + expect(h.register).not.toHaveBeenCalled(); + expect(h.ledgerHas('com.example.crm')).toBe(false); + }); + + it('refuses `managed` for a copy-only package — 422, naming `unmanaged`', async () => { + const h = await mount(dir); + const res = await h.install({ manifest: crm({ installModes: ['unmanaged'] }) }); + expect(res.status).toBe(422); + expect(res.payload.error.code).toBe('INSTALL_MODE_NOT_PERMITTED'); + expect(res.payload.error.details.permitted).toEqual(['unmanaged']); + expect(h.register).not.toHaveBeenCalled(); + expect(h.ledgerHas('com.example.crm')).toBe(false); + }); + + it('refuses an `installModes` its declaration refuses — PLUGIN_MANIFEST_INVALID, 400 for an inline manifest', async () => { + const h = await mount(dir); + const res = await h.install({ manifest: crm({ installModes: ['template'] }) }); + expect(res.status).toBe(400); + expect(res.payload.error.code).toBe('PLUGIN_MANIFEST_INVALID'); + expect(res.payload.error.message).toContain('installModes'); + expect(h.register).not.toHaveBeenCalled(); + }); + + it('reads `installModes` where a compiled artifact declares it — on its nested `manifest:`', async () => { + const h = await mount(dir); + const res = await h.install({ manifest: compiledArtifact({ installModes: BOTH }), installMode: 'unmanaged' }); + expect(res.status, JSON.stringify(res.payload)).toBe(200); + expect(res.payload.data.installMode).toBe('unmanaged'); + }); +}); + +describe('install-local — `unmanaged` under a walled REQUESTED posture', () => { + // An UNRECOGNISED posture value is read as walled by the same function + // (pinned in `package-unmanaged-install.test.ts`); at this door the + // install-capability admission refuses such a boot first, so it is not a + // case this suite can reach. + for (const posture of ['isolated', 'group']) { + it(`'${posture}': 409 UNMANAGED_INSTALL_WALLED_POSTURE naming the posture, the import never called, nothing written`, async () => { + process.env.OS_TENANCY_POSTURE = posture; + const h = await mount(dir); + const res = await h.install({ manifest: crm({ installModes: BOTH }), installMode: 'unmanaged' }); + expect(res.status).toBe(409); + expect(res.payload.error.code).toBe('UNMANAGED_INSTALL_WALLED_POSTURE'); + expect(res.payload.error.details.posture).toBe(posture); + expect(h.importUnmanagedPackage).not.toHaveBeenCalled(); + expect(h.register).not.toHaveBeenCalled(); + expect(h.syncSchemas).not.toHaveBeenCalled(); + expect(h.ledgerHas('com.example.crm')).toBe(false); + }); + } + + it('CONTROL: the same package installed MANAGED under `isolated` installs', async () => { + process.env.OS_TENANCY_POSTURE = 'isolated'; + const h = await mount(dir); + const res = await h.install({ manifest: crm({ installModes: BOTH }), installMode: 'managed' }); + expect(res.status, JSON.stringify(res.payload)).toBe(200); + expect(h.register).toHaveBeenCalledTimes(1); + }); +}); + +describe('install-local — the `unmanaged` branch is the import, and nothing of the managed install', () => { + it('hands the import ONE flat package body and the installer as actor; registers, ledgers and syncs nothing', async () => { + const h = await mount(dir); + const res = await h.install({ manifest: compiledArtifact({ installModes: BOTH }), installMode: 'unmanaged' }); + expect(res.status, JSON.stringify(res.payload)).toBe(200); + + expect(h.importUnmanagedPackage).toHaveBeenCalledTimes(1); + const request = h.importUnmanagedPackage!.mock.calls[0]![0]; + expect(request.actor).toBe(INSTALLER); + // Flat: the nested manifest's fields beside the collections, no `manifest:` left. + expect(request.manifest).not.toHaveProperty('manifest'); + expect(request.manifest).toMatchObject({ + id: 'com.example.crm', namespace: 'crm', version: '4.0.0', installModes: BOTH, + }); + expect(Array.isArray(request.manifest.objects)).toBe(true); + + expect(h.register).not.toHaveBeenCalled(); + expect(h.syncSchemas).not.toHaveBeenCalled(); + expect(h.ledgerHas('com.example.crm')).toBe(false); + }); + + it('answers the copy: mode, base, items, and `installedFrom` — and says the sample data was not loaded', async () => { + const h = await mount(dir); + const res = await h.install({ manifest: compiledArtifact({ installModes: BOTH }), installMode: 'unmanaged' }); + expect(res.payload.success).toBe(true); + expect(res.payload.data).toMatchObject({ + manifestId: 'com.example.crm', + installMode: 'unmanaged', + targetPackageId: 'com.example.crm-copy', + namespace: 'crm', + copiedCount: 2, + installedFrom: { packageId: 'com.example.crm', version: '4.0.0' }, + seeded: { mode: 'skipped' }, + }); + expect(res.payload.data.seeded.reason).toContain('sample data was not loaded'); + expect(res.payload.data).not.toHaveProperty('hotLoaded'); + expect(res.payload.data).not.toHaveProperty('storageDir'); + // Recorded for information on the operator log too. + const info = h.logger.info.mock.calls.map((call: any[]) => String(call[0])).join('\n'); + expect(info).toContain("installed com.example.crm unmanaged: 2 item(s) copied into the environment base 'com.example.crm-copy'"); + expect(info).toContain('installed from com.example.crm@4.0.0'); + }); + + it('a package with no seed data answers no `seeded` at all', async () => { + const h = await mount(dir); + const res = await h.install({ manifest: crm({ installModes: BOTH }), installMode: 'unmanaged' }); + expect(res.status).toBe(200); + expect(res.payload.data).not.toHaveProperty('seeded'); + }); + + it('relays the import\'s refusal with its own code, status, message and `details` — no `issues`', async () => { + const refusal = Object.assign(new Error("Package 'com.example.crm' cannot be installed unmanaged: `jobs` — 1 'job' item(s)."), { + code: 'UNMANAGED_INSTALL_UNCOPYABLE', + status: 422, + details: { findings: [{ key: 'jobs', reason: 'runtime-uncreatable' }] }, + issues: [{ path: 'jobs', message: 'x' }], + }); + const h = await mount(dir, { importer: async () => { throw refusal; } }); + const res = await h.install({ manifest: crm({ installModes: BOTH }), installMode: 'unmanaged' }); + expect(res.status).toBe(422); + expect(res.payload.error).toEqual({ + code: 'UNMANAGED_INSTALL_UNCOPYABLE', + message: refusal.message, + details: { findings: [{ key: 'jobs', reason: 'runtime-uncreatable' }] }, + }); + }); + + it('withholds a 5xx\'s message and logs it', async () => { + const h = await mount(dir, { + importer: async () => { throw Object.assign(new Error('SQLITE_ERROR: disk I/O error'), { status: 500 }); }, + }); + const res = await h.install({ manifest: crm({ installModes: BOTH }), installMode: 'unmanaged' }); + expect(res.status).toBe(500); + expect(res.payload.error.message).not.toContain('SQLITE_ERROR'); + expect(res.payload.error.message).toContain('the reason is in the server log'); + expect(h.logger.warn.mock.calls.map((call: any[]) => String(call[0])).join('\n')).toContain('SQLITE_ERROR'); + }); + + it('a runtime whose protocol has no copy-once import answers 503 SERVICE_UNAVAILABLE — never a managed install instead', async () => { + const h = await mount(dir, { importer: null }); + const res = await h.install({ manifest: crm({ installModes: BOTH }), installMode: 'unmanaged' }); + expect(res.status).toBe(503); + expect(res.payload.error.code).toBe('SERVICE_UNAVAILABLE'); + expect(h.register).not.toHaveBeenCalled(); + expect(h.ledgerHas('com.example.crm')).toBe(false); + }); +}); + +describe('install-local — a managed install never takes over an unmanaged copy (both sources)', () => { + const copyHeld: RegistryState = { + owners: { crm: ['com.example.crm-copy'] }, + packages: { 'com.example.crm-copy': { manifest: { id: 'com.example.crm-copy', namespace: 'crm' } } }, + }; + + it('inline: 409 RESOURCE_CONFLICT naming the base, before any write', async () => { + const h = await mount(dir, { registry: copyHeld }); + const res = await h.install({ manifest: crm({ installModes: BOTH }) }); + expect(res.status).toBe(409); + expect(res.payload.error.code).toBe('RESOURCE_CONFLICT'); + expect(res.payload.error.details).toEqual({ namespace: 'crm', base: 'com.example.crm-copy' }); + expect(res.payload.error.message).toContain("held by the environment base 'com.example.crm-copy'"); + expect(h.register).not.toHaveBeenCalled(); + expect(h.syncSchemas).not.toHaveBeenCalled(); + expect(h.ledgerHas('com.example.crm')).toBe(false); + }); + + it('cloud-fetched (the lenient branch): the same refusal, before the lenient register and its ledger entry', async () => { + serveSnapshot(crm({ version: '4.0.1', installModes: BOTH })); + const h = await mount(dir, { controlPlaneUrl: 'http://cloud.test', registry: copyHeld }); + const res = await h.install({ packageId: 'com.example.crm' }); + expect(res.status).toBe(409); + expect(res.payload.error.code).toBe('RESOURCE_CONFLICT'); + expect(res.payload.error.details.base).toBe('com.example.crm-copy'); + expect(h.register).not.toHaveBeenCalled(); + expect(h.ledgerHas('com.example.crm')).toBe(false); + }); + + it('reads the namespace the package derives from its id when it declares none', async () => { + const h = await mount(dir, { registry: copyHeld }); + const { namespace: _ns, ...undeclared } = crm(); + const res = await h.install({ manifest: undeclared }); + expect(res.status).toBe(409); + expect(res.payload.error.details).toEqual({ namespace: 'crm', base: 'com.example.crm-copy' }); + }); + + it('CONTROL: a namespace held by a CODE package is not this refusal\'s — the install proceeds to its own steps', async () => { + const h = await mount(dir, { + registry: { + owners: { crm: ['com.example.other'] }, + packages: { 'com.example.other': { manifest: { id: 'com.example.other', namespace: 'crm' } } }, + codePackages: ['com.example.other'], + }, + }); + const res = await h.install({ manifest: crm() }); + expect(res.payload?.error?.code).not.toBe('RESOURCE_CONFLICT'); + expect(h.register).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/cloud-connection/src/marketplace-install-local-plugin.ts b/packages/cloud-connection/src/marketplace-install-local-plugin.ts index c87f409f713..764209c7ada 100644 --- a/packages/cloud-connection/src/marketplace-install-local-plugin.ts +++ b/packages/cloud-connection/src/marketplace-install-local-plugin.ts @@ -45,6 +45,30 @@ * `200` with `hotLoaded: false` and the register error's message as * `hotLoadError` — in the ledger, loaded at the next restart. * + * `installMode: 'managed' | 'unmanaged'` (ADR-0131 D6; absent means + * `managed`, the key parsed against `InstallModeSchema`) chooses + * HOW the package is installed, on either source: + * - `managed`: everything above — registered as code, ledgered, + * upgradeable. Refused `409 RESOURCE_CONFLICT` when a WRITABLE + * base holds the package's namespace (an unmanaged copy of it), + * naming the base, before any write. + * - `unmanaged`: the package's metadata is copied ONCE into a new + * writable base in the environment ledger + * (`importUnmanagedPackage`): the environment's own metadata, + * which the next save edits (an item shipped with an authored + * `_lock` keeps that lock on the copy as written today, and a + * follow-up on #15213 strips it per #22971's ruling A), + * followed by no upgrade; + * nothing is registered, ledgered or rehydrated under the + * package's id, and its sample data is not loaded. Refused + * `409 UNMANAGED_INSTALL_WALLED_POSTURE` on a deployment whose + * REQUESTED tenancy posture walls organizations: after the + * payload check, step 1b (package id), the ADR-0131 D3 + * walled-catalog refusal and step 1c (protocol handshake), + * none of which writes, and before any write. + * A mode the package's `installModes` does not permit answers + * `422 INSTALL_MODE_NOT_PERMITTED`, before any write. + * * GET /api/v1/marketplace/install-local * → lists currently installed marketplace packages. Requires an * authenticated principal (anonymous → 401); `installedBy` and @@ -108,7 +132,15 @@ import { inProcessSessionReadInput, } from '@objectstack/types'; import { postureEnforcesWall, type TenancyPosture } from '@objectstack/spec/security'; -import { ManifestSchema, manifestIdRefusal } from '@objectstack/spec/kernel'; +import { + ManifestSchema, + manifestIdRefusal, + InstallModeSchema, + resolveInstallModes, + deriveNamespaceFromPackageId, + unmanagedInstallValueCarries, + type InstallMode, +} from '@objectstack/spec/kernel'; // [#21762] ADR-0087 D1's protocol handshake, its brand predicate and the ONE // answer both package-install doors give its refusal. Imported from the // producer, never restated: `POST /api/v1/packages` answers through the same @@ -148,7 +180,19 @@ import { ConnectionCredentialStore } from './connection-credential-store.js'; import { MARKETPLACE_INSTALLED_UI_BUNDLE } from './marketplace-ui.js'; import { matchSeedRows, purgeSeedRows, type PurgeSeedDataset } from './marketplace-install-local-purge.js'; import type { IHttpServer, IMetadataService, IObjectQLEngine } from '@objectstack/spec/contracts'; -import type { DeletePackageRequest, UninstallCleanupOutcome } from '@objectstack/metadata-protocol'; +import type { + DeletePackageRequest, + UninstallCleanupOutcome, + ObjectStackProtocolImplementation, + PackageWritabilityEngine, +} from '@objectstack/metadata-protocol'; +// [ADR-0131 D6] The `unmanaged` install mode's two shared judgements, imported +// from their producer rather than restated: the walled-posture refusal the +// import verb raises (answered here after the payload, id and handshake checks, +// before any write), the read-only-package rule (a namespace held by a WRITABLE base is +// this environment's own metadata), and the client-facing sentence rule. +import { unmanagedInstallPostureRefusal, isWritablePackage, clientFacingFailureText } from '@objectstack/metadata-protocol'; +import { resolveThrownHttpError } from '@objectstack/types'; const ROUTE_BASE = '/api/v1/marketplace/install-local'; @@ -242,6 +286,76 @@ const INSTALL_LOCAL_CAPABILITY = 'manage_metadata'; const UNRUNNABLE_REFUSAL_CODE = 'VALIDATION_ERROR'; const UNRUNNABLE_REFUSAL_STATUS = 422; +/** + * [ADR-0131 D6] The refusal of an install mode the package does not permit: + * its manifest's `installModes` (read through `resolveInstallModes`, absent + * meaning `['managed']`) does not list the requested mode. 422 — the request + * is well formed and the package refuses that way of installing it; the + * remedy is a permitted mode, named in the message and in + * `details.permitted`. Its own code because a consumer's branch differs from + * every other refusal here: retrying with another mode helps, rewriting the + * package does not need to. + */ +const INSTALL_MODE_NOT_PERMITTED_CODE = 'INSTALL_MODE_NOT_PERMITTED'; +const INSTALL_MODE_NOT_PERMITTED_STATUS = 422; + +/** + * [ADR-0131 D6] The one verb the `unmanaged` install mode calls: the + * copy-once import. Typed from the producer's own method signature, the + * shape `PackagesDomainProtocol` gives its verbs in + * `packages/runtime/src/domains/packages.ts`, and asked as a capability + * question at the call site — a protocol from a build without the verb cannot + * install unmanaged, and the door says so instead of falling back. + */ +type UnmanagedImporter = { + importUnmanagedPackage?: ObjectStackProtocolImplementation['importUnmanagedPackage']; +}; + +/** + * [ADR-0131 D6] What the managed-after-unmanaged refusal reads off the + * `objectql` slot: who holds a namespace, and — through ADR-0070's one + * predicate, whose own structural contract this extends — whether a holder is + * a writable base. A narrow per-consumer interface, so the lookup carries a + * contract rather than erasing the slot. + */ +type NamespaceHoldingEngine = PackageWritabilityEngine & { + registry?: PackageWritabilityEngine['registry'] & { + getNamespaceOwners?(namespace: string): unknown; + }; +}; + +/** A `{ … }` object — not an array, not null. */ +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** + * [ADR-0131 D6] The package manifest an install body DECLARES — where each of + * the two single-package shapes this door accepts carries it: a compiled + * artifact (`dist/objectstack.json`, what `os package install ` sends) + * nests it under `manifest:` beside the collections (ADR-0130 D4), and a + * flattened bundle carries its manifest fields at the top. Read for the keys + * an install decides by — `installModes`, `namespace` — never a second guess + * at either: each shape has exactly one place for them. + */ +function declaredPackageManifest(body: Record): Record { + return isPlainObject(body.manifest) ? body.manifest : body; +} + +/** + * [ADR-0131 D6] ONE package body, flat — what the `unmanaged` import copies: + * a compiled artifact's manifest fields beside its collections, the + * collections winning a key both carry (the assembled stage's `permissions` + * is the permission-set collection, its `objects` the definitions — the + * flatten order `AssembledPackageBodySchema` records). A body already flat is + * returned as it is. + */ +function flattenPackageBody(body: Record): Record { + if (!isPlainObject(body.manifest)) return body; + const { manifest: meta, ...sections } = body; + return { ...meta, ...sections }; +} + /** What this door cannot run in a package, as the runtime binder judges it. */ interface UnrunnableCode { jobs: ReadonlyArray<{ name: string; handler?: string; bodyRefusal?: string; pullRefusal?: string }>; @@ -969,6 +1083,27 @@ export class MarketplaceInstallLocalPlugin implements Plugin { let body: any = {}; try { body = await c.req.json(); } catch { /* empty body */ } + // 0. [ADR-0131 D6] ⭐ THE REQUESTED INSTALL MODE, parsed against the + // manifest key's own enum (`InstallModeSchema`, by reference — never + // a copy of the two spellings). Absent means `managed`, the mode + // every install took before the key existed. Answered before the + // cloud round-trip: a request this door cannot read is refused + // without fetching anything. + const requestedMode = body?.installMode === undefined + ? { success: true as const, data: 'managed' as InstallMode } + : InstallModeSchema.safeParse(body.installMode); + if (!requestedMode.success) { + return c.json({ + success: false, + error: { + code: 'INVALID_REQUEST', + message: `\`installMode\` must be 'managed' or 'unmanaged', received ${JSON.stringify(body.installMode)}. ` + + 'Omit it for a managed install.', + }, + }, 400); + } + const installMode: InstallMode = requestedMode.data; + // ── Offline path: an inline manifest was supplied (file import). ── // Bypass the cloud-fetch entirely; no OS_CLOUD_URL required. const inlineManifest = body?.manifest && typeof body.manifest === 'object' ? body.manifest : null; @@ -1185,6 +1320,27 @@ export class MarketplaceInstallLocalPlugin implements Plugin { }, 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 + // author changes in the package answers either refusal: + // - the package must PERMIT the requested mode: its manifest's + // `installModes`, parsed by reference + // (`ManifestSchema.shape.installModes`) and read through the one + // resolver, absent meaning `['managed']`; + // - `unmanaged` is refused on a deployment whose REQUESTED tenancy + // posture walls organizations (`group`, `isolated`, or an + // unrecognised value, read fail-closed). Requested, never in + // force (`classifyAdmissionTenancyPosture` answers `single` on a + // degraded walled boot and would admit an editable shared copy + // into a deployment its operator declared multi-tenant) — the + // posture the D5e gate below reads too. The refusal is the + // import verb's own, through the same function, so its code, + // status and sentence are one. + // Both on both branches, before any write. + const modeRefusal = this.judgeInstallMode(manifest, manifestId, installMode, inlineManifest ? 400 : 502); + if (modeRefusal) return c.json({ success: false, error: modeRefusal.error }, modeRefusal.status as any); + // 1d. [#21489] ⭐ CODE THIS DOOR CANNOT RUN IS REFUSED, not installed. // A job runs on a JSON door only through its sandboxed `body`; its // deprecated `handler` names a `defineStack({ functions })` entry, @@ -1230,6 +1386,54 @@ export class MarketplaceInstallLocalPlugin implements Plugin { }, UNRUNNABLE_REFUSAL_STATUS); } + // ── [ADR-0131 D6] The `unmanaged` install: a copy, not a registration. + // The package's metadata is copied ONCE into a new writable base in + // the environment ledger by the import verb, and the steps below — + // the conflict check, the D5e gate, `manifest.register`, the ledger + // entry, the schema sync, the binder, `applySideEffects` — are the + // MANAGED install's and never run for it: nothing is registered or + // recorded under the package's own id, so nothing upgrades it and no + // boot rehydrates it. Its items are the environment's own from here. + if (installMode === 'unmanaged') { + return this.installUnmanaged(c, ctx, { + manifest, + manifestId, + version, + versionId: resolvedVersionId, + userId, + }); + } + + // 1f. [ADR-0131 D6] ⭐ A MANAGED INSTALL NEVER TAKES OVER AN UNMANAGED + // COPY, on BOTH branches, before any write. A copy keeps the + // package's namespace under a writable base of its own, so a + // WRITABLE package holding this package's namespace is this + // environment's own metadata under the names the package ships. + // Registering the package over it would hand the namespace to code: + // on the inline branch the registry's namespace gate throws, but the + // cloud-fetched branch registers LENIENTLY (the ledger entry is + // written with `hotLoaded: false`), and at the next boot the + // package's definitions win and the copied, customer-edited items + // stop being served. So the door refuses first, naming the base. + // ⛔ Only a WRITABLE holder: a namespace held by another code + // package keeps the lenient branch's existing behaviour, whose fate + // is a separate decision. + const copyBase = this.findEnvironmentBaseHoldingNamespace(ctx, manifest, manifestId); + if (copyBase) { + return c.json({ + success: false, + error: { + code: 'RESOURCE_CONFLICT', + message: `Package '${manifestId}' cannot be installed managed: its namespace '${copyBase.namespace}' is held by ` + + `the environment base '${copyBase.base}' — an unmanaged install of this package, or a base authored ` + + 'in this environment — whose items are this environment\'s own metadata. A managed ' + + 'install would hand that namespace to the package and stop serving them. Keep the copy (it follows ' + + `no upgrade), or uninstall the base '${copyBase.base}' first; its items go with it.`, + details: { namespace: copyBase.namespace, base: copyBase.base }, + }, + }, 409); + } + // 2. Conflict check — refuse to overwrite user-authored apps const conflict = this.findConflict(ctx, manifestId); if (conflict === 'user-code') { @@ -1948,6 +2152,217 @@ export class MarketplaceInstallLocalPlugin implements Plugin { }; }; + /** + * [ADR-0131 D6] Judge the requested install mode against the package and + * the deployment — step 1e of {@link handleInstall}. `null` admits it; + * otherwise the refusal to answer with, before anything is written. + * + * 1. The package's `installModes`, parsed through its declaration by + * reference: a value the declaration refuses answers + * `PLUGIN_MANIFEST_INVALID` with the declaration's own sentence, at the + * status this door gives an invalid manifest (400 inline, 502 for a + * cloud snapshot) — the id gate's split. + * 2. The requested mode must be one the package permits + * ({@link resolveInstallModes}; absent means `['managed']`): + * `INSTALL_MODE_NOT_PERMITTED` / 422, naming the permitted modes. + * 3. `unmanaged` on a deployment whose REQUESTED tenancy posture walls + * organizations: the import verb's own refusal + * (`UNMANAGED_INSTALL_WALLED_POSTURE` / 409), from the same function. + */ + private judgeInstallMode = ( + manifest: Record, + manifestId: string, + installMode: InstallMode, + invalidManifestStatus: 400 | 502, + ): { status: number; error: { code: string; message: string; details?: Record } } | null => { + const declared = ManifestSchema.shape.installModes.safeParse(declaredPackageManifest(manifest).installModes); + if (!declared.success) { + const [issue] = declared.error.issues; + return { + status: invalidManifestStatus, + error: { + code: 'PLUGIN_MANIFEST_INVALID', + message: `Package '${manifestId}' declares an invalid \`installModes\`: ${issue?.message ?? 'refused by its declaration'}` + + `${issue && issue.path.length > 0 ? ` (at \`installModes.${issue.path.join('.')}\`)` : ''}.`, + }, + }; + } + const permitted = resolveInstallModes({ installModes: declared.data }); + if (!permitted.includes(installMode)) { + const others = permitted.map((m) => `'${m}'`).join(' or '); + return { + status: INSTALL_MODE_NOT_PERMITTED_STATUS, + error: { + code: INSTALL_MODE_NOT_PERMITTED_CODE, + message: `Package '${manifestId}' cannot be installed ${installMode}: it permits only ${others} ` + + `(its manifest's \`installModes\`${declared.data === undefined ? ', absent, which means [\'managed\']' : ''}). ` + + `Install it ${permitted.length === 1 ? permitted[0] : `with \`installMode\` ${others}`} instead.`, + details: { installMode, permitted: [...permitted] }, + }, + }; + } + if (installMode === 'unmanaged') { + const walled = unmanagedInstallPostureRefusal(manifestId) as (Error & { code?: string; status?: number; details?: Record }) | null; + if (walled) { + return { + status: walled.status ?? 409, + error: { + code: String(walled.code), + message: walled.message, + ...(walled.details ? { details: walled.details } : {}), + }, + }; + } + } + return null; + }; + + /** + * [ADR-0131 D6] The WRITABLE package holding this package's namespace, if + * any — step 1f of {@link handleInstall}, the managed-after-unmanaged + * refusal. An `unmanaged` install keeps the package's namespace under a + * new writable base, so a writable holder is environment metadata under + * the names the package ships. Read-only, so it is asked before any write. + * + * The namespace is the one the package DECLARES ({@link declaredPackageManifest}), + * or the one derived from its id when it declares none — the derivation + * the copy took. A shareable platform namespace (`base`, `system`, `sys`) + * is never one package's. "Writable" is ADR-0070's one predicate + * ({@link isWritablePackage}), the rule the authoring path enforces. + */ + private findEnvironmentBaseHoldingNamespace = ( + ctx: PluginContext, + manifest: Record, + manifestId: string, + ): { base: string; namespace: string } | null => { + const declared = declaredPackageManifest(manifest).namespace; + const namespace = typeof declared === 'string' && declared !== '' + ? declared + : deriveNamespaceFromPackageId(manifestId); + if (!namespace || namespace === 'base' || namespace === 'system' || namespace === 'sys') return null; + let ql: NamespaceHoldingEngine | undefined; + try { ql = ctx.getService('objectql'); } catch { return null; /* no data engine — nothing is held */ } + const owners: unknown = ql?.registry?.getNamespaceOwners?.(namespace); + if (!Array.isArray(owners)) return null; + for (const owner of owners) { + if (typeof owner !== 'string' || owner === manifestId) continue; + if (isWritablePackage(ql, owner)) return { base: owner, namespace }; + } + return null; + }; + + /** + * [ADR-0131 D6] The `unmanaged` install: the package's metadata copied + * ONCE into a new writable base by the protocol's + * `importUnmanagedPackage` — every item through the save door a Studio + * edit takes, `organization_id` NULL, the environment's own metadata from + * its first byte, and + * all or nothing (a write failure deletes the base). + * + * What this door does around the verb, and why: + * - it hands the verb ONE flat package body ({@link flattenPackageBody}); + * - the verb owns every refusal it can judge before the base exists + * (uncopyable content, collisions, an owned namespace, an existing + * target) and this door answers each with the verb's own code and + * status, the message when the verb declared it a refusal, and + * `details`; a 5xx's message is withheld and logged; + * - translation bundles are COPIED by the verb as `translation` items, so + * they reload at every boot with the environment's translations — this + * install writes no ledger entry for a rehydrate to replay; + * - seed data is NOT loaded: it is data, not metadata, and the + * reseed / purge doors act on ledger entries this install never + * writes, so seeded rows would be rows nothing here could clear. The + * answer says so (`seeded.mode: 'skipped'`) when the package declares any; + * - `installedFrom` — the source package id and version, for information + * only (ADR-0131 D6: "provenance recorded for information only") — is + * on the answer and on the operator log line, at `info`. + */ + private installUnmanaged = async ( + c: any, + ctx: PluginContext, + args: { manifest: Record; manifestId: string; version: string; versionId: string; userId: string | null }, + ): Promise => { + const { manifest, manifestId, version, versionId, userId } = args; + let protocol: UnmanagedImporter | undefined; + try { protocol = ctx.getService('protocol'); } catch { /* no protocol service */ } + if (!protocol || typeof protocol.importUnmanagedPackage !== 'function') { + return c.json({ + success: false, + error: { + code: 'SERVICE_UNAVAILABLE', + message: `Package '${manifestId}' cannot be installed unmanaged on this runtime: its metadata protocol ` + + 'has no copy-once import. Install it managed, or upgrade @objectstack/metadata-protocol alongside ' + + '@objectstack/cloud-connection.', + }, + }, 503); + } + + const packageBody = flattenPackageBody(manifest); + let result: Awaited>>; + try { + result = await protocol.importUnmanagedPackage({ + manifest: packageBody, + ...(userId ? { actor: userId } : {}), + } as Parameters>[0]); + } catch (err: any) { + const resolved = resolveThrownHttpError(err, 500); + if (resolved.status >= 500) { + ctx.logger?.warn?.( + `[MarketplaceInstallLocal] the unmanaged install of ${manifestId} failed: ${err?.message ?? err}`, + ); + } + const details = isPlainObject(err?.details) ? err.details : undefined; + return c.json({ + success: false, + error: { + code: resolved.code, + message: clientFacingFailureText( + err, + `The unmanaged install of '${manifestId}' failed; the reason is in the server log.`, + ), + ...(details ? { details } : {}), + }, + }, resolved.status as any); + } + + const installedFrom = { + packageId: result.sourcePackageId, + ...(result.sourceVersion !== undefined ? { version: result.sourceVersion } : {}), + }; + ctx.logger?.info?.( + `[MarketplaceInstallLocal] installed ${manifestId} unmanaged: ${result.copiedCount} item(s) copied into the ` + + `environment base '${result.targetPackageId}' (namespace '${result.namespace}'), installed from ` + + `${installedFrom.packageId}${installedFrom.version ? `@${installedFrom.version}` : ''}` + + `${userId ? ` by ${userId}` : ''}. Nothing is registered under '${manifestId}'; the copy follows no upgrade.`, + ); + const declaresSeeds = unmanagedInstallValueCarries(packageBody.data); + return c.json({ + success: true, + data: { + manifestId, + version, + versionId, + installMode: 'unmanaged', + installedAt: new Date().toISOString(), + installedFrom, + targetPackageId: result.targetPackageId, + namespace: result.namespace, + copiedCount: result.copiedCount, + copied: result.copied, + ...(declaresSeeds + ? { + seeded: { + mode: 'skipped', + reason: 'an unmanaged install copies metadata, not data: the package\'s sample data was not loaded', + }, + } + : {}), + note: `The package's metadata is now this environment's own, in the base '${result.targetPackageId}', ` + + 'and it follows no upgrade. Refresh the console to see it.', + }, + }, 200); + }; + /** * Detect whether `manifestId` is already known to the kernel and classify * the source so we can refuse vs upgrade gracefully. diff --git a/packages/cloud-connection/vitest.config.ts b/packages/cloud-connection/vitest.config.ts index 9206035c576..c06b74f39da 100644 --- a/packages/cloud-connection/vitest.config.ts +++ b/packages/cloud-connection/vitest.config.ts @@ -50,6 +50,16 @@ export default defineConfig({ find: /^@objectstack\/metadata-core$/, replacement: path.resolve(__dirname, '../metadata-core/src/index.ts'), }, + // [ADR-0131 D6] The install door now imports VALUES from the protocol — + // the `unmanaged` mode's walled-posture refusal, the read-only-package + // predicate and the client-facing sentence rule — so the install-local + // suites judge the door against the protocol in THIS checkout, not a + // `metadata-protocol/dist` that may be behind it. Anchored, same reason + // as the entries above. + { + find: /^@objectstack\/metadata-protocol$/, + replacement: path.resolve(__dirname, '../metadata-protocol/src/index.ts'), + }, // [#15204 W2] The install-local door reads a manifest's catalog // declarations through `declaredSecurityCatalogNames`, so every suite // that imports the plugin judges THIS checkout's engine reading, not an diff --git a/packages/metadata-protocol/src/index.ts b/packages/metadata-protocol/src/index.ts index e2a289d4a31..bfa62a288ee 100644 --- a/packages/metadata-protocol/src/index.ts +++ b/packages/metadata-protocol/src/index.ts @@ -225,6 +225,13 @@ export type { MetadataHostEngine } from './host-engine.js'; export { isWritablePackage, READ_ONLY_PACKAGE_SCOPES } from './package-writability.js'; export type { PackageWritabilityEngine } from './package-writability.js'; +// [ADR-0131 D6] The `unmanaged` install mode's walled-posture refusal — the +// function `importUnmanagedPackage` raises, exported so the install door +// (`@objectstack/cloud-connection` install-local) answers it with the same +// code, status and sentence before it judges anything else about a package, +// instead of keeping a second copy of the requested-posture rule. +export { unmanagedInstallPostureRefusal } from './package-unmanaged-install.js'; + // #4556 — the `sys_metadata_history.recorded_by` sentinel → NULL conversion, // as an ADR-0119 D2 migration plan. Run by `os migrate recorded-by`. export { diff --git a/packages/metadata-protocol/src/package-unmanaged-install.test.ts b/packages/metadata-protocol/src/package-unmanaged-install.test.ts index 7512612cac8..08c841e3f94 100644 --- a/packages/metadata-protocol/src/package-unmanaged-install.test.ts +++ b/packages/metadata-protocol/src/package-unmanaged-install.test.ts @@ -28,14 +28,13 @@ */ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { assertEngineDeleteDispatch, assertEngineFindOnePredicate, assertEngineUpdateDispatch } from '@objectstack/metadata-core'; -import { ObjectStackDefinitionSchema } from '@objectstack/spec'; import { ImportUnmanagedPackageResponseSchema } from '@objectstack/spec/api'; -import { DEFAULT_METADATA_TYPE_REGISTRY, ManifestSchema } from '@objectstack/spec/kernel'; +import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; import { resolveThrownHttpError } from '@objectstack/types'; import { UNMANAGED_IMPORT_COLLECTIONS, - UNMANAGED_IMPORT_KEY_DISPOSITIONS, planUnmanagedImport, + unmanagedTranslationItemName, } from './package-unmanaged-install.js'; import { ObjectStackProtocolImplementation } from './protocol.js'; import { resetEnvWritableMetadataTypes } from './sys-metadata-repository.js'; @@ -175,8 +174,9 @@ function crmBody(extra: Record = {}): Record { viewKind: 'list', columns: [{ field: 'title', label: 'Title' }], }], - // Not this verb's: the door applies seeds and bundles, in either mode. + // Not this verb's: seeds are data, and the door states what it did with them. data: [{ object: 'crm_lead', records: [{ title: 'First' }] }], + // An empty bundle list converts into no `translation` item. translations: [], i18n: { defaultLocale: 'en' }, ...extra, @@ -214,30 +214,10 @@ afterEach(() => { resetEnvWritableMetadataTypes(); }); -describe('the key classification is total against both schemas a package body is read with', () => { - const collections = new Set(UNMANAGED_IMPORT_COLLECTIONS.map(([key]) => key)); - // A key retired with a tombstone (ADR-0049) is refused as unrecognised, - // never classified: its declaration says nothing reads it. - const tombstoned = (schema: any) => /^\[REMOVED\]/.test(String(schema?.description ?? '')); - - it('classifies every live ManifestSchema key', () => { - const missing = Object.entries(ManifestSchema.shape) - .filter(([key, schema]) => !collections.has(key) && !tombstoned(schema) && !(key in UNMANAGED_IMPORT_KEY_DISPOSITIONS)) - .map(([key]) => key); - expect(missing).toEqual([]); - }); - - it('classifies every stack-definition key', () => { - const shape = (ObjectStackDefinitionSchema as any).shape as Record; - const missing = Object.keys(shape).filter((key) => !collections.has(key) && !(key in UNMANAGED_IMPORT_KEY_DISPOSITIONS)); - expect(missing).toEqual([]); - }); - - it('classifies nothing neither schema declares — a stale row is a lie about a key', () => { - const declared = new Set([...Object.keys(ManifestSchema.shape), ...Object.keys((ObjectStackDefinitionSchema as any).shape)]); - expect(Object.keys(UNMANAGED_IMPORT_KEY_DISPOSITIONS).filter((key) => !declared.has(key))).toEqual([]); - }); -}); +// The key classification's totality against `ManifestSchema` and the stack +// definition is held in spec, beside both schemas and the table itself +// (`packages/spec/src/kernel/unmanaged-install.test.ts`), so the PR that adds a +// key to either schema is the one that reddens. describe('planUnmanagedImport — what the body alone answers', () => { it('plans a NEW base: `-copy`, the SOURCE namespace kept, no scope, the identity carried', () => { @@ -309,6 +289,58 @@ describe('planUnmanagedImport — what the body alone answers', () => { }); } + it('reads `installModes` and writes it nowhere — the door judges it; the base makes no promise about how it installs', () => { + const p = plan(crmBody({ installModes: ['managed', 'unmanaged'] })); + expect(p.baseManifest).not.toHaveProperty('installModes'); + expect(p.items.map((i) => i.key)).not.toContain('installModes'); + }); + + it('an empty code container carries no code — `functions: {}` and `plugins: []` refuse nothing', () => { + const p = plan(crmBody({ functions: {}, plugins: [], objectExtensions: [] })); + expect(p.items.map((i) => `${i.type}/${i.name}`)).toEqual(['object/crm_lead', 'view/crm_lead_open']); + }); + + it('copies `translations` as one `translation` item per locale, named `_`, bundles merged in order', () => { + const p = plan(crmBody({ + translations: [ + { + en: { objects: { crm_lead: { label: 'Lead', fields: { title: { label: 'Title' } } } } }, + 'zh-CN': { objects: { crm_lead: { label: '线索', fields: { title: { label: '标题' } } } } }, + }, + // A second bundle on one locale: merged leaf by leaf, the later winning. + { 'zh-CN': { objects: { crm_lead: { fields: { title: { label: '主题' } } } }, messages: { saved: '已保存' } } }, + ], + })); + const translations = p.items.filter((i) => i.type === 'translation'); + expect(translations.map((i) => i.name)).toEqual(['crm_en', 'crm_zh_cn']); + expect(translations.every((i) => i.key === 'translations')).toBe(true); + expect(translations.find((i) => i.name === 'crm_zh_cn')!.body).toEqual({ + name: 'crm_zh_cn', + locale: 'zh-CN', + objects: { crm_lead: { label: '线索', fields: { title: { label: '主题' } } } }, + messages: { saved: '已保存' }, + }); + // After every collection whose items it may name: registry loadOrder. + expect(p.items.map((i) => i.type).indexOf('translation')).toBeGreaterThan(p.items.map((i) => i.type).indexOf('object')); + }); + + it('names the translation item after the namespace and the locale, in the save door\'s grammar', () => { + expect(unmanagedTranslationItemName('crm', 'zh-CN')).toBe('crm_zh_cn'); + expect(unmanagedTranslationItemName('crm', 'en')).toBe('crm_en'); + expect(unmanagedTranslationItemName('crm', 'zh-Hant-TW')).toBe('crm_zh_hant_tw'); + }); + + it('refuses a `translations` that is not a list of bundles — INVALID_REQUEST / 400, before anything is written', () => { + for (const translations of [{ en: {} }, ['en'], [{ en: 'Lead' }]]) { + let err: any; + try { plan(crmBody({ translations })); } catch (e) { err = e; } + const envelope = resolveThrownHttpError(err); + expect(envelope.status).toBe(400); + expect(envelope.code).toBe('INVALID_REQUEST'); + expect(err.message).toContain('`translations'); + } + }); + it('refuses a target equal to the source, and one the id declaration refuses — 400, no code minted', () => { for (const targetPackageId of ['com.example.crm', 'not-a-reverse-domain-id']) { let err: any; @@ -359,6 +391,21 @@ describe('importUnmanagedPackage — the verb', () => { expect(packages.has('com.example.crm')).toBe(false); }); + it('copies the translation bundles into the base as `translation` rows — environment translations, reloaded at every boot', async () => { + const { protocol, table } = makeProtocol(); + const result = await protocol.importUnmanagedPackage({ + manifest: crmBody({ + translations: [{ 'zh-CN': { objects: { crm_lead: { label: '线索' } } } }], + }), + }); + expect(result.copied).toContainEqual({ type: 'translation', name: 'crm_zh_cn' }); + const row = table('sys_metadata').find((r) => r.type === 'translation' && r.name === 'crm_zh_cn' && r.state === 'active'); + expect(row).toBeDefined(); + expect({ package_id: row!.package_id, organization_id: row!.organization_id ?? null }) + .toEqual({ package_id: 'com.example.crm-copy', organization_id: null }); + expect(JSON.parse(row!.metadata)).toMatchObject({ locale: 'zh-CN', objects: { crm_lead: { label: '线索' } } }); + }); + it('creates the copied object\'s table through the save door', async () => { const { protocol, ddl } = makeProtocol(); await protocol.importUnmanagedPackage({ manifest: crmBody() }); diff --git a/packages/metadata-protocol/src/package-unmanaged-install.ts b/packages/metadata-protocol/src/package-unmanaged-install.ts index 352e1b7afc9..6d30e75e2b4 100644 --- a/packages/metadata-protocol/src/package-unmanaged-install.ts +++ b/packages/metadata-protocol/src/package-unmanaged-install.ts @@ -9,7 +9,9 @@ * it upgrades, and nothing it ships may be edited. UNMANAGED (the spelling * ruled on the D6 execution card, 2026-10-11): every metadata item is copied * ONCE into the environment ledger, becomes this environment's own metadata, - * is fully editable, and never follows an upgrade. The orchestration — + * which the next save edits (an item shipped with an authored `_lock` keeps + * that lock as written today, and a follow-up on #15213 strips it per + * #22971's ruling A), and never follows an upgrade. The orchestration — * reading the store, minting the base, writing each item through * `saveMetaItem`, deleting the base when a write fails — is * `ObjectStackProtocolImplementation.importUnmanagedPackage` (`protocol.ts`). @@ -38,117 +40,45 @@ * - the artifact envelope (`packages`, a nested `manifest`), because one * import copies one package. * - * ## What is deliberately NOT this verb's — and is not dropped + * ## Translations are copied; seeds and deployment configuration are not * - * Two kinds of key are left to the caller, on purpose, because a managed - * install leaves them out of the manifest service too: - * - * - `data` (seed datasets) and `translations` (i18n bundles) are SIDE EFFECTS - * the install door applies after registering the package, in either mode — - * `marketplace-install-local-plugin.ts` step 5 runs the seeds and loads the - * bundles. Seeds are data, never metadata items (the artifact loader says - * so beside its collection map), so a copy of the metadata cannot carry - * them; the door that calls this verb decides their fate; + * - `translations` (i18n bundles) are COPIED, converted: each locale of each + * bundle becomes one `translation` item named `_` (the + * locale lower-cased, its hyphens underscored — `crm_zh_cn`), the bundles + * that declare one locale deep-merged in declaration order, as the i18n + * service merges bundles. A managed install's bundles reload at every boot + * because its install-ledger entry is replayed; an unmanaged install has no + * ledger entry, so its bundles live where environment translations live — + * in the environment ledger, reloaded at every boot by the + * authored-translation sync; + * - `data` (seed datasets) is data, never a metadata item (the artifact loader + * says so beside its collection map), so a copy of the metadata cannot carry + * it; the install door that calls this verb states what it did with it; * - deployment configuration (`i18n`, `requires`, `datasourceMapping`, `api`, * …) is read by the BOOT of the stack that declares it, never by an install * of a package, in either mode. * - * Every other key is refused as unrecognised: a key added to the stack later - * is refused here, loudly, until it is classified — never silently skipped. + * The classification of every key is spec's + * ({@link UNMANAGED_INSTALL_KEY_DISPOSITIONS}, `@objectstack/spec/kernel`), the + * one table `defineStack` also reads to refuse a package that permits + * `unmanaged` while carrying what this import refuses. Every key it does not + * classify is refused as unrecognised: a key added to the stack later is + * refused here, loudly, until it is classified — never silently skipped. */ -import { ManifestSchema, manifestIdRefusal, deriveNamespaceFromPackageId } from '@objectstack/spec/kernel'; +import { + ManifestSchema, + manifestIdRefusal, + deriveNamespaceFromPackageId, + UNMANAGED_INSTALL_KEY_DISPOSITIONS, + unmanagedInstallValueCarries, +} from '@objectstack/spec/kernel'; import { METADATA_ITEM_NAME_PATTERN, PLURAL_TO_SINGULAR } from '@objectstack/spec/shared'; import { ASSEMBLED_VIEW_ITEMS_KEY, expandViewContainer, isAggregatedViewContainer } from '@objectstack/spec/ui'; import { postureEnforcesWall, type TenancyPosture } from '@objectstack/spec/security'; import { resolveTenancyPosture } from '@objectstack/types'; import { deriveViewContainerObject } from '@objectstack/metadata/view-container'; -/** - * How the import treats one top-level key of a package body that is NOT a - * metadata collection. Collections are read off {@link UNMANAGED_IMPORT_COLLECTIONS}. - * - * - `identity` — describes the package; carried onto the new base's manifest. - * - `base` — read to mint the base and never carried as written: `id` is - * replaced by the target id, `namespace` is kept, `scope` is - * stripped because the base is a WRITABLE package. - * - `code` — refused: code, or a descriptor of a code-bearing artifact. - * - `uncopyable`— refused: package content the environment ledger has no row for. - * - `envelope` — refused: it describes an artifact, not one package. - * - `door` — not this verb's: a side effect the install door applies. - * - `deployment`— not this verb's: deployment configuration no install applies. - */ -export type UnmanagedImportKeyDisposition = - | 'identity' - | 'base' - | 'code' - | 'uncopyable' - | 'envelope' - | 'door' - | 'deployment'; - -/** - * Every non-collection key a flattened package body may carry, classified. - * The union of `ManifestSchema`'s live keys and the stack definition's - * non-collection keys; `package-unmanaged-install.test.ts` holds it total - * against both schemas, so a key either schema gains reddens a test instead of - * being refused at a door with no reason on record. - */ -export const UNMANAGED_IMPORT_KEY_DISPOSITIONS: Readonly> = { - // ── The package's identity ── - id: 'base', - namespace: 'base', - scope: 'base', - name: 'identity', - version: 'identity', - type: 'identity', - description: 'identity', - // An object with no `datasource` routes to its package's default, so a - // base that dropped this would move every such object to `default`. - defaultDatasource: 'identity', - dependencies: 'identity', - engines: 'identity', - engine: 'identity', - - // ── Code, and the descriptors of a code-bearing plugin artifact ── - functions: 'code', - onEnable: 'code', - plugins: 'code', - devPlugins: 'code', - runtimeModule: 'code', - main: 'code', - runtime: 'code', - packaging: 'code', - integrity: 'code', - - // ── Content the environment ledger has no row for ── - objectExtensions: 'uncopyable', - picklistExtensions: 'uncopyable', - navigationContributions: 'uncopyable', - contributes: 'uncopyable', - - // ── The artifact, not one package ── - packages: 'envelope', - manifest: 'envelope', - - // ── The install door's side effects, in either mode ── - data: 'door', - translations: 'door', - - // ── Deployment configuration: read by a stack's boot, by no install ── - i18n: 'deployment', - datasourceMapping: 'deployment', - api: 'deployment', - server: 'deployment', - appName: 'deployment', - email: 'deployment', - sms: 'deployment', - requires: 'deployment', - tiers: 'deployment', - devHint: 'deployment', - devLogins: 'deployment', -}; - /** * Every collection the import copies: manifest key → metadata type. The * manifest-collection spelling plus the assembled `viewItems` channel, which a @@ -310,7 +240,13 @@ export function planUnmanagedImport( } continue; } - const disposition = UNMANAGED_IMPORT_KEY_DISPOSITIONS[key]; + const disposition = UNMANAGED_INSTALL_KEY_DISPOSITIONS[key]; + // A refusing key that carries nothing — `functions: {}`, `plugins: []` — + // has nothing to refuse: a refusal names content, never a spelling. + if ((disposition === 'code' || disposition === 'uncopyable' || disposition === 'envelope') + && !unmanagedInstallValueCarries(value)) { + continue; + } switch (disposition) { case 'identity': identity[key] = value; @@ -332,6 +268,17 @@ export function planUnmanagedImport( : 'a nested manifest — flatten the bundle (its manifest fields beside its collections) first', }); break; + case 'converted': + // Copied as `translation` items — refused whole if that type + // ever stops being one the save door may create. + if (unmanagedInstallValueCarries(value) && !options.isRuntimeCreatable(TRANSLATION_TYPE)) { + findings.push({ + key, + reason: `copied as '${TRANSLATION_TYPE}' items, and the metadata-type registry declares ` + + `'${TRANSLATION_TYPE}' runtime-uncreatable`, + }); + } + break; case 'base': case 'door': case 'deployment': @@ -368,7 +315,7 @@ export function planUnmanagedImport( ); } - const items = enumerateItems(manifest, options.loadOrderOf); + const items = enumerateItems(manifest, options.loadOrderOf, namespace); const sourceVersion = stringKey(manifest, 'version'); const baseManifest: Record = { ...identity, @@ -386,19 +333,106 @@ export function planUnmanagedImport( }; } +/** The metadata type a `translations` bundle's locales are copied as. */ +const TRANSLATION_TYPE = 'translation'; + +/** A plain `{ … }` object — the shape a bundle, and each group in it, takes. */ +function isPlainRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** + * Merge `later` into `earlier`, later winning per leaf — the merge the i18n + * service applies when two bundles declare one locale, so the copied item + * resolves exactly as the two bundles did. Arrays and scalars replace. + */ +function mergeTranslationData(earlier: Record, later: Record): Record { + const out: Record = { ...earlier }; + for (const [key, value] of Object.entries(later)) { + const prior = out[key]; + out[key] = isPlainRecord(prior) && isPlainRecord(value) ? mergeTranslationData(prior, value) : value; + } + return out; +} + +/** + * The item name a locale's translations are copied under: the package's + * namespace, then the locale lower-cased with its hyphens underscored + * (`crm` + `zh-CN` → `crm_zh_cn`) — a name the save door's grammar admits, and + * one that no other package's copy of the same locale can take. + */ +export function unmanagedTranslationItemName(namespace: string, locale: string): string { + return `${namespace}_${locale.toLowerCase().replace(/-/g, '_')}`; +} + +/** + * The `translations` bundles as `translation` items — one per locale, the + * bundles that declare a locale merged in declaration order. Each item is the + * locale's groups as the bundle declares them, plus `name` and `locale`: the + * `translation` metadata type's item shape, which takes the same groups as a + * bundle entry. Problems are reported, never skipped. + */ +function translationItems( + value: unknown, + namespace: string, + problems: string[], +): Array<{ name: string; locale: string; body: Record }> { + if (!Array.isArray(value)) { + problems.push('`translations` is not an array of translation bundles'); + return []; + } + const byLocale = new Map>(); + value.forEach((bundle: unknown, index) => { + if (!isPlainRecord(bundle)) { + problems.push(`\`translations[${index}]\` is not a translation bundle (a map of locale to translations)`); + return; + } + for (const [locale, data] of Object.entries(bundle)) { + if (!isPlainRecord(data)) { + problems.push(`\`translations[${index}].${locale}\` is not a locale's translation data`); + continue; + } + const prior = byLocale.get(locale); + byLocale.set(locale, prior ? mergeTranslationData(prior, data) : data); + } + }); + return [...byLocale].map(([locale, data]) => { + const name = unmanagedTranslationItemName(namespace, locale); + return { name, locale, body: { ...data, name, locale } }; + }); +} + /** * Every item of every collection, named the way the artifact loader names it - * (a `defineView` container takes the object it binds), validated, and sorted - * into write order. Refuses a collection that is not an array, an item that is - * not an object or has no name, a name the save door's grammar refuses, and two + * (a `defineView` container takes the object it binds), plus the `translation` + * items the `translations` bundles convert into — validated, and sorted into + * write order. Refuses a collection that is not an array, an item that is not + * an object or has no name, a name the save door's grammar refuses, and two * items claiming one name. */ function enumerateItems( manifest: Record, loadOrderOf: (type: string) => number, + namespace: string, ): UnmanagedImportItem[] { const items: Array = []; const problems: string[] = []; + if (unmanagedInstallValueCarries(manifest.translations)) { + translationItems(manifest.translations, namespace, problems).forEach(({ name, body }, index) => { + if (!METADATA_ITEM_NAME_PATTERN.test(name)) { + problems.push(`\`translations\` locale '${String(body.locale)}' names the item ${JSON.stringify(name)}, which is not a legal metadata item name`); + return; + } + items.push({ + type: TRANSLATION_TYPE, + name, + key: 'translations', + body, + expands: [], + rank: [loadOrderOf(TRANSLATION_TYPE), UNMANAGED_IMPORT_COLLECTIONS.length, index], + }); + }); + } UNMANAGED_IMPORT_COLLECTIONS.forEach(([key, type], keyIndex) => { const value = manifest[key]; if (value === undefined) return; diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 850535b01db..85a9bc26d6a 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -3431,8 +3431,10 @@ function specValidationFindings( // as `: `, then a `(+N more)` tail. Read by // `duplicatePackage`'s `failed[].error`, `migrateStoredMetadata`'s // `rows[].reason`, `importUnmanagedPackage`'s rolled-back refusal - // (`'package-unmanaged-import'`), and the two out-of-package log - // faces — none of which carry `issues[]`. + // (`'package-unmanaged-import'`, whose one door relays no + // `issues[]` — see `MetadataWriteFace`), and the two + // out-of-package log faces — none of which carry `issues[]` to + // their caller. return issues.slice(0, 3) .map((i: { path: string; message: string }) => `${i.path || ''}: ${i.message}`) .join('; ') @@ -5043,10 +5045,18 @@ function detectDestructiveObjectChanges(prev: any, next: any): Array<{ * ⚠️ [ADR-0131 D6] `'package-unmanaged-import'` is stated by * {@link ObjectStackProtocolImplementation.importUnmanagedPackage}, the * `unmanaged` install mode's copy-once import, for every item it writes. Its - * refusal is relayed by that verb's own thrown error, which carries the - * item's message and nothing structured beside it, so it too keeps the - * full-prose default on the 422. Its 409 clause is its own: the import accepts - * no `force`, and it writes only names no environment row held when it began. + * refusal is relayed by that verb's own thrown error. The thrown value does + * forward the save door's `issues` (the rollback keeps the cause's code, + * status and issues), but the one door that calls the verb — + * `POST /api/v1/marketplace/install-local` with `installMode: 'unmanaged'` + * (`@objectstack/cloud-connection`) — answers with the message, the code and + * `details` only, so no `issues[]` reaches its caller beside the message and + * the message is the sole carrier of the findings: it keeps the full-prose + * default on the 422, the polarity `'external-import'` keeps for the same + * reason. A door that starts relaying `issues` moves this face into + * {@link specValidationFindings}' trimming case in the same change. Its 409 + * clause is its own: the import accepts no `force`, and it writes only names + * no environment row held when it began. */ type MetadataWriteFace = | 'package-duplicate' @@ -24474,8 +24484,10 @@ export class ObjectStackProtocolImplementation implements * the environment ledger, through {@link saveMetaItem} — the same save * door a Studio edit takes — so every copied item is environment metadata * from its first byte: owned by the base, `organization_id` NULL, served - * `_provenance: 'org'`, editable by the next save, and followed by no - * upgrade. The managed mode is the package install; this verb is what that + * `_provenance: 'org'`, edited by the next save (an item shipped with an + * authored `_lock` keeps that lock as written today, and a follow-up on + * #15213 strips it per #22971's ruling A), and followed by no upgrade. + * The managed mode is the package install; this verb is what that * install's manifest service is to it, and no more (see * `package-unmanaged-install.ts` for what is copied, what is refused, and * what is left to the door). diff --git a/packages/qa/dogfood/test/install-local-unmanaged-mode.dogfood.test.ts b/packages/qa/dogfood/test/install-local-unmanaged-mode.dogfood.test.ts new file mode 100644 index 00000000000..8f3ca22e7b8 --- /dev/null +++ b/packages/qa/dogfood/test/install-local-unmanaged-mode.dogfood.test.ts @@ -0,0 +1,385 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// GOLDEN REGRESSION — the `unmanaged` install mode end to end, on the real +// showcase composition with the install-local door mounted (ADR-0131 D6; the +// D6 execution card's stage-0 pins P1–P3, read with the spelling `unmanaged` +// the maintainer ruled, on the fixture the stage-0 ACCEPT named: the CRM +// example, `com.example.crm`). +// +// The package installed is the CRM example's built artifact, its manifest +// declaring `installModes: ['managed', 'unmanaged']` — exactly what +// `os package install ./dist/objectstack.json --install-mode unmanaged` sends. +// +// ## What each block pins +// +// P1 (`single`, one database file across a restart) +// - the install answers 200: the copy, its new base and `installedFrom`; +// - every copied item is served by `GET /meta` and saved by `PUT` (its own +// served body sent back) — editable shown by a write; +// - every copied row is owned by the new base, `organization_id` NULL; +// - nothing under the source id: no install-ledger entry, no registry +// package; the translation bundles arrive as `translation` items; +// - after a restart on the same database every item is still served, and +// the package is still not registered under its own id. +// P3 (the same boot, after the restart) +// - a managed install of a bumped version is refused before any write, +// naming the copy base — the copied rows' checksums byte-identical; +// - a second unmanaged install is refused on collision; +// - the import's three registry reads, measured on the REAL registry: an +// existing target, a target that is a booted code package, an owned +// namespace. +// P2 (`isolated`, its own boot) +// - the unmanaged install is refused with the ADR-0112 code and status, +// naming the posture, and writes nothing; positive control: the managed +// install of the same artifact on the same boot answers 200. +// +// Boots fixture stacks of its own, so it stays out of `SHARED_SHOWCASE`. + +import { existsSync, mkdtempSync, readdirSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import crmStack from '@objectstack/example-crm'; +import { type VerifyStack } from '@objectstack/verify'; +import { MarketplaceInstallLocalPlugin } from '@objectstack/cloud-connection'; +import type { IObjectQLEngine } from '@objectstack/spec/contracts'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; +import { bootShowcase } from './showcase-boot.js'; +import { buildShapedArtifact } from './build-shaped-artifact.js'; + +const SYS = { isSystem: true } as ExecutionContext; +const CRM = 'com.example.crm'; +const BASE_ID = 'com.example.crm-copy'; +const ROUTE = '/marketplace/install-local'; +const BOTH = ['managed', 'unmanaged']; + +type Answer = { status: number; body: any }; // eslint-disable-line @typescript-eslint/no-explicit-any + +type Item = { type: string; name: string }; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const rowsOf = (r: any): any[] => (Array.isArray(r) ? r : Array.isArray(r?.records) ? r.records : []); + +async function json(res: Response): Promise { + return { status: res.status, body: await res.json().catch(() => null) }; +} + +/** + * The install body `os package install .json [--install-mode …]` + * sends: the CRM example's built artifact, its manifest declaring both modes, + * `id` / `version` hoisted beside it. + */ +function installBody(opts: { installMode?: 'managed' | 'unmanaged'; version?: string } = {}): Record { + const { artifact } = buildShapedArtifact(crmStack as unknown as Record); + const manifest: Record = { + ...(artifact.manifest as Record), + installModes: BOTH, + ...(opts.version ? { version: opts.version } : {}), + }; + return { + manifest: { ...artifact, manifest, id: manifest.id, version: manifest.version }, + ...(opts.installMode ? { installMode: opts.installMode } : {}), + }; +} + +/** A served `/meta` document, unwrapped from its envelope. */ +function servedItem(answer: Answer): Record | undefined { + const doc = answer.body?.data ?? answer.body; + return (doc?.item ?? doc) as Record | undefined; +} + +/** The body a Studio editor sends back: the served document minus the read decorations. */ +function editableBody(item: Record): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(item)) if (!k.startsWith('_')) out[k] = v; + return out; +} + +/** The active `sys_metadata` rows of the copied items, as `type/name` → row. */ +async function copiedRows(ql: IObjectQLEngine, items: readonly Item[]): Promise> { // eslint-disable-line @typescript-eslint/no-explicit-any + const names = [...new Set(items.map((i) => i.name))]; + const rows = rowsOf(await ql.find('sys_metadata', { + where: { name: { $in: names }, state: 'active' }, + context: SYS, + })); + const wanted = new Set(items.map((i) => `${i.type}/${i.name}`)); + const out = new Map(); // eslint-disable-line @typescript-eslint/no-explicit-any + for (const row of rows) { + const key = `${row.type}/${row.name}`; + if (wanted.has(key)) out.set(key, row); + } + return out; +} + +describe('dogfood: the unmanaged install mode on `single` — P1 and P3 (ADR-0131 D6)', () => { + let stack: VerifyStack | undefined; + let storageDir: string; + let dbDir: string; + + let install: Answer; + let copied: Item[] = []; + let reads: Array = []; + let saves: Array = []; + let rowsAfterInstall = new Map(); // eslint-disable-line @typescript-eslint/no-explicit-any + let sourcePackageAfterInstall: unknown; + let ledgerFilesAfterInstall: string[] = []; + let translationServed: Answer; + + let readsAfterRestart: Array = []; + let sourcePackageAfterRestart: unknown; + let basePackageAfterRestart: unknown; + let namespaceOwnersAfterRestart: unknown; + + let managedBumped: Answer; + let checksumsBefore = new Map(); + let checksumsAfter = new Map(); + let ledgerFilesAfterManaged: string[] = []; + let secondUnmanaged: Answer; + let targetIsCodePackage: { code?: string; status?: number; message?: string } | null; + let namespaceOwned: { code?: string; status?: number; message?: string; details?: unknown } | null; + + const boot = (databaseFile: string) => bootShowcase({ + databaseFile, + extraPlugins: [new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir })], + }); + const ledgerFiles = () => (existsSync(storageDir) ? readdirSync(storageDir).filter((f) => f.endsWith('.json')) : []); + + beforeAll(async () => { + storageDir = mkdtempSync(join(tmpdir(), 'dogfood-unmanaged-ledger-')); + dbDir = mkdtempSync(join(tmpdir(), 'dogfood-unmanaged-db-')); + const databaseFile = join(dbDir, 'verify.db'); + + // ── boot 1: P1 ───────────────────────────────────────────────────────── + stack = await boot(databaseFile); + let token = await stack.signIn(); + let ql = stack.kernel.getService('objectql'); + + install = await json(await stack.apiAs(token, 'POST', ROUTE, installBody({ installMode: 'unmanaged' }))); + copied = (install.body?.data?.copied ?? []) as Item[]; + for (const item of copied) { + const read = await json(await stack.apiAs(token, 'GET', `/meta/${item.type}/${item.name}`)); + const served = servedItem(read); + reads.push({ ...item, status: read.status, provenance: served?._provenance }); + } + rowsAfterInstall = await copiedRows(ql, copied); + // Editable, shown by a write: each item's own served body, sent back. + for (const item of copied) { + const read = await json(await stack.apiAs(token, 'GET', `/meta/${item.type}/${item.name}`)); + const served = servedItem(read); + const put = await json(await stack.apiAs(token, 'PUT', `/meta/${item.type}/${item.name}`, editableBody(served ?? {}))); + saves.push({ ...item, status: put.status, body: put.status === 200 ? undefined : put.body }); + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sourcePackageAfterInstall = (ql as any).registry?.getPackage?.(CRM); + ledgerFilesAfterInstall = ledgerFiles(); + translationServed = await json(await stack.apiAs(token, 'GET', '/meta/translation/crm_zh_cn')); + await stack.stop(); + stack = undefined; + + // ── boot 2: the same database file and ledger directory ──────────────── + stack = await boot(databaseFile); + token = await stack.signIn(); + ql = stack.kernel.getService('objectql'); + for (const item of copied) { + const read = await json(await stack.apiAs(token, 'GET', `/meta/${item.type}/${item.name}`)); + readsAfterRestart.push({ ...item, status: read.status }); + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const registry = (ql as any).registry; + sourcePackageAfterRestart = registry?.getPackage?.(CRM); + basePackageAfterRestart = registry?.getPackage?.(BASE_ID); + namespaceOwnersAfterRestart = registry?.getNamespaceOwners?.('crm'); + + // ── P3 ────────────────────────────────────────────────────────────────── + const before = await copiedRows(ql, copied); + for (const [key, row] of before) checksumsBefore.set(key, `${row.checksum}|${row.metadata}`); + managedBumped = await json(await stack.apiAs(token, 'POST', ROUTE, installBody({ version: '4.0.1' }))); + const after = await copiedRows(ql, copied); + for (const [key, row] of after) checksumsAfter.set(key, `${row.checksum}|${row.metadata}`); + ledgerFilesAfterManaged = ledgerFiles(); + secondUnmanaged = await json(await stack.apiAs(token, 'POST', ROUTE, installBody({ installMode: 'unmanaged' }))); + + // The import's three registry reads, on the REAL registry (the verb + // itself, through the protocol service — the door always names the + // default target). + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const protocol: any = await stack.kernel.getServiceAsync('protocol'); + const flat = (() => { + const body = installBody().manifest as Record; // eslint-disable-line @typescript-eslint/no-explicit-any + const { manifest: meta, ...sections } = body; + return { ...meta, ...sections }; + })(); + const attempt = async (targetPackageId: string) => { + try { + await protocol.importUnmanagedPackage({ manifest: flat, targetPackageId }); + return null; + } catch (e: any) { // eslint-disable-line @typescript-eslint/no-explicit-any + return { code: e?.code, status: e?.status, message: e?.message, details: e?.details }; + } + }; + targetIsCodePackage = await attempt('com.example.showcase'); + namespaceOwned = await attempt('com.example.crm-second'); + }, 600_000); + + afterAll(async () => { + await stack?.stop?.(); + if (storageDir) rmSync(storageDir, { recursive: true, force: true }); + if (dbDir) rmSync(dbDir, { recursive: true, force: true }); + }); + + // ── P1 ──────────────────────────────────────────────────────────────────── + + it('P1: the unmanaged install answers 200 — the copy, its new base, and where it came from', () => { + expect(install.status, JSON.stringify(install.body)).toBe(200); + expect(install.body?.data).toMatchObject({ + manifestId: CRM, + installMode: 'unmanaged', + targetPackageId: BASE_ID, + namespace: 'crm', + installedFrom: { packageId: CRM, version: '4.0.0' }, + }); + expect(install.body.data.copiedCount).toBe(copied.length); + // Every kind the CRM ships, and a `defineView` CONTAINER — copied under the + // object it binds, its expansion judged and saved by the save door. + expect([...new Set(copied.map((i) => i.type))]).toEqual(expect.arrayContaining([ + 'object', 'view', 'app', 'page', 'dashboard', 'dataset', 'action', 'hook', 'flow', 'position', 'permission', + 'datasource', 'translation', + ])); + expect(copied).toContainEqual({ type: 'view', name: 'crm_lead' }); + // The CRM declares seed data, which an unmanaged install does not load — and says so. + expect(install.body.data.seeded?.mode).toBe('skipped'); + }); + + it('P1: every copied item is served by GET /meta, as environment-authored metadata', () => { + const unserved = reads.filter((r) => r.status !== 200); + expect(unserved, JSON.stringify(unserved)).toEqual([]); + const notEnvironment = reads.filter((r) => r.provenance !== undefined && r.provenance !== 'org'); + expect(notEnvironment, JSON.stringify(notEnvironment)).toEqual([]); + }); + + it('P1: every copied item is saved by PUT — its own served body, sent back', () => { + const refused = saves.filter((s) => s.status !== 200); + expect(refused, JSON.stringify(refused, null, 1)).toEqual([]); + }); + + it('P1: every copied row is owned by the NEW base, with organization_id NULL', () => { + expect(rowsAfterInstall.size, 'one active row per copied item').toBe(copied.length); + const misowned = [...rowsAfterInstall].filter(([, row]) => row.package_id !== BASE_ID || (row.organization_id ?? null) !== null); + expect(misowned.map(([key, row]) => `${key}@${row.package_id}|${row.organization_id}`)).toEqual([]); + }); + + it('P1: nothing under the source id — no install-ledger entry, no registry package', () => { + expect(ledgerFilesAfterInstall).toEqual([]); + expect(sourcePackageAfterInstall).toBeUndefined(); + }); + + it('P1: the translation bundles arrive as environment `translation` items', () => { + expect(copied).toContainEqual({ type: 'translation', name: 'crm_zh_cn' }); + expect(translationServed.status, JSON.stringify(translationServed.body)).toBe(200); + expect(servedItem(translationServed)).toMatchObject({ locale: 'zh-CN' }); + }); + + it('P1: after a restart on the same database every item is still served, and the package is still not registered', () => { + const unserved = readsAfterRestart.filter((r) => r.status !== 200); + expect(unserved, JSON.stringify(unserved)).toEqual([]); + expect(sourcePackageAfterRestart).toBeUndefined(); + expect(basePackageAfterRestart).toBeDefined(); + }); + + // ── P3 ──────────────────────────────────────────────────────────────────── + + it('P3: the copy base still holds the namespace after the restart — what the managed refusal reads', () => { + expect(namespaceOwnersAfterRestart).toEqual([BASE_ID]); + }); + + it('P3: a managed install of a bumped version is refused before any write, naming the copy base', () => { + expect(managedBumped.status, JSON.stringify(managedBumped.body)).toBe(409); + expect(managedBumped.body?.error?.code).toBe('RESOURCE_CONFLICT'); + expect(managedBumped.body?.error?.details).toEqual({ namespace: 'crm', base: BASE_ID }); + expect(managedBumped.body?.error?.message).toContain(`'${BASE_ID}'`); + expect(ledgerFilesAfterManaged, 'no ledger entry written').toEqual([]); + }); + + it('P3: the copied rows are byte-identical across the refused managed install', () => { + expect(checksumsBefore.size).toBe(copied.length); + expect([...checksumsAfter]).toEqual([...checksumsBefore]); + }); + + it('P3: a second unmanaged install is refused on collision — the copy base already exists', () => { + expect(secondUnmanaged.status, JSON.stringify(secondUnmanaged.body)).toBe(409); + expect(secondUnmanaged.body?.error?.code).toBe('RESOURCE_CONFLICT'); + expect(secondUnmanaged.body?.error?.message).toContain(`'${BASE_ID}' already exists`); + }); + + it('P3: the import\'s registry reads, on the real registry — a target that is a booted code package', () => { + expect(targetIsCodePackage?.status).toBe(409); + expect(targetIsCodePackage?.code).toBe('RESOURCE_CONFLICT'); + expect(targetIsCodePackage?.message).toContain("'com.example.showcase' already exists"); + }); + + it('P3: the import\'s registry reads, on the real registry — a namespace the copy base owns', () => { + expect(namespaceOwned?.status).toBe(409); + expect(namespaceOwned?.code).toBe('RESOURCE_CONFLICT'); + expect(namespaceOwned?.details).toEqual({ namespace: 'crm', owners: [BASE_ID] }); + }); +}); + +describe('dogfood: the unmanaged install mode on `isolated` — P2 (ADR-0131 D6)', () => { + let stack: VerifyStack | undefined; + let storageDir: string; + let tenancy: { posture?: string } | undefined; + let refused: Answer; + let baseAfterRefusal: unknown; + let baseRowsAfterRefusal: number; + let ledgerAfterRefusal: string[]; + let managed: Answer; + + beforeAll(async () => { + storageDir = mkdtempSync(join(tmpdir(), 'dogfood-unmanaged-isolated-')); + stack = await bootShowcase({ + multiTenant: 'posture-only', + extraPlugins: [new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir })], + }); + tenancy = await stack.kernel.getServiceAsync<{ posture: string }>('tenancy'); + const token = await stack.signIn(); + const ql = stack.kernel.getService('objectql'); + + refused = await json(await stack.apiAs(token, 'POST', ROUTE, installBody({ installMode: 'unmanaged' }))); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + baseAfterRefusal = (ql as any).registry?.getPackage?.(BASE_ID); + baseRowsAfterRefusal = rowsOf(await ql.find('sys_metadata', { where: { package_id: BASE_ID }, context: SYS })).length; + ledgerAfterRefusal = readdirSync(storageDir).filter((f) => f.endsWith('.json')); + + // Positive control on the same boot: the managed install of the same artifact. + managed = await json(await stack.apiAs(token, 'POST', ROUTE, installBody({ installMode: 'managed' }))); + }, 300_000); + + afterAll(async () => { + await stack?.stop?.(); + if (storageDir) rmSync(storageDir, { recursive: true, force: true }); + }); + + it('PRECONDITION: the requested posture on this boot is `isolated`', () => { + expect(tenancy?.posture).toBe('isolated'); + }); + + it('P2: refused 409 UNMANAGED_INSTALL_WALLED_POSTURE, naming the posture', () => { + expect(refused.status, JSON.stringify(refused.body)).toBe(409); + expect(refused.body?.error?.code).toBe('UNMANAGED_INSTALL_WALLED_POSTURE'); + expect(refused.body?.error?.details).toEqual({ posture: 'isolated' }); + expect(refused.body?.error?.message).toContain("'isolated' tenancy posture"); + }); + + it('P2: zero writes — no base, no row, no ledger entry', () => { + expect(baseAfterRefusal).toBeUndefined(); + expect(baseRowsAfterRefusal).toBe(0); + expect(ledgerAfterRefusal).toEqual([]); + }); + + it('P2 positive control: the managed install of the same artifact on the same boot answers 200', () => { + expect(managed.status, JSON.stringify(managed.body)).toBe(200); + expect(managed.body?.data?.manifestId).toBe(CRM); + expect(managed.body?.data).not.toHaveProperty('installMode'); + }); +}); diff --git a/packages/spec/api-surface/kernel.json b/packages/spec/api-surface/kernel.json index 24b3b0dc5c7..e5dabfa3d2e 100644 --- a/packages/spec/api-surface/kernel.json +++ b/packages/spec/api-surface/kernel.json @@ -33,6 +33,7 @@ "CompatibilityMatrixEntryParsed (type)", "CompatibilityMatrixEntrySchema (const)", "CompletenessFinding (interface)", + "DEFAULT_INSTALL_MODES (const)", "DEFAULT_METADATA_TYPE_REGISTRY (const)", "DeadLetterQueueEntry (type)", "DeadLetterQueueEntryParsed (type)", @@ -127,6 +128,9 @@ "HotReloadConfig (type)", "HotReloadConfigParsed (type)", "HotReloadConfigSchema (const)", + "INSTALL_MODES (const)", + "InstallMode (type)", + "InstallModeSchema (const)", "InstallPackageRequest (type)", "InstallPackageRequestParsed (type)", "InstallPackageRequestSchema (const)", @@ -416,11 +420,14 @@ "TenantRuntimeContext (type)", "TenantRuntimeContextParsed (type)", "TenantRuntimeContextSchema (const)", + "UNMANAGED_INSTALL_KEY_DISPOSITIONS (const)", + "UNMANAGED_INSTALL_REFUSING_DISPOSITIONS (const)", "UninstallPackageRequest (type)", "UninstallPackageRequestSchema (const)", "UninstallPackageResponse (type)", "UninstallPackageResponseSchema (const)", "UnknownAuthoringKeyFinding (interface)", + "UnmanagedInstallKeyDisposition (type)", "UpgradeImpactLevel (type)", "UpgradeImpactLevelSchema (const)", "UpgradePackageRequest (type)", @@ -484,8 +491,10 @@ "registerMetadataTypeActions (function)", "registerMetadataTypeRedactor (function)", "registerMetadataTypeSchema (function)", + "resolveInstallModes (function)", "resolveLockState (function)", "stripReadDecorations (function)", + "unmanagedInstallValueCarries (function)", "validateObjectNamespacePrefix (function)" ] } diff --git a/packages/spec/authorable-surface/kernel.json b/packages/spec/authorable-surface/kernel.json index ba66c3024b9..0bf45f41313 100644 --- a/packages/spec/authorable-surface/kernel.json +++ b/packages/spec/authorable-surface/kernel.json @@ -269,6 +269,7 @@ "kernel/Manifest:engines", "kernel/Manifest:extensions [RETIRED]", "kernel/Manifest:id", + "kernel/Manifest:installModes", "kernel/Manifest:integrity", "kernel/Manifest:loading [RETIRED]", "kernel/Manifest:main", diff --git a/packages/spec/declaration-map/kernel.json b/packages/spec/declaration-map/kernel.json index 92f5db85d9f..f4931f7cc36 100644 --- a/packages/spec/declaration-map/kernel.json +++ b/packages/spec/declaration-map/kernel.json @@ -85,6 +85,8 @@ "GetPackageResponseSchema": "kernel/GetPackageResponse", "HotReloadConfig": "kernel/HotReloadConfig", "HotReloadConfigSchema": "kernel/HotReloadConfig", + "InstallMode": "kernel/InstallMode", + "InstallModeSchema": "kernel/InstallMode", "InstallPackageRequest": "kernel/InstallPackageRequest", "InstallPackageRequestSchema": "kernel/InstallPackageRequest", "InstallPackageResponse": "kernel/InstallPackageResponse", diff --git a/packages/spec/export-origins/kernel.json b/packages/spec/export-origins/kernel.json index 3db69aff7fe..9fdab82e2c5 100644 --- a/packages/spec/export-origins/kernel.json +++ b/packages/spec/export-origins/kernel.json @@ -33,6 +33,7 @@ "CompatibilityMatrixEntryParsed": "src/kernel/plugin-versioning.zod.ts#CompatibilityMatrixEntryParsed (type)", "CompatibilityMatrixEntrySchema": "src/kernel/plugin-versioning.zod.ts#CompatibilityMatrixEntrySchema (const)", "CompletenessFinding": "src/kernel/functional-completeness.ts#CompletenessFinding (interface)", + "DEFAULT_INSTALL_MODES": "src/kernel/manifest.zod.ts#DEFAULT_INSTALL_MODES (const)", "DEFAULT_METADATA_TYPE_REGISTRY": "src/kernel/metadata-plugin.zod.ts#DEFAULT_METADATA_TYPE_REGISTRY (const)", "DeadLetterQueueEntry": "src/kernel/events/dlq.zod.ts#DeadLetterQueueEntry (type)", "DeadLetterQueueEntryParsed": "src/kernel/events/dlq.zod.ts#DeadLetterQueueEntryParsed (type)", @@ -126,6 +127,9 @@ "HotReloadConfig": "src/kernel/plugin-lifecycle-advanced.zod.ts#HotReloadConfig (type)", "HotReloadConfigParsed": "src/kernel/plugin-lifecycle-advanced.zod.ts#HotReloadConfigParsed (type)", "HotReloadConfigSchema": "src/kernel/plugin-lifecycle-advanced.zod.ts#HotReloadConfigSchema (const)", + "INSTALL_MODES": "src/kernel/manifest.zod.ts#INSTALL_MODES (const)", + "InstallMode": "src/kernel/manifest.zod.ts#InstallMode (type)", + "InstallModeSchema": "src/kernel/manifest.zod.ts#InstallModeSchema (const)", "InstallPackageRequest": "src/kernel/package-registry.zod.ts#InstallPackageRequest (type)", "InstallPackageRequestParsed": "src/kernel/package-registry.zod.ts#InstallPackageRequestParsed (type)", "InstallPackageRequestSchema": "src/kernel/package-registry.zod.ts#InstallPackageRequestSchema (const)", @@ -413,11 +417,14 @@ "TenantRuntimeContext": "src/kernel/context.zod.ts#TenantRuntimeContext (type)", "TenantRuntimeContextParsed": "src/kernel/context.zod.ts#TenantRuntimeContextParsed (type)", "TenantRuntimeContextSchema": "src/kernel/context.zod.ts#TenantRuntimeContextSchema (const)", + "UNMANAGED_INSTALL_KEY_DISPOSITIONS": "src/kernel/unmanaged-install.ts#UNMANAGED_INSTALL_KEY_DISPOSITIONS (const)", + "UNMANAGED_INSTALL_REFUSING_DISPOSITIONS": "src/kernel/unmanaged-install.ts#UNMANAGED_INSTALL_REFUSING_DISPOSITIONS (const)", "UninstallPackageRequest": "src/kernel/package-registry.zod.ts#UninstallPackageRequest (type)", "UninstallPackageRequestSchema": "src/kernel/package-registry.zod.ts#UninstallPackageRequestSchema (const)", "UninstallPackageResponse": "src/kernel/package-registry.zod.ts#UninstallPackageResponse (type)", "UninstallPackageResponseSchema": "src/kernel/package-registry.zod.ts#UninstallPackageResponseSchema (const)", "UnknownAuthoringKeyFinding": "src/data/authoring-key-lint.ts#UnknownAuthoringKeyFinding (interface)", + "UnmanagedInstallKeyDisposition": "src/kernel/unmanaged-install.ts#UnmanagedInstallKeyDisposition (type)", "UpgradeImpactLevel": "src/kernel/package-upgrade.zod.ts#UpgradeImpactLevel (type)", "UpgradeImpactLevelSchema": "src/kernel/package-upgrade.zod.ts#UpgradeImpactLevelSchema (const)", "UpgradePackageRequest": "src/kernel/package-upgrade.zod.ts#UpgradePackageRequest (type)", @@ -480,8 +487,10 @@ "registerMetadataTypeActions": "src/kernel/metadata-type-schemas.ts#registerMetadataTypeActions (function)", "registerMetadataTypeRedactor": "src/kernel/metadata-type-redaction.ts#registerMetadataTypeRedactor (function)", "registerMetadataTypeSchema": "src/kernel/metadata-type-schemas.ts#registerMetadataTypeSchema (function)", + "resolveInstallModes": "src/kernel/manifest.zod.ts#resolveInstallModes (function)", "resolveLockState": "src/kernel/metadata-protection.zod.ts#resolveLockState (function)", "stripReadDecorations": "src/kernel/metadata-read-decorations.ts#stripReadDecorations (function)", + "unmanagedInstallValueCarries": "src/kernel/unmanaged-install.ts#unmanagedInstallValueCarries (function)", "validateObjectNamespacePrefix": "src/kernel/namespace-prefix.ts#validateObjectNamespacePrefix (function)" } } diff --git a/packages/spec/json-schema.manifest/kernel.json b/packages/spec/json-schema.manifest/kernel.json index 93d80ccb801..2429ab52b4c 100644 --- a/packages/spec/json-schema.manifest/kernel.json +++ b/packages/spec/json-schema.manifest/kernel.json @@ -45,6 +45,7 @@ "kernel/GetPackageRequest", "kernel/GetPackageResponse", "kernel/HotReloadConfig", + "kernel/InstallMode", "kernel/InstallPackageRequest", "kernel/InstallPackageResponse", "kernel/InstalledPackage", diff --git a/packages/spec/liveness/manifest.json b/packages/spec/liveness/manifest.json index 4910c774328..e0cadf3d8d8 100644 --- a/packages/spec/liveness/manifest.json +++ b/packages/spec/liveness/manifest.json @@ -44,6 +44,13 @@ "evidenceScope": "in-repo", "note": "Three consumers of three different kinds — a default, a writability gate and a UI filter — so the deployment scope is read on both the runtime and the admin path. 2026-08-28: RE-ANCHORED (commit 9ee2dcfbd) — all three citations were still ACCURATE, so this is a grammar migration. The anchor is what the entry gains: `settings-service.ts` is 2586 lines and its read sits ~800 lines in, which is exactly the shape that rots next." }, + "installModes": { + "status": "live", + "evidence": "packages/cloud-connection/src/marketplace-install-local-plugin.ts#judgeInstallMode (the install door, `POST /api/v1/marketplace/install-local`: parses the package's `installModes` through `ManifestSchema.shape.installModes`, resolves it with `resolveInstallModes` — absent meaning ['managed'] — and refuses a requested `installMode` it does not list with 422 INSTALL_MODE_NOT_PERMITTED before any write); packages/spec/src/stack.zod.ts#validateUnmanagedInstallMode (`defineStack`, so `os validate` / `os build`: a package whose `installModes` permits 'unmanaged' while it carries a runtime-uncreatable collection, code or another package's extension is refused STACK_UNMANAGED_INSTALL_UNCOPYABLE)", + "verifiedAt": "2026-10-11", + "evidenceScope": "in-repo", + "note": "ADR-0131 D6, landed with its reader so there was never a declared-but-unenforced window (the stage-0 ACCEPT's Q8 on the D6 execution card): the key and the install door's mode arrived in one change, after the copy-once import verb (`importUnmanagedPackage`) it calls. Spelled `managed` / `unmanaged` by the maintainer's ruling B on that card. Optional with NO parse-time default (Q9): absence survives a parse and `resolveInstallModes` (kernel/manifest.zod.ts) is the one reader of what it means. Enforced at both ends: at authoring by `defineStack`, at install by the door — and the door's `unmanaged` branch refuses a deployment whose REQUESTED tenancy posture walls organizations (Q2) through the import verb's own refusal. The key-classification table both refusals read is `UNMANAGED_INSTALL_KEY_DISPOSITIONS` (kernel/unmanaged-install.ts), held total against this schema by its test." + }, "name": { "status": "live", "evidence": "packages/objectql/src/engine.ts#registerApp (`const id = manifest.id || manifest.name` — the package-id fallback when `id` is absent; the same function then registers manifest-as-app under it: `if (manifest.name && manifest.navigation && !manifest.apps?.length)`); packages/cli/src/commands/plugin/publish.ts#PluginPublish (`flags['display-name'] ?? manifest.name ?? id` — the marketplace display name)", diff --git a/packages/spec/liveness/state-counts/manifest.md b/packages/spec/liveness/state-counts/manifest.md index 9be886cb044..d14767a0008 100644 --- a/packages/spec/liveness/state-counts/manifest.md +++ b/packages/spec/liveness/state-counts/manifest.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `manifest` | 26 | 0 | 1 | 15 | 0 | 42 | +| `manifest` | 27 | 0 | 1 | 15 | 0 | 43 | diff --git a/packages/spec/src/api/error-code-ledger.zod.ts b/packages/spec/src/api/error-code-ledger.zod.ts index 8d036a26638..0c7fc62dac8 100644 --- a/packages/spec/src/api/error-code-ledger.zod.ts +++ b/packages/spec/src/api/error-code-ledger.zod.ts @@ -650,7 +650,11 @@ export const ERROR_CODE_LEDGER = { // so nothing is written. Each gives a consumer a branch no existing code // gives: the package installs MANAGED instead, and neither the // `OS_METADATA_WRITABLE` hatch (`NOT_CREATABLE`'s remedy) nor a retry - // helps. No HTTP door on this tree yet; the thrown value is the boundary. + // helps. Doored: `POST /api/v1/marketplace/install-local` with + // `installMode: 'unmanaged'` (`@objectstack/cloud-connection`) answers + // both with their own code and status; the install door also raises the + // WALLED_POSTURE refusal itself, through the same function, before it + // judges anything else about the package. // - UNCOPYABLE, 422: the body carries what the copy cannot write as // environment rows — a runtime-uncreatable type, code, an extension of // another package's items, or an artifact envelope. @@ -1076,6 +1080,12 @@ export const ERROR_CODE_LEDGER = { // admits the (code, shadows) pair, and this row extends its emitter list, // never endorses the spelling for new code. 'FORBIDDEN', + // [ADR-0131 D6] install-local refused a requested `installMode` the + // package's `installModes` does not permit (absent meaning `['managed']`), + // 422, before any write; `details.permitted` names the modes it does. + // Its own code because the consumer's branch is its own: retrying with a + // permitted mode helps, and no edit of the package is needed. + 'INSTALL_MODE_NOT_PERMITTED', 'INVALID_REQUEST', 'MANIFEST_CONFLICT', // manifest_id already defined by local code 'MARKETPLACE_PROXY_FAILED', @@ -1474,6 +1484,13 @@ export const ERROR_CODE_LEDGER = { 'STACK_SCHEMA_INVALID', // `ObjectStackDefinitionSchema.safeParse` failed; `issues` carries the zod issues structurally 'STACK_SINGLE_APP_VIOLATION', // an `app` package declares more than one app (ADR-0019 D3) 'STACK_TRIGGER_CAPABILITY_REQUIRED', // an auto-launched flow while `requires` omits `triggers` or `automation` (the pair installs its trigger) + // [ADR-0131 D6] The eighth `defineStack` refusal, same family and same + // reading (`door: 'none'`, `status: 422`): the package permits the + // `unmanaged` install mode while it carries what an unmanaged install + // cannot copy — an item of a runtime-uncreatable type, code, an extension + // of another package's items. Its install-time counterpart is + // `UNMANAGED_INSTALL_UNCOPYABLE` under `@objectstack/metadata-protocol`. + 'STACK_UNMANAGED_INSTALL_UNCOPYABLE', // [#16348] The COMPOSITION half of the same family, and `door: 'none'` on // the same reading: the six `composeStacks` refusals, one code per raise // site, every one `status: 422` (`StackRefusalError`, `stack.zod.ts`), the diff --git a/packages/spec/src/kernel/index.ts b/packages/spec/src/kernel/index.ts index 919d7090a10..d8f85d5aff7 100644 --- a/packages/spec/src/kernel/index.ts +++ b/packages/spec/src/kernel/index.ts @@ -57,6 +57,9 @@ export type { UnknownAuthoringKeyFinding } from '../data/authoring-key-lint'; export * from './package-artifact.zod'; export * from './package-registry.zod'; export * from './package-upgrade.zod'; +// [ADR-0131 D6] What an `unmanaged` install can copy: the one key +// classification `defineStack` and the install door's import planner read. +export * from './unmanaged-install'; export * from './plugin-capability.zod'; export * from './plugin-lifecycle-advanced.zod'; export * from './plugin-loading.zod'; diff --git a/packages/spec/src/kernel/manifest-unknown-keys.test.ts b/packages/spec/src/kernel/manifest-unknown-keys.test.ts index fba52f44303..f9b9020cc71 100644 --- a/packages/spec/src/kernel/manifest-unknown-keys.test.ts +++ b/packages/spec/src/kernel/manifest-unknown-keys.test.ts @@ -38,6 +38,8 @@ import { describe, it, expect } from 'vitest'; import { ManifestSchema, PluginEnginesSchema } from './manifest.zod'; +import { existsSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; import { PROTOCOL_MAJOR } from './protocol-version'; import { ArtifactPackageSchema, ObjectStackDefinitionSchema } from '../stack.zod'; import { formatZodError } from '../shared/error-map.zod'; @@ -110,6 +112,11 @@ describe("unknown keys inside `manifest:` are refused at parse (the silent-drop expect(issue.message).toContain(`protocol: '^${PROTOCOL_MAJOR}'`); expect(issue.message).not.toContain(`protocol: '^${PROTOCOL_MAJOR - 1}'`); expect(issue.message).not.toContain('Did you mean'); + // The surface it points at for the OTHER meaning is a file that exists: + // the marketplace template manifest, since the cloud subpath retired. + expect(issue.message).toContain('`marketplace/template-manifest.zod.ts`'); + expect(issue.message).not.toContain('cloud/template-manifest'); + expect(existsSync(fileURLToPath(new URL('../marketplace/template-manifest.zod.ts', import.meta.url)))).toBe(true); }); }); diff --git a/packages/spec/src/kernel/manifest.test.ts b/packages/spec/src/kernel/manifest.test.ts index f0a9eef2f12..b69d8ac3f06 100644 --- a/packages/spec/src/kernel/manifest.test.ts +++ b/packages/spec/src/kernel/manifest.test.ts @@ -4,6 +4,10 @@ import { MANIFEST_ID_PATTERN, MANIFEST_ID_EXAMPLES, manifestIdRefusal, + INSTALL_MODES, + DEFAULT_INSTALL_MODES, + InstallModeSchema, + resolveInstallModes, type ObjectStackManifest, } from './manifest.zod'; import { PackageSchema } from '../marketplace/package.zod'; @@ -795,3 +799,56 @@ describe('manifest.namespace — the refusal states the rule the regex enforces' }, ); }); + +/** + * [ADR-0131 D6] `installModes` — the install modes a package permits, spelled + * per the maintainer's ruling B on the D6 execution card (`unmanaged`). The + * key is optional with NO parse-time default (the stage-0 ACCEPT's Q9): absence + * survives a parse, and `resolveInstallModes` is the one reader of what it + * means. + */ +describe('ManifestSchema.installModes (ADR-0131 D6)', () => { + const legal = { id: 'com.example.modes', version: '1.0.0', type: 'app' as const, name: 'Modes' }; + + it('spells the two modes `managed` and `unmanaged`, and nothing else', () => { + expect([...INSTALL_MODES]).toEqual(['managed', 'unmanaged']); + expect(InstallModeSchema.options).toEqual(['managed', 'unmanaged']); + for (const retired of ['template', 'copy']) { + expect(InstallModeSchema.safeParse(retired).success, retired).toBe(false); + } + }); + + it('accepts each mode alone and both together — a copy-only package is legal', () => { + for (const installModes of [['managed'], ['unmanaged'], ['managed', 'unmanaged'], ['unmanaged', 'managed']]) { + const parsed = ManifestSchema.safeParse({ ...legal, installModes }); + expect(parsed.success, JSON.stringify(installModes)).toBe(true); + expect(parsed.data?.installModes).toEqual(installModes); + } + }); + + it('has no parse-time default — an absent key stays absent, so no stored manifest changes by a byte', () => { + const parsed = ManifestSchema.parse(legal); + expect(parsed).not.toHaveProperty('installModes'); + }); + + it('refuses an empty list and an unknown mode', () => { + const empty = ManifestSchema.safeParse({ ...legal, installModes: [] }); + expect(empty.success).toBe(false); + expect(empty.error?.issues[0]?.message).toContain("omit the key for the default, ['managed']"); + + const unknown = ManifestSchema.safeParse({ ...legal, installModes: ['template'] }); + expect(unknown.success).toBe(false); + expect(unknown.error?.issues[0]?.path).toEqual(['installModes', 0]); + }); + + it('resolveInstallModes: absent means managed only; a declared list is read as declared', () => { + expect(resolveInstallModes(undefined)).toEqual(['managed']); + expect(resolveInstallModes(null)).toEqual(['managed']); + expect(resolveInstallModes({})).toEqual(['managed']); + expect(resolveInstallModes(ManifestSchema.parse(legal))).toEqual(['managed']); + expect(resolveInstallModes({ installModes: ['unmanaged'] })).toEqual(['unmanaged']); + expect(resolveInstallModes({ installModes: ['managed', 'unmanaged'] })).toEqual(['managed', 'unmanaged']); + expect(resolveInstallModes(undefined)).toBe(DEFAULT_INSTALL_MODES); + expect(Object.isFrozen(DEFAULT_INSTALL_MODES)).toBe(true); + }); +}); diff --git a/packages/spec/src/kernel/manifest.zod.ts b/packages/spec/src/kernel/manifest.zod.ts index ca3e57ad59a..1a235a9f71a 100644 --- a/packages/spec/src/kernel/manifest.zod.ts +++ b/packages/spec/src/kernel/manifest.zod.ts @@ -398,6 +398,61 @@ export function manifestIdRefusal(key: string, input: unknown): string { return base; } +/** + * [ADR-0131 D6] The two ways a package reaches a deployment — its INSTALL MODES. + * + * - `managed`: the package is registered as code and stays the authority. It + * upgrades, and no door edits what it ships; the customer customises BESIDE + * it (an environment overlay, disable + clone, extend). + * - `unmanaged`: every metadata item is copied ONCE into the environment + * ledger, under a new writable base whose id differs from the package's. + * From then on it is the environment's own metadata and the next save + * edits it, except that an item shipped with an authored protection lock + * (`_lock`) keeps that lock on the copy as written today, and a follow-up + * on #15213 strips it per #22971's ruling A; it follows no upgrade. A + * deployment whose tenancy posture walls organizations (`group`, + * `isolated`) refuses it, whatever the package permits. + * + * The spelling is the maintainer's ruling B on the D6 execution card + * (2026-10-11): `managed` and `unmanaged` are the two states of one property, + * whether the package stays the authority — the names Salesforce and Power + * Platform give the same two states. + */ +export const INSTALL_MODES = ['managed', 'unmanaged'] as const; + +/** One install mode — see {@link INSTALL_MODES}. */ +export const InstallModeSchema = z.enum(INSTALL_MODES) + .describe('Install mode: managed (registered as code, upgradeable, sealed) | unmanaged (copied once into the environment as its own metadata, no upgrade)'); + +export type InstallMode = z.input; + +/** + * The install modes a package permits when its manifest declares none. + * Read through {@link resolveInstallModes} — ⛔ never restated at a reader. + */ +export const DEFAULT_INSTALL_MODES: readonly InstallMode[] = Object.freeze(['managed'] as const); + +/** + * [ADR-0131 D6] The install modes a package permits — THE one resolver of the + * manifest's `installModes` key. + * + * The key is optional and carries no parse-time default, so absence survives + * a parse as absence: a `.default()` would stamp the key into every parsed + * manifest and every stored package record, byte-changing every existing + * artifact for no change of meaning. Absence means {@link DEFAULT_INSTALL_MODES} + * — `['managed']` — and every reader asks this function for that answer, so + * the default has one spelling. The authoring refusal (`defineStack`) and the + * install door both read it. + * + * Takes a PARSED value: a door holding an untrusted manifest parses the key + * first (`ManifestSchema.shape.installModes`), then resolves. + */ +export function resolveInstallModes( + manifest: { installModes?: readonly InstallMode[] | undefined } | null | undefined, +): readonly InstallMode[] { + return manifest?.installModes ?? DEFAULT_INSTALL_MODES; +} + export const ManifestSchema = strictObject({ surface: 'this package manifest', history: @@ -415,7 +470,7 @@ export const ManifestSchema = strictObject({ '`specVersion` is not a package-manifest key. The protocol axis the runtime checks at ' + `load is \`engines.protocol\` — declare \`engines: { protocol: '^${PROTOCOL_MAJOR}' }\` (the range the ` + 'scaffold stamps); `specVersion` keeps its meaning only on the marketplace TEMPLATE ' - + 'manifest (`cloud/template-manifest.zod.ts`), which is a different surface.', + + 'manifest (`marketplace/template-manifest.zod.ts`), which is a different surface.', }, }, { /** @@ -552,6 +607,35 @@ export const ManifestSchema = strictObject({ scope: z.enum(['cloud', 'system', 'project']).default('project') .describe('Deployment scope: cloud | system | project'), + /** + * [ADR-0131 D6] The install modes this package permits — see + * {@link INSTALL_MODES} for what each one does. + * + * Optional, with NO parse-time default: absent means `['managed']` only, the + * answer {@link resolveInstallModes} gives — the one reader of it. Non-empty + * (omit the key instead of writing an empty list); naming a mode twice changes + * nothing, since a reader asks only whether a mode is listed. A package may permit + * `unmanaged` alone (a starter app a customer takes over wholesale); a + * deployment that walls organizations then refuses it outright. + * + * ⭐ Declared is enforced, at both ends: + * - at AUTHORING: `defineStack` (so `os validate` and `os build`) refuses a + * package that permits `unmanaged` while it carries what the copy cannot + * write as environment metadata — an item of a type the metadata-type + * registry declares runtime-uncreatable (`allowRuntimeCreate: false`), + * code, an extension of another package's items — with + * `UNMANAGED_INSTALL_UNCOPYABLE`; + * - at INSTALL: `POST /api/v1/marketplace/install-local` (and so + * `os package install --install-mode`) refuses a mode the package does not + * permit, before anything is written. + * + * @example ['managed', 'unmanaged'] + */ + installModes: z.array(InstallModeSchema) + .min(1, '`installModes` lists at least one install mode — omit the key for the default, [\'managed\'].') + .optional() + .describe('Install modes this package permits (ADR-0131 D6): managed | unmanaged. Absent means [\'managed\'].'), + /** * Human-readable name of the package. * Displayed in the UI for users. diff --git a/packages/spec/src/kernel/unmanaged-install.test.ts b/packages/spec/src/kernel/unmanaged-install.test.ts new file mode 100644 index 00000000000..7afe4b6da74 --- /dev/null +++ b/packages/spec/src/kernel/unmanaged-install.test.ts @@ -0,0 +1,75 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D6] The one classification of a package body's keys for an + * `unmanaged` install (`unmanaged-install.ts`) — read by `defineStack`'s + * authoring refusal and by the install door's import planner + * (`@objectstack/metadata-protocol`). + * + * Held TOTAL here, beside both schemas a package body is read with, so the PR + * that adds a key to `ManifestSchema` or to the stack definition is the PR that + * reddens — not a later install that refuses the key with no reason on record. + */ +import { describe, expect, it } from 'vitest'; +import { ManifestSchema } from './manifest.zod'; +import { ObjectStackDefinitionSchema } from '../stack.zod'; +import { PLURAL_TO_SINGULAR } from '../meta-spelling/manifest-collection-spelling'; +import { ASSEMBLED_VIEW_ITEMS_KEY } from '../ui/assembled-views.zod'; +import { + UNMANAGED_INSTALL_KEY_DISPOSITIONS, + UNMANAGED_INSTALL_REFUSING_DISPOSITIONS, + unmanagedInstallValueCarries, +} from './unmanaged-install'; + +/** The collections — judged by their type's registry flag, never by this table. */ +const collections = new Set([...Object.keys(PLURAL_TO_SINGULAR), ASSEMBLED_VIEW_ITEMS_KEY]); +/** A key retired with a tombstone (ADR-0049) is refused as unrecognised, never classified. */ +const tombstoned = (schema: unknown) => + /^\[REMOVED\]/.test(String((schema as { description?: unknown } | undefined)?.description ?? '')); +const stackShape = (ObjectStackDefinitionSchema as unknown as { shape: Record }).shape; + +describe('the unmanaged-install key classification is total against both schemas a package body is read with', () => { + it('classifies every live ManifestSchema key', () => { + const missing = Object.entries(ManifestSchema.shape) + .filter(([key, schema]) => !collections.has(key) && !tombstoned(schema) && !(key in UNMANAGED_INSTALL_KEY_DISPOSITIONS)) + .map(([key]) => key); + expect(missing).toEqual([]); + }); + + it('classifies every stack-definition key', () => { + const missing = Object.keys(stackShape).filter((key) => !collections.has(key) && !(key in UNMANAGED_INSTALL_KEY_DISPOSITIONS)); + expect(missing).toEqual([]); + }); + + it('classifies nothing neither schema declares — a stale row is a lie about a key', () => { + const declared = new Set([...Object.keys(ManifestSchema.shape), ...Object.keys(stackShape)]); + expect(Object.keys(UNMANAGED_INSTALL_KEY_DISPOSITIONS).filter((key) => !declared.has(key))).toEqual([]); + }); + + it('classifies `installModes` as the door\'s — read to judge the requested mode, never written onto the base', () => { + expect(UNMANAGED_INSTALL_KEY_DISPOSITIONS.installModes).toBe('door'); + }); + + it('copies `translations` (converted), and leaves `data` to the door', () => { + expect(UNMANAGED_INSTALL_KEY_DISPOSITIONS.translations).toBe('converted'); + expect(UNMANAGED_INSTALL_KEY_DISPOSITIONS.data).toBe('door'); + }); + + it('refuses exactly code, uncopyable content and an artifact envelope', () => { + expect([...UNMANAGED_INSTALL_REFUSING_DISPOSITIONS].sort()).toEqual(['code', 'envelope', 'uncopyable']); + }); +}); + +describe('unmanagedInstallValueCarries — a refusal names content, never the spelling of a key', () => { + it('reads an absent, null or empty value as carrying nothing', () => { + for (const value of [undefined, null, [], {}, '']) { + expect(unmanagedInstallValueCarries(value), JSON.stringify(value)).toBe(false); + } + }); + + it('reads a non-empty list, map or string, a function and a scalar as carrying something', () => { + for (const value of [[{}], { fn: 'ref' }, 'hook', () => undefined, true, 0]) { + expect(unmanagedInstallValueCarries(value), String(value)).toBe(true); + } + }); +}); diff --git a/packages/spec/src/kernel/unmanaged-install.ts b/packages/spec/src/kernel/unmanaged-install.ts new file mode 100644 index 00000000000..d294af7165a --- /dev/null +++ b/packages/spec/src/kernel/unmanaged-install.ts @@ -0,0 +1,141 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D6] What an `unmanaged` install can copy — the ONE classification + * of a package body's non-collection keys, read at both ends of the mode: + * + * - at AUTHORING, by `defineStack` (`stack.zod.ts`), which refuses a package + * that permits `unmanaged` while it carries what the copy cannot write; + * - at INSTALL, by `importUnmanagedPackage`'s planner + * (`@objectstack/metadata-protocol`, `package-unmanaged-install.ts`), which + * refuses the same body before the new base exists. + * + * One table, so the two refusals cannot disagree about a key: a package that + * passes `os validate` with `installModes: ['managed', 'unmanaged']` is not + * refused at the install door for a key the author was never told about. + * Collections (`PLURAL_TO_SINGULAR`) are not in it — each one is judged by its + * metadata type's own `allowRuntimeCreate` flag in + * `DEFAULT_METADATA_TYPE_REGISTRY`, the flag the save door reads. + * + * `unmanaged-install.test.ts` holds the table TOTAL against both schemas a + * package body is read with — `ManifestSchema` and `ObjectStackDefinitionSchema` + * — so a key either schema gains reddens a spec test on the PR that adds it, + * instead of being refused at an install door with no reason on record. + */ + +/** + * How an unmanaged install treats one top-level key of a package body that is + * NOT a metadata collection. + * + * - `identity` — describes the package; carried onto the new base's manifest. + * - `base` — read to mint the base and never carried as written: `id` is + * replaced by the target id, `namespace` is kept, `scope` is + * stripped because the base is a WRITABLE package. + * - `converted` — copied as items of a metadata type after a mechanical + * conversion: each locale of a `translations` bundle becomes + * one `translation` item, so the bundles live in the + * environment ledger beside the rest of the copy, and reload + * at every boot the way environment translations do. + * - `code` — refused: code, or a descriptor of a code-bearing artifact. + * - `uncopyable` — refused: package content the environment ledger has no row for. + * - `envelope` — refused: it describes an artifact, not one package. + * - `door` — not the copy's: read or applied by the install door, and + * never written onto the base. + * - `deployment` — not the copy's: deployment configuration no install applies. + */ +export type UnmanagedInstallKeyDisposition = + | 'identity' + | 'base' + | 'converted' + | 'code' + | 'uncopyable' + | 'envelope' + | 'door' + | 'deployment'; + +/** + * The dispositions that REFUSE an unmanaged install — the copy cannot reproduce + * the key as environment rows, so the package is refused whole (no partial copy). + */ +export const UNMANAGED_INSTALL_REFUSING_DISPOSITIONS: ReadonlySet = + new Set(['code', 'uncopyable', 'envelope']); + +/** + * Every non-collection key a package body may carry — the union of + * `ManifestSchema`'s live keys and the stack definition's non-collection keys — + * classified for an unmanaged install. + */ +export const UNMANAGED_INSTALL_KEY_DISPOSITIONS: Readonly> = { + // ── The package's identity ── + id: 'base', + namespace: 'base', + scope: 'base', + name: 'identity', + version: 'identity', + type: 'identity', + description: 'identity', + // An object with no `datasource` routes to its package's default, so a + // base that dropped this would move every such object to `default`. + defaultDatasource: 'identity', + dependencies: 'identity', + engines: 'identity', + engine: 'identity', + + // ── Code, and the descriptors of a code-bearing plugin artifact ── + functions: 'code', + onEnable: 'code', + plugins: 'code', + devPlugins: 'code', + runtimeModule: 'code', + main: 'code', + runtime: 'code', + packaging: 'code', + integrity: 'code', + + // ── Content the environment ledger has no row for ── + objectExtensions: 'uncopyable', + picklistExtensions: 'uncopyable', + navigationContributions: 'uncopyable', + contributes: 'uncopyable', + + // ── The artifact, not one package ── + packages: 'envelope', + manifest: 'envelope', + + // ── Copied after conversion ── + translations: 'converted', + + // ── The install door's ── + // Seed datasets are data, never metadata items, so a copy of the metadata + // cannot carry them; the door states what it did with them. + data: 'door', + // Read by the door to judge the requested mode; the base is the + // environment's own package and makes no promise about how it installs. + installModes: 'door', + + // ── Deployment configuration: read by a stack's boot, by no install ── + i18n: 'deployment', + datasourceMapping: 'deployment', + api: 'deployment', + server: 'deployment', + appName: 'deployment', + email: 'deployment', + sms: 'deployment', + requires: 'deployment', + tiers: 'deployment', + devHint: 'deployment', + devLogins: 'deployment', +}; + +/** + * Whether a key's value carries anything for an unmanaged install to judge. + * An empty container — `functions: {}`, `plugins: []` — carries no code, so it + * refuses nothing; a refusal names content, never the mere spelling of a key. + */ +export function unmanagedInstallValueCarries(value: unknown): boolean { + if (value === undefined || value === null) return false; + if (Array.isArray(value)) return value.length > 0; + if (typeof value === 'string') return value.length > 0; + if (typeof value === 'object') return Object.keys(value as object).length > 0; + return true; +} diff --git a/packages/spec/src/migrations/entries/semantic/18.meta-doors-organization-scope-retired.ts b/packages/spec/src/migrations/entries/semantic/18.meta-doors-organization-scope-retired.ts index 5fbef6f589a..cf265a6a475 100644 --- a/packages/spec/src/migrations/entries/semantic/18.meta-doors-organization-scope-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.meta-doors-organization-scope-retired.ts @@ -27,7 +27,7 @@ export const entry: SemanticMigration = { + '`metaCallerOrganizationId`', reason: 'ADR-0131 D6 retires the per-organization overlay axis: environment metadata written by ' - + 'Studio, by the cloud build agent or by a template install belongs to the whole deployment. ' + + 'Studio, by the cloud build agent or by an unmanaged install belongs to the whole deployment. ' + 'The doors threaded the active organization for view, dashboard, report, translation and ' + 'email_template, so under the single posture, where the Default Organization is active, ' + 'every Studio save of those types was stored under that organization. The read and the ' diff --git a/packages/spec/src/stack-conversions-record.test.ts b/packages/spec/src/stack-conversions-record.test.ts index 3bd2e633ba3..a5af5107859 100644 --- a/packages/spec/src/stack-conversions-record.test.ts +++ b/packages/spec/src/stack-conversions-record.test.ts @@ -469,6 +469,11 @@ describe('every refusal site in defineStack carries the record — census, both code: 'STACK_TRIGGER_CAPABILITY_REQUIRED', config: { manifest, objects: [task], pages, requires: ['automation'], flows: [recordFlow] }, }, + { + site: 'unmanaged install mode', + code: 'STACK_UNMANAGED_INSTALL_UNCOPYABLE', + config: { manifest: { ...manifest, installModes: ['unmanaged'] }, objects: [task], pages, functions: { probe_noop: () => undefined } }, + }, { site: 'bound-action merge, strict: false — objects not an array', code: 'STACK_SCHEMA_INVALID', config: { manifest, objects: 'nope', pages }, strict: false }, { site: 'bound-action merge, strict: false — an objects entry not an object', code: 'STACK_SCHEMA_INVALID', config: { manifest, objects: [null], pages }, strict: false }, { site: 'bound-action merge, strict: false — actions not an array', code: 'STACK_SCHEMA_INVALID', config: { manifest, objects: [task], actions: 7, pages }, strict: false }, @@ -484,8 +489,8 @@ describe('every refusal site in defineStack carries the record — census, both }); } - it('the census reaches every code defineStack refuses with: seven distinct codes', () => { - expect(new Set(rows.map((r) => r.code)).size).toBe(7); + it('the census reaches every code defineStack refuses with: eight distinct codes', () => { + expect(new Set(rows.map((r) => r.code)).size).toBe(8); }); }); diff --git a/packages/spec/src/stack-refusal-envelopes.test.ts b/packages/spec/src/stack-refusal-envelopes.test.ts index 83ac6aaa87d..b839974ce81 100644 --- a/packages/spec/src/stack-refusal-envelopes.test.ts +++ b/packages/spec/src/stack-refusal-envelopes.test.ts @@ -5,7 +5,8 @@ * * ## What was wrong * - * `defineStack` has seven refusal sites. After #14552 one of them — the + * `defineStack` had seven refusal sites (ADR-0131 D6 adds the eighth, the + * `unmanaged` install mode's). After #14552 one of them — the * cross-reference refusal, pinned in `stack-cross-reference-envelope.test.ts` * — carried `code` / `status`; the other six threw `new Error(message)` with * both `undefined`. A consumer that had learned to branch on `error.code` @@ -151,6 +152,22 @@ const rows: Array<{ accepted: { manifest, objects: [task], requires: ['automation', 'triggers'], flows: [recordFlow] }, finding: "flow 'task_fanout' declares a 'record_change' trigger", }, + { + site: 'unmanaged install mode — `unmanaged` permitted on a package the copy cannot write (ADR-0131 D6)', + code: 'STACK_UNMANAGED_INSTALL_UNCOPYABLE', + header: 'defineStack unmanaged install-mode validation failed', + refused: { + manifest: { ...manifest, installModes: ['managed', 'unmanaged'] }, + objects: [task], + functions: { probe_noop: () => undefined }, + }, + accepted: { + manifest: { ...manifest, installModes: ['managed'] }, + objects: [task], + functions: { probe_noop: () => undefined }, + }, + finding: '`functions` is code', + }, ]; describe('every defineStack refusal carries an ADR-0112 envelope', () => { @@ -220,18 +237,18 @@ describe('every defineStack refusal carries an ADR-0112 envelope', () => { }); }); - describe('census over all seven refusal sites', () => { + describe('census over all eight refusal sites', () => { const crossReference = { manifest, objects: [task], data: [{ object: 'missing_object', records: [] }] }; const everySite = [...rows.map((r) => r.refused), { manifest: {} }, crossReference]; - it('seven sites, seven distinct STACK_* codes, every status 422', () => { + it('eight sites, eight distinct STACK_* codes, every status 422', () => { const envelopes = everySite.map((config) => refusal(config)); for (const envelope of envelopes) { expect(envelope).toBeInstanceOf(Error); expect(envelope?.code).toMatch(/^STACK_[A-Z_]+$/); expect(envelope?.status).toBe(422); } - expect(new Set(envelopes.map((e) => e?.code)).size).toBe(7); + expect(new Set(envelopes.map((e) => e?.code)).size).toBe(8); expect(envelopes.map((e) => e?.code)).toContain('STACK_CROSS_REFERENCE_INVALID'); }); diff --git a/packages/spec/src/stack-unmanaged-install-mode.test.ts b/packages/spec/src/stack-unmanaged-install-mode.test.ts new file mode 100644 index 00000000000..3b22b23ea85 --- /dev/null +++ b/packages/spec/src/stack-unmanaged-install-mode.test.ts @@ -0,0 +1,131 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D6] `defineStack` refuses a package that PERMITS the `unmanaged` + * install mode while it carries what an unmanaged install cannot copy — the + * authoring half of the stage-0 ACCEPT's Q5 ("a declared mode is enforced", + * refused at authoring and again at the install door before any write). + * + * What is pinned: + * - the refusal: `STACK_UNMANAGED_INSTALL_UNCOPYABLE` / 422, one `issues` + * entry per key, each naming the key — for a runtime-uncreatable collection + * (read off the registry's `allowRuntimeCreate`, never a list kept here), code, + * an extension of another package's items, and a manifest-level key; + * - the CONTROL on every row: the same stack permitting only `managed` — or + * declaring no `installModes` at all — is accepted, so the refusal is about + * the declared mode and nothing else; + * - a copyable package permits `unmanaged` cleanly, alone or beside `managed`, + * translation bundles and seed data included; an empty code container is no + * code. + */ +import { describe, expect, it } from 'vitest'; +import { defineStack } from './stack.zod'; +import { DEFAULT_METADATA_TYPE_REGISTRY } from './kernel/metadata-plugin.zod'; +import { ERROR_CODE_LEDGER, ErrorCode } from './api/error-code-ledger.zod'; + +type Envelope = Error & { code?: string; status?: number; issues?: readonly string[] }; + +function refusal(config: unknown): Envelope | null { + try { + defineStack(config as never); + return null; + } catch (e) { + return e as Envelope; + } +} + +const manifest = (installModes?: string[]) => ({ + id: 'com.example.unmanagedprobe', + name: 'unmanaged-probe', + version: '1.0.0', + type: 'app' as const, + namespace: 'probe', + ...(installModes ? { installModes } : {}), +}); + +const task = { name: 'probe_task', label: 'Task', fields: { title: { type: 'text', label: 'Title' } } }; +const region = { + name: 'probe_region', + label: 'Region', + options: [{ label: 'North', value: 'north' }, { label: 'South', value: 'south' }], +}; + +const BOTH = ['managed', 'unmanaged']; + +const rows: Array<{ site: string; extra: Record; manifestExtra?: Record; key: string }> = [ + { site: 'a runtime-uncreatable collection (`picklists`)', extra: { picklists: [region] }, key: '`picklists`' }, + { site: 'code (`functions`)', extra: { functions: { probe_noop: () => undefined } }, key: '`functions`' }, + { + site: 'an extension of another package\'s object (`objectExtensions`)', + extra: { objectExtensions: [{ extend: 'sys_user', fields: { probe_badge: { type: 'text', label: 'Badge' } } }] }, + key: '`objectExtensions`', + }, +]; + +describe('defineStack refuses `installModes` with `unmanaged` on a package the copy cannot write (ADR-0131 D6)', () => { + it('the premise: the registry declares runtime-uncreatable types, `picklist` among them', () => { + const uncreatable = DEFAULT_METADATA_TYPE_REGISTRY.filter((e) => !e.allowRuntimeCreate).map((e) => e.type); + expect(uncreatable).toEqual(expect.arrayContaining(['agent', 'api', 'capability', 'field', 'job', 'picklist'])); + }); + + for (const row of rows) { + describe(row.site, () => { + it('is refused with STACK_UNMANAGED_INSTALL_UNCOPYABLE / 422, the finding naming the key', () => { + const refused = refusal({ manifest: manifest(BOTH), objects: [task], ...row.extra }); + expect(refused, 'refused').toBeInstanceOf(Error); + expect(refused?.code).toBe('STACK_UNMANAGED_INSTALL_UNCOPYABLE'); + expect(refused?.status).toBe(422); + expect(refused?.message).toMatch(/^defineStack unmanaged install-mode validation failed \(1 issue\):/); + expect(refused?.issues).toHaveLength(1); + expect(refused?.issues?.[0]).toContain(row.key); + expect(refused?.message).toContain("Remove 'unmanaged' from `manifest.installModes`"); + }); + + it('CONTROL: the same stack permitting only `managed`, or declaring no `installModes`, is accepted', () => { + expect(refusal({ manifest: manifest(['managed']), objects: [task], ...row.extra })).toBeNull(); + expect(refusal({ manifest: manifest(), objects: [task], ...row.extra })).toBeNull(); + }); + }); + } + + it('judges the manifest\'s own keys too — `manifest.navigationContributions` targets another package\'s app', () => { + const navigationContributions = [{ app: 'setup', items: [{ id: 'nav_probe', type: 'url', label: 'Probe', url: '/probe' }] }]; + const refused = refusal({ manifest: { ...manifest(BOTH), navigationContributions }, objects: [task] }); + expect(refused?.code).toBe('STACK_UNMANAGED_INSTALL_UNCOPYABLE'); + expect(refused?.issues).toHaveLength(1); + expect(refused?.issues?.[0]).toContain('`manifest.navigationContributions`'); + // CONTROL: the same contribution on a managed-only package is accepted. + expect(refusal({ manifest: { ...manifest(['managed']), navigationContributions }, objects: [task] })).toBeNull(); + }); + + it('names every finding at once — one `issues` entry per key', () => { + const refused = refusal({ + manifest: manifest(['unmanaged']), + objects: [task], + picklists: [region], + functions: { probe_noop: () => undefined }, + }); + expect(refused?.code).toBe('STACK_UNMANAGED_INSTALL_UNCOPYABLE'); + expect(refused?.issues).toHaveLength(2); + expect(refused?.message).toMatch(/\(2 issues\):/); + }); + + it('a copyable package permits `unmanaged` — alone or beside `managed` — with translations and seed data', () => { + const copyable = { + objects: [task], + translations: [{ 'zh-CN': { objects: { probe_task: { label: '任务' } } } }], + data: [{ object: 'probe_task', records: [{ title: 'First' }] }], + }; + expect(refusal({ manifest: manifest(BOTH), ...copyable })).toBeNull(); + expect(refusal({ manifest: manifest(['unmanaged']), ...copyable })).toBeNull(); + }); + + it('an empty code container is no code — `functions: {}` permits `unmanaged`', () => { + expect(refusal({ manifest: manifest(BOTH), objects: [task], functions: {} })).toBeNull(); + }); + + it('the code is a member of the closed ErrorCode union, registered under @objectstack/spec', () => { + expect(ErrorCode.safeParse('STACK_UNMANAGED_INSTALL_UNCOPYABLE').success).toBe(true); + expect(ERROR_CODE_LEDGER['@objectstack/spec']).toContain('STACK_UNMANAGED_INSTALL_UNCOPYABLE'); + }); +}); diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index a3a6790a16c..47014c08c1d 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -2,7 +2,14 @@ import { z } from 'zod'; -import { ManifestSchema } from './kernel/manifest.zod'; +import { ManifestSchema, resolveInstallModes } from './kernel/manifest.zod'; +import { DEFAULT_METADATA_TYPE_REGISTRY } from './kernel/metadata-plugin.zod'; +import { + UNMANAGED_INSTALL_KEY_DISPOSITIONS, + UNMANAGED_INSTALL_REFUSING_DISPOSITIONS, + unmanagedInstallValueCarries, +} from './kernel/unmanaged-install'; +import { PLURAL_TO_SINGULAR } from './meta-spelling/manifest-collection-spelling'; import { validateObjectNamespacePrefix } from './kernel/namespace-prefix'; import { PLATFORM_CAPABILITY_TOKENS, RETIRED_PLATFORM_CAPABILITY_GUIDANCE } from './kernel/platform-capabilities'; import { DatasourceSchema } from './data/datasource.zod'; @@ -1937,6 +1944,79 @@ function validateSingleApp(config: ObjectStackDefinition): string[] { ]; } +/** + * [ADR-0131 D6] The types the metadata-type registry declares + * runtime-uncreatable (`allowRuntimeCreate: false`) — the flag the save door + * reads, so an unmanaged install's one write path cannot create an item of + * any of them. Derived, never listed. + */ +const RUNTIME_UNCREATABLE_TYPES: ReadonlySet = new Set( + DEFAULT_METADATA_TYPE_REGISTRY.filter((entry) => !entry.allowRuntimeCreate).map((entry) => entry.type), +); + +/** + * [ADR-0131 D6] A package that PERMITS the `unmanaged` install mode must be + * copyable — the authoring half of "a declared mode is enforced" (the stage-0 + * ACCEPT's Q5 on the D6 execution card). + * + * An unmanaged install copies every metadata item into the environment ledger + * through the one save door, or copies nothing. So a package that declares + * `installModes` with `'unmanaged'` while it carries what that door cannot + * write is refused HERE, naming each key, instead of passing `os validate` and + * being refused at the install door: + * + * - a non-empty collection whose metadata type the registry declares + * runtime-uncreatable ({@link RUNTIME_UNCREATABLE_TYPES}); + * - a key the shared classification refuses + * (`UNMANAGED_INSTALL_KEY_DISPOSITIONS`: code, an extension of another + * package's items, an artifact envelope), on the stack or on its manifest. + * + * The classification is the one the install door's import planner reads + * (`kernel/unmanaged-install.ts`), so the two refusals cannot disagree. ⛔ No + * partial copy and no `OS_METADATA_WRITABLE` hatch: the remedy is to stop + * permitting `unmanaged`, or to move what cannot be copied out of the package. + * + * Mirrors {@link validateSingleApp}: one finding per key; `defineStack` + * aggregates and throws. + */ +function validateUnmanagedInstallMode(config: ObjectStackDefinition): string[] { + if (!resolveInstallModes(config.manifest).includes('unmanaged')) return []; + const findings: string[] = []; + const judge = (value: unknown, key: string, where: string): void => { + if (!unmanagedInstallValueCarries(value)) return; + const type = PLURAL_TO_SINGULAR[key]; + if (type !== undefined) { + if (RUNTIME_UNCREATABLE_TYPES.has(type)) { + const count = Array.isArray(value) ? value.length : Object.keys(value as object).length; + findings.push( + `\`${where}\` carries ${count} '${type}' item(s), and the metadata-type registry declares '${type}' ` + + 'runtime-uncreatable, so an unmanaged install cannot copy them into the environment.', + ); + } + return; + } + const disposition = UNMANAGED_INSTALL_KEY_DISPOSITIONS[key]; + if (disposition === undefined || !UNMANAGED_INSTALL_REFUSING_DISPOSITIONS.has(disposition)) return; + findings.push( + disposition === 'code' + ? `\`${where}\` is code, which has no metadata form an unmanaged install can copy into the environment.` + : disposition === 'uncopyable' + ? `\`${where}\` extends items another package owns, and an unmanaged install has no environment row for that.` + : `\`${where}\` describes an artifact of several packages, and an unmanaged install copies one package.`, + ); + }; + for (const [key, value] of Object.entries(config)) { + // `manifest` is the package's own manifest here, judged key by key below — + // never the nested-manifest envelope the classification names for a body. + if (key === 'manifest') continue; + judge(value, key, key); + } + for (const [key, value] of Object.entries(config.manifest ?? {})) { + judge(value, key, `manifest.${key}`); + } + return findings; +} + /** * Platform-provided object names (`sys_` / `cloud_` / `ai_` prefixes — the * same classification the seed loader applies). These objects are contributed @@ -2531,6 +2611,23 @@ class StackTriggerCapabilityRequiredError extends StackRefusalError { } } +/** + * [ADR-0131 D6 · ADR-0112] A package permits the `unmanaged` install mode + * while it carries what an unmanaged install cannot copy — + * {@link validateUnmanagedInstallMode}. Spelled in this family's `STACK_*` + * vocabulary, one code per raise site; its install-time counterpart is + * `UNMANAGED_INSTALL_UNCOPYABLE` (`importUnmanagedPackage`, + * `@objectstack/metadata-protocol`), which judges the same body with the same + * key classification (`kernel/unmanaged-install.ts`). + */ +class StackUnmanagedInstallUncopyableError extends StackRefusalError { + readonly code = 'STACK_UNMANAGED_INSTALL_UNCOPYABLE'; + + constructor(message: string, issues: readonly string[]) { + super('StackUnmanagedInstallUncopyableError', message, issues); + } +} + /** * [ADR-0112 · #20367 ruling B] `composeStacks` refuses an input no stack * producer built — the composition half of the one-authoring-shape rule @@ -4152,6 +4249,16 @@ function buildDefinedStack( throw new StackTriggerCapabilityRequiredError(`${header}\n\n${lines.join('\n')}`, triggerErrors); } + const unmanagedErrors = validateUnmanagedInstallMode(data); + if (unmanagedErrors.length > 0) { + const header = `defineStack unmanaged install-mode validation failed (${unmanagedErrors.length} issue${unmanagedErrors.length === 1 ? '' : 's'}):`; + const lines = unmanagedErrors.map((e) => ` ✗ ${e}`); + const hint = '\n\n`manifest.installModes` permits \'unmanaged\', which copies every item of this package into the ' + + 'environment ledger or copies nothing (ADR-0131 D6). Remove \'unmanaged\' from `manifest.installModes`, or move ' + + 'what cannot be copied out of this package.'; + throw new StackUnmanagedInstallUncopyableError(`${header}\n\n${lines.join('\n')}${hint}`, unmanagedErrors); + } + // Post-parse and advisory only: this call has no effect on what is // returned. `locale` carries a default, so this has to run AFTER the parse // or a row that omits the key would read as a missing floor it actually diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index ba70d00d470..deeb1292251 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -274,7 +274,7 @@ import type * as M187 from './shared/duration.zod.js'; import type * as M188 from './ai/build-progress.zod.js'; // --------------------------------------------------------------------------- -// 775 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. +// 776 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. // // That number is machine-checked, not hand-kept. The runtime companion at the // bottom of this file recomputes the pin count from the source and asserts that @@ -963,6 +963,7 @@ export type Iso_kernel_events_core__EventPriority = Assert, z.infer< typeof M84.EventRouteSchema > >>; // kernel/manifest.zod.ts +export type Iso_kernel_manifest__InstallModeSchema = Assert, z.infer< typeof M85.InstallModeSchema > >>; export type Iso_kernel_manifest__ManifestPermissionsSchema = Assert, z.infer< typeof M85.ManifestPermissionsSchema > >>; export type Iso_kernel_manifest__PluginEnginesSchema = Assert, z.infer< typeof M85.PluginEnginesSchema > >>; export type Iso_kernel_manifest__PluginIntegritySchema = Assert, z.infer< typeof M85.PluginIntegritySchema > >>; @@ -1673,7 +1674,7 @@ describe('ADR-0122 type-alias convention', () => { // this title and the section header above the pin list — are now asserted // against the recomputed count below, so neither can go stale without a red // test naming it. - it('still declares all 775 isomorphic pins', () => { + it('still declares all 776 isomorphic pins', () => { // The truth of each pin is proved by tsc, not here — an `Assert>` // that stops holding is a compile error with the alias named. What tsc // cannot notice is a pin that was DELETED: removing the assertion removes @@ -2460,7 +2461,13 @@ describe('ADR-0122 type-alias convention', () => { // exactly as on `EngineQueryOptionsSchema`, so input !== infer as on the // aggregate verb above. Iso_data_dataEngine__EngineCountOptionsSchema // leaves and `EngineCountOptionsParsed` is declared. -1 removed. - expect(pins).toHaveLength(775); + // + // 775 -> 776 is ADR-0131 D6's install-mode stage: the new exported + // `InstallModeSchema` (the manifest's `installModes` element, `managed` / + // `unmanaged`) is a bare enum with no default and no transform, so it is + // pinned here (Iso_kernel_manifest__InstallModeSchema) rather than given an + // `InstallModeParsed` synonym. +1 added. + expect(pins).toHaveLength(776); // The count is stated in PROSE twice as well — this case's title and the // section header above the pin list — and until commit c6b05c76a nothing read either