Skip to content

Commit 4c31821

Browse files
fix(runtime): attribute the package DELETE and clear the disable record in the uninstall verb (#31183)
<!-- claude-projects-attribution: {"github_login":"marchtian"} --> _Requested by **田騏** · [project thread](https://claude.ai/code/project/chan_01VmuUhXU9SSUzqJYyXSmYSi?thread=cmsg_01VmuUhXU9SSUzqJYyXSmYSiUfC8G7ZWNaCdELoQcXkaCC)_ Fixes #31023 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:Studio 删除应用时,把当前登录的人传给卸载动作,被删的每条元数据在审计日志和历史里都记下是谁删的;「此包在本环境已停用」的记录改由卸载动作自己清掉,平台内部任何地方调用删除都会清。 - 为什么改:删除接口原先不传操作人,审计里删除人一栏是空的;cloud 的 AI 工具、本地安装撤销、导入回滚这些内部删除不清停用记录,重启后再装同一个包一装上就是停用。 - 风险与回滚:只改 runtime;卸载结果的 cleanups 里多一条 runtime.package-state;对一个已不存在的包发删除(404)现在也会清掉同名的停用记录;`sys_packages` 那一行的删除仍然没有审计行(见下方说明,需席位定夺)。回滚即 revert 本 PR。 - 先看:packages/runtime/src/package-state-host-store.ts(新的卸载清理)、packages/runtime/src/domains/packages.ts(DELETE 分支的操作人与「卸载动作已清则门不再写」判断)、packages/runtime/src/app-plugin.ts(start 里注册清理)。 - 两分钟验证:`pnpm --filter @objectstack/runtime exec vitest run src/package-state-host-store.test.ts src/domains/packages-write-actor-session.test.ts src/package-uninstall-store-refusal.integration.test.ts`,预期三个文件全绿。 - 需要你做的:无,席位落地。 ## What changes - **The actor.** `DELETE /api/v1/packages/:id` now resolves the request's authenticated user with `resolvePackageWriteActor` (ADR-0146 D1, the one helper its sibling doors use; no second copy) and hands it to `protocol.deletePackage` as `actor`. The verb already forwarded `actor` to each `deleteMetaItem`, so every metadata delete is now credited: `provenance.attributedUserId` on the `sys_metadata` delete (what the audit writer records as `sys_audit_log.user_id`) and `recorded_by` on the history tombstone. A caller with no user id sends no `actor`, as before. No new key: `actor` was already on `DeletePackageRequest`. - **The disable-record clear moves into the verb, through a seam it already runs.** `@objectstack/metadata-protocol` cannot reach the runtime's package-state store without importing `@objectstack/runtime`, so the clear rides the protocol's uninstall-cleanup registry (`registerUninstallCleanup`, ADR-0086 D3), which `deletePackage` runs as its last step and install-local's `DELETE` runs too. `AppPlugin.start` registers it once per protocol (`ensurePackageStateUninstallCleanup`, name `runtime.package-state`); it resolves the store when it runs (the host's `package-state-store` service, else the file under `OS_HOME`) and clears the key the boot seed reads (`AppPlugin`'s project-context environment). A refusing store is that cleanup's failed outcome on the uninstall's `cleanups`, through the registry's one runner. `protocol-package-uninstall.ts` is not edited. - **The door writes the record only when the verb did not.** When `persisted.cleanups` carries the `runtime.package-state` outcome, the door leaves the record alone; with no persisted half, or a protocol on a kernel that registered no such cleanup (no `AppPlugin`), it clears the record itself exactly as before. ## Tests - `package-uninstall-store-refusal.integration.test.ts` (real ObjectQL + SQLite + protocol + dispatcher): a signed-in admin's DELETE credits `u_admin` on the audited `sys_metadata` delete and on the history tombstone; CONTROL: a system caller's DELETE credits no one. - `packages-write-actor-session.test.ts`: the DELETE door hands the verb the session user (with and without `keepData`); CONTROL: a system context sends no `actor`. - `package-state-host-store.test.ts` (real kernel, `ObjectQLPlugin` + `AppPlugin` + SQLite): an in-process `deletePackage` clears the file record and a reinstall after a restart comes up enabled; the same with a host store; a refusing host store is the cleanup's failed outcome and the record stays; the HTTP DELETE leaves the clear to the verb (one write, not two). - Four existing pins that asserted the exact `deletePackage` request now expect the signed-in caller's `actor` beside the unchanged keys (`packages-write-flag-arity`, `packages-write-doors-closed-query`, `packages-uninstall-refuse-before-mutate`, `packages-orgless-grants-capability-gate`). The edited test files take a module-top `import '@objectstack/metadata'` (`check:test-source-alias`). ## Acceptance notes - **Not done, for the seat to decide: the `sys_packages` delete is not audited at all.** `sys_packages` is not an object; the package service deletes its row with raw SQL through `engine.execute`, which runs no hooks, so no `sys_audit_log` row exists for that delete with or without an actor. Attributing it needs a new audit writer or a `PackageService.delete` signature change, which this card's `Clause-②: no` does not cover. What the person did is still recorded: each metadata row the uninstall removed carries the actor. - **A 404 DELETE now clears a record naming the id.** The verb runs its cleanups for every id it is asked to uninstall, so a DELETE of an id that is neither installed nor stored clears a disable record naming it. Before, the door wrote nothing on a 404. - **install-local's `DELETE` and the two rollbacks also clear the record now**, since they reach the same runner (#18877 item 3: a package that no longer exists has no lifecycle state). - **The environment key is the boot seed's, not the request's.** On a standalone boot both are unset; a host whose resolver sets a request environment different from `AppPlugin`'s project context would already have its disable door and boot seed disagree. - **File surface:** `app-plugin.ts` and `package-state-host-store.ts` (with its test) are outside the claim's declared surface; they are where the seam has to be registered. No new package export (`PACKAGE_STATE_UNINSTALL_CLEANUP` and `ensurePackageStateUninstallCleanup` are module-internal; `index.ts` names its exports). - The audit pin reads the hook's `provenance.attributedUserId` rather than a `sys_audit_log` row: `@objectstack/plugin-audit` is not a runtime dependency, and its own tests pin that field into `user_id`. ## Verification <details><summary>Reverse verification (each mutation landed on disk, then restored to the HEAD blob)</summary> - A1, the DELETE door sends no actor: 3 red (`a signed-in caller's delete names the caller`, `beside keepData, the caller is still named`, `a signed-in admin's delete names the admin on the audited delete and on the history tombstone`); both CONTROLs stay green. - A2, `AppPlugin.start` registers no cleanup: 3 red (the in-process delete, the host-store clear, the refusing store). - A3, the door clears even when the verb did: 1 red (`one write, not two`). </details> <details><summary>Local runs</summary> - `pnpm --filter @objectstack/runtime test` before the fixture update: 9 failed in 4 files, all the exact-request pins above, fixed in this PR; the seven edited test files then: 7 files, 148 tests passed. - `pnpm --filter @objectstack/runtime typecheck`: exit 0. eslint over `packages/runtime` with `--no-inline-config`: exit 0. - The derived gate list, run locally: see the report comment on the card. </details> 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent de3996b commit 4c31821

12 files changed

Lines changed: 305 additions & 17 deletions
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/runtime': patch
3+
---
4+
5+
fix(runtime): `DELETE /api/v1/packages/:id` credits its deletes to the signed-in user, and the uninstall verb clears the package's disable record for every caller
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
The package `DELETE` door now hands `deletePackage` the request's authenticated user, as the other package write doors already do (ADR-0146 D1), so each metadata row the uninstall removes is credited to that user: the audit log row's user and the history tombstone's `recorded_by`. A caller with no user id (a system context) still sends no actor, and its deletes stay unattributed.
11+
12+
A deleted package's per-environment disable record is now cleared by the uninstall verb itself, through a runtime uninstall cleanup (`runtime.package-state`) that `AppPlugin` registers on the protocol. Before, only the HTTP door cleared it, so an in-process `deletePackage` (a host's uninstall tool, install-local's `DELETE` and pair-install undo, the unmanaged-import rollback) left the id listed and the next boot brought a reinstalled package back disabled. The clear writes the key the boot reads, in the host's package-state store when one is registered and the file under `OS_HOME` otherwise; a store that refuses shows as that cleanup's failed outcome in the uninstall's `cleanups`. On a kernel that registers no such cleanup, the door still clears the record itself.

‎packages/qa/http-conformance/src/hono-query-multiplicity.conformance.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,7 @@ describe('[#23214] a real createHonoApp host refuses a repeated single-valued na
162162
const { send, protocol } = await boot();
163163
const answer = await send('DELETE', `/packages/${PKG}?keepData=true`);
164164
expect(answer.status).toBe(200);
165-
expect(protocol.deletePackage.mock.calls.map((c) => c[0])).toEqual([{ packageId: PKG, keepData: true }]);
165+
// [#31023] The door also credits the delete to the signed-in author (ADR-0146 D1).
166+
expect(protocol.deletePackage.mock.calls.map((c) => c[0])).toEqual([{ packageId: PKG, keepData: true, actor: AUTHOR.userId }]);
166167
});
167168
});

‎packages/runtime/src/app-plugin.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import { describeSeedLocale, resolveSeedLocaleForLoad } from './seed-locale.js';
3131
import { declareSeedSource, recordOrganizationReplay, type SeedLandingReport } from './seed-settlement.js';
3232
import { loadDisabledPackageIds } from './package-state-store.js';
3333
import { readServedWithheldFieldReaders, servedWithheldFieldReadersMessage } from './domains/packages.js';
34-
import { asPackageStateStore, PACKAGE_STATE_STORE_PLUGIN_NAME } from './package-state-host-store.js';
34+
import { asPackageStateStore, ensurePackageStateUninstallCleanup, PACKAGE_STATE_STORE_PLUGIN_NAME } from './package-state-host-store.js';
3535
import type { IFieldFileService, IMetadataService, IObjectQLEngine, II18nService } from '@objectstack/spec/contracts';
3636
import { FIELD_FILE_SERVICE } from '@objectstack/spec/contracts';
3737
import { normalizeFlowFunctionEntry, type NormalizedFlowFunction } from '@objectstack/spec/automation';
@@ -1396,6 +1396,11 @@ export class AppPlugin implements Plugin {
13961396

13971397
start = async (ctx: PluginContext) => {
13981398
this.refuseLatePackageStateStore(ctx);
1399+
// [#31023] The uninstall verb clears a deleted package's disable record
1400+
// under the key this plugin's boot seed reads. Before the empty-env
1401+
// return: an empty env is where every package arrives by hydration or
1402+
// install, and is deleted the same way.
1403+
ensurePackageStateUninstallCleanup(ctx, this.projectContext?.environmentId);
13991404
if (this.empty) {
14001405
ctx.logger.debug('[AppPlugin] empty env — no app payload, skipping start', {
14011406
pluginName: this.name,

‎packages/runtime/src/domains/packages-orgless-grants-capability-gate.test.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach }
4848
import { SchemaRegistry } from '@objectstack/objectql';
4949
import { HttpDispatcher } from '../http-dispatcher.js';
5050
import { bindCatalogFromTables } from '../security/security-catalog.testkit.js';
51+
// Paid during collection, not inside a case's clock: the package-state store's
52+
// `standalone-stack.ts` loads it lazily.
53+
import '@objectstack/metadata';
5154

5255
const PKG = 'com.acme.crm';
5356
const ALPHA = 'org_alpha';
@@ -339,7 +342,8 @@ describe('[#20515] what the rule leaves unchanged', () => {
339342
it('the operator\'s uninstall proceeds naming no organization, and its disable lands — the positive control that makes `switchedOff` false above mean something', async () => {
340343
const r = rig();
341344
expect((await r.call('DELETE', 'admin', `/packages/${PKG}`)).status).toBe(200);
342-
expect(r.deleteRequests).toEqual([{ packageId: PKG }]);
345+
// [#31023] The signed-in operator is the actor; still no organization.
346+
expect(r.deleteRequests).toEqual([{ packageId: PKG, actor: 'u_admin' }]);
343347
const d = rig();
344348
expect((await d.call('PATCH', 'admin', `/packages/${PKG}/disable`)).status).toBe(200);
345349
expect(switchedOff(d)).toBe(true);

‎packages/runtime/src/domains/packages-uninstall-refuse-before-mutate.test.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,9 @@ import { SchemaRegistry } from '@objectstack/objectql';
6767
import { HttpDispatcher } from '../http-dispatcher.js';
6868
import { bindCatalogFromTables } from '../security/security-catalog.testkit.js';
6969
import { loadDisabledPackageIds, setPackageDisabled } from '../package-state-store.js';
70+
// Paid during collection, not inside a case's clock: the package-state store's
71+
// `standalone-stack.ts` loads it lazily.
72+
import '@objectstack/metadata';
7073

7174
const PKG = 'com.acme.crm';
7275
/** `PKG`'s paired customization package (ADR-0146 D3): the id `customizationPackageId` derives. */
@@ -311,7 +314,8 @@ describe('[ADR-0131 D6] DELETE /packages/:id names no organization, whoever the
311314
const answer = await r.call('DELETE', who, `/packages/${PKG}`);
312315
expect(answer.status).toBe(200);
313316
expect(answer.body?.data).toMatchObject({ packageId: PKG, success: true, registryRemoved: true });
314-
expect(r.deleteRequests).toEqual([{ packageId: PKG }]);
317+
// [#31023] The signed-in operator is the actor; still no organization.
318+
expect(r.deleteRequests).toEqual([{ packageId: PKG, actor: 'u_admin' }]);
315319
expect(await observed(r)).toEqual({
316320
detailStatus: 404,
317321
listed: false,

‎packages/runtime/src/domains/packages-write-actor-session.test.ts‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@
2727
import { describe, it, expect, vi } from 'vitest';
2828
import { SchemaRegistry } from '@objectstack/objectql';
2929
import { HttpDispatcher } from '../http-dispatcher.js';
30+
import { PACKAGE_STATE_UNINSTALL_CLEANUP } from '../package-state-host-store.js';
31+
// Paid during collection, not inside a case's clock: the package-state store's
32+
// `standalone-stack.ts` loads it lazily.
33+
import '@objectstack/metadata';
3034

3135
const PKG = 'com.acme.app';
3236
const SESSION_USER = 'u_author';
@@ -52,6 +56,12 @@ function boot(caller: Record<string, unknown>) {
5256
rollbackToPackageCommit: vi.fn(async (_r: any) => ({ success: true, revertedCommits: [], failed: [] })),
5357
reassignOrphanedMetadata: vi.fn(async (r: any) => ({ success: true, reassignedCount: 0, reassigned: [], targetPackageId: r.targetPackageId })),
5458
duplicatePackage: vi.fn(async (r: any) => ({ success: true, copiedCount: 0, failedCount: 0, targetPackageId: r.targetPackageId, copied: [], failed: [] })),
59+
// [#31023] Its outcome names the runtime's disable-record cleanup, so the
60+
// door leaves the record to the verb and writes no state file here.
61+
deletePackage: vi.fn(async (_r: any) => ({
62+
success: true, deletedCount: 1, failedCount: 0, deleted: [], failed: [],
63+
cleanups: [{ name: PACKAGE_STATE_UNINSTALL_CLEANUP, success: true, removed: 0 }],
64+
})),
5565
};
5666
const services: Record<string, unknown> = { objectql: { registry }, protocol };
5767
const get = (n: string) => services[n] ?? null;
@@ -149,3 +159,32 @@ describe('[#28414] the publish-drafts visibility flip re-saves each app as the s
149159
expect(await flip({ ...SYSTEM }, { actor: SPOOFED })).not.toHaveProperty('actor');
150160
});
151161
});
162+
163+
/**
164+
* [#31023, ADR-0146 D1] `DELETE /packages/:id` hands the uninstall verb the
165+
* session user, as its sibling doors do, so each metadata delete the verb
166+
* performs is credited to that user. It sent no actor at all. Its query takes
167+
* `keepData` alone, so no request-supplied name can reach the actor.
168+
*/
169+
describe('[#31023] DELETE /packages/:id hands the uninstall verb the session user', () => {
170+
async function del(caller: Record<string, unknown>, query: Record<string, string> = {}) {
171+
const { dispatcher, protocol } = boot(caller);
172+
const res = await dispatcher.dispatch('DELETE', `/packages/${PKG}`, undefined, query, { request: { headers: {} } } as any);
173+
const status = res.response?.status ?? (res.handled ? 200 : 0);
174+
expect(status, `expected 200, got ${JSON.stringify(res.response)}`).toBe(200);
175+
expect(protocol.deletePackage).toHaveBeenCalledTimes(1);
176+
return protocol.deletePackage.mock.calls[0][0] as Record<string, unknown>;
177+
}
178+
179+
it('a signed-in caller\'s delete names the caller', async () => {
180+
expect(await del(AUTHOR)).toEqual({ packageId: PKG, actor: SESSION_USER });
181+
});
182+
183+
it('beside keepData, the caller is still named', async () => {
184+
expect(await del(AUTHOR, { keepData: 'true' })).toEqual({ packageId: PKG, keepData: true, actor: SESSION_USER });
185+
});
186+
187+
it('CONTROL: a system context names no one — no actor is invented', async () => {
188+
expect(await del(SYSTEM)).toEqual({ packageId: PKG });
189+
});
190+
});

‎packages/runtime/src/domains/packages-write-doors-closed-query.test.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ import { join } from 'node:path';
2929
import { SchemaRegistry } from '@objectstack/objectql';
3030
import { unknownQueryParamRefusalMessage } from '@objectstack/rest';
3131
import { HttpDispatcher } from '../http-dispatcher.js';
32+
// Paid during collection, not inside a case's clock: the package-state store's
33+
// `standalone-stack.ts` loads it lazily.
34+
import '@objectstack/metadata';
3235

3336
const PKG = 'com.acme.app';
3437

@@ -168,8 +171,9 @@ describe('[#26857] DELETE /packages/:id — a misspelled keep-data flag is refus
168171
});
169172

170173
it.each([
171-
['true', { packageId: PKG, keepData: true }],
172-
['1', { packageId: PKG, keepData: true }],
174+
// [#31023] The signed-in caller rides as the actor (ADR-0146 D1).
175+
['true', { packageId: PKG, keepData: true, actor: AUTHOR.userId }],
176+
['1', { packageId: PKG, keepData: true, actor: AUTHOR.userId }],
173177
])('preserves the declared `keepData=%s`: deletePackage receives %j', async (keepData, received) => {
174178
const { dispatcher, acts } = boot();
175179
const answer = await call(dispatcher, 'DELETE', `/packages/${PKG}`, undefined, { keepData });

‎packages/runtime/src/domains/packages-write-flag-arity.test.ts‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ import { describe, it, expect, vi } from 'vitest';
2828
import { SchemaRegistry } from '@objectstack/objectql';
2929
import { repeatedQueryParamMessage } from '@objectstack/rest';
3030
import { HttpDispatcher } from '../http-dispatcher.js';
31+
// Paid during collection, not inside a case's clock: the package-state store's
32+
// `standalone-stack.ts` loads it lazily.
33+
import '@objectstack/metadata';
3134

3235
const PKG = 'com.acme.app';
3336

@@ -82,10 +85,11 @@ describe('[#23214] DELETE /packages/:id — `keepData` is one flag', () => {
8285
});
8386

8487
it.each([
85-
['true', { packageId: PKG, keepData: true }],
86-
['1', { packageId: PKG, keepData: true }],
87-
[['true'], { packageId: PKG, keepData: true }],
88-
[undefined, { packageId: PKG }],
88+
// [#31023] The signed-in caller rides as the actor (ADR-0146 D1).
89+
['true', { packageId: PKG, keepData: true, actor: AUTHOR.userId }],
90+
['1', { packageId: PKG, keepData: true, actor: AUTHOR.userId }],
91+
[['true'], { packageId: PKG, keepData: true, actor: AUTHOR.userId }],
92+
[undefined, { packageId: PKG, actor: AUTHOR.userId }],
8993
])('preserves one `keepData` (%j): deletePackage receives %j', async (keepData, received) => {
9094
const { dispatcher, protocol } = boot();
9195
const answer = await call(dispatcher, 'DELETE', `/packages/${PKG}`, undefined, keepData === undefined ? {} : { keepData });

‎packages/runtime/src/domains/packages.ts‎

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ import { parseBooleanParam } from '../query-param.js';
159159
import { setPackageDisabled } from '../package-state-store.js';
160160
// [#30962] The host seam for the same state: a store a host registers as the
161161
// `package-state-store` service replaces the file at every door below.
162-
import { asPackageStateStore, PACKAGE_STATE_STORE_SERVICE } from '../package-state-host-store.js';
162+
import { asPackageStateStore, PACKAGE_STATE_STORE_SERVICE, PACKAGE_STATE_UNINSTALL_CLEANUP } from '../package-state-host-store.js';
163163
import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js';
164164
import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js';
165165
import { readDispatchedBody } from '../deferred-request-body.js';
@@ -413,7 +413,8 @@ function requireManageMetadata(deps: DomainHandlerDeps, context: HttpProtocolCon
413413
* doors, so attribution cannot drift from authorization. [#28414] Every
414414
* package door that stamps history or a commit reads it: `publish-drafts`
415415
* (and its app re-save), `discard-drafts`, `commits/:commitId/revert`,
416-
* `rollback`, `adopt-orphans` and `duplicate`. A body `actor` is ignored, not
416+
* `rollback`, `adopt-orphans` and `duplicate`, and [#31023] the package
417+
* `DELETE`, whose verb credits each metadata delete. A body `actor` is ignored, not
417418
* refused, so a client still sending one keeps working and is recorded as
418419
* itself. No caller acts on behalf of another identity through these doors
419420
* (measured: the console and the SDK send none, the CLI calls none); one that
@@ -3804,12 +3805,21 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin
38043805
//
38053806
// [#20492] `protocol` is the one resolved above, before the registry
38063807
// was touched.
3808+
//
3809+
// [#31023, ADR-0146 D1] The actor is the request's authenticated
3810+
// user, resolved as every sibling package write door resolves it
3811+
// (`resolvePackageWriteActor`), so each metadata delete the verb
3812+
// performs is credited to that user in `sys_audit_log` and
3813+
// `sys_metadata_history`. A caller with no user id sends no
3814+
// `actor`, and the deletes stay unattributed, as before.
38073815
if (protocol && typeof protocol.deletePackage === 'function') {
38083816
try {
38093817
const keepData = query?.keepData === 'true' || query?.keepData === '1';
3818+
const actor = resolvePackageWriteActor(_context);
38103819
persisted = await protocol.deletePackage({
38113820
packageId: id,
38123821
...(keepData ? { keepData: true } : {}),
3822+
...(actor ? { actor } : {}),
38133823
});
38143824
} catch (e: any) {
38153825
// [#21276] Nothing was touched on this path: the registry
@@ -3872,7 +3882,19 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin
38723882
// Same best-effort try/catch as the install arm above: the
38733883
// uninstall itself already happened, so a state-store failure must
38743884
// not turn it into a 500.
3875-
if (persists ? existed : registryRemoved) {
3885+
//
3886+
// [#31023] The verb clears the record itself when the runtime's
3887+
// uninstall cleanup is registered on its protocol
3888+
// (`ensurePackageStateUninstallCleanup`, from `AppPlugin.start`),
3889+
// so every in-process caller of `deletePackage` gets the clear,
3890+
// not only this door; its outcome rides on `persisted.cleanups`.
3891+
// The verb runs its cleanups for every id it is asked to
3892+
// uninstall, so with it a 404 here clears a record naming an id
3893+
// that is neither installed nor stored. This door writes the
3894+
// record only when the verb did not: a host with no persisted
3895+
// half, or a protocol on a kernel that registered no such cleanup.
3896+
const verbClearedState = persisted?.cleanups?.some((c) => c?.name === PACKAGE_STATE_UNINSTALL_CLEANUP) === true;
3897+
if (!verbClearedState && (persists ? existed : registryRemoved)) {
38763898
try {
38773899
await writePackageStateAfterChange(deps, _context, id, false);
38783900
} catch (err) {

0 commit comments

Comments
 (0)