diff --git a/.changeset/enableoninstall-optional-preserve.md b/.changeset/enableoninstall-optional-preserve.md new file mode 100644 index 00000000000..e25b69e246f --- /dev/null +++ b/.changeset/enableoninstall-optional-preserve.md @@ -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. diff --git a/content/docs/references/api/package-api.mdx b/content/docs/references/api/package-api.mdx index 2c9e97b8cd8..dedc1f2a6ce 100644 --- a/content/docs/references/api/package-api.mdx +++ b/content/docs/references/api/package-api.mdx @@ -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` | 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 | @@ -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` | 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 | diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index ef17f9aa1a1..04a73e91547 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1910,7 +1910,7 @@ Install package request | :--- | :--- | :--- | :--- | | **manifest** | `{ id: string; namespace?: string; defaultDatasource?: string; version: string; … }` | ✅ | Package manifest to install | | **settings** | `Record` | 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` diff --git a/content/docs/references/kernel/package-registry.mdx b/content/docs/references/kernel/package-registry.mdx index 4a90ffa4274..d9638df2145 100644 --- a/content/docs/references/kernel/package-registry.mdx +++ b/content/docs/references/kernel/package-registry.mdx @@ -184,7 +184,7 @@ Install package request | :--- | :--- | :--- | :--- | | **manifest** | `{ id: string; namespace?: string; defaultDatasource?: string; version: string; … }` | ✅ | Package manifest to install | | **settings** | `Record` | 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` diff --git a/content/docs/references/marketplace/marketplace.mdx b/content/docs/references/marketplace/marketplace.mdx index 4bfecc61a83..913d9c2cc25 100644 --- a/content/docs/references/marketplace/marketplace.mdx +++ b/content/docs/references/marketplace/marketplace.mdx @@ -145,7 +145,7 @@ Install from marketplace request | **version** | `string` | optional | Version to install | | **licenseKey** | `string` | optional | License key for paid packages | | **settings** | `Record` | 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 | diff --git a/packages/spec/authorable-defaults/api.json b/packages/spec/authorable-defaults/api.json index a0330e85e21..b0b39dc1982 100644 --- a/packages/spec/authorable-defaults/api.json +++ b/packages/spec/authorable-defaults/api.json @@ -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", @@ -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", diff --git a/packages/spec/authorable-defaults/kernel.json b/packages/spec/authorable-defaults/kernel.json index e4997e515e6..c06defe52a9 100644 --- a/packages/spec/authorable-defaults/kernel.json +++ b/packages/spec/authorable-defaults/kernel.json @@ -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 = {}", diff --git a/packages/spec/authorable-defaults/marketplace.json b/packages/spec/authorable-defaults/marketplace.json index 5c9679f4632..fc991f3ed77 100644 --- a/packages/spec/authorable-defaults/marketplace.json +++ b/packages/spec/authorable-defaults/marketplace.json @@ -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\"", diff --git a/packages/spec/scripts/lib/default-changes.ts b/packages/spec/scripts/lib/default-changes.ts index fa3dbead7ed..7fbd0dd9773 100644 --- a/packages/spec/scripts/lib/default-changes.ts +++ b/packages/spec/scripts/lib/default-changes.ts @@ -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> = { 17: [ { @@ -397,6 +470,34 @@ export const DEFAULT_CHANGES_BY_MAJOR: Readonly { - 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', @@ -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', () => { diff --git a/packages/spec/src/api/package-api.zod.ts b/packages/spec/src/api/package-api.zod.ts index 7204ae4f233..517c704b3a1 100644 --- a/packages/spec/src/api/package-api.zod.ts +++ b/packages/spec/src/api/package-api.zod.ts @@ -360,23 +360,48 @@ export const PackageInstallRequestSchema = lazySchema(() => z.object({ /** * Whether to enable the package immediately after install. * + * ## ⭐ THREE STATES, and absence is one of them — that is why it is + * `optional()` and NOT `.default(true)` + * + * - `true` — the row is ENABLED after this install, existing or fresh. + * - `false` — the row is DISABLED after this install: present-but-not-active, + * and the disable survives a restart. + * - ABSENT — the row KEEPS ITS CURRENT LIFECYCLE STATE. No lifecycle call is + * made 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, which is the registry's own new-row value, ⛔ not a default + * this declaration applies. + * + * ⭐ 「缺省 = 保持,有旗 = 设置」 — ruled in maintainer batch #157 item 5 + * letter C and implemented at the door (`packages/runtime/src/domains/packages.ts`), + * which reads the raw body and makes NO lifecycle call when the key is + * absent. The declaration followed in batch #210 item 4 letter A. + * + * ⛔ `.default(true)` is what this key may never go back to, and the reason + * is mechanical rather than stylistic: a default RESOLVES absence at parse + * time, so a parsed request that omitted the key becomes byte-identical to + * one that set `true`, and the third state stops existing on the published + * surface while the door still honours it — 「declared ≠ enforced」 on a + * contract this repo does not own both ends of. + * + * ⛔ Nor may the key be made to MEAN nothing in the name of making absence + * visible: the `true` and `false` arms are unchanged by that ruling and are + * re-read as such in `package-install-one-authority.test.ts`. + * * ## ⭐ THE ONE AUTHORITY for this key, and the map to the other two * * `enableOnInstall` is declared in three published schemas. This one is the * authority, because it is the request contract of the door that HONOURS it: - * `POST /api/v1/packages` writes the registry row's `enabled` from - * `enableOnInstall ?? true`, through the same registry flip and durable - * state write `PATCH /packages/:id/disable` uses - * (`packages/runtime/src/domains/packages.ts`). A `false` here installs the - * package present-but-not-active and survives a restart; `true` and absent - * install it enabled, which is this declaration's default. + * `POST /api/v1/packages` moves the registry row through the same registry + * flip and durable state write `PATCH /packages/:id/enable` and + * `PATCH /packages/:id/disable` use. * * The other two are re-read here so a reader never has to guess which of * three identical-looking declarations governs: * * - `InstallPackageRequestSchema` (`src/kernel/package-registry.zod.ts`) — * **a COPY of this key**, restated on the in-process protocol primitive - * `ObjectStackProtocol.installPackage`. Same type, same default, same + * `ObjectStackProtocol.installPackage`. Same type, same optionality, same * meaning; its own implementation does not read it, and this door does not * forward it down that seam. Held to this declaration by * `package-install-one-authority.test.ts`, not by an import: the authority @@ -387,13 +412,15 @@ export const PackageInstallRequestSchema = lazySchema(() => z.object({ * listing, its door is the control plane's `POST /api/v1/marketplace/install`, * and its `enableOnInstall` is what a caller asks the marketplace channel * to request on its behalf, one translation upstream of this one. It stays - * a declaration of its own and says why at its own site. + * a declaration of its own and says why at its own site. Its 缺省 cell moved + * with the other two so the matrix stays readable as one row per state, ⛔ + * not because the key was folded. * * ⛔ Never unify the three silently, in either direction: two of them are * one commitment and the third is a different party's. */ - enableOnInstall: z.boolean().default(true) - .describe('Whether to enable immediately after install — honoured at POST /api/v1/packages: the installed row\'s `enabled` is written from this key'), + enableOnInstall: z.boolean().optional() + .describe('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)'), /** * Opt back in to overwriting an already-installed package id. diff --git a/packages/spec/src/api/package-install-one-authority.test.ts b/packages/spec/src/api/package-install-one-authority.test.ts index 8dd3ec204ee..206fdc422a5 100644 --- a/packages/spec/src/api/package-install-one-authority.test.ts +++ b/packages/spec/src/api/package-install-one-authority.test.ts @@ -24,6 +24,40 @@ * its door is the control plane's, not this platform's install door — so * what is pinned here is the difference that carries that reading, not the * sameness. + * + * ## ⭐ [#19273] THE 缺省 CELL FLIPPED — and this is the registered reason + * + * When this file was written, every 缺省 (absent) reading below was `true`, + * because all three declarations spelled `z.boolean().default(true)`. They now + * spell `z.boolean().optional()` and the 缺省 readings are `undefined`. + * + * ⛔ That is a FLIP, not a repair: the install door was ruled onto + * 「缺省 = 保持,有旗 = 设置」 (maintainer batch #157 item 5 letter C) and stopped + * making any lifecycle call on an absent key, which left the declarations + * claiming a default the runtime deliberately no longer applies. The + * declarations followed in maintainer batch #210 item 4 letter A. + * + * ⚠️ {@link FLIP_TRIGGER} is registered HERE, WITH the flip — ⛔ it was NOT + * pre-registered when this pin landed. Measured on the parent commit: this + * file carried the phrase nowhere, and no flip or trigger note of any kind + * (zero hits for 缺省 / 保持 / 有旗 / flip / trigger, against `MATRIX` and + * `absent` as the controls proving the file was read). The card's own protocol + * — 「本卡 pin 断言兄弟卡在改的行为 ⇒ 在用例内预登记翻转触发词」 — expected a + * pin asserting a behaviour a sibling card was already changing to carry the + * trigger in advance, and that did not happen. ⭐ The gap is left recorded + * rather than papered over, because a reader who learns the pre-registration + * was MISSED is better served than one told it happened: the lesson is that a + * pin written against a contested cell needs its trigger at the moment the pin + * lands, when the contest is known, not at the moment the cell finally moves. + * + * ⭐ What did NOT move, and is re-read below precisely because of that: the + * `true` and `false` arms. A fix that makes absence visible by making the key + * mean nothing would be worse than the defect it closes, so both booleans are + * asserted after the flip rather than assumed to have survived it. + * + * ⛔ Only the 缺省 cell moved. The string and `null` cells still refuse, and + * the authority/copy agreement is still judged cell by cell — the flip is one + * row of the matrix, never a relaxation of the matrix. */ import { describe, it, expect } from 'vitest'; @@ -39,23 +73,53 @@ const MANIFEST = { type: 'app', } as const; +/** + * ⭐ THE FLIP-TRIGGER, registered in this file rather than patched away. + * + * The phrase the install door was ruled onto. While the declarations spelled + * `.default(true)`, every assertion here read the 缺省 cell as `true` — and + * that reading was known to be living on borrowed time, because this phrase + * says absence is a state the door ACTS ON (by making no lifecycle call at + * all), and a `.default()` resolves absence at parse time so the state cannot + * survive to the published surface. + * + * So: a 缺省 reading of `undefined` below is this trigger having fired. It is + * spelled once, here, and quoted into the cell name so a test run prints it. + * ⛔ Reading a red on the 缺省 cell as "the pin needs updating" and writing the + * new value in silently is the failure this const exists to prevent. + */ +const FLIP_TRIGGER = '缺省 = 保持,有旗 = 设置'; + /** * The matrix. Each cell is a body the two contracts must answer identically — - * absent (the default), both booleans, and the non-boolean spelling the door - * itself treats as absent (recorded on `PackageInstallBodySchema`'s residual). + * absent (a state of its own, {@link FLIP_TRIGGER}), both booleans, and the + * non-boolean spelling the door itself treats as absent (recorded on + * `PackageInstallBodySchema`'s residual). */ const MATRIX: ReadonlyArray<{ name: string; enableOnInstall?: unknown }> = [ - { name: 'absent — the declared default applies' }, + { name: `absent — a state of its own, 「${FLIP_TRIGGER}」: no default resolves it` }, { name: 'false — install present, not active', enableOnInstall: false }, - { name: 'true — the default, spelled', enableOnInstall: true }, + { name: 'true — the enable request, spelled', enableOnInstall: true }, { name: "'false' — a string, refused by the declaration", enableOnInstall: 'false' }, { name: 'null — refused by the declaration', enableOnInstall: null }, ]; describe('#18605 — `enableOnInstall` has ONE authority', () => { describe('the authority: `PackageInstallRequestSchema`', () => { - it('defaults to `true` — the value the install door installs enabled on', () => { + it(`leaves an absent key \`undefined\` — 「${FLIP_TRIGGER}」, so the door still sees the absence`, () => { const parsed = PackageInstallRequestSchema.parse({ manifest: MANIFEST }); + expect(parsed.enableOnInstall).toBeUndefined(); + // ⛔ Not merely "not `true`": the key must be ABSENT-shaped after the + // parse, because the door's three-way read is `=== true` / `=== false` / + // neither. Any other resolved value would be a fourth state. + expect('enableOnInstall' in parsed).toBe(false); + }); + + it('carries `true` through — ⭐ re-read after the flip, not assumed to have survived it', () => { + // The control in the other direction: making absence visible by making + // the key mean nothing would be worse than the defect. This arm and the + // `false` one below are what say the key still means something. + const parsed = PackageInstallRequestSchema.parse({ manifest: MANIFEST, enableOnInstall: true }); expect(parsed.enableOnInstall).toBe(true); }); @@ -92,11 +156,21 @@ describe('#18605 — `enableOnInstall` has ONE authority', () => { }); } - it('declares the key with the same type and default, not merely the same name', () => { + it('declares the key with the same type and the same OPTIONALITY, not merely the same name', () => { + // ⭐ [#19273] This assertion used to read `typeof copyOnly === 'boolean'`, + // which only held while a `.default(true)` was resolving the absence. + // The flip-trigger removed that default from both declarations, so what + // is pinned now is that neither one invents a value — and the boolean + // half of the type is held by the spelled cells in the matrix above. const authorityOnly = PackageInstallRequestSchema.parse({ manifest: MANIFEST }).enableOnInstall; const copyOnly = InstallPackageRequestSchema.parse({ manifest: MANIFEST }).enableOnInstall; - expect(typeof copyOnly).toBe('boolean'); + expect(copyOnly).toBeUndefined(); expect(copyOnly).toBe(authorityOnly); + + // And the copy still carries a spelled value through, both ways — the + // same control the authority gets above. + expect(InstallPackageRequestSchema.parse({ manifest: MANIFEST, enableOnInstall: true }).enableOnInstall).toBe(true); + expect(InstallPackageRequestSchema.parse({ manifest: MANIFEST, enableOnInstall: false }).enableOnInstall).toBe(false); }); }); @@ -116,9 +190,18 @@ describe('#18605 — `enableOnInstall` has ONE authority', () => { expect(PackageInstallRequestSchema.safeParse({ listingId: 'com.acme.crm' }).success).toBe(false); }); - it('declares `enableOnInstall` in its own right, defaulting to `true`', () => { + it('declares `enableOnInstall` in its own right, and leaves an absent one `undefined` too', () => { + // ⭐ [#19273] The 缺省 cell moved here as well, so the matrix reads as one + // row per state across all three declarations. ⛔ Not a fold: what this + // request means by 「enable」 is still one translation upstream of the + // install door, which is what the two assertions above pin. const parsed = MarketplaceInstallRequestSchema.parse({ listingId: 'com.acme.crm' }); - expect(parsed.enableOnInstall).toBe(true); + expect(parsed.enableOnInstall).toBeUndefined(); + + // Re-read in the other direction here too — the key still means + // something on this request after the flip. + expect(MarketplaceInstallRequestSchema.parse({ listingId: 'com.acme.crm', enableOnInstall: true }).enableOnInstall).toBe(true); + expect(MarketplaceInstallRequestSchema.parse({ listingId: 'com.acme.crm', enableOnInstall: false }).enableOnInstall).toBe(false); }); }); }); diff --git a/packages/spec/src/kernel/package-registry.test.ts b/packages/spec/src/kernel/package-registry.test.ts index 70038c6b44d..842b40a715a 100644 --- a/packages/spec/src/kernel/package-registry.test.ts +++ b/packages/spec/src/kernel/package-registry.test.ts @@ -190,9 +190,21 @@ describe('InstallPackageRequestSchema', () => { expect(() => InstallPackageRequestSchema.parse(request)).not.toThrow(); }); - it('should apply default enableOnInstall', () => { + it('should leave an absent enableOnInstall UNDEFINED — it declares no default', () => { + // ⭐ [#19273] Was `should apply default enableOnInstall` ⇒ `true`. This + // declaration restates the install door's key, which was ruled onto + // 「缺省 = 保持,有旗 = 设置」: absence is a state the door acts on, so no + // default may resolve it away. The matrix that holds this copy equal to + // its authority is `src/api/package-install-one-authority.test.ts`. const parsed = InstallPackageRequestSchema.parse({ manifest: validManifest }); - expect(parsed.enableOnInstall).toBe(true); + expect(parsed.enableOnInstall).toBeUndefined(); + }); + + it('should still carry a spelled enableOnInstall through — both arms', () => { + // The control in the other direction: absence became visible without the + // key ceasing to mean anything. + expect(InstallPackageRequestSchema.parse({ manifest: validManifest, enableOnInstall: true }).enableOnInstall).toBe(true); + expect(InstallPackageRequestSchema.parse({ manifest: validManifest, enableOnInstall: false }).enableOnInstall).toBe(false); }); it('should accept install request with settings', () => { diff --git a/packages/spec/src/kernel/package-registry.zod.ts b/packages/spec/src/kernel/package-registry.zod.ts index c3ce48e6de5..df3fdfe514b 100644 --- a/packages/spec/src/kernel/package-registry.zod.ts +++ b/packages/spec/src/kernel/package-registry.zod.ts @@ -285,14 +285,24 @@ export const InstallPackageRequestSchema = lazySchema(() => z.object({ * ## A RESTATEMENT of the install-request key — the one authority is * `PackageInstallRequestSchema` in `src/api/package-api.zod.ts` * - * Same type, same default, same meaning: this is a COPY of the request key, - * not a second key that happens to share a spelling. The authority is the - * request contract bound to the door that actually serves — - * `POST /api/v1/packages`, which writes the registry row's `enabled` from - * `enableOnInstall ?? true` (`packages/runtime/src/domains/packages.ts`). + * Same type, same optionality, same meaning: this is a COPY of the request + * key, not a second key that happens to share a spelling. The authority is + * the request contract bound to the door that actually serves — + * `POST /api/v1/packages` (`packages/runtime/src/domains/packages.ts`). * ⛔ Never let the two drift: `src/api/package-install-one-authority.test.ts` * parses BOTH over one matrix and reds when they disagree on any cell. * + * ## ⭐ THREE STATES — absence is one, and it is not a default + * + * `true` enables the row, `false` disables it, and ABSENT keeps the row's + * current lifecycle state; a fresh id has no state to keep and lands + * ENABLED. 「缺省 = 保持,有旗 = 设置」, ruled in maintainer batch #157 item 5 + * letter C for the door and carried onto the declarations in batch #210 + * item 4 letter A. ⛔ This key is therefore `optional()` and never + * `.default(true)`: a default resolves absence at parse time, which erases + * the third state from the published surface while the door still honours + * it. + * * ## ⭐ This contract's own implementation HONOURS the key — on the registry row * * This schema types the in-process protocol primitive @@ -308,9 +318,11 @@ export const InstallPackageRequestSchema = lazySchema(() => z.object({ * * `=== true` / `=== false`, never a truthiness test and never a `??` default: * the THREE states are the contract, and a non-boolean value is read as - * ABSENT rather than coerced. The `.default(true)` below never reaches that - * path — nothing parses an install request through this schema there — so an - * absent key arrives intact and is read as absent. + * ABSENT rather than coerced. The declaration below resolves nothing on an + * absent key — it is `optional()`, and ⛔ never `.default(true)`, precisely + * so that the third state survives the parse — and nothing parses an install + * request through this schema on that path anyway, so an absent key arrives + * intact and is read as absent. * * ⚠️ What this seam does NOT write, stated so the scope is not over-read: the * runtime's DURABLE disabled-package file. That record is keyed by @@ -341,8 +353,8 @@ export const InstallPackageRequestSchema = lazySchema(() => z.object({ * `PackageInstallRequestSchema.shape.enableOnInstall` — the pin above is the * mechanical half of the reference, and it is the half that can fail. */ - enableOnInstall: z.boolean().default(true) - .describe('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: z.boolean().optional() + .describe('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)'), /** * Current platform version for compatibility checking. * When provided, the system compares this against the package's diff --git a/packages/spec/src/marketplace/marketplace.test.ts b/packages/spec/src/marketplace/marketplace.test.ts index e92aab0e3ab..7eb2e141b6b 100644 --- a/packages/spec/src/marketplace/marketplace.test.ts +++ b/packages/spec/src/marketplace/marketplace.test.ts @@ -323,12 +323,16 @@ describe('MarketplaceSearchResponseSchema', () => { }); describe('MarketplaceInstallRequestSchema', () => { - it('should accept minimal install request', () => { + it('should accept minimal install request — and leave an absent enableOnInstall UNDEFINED', () => { const request = { listingId: 'listing-001', }; const parsed = MarketplaceInstallRequestSchema.parse(request); - expect(parsed.enableOnInstall).toBe(true); + // ⭐ [#19273] Was `toBe(true)`. The 缺省 cell moved on all three + // `enableOnInstall` declarations together so the consistency matrix in + // `src/api/package-install-one-authority.test.ts` stays one row per state. + // ⛔ Not a fold — this request's subject is still a marketplace listing. + expect(parsed.enableOnInstall).toBeUndefined(); }); it('should accept full install request with license', () => { diff --git a/packages/spec/src/marketplace/marketplace.zod.ts b/packages/spec/src/marketplace/marketplace.zod.ts index 0a9874458ec..5a3785f0818 100644 --- a/packages/spec/src/marketplace/marketplace.zod.ts +++ b/packages/spec/src/marketplace/marketplace.zod.ts @@ -520,9 +520,22 @@ export const MarketplaceInstallRequestSchema = lazySchema(() => z.object({ * control-plane contract that no PR in this repo can even see. * `src/api/package-install-one-authority.test.ts` pins the difference that * carries this reading, so a later fold cannot be a silent one. + * + * ## ⭐ Why the 缺省 cell moved here too, on a key that is NOT the door's + * + * `true` asks the channel to enable, `false` asks it not to, and ABSENT asks + * it to leave the package's lifecycle state where it is — a fresh install + * lands enabled. That is the same three-state reading the platform door was + * ruled onto (batch #157 item 5 letter C, carried onto the declarations by + * batch #210 item 4 letter A), and the consistency pin holds all three + * declarations to one row per state. ⛔ It is a matrix that moved, not a + * fold: what this request means by 「enable」 is still one translation + * upstream of the door, and a `.default(true)` here would re-introduce a + * value the caller never wrote — this time into a control-plane contract no + * PR in this repo can see the other end of. */ - enableOnInstall: z.boolean().default(true) - .describe('Whether to enable immediately after install — the marketplace channel\'s own install option, not the platform install-door key (api/PackageInstallRequest)'), + enableOnInstall: z.boolean().optional() + .describe('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'), /** Artifact reference (resolved from listing version, or provided directly) */ artifactRef: ArtifactReferenceSchema.optional()