Skip to content
Merged
62 changes: 62 additions & 0 deletions .changeset/enableoninstall-optional-preserve.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
---
'@objectstack/spec': minor
---

fix(spec): `enableOnInstall` becomes `optional()` so absence survives the parse

The install door was ruled onto three states — 「缺省 = 保持,有旗 = 设置」 — and
implements them: `enableOnInstall: true` enables the row, `false` disables it,
and an **absent** key makes no lifecycle call at all, so a package an operator
disabled stays disabled across an upgrade or a re-install. A fresh id has no
state to keep and lands enabled.

The published declarations said something else. `z.boolean().default(true)`
resolves absence **at parse time**, so a request that omitted the key came out
of the parse byte-identical to one that set `true` — the third state did not
exist on the published surface, while the door went on acting on it. That is a
declared default the runtime deliberately stops applying, on a contract this
repo does not own both ends of.

All three declarations now spell `z.boolean().optional()`, with the semantics
written on the field in the `describe` and the docblock:

- `api/PackageInstallRequest` (`src/api/package-api.zod.ts`) — the authority.
- `kernel/InstallPackageRequest` (`src/kernel/package-registry.zod.ts`) — the
copy restated on the in-process protocol primitive. It is re-exported through
`src/api/protocol.zod.ts`, so it publishes under `api/InstallPackageRequest`
too: one declaration, two published defs.
- `marketplace/MarketplaceInstallRequest` — a different party's key on a
different door, moved with the others so the consistency matrix stays one row
per state. Not a fold.

**Runtime behaviour is deliberately UNCHANGED**, and nothing in this repo starts
or stops being refused. Nothing parses an install body through these schemas on
the serving path — the door reads the raw body, and `PackageApiContracts` is a
declarative catalog entry rather than a parse. The accept set does not move
either: absent, `true` and `false` are accepted before and after, and a string
or `null` is refused before and after.

### Migration: FROM → TO

| FROM | TO |
| :--- | :--- |
| omitting the key and expecting an unconditional enable, because the schema said `default: true` | send `enableOnInstall: true` — the only spelling the door has ever read as "enable" |
| omitting it and expecting the package's current state to be left alone | change nothing; that is what the door already does, and now what is declared |
| sending `enableOnInstall: false` | unchanged in every respect |
| reading `PackageInstallRequestParsed.enableOnInstall` (or the `InstallPackageRequestParsed` / `MarketplaceInstallRequestParsed` copies) after parsing a body without the key | it now yields `undefined` instead of `true` — the third state, and the one the door acts on |
| reading the published JSON Schema's `default` keyword for this key | it is gone; the key is still `type: "boolean"` and still not `required` |

**Who is actually affected:** a client or SDK outside this repo that validates
its request through the published schema and sends the **parsed** object. It
materialised `enableOnInstall: true` from the declared default and sent it
explicitly — and an explicit `true` is a force-enable, so that caller silently
re-enables a package an operator deliberately disabled, on every upgrade, while
a caller sending the identical body without validating preserves the disable.
Identical request bodies, opposite behaviour, decided by whether the caller
validated before sending. A caller that never parsed its own request body is
unaffected in every direction.

The four moved published defaults are declared in
`DEFAULT_CHANGES_BY_MAJOR` (`packages/spec/scripts/lib/default-changes.ts`),
each with the consumer prescription above; `check:authorable-surface` prints
them in full on every build that accepts them.
4 changes: 2 additions & 2 deletions content/docs/references/api/package-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -495,7 +495,7 @@ Install package request
| :--- | :--- | :--- | :--- |
| **manifest** | `{ id: string; namespace?: string; defaultDatasource?: string; version: string; … }` | ✅ | Package manifest to install (AUTHORING stage: `objects` are glob patterns) |
| **settings** | `Record<string, any>` | optional | User-provided settings at install time |
| **enableOnInstall** | `boolean` | optional (default: `true`) | Whether to enable immediately after install — honoured at POST /api/v1/packages: the installed row's `enabled` is written from this key |
| **enableOnInstall** | `boolean` | optional | Whether to enable immediately after install — honoured at POST /api/v1/packages: `true` enables the installed row, `false` disables it, and ABSENT keeps the row's current lifecycle state (a fresh install lands enabled) |
| **overwrite** | `boolean` | optional | Overwrite an already-installed package id instead of answering 409 Conflict |
| **platformVersion** | `string` | optional | Current platform version for compatibility verification |
| **artifactRef** | `{ url: string; sha256: string; size: integer; format?: Enum<'tgz' \| 'zip'>; … }` | optional | Artifact reference for marketplace installation |
Expand Down Expand Up @@ -658,7 +658,7 @@ Install package request
| :--- | :--- | :--- | :--- |
| **manifest** | `{ id: string; namespace?: string; defaultDatasource?: string; version: string; … }` | ✅ | Package manifest to install (AUTHORING stage: `objects` are glob patterns) |
| **settings** | `Record<string, any>` | optional | User-provided settings at install time |
| **enableOnInstall** | `boolean` | optional (default: `true`) | Whether to enable immediately after install — honoured at POST /api/v1/packages: the installed row's `enabled` is written from this key |
| **enableOnInstall** | `boolean` | optional | Whether to enable immediately after install — honoured at POST /api/v1/packages: `true` enables the installed row, `false` disables it, and ABSENT keeps the row's current lifecycle state (a fresh install lands enabled) |
| **overwrite** | `boolean` | optional | Overwrite an already-installed package id instead of answering 409 Conflict |
| **platformVersion** | `string` | optional | Current platform version for compatibility verification |
| **artifactRef** | `{ url: string; sha256: string; size: integer; format?: Enum<'tgz' \| 'zip'>; … }` | optional | Artifact reference for marketplace installation |
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/api/protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1910,7 +1910,7 @@ Install package request
| :--- | :--- | :--- | :--- |
| **manifest** | `{ id: string; namespace?: string; defaultDatasource?: string; version: string; … }` | ✅ | Package manifest to install |
| **settings** | `Record<string, any>` | optional | User-provided settings at install time |
| **enableOnInstall** | `boolean` | optional (default: `true`) | Whether to enable immediately after install — restates the install-door request key, whose one authority is api/PackageInstallRequest; this protocol primitive honours it on the registry row: true enables, false disables, absent makes no lifecycle call |
| **enableOnInstall** | `boolean` | optional | Whether to enable immediately after install — restates the install-door request key, whose one authority is api/PackageInstallRequest; this protocol primitive honours it on the registry row: `true` enables, `false` disables, and ABSENT keeps the row's current lifecycle state (a fresh install lands enabled) |
| **platformVersion** | `string` | optional | Current platform version for compatibility verification |

### Nested Shape: `InstallPackageRequest.manifest`
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/kernel/package-registry.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ Install package request
| :--- | :--- | :--- | :--- |
| **manifest** | `{ id: string; namespace?: string; defaultDatasource?: string; version: string; … }` | ✅ | Package manifest to install |
| **settings** | `Record<string, any>` | optional | User-provided settings at install time |
| **enableOnInstall** | `boolean` | optional (default: `true`) | Whether to enable immediately after install — restates the install-door request key, whose one authority is api/PackageInstallRequest; this protocol primitive honours it on the registry row: true enables, false disables, absent makes no lifecycle call |
| **enableOnInstall** | `boolean` | optional | Whether to enable immediately after install — restates the install-door request key, whose one authority is api/PackageInstallRequest; this protocol primitive honours it on the registry row: `true` enables, `false` disables, and ABSENT keeps the row's current lifecycle state (a fresh install lands enabled) |
| **platformVersion** | `string` | optional | Current platform version for compatibility verification |

### Nested Shape: `InstallPackageRequest.manifest`
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/marketplace/marketplace.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ Install from marketplace request
| **version** | `string` | optional | Version to install |
| **licenseKey** | `string` | optional | License key for paid packages |
| **settings** | `Record<string, any>` | optional | User-provided settings at install time |
| **enableOnInstall** | `boolean` | optional (default: `true`) | Whether to enable immediately after install — the marketplace channel's own install option, not the platform install-door key (api/PackageInstallRequest) |
| **enableOnInstall** | `boolean` | optional | Whether to enable immediately after install — the marketplace channel's own install option, not the platform install-door key (api/PackageInstallRequest); `true` asks the channel to enable, `false` not to, and ABSENT leaves the package's current lifecycle state alone |
| **artifactRef** | `{ url: string; sha256: string; size: integer; format: Enum<'tgz' \| 'zip'>; … }` | optional | Artifact reference for direct installation |
| **tenantId** | `string` | optional | Tenant identifier |

Expand Down
2 changes: 0 additions & 2 deletions packages/spec/authorable-defaults/api.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,6 @@
"api/ImportValidationConfig:trimWhitespace = true",
"api/InitiateChunkedUploadRequest:chunkSize = 5242880",
"api/InitiateChunkedUploadRequest:scope = \"user\"",
"api/InstallPackageRequest:enableOnInstall = true",
"api/ListExportJobsRequest:limit = 20",
"api/ListFlowsRequest:limit = 50",
"api/ListImportJobsRequest:limit = 50",
Expand Down Expand Up @@ -129,7 +128,6 @@
"api/OpenApiGenerationConfig:version = \"3.0.3\"",
"api/OpenApiSpec:openapi = \"3.0.0\"",
"api/OpenApiSpec:servers = []",
"api/PackageInstallRequest:enableOnInstall = true",
"api/PackageRollbackRequest:rollbackCustomizations = true",
"api/PackageUpgradeRequest:createSnapshot = true",
"api/PackageUpgradeRequest:dryRun = false",
Expand Down
1 change: 0 additions & 1 deletion packages/spec/authorable-defaults/kernel.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,6 @@
"kernel/HotReloadConfig:preserveState = true",
"kernel/HotReloadConfig:shutdownTimeout = 30000",
"kernel/HotReloadConfig:stateStrategy = \"memory\"",
"kernel/InstallPackageRequest:enableOnInstall = true",
"kernel/InstalledPackage:enabled = true",
"kernel/InstalledPackage:status = \"installed\"",
"kernel/KernelContext:features = {}",
Expand Down
1 change: 0 additions & 1 deletion packages/spec/authorable-defaults/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
"category": "marketplace",
"defaults": [
"marketplace/ArtifactReference:format = \"tgz\"",
"marketplace/MarketplaceInstallRequest:enableOnInstall = true",
"marketplace/MarketplaceListing:packageType = \"app\"",
"marketplace/MarketplaceListing:pricing = \"free\"",
"marketplace/MarketplaceListing:status = \"draft\"",
Expand Down
101 changes: 101 additions & 0 deletions packages/spec/scripts/lib/default-changes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,79 @@ const AUTONUMBER_FORMAT_DEFAULT_REASON =
+ 'consumer reading `FieldParsed.autonumberFormat` still sees `undefined` when the '
+ 'author omitted it, and asks `resolveAutonumberFormat` what that means.';

/**
* Shared by the THREE platform-side `enableOnInstall` rows below. One edit to
* `PackageInstallRequestSchema` and one to `InstallPackageRequestSchema` move
* three published defaults, because the second schema is re-exported through
* `src/api/protocol.zod.ts` and therefore publishes under TWO def keys
* (`kernel/InstallPackageRequest` and `api/InstallPackageRequest`, byte-identical
* but for the `$id`) — the `CreateImportJobRequest` / `ImportRequest` shape
* above. The ratchet names keys, not schemas, so dropping any row leaves that
* def's default unauthorised and the gate red.
*/
const ENABLE_ON_INSTALL_PRESERVE_REASON =
'The declared default was WRONG about the shipped runtime, and this row corrects the '
+ 'declaration rather than the behaviour — the `api/ImportRequest:runAutomations` shape, '
+ 'spec follows runtime. `POST /api/v1/packages` was ruled onto three states in maintainer '
+ 'batch #157 item 5 letter C — 「缺省 = 保持,有旗 = 设置」 — and implements them in '
+ '`packages/runtime/src/domains/packages.ts`: `enableOnInstall: true` calls `enablePackage`, '
+ '`false` calls `disablePackage`, and an ABSENT key makes NO lifecycle call at all, so the '
+ "row keeps whatever state it already had. A FRESH id has no state to keep and lands enabled "
+ "— that is the registry's own new-row value, not a default this schema applies. The schema "
+ 'said something else, in both machine-readable and human-readable form: `.default(true)` '
+ "shipped in `@objectstack/spec`'s JSON Schema for all three defs, and the `describe` prose "
+ 'rendered into the published reference tables. '
+ 'NO deployed server behaviour moves here: nothing parses an install body through these '
+ 'schemas on the serving path — the door reads the raw body, and `PackageApiContracts` is a '
+ 'declarative catalog entry rather than a parse — so a request that omitted the key '
+ "preserved the row's state before this change and preserves it after. "
+ 'The consumer who WAS affected, and who is the reason this is a correction rather than a '
+ 'cosmetic edit, lives outside this repo: a client or SDK that validates its request through '
+ 'the published schema materialised `enableOnInstall: true` from the declared default and '
+ 'SENT it explicitly. Under letter C an explicit `true` is a FORCE-ENABLE, so that caller '
+ 'silently re-enables a package an operator deliberately disabled — on every upgrade — while '
+ 'a non-validating caller sending the identical body preserves the disable. Identical request '
+ 'bodies, opposite behaviour, decided by whether the caller validated before sending. '
+ 'To keep an unconditional enable on every install, WRITE it — `enableOnInstall: true` — '
+ 'which is the only spelling the door has ever read as "enable". To get "leave this '
+ "package's lifecycle state where it is\", omit the key, which is now what the published "
+ 'schema says absence means. `enableOnInstall: false` is unchanged in every respect. '
+ 'Reading a materialised `PackageInstallRequestParsed.enableOnInstall` (or the '
+ '`InstallPackageRequestParsed` copy) now yields `boolean | undefined` where it yielded '
+ '`boolean`; `undefined` is the third state, and it is the one the door acts on. '
+ 'No `semantic` migration entry accompanies this: these are REQUEST bodies, not stored '
+ 'metadata — no `sys_metadata` document carries the key, so `os migrate meta` has nothing to '
+ 'rewrite, the same reading as the two request-schema rows above. Maintainer ruling batch '
+ '#210 item 4 letter A, 2026-09-22, which explicitly refused the other direction (runtime '
+ 'back to default-on) because it re-enables a disabled package on re-install.';

const MARKETPLACE_ENABLE_ON_INSTALL_REASON =
'The 缺省 cell moved on all three `enableOnInstall` declarations together, and this is the '
+ 'third — the one that is NOT the platform install door\'s key. This request names a '
+ 'marketplace LISTING and its door is the control plane\'s '
+ '`POST /api/v1/marketplace/install`, which resolves the artefact and validates the licence '
+ 'before mapping what it holds into a platform install; so this key is what a caller asks '
+ 'the MARKETPLACE to request on its behalf, one translation upstream of the door key. It is '
+ 'held to the same three states by the consistency pin in '
+ '`src/api/package-install-one-authority.test.ts`, whose matrix is one row per state across '
+ 'all three declarations: `true` asks the channel to enable, `false` asks it not to, and '
+ 'ABSENT asks it to leave the package\'s lifecycle state alone (a fresh install lands '
+ 'enabled). Keeping `.default(true)` here alone would have re-materialised, for the '
+ 'marketplace channel, exactly the value the platform door stopped applying — and done it '
+ 'inside a control-plane contract no PR in this repo can see the other end of, which is the '
+ 'worst place for a caller-invented value to live. '
+ 'Nothing in this repo changes behaviour: a runtime mounts `/api/v1/marketplace/*` only as a '
+ 'read-only proxy to the configured control plane (`MarketplaceProxyPlugin`), so no install '
+ 'body is parsed through this schema here at all. The consumer affected is the control-plane '
+ 'caller who validates through the published schema: to keep asking the channel for an '
+ 'unconditional enable, write `enableOnInstall: true`; to leave the package\'s state alone, '
+ 'omit the key; `false` is unchanged. Reading '
+ '`MarketplaceInstallRequestParsed.enableOnInstall` now yields `boolean | undefined` where it '
+ 'yielded `boolean`. ⛔ This is a matrix that moved, NOT a fold — the two requests remain '
+ 'separately owned on separate release cadences, and the pin still asserts that neither can '
+ 'be sent where the other is expected. Maintainer ruling batch #210 item 4 letter A, '
+ '2026-09-22.';

export const DEFAULT_CHANGES_BY_MAJOR: Readonly<Record<number, readonly DeclaredDefaultChange[]>> = {
17: [
{
Expand Down Expand Up @@ -397,6 +470,34 @@ export const DEFAULT_CHANGES_BY_MAJOR: Readonly<Record<number, readonly Declared
+ '`required` that lists defaulted keys is this repo\'s existing output-mode convention, not '
+ 'a new one.',
},
{
key: 'api/PackageInstallRequest:enableOnInstall',
from: 'true',
to: '(none)',
reason: ENABLE_ON_INSTALL_PRESERVE_REASON,
},
{
// `InstallPackageRequestSchema` (`src/kernel/package-registry.zod.ts`) is
// re-exported through `src/api/protocol.zod.ts`, so ONE declaration
// publishes under two def keys. Both rows are required; dropping either
// leaves that def's default unauthorised and the gate red.
key: 'kernel/InstallPackageRequest:enableOnInstall',
from: 'true',
to: '(none)',
reason: ENABLE_ON_INSTALL_PRESERVE_REASON,
},
{
key: 'api/InstallPackageRequest:enableOnInstall',
from: 'true',
to: '(none)',
reason: ENABLE_ON_INSTALL_PRESERVE_REASON,
},
{
key: 'marketplace/MarketplaceInstallRequest:enableOnInstall',
from: 'true',
to: '(none)',
reason: MARKETPLACE_ENABLE_ON_INSTALL_REASON,
},
{
key: 'system/TracingConfig:sampling',
from: '(none)',
Expand Down
10 changes: 8 additions & 2 deletions packages/spec/src/api/package-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ describe('the /packages doors declare the query parameters they execute (#17667)
// ==========================================

describe('PackageInstallRequestSchema', () => {
it('should accept a minimal install request', () => {
it('should accept a minimal install request — and leave an absent `enableOnInstall` UNDEFINED', () => {
const result = PackageInstallRequestSchema.parse({
manifest: {
id: 'com.acme.crm',
Expand All @@ -162,7 +162,13 @@ describe('PackageInstallRequestSchema', () => {
type: 'plugin',
},
});
expect(result.enableOnInstall).toBe(true);
// ⭐ [#19273] This assertion read `toBe(true)` while the declaration spelled
// `.default(true)`, and it was the lit control proving absence really was
// erased at parse time. The declaration is `optional()` now — 「缺省 = 保持,
// 有旗 = 设置」 — so the absence survives the parse and the door's three-way
// read has a third state to see. The full matrix, with the flip-trigger it
// was registered under, is in `package-install-one-authority.test.ts`.
expect(result.enableOnInstall).toBeUndefined();
});

it('should accept full install request with platform version', () => {
Expand Down
Loading
Loading