Skip to content

Commit 240890c

Browse files
docs(deployment): the install modes page; fix(runtime): the read-only package refusals stop prescribing the retired org overlay (#23108)
Part of #15213 Clause-②: no Stage S3 of #15213: the authoring docs page for a package's install modes, and the `packages.ts` refusal text that still prescribed the retired "ADR-0005 org overlay". S4 (the objectui marketplace choice) remains on the card, so #15213 stays open. Clause-② is `no` because only text moves. No accept set, schema, published shape, refusal `code`, status, `details`, condition or order changes. ## What changes - **`content/docs/deployment/install-modes.mdx`** (new) and its nav entry in `content/docs/deployment/meta.json`, right after Publish, Versioning & Preview. Packaging and publishing pages live in that tree, and that page covers the install step this one extends. `scripts/docs-audit/handwritten-docs.json` was regenerated (`check-audit-scope.mjs --write`, +1 line). - **`packages/runtime/src/domains/packages.ts`**: the two read-only refusals name the customisation that works today. One shared clause, `CUSTOMISE_BESIDE_REMEDY`, serves both doors. The duplicate refusal also names the unmanaged install. The `:389` docblock and the duplicate docblock's two remedy sentences were reworded to match. - **`packages/runtime/src/domains/packages-readonly-gate.test.ts`**: the three pins that read `ADR-0005` now read `ADR-0131 D6`, and they assert that `ADR-0005` is absent. The duplicate pin also asserts `installMode 'unmanaged'`. - **`.changeset/15213-readonly-package-remedy.md`**: `@objectstack/runtime` `patch`. A1 holds. At `2d21b9c891`, `git grep -il 'installModes\|unmanaged'` over `content/docs` (excluding `releases/` and `references/`) matched 0 files. The control word `os package install` matched 7 files on the same tree. No existing page covered install modes, so this is a new page rather than an extension of one. ## The page, sentence by sentence, with the code that backs it Each reading was taken on `main` before the sentence was written. The suites named here were run at base `2d21b9c89` and again at head `338dc51bf` (verification record below). **The two modes (the intro table)** - A package installs as managed or unmanaged, and the package permits the modes. Backing: `ManifestSchema.installModes` and `INSTALL_MODES` in `spec/src/kernel/manifest.zod.ts`. - Managed is registered as code and is the authority. Unmanaged is a copy, written once, and is this environment's own metadata. Backing: the install-local door docblock. Dogfood P1: copied rows have `package_id` = the base and `organization_id` NULL, are served `_provenance: 'org'`, and `PUT` of every copied item answers 200. - Managed takes the next version; the copy follows no upgrade. Backing: `findConflict` answers `'marketplace'` (allow upgrade). Dogfood P1: no ledger file, and `getPackage(source)` is undefined across a restart. - Managed keeps the locks; the copy drops them and lists them. Backing: dogfood "lock control" and P1 (below). - Managed installs under any posture; unmanaged is refused under `group` and `isolated`. Backing: dogfood P2. On `isolated` the unmanaged install answers 409 `UNMANAGED_INSTALL_WALLED_POSTURE` with zero writes, and the managed control answers 200. **Declare the modes** - The `defineStack` example with `installModes: ['managed', 'unmanaged']`. Backing: the key is a live manifest key, and `manifest.test.ts` parses it. - Absent means `['managed']`, with no parse-time default, so nothing is stamped. Backing: `resolveInstallModes` and `DEFAULT_INSTALL_MODES`. `manifest.test.ts` pins that the parsed output has no `installModes` property. - An empty list is refused, and so is a value other than `managed` or `unmanaged`. Backing: `.min(1)` and `z.enum(INSTALL_MODES)`. `manifest.test.ts` pins `[]` and `['template']`. - `unmanaged` alone is legal, and a walled deployment cannot install such a package. Backing: `judgeInstallMode`. A managed request gets 422 `INSTALL_MODE_NOT_PERMITTED`, and an unmanaged one gets the walled refusal. - `defineStack`, `os validate` and `os build` refuse a package that permits `unmanaged` while carrying certain keys, with `STACK_UNMANAGED_INSTALL_UNCOPYABLE` and one finding per key. Backing: `validateUnmanagedInstallMode` in `stack.zod.ts` and `stack-unmanaged-install-mode.test.ts`. The keys are: - runtime-uncreatable collections (`RUNTIME_UNCREATABLE_TYPES`, read off `allowRuntimeCreate`); - code (disposition `code`); - other packages' extensions (`uncopyable`: `objectExtensions`, `picklistExtensions`, `navigationContributions`, `contributes`); - `packages` (`envelope`). - The fix is to stop permitting `unmanaged`, or to move what cannot be copied. Backing: the validator docblock's own remedy. - The install door judges the same body with the same classification. Backing: `UNMANAGED_INSTALL_KEY_DISPOSITIONS` in `kernel/unmanaged-install.ts`, read by both `defineStack` and the import planner. **Pick the mode at install time** - `--install-mode` sets `installMode` on `POST /api/v1/marketplace/install-local`. Without the flag no field is sent, and the runtime installs managed. Backing: CLI `install.ts:201`–`:202`. The CLI test pins that no `installMode` is sent without the flag. - The refusal table: a mode not permitted gets 422 `INSTALL_MODE_NOT_PERMITTED`, naming what is permitted; walled gets 409, naming the posture; managed while a copy holds the namespace gets 409 `RESOURCE_CONFLICT`, naming the base. Each is judged before any write. Backing: `judgeInstallMode` and step 1f. The cloud-connection install-modes suite covers all three (21 tests), and dogfood P2 and P3 cover them end to end. - A runtime that predates install modes installs managed, and the CLI says so and exits 1. Backing: CLI `install.ts:297`, pinned by the CLI test "asked for unmanaged, answered without it". **What a managed install keeps** - The install is recorded on the host, registered again at every boot, and a later version installs over it. Backing: the install-local ledger plus `kernel:ready` rehydrate, and `findConflict`. - A `view`, `dashboard`, `report`, `translation` or `email_template` is overlaid by saving one under the same name, and removing the overlay restores the package's own. Backing: the `allowEnvironmentOverlay: true` registry rows in `metadata-plugin.zod.ts`. Dogfood `managed-content-sealed` "control (regime O)": `PUT` 200, `DELETE` 200, "reset to artifact default". - A flow is switched off or cloned. Backing: the same suite's switch and clone controls, all 200. - A save onto any other shipped item is refused, naming its route. Backing: `managedItemSealedSentence` and `packagedBaseRegimeSentence` in `packaged-base-regime.ts`, and the same suite's 403 cases. - Locks hold. A `protection` block is translated into `_lock` with `_lockSource: 'package'`, the item is served locked with the reason, and the write doors answer 403 `ITEM_LOCKED` per the lock matrix. Backing: dogfood "lock control" (the dashboard serves `lock: 'full'`, `editable: false`, `deletable: false`; the dataset serves `_lock: 'no-overlay'` and `_lockSource: 'package'`, with no `protection`). The matrix is `objectql/src/protocol-lock-enforcement.test.ts` (7 tests). **What an unmanaged copy keeps and drops** - The copy goes into a new base, `PACKAGE_ID-copy`, which keeps the namespace. Backing: the door passes no `targetPackageId`, so `planUnmanagedImport` uses its default. Dogfood P1 answers `targetPackageId: 'com.example.crm-copy'` and `namespace: 'crm'`. - Every item goes through the Studio save door, environment-wide, and is served as environment-authored. Backing: `importUnmanagedPackage` calls `saveMetaItem` for each item, and dogfood P1. - Translations become `NAMESPACE_LOCALE` items (`crm_zh_cn`). Backing: the `translations: 'converted'` disposition, the unit test, and dogfood P1. - Sample data is not loaded, and the answer says `seeded.mode: 'skipped'` when the package declares any. Backing: `installUnmanaged` (`declaresSeeds`) and dogfood P1. - Nothing is registered under the source id; a later managed install is refused, and so is a second unmanaged install. Backing: dogfood P1 and P3. - All or nothing: refusals come before the base exists, a name collision included, and a failed write deletes the base. Backing: the `importUnmanagedPackage` steps 1–8 and `rollBackUnmanagedImport`. Unit tests cover the environment-row collision, the managed-name collision and the mid-copy failure. **The copy keeps no lock, in either spelling** (the seat's carry `6128999134`, restated in `6132465308`) - The reason paragraph paraphrases ruling A on #22971 (`6119681934`): "a package permits `unmanaged` explicitly through `installModes`, so the vendor has already chosen to let environments take editable copies". - Each copy is written without `_lock`, `_lockReason`, `_lockSource` and `_lockDocsUrl`, and without its `protection` block on a type whose schema declares one. Backing: `stripUnmanagedImportLock`. The block's declaration is asked of `overlaySchemaDeclaredKey`. Dogfood P1 finds no copied row carrying any of the five keys. - Every copied item is served unlocked, and its next save is accepted. Backing: dogfood P1, including the three locked fixture items. - A lock value the declarations refuse is not stripped, and the save door answers 422 `INVALID_METADATA` with a rollback. A `protection` block on `action`, `hook` or `page` is left in place and refused the same way. Backing: unit tests "a lock family the spec refuses is not stripped" and "a `protection` block on a type that declares none is still refused by the save door". **The install lists each lock it drops** - `strippedLocks` has one entry per binding lock, in write order, shaped `{ type, name, lock, reason? }`. `reason` is verbatim, or absent when the package gave none. Both spellings on one item produce one entry with the block's lock. `none` is not listed. The list is always present. Backing: `ImportUnmanagedPackageResponseSchema.strippedLocks` and the unit tests for both spellings, `none`, and a package that locks nothing. Dogfood P1 checks the exact list. - The CLI prints `Locks removed`, then one `TYPE.NAME: LOCK — REASON` line per lock, leaving off the dash and reason when there is no reason. Backing: CLI `install.ts:275`–`:283`. The CLI test pins `Locks removed:\s+2` and both line forms. The printed block mirrors `printKV`'s layout. - "Choosing what to permit" applies the ruling: there is no "copy, but keep the lock" mode (ruling A's fallback B is unbuilt). ## The remedy text, FROM → TO **`WRITABLE_PACKAGE_REQUIRED`** (`DELETE`, `PATCH /disable`, `POST /publish`, `POST /revert` on a read-only package) FROM: > … — ACTION a package you own, or customise what this one provides in place, with an ADR-0005 org overlay. TO: > … — ACTION a package you own, or customise what this package provides beside it, as environment-level state (ADR-0131 D6): save an item under its own name — that overlays it where the item's type takes an environment overlay and the package's lock does not refuse it, and is otherwise refused naming the route that type has, or the lock. **`DUPLICATE_SOURCE_NOT_A_BASE`** (`POST /packages/:id/duplicate` on a read-only package) FROM: > … Duplicate a base you own, or customise what this package provides in place, with an ADR-0005 org overlay. TO: > … Duplicate a base you own, or customise what this package provides beside it, as environment-level state (ADR-0131 D6): save an item under its own name — that overlays it where the item's type takes an environment overlay and the package's lock does not refuse it, and is otherwise refused naming the route that type has, or the lock. For an editable copy of the whole package, install it unmanaged instead (installMode 'unmanaged'), where its manifest's installModes permits that. **Why each remedy, measured on `main`:** - **The org overlay is dead.** Every write that names an organization is refused 403 `NOT_OVERRIDABLE` by `organizationScopedWriteRefusal`. `protocol.org-scoped-write-refused.test.ts` pins it for every registry type, on the save, the publish and the package publish. Together with the unmanaged-install suite it passes 364 tests at head. - **Customising beside the package works.** - Environment overlay: the regime O control saves and removes an overlay of a packaged view. - Flow switch and clone: both answer 200. - Any other sealed item is refused with the route its type has: `managedItemSealedSentence`. - A locked item is refused with its lock and reason: `ITEM_LOCKED`, per the matrix test. - **The unmanaged install covers the duplicate gesture's intent**, an editable copy of a package. - It works where the package permits it: dogfood P1. - It is refused loudly otherwise, each refusal before any write: - a mode the package does not permit gets 422 `INSTALL_MODE_NOT_PERMITTED`; - a walled posture gets 409; - a managed install of the same package holding the namespace gets 409 `RESOURCE_CONFLICT` with "Uninstall that package first, or keep the managed install" (unit test "refuses an owned namespace — a managed install of the same package included"). - "Instead" in the sentence carries that last condition. - **A2 holds.** There were 3 hits for the retired remedy at `2d21b9c891` (`:389`, `:538`, `:641`) and 0 at head. No refusal is left without a working remedy, so there is no open question. ## Verification record At head `338dc51bf`, the merge of `origin/main` `abf86554c` through `scripts/pm/os-regen-merge.sh`. The merge left no further changes to commit, and the branch touches no generated artifact that needs regeneration. The dogfood closure was rebuilt on the merged tree: `turbo run build --filter=@objectstack/dogfood^...`, 63/63, through `os-verify-lock.sh`. Every run below went through the lock, and each VERDICT line reads exit 0. The reading-suite chain is joined with `&&` after one leading `export`, so its last exit covers every suite in it. The reverse-verification script exits 0 by design, and the red it measured is quoted below. - **`@objectstack/runtime`, the affected package.** Full `vitest run --project local`: 361 files, 5163 passed, 19 skipped. `typecheck` exit 0. - **The reading suites, at head:** - metadata-protocol `package-unmanaged-install.test.ts` + `protocol.org-scoped-write-refused.test.ts`: 364 passed; - objectql `protocol-lock-enforcement.test.ts`: 7; - cli `--project unit` `package-install-mode.test.ts`: 10; - cloud-connection `marketplace-install-local-install-modes.test.ts`: 21; - spec `stack-unmanaged-install-mode.test.ts` + `kernel/unmanaged-install.test.ts` + `kernel/manifest.test.ts`: 103; - dogfood `install-local-unmanaged-mode` + `managed-content-sealed`: 30. The same suites, minus the org-scope and lock-matrix files, passed at base `2d21b9c89` before the page was written. - **Reverse verification**, from the committed state: - `packages.ts` was restored from base `2d21b9c891` (blob `aad47d51d5`, landing proven by hash equality; "ADR-0005 org overlay" ×3, `CUSTOMISE_BESIDE_REMEDY` ×0). - `packages-readonly-gate.test.ts` then went red as predicted: 3 failed, 22 passed. The three failures were the duplicate envelope pin and the DELETE and disable remedy pins. - It was restored with `git checkout HEAD --` under an EXIT/INT/TERM trap. Afterwards the blob equals HEAD (`fd665ab7e8`) and `git diff HEAD` is empty. - **Gates:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` was derived at `338dc51bf`. Its stderr line 1 names `objectstack-ai/objectstack` at `338dc51bf`, and it derived 100 commands. One pass ran at `338dc51bf`, with HEAD recorded before and after and stdin closed: - **97 exit 0.** - **`pnpm check:type-check-debt`: skipped**, NOT MEASURED by the dispatch. - **`pnpm --filter @objectstack/spec run check:skill-examples` and `pnpm check:dual-build-cjs-loads`: exit 3, PREREQUISITE NOT MET.** They need `dist/` of packages this diff does not touch: `client-react`, `studio`, `embedder-openai`, `knowledge-memory`, `knowledge-ragflow`, `organizations`, `service-cluster-redis` and `service-knowledge`. Three tries to build them through the lock each hit queue-timeout (exit 99, about 27 minutes, while another run held the lock). This is a declared narrowing: NOT MEASURED here, and CI runs both. - **`--ran` reconciliation:** 100 derived, 97 run, 2 NOT-MEASURED, 1 UNRUN (`check:type-check-debt`, as dispatched). - **The named docs gates, all exit 0:** `check:doc-authoring`; `check:doc-anchors` (491 fragment links resolve); `check-affected-docs`; `check:docs-audit-scope` (203 hand-written docs); `check-doc-frontmatter` (414 pages parse); `check:docs-single-h1`; `@objectstack/spec check:docs`. `check:nul-bytes` exit 0 too. - **`check:generated` is not derived** for this diff, which touches no spec file. - **The link lane is NOT MEASURED.** lychee runs in an advisory CI lane and is not installed here. The page's five internal link targets and its two ADR files exist on disk. - **Lint, narrowed.** eslint's config matches 2 of the 6 changed files, `packages.ts` and its test. The `.mdx`, the `.md` and the two `.json` files answer "File ignored because no matching configuration". `eslint --no-inline-config --format json` on the 2 files reports 2 files, 0 errors and 0 warnings. The config enables no type-aware linting (no `parserOptions.project`, no `projectService`; 6 rules resolve for `packages.ts`), so this diff cannot move a verdict on an untouched file. - **Docs re-read.** `affected-docs.mjs origin/main` names `content/docs/api/metadata-api.mdx` (re-read: it quotes neither refusal's text, so nothing changes) and one release-owned page. - **Size:** 322 changed lines (+299 / −23), 6 files, against merge base `abf86554c`. Under the 3,000 human-merge line. ## Acceptance notes - **S2's carry on `package-lifecycle.zod.ts:233` (A3) is not applied here.** PR #23099 (`claude/issue-23073-list-member`, another seat) edits that file and is open and not merged, so this stage does not touch it. - The sentence is false on `main`. It says "No route serves the verb yet; the install door that reads an `installMode` calls it". Since S2 (#22916), `POST /api/v1/marketplace/install-local` with `installMode: 'unmanaged'` calls it: `installUnmanaged` calls `protocol.importUnmanagedPackage`. - Corrected sentence for the TSDoc above `ImportUnmanagedPackageRequestSchema` (`:233`–`:234`): "The install door `POST /api/v1/marketplace/install-local` calls it for `installMode: 'unmanaged'`, with the package body flattened and no `targetPackageId`, so the base takes the default id." It is TSDoc only, with no `.describe()` change. - Carrier: #23099's lander. - **The CLI reference does not list `--install-mode`.** `content/docs/deployment/cli.mdx`, the `os package install` options table, has no row for the flag S2 shipped. Neither Publish, Versioning & Preview (step 3) nor the deployment index table ("The pages for shipping an app") links the new page. All three are outside this claim's file surface (one new page plus its nav entry). Carrier: none. - **The `installModes` TSDoc names the wrong code for the authoring refusal.** `spec/src/kernel/manifest.zod.ts` (`:628`) says `UNMANAGED_INSTALL_UNCOPYABLE`. `defineStack` raises `STACK_UNMANAGED_INSTALL_UNCOPYABLE`; the shorter code is the import's. It is TSDoc only and does not reach the generated reference. Carrier: none. - **`packaged-base-regime.ts` says the install mode is not modelled yet.** Its header (the "A managed package" section) reads "The install mode is not modelled yet (the manifest declaration of permitted modes is later work)", which has been stale since S2. Its conclusion still holds: only a managed install registers items through the artifact loader. Carrier: none. - **ADR-0126's status line still spells the mode `template`.** S2's ACCEPT routed this to S3. It is Tier H and outside this claim's file surface, so it is not edited. Carrier: none. - **The page does not cover an unrecognised posture value.** It names only `group` and `isolated` for the walled refusal. The verb reads an unrecognised `OS_TENANCY_POSTURE` as walled, but S2 observed the door's admission answering 401 before that point in a stub harness. That is unmeasured here, so the page does not state it. - **No real package declares `installModes` yet.** `git grep -l installModes -- examples` matches 0 files at head. The control `manifest:` matches `examples/app-crm` and `examples/app-multi-package` on the same tree. The page's guidance is prospective, and only the dogfood fixture adds the key. --- _Generated by [Claude Code](https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 21c8644 commit 240890c

6 files changed

Lines changed: 299 additions & 23 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/runtime': patch
3+
---
4+
5+
The `/packages` refusals of a read-only package name the customisation that works today, instead of the retired organization overlay (ADR-0131 D6)
6+
7+
Clause-②: no
8+
9+
`DELETE`, `PATCH /disable`, `POST /publish` and `POST /revert` on a package provided by code or an installed app answer `422 WRITABLE_PACKAGE_REQUIRED`, and `POST /packages/:id/duplicate` on one answers `422 DUPLICATE_SOURCE_NOT_A_BASE`. Both refusals told the caller to customise the package "in place, with an ADR-0005 org overlay". ADR-0131 D6 retired the per-organization overlay axis: no metadata write lands organization-scoped any more, and the protocol refuses one that names an organization with `403 NOT_OVERRIDABLE`. The remedy named a mechanism that no longer exists.
10+
11+
Both refusals now name the customisation that works: customise what the package provides beside it, as environment-level state. Saving an item under its own name overlays it where the item's type takes an environment overlay and the package's lock does not refuse it. Any other save is refused, and that refusal names the route the item's type has, or the lock. The duplicate refusal also names the editable copy of a whole package: install it unmanaged instead (`installMode: 'unmanaged'`), where its manifest's `installModes` permits that.
12+
13+
Only the message text changes. Each refusal's `code`, status, `details`, condition and order are unchanged.
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
---
2+
title: Install modes — managed or an unmanaged copy
3+
navTitle: Install Modes
4+
description: Declare which install modes a package permits with installModes, then pick one at install time. What a managed install keeps, and what an unmanaged copy keeps and drops, the vendor's protection locks included.
5+
---
6+
7+
A package reaches a running runtime in one of two **install modes**
8+
([ADR-0131](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0131-total-organization-ownership-no-null-organization-id.md)
9+
D6). The package author declares which modes it permits; whoever installs it
10+
picks one of those.
11+
12+
| | **managed** (the default) | **unmanaged** |
13+
|:---|:---|:---|
14+
| What is installed | the package, registered as code | a copy of every metadata item, written once into the environment |
15+
| Who is the authority | the package | this environment: the copy is its own metadata |
16+
| Editing | nothing it ships is edited in place; you customise beside it | every copied item is edited like anything authored in Studio |
17+
| Later versions | install the next version | the copy follows no upgrade |
18+
| The vendor's protection locks | kept and enforced | not copied; the install lists each lock it drops |
19+
| Tenancy posture | any | refused where the deployment walls organizations (`group`, `isolated`) |
20+
21+
---
22+
23+
## Declare the modes a package permits
24+
25+
The manifest key is `installModes`:
26+
27+
```ts
28+
import { defineStack } from '@objectstack/spec';
29+
30+
export default defineStack({
31+
manifest: {
32+
id: 'com.acme.crm',
33+
namespace: 'crm',
34+
version: '1.0.0',
35+
type: 'app',
36+
name: 'Acme CRM',
37+
installModes: ['managed', 'unmanaged'],
38+
},
39+
// objects, views, dashboards, …
40+
});
41+
```
42+
43+
- **Leave it out and the package permits `managed` only.** The key has no
44+
parse-time default: absent means `['managed']`, and nothing is stamped into
45+
the stored manifest.
46+
- **Name at least one mode.** An empty list is refused; omit the key instead.
47+
A value other than `managed` or `unmanaged` is refused too.
48+
- **`unmanaged` alone is legal.** It suits a starter app a customer takes over
49+
wholesale. A deployment that walls organizations cannot install such a
50+
package at all.
51+
52+
### Permitting `unmanaged` is checked when you build
53+
54+
An unmanaged install copies every item through the same save door a Studio edit
55+
takes, or copies nothing; there is no partial copy. So `defineStack`, and with
56+
it `os validate` and `os build`, refuses a package that permits `unmanaged`
57+
while it carries something that door cannot write as environment metadata. The
58+
refusal is `STACK_UNMANAGED_INSTALL_UNCOPYABLE`, with one finding per key:
59+
60+
- items of a metadata type the metadata-type registry declares
61+
runtime-uncreatable (`allowRuntimeCreate: false`);
62+
- code: `functions`, `plugins` and the other keys that carry or describe code;
63+
- extensions of another package's items: `objectExtensions`,
64+
`picklistExtensions`, `navigationContributions`, `contributes`;
65+
- `packages`, which describes an artifact of several packages, while one
66+
unmanaged install copies one package.
67+
68+
The fix is to stop permitting `unmanaged`, or to move what cannot be copied
69+
out of the package. The install door judges the same body with the same
70+
classification, so a package that passes `os validate` is not refused there
71+
for a key you were never told about.
72+
73+
---
74+
75+
## Pick the mode at install time
76+
77+
```bash
78+
# managed, the default
79+
os package install ./dist/objectstack.json
80+
81+
# an unmanaged copy, from a local artifact or from the runtime's catalog
82+
os package install ./dist/objectstack.json --install-mode unmanaged
83+
os package install com.acme.crm --version 1.2.0 --install-mode unmanaged
84+
```
85+
86+
`--install-mode` sets the `installMode` field of
87+
`POST /api/v1/marketplace/install-local`; without it no field is sent and the
88+
runtime installs managed. The door judges the mode before it writes anything:
89+
90+
| The request | The answer |
91+
|:---|:---|
92+
| A mode the package's `installModes` does not permit | `422 INSTALL_MODE_NOT_PERMITTED`, naming what it permits |
93+
| `unmanaged` on a deployment whose requested tenancy posture walls organizations (`group`, `isolated`) | `409 UNMANAGED_INSTALL_WALLED_POSTURE`, naming the posture |
94+
| `managed`, while an unmanaged copy of the package holds its namespace | `409 RESOURCE_CONFLICT`, naming the copy's base |
95+
96+
A runtime that predates install modes ignores the field and installs the
97+
package managed. If you asked for `unmanaged`, the CLI says so and exits `1`.
98+
The install door itself, credentials included, is described under
99+
[`os package install`](/docs/deployment/cli#os-package-install).
100+
101+
---
102+
103+
## What a managed install keeps
104+
105+
The package is registered as code and stays the authority. Its install is
106+
recorded on the runtime host, so it is registered again at every boot, and a
107+
later version is installed over it.
108+
109+
**Nothing it ships is edited in place.** What a customer changes lives beside
110+
the package, as environment-level state:
111+
112+
- a `view`, `dashboard`, `report`, `translation` or `email_template` is
113+
overlaid by saving one under the same name, and removing the overlay brings
114+
the package's own back;
115+
- a flow is switched off, or cloned under a new name and changed there;
116+
- a save onto any other item the package ships is refused, and the refusal
117+
names the route its type has.
118+
119+
**The vendor's protection locks hold.** A package locks an item in either
120+
[ADR-0010](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0010-metadata-protection-model.md)
121+
spelling: the `_lock` envelope (`_lock`, `_lockReason`, `_lockDocsUrl`), or the
122+
author-facing `protection: { lock, reason, docsUrl }` block, which the install
123+
translates into the envelope with `_lockSource: 'package'`. The item is served
124+
locked with the vendor's reason, and the write doors refuse what the lock
125+
forbids with `403 ITEM_LOCKED`: `no-overlay` refuses a save, `no-delete` a
126+
delete, and `full` both.
127+
128+
---
129+
130+
## What an unmanaged copy keeps, and what it drops
131+
132+
The package's metadata is copied **once** into a new writable base,
133+
`<package id>-copy`, which keeps the package's namespace. Every item is written
134+
through the save door a Studio edit takes, environment-wide, and is served as
135+
environment-authored metadata: the next save edits it.
136+
137+
- **Translations are copied.** Each locale of each bundle becomes one
138+
`translation` item named `<namespace>_<locale>`, the locale lower-cased with
139+
its hyphens underscored (`crm_zh_cn`).
140+
- **Sample data is not loaded.** When the package declares any, the install
141+
answer says so, with `seeded.mode: 'skipped'`.
142+
- **Nothing is registered under the package's own id.** No install record,
143+
nothing re-registered at boot, nothing upgraded. A managed install of the
144+
package is refused while the copy holds its namespace, and a second unmanaged
145+
install is refused because the base already exists.
146+
- **All or nothing.** Every refusal is judged before the base exists, including
147+
a name the environment or a managed package already holds. A write that fails
148+
deletes the base, so there is never a half-copied app.
149+
150+
### The copy keeps no lock, in either spelling
151+
152+
The package permitted `unmanaged` through `installModes`, so its vendor already
153+
chose to let environments take editable copies. Once the package is no longer
154+
the authority, a lock whose source is the package has nothing to stand on. So
155+
every copied item is written:
156+
157+
- without `_lock`, `_lockReason`, `_lockSource` and `_lockDocsUrl`;
158+
- without its `protection` block, on a type whose schema declares one.
159+
160+
Every copied item is then served unlocked, and its next save is accepted.
161+
162+
Two cases are left to the save door exactly as before, and either refuses the
163+
whole install, which rolls back:
164+
165+
- a lock value the declarations refuse, such as `_lock: 'locked'`, a reason
166+
that is not a string, or a malformed `protection` block, is not stripped: the
167+
save door answers `422 INVALID_METADATA`;
168+
- a `protection` block on a type whose schema does not declare one (`action`,
169+
`hook`, `page`) is left in place, and the save door refuses it as an
170+
unrecognised key.
171+
172+
### The install lists each lock it drops
173+
174+
The install answer carries `strippedLocks`: one entry per dropped lock that
175+
binds (`no-overlay`, `no-delete` or `full`), in the order the items were
176+
written.
177+
178+
```json
179+
"strippedLocks": [
180+
{ "type": "dashboard", "name": "pipeline_dashboard", "lock": "full", "reason": "The pipeline figures feed the board pack — edit the source package instead." },
181+
{ "type": "app", "name": "crm_app", "lock": "no-delete" }
182+
]
183+
```
184+
185+
- `reason` is the vendor's text, verbatim, and is absent when the package gave
186+
none.
187+
- An item that carries both spellings is listed once, with the lock a managed
188+
install would bind: the `protection` block's.
189+
- A `none` lock declares nothing and is not listed.
190+
- The list is always present, and empty when the package locked nothing.
191+
192+
`os package install --install-mode unmanaged` prints the list under
193+
`Locks removed`, one line per lock as `<type>.<name>: <lock> — <reason>`, the
194+
dash and reason left off when there is none:
195+
196+
```text
197+
Locks removed: 2 — the package locked these items; the copy is editable
198+
dashboard.pipeline_dashboard: full — The pipeline figures feed the board pack — edit the source package instead.
199+
app.crm_app: no-delete
200+
```
201+
202+
Read it before you edit: it is why the vendor locked each item.
203+
204+
---
205+
206+
## Choosing what to permit
207+
208+
- **Permit `unmanaged`** when the package is a starting point a customer should
209+
be able to take over and change freely.
210+
- **Keep it managed only** when you must keep shipping upgrades to it, or when
211+
some item must never be edited by any customer. An unmanaged copy drops every
212+
lock, so a lock is no reason to permit the copy; it is a reason to withhold
213+
it.
214+
215+
## Related
216+
217+
- [Publish, Versioning & Preview](/docs/deployment/publish-and-preview): compile,
218+
publish and install a version
219+
- [`os package install`](/docs/deployment/cli#os-package-install): the flag-level
220+
reference
221+
- [Package manifest reference](/docs/references/kernel/manifest)
222+
- [Package lifecycle reference](/docs/references/api/package-lifecycle): the
223+
import's request and answer, `strippedLocks` included
224+
- [Tenancy Modes](/docs/deployment/tenancy-modes)

‎content/docs/deployment/meta.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"seed-tenancy-repair",
99
"single-project-mode",
1010
"publish-and-preview",
11+
"install-modes",
1112
"validating-metadata",
1213
"cli",
1314
"environment-variables",

‎packages/runtime/src/domains/packages-readonly-gate.test.ts‎

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -393,10 +393,14 @@ describe('/packages duplicate — a source that is not a base is refused (#14451
393393
expect(err?.httpStatus).toBe(422);
394394
expect(err?.details?.packageId).toBe(CODE_LOADED);
395395
expect(err?.details?.docs).toBe('docs/adr/0070-package-first-authoring.md');
396-
// The remedy is the one that EXISTS for a code package (ADR-0005
397-
// overlay), and the message says why the copy would be empty rather
398-
// than only that it is refused.
399-
expect(err?.message).toContain('ADR-0005');
396+
// The remedies are the ones that EXIST for a code package (ADR-0131 D6:
397+
// customise beside it, or take an unmanaged copy), and the message says
398+
// why the copy would be empty rather than only that it is refused. The
399+
// retired ADR-0005 organization overlay (a write naming an organization
400+
// answers 403 NOT_OVERRIDABLE) is no longer prescribed.
401+
expect(err?.message).toContain('ADR-0131 D6');
402+
expect(err?.message).toContain("installMode 'unmanaged'");
403+
expect(err?.message).not.toContain('ADR-0005');
400404
expect(err?.message).toContain('read-only');
401405
});
402406

@@ -481,7 +485,7 @@ describe('/packages lifecycle — the read-only refusal no longer sends callers
481485
{ label: 'DELETE', path: (id: string) => `/${id}`, method: 'DELETE' },
482486
{ label: 'disable', path: (id: string) => `/${id}/disable`, method: 'PATCH' },
483487
]) {
484-
it(`${verb.label} prescribes the ADR-0005 overlay, not POST /:id/duplicate`, async () => {
488+
it(`${verb.label} prescribes customising beside the package (ADR-0131 D6), not POST /:id/duplicate`, async () => {
485489
const { dispatcher } = make();
486490
const r = await dispatcher.handlePackages(verb.path(CODE_LOADED), verb.method, {}, {}, admin());
487491
const message = String(r.response?.body?.error?.message ?? '');
@@ -491,9 +495,12 @@ describe('/packages lifecycle — the read-only refusal no longer sends callers
491495
// (POST /packages/<id>/duplicate) and change that" — a remedy that,
492496
// for exactly the packages this refusal fires on, now answers 422
493497
// and before that answered an empty 200. A refusal that prescribes a
494-
// dead end is worse than one that prescribes nothing.
498+
// dead end is worse than one that prescribes nothing. Its next
499+
// remedy, the ADR-0005 organization overlay, is retired too (a write
500+
// naming an organization answers 403 NOT_OVERRIDABLE).
495501
expect(message).not.toContain('/duplicate');
496-
expect(message).toContain('ADR-0005');
502+
expect(message).not.toContain('ADR-0005');
503+
expect(message).toContain('ADR-0131 D6');
497504
});
498505
}
499506
});

0 commit comments

Comments
 (0)