Skip to content

Commit 482d584

Browse files
huangyiireneclaude
andauthored
fix(metadata-protocol): the in-process install door honours enableOnInstall (#19338)
Fixes #19277 Clause-②: no `InstallPackageRequestSchema.enableOnInstall` (`packages/spec/src/kernel/package-registry.zod.ts`) is the request contract of the in-process `ObjectStackProtocol.installPackage` / `MetadataProtocol.installPackage` primitive. The implementation read `request.manifest` and `request.settings` and nothing else, so a caller that asked for `enableOnInstall: false` got an ENABLED install — no refusal, no warning, no effect. Declared but not enforced on a published option, which ADR-0049 (enforce-or-remove) and Prime Directive #10 refuse outright. Ruling batch #153 item 5 letter 1 (#18605, record `5724940709`) kept this declaration as a COPY of the HTTP request key with the SAME meaning, so the disposition is **enforce, not retire**. ## What changed `MetadataProtocol.installPackage` now applies the same rule the HTTP door applies, through the same registry verbs `PATCH /packages/:id/enable` and `PATCH /packages/:id/disable` use: | `enableOnInstall` | effect | |:--|:--| | `true` | `enablePackage` — clears a disable, including a boot-seeded one | | `false` | `disablePackage` — the row and its `status` both move | | absent | no lifecycle call at all; the row the registry returned stands | `=== true` / `=== false`, never a truthiness test and never a `??` default — the three states are the contract. A non-boolean value is read as absent rather than coerced. ## ⚠️ The card's mechanism sentence was stale; the matrix was taken from the tree The card (written 2026-09-20T09:06Z) asks for 「the registry row's `enabled` (and `status`) follow `enableOnInstall ?? true` on install **and on re-install**」. PR #19291 (`4fef271b7`, 2026-09-20T11:10Z) re-ruled exactly those cells under maintainer ruling batch #157 item 5 letter C (「缺省 = 保持,有旗 = 设置」), which is younger than this card's own ruling. `?? true` on re-install is precisely what the HTTP door **stopped** doing. The direction 「honour it the way the HTTP door does」 is self-updating and still governs, so the matrix below was read off `packages/runtime/src/domains/packages-install-enable-on-install.test.ts` on `origin/main`, not off the card's prose. The four cells checked, and they match the dispatch's table exactly: | line | case | on the tree | |:--|:--|:--| | `:154` | ABSENT flag, FRESH install | enabled | | `:233` | `[#18877 re-ruled]` re-install, flag ABSENT | **PRESERVES** the disable | | `:262` | re-install, `enableOnInstall: true` | clears the durable disable | | `:278` | `[#18877 re-ruled]` BARE re-install | **PRESERVES** it too | ⛔ One HTTP-door cell has no analogue at this seam: the BARE body form (a manifest posted as the whole body) does not exist in-process — `InstallPackageRequest` always carries `manifest` as a field. What is pinned instead is the third state's boundary: a non-boolean value is read as ABSENT. ## ⛔ What this seam does NOT write The runtime's durable disabled-package file is keyed by **environment** (`setPackageDisabled(environmentId, id, disabled)`, `packages/runtime/src/package-state-store.ts`), and an `InstallPackageRequest` carries no environment — so that key cannot even be formed here. The module also lives in `@objectstack/runtime`, which depends on `@objectstack/metadata-protocol` and not the other way round. The HTTP door owns that half and writes it from the row it returned. So `enableOnInstall` through the in-process primitive moves the **registry row** — what every in-process reader serves from — for the life of the process. This is exactly the scope the card's acceptance names (「registry row + status」). It is stated in the code, in the changeset and here rather than left to be rediscovered; see acceptance notes for the follow-up it earns. ## No behaviour change for any caller on the tree The card's own measurement, re-verified rather than inherited. Radius: `packages/**`, `examples/**`, `apps/**` in this repo, at `2c8e2667c`. - `packages/runtime/src/domains/packages.ts:769` — `protocolSvc.installPackage({ manifest, settings: body.settings })`. The key is deliberately not forwarded; the door performs the flip itself. - `packages/metadata-protocol/src/protocol.ts` (`duplicatePackage`) — `this.installPackage({ manifest: dupManifest })`. Flag absent. Those are the only two call sites. ⇒ confirmed: no existing caller sets the key, so this is observable only to a caller that sets it — one that until now got silence. ## Verification **Gates** — ⛔ not a list taken on trust: derived from the actual changed files with `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands`, each exit code landed to a file before any pipe, then reconciled: ```text ✓ dispatch-gates --ran: 61 derived famil(ies) accounted for — 61 run, 0 NOT-MEASURED (a DERIVED zero — all 61 recorded an exit code and none of them is 3). ``` All 61 exit 0, measured at `2c8e2667c`. Three of them (`check:dual-build-cjs-loads`, `check:lean-entry-closure`, `check:type-check-debt`) first answered **exit 3 = PREREQUISITE NOT MET**; that was cleared with a full workspace build and they were re-run, ⛔ never read as a pass. **Tests** | run | result | |:--|:--| | `pnpm --filter @objectstack/metadata-protocol test` | 2596 passed, 19 skipped (185 files) | | `pnpm --filter @objectstack/objectql test` | 5037 passed (303 files) | | `pnpm --filter @objectstack/{metadata-protocol,objectql} typecheck` | pass | | `pnpm --filter @objectstack/runtime exec vitest run src/domains/packages` | 243 passed (16 files) — the HTTP door is unmoved | | `pnpm lint` (repo-wide `eslint . --no-inline-config`) | pass | **Ablation** — the new pin is proven able to fail. `packages/objectql` resolves `@objectstack/metadata-protocol` through its `exports`, i.e. `dist/`, with no vitest alias (it is a `KNOWN_UNALIASED_TEST_IMPORTS` entry), so the mutation was rebuilt and proven present in the artifact before the run's colour was read: ```text mutate ablation-replace: anchor x1 -> x0, blob e7a7874 -> 7d197b0f7299 rebuild pnpm --filter @objectstack/metadata-protocol build dist ✓ marker present in 2 built files — the ablation is live in the artifact the suite consumes run Tests 7 failed | 5 passed (12) ← direction: turned RED, the ordinary direction restore ✓ restored: blob == HEAD (e7a7874) and `git diff HEAD` is empty rebuild pnpm --filter @objectstack/metadata-protocol build dist ✓ marker absent from all 24 built files tree ✓ working tree clean against HEAD ``` The 5 cases that stay green under the ablation are the control legs — fresh-absent, fresh-true, the non-boolean cell, seeded-absent and the unseeded control — none of which depends on a flag arm. Nothing of the ablation is left in the tree; the mutation script carried a `trap` on `EXIT INT TERM` with absolute paths. ## Acceptance notes **1. ⭐ A published description is falsified by this PR, and it is fenced out of this card.** `packages/spec/src/kernel/package-registry.zod.ts:325` ships this `.describe()` text, which reaches the published reference page (`content/docs/references/kernel/package-registry.mdx:187` and `content/docs/references/api/protocol.mdx:1913`): > 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** The doc block above it says the same at length (「This contract's own implementation does not read the key」), and `packages/spec/src/api/package-api.zod.ts:305` carries a second copy. As of this PR all three are **false**. They were written by #19130, which merged at 11:10Z — two hours *after* this card was filed — so the card's author could not have fenced around them. ⛔ Not fixed here: the card and the dispatch both fence `packages/spec` out (「the declaration half belongs to #19273」), and editing a `.describe()` pulls in the whole spec generated-artifact family (`gen:schema`, `gen:docs`, `check:generated`) plus a second package's changeset — a new verification surface, so the bounded-in-place-fix exemption does not hold. It belongs to **#19273**, whose open question is already 「once the runtime honours 「缺省 = 保持」, what should the published `enableOnInstall` declaration say?」. Recorded here and in the report so it is not rediscovered as drift. No gate goes red on it: `check:docs` compares the generated page against the describe, and both still agree with each other. **2. The durable half of the in-process door, noted not filed.** A caller that sets `enableOnInstall: false` in-process now gets a disable that is real in the registry and absent from the runtime's disable file, so a restart re-enables it. That is narrower than the pre-PR gap (where the key did nothing at all) but newly reachable, and it cannot be closed at this seam: the record is keyed by an environment the request does not carry. Closing it means either giving `InstallPackageRequest` an environment or giving the caller the durable verb — a contract decision, not an implementation one. Who would meet this: only a caller that sets the key, of which there are none on the tree today. **3. `.changeset/18605-enable-on-install-one-authority.md` (unreleased) states 「Its published description now records that this layer does not read it」.** If it and this PR's changeset ship in the same release, one release's notes will say both. Belongs with finding 1, in #19273. Nothing else was touched: this diff is `packages/metadata-protocol/src/protocol.ts`, one new test file under `packages/objectql/src/`, and the changeset. --- _Generated by [Claude Code](https://claude.ai/code/session_01NcPSwnmJHczmTu6FG7NMjE)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9c3a314 commit 482d584

3 files changed

Lines changed: 372 additions & 1 deletion

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
"@objectstack/metadata-protocol": patch
3+
---
4+
5+
The in-process install primitive honours `enableOnInstall` instead of ignoring it (#19277).
6+
7+
`InstallPackageRequestSchema.enableOnInstall` (`kernel/package-registry.zod.ts`) is the request contract of `ObjectStackProtocol.installPackage` / `MetadataProtocol.installPackage`. The implementation read `request.manifest` and `request.settings` and nothing else, so a caller that asked for `enableOnInstall: false` got an ENABLED install — no refusal, no warning, no effect. That is a declared option the runtime did not deliver, which ADR-0049 (enforce-or-remove) and Prime Directive #10 refuse outright. Ruling batch #153 item 5 letter 1 (#18605) kept this declaration as a COPY of the HTTP request key with the same meaning, so the disposition is enforce, not retire.
8+
9+
The primitive now applies the same rule the HTTP door applies (maintainer ruling batch #157 item 5 letter C, 「缺省 = 保持,有旗 = 设置」), through the same registry verbs `PATCH /packages/:id/enable` and `PATCH /packages/:id/disable` use:
10+
11+
```text
12+
enableOnInstall: true ⇒ enablePackage — clears a disable, including a boot-seeded one
13+
enableOnInstall: false ⇒ disablePackage — the row and its `status` both move
14+
enableOnInstall absent ⇒ no lifecycle call at all; the row the registry returned stands
15+
```
16+
17+
Absent is a third state, not a synonym for `true`: on a FRESH id the registry still lands the package enabled (the declared default), and on an EXISTING row it preserves whatever that row says (#18877). A non-boolean value is read as absent rather than coerced.
18+
19+
⚠️ **What this seam does not write, stated rather than implied.** The runtime's durable disabled-package file is keyed by environment (`setPackageDisabled(environmentId, id, disabled)`, `@objectstack/runtime`), and an `InstallPackageRequest` carries no environment, so that record cannot be written from here — the HTTP door owns that half and writes it from the row it returned. `enableOnInstall` through the in-process primitive therefore moves the registry row, which is what every in-process reader serves from, for the life of the process; a caller that needs the choice replayed after a restart goes through the door that owns the durable record.
20+
21+
No behaviour changes for any caller on the tree: measured across `packages/**`, `examples/**` and `apps/**`, no existing call site sets the key — the HTTP door deliberately calls `installPackage({ manifest, settings })` and performs the flip itself, and `duplicatePackage` passes `{ manifest }` alone. The change is observable only to a caller that sets the key, which until now got silence.
22+
23+
Clause-②: no

‎packages/metadata-protocol/src/protocol.ts‎

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22327,6 +22327,13 @@ export class ObjectStackProtocolImplementation implements
2232722327
* The DB write is best-effort and non-fatal: when the `package` service is
2232822328
* absent (e.g. the `marketplace` capability is off) the package is still
2232922329
* registered in-memory and visible for the lifetime of the process.
22330+
*
22331+
* [#19277] `request.enableOnInstall` is HONOURED here, under the same rule
22332+
* the HTTP door implements — 「缺省 = 保持,有旗 = 设置」: `true` enables,
22333+
* `false` disables, and an ABSENT key makes no lifecycle call at all. The
22334+
* durable disabled-package FILE is not this seam's to write (it is keyed by
22335+
* environment, which this request does not carry); see the comment on the
22336+
* flag arms below.
2233022337
*/
2233122338
async installPackage(request: InstallPackageRequest): Promise<InstallPackageResponse> {
2233222339
// #2532 — runtime-created base packages routinely arrive versionless
@@ -22363,7 +22370,63 @@ export class ObjectStackProtocolImplementation implements
2236322370
// only); an unparsed range never causes a false rejection.
2236422371
assertProtocolCompat(manifest);
2236522372

22366-
const pkg = this.engine.registry.installPackage(manifest as any, request.settings);
22373+
let pkg = this.engine.registry.installPackage(manifest as any, request.settings);
22374+
22375+
// [#19277] HONOUR `enableOnInstall` — the key THIS request contract
22376+
// declares and this primitive read past. `InstallPackageRequestSchema`
22377+
// (`packages/spec/src/kernel/package-registry.zod.ts`) has carried the
22378+
// key since it was written, and the implementation here read
22379+
// `request.manifest` and `request.settings` and nothing else: a caller
22380+
// that switched the option off got an ENABLED install, with no refusal
22381+
// and no warning. That is «declared but not enforced» on a published
22382+
// option — what ADR-0049 (enforce-or-remove) and Prime Directive #10
22383+
// refuse outright. Ruling batch #153 item 5 letter 1 (#18605) kept the
22384+
// kernel declaration as a COPY of the HTTP request key with the SAME
22385+
// meaning, so the disposition is ENFORCE, not retire.
22386+
//
22387+
// ⭐ The contract is 「缺省 = 保持,有旗 = 设置」 — maintainer ruling batch
22388+
// #157 item 5 letter C, the same rule the HTTP door implements
22389+
// (`packages/runtime/src/domains/packages.ts`). Three states, three
22390+
// outcomes, through the SAME registry verbs `PATCH /packages/:id/enable`
22391+
// and `PATCH /packages/:id/disable` use:
22392+
//
22393+
// true ⇒ enablePackage
22394+
// false ⇒ disablePackage
22395+
// absent ⇒ nothing at all; the row the registry returned stands
22396+
//
22397+
// ⚠️ The `true` arm is not decoration. `SchemaRegistry.installPackage`
22398+
// has preserved an existing row's `enabled` / `status` /
22399+
// `statusChangedAt` since #18877, so on a re-install nothing else will
22400+
// clear a disable any more — dropping this arm would silently stop
22401+
// honouring `true` on exactly the path an upgrade takes.
22402+
//
22403+
// ⚠️ `=== true` / `=== false`, never a truthiness test and never a `??`
22404+
// default: the THREE states of this key are the contract, and
22405+
// collapsing absent into either one is the defect. The declaration's own
22406+
// `.default(true)` never reaches here — nothing parses an install
22407+
// request through `InstallPackageRequestSchema` on this path — so
22408+
// absence arrives intact and is read as absence.
22409+
//
22410+
// ⛔ What this seam does NOT write, recorded so it is not mistaken for
22411+
// an oversight: the runtime's durable disabled-package file. That record
22412+
// is keyed by ENVIRONMENT (`setPackageDisabled(environmentId, id,
22413+
// disabled)`, `packages/runtime/src/package-state-store.ts`) and this
22414+
// request carries no environment, so the key cannot even be formed here;
22415+
// the module also lives in `@objectstack/runtime`, which depends on this
22416+
// package and not the other way round. The HTTP door owns that half and
22417+
// writes it from the row it returned. So `enableOnInstall` through this
22418+
// primitive moves the registry row — what every in-process reader serves
22419+
// from — for the life of the process, and a caller that needs the choice
22420+
// to survive a restart goes through the door that owns the durable
22421+
// record.
22422+
const requestedEnabled = request.enableOnInstall;
22423+
if (requestedEnabled === true) {
22424+
const enabled = this.engine.registry.enablePackage(manifest.id);
22425+
if (enabled) pkg = enabled;
22426+
} else if (requestedEnabled === false) {
22427+
const disabled = this.engine.registry.disablePackage(manifest.id);
22428+
if (disabled) pkg = disabled;
22429+
}
2236722430

2236822431
// Best-effort durable persistence to `sys_packages` (non-fatal by
2236922432
// design — without the `package` service the install stays visible

0 commit comments

Comments
 (0)