You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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>
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
|**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)|
499
499
|**overwrite**|`boolean`| optional | Overwrite an already-installed package id instead of answering 409 Conflict |
500
500
|**platformVersion**|`string`| optional | Current platform version for compatibility verification |
|**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)|
662
662
|**overwrite**|`boolean`| optional | Overwrite an already-installed package id instead of answering 409 Conflict |
663
663
|**platformVersion**|`string`| optional | Current platform version for compatibility verification |
|**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)|
1914
1914
|**platformVersion**|`string`| optional | Current platform version for compatibility verification |
|**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)|
188
188
|**platformVersion**|`string`| optional | Current platform version for compatibility verification |
Copy file name to clipboardExpand all lines: content/docs/references/marketplace/marketplace.mdx
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -145,7 +145,7 @@ Install from marketplace request
145
145
|**version**|`string`| optional | Version to install |
146
146
|**licenseKey**|`string`| optional | License key for paid packages |
147
147
|**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|
0 commit comments