diff --git a/.changeset/18605-enable-on-install-one-authority.md b/.changeset/18605-enable-on-install-one-authority.md new file mode 100644 index 0000000000..2bd6cbd7a2 --- /dev/null +++ b/.changeset/18605-enable-on-install-one-authority.md @@ -0,0 +1,27 @@ +--- +"@objectstack/spec": minor +--- + +`enableOnInstall` is declared in three published schemas; each one now says which of the three governs it, and the two that are not the authority say what they are (#18605). + +The install door already honours the key — `POST /api/v1/packages` writes the registry row's `enabled` from `enableOnInstall ?? true` (#18058). What was left was three declarations that looked identical (`z.boolean().default(true)`, same description) with nothing saying which one an author should read. + +Clause-②: yes + +**The authority** + +`PackageInstallRequestSchema` (`api/package-api.zod.ts`) is the one authority, because it is the request contract of the door that honours the key. Its published description now says so: "honoured at POST /api/v1/packages: the installed row's `enabled` is written from this key". Its doc block carries the map to the other two, so a reader never has to guess which of three identical-looking declarations governs. + +**`kernel/InstallPackageRequest.enableOnInstall` — a COPY of the request key** + +Same type, same default, same meaning, restated on the in-process protocol primitive `ObjectStackProtocol.installPackage`. Its published description now records that this layer does not read it: the implementation reads `manifest` and `settings` only, and the HTTP door does not forward the key down that seam — it calls `installPackage({ manifest, settings })` and performs the enable/disable flip itself, because the durable half must follow the row that door returned rather than the request's intent. + +The copy is held to the authority by a **parity pin** rather than by a structural reference. The structural spelling is not available in this direction: the authority is built from `ManifestSchema` and `InstalledPackageSchema`, both declared in `kernel/package-registry.zod.ts`, so `PackageInstallRequestSchema.shape.enableOnInstall` spelled there is an import cycle, and under `OS_EAGER_SCHEMAS=1` — the mode `gen:schema` and `check:authorable-surface` run in — it dies with `ReferenceError: Cannot access 'InstalledPackageSchema' before initialization`. `api/package-install-one-authority.test.ts` parses both declarations over one matrix (absent, `false`, `true`, a string, `null`) and reds on any cell where they disagree. + +**`marketplace/MarketplaceInstallRequest.enableOnInstall` — not this key at all** + +It stays, and its published description says what it is: the marketplace channel's own install option. That request's subject is a listing (`listingId`, `version`, `licenseKey`, `tenantId`), not a manifest; its door is the control plane's `POST /api/v1/marketplace/install`, of which a runtime mounts only a read-only proxy; and the channel resolves the artefact and validates the licence before mapping what it holds into a platform install. It is one translation upstream of the door key, owned by a different party on a different release cadence, so folding it would let a narrowing at the platform door silently narrow a control-plane contract. + +**What does not move** + +No key is added, removed, renamed or retyped, and no default changes: the accept set of all three schemas is byte-for-byte what it was, and `api-surface`, `api-surface-declarations`, `authorable-surface` and `authorable-defaults` are all unchanged. What moves is the published description text of three keys and the reference pages generated from it. The `Clause-②` declaration is `yes` as the conservative arm, because three published declarations' stated meaning moves. diff --git a/content/docs/references/api/package-api.mdx b/content/docs/references/api/package-api.mdx index c79c4cc219..6d6b0f0a7a 100644 --- a/content/docs/references/api/package-api.mdx +++ b/content/docs/references/api/package-api.mdx @@ -491,7 +491,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 | +| **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 | | **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 | @@ -654,7 +654,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 | +| **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 | | **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 008b1df006..bdb10a3bf7 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 | +| **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 does not read it | | **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 d364c6b318..54af200833 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 | +| **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 does not read it | | **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 f7146f815a..4bfecc61a8 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 | +| **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) | | **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/src/api/package-api.zod.ts b/packages/spec/src/api/package-api.zod.ts index 6a2e489b45..bf671d79d1 100644 --- a/packages/spec/src/api/package-api.zod.ts +++ b/packages/spec/src/api/package-api.zod.ts @@ -282,9 +282,43 @@ export const PackageInstallRequestSchema = lazySchema(() => z.object({ settings: z.record(z.string(), z.unknown()).optional() .describe('User-provided settings at install time'), - /** Whether to enable immediately after install */ + /** + * Whether to enable the package immediately after install. + * + * ## ⭐ 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. + * + * 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 + * 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 + * sits above `kernel/` in the module graph, so a `…Schema.shape.…` + * reference from there is a cycle that dies under `OS_EAGER_SCHEMAS=1`. + * - `MarketplaceInstallRequestSchema` (`src/marketplace/marketplace.zod.ts`) + * — **not this key at all**. That request's subject is a marketplace + * 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. + * + * ⛔ 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'), + .describe('Whether to enable immediately after install — honoured at POST /api/v1/packages: the installed row\'s `enabled` is written from this key'), /** * 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 new file mode 100644 index 0000000000..8dd3ec204e --- /dev/null +++ b/packages/spec/src/api/package-install-one-authority.test.ts @@ -0,0 +1,124 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#18605] `enableOnInstall` — ONE AUTHORITY, and the two re-reads under it. + * + * The card measured the key declared in three published schemas. #18752 closed + * the first half of the ruling (the install door now honours it). This file + * pins the SECOND half — which of the other two declarations is a copy of the + * request key and which means something else — so neither the fold nor a + * silent unification can happen unobserved: + * + * 1. The authority is `PackageInstallRequestSchema` (`package-api.zod.ts`), + * the request contract of `POST /api/v1/packages`. + * 2. `InstallPackageRequestSchema` (`kernel/package-registry.zod.ts`) is a + * COPY of the request key. It cannot be folded to a structural reference — + * the authority sits above `kernel/` in the module graph, so + * `PackageInstallRequestSchema.shape.enableOnInstall` spelled there is an + * import cycle that dies under `OS_EAGER_SCHEMAS=1`, the mode `gen:schema` + * and `check:authorable-surface` run in. The reference is therefore + * MECHANICAL and lives here: the two declarations are parsed over one + * matrix, and any drift on any cell reds. + * 3. `MarketplaceInstallRequestSchema` (`marketplace/marketplace.zod.ts`) + * means something else and stays. Its subject is a marketplace LISTING and + * 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. + */ + +import { describe, it, expect } from 'vitest'; +import { PackageInstallRequestSchema } from './package-api.zod'; +import { InstallPackageRequestSchema } from '../kernel/package-registry.zod'; +import { MarketplaceInstallRequestSchema } from '../marketplace/marketplace.zod'; + +/** A manifest both install-request contracts accept, so only the key varies. */ +const MANIFEST = { + id: 'com.acme.crm', + name: 'Acme CRM', + version: '1.0.0', + type: 'app', +} as const; + +/** + * 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). + */ +const MATRIX: ReadonlyArray<{ name: string; enableOnInstall?: unknown }> = [ + { name: 'absent — the declared default applies' }, + { name: 'false — install present, not active', enableOnInstall: false }, + { name: 'true — the default, 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', () => { + const parsed = PackageInstallRequestSchema.parse({ manifest: MANIFEST }); + expect(parsed.enableOnInstall).toBe(true); + }); + + it('carries `false` through — the value the install door installs disabled on', () => { + const parsed = PackageInstallRequestSchema.parse({ manifest: MANIFEST, enableOnInstall: false }); + expect(parsed.enableOnInstall).toBe(false); + }); + + it('refuses a non-boolean by name rather than coercing it', () => { + const result = PackageInstallRequestSchema.safeParse({ manifest: MANIFEST, enableOnInstall: 'false' }); + expect(result.success).toBe(false); + expect(result.error?.issues.some((i) => i.path.join('.') === 'enableOnInstall')).toBe(true); + }); + }); + + /** + * ⭐ THE REFERENCE, made mechanical. `InstallPackageRequestSchema` restates + * the authority's key; this is what holds the restatement equal to it in the + * absence of an import that would be a cycle. + */ + describe('the COPY: `kernel/InstallPackageRequestSchema` answers exactly as the authority does', () => { + for (const cell of MATRIX) { + it(`agrees with the authority — ${cell.name}`, () => { + const body: Record = { manifest: MANIFEST }; + if ('enableOnInstall' in cell) body.enableOnInstall = cell.enableOnInstall; + + const authority = PackageInstallRequestSchema.safeParse(body); + const copy = InstallPackageRequestSchema.safeParse(body); + + expect(copy.success).toBe(authority.success); + if (authority.success && copy.success) { + expect(copy.data.enableOnInstall).toBe(authority.data.enableOnInstall); + } + }); + } + + it('declares the key with the same type and default, not merely the same name', () => { + const authorityOnly = PackageInstallRequestSchema.parse({ manifest: MANIFEST }).enableOnInstall; + const copyOnly = InstallPackageRequestSchema.parse({ manifest: MANIFEST }).enableOnInstall; + expect(typeof copyOnly).toBe('boolean'); + expect(copyOnly).toBe(authorityOnly); + }); + }); + + /** + * ⛔ The marketplace declaration is NOT folded, and these are the measured + * differences that say why. If a later change makes this request a second + * spelling of the install door's body, these reds are the notice. + */ + describe('the OTHER MEANING: `MarketplaceInstallRequestSchema` is a different request', () => { + it('is keyed by a marketplace LISTING, not by a manifest', () => { + expect(Object.keys(MarketplaceInstallRequestSchema.shape)).toContain('listingId'); + expect(Object.keys(MarketplaceInstallRequestSchema.shape)).not.toContain('manifest'); + }); + + it('refuses the install door\'s body — nothing can send one where the other is expected', () => { + expect(MarketplaceInstallRequestSchema.safeParse({ manifest: MANIFEST }).success).toBe(false); + expect(PackageInstallRequestSchema.safeParse({ listingId: 'com.acme.crm' }).success).toBe(false); + }); + + it('declares `enableOnInstall` in its own right, defaulting to `true`', () => { + const parsed = MarketplaceInstallRequestSchema.parse({ listingId: 'com.acme.crm' }); + expect(parsed.enableOnInstall).toBe(true); + }); + }); +}); diff --git a/packages/spec/src/kernel/package-registry.zod.ts b/packages/spec/src/kernel/package-registry.zod.ts index bd0a0c948d..64a17a8bc3 100644 --- a/packages/spec/src/kernel/package-registry.zod.ts +++ b/packages/spec/src/kernel/package-registry.zod.ts @@ -279,9 +279,50 @@ export const InstallPackageRequestSchema = lazySchema(() => z.object({ /** Optional: user-provided settings at install time */ settings: z.record(z.string(), z.unknown()).optional() .describe('User-provided settings at install time'), - /** Whether to enable immediately after install (default: true) */ + /** + * Whether to enable the package immediately after install. + * + * ## 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`). + * ⛔ 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. + * + * ## ⚠️ This contract's own implementation does not read the key + * + * This schema types the in-process protocol primitive + * `ObjectStackProtocol.installPackage` (`src/api/protocol.zod.ts`), whose + * implementation reads `request.manifest` and `request.settings` and nothing + * else (`packages/metadata-protocol/src/protocol.ts`). The HTTP door does + * NOT forward the key down this seam either: it calls + * `installPackage({ manifest, settings })` and performs the enable/disable + * flip itself afterwards, because the durable half must follow the ROW that + * door returned rather than the request's intent. So an `enableOnInstall` + * spelled on THIS request reaches no code that acts on it — which is why the + * `.describe()` says so on the published reference page rather than + * repeating the authority's promise a layer that cannot keep it. + * + * ## ⛔ Why the reference is documentary and not `…Schema.shape.…` + * + * `PackageInstallRequestSchema` sits ABOVE this module in the import graph — + * it is built from `ManifestSchema` and `InstalledPackageSchema`, both + * declared here — so a reference from here up to it is an import cycle. It + * is not a cycle the `lazySchema` proxy absorbs: under `OS_EAGER_SCHEMAS=1`, + * the mode `gen:schema` and `check:authorable-surface` run in, the factory + * bodies evaluate at module load and the cycle dies with + * `ReferenceError: Cannot access 'InstalledPackageSchema' before + * initialization`. Measured in both directions, on this key, before this + * doc block was written. ⛔ Do not "fix" this into + * `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'), + .describe('Whether to enable immediately after install — restates the install-door request key, whose one authority is api/PackageInstallRequest; this protocol primitive does not read it'), /** * Current platform version for compatibility checking. * When provided, the system compares this against the package's diff --git a/packages/spec/src/marketplace/marketplace.zod.ts b/packages/spec/src/marketplace/marketplace.zod.ts index 22796c142b..0a9874458e 100644 --- a/packages/spec/src/marketplace/marketplace.zod.ts +++ b/packages/spec/src/marketplace/marketplace.zod.ts @@ -490,9 +490,39 @@ export const MarketplaceInstallRequestSchema = lazySchema(() => z.object({ settings: z.record(z.string(), z.unknown()).optional() .describe('User-provided settings at install time'), - /** Whether to enable immediately after install */ + /** + * Whether the marketplace channel should enable the package immediately + * after it installs it. + * + * ## This declaration STAYS — it is not the platform install door's key + * + * Same words, a different commitment, and the difference is the SUBJECT of + * the request it sits on. This request names a marketplace LISTING + * (`listingId`, `version`, `licenseKey`, `tenantId`); the platform install + * door's request (`PackageInstallRequestSchema`, `src/api/package-api.zod.ts`) + * names a MANIFEST. They are not two spellings of one body — nothing can + * send one where the other is expected. + * + * The doors differ too. This one is the control plane's + * `POST /api/v1/marketplace/install`; a runtime mounts `/api/v1/marketplace/*` + * only as a read-only proxy to the configured control plane + * (`MarketplaceProxyPlugin`, `docs/design/marketplace-publishing.md` §4.2). + * That channel resolves the artefact and validates the licence, and only + * then maps what it holds into a platform install (§4.3) — so this key is + * what a caller asks the MARKETPLACE to request on its behalf, one + * translation upstream of the door key, not the door key itself. + * + * ⛔ So it is deliberately NOT folded into the install door's contract. The + * two are owned by different parties on different release cadences — this + * declaration was `cloud/MarketplaceInstallRequest` before it moved into + * this namespace (`scripts/lib/renamed-defs.ts`) — and one shared + * declaration would let a narrowing at the platform door silently narrow a + * 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. + */ enableOnInstall: z.boolean().default(true) - .describe('Whether to enable immediately after install'), + .describe('Whether to enable immediately after install — the marketplace channel\'s own install option, not the platform install-door key (api/PackageInstallRequest)'), /** Artifact reference (resolved from listing version, or provided directly) */ artifactRef: ArtifactReferenceSchema.optional()