Skip to content

Commit 8ef2523

Browse files
feat(audit): a package uninstall writes one package_uninstall ledger row about the package itself (#31286)
<!-- claude-projects-attribution: {"github_login":"marchtian"} --> _Requested by **田騏** · [project thread](https://claude.ai/code/project/chan_01VmuUhXU9SSUzqJYyXSmYSi?thread=cmsg_01VmuUhXU9SSUzqJYyXSmYSiMUyQBfkxjbMPiRuAiJCX6U)_ Fixes #31186 Clause-②: yes Metadata-protocol: none ## 维护者速读 - 改了什么:卸载一个软件包后,审计日志里会多一条 `package_uninstall`(卸载软件包)记录,写明谁卸载的、哪个包、版本、所在环境、是否保留了数据表,以及删掉了几项元数据、有几项没删掉。所有卸载入口(Studio / `DELETE /api/v1/packages/:id`、导入回滚、配对定制包安装撤销、cloud 侧直接调用卸载动词的工具)都会写,因为写入点在它们共同经过的卸载动词 `deletePackage` 里。 - 为什么改:此前卸载只留下逐条元数据的删除记录,包本身的那一行(`sys_packages`)是用原始 SQL 删的,不走钩子,所以「这个应用被谁卸载了」在审计日志里没有任何记录;一个没有存储元数据的包卸载后完全不留痕。 - 风险与回滚:只新增一条审计行,卸载的行为和返回内容都不变。审计行在包已删除、清理全部跑完之后才写;写失败只记一条 error 日志,卸载照常成功;没有审计插件的环境完全不受影响。被拒绝的卸载、以及不存在的包(404)都不写。这行不属于任何组织,只有平台管理员能看到。可整个 PR 回滚。 - 先看:`packages/metadata-protocol/src/protocol-package-uninstall.ts` 末尾 `return` 前的调用与 `readInstalledPackage`;`packages/plugins/plugin-audit/src/package-lifecycle-audit.ts` 的 `buildPackageLifecycleAuditRow`;`sys-audit-log.object.ts` 新增的 `package_uninstall` 选项。 - 两分钟验证:`pnpm --filter @objectstack/plugin-audit exec vitest run src/package-lifecycle-audit.test.ts`,预期 9 个用例全部通过,其中第二个用例从真实引擎读回整行,断言操作人、包、版本、环境都在。 - 需要你做的:无,席位落地。 ## What changes An uninstall left one `sys_audit_log` `delete` row per metadata item it removed (credited to the caller since #31023) and nothing about the package. `@objectstack/service-package` deletes the package's own `sys_packages` row with `DELETE FROM sys_packages WHERE id = ?` through `engine.execute`, which fires no hook, so no writer could ever see it. Measured at branch start on `main` 169ff7e: the verb's body (`protocol-package-uninstall.ts`) makes no audit call, and the new verb and door pins below fail against it (the ablation section is that measurement). Per the seat's ruling (an event writer in `plugin-audit` reached through the `audit` service slot, the #27908 pattern; uninstall only): - **`@objectstack/plugin-audit`**: new `package-lifecycle-audit.ts`. `createPackageLifecycleAuditWriter` registers `recordPackageLifecycleEvent` on the `audit` slot beside `recordAuthEvent`, `recordActionInvocation` and `recordDataExport`. The caller hands over an event; this package owns the row: - `action: 'package_uninstall'`; - `actor` = the principal the verb was called for; `user_id` = the same value unless it is a `svc:` principal (no `sys_user` row carries one); - `object_name` / `record_id` null: `sys_packages` is not an object, so the row is about no record and the ledger's record gate serves it under the ledger's own grant (`ledgerRowAboutNoRecordWhere`); - `tenant_id` null: an uninstall is environment-wide (ADR-0131 D6/D12), so this is a deployment-level row, served to the platform administrator alone (#26750 ruling A). Pinned by adding the row to `audit-log-deployment-level-rows.integration.test.ts` (all three tenancy postures); - `metadata`: `package_id`, `version`, `environment_id`, `keep_data`, `items_removed`, `items_failed`, `outcome` (`complete` / `partial`). - Never throws; a refused insert is reported at `error` once per cause through a named callee (`persistPackageLifecycleAuditRow`), declared in `check-durability-degradation-log-level.mjs` and `measure-durability-swallow-family.mjs` the way `persistDataExportAuditRow` is. - `sys_audit_log.action` gains the `package_uninstall` option, writer-first, with its label and all ten translated locales; the writer is registered in `sys-audit-log-retired-actions.test.ts`'s `ACTIONS_WITH_WRITERS`. - **`@objectstack/metadata-protocol`**: `deletePackage` calls the slot once, after the cleanups and before it returns, so every caller of the verb is covered. Two reads feed it: the installed version, read from the registry right before the withdrawal releases it (beside `captureUninstallDeclared`, nothing near `assertPackageUninstallable` changes), and the protocol's `environmentId`, now handed to the moved body through its host. A refusal throws before the call, so it writes nothing; an id the registry did not hold and the store bound no metadata to writes nothing either (the door answers it `404`). No audit slot means no call. A slot that throws anyway is logged at `error` and never changes the answer. - The tenant-audit census counts the writer's one new insert site (`content/docs/permissions/tenant-audit-census.mdx` and its counts file, regenerated with `node scripts/tenant-audit-census.mjs --write` plus the six hand-written figures the gate holds to it). No `packages/spec` change. ## Tests - `packages/plugins/plugin-audit/src/package-lifecycle-audit.test.ts` (new, 9 cases): on the real `ObjectQLPlugin` + `AuditPlugin` boot, through the `audit` slot, read back from the engine: the full row; a partial uninstall; absent actor / version / environment recorded as null; no row for an event naming no package or another action; a `svc:` actor never in `user_id`; a ledger without the `actor` column; no engine, no throw; a refused insert reported once at `error` and never thrown. - `packages/metadata-protocol/src/protocol.package-delete-refusal.test.ts`, new top-level describe in the existing harness (9 cases): one event with package, version, environment, actor, `keepData` and counts, handed over after the cleanups; partial uninstall; CONTROLs: both store refusal channels, the registry's refusal and a retired request key hand over nothing; a non-installed id hands over nothing; no slot, the uninstall completes; a throwing slot does not change the answer. - `packages/runtime/src/package-uninstall-store-refusal.integration.test.ts`, new top-level describe in the existing harness (4 cases, real `ObjectQL` + `SqlDriver` + `PackageServicePlugin` + protocol + `HttpDispatcher`): `DELETE /packages/:id` hands the slot one event naming the signed-in user, the package, `1.0.0` and the environment; an in-process `protocol.deletePackage` call hands over the same event naming its actor; CONTROLs: a store-refused delete (500) and a non-installed id (404) hand over nothing. - `audit-log-deployment-level-rows.integration.test.ts`: the `package_uninstall` row joins the deployment-level fixture rows; served to the platform administrator, to no organization reader, in all three postures. <details><summary>Ablation (one-time, scripts/ablation-replace.mjs)</summary> The call in `deletePackage` was replaced by a no-op with the same arguments (marker `_ablate31186`), `@objectstack/metadata-protocol` rebuilt, and `ablation-dist-preflight.mjs` confirmed the marker in `dist/`. Result: `protocol.package-delete-refusal.test.ts` 4 failed / 16 passed (the four positive #31186 cases), `package-uninstall-store-refusal.integration.test.ts` 2 failed / 9 passed (the HTTP door case and the in-process case). Every CONTROL stayed green. Restore: `ok restored: blob == HEAD (2b053d2) and git diff HEAD is empty`, rebuilt, preflight `--absent` exit 0, both files green again (20/20, 11/11). </details> <details><summary>Package suites and typecheck</summary> - `pnpm --filter @objectstack/plugin-audit test`: Test Files 77 passed, Tests 1212 passed. - `pnpm --filter @objectstack/metadata-protocol test`: Test Files 324 passed / 4 skipped, Tests 22545 passed / 26 skipped. - `pnpm --filter @objectstack/runtime exec vitest run src/package-uninstall-store-refusal.integration.test.ts`: 11 passed (only this runtime file changed; the runtime suite runs in CI). - `pnpm --filter <pkg> typecheck` for plugin-audit, metadata-protocol and runtime: exit 0 each. </details> ## Acceptance notes - **Uncovered path, declared:** install-local's uninstall door (`handleUninstall` in `packages/cloud-connection/src/marketplace-install-local-plugin.ts`) does not go through `deletePackage`: it removes its marketplace ledger entry, withdraws the package from the registry and calls `protocol.runUninstallCleanups` directly, so it writes no `package_uninstall` row. Per the seat's ruling no second call site is added here; the seat files it. - Install, enable and disable would fit the same writer (a closed `PackageLifecycleAuditAction` union and the same event shape); none is built. - The cleanup-registration route (`registerUninstallCleanup`) was weighed and not taken (seat ruling A): cleanups run for every id including a non-installed one, run after the registry released the version, carry no environment or `keepData`, and their outcomes ride on the `DELETE` answer's `cleanups[]`. ## Verification Measured on head `99f0fba08` (branch merged with `origin/main` ea62933): - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 119 gate commands derived for this change set; all 119 run, every one `exit 0`; `--ran` reconciliation: 119 derived, 119 run, 0 NOT-MEASURED, 0 UNRUN. - The package suites, the three typechecks and `eslint --no-inline-config` over the 25 touched `.ts` / `.mjs` files, each `exit 0`, on the same head. - The full command list with exit codes is in the os-dev-report comment on #31186. Session: https://claude.ai/code/session_017ihL2bwU56eMCkj69h7Gd8 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: objectstack-fleet[bot] <332303061+objectstack-fleet[bot]@users.noreply.github.com>
1 parent b85190a commit 8ef2523

28 files changed

Lines changed: 850 additions & 42 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/plugin-audit': patch
3+
'@objectstack/metadata-protocol': patch
4+
---
5+
6+
feat(audit): a package uninstall writes one `package_uninstall` row about the package itself
7+
8+
Clause-②: yes
9+
Metadata-protocol: none
10+
11+
Uninstalling a package left a `sys_audit_log` row for each metadata item it removed, and nothing about the package: its `sys_packages` row is deleted with raw SQL, so no hook ever saw it. A package that shipped no stored metadata left no trace at all. The uninstall verb (`deletePackage`, the path `DELETE /api/v1/packages/:id`, the import rollback, the paired-customization undo and any in-process caller take) now writes one row per package it uninstalled, after the package's stored row is gone: `action: "package_uninstall"`, the actor the verb was called for (`user_id` and `actor`; a `svc:` principal is the `actor` only), no record and no organization (an uninstall is environment-wide, so the row is deployment-level and served to the platform administrator alone), and in `metadata` the package id, its installed version, the environment, whether the object tables were kept, the items removed and left behind, and `outcome` (`complete` or `partial`).
12+
13+
An uninstall the verb refuses writes no row, and neither does an id that is not installed. The writer lives on the `audit` service slot as `recordPackageLifecycleEvent`, beside `recordAuthEvent`; a kernel without the audit plugin uninstalls exactly as before, and a ledger write that fails is logged at `error` once per cause without changing the uninstall's answer.

‎content/docs/permissions/tenant-audit-census.mdx‎

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ are reported as `undecidable` rather than assumed either way.
132132

133133
The same holds twice over for the context. An options argument spelled as a
134134
literal can be read; one spelled `options`, `{ ...opts }`, or handed through a
135-
forwarding shim cannot, and **56 of the 269 sites are spelled that way**. A
135+
forwarding shim cannot, and **56 of the 270 sites are spelled that way**. A
136136
context resolved from an inline literal or a local `const` can be tested for
137137
`isSystem`; one arriving from a helper call cannot.
138138

@@ -197,10 +197,10 @@ reproduce them. Where it disagrees, it disagrees on the page:
197197

198198
| carried figure | where it survives | this census |
199199
| :--- | :--- | ---: |
200-
| 175 write call sites | quoted in the merged changeset | **269** |
200+
| 175 write call sites | quoted in the merged changeset | **270** |
201201
| 24 carrying no tenant context | quoted in the merged changeset | **2** provable and tenancy-enabled; **30** more whose options argument is unreadable |
202-
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **191 of 269** decidable, **78** undecidable |
203-
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 149 decidably elevated, 0 decidably not, 112 undecidable |
202+
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **192 of 270** decidable, **78** undecidable |
203+
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 150 decidably elevated, 0 decidably not, 112 undecidable |
204204
| 141 and 132, two independent re-derivations | the card that filed this work | — |
205205

206206
**The differences are not reconciled, and deliberately so.** The old census's
@@ -217,11 +217,11 @@ would report a smaller number and would not say so.
217217

218218
The fourth row is the one worth flagging to anyone citing it. **The 135 / 77%
219219
figure has no surviving corroboration anywhere in the tree.** This census reads
220-
149 of 269 (55%) as decidably elevated, with 112 more whose elevation is a
220+
150 of 270 (56%) as decidably elevated, with 112 more whose elevation is a
221221
run-time fact — so the claim is neither confirmed nor refuted, and the honest
222222
answer is that a static reading cannot settle it.
223223

224-
⇒ **Cite `2 / 269`, and say what it is**: the sites whose options argument was
224+
⇒ **Cite `2 / 270`, and say what it is**: the sites whose options argument was
225225
READ and holds no tenant context, against a decidably tenancy-enabled object.
226226
That is the control's provable yield surface. ⛔ Do not cite it as "the sites
227227
without tenant context" — **30 further sites** have an options argument this
@@ -233,28 +233,28 @@ cannot read, and they are neither in nor out.
233233

234234
| what | count |
235235
| :--- | ---: |
236-
| write call sites on the application surface | **269** |
237-
| …whose object name is statically decidable | 191 |
236+
| write call sites on the application surface | **270** |
237+
| …whose object name is statically decidable | 192 |
238238
| …whose object name is chosen at run time | 78 |
239-
| …against an object with tenancy ENABLED | 185 |
239+
| …against an object with tenancy ENABLED | 186 |
240240
| …against an object that declares tenancy off | 6 |
241-
| threading a tenant context | 205 |
241+
| threading a tenant context | 206 |
242242
| PROVABLY carrying none (options read, no context key) | **8** |
243243
| …of those, against a decidably tenancy-enabled object | **2** |
244244
| options argument UNREADABLE — may or may not carry one | 56 |
245245
| …of those, against a decidably tenancy-enabled object | 30 |
246-
| threading a decidably ELEVATED (`isSystem`) context | 149 |
246+
| threading a decidably ELEVATED (`isSystem`) context | 150 |
247247
| threading a context that is decidably NOT elevated | 0 |
248248
| threading a context whose elevation is a run-time fact | 112 |
249249

250250
| how the instrument reached the site | count |
251251
| :--- | ---: |
252-
| receiver carried a readable engine type | 205 |
252+
| receiver carried a readable engine type | 206 |
253253
| receiver erased, placed by the object NAME | 54 |
254254
| receiver erased, placed by an `object: string` PARAMETER | 5 |
255255
| receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 |
256256

257-
| object name spelled inline | 122 |
257+
| object name spelled inline | 123 |
258258
| object name spelled through a `const` | 69 |
259259
| object name is an `object: string` parameter | 7 |
260260
| object name is some other run-time expression | 71 |
@@ -307,13 +307,13 @@ holds still. They are required to be HERE and to say WHEN they were true;
307307
their values are not compared. The reasoning, and the measurement behind it,
308308
are in `scripts/check-tenant-audit-census.mjs`.
309309

310-
Measured on 2026-11-07 at `a06983b5c`.
310+
Measured on 2026-11-07 at `9dd4abeaa`.
311311

312312
| corpus scale (not enforced) | count |
313313
| :--- | ---: |
314-
| tracked non-test sources scanned | 1052 |
314+
| tracked non-test sources scanned | 1057 |
315315
| engine-shaped types recognised | 113 |
316316
| declared objects in the registry | 131 |
317-
| same-named calls subtracted as non-engine | 237 |
317+
| same-named calls subtracted as non-engine | 239 |
318318

319319
{/* END GENERATED: tenant-audit-census */}

‎docs/audits/2026-08-tenant-audit-write-call-sites.counts.md‎

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,17 +33,17 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution.
3333

3434
| Measure | Value |
3535
|---|---:|
36-
| Write call sites | 269 |
37-
| Object name statically decidable | 191 |
36+
| Write call sites | 270 |
37+
| Object name statically decidable | 192 |
3838
| Object name chosen at run time | 78 |
39-
| Against a tenancy-enabled object | 185 |
39+
| Against a tenancy-enabled object | 186 |
4040
| Against an object declaring tenancy off | 6 |
41-
| Threading a tenant context | 205 |
41+
| Threading a tenant context | 206 |
4242
| Provably carrying none | 8 |
4343
| …and decidably tenancy-enabled | 2 |
4444
| Options argument unreadable | 56 |
4545
| …and decidably tenancy-enabled | 30 |
46-
| Threading a decidably elevated context | 149 |
46+
| Threading a decidably elevated context | 150 |
4747
| Threading a decidably non-elevated context | 0 |
4848
| Threading a context of undecidable elevation | 112 |
4949

@@ -90,14 +90,14 @@ holds still. They are required to be HERE and to say WHEN they were true;
9090
their values are not compared. The reasoning, and the measurement behind it,
9191
are in `scripts/check-tenant-audit-census.mjs`.
9292

93-
Measured on 2026-11-07 at `a06983b5c`.
93+
Measured on 2026-11-07 at `9dd4abeaa`.
9494

9595
| corpus scale (not enforced) | count |
9696
| :--- | ---: |
97-
| tracked non-test sources scanned | 1052 |
97+
| tracked non-test sources scanned | 1057 |
9898
| engine-shaped types recognised | 113 |
9999
| declared objects in the registry | 131 |
100-
| same-named calls subtracted as non-engine | 237 |
100+
| same-named calls subtracted as non-engine | 239 |
101101

102102
## Every site
103103

@@ -123,6 +123,7 @@ Measured on 2026-11-07 at `a06983b5c`.
123123
| `packages/plugins/plugin-audit/src/data-export-audit.ts` | `insert` | `sys_audit_log` | enabled | elevated | 1 |
124124
| `packages/plugins/plugin-audit/src/mail-log-body-migration.ts` | `update` | `object` | undecidable | elevated | 1 |
125125
| `packages/plugins/plugin-audit/src/mail-log-body-migration.ts` | `update` | `object` | undecidable | options unreadable | 1 |
126+
| `packages/plugins/plugin-audit/src/package-lifecycle-audit.ts` | `insert` | `sys_audit_log` | enabled | elevated | 1 |
126127
| `packages/plugins/plugin-audit/src/read-audit.ts` | `insert` | `sys_audit_log` | enabled | options unreadable | 1 |
127128
| `packages/plugins/plugin-audit/src/stored-metadata-body-migration.ts` | `update` | `object` | undecidable | elevated | 1 |
128129
| `packages/plugins/plugin-auth/src/admin-import-users.ts` | `insert` | `sys_audit_log` | enabled | options unreadable | 1 |

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

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,12 @@ import { canonicalMetaType, captureUninstallDeclared, metadataReadFailureError,
4646
export interface PackageUninstallHost {
4747
readonly deleteMetaItem: ObjectStackProtocolImplementation['deleteMetaItem'];
4848
readonly engine: ObjectStackProtocolImplementation['engine'];
49+
/**
50+
* [#31186] The environment the protocol is bound to. The class field is
51+
* private, so it cannot be named by an indexed access; it is read for the
52+
* package's ledger row and nothing else.
53+
*/
54+
readonly environmentId: string | undefined;
4955
readonly findEnvironmentRows: ObjectStackProtocolImplementation['findEnvironmentRows'];
5056
readonly getServicesRegistry: ObjectStackProtocolImplementation['getServicesRegistry'];
5157
readonly packagelessAddress: ObjectStackProtocolImplementation['packagelessAddress'];
@@ -54,7 +60,7 @@ export interface PackageUninstallHost {
5460
}
5561

5662
/** The body of `ObjectStackProtocolImplementation#deletePackage`; the method's docblock there is its contract. */
57-
export async function deletePackage(this: Pick<PackageUninstallHost, 'deleteMetaItem' | 'engine' | 'findEnvironmentRows' | 'getServicesRegistry' | 'packagelessAddress' | 'runUninstallCleanups' | 'shouldDropStorage'>, request: DeletePackageRequest): Promise<DeletePackageResponse> {
63+
export async function deletePackage(this: Pick<PackageUninstallHost, 'deleteMetaItem' | 'engine' | 'environmentId' | 'findEnvironmentRows' | 'getServicesRegistry' | 'packagelessAddress' | 'runUninstallCleanups' | 'shouldDropStorage'>, request: DeletePackageRequest): Promise<DeletePackageResponse> {
5864
// [ADR-0131 D6/D12] An uninstall is environment-wide by construction:
5965
// every `sys_metadata` row bound to the package, in this environment,
6066
// whatever organization a legacy row was stored under. The
@@ -332,6 +338,10 @@ export async function deletePackage(this: Pick<PackageUninstallHost, 'deleteMeta
332338
// time: it is logged, not fatal, because the durable row is already
333339
// gone and the next boot is clean either way.
334340
//
341+
// [#31186] What the registry holds for this id, read before the withdrawal
342+
// below releases it: whether there was a package to uninstall, and its
343+
// version, for the package's own ledger row at the end of this verb.
344+
const installed = readInstalledPackage((this.engine as any)?.registry, request.packageId);
335345
// [ADR-0090 D5] What the package declared is captured first: the
336346
// withdrawal releases its names, and the cleanups below revoke the
337347
// grants that name them.
@@ -354,6 +364,24 @@ export async function deletePackage(this: Pick<PackageUninstallHost, 'deleteMeta
354364
// uninstall door calls too.
355365
const cleanups = await this.runUninstallCleanups({ ...request, ...(declared ? { declared } : {}) });
356366

367+
// [#31186] The package's own ledger row, written once, here, after its
368+
// stored row is gone — the one place every uninstall door passes. A
369+
// refusal above threw before this line, so it writes none. An id the
370+
// registry did not hold and the store bound no metadata to was not a
371+
// package, so it writes none either: the `DELETE` door answers that `404`.
372+
if (installed.held || deleted.length > 0 || failed.length > 0) {
373+
await recordPackageUninstall(this.getServicesRegistry?.()?.get('audit'), {
374+
action: 'package_uninstall',
375+
packageId: request.packageId,
376+
...(installed.version ? { version: installed.version } : {}),
377+
...(this.environmentId ? { environmentId: this.environmentId } : {}),
378+
...(request.actor ? { actor: request.actor } : {}),
379+
...(request.keepData === true ? { keepData: true } : {}),
380+
itemsRemoved: deleted.length,
381+
itemsFailed: failed.length,
382+
});
383+
}
384+
357385
return {
358386
success: failed.length === 0 && deleted.length > 0,
359387
deletedCount: deleted.length,
@@ -363,3 +391,61 @@ export async function deletePackage(this: Pick<PackageUninstallHost, 'deleteMeta
363391
cleanups,
364392
};
365393
}
394+
395+
/**
396+
* [#31186] The event the uninstall verb hands the `audit` slot's
397+
* `recordPackageLifecycleEvent` — declared here, structurally, so this package
398+
* does not depend on `@objectstack/plugin-audit`, which owns the row shape
399+
* (`package-lifecycle-audit.ts` there).
400+
*/
401+
export interface PackageUninstallAuditEvent {
402+
action: 'package_uninstall';
403+
packageId: string;
404+
version?: string;
405+
environmentId?: string;
406+
actor?: string;
407+
keepData?: boolean;
408+
itemsRemoved: number;
409+
itemsFailed: number;
410+
}
411+
412+
/** The `audit` slot member the verb calls. */
413+
interface PackageLifecycleAuditSurface {
414+
recordPackageLifecycleEvent?(event: PackageUninstallAuditEvent): unknown;
415+
}
416+
417+
/** Whether the registry holds `packageId`, and the version it holds. Never throws. */
418+
function readInstalledPackage(registry: unknown, packageId: string): { held: boolean; version?: string } {
419+
try {
420+
const pkg = (registry as { getPackage?(id: string): unknown } | undefined)?.getPackage?.(packageId) as
421+
| { manifest?: { version?: unknown } }
422+
| undefined;
423+
if (pkg === undefined || pkg === null) return { held: false };
424+
const version = pkg.manifest?.version;
425+
return { held: true, ...(typeof version === 'string' && version !== '' ? { version } : {}) };
426+
} catch {
427+
// A registry that cannot answer is not asked again: the row is then
428+
// written only when stored items were removed, without a version.
429+
return { held: false };
430+
}
431+
}
432+
433+
/**
434+
* Hand the event to the `audit` slot. A host with no audit plugin has no
435+
* ledger at all, so the call is skipped silently. The writer never throws; a
436+
* slot that does anyway is a foreign writer, and the uninstall it reports on
437+
* has already completed, so its failure is logged, never answered.
438+
*/
439+
async function recordPackageUninstall(audit: unknown, event: PackageUninstallAuditEvent): Promise<void> {
440+
const record = (audit as PackageLifecycleAuditSurface | undefined)?.recordPackageLifecycleEvent;
441+
if (typeof record !== 'function') return;
442+
try {
443+
await record.call(audit, event);
444+
} catch (e) {
445+
console.error(
446+
`[protocol.deletePackage] '${event.packageId}' was uninstalled, but the audit service refused its `
447+
+ '`package_uninstall` row, so the compliance ledger does not record who uninstalled it: '
448+
+ `${(e as Error)?.message ?? String(e)}`,
449+
);
450+
}
451+
}

0 commit comments

Comments
 (0)