diff --git a/.changeset/15213-readonly-package-remedy.md b/.changeset/15213-readonly-package-remedy.md new file mode 100644 index 00000000000..9ae7f70902e --- /dev/null +++ b/.changeset/15213-readonly-package-remedy.md @@ -0,0 +1,13 @@ +--- +'@objectstack/runtime': patch +--- + +The `/packages` refusals of a read-only package name the customisation that works today, instead of the retired organization overlay (ADR-0131 D6) + +Clause-②: no + +`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. + +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. + +Only the message text changes. Each refusal's `code`, status, `details`, condition and order are unchanged. diff --git a/content/docs/deployment/install-modes.mdx b/content/docs/deployment/install-modes.mdx new file mode 100644 index 00000000000..1606bfa8a9e --- /dev/null +++ b/content/docs/deployment/install-modes.mdx @@ -0,0 +1,224 @@ +--- +title: Install modes — managed or an unmanaged copy +navTitle: Install Modes +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. +--- + +A package reaches a running runtime in one of two **install modes** +([ADR-0131](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0131-total-organization-ownership-no-null-organization-id.md) +D6). The package author declares which modes it permits; whoever installs it +picks one of those. + +| | **managed** (the default) | **unmanaged** | +|:---|:---|:---| +| What is installed | the package, registered as code | a copy of every metadata item, written once into the environment | +| Who is the authority | the package | this environment: the copy is its own metadata | +| Editing | nothing it ships is edited in place; you customise beside it | every copied item is edited like anything authored in Studio | +| Later versions | install the next version | the copy follows no upgrade | +| The vendor's protection locks | kept and enforced | not copied; the install lists each lock it drops | +| Tenancy posture | any | refused where the deployment walls organizations (`group`, `isolated`) | + +--- + +## Declare the modes a package permits + +The manifest key is `installModes`: + +```ts +import { defineStack } from '@objectstack/spec'; + +export default defineStack({ + manifest: { + id: 'com.acme.crm', + namespace: 'crm', + version: '1.0.0', + type: 'app', + name: 'Acme CRM', + installModes: ['managed', 'unmanaged'], + }, + // objects, views, dashboards, … +}); +``` + +- **Leave it out and the package permits `managed` only.** The key has no + parse-time default: absent means `['managed']`, and nothing is stamped into + the stored manifest. +- **Name at least one mode.** An empty list is refused; omit the key instead. + A value other than `managed` or `unmanaged` is refused too. +- **`unmanaged` alone is legal.** It suits a starter app a customer takes over + wholesale. A deployment that walls organizations cannot install such a + package at all. + +### Permitting `unmanaged` is checked when you build + +An unmanaged install copies every item through the same save door a Studio edit +takes, or copies nothing; there is no partial copy. So `defineStack`, and with +it `os validate` and `os build`, refuses a package that permits `unmanaged` +while it carries something that door cannot write as environment metadata. The +refusal is `STACK_UNMANAGED_INSTALL_UNCOPYABLE`, with one finding per key: + +- items of a metadata type the metadata-type registry declares + runtime-uncreatable (`allowRuntimeCreate: false`); +- code: `functions`, `plugins` and the other keys that carry or describe code; +- extensions of another package's items: `objectExtensions`, + `picklistExtensions`, `navigationContributions`, `contributes`; +- `packages`, which describes an artifact of several packages, while one + unmanaged install copies one package. + +The fix is to stop permitting `unmanaged`, or to move what cannot be copied +out of the package. The install door judges the same body with the same +classification, so a package that passes `os validate` is not refused there +for a key you were never told about. + +--- + +## Pick the mode at install time + +```bash +# managed, the default +os package install ./dist/objectstack.json + +# an unmanaged copy, from a local artifact or from the runtime's catalog +os package install ./dist/objectstack.json --install-mode unmanaged +os package install com.acme.crm --version 1.2.0 --install-mode unmanaged +``` + +`--install-mode` sets the `installMode` field of +`POST /api/v1/marketplace/install-local`; without it no field is sent and the +runtime installs managed. The door judges the mode before it writes anything: + +| The request | The answer | +|:---|:---| +| A mode the package's `installModes` does not permit | `422 INSTALL_MODE_NOT_PERMITTED`, naming what it permits | +| `unmanaged` on a deployment whose requested tenancy posture walls organizations (`group`, `isolated`) | `409 UNMANAGED_INSTALL_WALLED_POSTURE`, naming the posture | +| `managed`, while an unmanaged copy of the package holds its namespace | `409 RESOURCE_CONFLICT`, naming the copy's base | + +A runtime that predates install modes ignores the field and installs the +package managed. If you asked for `unmanaged`, the CLI says so and exits `1`. +The install door itself, credentials included, is described under +[`os package install`](/docs/deployment/cli#os-package-install). + +--- + +## What a managed install keeps + +The package is registered as code and stays the authority. Its install is +recorded on the runtime host, so it is registered again at every boot, and a +later version is installed over it. + +**Nothing it ships is edited in place.** What a customer changes lives beside +the package, as environment-level state: + +- a `view`, `dashboard`, `report`, `translation` or `email_template` is + overlaid by saving one under the same name, and removing the overlay brings + the package's own back; +- a flow is switched off, or cloned under a new name and changed there; +- a save onto any other item the package ships is refused, and the refusal + names the route its type has. + +**The vendor's protection locks hold.** A package locks an item in either +[ADR-0010](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0010-metadata-protection-model.md) +spelling: the `_lock` envelope (`_lock`, `_lockReason`, `_lockDocsUrl`), or the +author-facing `protection: { lock, reason, docsUrl }` block, which the install +translates into the envelope with `_lockSource: 'package'`. The item is served +locked with the vendor's reason, and the write doors refuse what the lock +forbids with `403 ITEM_LOCKED`: `no-overlay` refuses a save, `no-delete` a +delete, and `full` both. + +--- + +## What an unmanaged copy keeps, and what it drops + +The package's metadata is copied **once** into a new writable base, +`-copy`, which keeps the package's namespace. Every item is written +through the save door a Studio edit takes, environment-wide, and is served as +environment-authored metadata: the next save edits it. + +- **Translations are copied.** Each locale of each bundle becomes one + `translation` item named `_`, the locale lower-cased with + its hyphens underscored (`crm_zh_cn`). +- **Sample data is not loaded.** When the package declares any, the install + answer says so, with `seeded.mode: 'skipped'`. +- **Nothing is registered under the package's own id.** No install record, + nothing re-registered at boot, nothing upgraded. A managed install of the + package is refused while the copy holds its namespace, and a second unmanaged + install is refused because the base already exists. +- **All or nothing.** Every refusal is judged before the base exists, including + a name the environment or a managed package already holds. A write that fails + deletes the base, so there is never a half-copied app. + +### The copy keeps no lock, in either spelling + +The package permitted `unmanaged` through `installModes`, so its vendor already +chose to let environments take editable copies. Once the package is no longer +the authority, a lock whose source is the package has nothing to stand on. So +every copied item is written: + +- without `_lock`, `_lockReason`, `_lockSource` and `_lockDocsUrl`; +- without its `protection` block, on a type whose schema declares one. + +Every copied item is then served unlocked, and its next save is accepted. + +Two cases are left to the save door exactly as before, and either refuses the +whole install, which rolls back: + +- a lock value the declarations refuse, such as `_lock: 'locked'`, a reason + that is not a string, or a malformed `protection` block, is not stripped: the + save door answers `422 INVALID_METADATA`; +- a `protection` block on a type whose schema does not declare one (`action`, + `hook`, `page`) is left in place, and the save door refuses it as an + unrecognised key. + +### The install lists each lock it drops + +The install answer carries `strippedLocks`: one entry per dropped lock that +binds (`no-overlay`, `no-delete` or `full`), in the order the items were +written. + +```json +"strippedLocks": [ + { "type": "dashboard", "name": "pipeline_dashboard", "lock": "full", "reason": "The pipeline figures feed the board pack — edit the source package instead." }, + { "type": "app", "name": "crm_app", "lock": "no-delete" } +] +``` + +- `reason` is the vendor's text, verbatim, and is absent when the package gave + none. +- An item that carries both spellings is listed once, with the lock a managed + install would bind: the `protection` block's. +- A `none` lock declares nothing and is not listed. +- The list is always present, and empty when the package locked nothing. + +`os package install --install-mode unmanaged` prints the list under +`Locks removed`, one line per lock as `.: — `, the +dash and reason left off when there is none: + +```text + Locks removed: 2 — the package locked these items; the copy is editable + dashboard.pipeline_dashboard: full — The pipeline figures feed the board pack — edit the source package instead. + app.crm_app: no-delete +``` + +Read it before you edit: it is why the vendor locked each item. + +--- + +## Choosing what to permit + +- **Permit `unmanaged`** when the package is a starting point a customer should + be able to take over and change freely. +- **Keep it managed only** when you must keep shipping upgrades to it, or when + some item must never be edited by any customer. An unmanaged copy drops every + lock, so a lock is no reason to permit the copy; it is a reason to withhold + it. + +## Related + +- [Publish, Versioning & Preview](/docs/deployment/publish-and-preview): compile, + publish and install a version +- [`os package install`](/docs/deployment/cli#os-package-install): the flag-level + reference +- [Package manifest reference](/docs/references/kernel/manifest) +- [Package lifecycle reference](/docs/references/api/package-lifecycle): the + import's request and answer, `strippedLocks` included +- [Tenancy Modes](/docs/deployment/tenancy-modes) diff --git a/content/docs/deployment/meta.json b/content/docs/deployment/meta.json index 2165832ed47..0274ee0087f 100644 --- a/content/docs/deployment/meta.json +++ b/content/docs/deployment/meta.json @@ -8,6 +8,7 @@ "seed-tenancy-repair", "single-project-mode", "publish-and-preview", + "install-modes", "validating-metadata", "cli", "environment-variables", diff --git a/packages/runtime/src/domains/packages-readonly-gate.test.ts b/packages/runtime/src/domains/packages-readonly-gate.test.ts index 4faeaa1a59a..4ae194c18dd 100644 --- a/packages/runtime/src/domains/packages-readonly-gate.test.ts +++ b/packages/runtime/src/domains/packages-readonly-gate.test.ts @@ -393,10 +393,14 @@ describe('/packages duplicate — a source that is not a base is refused (#14451 expect(err?.httpStatus).toBe(422); expect(err?.details?.packageId).toBe(CODE_LOADED); expect(err?.details?.docs).toBe('docs/adr/0070-package-first-authoring.md'); - // The remedy is the one that EXISTS for a code package (ADR-0005 - // overlay), and the message says why the copy would be empty rather - // than only that it is refused. - expect(err?.message).toContain('ADR-0005'); + // The remedies are the ones that EXIST for a code package (ADR-0131 D6: + // customise beside it, or take an unmanaged copy), and the message says + // why the copy would be empty rather than only that it is refused. The + // retired ADR-0005 organization overlay (a write naming an organization + // answers 403 NOT_OVERRIDABLE) is no longer prescribed. + expect(err?.message).toContain('ADR-0131 D6'); + expect(err?.message).toContain("installMode 'unmanaged'"); + expect(err?.message).not.toContain('ADR-0005'); expect(err?.message).toContain('read-only'); }); @@ -481,7 +485,7 @@ describe('/packages lifecycle — the read-only refusal no longer sends callers { label: 'DELETE', path: (id: string) => `/${id}`, method: 'DELETE' }, { label: 'disable', path: (id: string) => `/${id}/disable`, method: 'PATCH' }, ]) { - it(`${verb.label} prescribes the ADR-0005 overlay, not POST /:id/duplicate`, async () => { + it(`${verb.label} prescribes customising beside the package (ADR-0131 D6), not POST /:id/duplicate`, async () => { const { dispatcher } = make(); const r = await dispatcher.handlePackages(verb.path(CODE_LOADED), verb.method, {}, {}, admin()); const message = String(r.response?.body?.error?.message ?? ''); @@ -491,9 +495,12 @@ describe('/packages lifecycle — the read-only refusal no longer sends callers // (POST /packages//duplicate) and change that" — a remedy that, // for exactly the packages this refusal fires on, now answers 422 // and before that answered an empty 200. A refusal that prescribes a - // dead end is worse than one that prescribes nothing. + // dead end is worse than one that prescribes nothing. Its next + // remedy, the ADR-0005 organization overlay, is retired too (a write + // naming an organization answers 403 NOT_OVERRIDABLE). expect(message).not.toContain('/duplicate'); - expect(message).toContain('ADR-0005'); + expect(message).not.toContain('ADR-0005'); + expect(message).toContain('ADR-0131 D6'); }); } }); diff --git a/packages/runtime/src/domains/packages.ts b/packages/runtime/src/domains/packages.ts index aad47d51d54..fd665ab7e86 100644 --- a/packages/runtime/src/domains/packages.ts +++ b/packages/runtime/src/domains/packages.ts @@ -386,14 +386,20 @@ function requireReadCapability(deps: DomainHandlerDeps, context: HttpProtocolCon * (ADR-0070 D2). Revert asks it only after the protocol's stored-row answer, so * a `sys_metadata` row bound to the package keeps its own answer. * - * [#14451] The remedy it names is ADR-0005 org overlay, and it used to name - * `POST /packages/:id/duplicate` instead. That sentence sent a caller holding a - * read-only package at a route which — for exactly the packages this refusal - * fires on — cannot help: duplicate clones a base's `sys_metadata` rows, a code - * package owns none, and until {@link requireDuplicableSource} the trip ended - * in a 200 reporting `copiedCount: 0` (the #14451 measurement). A refusal that - * prescribes a dead end is worse than one that prescribes nothing, so the two - * doors now agree: overlay is how a code package is customised. + * [#14451] It used to name `POST /packages/:id/duplicate` as the remedy. That + * sentence sent a caller holding a read-only package at a route which — for + * exactly the packages this refusal fires on — cannot help: duplicate clones a + * base's `sys_metadata` rows, a code package owns none, and until {@link + * requireDuplicableSource} the trip ended in a 200 reporting `copiedCount: 0` + * (the #14451 measurement). A refusal that prescribes a dead end is worse than + * one that prescribes nothing. + * + * [ADR-0131 D6] For the same reason it no longer names the ADR-0005 + * organization overlay, its remedy after that: the per-organization overlay + * axis is retired, and a write naming an organization answers `403 + * NOT_OVERRIDABLE`. What a managed package provides is customised BESIDE it, + * as environment-level state, and the two doors name that remedy in one + * clause ({@link CUSTOMISE_BESIDE_REMEDY}). * * ⛔ Deliberately NOT caller-sensitive — there is no `isSystem` bypass, unlike * {@link requireManageMetadata}. Read-only is a property of the PACKAGE. Engine @@ -521,6 +527,24 @@ function commitItemTypes(items: unknown): unknown[] { return Array.isArray(list) ? list.map((item) => (item as { type?: unknown } | null)?.type) : []; } +/** + * [ADR-0131 D6] The customisation remedy both read-only refusals below name + * ({@link requireWritablePackage}, {@link requireDuplicableSource}), in one + * spelling. A managed package's items are sealed, and what a customer changes + * lives beside them as environment-level state: a save under an item's own + * name is an environment overlay on a type whose registry entry takes one + * (`allowEnvironmentOverlay`) unless the package's lock refuses it — that save + * is refused `ITEM_LOCKED`, naming the lock and its reason (ADR-0010 §3.3) — + * and on every other type the save door's own refusal names the route that + * type has: a flow's clone and switch, an action's switch, a permission set's + * clone, else the source. The sentence names that door rather than a list of + * types, so it cannot drift from the regime table the door reads. + */ +const CUSTOMISE_BESIDE_REMEDY = + '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.'; + function requireWritablePackage( deps: DomainHandlerDeps, engine: unknown, @@ -534,8 +558,8 @@ function requireWritablePackage( response: deps.error( `[writable_package_required] Cannot ${action} package '${id}': it is read-only ` + `(provided by code or an installed app). Packages the deployment ships are managed by ` - + `the deployment, not over this API — ${action} a package you own, or customise what ` - + `this one provides in place, with an ADR-0005 org overlay.`, + + `the deployment, not over this API — ${action} a package you own, or ` + + CUSTOMISE_BESIDE_REMEDY, 422, { code: 'WRITABLE_PACKAGE_REQUIRED', @@ -591,9 +615,13 @@ function requireWritablePackage( * Making it clone a code package's items would extend D4 from bases to code * packages — a NEW decision, and one the ADR itself still lists as open * ("should customising a code item also fork it into a writable base? - * *Leaning: keep overlay for surgical tweaks*"). ADR-0005 overlay is the built, - * shipped answer for customising what a code package provides, so the honest - * behaviour of an unbuilt gesture is to say so. + * *Leaning: keep overlay for surgical tweaks*"). Two built answers exist + * instead (ADR-0131 D6): customising BESIDE the package, as environment-level + * state ({@link CUSTOMISE_BESIDE_REMEDY}), and, for an editable copy of the + * whole package, its `unmanaged` install, which the install door judges (the + * package must permit it, and the copy cannot sit beside a managed install of + * the same package). So the honest behaviour of an unbuilt gesture is to say + * so and name both. * * ## Shape * @@ -603,7 +631,8 @@ function requireWritablePackage( * a lie by implicature here: nothing is being written to the source, and its * remedy ("use a writable package") reads as *make the source writable*, which * is neither possible nor the point. `DUPLICATE_SOURCE_NOT_A_BASE` names the - * one thing the caller can act on — pick a base, or overlay instead. + * one thing the caller can act on — pick a base, customise beside the package, + * or install it unmanaged where it permits that. * * ⛔ Deliberately NOT a check on emptiness. A WRITABLE base holding no active * rows still duplicates to `copiedCount: 0`, and that answer stays exactly as @@ -637,8 +666,9 @@ function requireDuplicableSource( + `(provided by code or an installed app), and duplicate clones a writable base's ` + `stored metadata rows. A code package's metadata is delivered as code, so there are ` + `no rows to clone and the copy would be empty (ADR-0070 D4 duplicates a BASE). ` - + `Duplicate a base you own, or customise what this package provides in place, with an ` - + `ADR-0005 org overlay.`, + + `Duplicate a base you own, or ${CUSTOMISE_BESIDE_REMEDY} ` + + `For an editable copy of the whole package, install it unmanaged instead ` + + `(installMode 'unmanaged'), where its manifest's installModes permits that.`, 422, { code: 'DUPLICATE_SOURCE_NOT_A_BASE', diff --git a/scripts/docs-audit/handwritten-docs.json b/scripts/docs-audit/handwritten-docs.json index b91bb36e018..75ca12d2c67 100644 --- a/scripts/docs-audit/handwritten-docs.json +++ b/scripts/docs-audit/handwritten-docs.json @@ -94,6 +94,7 @@ "content/docs/deployment/cli.mdx", "content/docs/deployment/environment-variables.mdx", "content/docs/deployment/index.mdx", + "content/docs/deployment/install-modes.mdx", "content/docs/deployment/production-readiness.mdx", "content/docs/deployment/publish-and-preview.mdx", "content/docs/deployment/seed-tenancy-repair.mdx",