Skip to content

Commit 4fef271

Browse files
huangyiireneclaude
andauthored
fix(objectql,runtime): a flag-absent install preserves the package's lifecycle state (#19291)
Fixes #18877 Clause-②: no Maintainer decision batch #157 item 5, letter C (ruling comment `5727164909`, director seat summon #24): the install contract becomes 「缺省 = 保持,有旗 = 设置」. ## The defect `SchemaRegistry.initialDisabledPackageIds` is a **boot hydration input** — filled once, before any registration, from the durable disable file, and never updated by `enablePackage` / `disablePackage`. It was nevertheless consulted by **every** `installPackage` call, so once an id was in the boot seed set, every re-install within that boot re-landed it disabled whatever the operator had most recently done. Since PR #18752 made the durable write follow the row the door returns, that stopped being memory-only: ```text boot 1 operator disables the package → disk lists the id boot 2 seeded from disk; the package installs disabled PATCH /packages/:id/enable → 200, registry true, disk CLEARED install(m, { overwrite: true }) (no flag) → the seed still listed the id → row disabled, disk written DISABLED boot 3 the operator's enable is gone, with no error anywhere ``` Reachable with nothing exotic: disable → restart → enable in Studio → an SDK upgrade with `overwrite`. ## What changed — the ruling's items 1, 2 and 3 **Item 1 · `packages/objectql/src/registry.ts` · `installPackage`.** The row is read **first**: an existing row keeps its own `enabled`, `status` and `statusChangedAt`, and the boot seed decides only for an id that has no row yet (boot hydration and a genuinely fresh install). A fresh id the seed never named still lands enabled, the declared default. `statusChangedAt` is carried, never restamped — a re-install is not a lifecycle move. **Item 2 · `packages/runtime/src/domains/packages.ts` · the install door.** `enableOnInstall: true` ⇒ `enablePackage`, `false` ⇒ `disablePackage`, **absent ⇒ no lifecycle call at all**. Only `false` was read before; the `true` case was carried by the re-install restamping every row enabled, so under item 1 it would have silently stopped working — the same «declared ≠ enforced» defect card #18058 was about, pointing the other way. The bare (unwrapped) body form still honours nothing: no schema declares the key there, so it is always 「缺省」. **Item 3 · `DELETE /packages/:id`.** The id leaves the boot seed set with its row (inside `uninstallPackage`, downstream of the ADR-0029 refusal point, so a refused uninstall still removes nothing at all) and its durable disable entry is cleared. Previously that record was immortal: a delete left behind a disable naming a package that no longer existed. **Item 5 is not here.** It measured TRUE and rides on spec sub-card #19273; this PR does not touch `packages/spec/**` at all, which is why it declares `Clause-②: no`. ## Item 4 — the pins that were re-ruled Every flip is an expectation change on an existing pin, ⛔ never a deletion. In `packages/runtime/src/domains/packages-install-enable-on-install.test.ts`: | pin | asserted before | asserts now | authority | |---|---|---|---| | `re-installing with the flag ABSENT …` | clears the durable disable — row / registry / disk all `true` | **preserves** it — row / registry / disk all `false`, plus `status` carried | ruling item 4, by name | | `a BARE re-install …` | clears it too, because that form cannot ask for `false` | **preserves** it — a body that cannot ask for a lifecycle change is 「缺省」 | ruling items 1 and 2 | | `a seeded id asked for enableOnInstall: true …` | SELF-CONSISTENT only — 「the seed wins」, no claim about which state it lands in | **ENABLED** — the flag outranks the seed; the self-consistency assertions are kept underneath the new one | ruling item 2 | **F1b stands unchanged**, exactly as the ruling says: every one of its cases is a seeded id whose install asks for nothing, which still lands — and stays — disabled. The `#18058 MEASURED` matrix (`disk.has(id) === (row.enabled === false)`) is untouched and still holds on all eleven arms. New pins: `packages/runtime/src/domains/packages-install-preserves-lifecycle.test.ts` (the card's own scenario end to end — row, registry, disk **and** a restart — plus both 「有旗」 arms and both halves of item 3) and `packages/objectql/src/registry-install-preserves-lifecycle.test.ts` (the registry's own answer, which the door's pins structurally cannot see: on every door arm 「the registry preserved the row」 and 「the door re-applied the same state」 produce the same three records). ## Verification Full local suites of both affected packages, plus the derived gate set, at the final commit. | run | result | |---|---| | `pnpm --filter @objectstack/objectql test` | 301 files / 5018 tests passed | | `pnpm --filter @objectstack/objectql typecheck` | exit 0 | | `pnpm --filter @objectstack/runtime test` | 269 files / 3714 passed, 1 skipped | | `pnpm --filter @objectstack/runtime run test:repo` | 2 files / 69 tests passed | | `pnpm --filter @objectstack/runtime typecheck` | exit 0 | | `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` | 63 families derived, 63 run, **all exit 0** | | `--ran` reconciliation | 63 derived, 63 accounted, 0 UNRUN, 0 NOT-MEASURED | | `pnpm lint` (repo-wide `eslint . --no-inline-config`) | exit 0 — run whole, so no narrowing to justify | | four roster gates whose roster sits under these paths | `check:authz-resolver`, `check:error-code-casing`, `check:filter-alias-parity`, `check:route-ledger-census` — all exit 0 | `pnpm check:dual-build-cjs-loads` first answered **exit 3 — PREREQUISITE NOT MET** (no `dist/` for 37 packages). That is not a pass and was not recorded as one: a full `pnpm build` (73/73 tasks) was run and the gate re-run to exit 0. ### Reverse verification — direction predicted before running, both legs proven on disk Both ablations went through `scripts/ablation-replace.mjs` (anchor must hit; the write is verified against the disk and the blob hash, never against an exit code) and `scripts/ablation-dist-preflight.mjs` (the mutation really reached the `dist/` the consuming suite reads, and really left it again). **A · revert item 1** — plant `ablation18877RevertsPreserve` so `installPackage` never sees the existing row. Marker present in 4 built files of `@objectstack/objectql`. Predicted red, observed red: 3 of 9 registry pins and 6 of 34 door pins failed, including the card's own end-to-end scenario and both flipped ABSENT pins. Restored: blob `f75d202b2b98` == HEAD, `git diff HEAD` empty, marker absent from all 14 built files, whole-tree `git status --porcelain` clean. **B · drop item 2's `true` arm** — plant `ablation18877DropsTrueArm` to restore the pre-ruling door (only `false` moves the registry). Marker present in 2 built files of `@objectstack/runtime`. Predicted red, observed red: exactly 3 pins failed, all of them `true`-arm ones — and one of them is #18058's own pre-existing `re-installing with enableOnInstall: true clears the durable disable`, which is the measurement showing the new arm is load-bearing rather than decoration. Restored: blob `010a542447e8` == HEAD, `git diff HEAD` empty, marker absent from all 6 built files, tree clean. Both suites green again afterwards at `e40efa41b` (9 + 34 passed), which is the commit every number above was taken at. ## Acceptance notes - **Declared file-surface breach.** The dispatch claim named `packages/objectql/src/` as the file surface. The ruling's items 2 and 3 land in `packages/runtime/src/domains/packages.ts`, and the pins item 4 re-rules live in `packages/runtime/src/domains/packages-install-enable-on-install.test.ts` — so **no arm of this card is implementable inside the declared surface**, including a registry-only slice, which would have left `enableOnInstall: true` silently unhonoured on an existing row. The breach was de-risked first, not assumed: all 24 open PRs were enumerated and **none** touches `packages/objectql/src/registry.ts`, `packages/runtime/src/domains/packages.ts`, `packages/runtime/src/package-state-store.ts` or the `#18058` pin file. PR #18319 touches two other test files in the same directory and is file-disjoint. - **H17 rider #12789 — SKIPPED, deliberately.** The two `[Registry] Collision` warnings in `registry.ts` do quote the package id inconsistently (one double-quoted at the `sys_metadata`-shadow branch, one single-quoted at its sibling), so the defect is real and it is one line. It is skipped for three reasons: #12789 is `pm:on-hold` and its card was not read, so which spelling it rules canonical is unknown; the text is runtime output of a published package, so changing it grows the changeset story this card was told not to grow; and it is unrelated to the install contract. - **noted, not filed:** `setPackageDisabled` failures are logged at `warn` at all four call sites in this door (three pre-existing, one added by item 3 in the same words). By the degradation-log rule that is arguably a durability seam — the write claims to persist, the response stays a clean 200/201, and the loss surfaces one restart later — but `setPackageDisabled` is deliberately absent from `DURABILITY_CRITICAL_CALLEES`, so escalating it would red all four sites at once and is a vocabulary decision, not this card's. Carrier: the next card that touches this door's durable half. - **noted, not filed:** nothing parses an install request through `PackageInstallRequestSchema` on the serving path — `PackageApiContracts` has no runtime consumer and the handler reads the raw body — so the schema's `.default(true)` is inert there today. That is what makes «absent» visible to the door at all, and it is the mechanism the PM's own assumption 1 predicted by a different route. It is not filed as a defect because #19273 already owns the declaration half; recorded here so the next reader does not conclude the door is parsing. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- _Generated by [Claude Code](https://claude.ai/code/session_01NcPSwnmJHczmTu6FG7NMjE)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent cdedfb3 commit 4fef271

6 files changed

Lines changed: 797 additions & 83 deletions

File tree

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
"@objectstack/objectql": patch
3+
"@objectstack/runtime": patch
4+
---
5+
6+
Installing a package no longer reverts an operator's most recent enable/disable. The install contract is now 「缺省 = 保持,有旗 = 设置」: an install that was not asked to move the lifecycle state does not move it (#18877).
7+
8+
`SchemaRegistry.initialDisabledPackageIds` is a boot hydration input — filled once, before any registration, from the durable disable file, and never updated by `enablePackage` / `disablePackage`. It was nevertheless consulted by every `installPackage` call, so once an id was in the boot seed set, every re-install within that boot re-landed it DISABLED whatever the operator had most recently done. Since the durable write started following the row the door returns (#18752), that stopped being memory-only:
9+
10+
```text
11+
boot 1 operator disables the package → disk lists the id
12+
boot 2 seeded from disk; the package installs disabled
13+
PATCH /packages/:id/enable → 200, registry true, disk CLEARED
14+
install(m, { overwrite: true }) (no flag) → the seed still listed the id
15+
→ row disabled, disk written DISABLED
16+
boot 3 the operator's enable is gone, with no error anywhere
17+
```
18+
19+
Reachable with nothing exotic: disable → restart → enable in Studio → an SDK upgrade with `overwrite`.
20+
21+
- **`installPackage` reads the ROW first.** An existing row keeps its own `enabled`, `status` and `statusChangedAt`; the boot seed decides only for an id that has no row yet (boot hydration and a genuinely fresh install). A fresh id the seed never named still lands enabled, the declared default.
22+
- **`enableOnInstall` now sets the state in BOTH directions.** `true` ⇒ `enablePackage`, `false` ⇒ `disablePackage`, and an ABSENT flag makes no lifecycle call at all — previously only `false` was read, and the `true` case was carried by the re-install restamping every row enabled. The bare (unwrapped) body form still honours nothing: no schema declares the key there.
23+
- **`DELETE /packages/:id` clears both records.** The id leaves the boot seed set with its row, and its durable disable entry is cleared, so the next install of that id is a fresh install. Previously the durable record was immortal — a delete left a disable behind that named a package that no longer existed.
24+
- ⚠️ **Behaviour change for a flag-absent re-install of an EXISTING row.** It used to return the package to the declared default (enabled); it now preserves what the row says. An upgrade flow that relied on a re-install to clear a disable must now send `enableOnInstall: true` — the same key, the same door, now honoured in that direction. A fresh install is unaffected.
25+
26+
Maintainer decision batch #157 item 5, letter C. Item 4 of that ruling re-rules the #18058 F1 pin 「flag-absent re-install clears the durable disable」 to 「preserves」; F1b stands unchanged.
27+
28+
Clause-②: no
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#18877] `SchemaRegistry.installPackage` — 「缺省 = 保持」, the engine half of
5+
* the install contract ruled in maintainer decision batch #157 item 5, letter C.
6+
*
7+
* ## The rule, in one sentence
8+
*
9+
* An EXISTING row keeps its own `enabled` / `status` / `statusChangedAt`, and
10+
* the boot seed decides only for an id that has no row yet. `installPackage`
11+
* takes no lifecycle argument at all, so at this layer every install is 「缺省」
12+
* — 「有旗」 is spelled one layer up, where `enableOnInstall` reaches
13+
* `enablePackage` / `disablePackage` (`packages/runtime/src/domains/packages.ts`).
14+
*
15+
* ## Why the seed had to stop being consulted every time
16+
*
17+
* `initialDisabledPackageIds` is a BOOT HYDRATION input. It is filled once,
18+
* before any registration, from the durable disable file, and no lifecycle verb
19+
* updates it — so it goes stale the moment an operator enables anything. Read
20+
* on every install, it let the most recent explicit lifecycle action be
21+
* reverted by an unrelated re-install, durably and with no error anywhere
22+
* (`packages/runtime/src/domains/packages-install-preserves-lifecycle.test.ts`
23+
* pins that scenario through the real door, disk and restart included).
24+
*
25+
* Reading the ROW first is what makes the stale answer unreachable: by the time
26+
* an operator can have acted on a package, a row for it exists.
27+
*
28+
* ⛔ These cases deliberately do NOT go through the HTTP door. The door's own
29+
* pins cannot tell 「the registry preserved the row」 from 「the door re-applied
30+
* the same state」, because on every arm the two produce the same three
31+
* records. This file is the only place the registry's own answer is visible.
32+
*/
33+
34+
import { describe, it, expect, beforeEach } from 'vitest';
35+
import { SchemaRegistry } from './registry.js';
36+
37+
const manifest = (id: string, namespace?: string) =>
38+
({ id, name: `Pkg ${id}`, version: '1.0.0', ...(namespace ? { namespace } : {}) }) as any;
39+
40+
describe('#18877 — installPackage preserves an existing row\'s lifecycle state', () => {
41+
let registry: SchemaRegistry;
42+
43+
beforeEach(() => {
44+
registry = new SchemaRegistry({ multiTenant: false, collisionPolicy: 'error' });
45+
(registry as any).logLevel = 'silent';
46+
});
47+
48+
it('a re-install carries `enabled` / `status` / `statusChangedAt` over from the existing row', () => {
49+
registry.installPackage(manifest('com.acme.carry'));
50+
const disabled = registry.disablePackage('com.acme.carry');
51+
const stampedAt = disabled?.statusChangedAt;
52+
expect(typeof stampedAt, 'precondition: the disable stamped a time').toBe('string');
53+
54+
const again = registry.installPackage(manifest('com.acme.carry'));
55+
56+
expect(again.enabled, 'the row the registry RETURNED').toBe(false);
57+
expect(again.status).toBe('disabled');
58+
// A re-install is not a lifecycle move, so the stamp is carried, not
59+
// refreshed — otherwise «when did an operator last move this package»
60+
// would answer «at the last upgrade».
61+
expect(again.statusChangedAt).toBe(stampedAt);
62+
expect(registry.getPackage('com.acme.carry')?.enabled).toBe(false);
63+
});
64+
65+
it('⛔ the control leg — a re-install does not disable an ENABLED row either', () => {
66+
// Without this, «preserve» is satisfiable by a registry that simply
67+
// stopped enabling anything.
68+
registry.installPackage(manifest('com.acme.stayon'));
69+
const again = registry.installPackage(manifest('com.acme.stayon'));
70+
71+
expect(again.enabled).toBe(true);
72+
expect(again.status).toBe('installed');
73+
expect(again.statusChangedAt, 'an always-enabled row never carried a stamp').toBeUndefined();
74+
});
75+
76+
it('the boot seed still decides for an id that has NO row yet', () => {
77+
registry.setInitialDisabledPackageIds(['com.acme.seeded']);
78+
79+
const first = registry.installPackage(manifest('com.acme.seeded'));
80+
81+
expect(first.enabled, 'boot hydration must still replay a persisted disable').toBe(false);
82+
expect(first.status).toBe('disabled');
83+
expect(typeof first.statusChangedAt).toBe('string');
84+
});
85+
86+
it('⭐ but the seed cannot outrank a row: enable, then re-install, and the enable stands', () => {
87+
// The card's own defect, at this layer. Before #18877 the second
88+
// install read the seed again and re-landed the package disabled.
89+
registry.setInitialDisabledPackageIds(['com.acme.seeded.enabled']);
90+
registry.installPackage(manifest('com.acme.seeded.enabled'));
91+
registry.enablePackage('com.acme.seeded.enabled');
92+
93+
const again = registry.installPackage(manifest('com.acme.seeded.enabled'));
94+
95+
expect(again.enabled, 'the most recent explicit lifecycle action stands').toBe(true);
96+
expect(again.status).toBe('installed');
97+
expect(registry.getPackage('com.acme.seeded.enabled')?.enabled).toBe(true);
98+
});
99+
100+
it('an id the seed never named still lands at the declared default', () => {
101+
registry.setInitialDisabledPackageIds(['com.acme.someone.else']);
102+
const fresh = registry.installPackage(manifest('com.acme.unseeded'));
103+
expect(fresh.enabled).toBe(true);
104+
expect(fresh.status).toBe('installed');
105+
});
106+
107+
it('the non-lifecycle facets of a re-install are UNCHANGED — manifest and `updatedAt` still move', () => {
108+
// ⛔ The scope leg: 「preserve」 is about lifecycle state only. A
109+
// re-install is still an install, and the row it writes is still the
110+
// new manifest's.
111+
registry.installPackage({ ...manifest('com.acme.scope'), version: '1.0.0' } as any);
112+
registry.disablePackage('com.acme.scope');
113+
114+
const again = registry.installPackage({ ...manifest('com.acme.scope'), version: '2.0.0' } as any);
115+
116+
expect(again.manifest.version, 'the re-install really replaced the manifest').toBe('2.0.0');
117+
expect(again.enabled, 'and did so without touching the lifecycle state').toBe(false);
118+
});
119+
});
120+
121+
describe('#18877 item 3 — uninstallPackage forgets the id from the boot seed', () => {
122+
let registry: SchemaRegistry;
123+
124+
beforeEach(() => {
125+
registry = new SchemaRegistry({ multiTenant: false, collisionPolicy: 'error' });
126+
(registry as any).logLevel = 'silent';
127+
});
128+
129+
it('a package deleted and re-installed in the same boot is a FRESH install', () => {
130+
registry.setInitialDisabledPackageIds(['com.acme.gone']);
131+
expect(registry.installPackage(manifest('com.acme.gone')).enabled, 'precondition: the seed applied').toBe(false);
132+
133+
expect(registry.uninstallPackage('com.acme.gone')).toBe(true);
134+
135+
const reinstalled = registry.installPackage(manifest('com.acme.gone'));
136+
expect(
137+
reinstalled.enabled,
138+
'a row that no longer exists has no lifecycle state — and the seed must not supply one',
139+
).toBe(true);
140+
expect(reinstalled.status).toBe('installed');
141+
});
142+
143+
it('⛔ and it forgets only the id it was given', () => {
144+
registry.setInitialDisabledPackageIds(['com.acme.gone2', 'com.acme.neighbour']);
145+
registry.installPackage(manifest('com.acme.gone2'));
146+
registry.uninstallPackage('com.acme.gone2');
147+
148+
const neighbour = registry.installPackage(manifest('com.acme.neighbour'));
149+
expect(neighbour.enabled, 'the neighbour\'s persisted disable is untouched').toBe(false);
150+
});
151+
152+
it('⛔ a refused uninstall forgets nothing — every mutation stays downstream of the refusal', () => {
153+
// ADR-0029: a package whose object another package `extend`s cannot be
154+
// uninstalled. The seed delete must not be the one step that happens
155+
// anyway, or a refused uninstall would silently re-enable the package
156+
// on its next registration.
157+
registry.setInitialDisabledPackageIds(['com.acme.extended']);
158+
registry.installPackage(manifest('com.acme.extended', 'ext'));
159+
registry.registerObject({ name: 'account', fields: {} } as any, 'com.acme.extended', 'ext', 'own');
160+
registry.registerObject({ name: 'account', fields: {} } as any, 'com.acme.extender', undefined, 'extend');
161+
162+
expect(() => registry.uninstallPackage('com.acme.extended')).toThrow(
163+
/extended by com\.acme\.extender/,
164+
);
165+
166+
// The row survived the refusal — and so did the seed entry, which is
167+
// only observable once the row is gone for real.
168+
expect(registry.getPackage('com.acme.extended'), 'the refused uninstall removed nothing').toBeDefined();
169+
registry.unregisterObjectsByPackage('com.acme.extender');
170+
expect(registry.uninstallPackage('com.acme.extended'), 'now it really goes').toBe(true);
171+
expect(
172+
registry.installPackage(manifest('com.acme.extended', 'ext')).enabled,
173+
'and only THEN is the id forgotten',
174+
).toBe(true);
175+
});
176+
});

‎packages/objectql/src/registry.ts‎

Lines changed: 69 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1980,19 +1980,28 @@ export class SchemaRegistry {
19801980
private appNavContributions = new Map<string, Array<{ packageId?: string; group?: string; priority: number; items: any[] }>>();
19811981

19821982
/**
1983-
* Package ids that must be installed in a DISABLED state. Seeded once at
1984-
* boot (from persisted state) BEFORE any package registration so that every
1985-
* registration path — boot artifact, marketplace rehydrate, local import —
1986-
* honors persisted disable state uniformly without a fragile post-boot
1987-
* re-application hook. See {@link setInitialDisabledPackageIds} and
1988-
* {@link installPackage}.
1983+
* Package ids that must be installed in a DISABLED state **when they have no
1984+
* row yet**. Seeded once at boot (from persisted state) BEFORE any package
1985+
* registration so that every registration path — boot artifact, marketplace
1986+
* rehydrate, local import — honors persisted disable state uniformly without
1987+
* a fragile post-boot re-application hook.
1988+
*
1989+
* ⚠️ [#18877] It is a BOOT HYDRATION input, not a standing verdict: nothing
1990+
* updates it after the seed, so once a row exists the row is the truth and
1991+
* this set is not consulted. Reading it on every install is what let a
1992+
* flag-absent re-install durably revert an operator's later `enable`.
1993+
*
1994+
* See {@link setInitialDisabledPackageIds}, {@link installPackage} and
1995+
* {@link uninstallPackage} (which forgets the id with the row).
19891996
*/
19901997
private initialDisabledPackageIds = new Set<string>();
19911998

19921999
/**
19932000
* Seed the set of package ids that should be installed disabled. Call this
1994-
* before package registration begins; later `installPackage` calls for these
1995-
* ids land in the `disabled` state. Replaces any previously seeded set.
2001+
* before package registration begins; the FIRST `installPackage` call for
2002+
* each of these ids lands it in the `disabled` state, and every later call
2003+
* for that id reads the row it created instead (#18877). Replaces any
2004+
* previously seeded set.
19962005
*/
19972006
setInitialDisabledPackageIds(ids: Iterable<string>): void {
19982007
this.initialDisabledPackageIds = new Set(ids);
@@ -4169,18 +4178,56 @@ export class SchemaRegistry {
41694178
this.refuseCoOwnedObjectNameCollision(manifest, selfId, coOwners);
41704179

41714180
const now = new Date().toISOString();
4172-
const disabled = this.initialDisabledPackageIds.has(manifest.id);
4181+
4182+
// [#18877] 「缺省 = 保持,有旗 = 设置」 — an install that was not asked to move
4183+
// the lifecycle state does not move it. The install verb carries no
4184+
// lifecycle argument at all, so at THIS layer every install is
4185+
// 「缺省」 and the whole rule is: an EXISTING row keeps its own
4186+
// `enabled` / `status` / `statusChangedAt`, and the boot seed decides only
4187+
// for an id that has no row yet. The door above is where 「有旗」 is spelled
4188+
// (`enableOnInstall` ⇒ `enablePackage` / `disablePackage`,
4189+
// `packages/runtime/src/domains/packages.ts`).
4190+
//
4191+
// ⚠️ The seed set is a BOOT HYDRATION input, not a standing verdict. It is
4192+
// filled once, before any registration, from the durable disable file
4193+
// (`AppPlugin.seedPersistedDisabledPackages`) and no lifecycle verb updates
4194+
// it. Consulting it on every install is what made the most recent explicit
4195+
// operator action revertible by an unrelated re-install: disable → restart
4196+
// → `PATCH /packages/:id/enable` (200, registry true, disk cleared) →
4197+
// flag-absent `install(m, { overwrite: true })` → the seed still listed the
4198+
// id → the row landed disabled again, and since #18752 the disk follows the
4199+
// row, so the enable was gone after the next restart with no error
4200+
// anywhere. Reading the ROW first is what makes the seed's answer
4201+
// unreachable once a row exists — and a row always exists by the time an
4202+
// operator can have acted on it.
4203+
const existing = this.getPackage(manifest.id);
4204+
const seeded = this.initialDisabledPackageIds.has(manifest.id);
4205+
const lifecycle: Pick<InstalledPackage, 'status' | 'enabled' | 'statusChangedAt'> = existing
4206+
? {
4207+
status: existing.status,
4208+
enabled: existing.enabled,
4209+
// Carried, never restamped: `statusChangedAt` answers "when did an
4210+
// operator last move this package", and a re-install is not such a
4211+
// move. A row that never carried one still does not.
4212+
...(existing.statusChangedAt !== undefined
4213+
? { statusChangedAt: existing.statusChangedAt }
4214+
: {}),
4215+
}
4216+
: {
4217+
status: seeded ? 'disabled' : 'installed',
4218+
enabled: !seeded,
4219+
...(seeded ? { statusChangedAt: now } : {}),
4220+
};
4221+
41734222
const pkg: InstalledPackage = {
41744223
// The RECORD's manifest, not the caller's live object — see
41754224
// {@link toRecordManifest}. Every other read below (`manifest.id`,
41764225
// `manifest.namespace`) deliberately keeps reading the ARGUMENT: the
41774226
// projection is what the registry hands out, never what it decides with.
41784227
manifest: toRecordManifest(manifest),
4179-
status: disabled ? 'disabled' : 'installed',
4180-
enabled: !disabled,
4228+
...lifecycle,
41814229
installedAt: now,
41824230
updatedAt: now,
4183-
...(disabled ? { statusChangedAt: now } : {}),
41844231
settings,
41854232
};
41864233

@@ -4295,6 +4342,16 @@ export class SchemaRegistry {
42954342
// (ADR-0029 extenders): a refused uninstall must remove nothing at all.
42964343
this.unregisterItemsByPackage(id);
42974344

4345+
// [#18877] The boot seed forgets the id together with the row. A package
4346+
// that no longer exists has no lifecycle state to preserve, so the next
4347+
// install of this id must be a FRESH install — and without this line the
4348+
// seed would outlive the row and re-disable it, which is exactly the
4349+
// 「听不见的持久回退」 this card removed from the install path. Runs with the
4350+
// record removal below rather than at the top: every mutation here is
4351+
// downstream of `unregisterObjectsByPackage`, the one step that can REFUSE
4352+
// (ADR-0029), so a refused uninstall still removes nothing at all.
4353+
this.initialDisabledPackageIds.delete(id);
4354+
42984355
// Remove package record
42994356
const collection = this.metadata.get('package');
43004357
if (collection) {

0 commit comments

Comments
 (0)