Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .changeset/18605-enable-on-install-one-authority.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions content/docs/references/api/package-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>` | 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 |
Expand Down Expand Up @@ -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<string, any>` | 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 |
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/api/protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1910,7 +1910,7 @@ Install package request
| :--- | :--- | :--- | :--- |
| **manifest** | `{ id: string; namespace?: string; defaultDatasource?: string; version: string; … }` | ✅ | Package manifest to install |
| **settings** | `Record<string, any>` | optional | User-provided settings at install time |
| **enableOnInstall** | `boolean` | optional (default: `true`) | Whether to enable immediately after install |
| **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`
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/kernel/package-registry.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ Install package request
| :--- | :--- | :--- | :--- |
| **manifest** | `{ id: string; namespace?: string; defaultDatasource?: string; version: string; … }` | ✅ | Package manifest to install |
| **settings** | `Record<string, any>` | optional | User-provided settings at install time |
| **enableOnInstall** | `boolean` | optional (default: `true`) | Whether to enable immediately after install |
| **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`
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/marketplace/marketplace.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ Install from marketplace request
| **version** | `string` | optional | Version to install |
| **licenseKey** | `string` | optional | License key for paid packages |
| **settings** | `Record<string, any>` | optional | User-provided settings at install time |
| **enableOnInstall** | `boolean` | optional (default: `true`) | Whether to enable immediately after install |
| **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 |

Expand Down
38 changes: 36 additions & 2 deletions packages/spec/src/api/package-api.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
124 changes: 124 additions & 0 deletions packages/spec/src/api/package-install-one-authority.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = { 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);
});
});
});
Loading
Loading