Skip to content

Commit fb59fb5

Browse files
os-justinclaude
andauthored
fix(spec): enableOnInstall becomes optional() so absence survives the parse (#19690)
Fixes #19273 Clause-②: yes Ruling batch #210 item 4 · letter A · maintainer 「210 同意」 (`5770455384`, 2026-09-22T02:41Z). The direction was ruled, not chosen here. ## The defect `packages/runtime/src/domains/packages.ts:1095` has honoured 「缺省 = 保持,有旗 = 设置」 since PR #19291 landed: ``` const requestedEnabled = wrapped ? body?.enableOnInstall : undefined; ``` `true` calls `enablePackage`, `false` calls `disablePackage`, and an **absent** key makes no lifecycle call at all — so a package an operator disabled stays disabled across an upgrade. Verified unchanged on this branch; the runtime is not touched by this PR. 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 — a declared default the runtime deliberately stops applying, on a contract this repo does not own both ends of. ## What changed All three declarations now spell `z.boolean().optional()`, with the semantics on the field in **both** the `describe` and the docblock — absent = keep the row's current lifecycle state; explicit `true` / `false` unchanged; a fresh install lands enabled: | declaration | file | | :--- | :--- | | `api/PackageInstallRequest` — the authority | `packages/spec/src/api/package-api.zod.ts` | | `kernel/InstallPackageRequest` — the copy | `packages/spec/src/kernel/package-registry.zod.ts` | | `marketplace/MarketplaceInstallRequest` — a different party's key | `packages/spec/src/marketplace/marketplace.zod.ts` | ### The executable criterion, both directions Read off the **built** package (`packages/spec/dist`), not `src/`, at head `f5b094a96`: ``` api/PackageInstallRequest | absent => undefined (key in parse output: false) | true => true | false => false kernel|api/InstallPackageReq | absent => undefined (key in parse output: false) | true => true | false => false marketplace/MarketplaceInst. | absent => undefined (key in parse output: false) | true => true | false => false ``` The `true` and `false` arms are **re-read after the change on all three declarations, never assumed** — the card's control in the other direction: a fix that makes absence visible by making the key mean nothing would be worse than the defect. The two refusal cells are unmoved: a string `'false'` and `null` are still refused by name. ### PR #19130's consistency pin — flipped with its trigger registered, ⛔ not patched green `packages/spec/src/api/package-install-one-authority.test.ts` asserted `true` on every 缺省 reading. Only the 缺省 cell moves; the `false`, `true`, string and `null` cells are untouched, and the authority/copy agreement is still judged cell by cell. The **flip-trigger phrase registered in the test** is: ``` 缺省 = 保持,有旗 = 设置 ``` It is a named `FLIP_TRIGGER` const with its own docblock explaining that while the declarations spelled `.default(true)` the 缺省 reading was living on borrowed time — the phrase says absence is a state the door ACTS ON, and a `.default()` resolves absence at parse time so that state cannot survive to the published surface. It is quoted into the 缺省 cell's name so a test run prints it, and into the two flipped assertion titles. The file's header docblock carries a section stating that the cell FLIPPED, that this was expected on the day the pin landed, and that reading the red as "the pin needs updating" and writing the new value in silently is the failure the const exists to prevent. ## ⚠️ DECLARED file-surface expansion, with the mechanism that forces it Beyond the three declarations, their tests and the changeset, four more paths are in this diff. Each is mechanically forced; none is a discretionary edit. 1. **`packages/spec/scripts/lib/default-changes.ts`** (+101). `check:authorable-surface` **refuses the build** on an undeclared move of an authorable key's default, and prints the copy-pasteable block naming each key and both fingerprints. The build exits 1 until the entries exist. Four entries are required, not three: `InstallPackageRequestSchema` is re-exported through `src/api/protocol.zod.ts`, so one declaration publishes under **two** def keys (`kernel/InstallPackageRequest` and `api/InstallPackageRequest`, byte-identical but for the `$id`) — the `CreateImportJobRequest` / `ImportRequest` shape already in that table. The ratchet names keys, not schemas, so dropping either row leaves that def unauthorised and the gate red. 2. **`packages/spec/authorable-defaults/{api,kernel,marketplace}.json`** (-4 lines total). Generated. `pnpm --filter @objectstack/spec build` writes them; exactly the four `… = true` entries are removed and nothing else moves. 3. **`content/docs/references/{api/package-api,api/protocol,kernel/package-registry,marketplace/marketplace}.mdx`** (+5 / -5). Generated by `gen:docs`, run via `check:generated --fix`, which regenerated **only** the one artefact it proved stale. The four projected rows lose their `(default: true)` cell and gain the three-state prose. No other row moves. `authorable-surface/*.json` and `authorable-surface.base.json` are **not** in this diff: the keys stay authorable, and the base anchor is only ever written by the explicit `gen:authorable-surface-base`, never by a build. ## Verification Reconciliation line, verbatim, derived and run at head `f5b094a96`: ``` Run reconciliation — 108 derived, 108 run, 0 NOT-MEASURED, 0 UNRUN. ``` `✓ dispatch-gates --ran: 108 derived famil(ies) accounted for — 108 run, 0 NOT-MEASURED (a DERIVED zero — all 108 recorded an exit code and none of them is 3).` Every command's exit code was captured **before any pipe**; no command answered `exit 3`, so nothing in the derived set measured nothing. Everything below ran in the foreground; each heavy run went through `scripts/pm/os-verify-lock.sh` with `OS_VERIFY_LOCK_SLOT=issue-19273`, and each verdict is that wrapper's own `VERDICT command-exit` line, never a bare shell status. | run | verdict | | :--- | :--- | | `pnpm --filter @objectstack/spec test` | `VERDICT command-exit 0` — 512 files, 14955 passed, 1 todo | | `pnpm --filter @objectstack/rest test` | `VERDICT command-exit 0` — 194 files, 3265 passed, 1 skipped | | `pnpm --filter @objectstack/runtime test` | `VERDICT command-exit 0` — 272 files, 3799 passed, 1 skipped | | `pnpm exec turbo run typecheck` | `VERDICT command-exit 0` — 143 tasks successful | | `pnpm build` | `VERDICT command-exit 0` — 73 tasks successful | | `pnpm --filter @objectstack/spec check:generated` | `VERDICT command-exit 0` — all 15 generated artifacts up to date | | `pnpm lint` | **exit 0**, run WHOLE (`eslint . --no-inline-config`), not narrowed — so no narrowing evidence is owed | `origin/main` was merged and the build state refreshed before the final push; the generated re-check and the union above were both taken **after** that merge, on the head this PR carries. ## ⚠️ The open reading the ruling hands the dev, reported as a zero WITH its radius **Zero consumers found that parse an install request through the published schema.** The instrument's reachable radius, stated because a zero without one is not a reading: - **Reached:** `objectstack-ai/objectstack` at `f5b094a96` — `packages/**`, `apps/**`, `examples/**`, `scripts/**`, `content/**`, `docs/**`, `skills/**`, excluding `node_modules`. And `objectstack-ai/objectui` at `0cf2d66`, the only sibling checkout in this container, excluding `node_modules`. - **objectui reading, with a positive control:** `enableOnInstall` — **0** hits. `PackageInstall` (the schema name) — **0** hits. Control that proves the instrument reads that tree: `packages.install` / `/api/v1/packages` — **52** hits. So objectui calls the install route and never names the key, never parses through the published schema. - **⛔ NOT reached, and so NOT established in either direction:** `objectstack-ai/cloud` (no checkout exists in this container) and any third-party consumer of the published `@objectstack/spec`. The changeset body and all four `DEFAULT_CHANGES_BY_MAJOR` reasons are written for exactly that unreachable consumer — the caller who validates before sending — because they are the only channel that reaches them. ## Changeset grade **`minor`** for `@objectstack/spec`, ⛔ not the `patch` ruling #157 item 5 wrote. Ruling #210 item 4 overrode it and the override is measured: `check-changeset-no-major.mjs`'s `judgeLevel` verdict `enforce` refuses a clause-②-carrying diff whose moved packages are graded `patch` with none at `minor` or above. Judged against `packages/spec/package.json`'s `files[]` after a build as usual — `dist/` and `json-schema/` both ship, and both move here — so the floor and the measurement agree. `node scripts/check-changeset-no-major.mjs --base origin/main` and `node scripts/check-adr-0087-registration.mjs --base origin/main` both exit 0 on this head. ## ⛔ Fences honoured - **Not the engine half.** `packages/runtime/src/domains/packages.ts:1095` verified to still read `const requestedEnabled = wrapped ? body?.enableOnInstall : undefined;`. The runtime is not in this diff. - **The door does not sniff the raw body around the schema.** Nothing in this PR adds a parse on the serving path. - **No label writes of any kind**, and **no new issues filed** — findings go back to the dispatching seat. ## Acceptance notes None. Nothing outside this card's scope was surfaced that meets the filing bar. ## 维护者速读(草稿) **改了什么** — 三处 `enableOnInstall` 声明从「默认 true」改成「可缺省」。安装接口的实际行为半年前就被裁决改成了「不写这个键 = 保持这个包当前的启用/停用状态」,但对外发布的协议声明一直还写着「不写 = 启用」。这次让声明跟上已经生效的行为。 **为什么改** — 声明与实际不一致,受伤的是仓库外面的调用方。一个会先按协议校验请求再发送的客户端,会从「默认 true」里自动补出一个 `enableOnInstall: true` 发过来;而这个显式的 true 的含义是「强制启用」。结果就是:同样一个请求体,先校验的那一方会在每次升级时把运维手动停用的包悄悄重新打开,不校验的那一方则正常保持停用。两边行为相反,差别只在于有没有先校验。 **风险与代价(含回滚)** — 本仓内运行时行为零变化:安装接口读的是原始请求体,没有任何服务路径经过这几个 schema 解析,接受集也一个字节没动(缺省、true、false 照收,字符串和 null 照拒)。真正受影响的是仓外那位会校验的调用方,处方已写进 changeset 和四条默认值台账记录里:想要每次都强制启用,就把 `enableOnInstall: true` 显式写出来。回滚代价低——三处声明改回 `.default(true)`、撤掉四条台账记录、重跑生成即可,但回滚会把「声明 ≠ 实际」这个问题原样退回去。 **席位意见** — **你要做的** — 确认一件事就够了:仓外(尤其 cloud 侧和第三方)有没有会先按发布的 schema 校验安装请求、再把校验后的对象发出去的调用方。本次探测半径只到本仓和 objectui 两棵树,读数为零且带正控(objectui 会调安装接口但从不提这个键);cloud 在本容器里没有检出,所以那边是**未测**,不是「没有」。若那边确实有这样的调用方,它就是这次改动唯一会碰到的对象,而 changeset 里的处方正是写给它的。 --- _Generated by [Claude Code](https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a251aaa commit fb59fb5

16 files changed

Lines changed: 362 additions & 46 deletions
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
fix(spec): `enableOnInstall` becomes `optional()` so absence survives the parse
6+
7+
The install door was ruled onto three states — 「缺省 = 保持,有旗 = 设置」 — and
8+
implements them: `enableOnInstall: true` enables the row, `false` disables it,
9+
and an **absent** key makes no lifecycle call at all, so a package an operator
10+
disabled stays disabled across an upgrade or a re-install. A fresh id has no
11+
state to keep and lands enabled.
12+
13+
The published declarations said something else. `z.boolean().default(true)`
14+
resolves absence **at parse time**, so a request that omitted the key came out
15+
of the parse byte-identical to one that set `true` — the third state did not
16+
exist on the published surface, while the door went on acting on it. That is a
17+
declared default the runtime deliberately stops applying, on a contract this
18+
repo does not own both ends of.
19+
20+
All three declarations now spell `z.boolean().optional()`, with the semantics
21+
written on the field in the `describe` and the docblock:
22+
23+
- `api/PackageInstallRequest` (`src/api/package-api.zod.ts`) — the authority.
24+
- `kernel/InstallPackageRequest` (`src/kernel/package-registry.zod.ts`) — the
25+
copy restated on the in-process protocol primitive. It is re-exported through
26+
`src/api/protocol.zod.ts`, so it publishes under `api/InstallPackageRequest`
27+
too: one declaration, two published defs.
28+
- `marketplace/MarketplaceInstallRequest` — a different party's key on a
29+
different door, moved with the others so the consistency matrix stays one row
30+
per state. Not a fold.
31+
32+
**Runtime behaviour is deliberately UNCHANGED**, and nothing in this repo starts
33+
or stops being refused. Nothing parses an install body through these schemas on
34+
the serving path — the door reads the raw body, and `PackageApiContracts` is a
35+
declarative catalog entry rather than a parse. The accept set does not move
36+
either: absent, `true` and `false` are accepted before and after, and a string
37+
or `null` is refused before and after.
38+
39+
### Migration: FROM → TO
40+
41+
| FROM | TO |
42+
| :--- | :--- |
43+
| 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" |
44+
| 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 |
45+
| sending `enableOnInstall: false` | unchanged in every respect |
46+
| 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 |
47+
| reading the published JSON Schema's `default` keyword for this key | it is gone; the key is still `type: "boolean"` and still not `required` |
48+
49+
**Who is actually affected:** a client or SDK outside this repo that validates
50+
its request through the published schema and sends the **parsed** object. It
51+
materialised `enableOnInstall: true` from the declared default and sent it
52+
explicitly — and an explicit `true` is a force-enable, so that caller silently
53+
re-enables a package an operator deliberately disabled, on every upgrade, while
54+
a caller sending the identical body without validating preserves the disable.
55+
Identical request bodies, opposite behaviour, decided by whether the caller
56+
validated before sending. A caller that never parsed its own request body is
57+
unaffected in every direction.
58+
59+
The four moved published defaults are declared in
60+
`DEFAULT_CHANGES_BY_MAJOR` (`packages/spec/scripts/lib/default-changes.ts`),
61+
each with the consumer prescription above; `check:authorable-surface` prints
62+
them in full on every build that accepts them.

‎content/docs/references/api/package-api.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -495,7 +495,7 @@ Install package request
495495
| :--- | :--- | :--- | :--- |
496496
| **manifest** | `{ id: string; namespace?: string; defaultDatasource?: string; version: string; … }` | ✅ | Package manifest to install (AUTHORING stage: `objects` are glob patterns) |
497497
| **settings** | `Record<string, any>` | optional | User-provided settings at install time |
498-
| **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 |
498+
| **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) |
499499
| **overwrite** | `boolean` | optional | Overwrite an already-installed package id instead of answering 409 Conflict |
500500
| **platformVersion** | `string` | optional | Current platform version for compatibility verification |
501501
| **artifactRef** | `{ url: string; sha256: string; size: integer; format?: Enum<'tgz' \| 'zip'>; … }` | optional | Artifact reference for marketplace installation |
@@ -658,7 +658,7 @@ Install package request
658658
| :--- | :--- | :--- | :--- |
659659
| **manifest** | `{ id: string; namespace?: string; defaultDatasource?: string; version: string; … }` | ✅ | Package manifest to install (AUTHORING stage: `objects` are glob patterns) |
660660
| **settings** | `Record<string, any>` | optional | User-provided settings at install time |
661-
| **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 |
661+
| **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) |
662662
| **overwrite** | `boolean` | optional | Overwrite an already-installed package id instead of answering 409 Conflict |
663663
| **platformVersion** | `string` | optional | Current platform version for compatibility verification |
664664
| **artifactRef** | `{ url: string; sha256: string; size: integer; format?: Enum<'tgz' \| 'zip'>; … }` | optional | Artifact reference for marketplace installation |

‎content/docs/references/api/protocol.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1910,7 +1910,7 @@ Install package request
19101910
| :--- | :--- | :--- | :--- |
19111911
| **manifest** | `{ id: string; namespace?: string; defaultDatasource?: string; version: string; … }` | ✅ | Package manifest to install |
19121912
| **settings** | `Record<string, any>` | optional | User-provided settings at install time |
1913-
| **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 |
1913+
| **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) |
19141914
| **platformVersion** | `string` | optional | Current platform version for compatibility verification |
19151915

19161916
### Nested Shape: `InstallPackageRequest.manifest`

‎content/docs/references/kernel/package-registry.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ Install package request
184184
| :--- | :--- | :--- | :--- |
185185
| **manifest** | `{ id: string; namespace?: string; defaultDatasource?: string; version: string; … }` | ✅ | Package manifest to install |
186186
| **settings** | `Record<string, any>` | optional | User-provided settings at install time |
187-
| **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 |
187+
| **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) |
188188
| **platformVersion** | `string` | optional | Current platform version for compatibility verification |
189189

190190
### Nested Shape: `InstallPackageRequest.manifest`

‎content/docs/references/marketplace/marketplace.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ Install from marketplace request
145145
| **version** | `string` | optional | Version to install |
146146
| **licenseKey** | `string` | optional | License key for paid packages |
147147
| **settings** | `Record<string, any>` | optional | User-provided settings at install time |
148-
| **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) |
148+
| **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 |
149149
| **artifactRef** | `{ url: string; sha256: string; size: integer; format: Enum<'tgz' \| 'zip'>; … }` | optional | Artifact reference for direct installation |
150150
| **tenantId** | `string` | optional | Tenant identifier |
151151

‎packages/spec/authorable-defaults/api.json‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,6 @@
8787
"api/ImportValidationConfig:trimWhitespace = true",
8888
"api/InitiateChunkedUploadRequest:chunkSize = 5242880",
8989
"api/InitiateChunkedUploadRequest:scope = \"user\"",
90-
"api/InstallPackageRequest:enableOnInstall = true",
9190
"api/ListExportJobsRequest:limit = 20",
9291
"api/ListFlowsRequest:limit = 50",
9392
"api/ListImportJobsRequest:limit = 50",
@@ -129,7 +128,6 @@
129128
"api/OpenApiGenerationConfig:version = \"3.0.3\"",
130129
"api/OpenApiSpec:openapi = \"3.0.0\"",
131130
"api/OpenApiSpec:servers = []",
132-
"api/PackageInstallRequest:enableOnInstall = true",
133131
"api/PackageRollbackRequest:rollbackCustomizations = true",
134132
"api/PackageUpgradeRequest:createSnapshot = true",
135133
"api/PackageUpgradeRequest:dryRun = false",

‎packages/spec/authorable-defaults/kernel.json‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,6 @@
4646
"kernel/HotReloadConfig:preserveState = true",
4747
"kernel/HotReloadConfig:shutdownTimeout = 30000",
4848
"kernel/HotReloadConfig:stateStrategy = \"memory\"",
49-
"kernel/InstallPackageRequest:enableOnInstall = true",
5049
"kernel/InstalledPackage:enabled = true",
5150
"kernel/InstalledPackage:status = \"installed\"",
5251
"kernel/KernelContext:features = {}",

‎packages/spec/authorable-defaults/marketplace.json‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
"category": "marketplace",
44
"defaults": [
55
"marketplace/ArtifactReference:format = \"tgz\"",
6-
"marketplace/MarketplaceInstallRequest:enableOnInstall = true",
76
"marketplace/MarketplaceListing:packageType = \"app\"",
87
"marketplace/MarketplaceListing:pricing = \"free\"",
98
"marketplace/MarketplaceListing:status = \"draft\"",

‎packages/spec/scripts/lib/default-changes.ts‎

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,79 @@ const AUTONUMBER_FORMAT_DEFAULT_REASON =
141141
+ 'consumer reading `FieldParsed.autonumberFormat` still sees `undefined` when the '
142142
+ 'author omitted it, and asks `resolveAutonumberFormat` what that means.';
143143

144+
/**
145+
* Shared by the THREE platform-side `enableOnInstall` rows below. One edit to
146+
* `PackageInstallRequestSchema` and one to `InstallPackageRequestSchema` move
147+
* three published defaults, because the second schema is re-exported through
148+
* `src/api/protocol.zod.ts` and therefore publishes under TWO def keys
149+
* (`kernel/InstallPackageRequest` and `api/InstallPackageRequest`, byte-identical
150+
* but for the `$id`) — the `CreateImportJobRequest` / `ImportRequest` shape
151+
* above. The ratchet names keys, not schemas, so dropping any row leaves that
152+
* def's default unauthorised and the gate red.
153+
*/
154+
const ENABLE_ON_INSTALL_PRESERVE_REASON =
155+
'The declared default was WRONG about the shipped runtime, and this row corrects the '
156+
+ 'declaration rather than the behaviour — the `api/ImportRequest:runAutomations` shape, '
157+
+ 'spec follows runtime. `POST /api/v1/packages` was ruled onto three states in maintainer '
158+
+ 'batch #157 item 5 letter C — 「缺省 = 保持,有旗 = 设置」 — and implements them in '
159+
+ '`packages/runtime/src/domains/packages.ts`: `enableOnInstall: true` calls `enablePackage`, '
160+
+ '`false` calls `disablePackage`, and an ABSENT key makes NO lifecycle call at all, so the '
161+
+ "row keeps whatever state it already had. A FRESH id has no state to keep and lands enabled "
162+
+ "— that is the registry's own new-row value, not a default this schema applies. The schema "
163+
+ 'said something else, in both machine-readable and human-readable form: `.default(true)` '
164+
+ "shipped in `@objectstack/spec`'s JSON Schema for all three defs, and the `describe` prose "
165+
+ 'rendered into the published reference tables. '
166+
+ 'NO deployed server behaviour moves here: nothing parses an install body through these '
167+
+ 'schemas on the serving path — the door reads the raw body, and `PackageApiContracts` is a '
168+
+ 'declarative catalog entry rather than a parse — so a request that omitted the key '
169+
+ "preserved the row's state before this change and preserves it after. "
170+
+ 'The consumer who WAS affected, and who is the reason this is a correction rather than a '
171+
+ 'cosmetic edit, lives outside this repo: a client or SDK that validates its request through '
172+
+ 'the published schema materialised `enableOnInstall: true` from the declared default and '
173+
+ 'SENT it explicitly. Under letter C an explicit `true` is a FORCE-ENABLE, so that caller '
174+
+ 'silently re-enables a package an operator deliberately disabled — on every upgrade — while '
175+
+ 'a non-validating caller sending the identical body preserves the disable. Identical request '
176+
+ 'bodies, opposite behaviour, decided by whether the caller validated before sending. '
177+
+ 'To keep an unconditional enable on every install, WRITE it — `enableOnInstall: true` — '
178+
+ 'which is the only spelling the door has ever read as "enable". To get "leave this '
179+
+ "package's lifecycle state where it is\", omit the key, which is now what the published "
180+
+ 'schema says absence means. `enableOnInstall: false` is unchanged in every respect. '
181+
+ 'Reading a materialised `PackageInstallRequestParsed.enableOnInstall` (or the '
182+
+ '`InstallPackageRequestParsed` copy) now yields `boolean | undefined` where it yielded '
183+
+ '`boolean`; `undefined` is the third state, and it is the one the door acts on. '
184+
+ 'No `semantic` migration entry accompanies this: these are REQUEST bodies, not stored '
185+
+ 'metadata — no `sys_metadata` document carries the key, so `os migrate meta` has nothing to '
186+
+ 'rewrite, the same reading as the two request-schema rows above. Maintainer ruling batch '
187+
+ '#210 item 4 letter A, 2026-09-22, which explicitly refused the other direction (runtime '
188+
+ 'back to default-on) because it re-enables a disabled package on re-install.';
189+
190+
const MARKETPLACE_ENABLE_ON_INSTALL_REASON =
191+
'The 缺省 cell moved on all three `enableOnInstall` declarations together, and this is the '
192+
+ 'third — the one that is NOT the platform install door\'s key. This request names a '
193+
+ 'marketplace LISTING and its door is the control plane\'s '
194+
+ '`POST /api/v1/marketplace/install`, which resolves the artefact and validates the licence '
195+
+ 'before mapping what it holds into a platform install; so this key is what a caller asks '
196+
+ 'the MARKETPLACE to request on its behalf, one translation upstream of the door key. It is '
197+
+ 'held to the same three states by the consistency pin in '
198+
+ '`src/api/package-install-one-authority.test.ts`, whose matrix is one row per state across '
199+
+ 'all three declarations: `true` asks the channel to enable, `false` asks it not to, and '
200+
+ 'ABSENT asks it to leave the package\'s lifecycle state alone (a fresh install lands '
201+
+ 'enabled). Keeping `.default(true)` here alone would have re-materialised, for the '
202+
+ 'marketplace channel, exactly the value the platform door stopped applying — and done it '
203+
+ 'inside a control-plane contract no PR in this repo can see the other end of, which is the '
204+
+ 'worst place for a caller-invented value to live. '
205+
+ 'Nothing in this repo changes behaviour: a runtime mounts `/api/v1/marketplace/*` only as a '
206+
+ 'read-only proxy to the configured control plane (`MarketplaceProxyPlugin`), so no install '
207+
+ 'body is parsed through this schema here at all. The consumer affected is the control-plane '
208+
+ 'caller who validates through the published schema: to keep asking the channel for an '
209+
+ 'unconditional enable, write `enableOnInstall: true`; to leave the package\'s state alone, '
210+
+ 'omit the key; `false` is unchanged. Reading '
211+
+ '`MarketplaceInstallRequestParsed.enableOnInstall` now yields `boolean | undefined` where it '
212+
+ 'yielded `boolean`. ⛔ This is a matrix that moved, NOT a fold — the two requests remain '
213+
+ 'separately owned on separate release cadences, and the pin still asserts that neither can '
214+
+ 'be sent where the other is expected. Maintainer ruling batch #210 item 4 letter A, '
215+
+ '2026-09-22.';
216+
144217
export const DEFAULT_CHANGES_BY_MAJOR: Readonly<Record<number, readonly DeclaredDefaultChange[]>> = {
145218
17: [
146219
{
@@ -397,6 +470,34 @@ export const DEFAULT_CHANGES_BY_MAJOR: Readonly<Record<number, readonly Declared
397470
+ '`required` that lists defaulted keys is this repo\'s existing output-mode convention, not '
398471
+ 'a new one.',
399472
},
473+
{
474+
key: 'api/PackageInstallRequest:enableOnInstall',
475+
from: 'true',
476+
to: '(none)',
477+
reason: ENABLE_ON_INSTALL_PRESERVE_REASON,
478+
},
479+
{
480+
// `InstallPackageRequestSchema` (`src/kernel/package-registry.zod.ts`) is
481+
// re-exported through `src/api/protocol.zod.ts`, so ONE declaration
482+
// publishes under two def keys. Both rows are required; dropping either
483+
// leaves that def's default unauthorised and the gate red.
484+
key: 'kernel/InstallPackageRequest:enableOnInstall',
485+
from: 'true',
486+
to: '(none)',
487+
reason: ENABLE_ON_INSTALL_PRESERVE_REASON,
488+
},
489+
{
490+
key: 'api/InstallPackageRequest:enableOnInstall',
491+
from: 'true',
492+
to: '(none)',
493+
reason: ENABLE_ON_INSTALL_PRESERVE_REASON,
494+
},
495+
{
496+
key: 'marketplace/MarketplaceInstallRequest:enableOnInstall',
497+
from: 'true',
498+
to: '(none)',
499+
reason: MARKETPLACE_ENABLE_ON_INSTALL_REASON,
500+
},
400501
{
401502
key: 'system/TracingConfig:sampling',
402503
from: '(none)',

‎packages/spec/src/api/package-api.test.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ describe('the /packages doors declare the query parameters they execute (#17667)
153153
// ==========================================
154154

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

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

0 commit comments

Comments
 (0)