Skip to content

Commit 4363945

Browse files
fix(metadata-protocol,runtime)!: the in-process uninstall verb refuses while an active validation rule reads a field the package adds (#31280)
domain:server#4 · marchtian · session_017ihL2dLQEXWGsE2SZ3Qn5F <!-- claude-projects-attribution: {"github_login":"marchtian"} --> _Requested by **田騏** · [project thread](https://claude.ai/code/project/chan_01VmuUhXU9SSUzqJYyXSmYSi?thread=cmsg_01VmuUhXU9SSUzqJYyXSmYSiRpCzq4DthSjDjvD4ZgZ1Su)_ Fixes #31232 Clause-②: yes (narrowing) Metadata-protocol: none ## 维护者速读 - **改了什么**:卸载动词本身(`protocol.deletePackage`)现在在动任何东西之前,先问一遍「卸载前守卫」。runtime 把停用门/卸载门已经在用的那个检查注册成守卫:如果别的已启用包有生效中的校验规则在读这个包通过 `objectExtensions` 加的字段,进程内卸载也直接拒绝,抛 409 `PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE`,消息同门上那句话,列出每条规则。拒绝时什么都不读、不动。 - **为什么改**:#31227 只让 HTTP 卸载门拒绝;平台内部直接调用卸载的路径(cloud 的卸载 AI 工具、本地安装的配对安装回滚、非托管导入回滚)不走那道门,仍会把字段撤掉,之后那条规则会让该对象的每次写入都报 400 `VALIDATION_FAILED`,而包已没了,无法再启用。 - **风险与回滚**:这是一次收窄(BREAKING):进程内调用方以前能卸载成功的请求,在有依赖规则时改为被 409 拒绝;两个回滚路径会把拒绝当作「未能完全删除」的残留照常报告。没有依赖规则的卸载、已停用包的卸载、没注册守卫的协议,行为都不变。没改 spec、没加错误码。#31227 已合并,本 PR 已合入 main:拒绝文案用卸载措辞,HTTP 门和卸载动词的守卫共用同一个函数 `packageUninstallDependentRulesRefusal`。回滚就是 revert 本 PR。 - **先看**:`packages/metadata-protocol/src/protocol-package-uninstall.ts`(`askUninstallGuards` 及其在 `deletePackage` 里的调用位置:退役键拒绝之后、第一次读库之前);`packages/runtime/src/domains/packages.ts` 的 `ensureDependentRulesUninstallGuard` / `packageUninstallDependentRulesRefusal`;`packages/runtime/src/domains/packages-disable-module-extension-fields.test.ts` 末尾的 `[#31232]` describe。 - **两分钟验证**:`pnpm --filter @objectstack/runtime exec vitest run src/domains/packages-disable-module-extension-fields.test.ts -t 31232`,预期 4 条全绿:进程内卸载被 409 拒绝且什么都没读没动、HTTP 门答同一句话、两条 CONTROL(无依赖的包照常卸载并清停用记录;规则在租户覆盖层关掉后照常卸载)。 - **需要你做的**:维护者在 PR 上确认这次收窄(进程内卸载动词在有依赖规则时改为拒绝)。 ## What changes After #31227 the package door (`DELETE /api/v1/packages/:id`) refuses an uninstall that would withdraw a field an active validation rule of another enabled package reads. The uninstall verb itself, `protocol.deletePackage` in `@objectstack/metadata-protocol`, never asked, so every in-process caller withdrew the field unchecked. This adds the seat-ruled seam and fills it: - **The seam (`@objectstack/metadata-protocol`).** `ObjectStackProtocolImplementation#registerUninstallGuard(name, guard)`, the before-half of `registerUninstallCleanup`. A guard is handed `{ packageId, actor? }` and answers `undefined` (proceed) or `{ code, status, message }` (refuse). `deletePackage` asks every registered guard in registration order right after its retired-key refusal and BEFORE its first read (`sys_metadata`), the registry's own refusal, the store delete, the per-item deletes, the registry withdrawal and the cleanups; the first refusal is thrown as an `Error` carrying the guard's `message`, `code` and `status`. A guard that throws refuses with its throw. One guard per name; a second registration replaces the first. The types `UninstallGuard` and `UninstallGuardRefusal` are exported from the package entry. With no guard registered the verb is byte-for-byte the old path. - **The registration (`@objectstack/runtime`).** `ensureDependentRulesUninstallGuard(ctx)` registers, once per protocol, the guard `runtime.package-dependent-rules`, called from `AppPlugin.start` beside `ensurePackageStateUninstallCleanup` (the after-half #31023 added). The guard resolves the engine's registry when it is asked and answers `packageUninstallDependentRulesRefusal(registry, packageId)`: the doors' verdict `readDisableDependentRules` and sentence `disableDependentRulesMessage` under the lead `Package 'ID' cannot be uninstalled`, as the wire pair `409` `PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE`. `@objectstack/metadata-protocol` imports nothing from the runtime and holds no copy of the rule. - **No spec change, no new error code.** The code is the one already ledgered under `@objectstack/runtime`. ## Tests - **`packages/metadata-protocol/src/protocol.uninstall-guards.test.ts` (new, 6 cases)**, a protocol over an engine double that counts every step that reads or moves something (the `sys_metadata` read, the registry's uninstallability question, the store delete, the registry withdrawal, the cleanups): - a refusing guard: the verb rejects with the guard's code, status and message, and all five counts are 0; - each guard is handed the package id and the request's actor; the first refusal wins, a later guard is not asked, counts 0; - a guard that throws: the same error object is rejected, counts 0; - a second registration under one name replaces the first; - CONTROL: guards that answer nothing, every step runs once; - CONTROL: a bare protocol (no guard registered), every step runs once, as before. - **`packages/runtime/src/domains/packages-disable-module-extension-fields.test.ts`, new top-level describe `[#31232]` (4 cases)** in the shared dependent-rule harness, on a real kernel (ObjectQL on SQLite, MetadataPlugin, AppPlugin), the verb called in process on the kernel's `protocol` service. Two writable packages extend the app's object with one field each; a third package's active rule reads the first one's field. - **Refusal.** `deletePackage` rejects with `409` and the code; the message equals the exported builder over the exported verdict under the uninstall lead. Nothing was read or moved: `findEnvironmentRows`, `deleteMetaItem`, `runUninstallCleanups` and `registry.uninstallPackage` were never called, the package and its field are still served, the state file is unchanged, and a write to the object lands. - **The door answers the same sentence.** `DELETE /packages/:id` answers `409`, the code, the same message, no `details`. - **CONTROL: no dependent.** The second package, disabled first, uninstalls in process; the verb's cleanups include `runtime.package-state`, the package leaves the registry, its field leaves the served object, its disable record is cleared, and the guard stays registered. - **CONTROL: rule switched off through the tenant overlay.** The first package then uninstalls in process, its field leaves the served object, and a write to the object lands. ## Acceptance notes - **Re-measure at branch start (premise holds).** On this rig with the guard call removed from the verb: the in-process `deletePackage` answered `{ success: false, deletedCount: 0, failedCount: 0, cleanups: [runtime.package-state ok] }` without throwing, the package left the registry and its field left the served object, and the next write to the object answered `400 VALIDATION_FAILED`: `Validation rule 'qz_tier_known' could not be evaluated (runtime: No such key: qz_tier) — write rejected.` - **Uninstall form in use.** #31227 landed (`57675a21`). The guard's sentence is the builder's uninstall form, `formatPackageDisableDependents(lead, dependents, { operation: 'uninstall' })` ("which the uninstall would withdraw", "or keep the package installed"). The in-process refusal pin holds that sentence word for word. - **The door and the guard share `packageUninstallDependentRulesRefusal`.** The `DELETE /api/v1/packages/:id` arm asks that one function, with no inline scan, and answers its code, status and message. The door keeps its check ahead of `resolveProtocol` (seat ruling A), because a host whose protocol has no `deletePackage` or no guard registered uninstalls through the registry. With a guard registered, the door answers first and the verb's guard is not reached on that path. An ablation of the door's call shows the guard alone answers the identical 409, code and message. - **The two rollback callers.** Install-local's pair-install undo and the unmanaged-import rollback already catch a thrown uninstall and report it as residue in their own failure answer, so a refusal there leaves the partial package installed and says so; nothing new is swallowed. - **Not covered: install-local's own `DELETE` door.** `DELETE /api/v1/marketplace/install-local/:manifestId` (`@objectstack/cloud-connection`) does not call `deletePackage`: it withdraws through `registry.uninstallPackage` and runs the cleanups itself, so it neither asks the guards nor the verdict. Raised in the report for the seat; outside this card's file surface. ## Verification <details><summary>Ablation: the guard call removed from the verb, the refusal pins go red</summary> `node scripts/ablation-replace.mjs` replaced `await askUninstallGuards(this.uninstallGuards, request);` in `protocol-package-uninstall.ts` with a marker assignment (anchor `1 -> 0`, blob `24b1fe27dc2a -> b53ac531fceb` on the first spelling, whose comment marker no build emits, so it was restored and redone with a code marker), `@objectstack/metadata-protocol` was rebuilt, and `node scripts/ablation-dist-preflight.mjs` found the marker in `dist/index.js` and `dist/index.cjs` (the runtime suite reaches the verb through that `dist/`). Then restored (blob equals HEAD, `git diff HEAD` empty), rebuilt, and the preflight `--absent` leg found the marker in none of the 24 built files. - `protocol.uninstall-guards.test.ts`: 5 red, 1 green (the bare-protocol CONTROL, which registers no guard). Refusal case: `promise resolved "{ success: false, …(5) }" instead of rejecting`. - `[#31232]` describe: the refusal case red (`the in-process uninstall was not refused`), the door case red by cascade (`expected { status: 404 … } to deeply equal { status: 409 … }`: the unrefused uninstall had already removed the package); both CONTROLs green. </details> <details><summary>Rework 1 at head ec3a142 (after #31227 landed)</summary> - `node scripts/pm/dispatch-gates.mjs --commands` derived 73 commands; all 73 exit 0. `--ran`: 73 derived, 73 run, 0 NOT-MEASURED, 0 UNRUN. - `pnpm --filter @objectstack/runtime test`: 555 files, 8953 tests passed, 19 skipped. `pnpm --filter @objectstack/metadata-protocol test`: 325 files, 22542 tests passed, 26 skipped. Typecheck on both, and `eslint packages/metadata-protocol packages/runtime --no-inline-config`: exit 0. - Harness `packages-disable-module-extension-fields.test.ts` + `package-state-host-store.test.ts`: 56/56 (#31227's 4 and this PR's 4). - Dogfood battery not rerun on this head (declared narrowing; last full run green at e052b19). </details> <details><summary>Local gates and suites at head e052b19 (after merging main)</summary> - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 67 commands; all 67 exit 0. `check:dual-build-cjs-loads` first exited 3 before a full `pnpm build` and was rerun after it; `check:test-source-alias` first exited 1 on the new metadata-protocol test (the protocol reaches `@objectstack/lint` through a dynamic import) and was fixed by a module-top `import '@objectstack/lint'`, as the gate prescribes. - `--ran` reconciliation: 67 derived, 67 run, 0 NOT-MEASURED, 0 UNRUN. - `pnpm --filter @objectstack/metadata-protocol test`: 323 files passed, 22533 tests passed, 26 skipped; `typecheck` exit 0. - `pnpm --filter @objectstack/runtime test`: 555 files passed, 8943 tests passed, 19 skipped; `typecheck` exit 0. - `eslint packages/metadata-protocol packages/runtime --no-inline-config` (the root `pnpm lint` over the touched packages): exit 0. - Dogfood battery (`pnpm --filter @objectstack/dogfood test`, full, no shard): 435 files passed, 3 skipped; 3610 tests passed, 41 skipped. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (`packages/spec` is not touched.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (Not touched.) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- _Generated by [Claude Code](https://claude.ai/code/session_017ihL2c1rk1PGZ51s32cxZt)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent dc7f5c4 commit 4363945

11 files changed

Lines changed: 518 additions & 13 deletions
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
'@objectstack/runtime': patch
4+
---
5+
6+
fix(metadata-protocol,runtime)!: the in-process uninstall verb is refused while an active validation rule reads a field the package adds through `objectExtensions`
7+
8+
Clause-②: yes (narrowing) — `protocol.deletePackage` now refuses, with `409 PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE`, an uninstall it used to perform for an in-process caller: one whose package adds a field through `objectExtensions` that an active validation rule of another enabled package reads.
9+
Metadata-protocol: none
10+
11+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped, and no stored row changes shape: what moves is the uninstall verb's answer to a request that would leave a validation rule reading a withdrawn field. Nothing at rest records that answer, so there is nothing for a migration to rewrite. -->
12+
13+
**BREAKING** accept-set narrowing at the uninstall verb (`@objectstack/metadata-protocol`, `@objectstack/runtime`): `deletePackage` asks every registered pre-uninstall guard before it reads or moves anything, and the runtime registers the dependent-rule check the package doors ask as one.
14+
15+
- **Before.** An in-process caller of `protocol.deletePackage` (a host's own uninstall tool, install-local's pair-install undo, the unmanaged-import rollback) withdrew the package's `objectExtensions` fields with no check. Another enabled package's active validation rule that read one of those fields then failed closed, so every write to that rule's object answered `400 VALIDATION_FAILED`, and with the package gone there was nothing left to re-enable.
16+
- **Now.** While such a rule exists, `deletePackage` throws before it reads or moves anything: an error with `code` `PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE`, `status` `409`, and the package doors' sentence in its uninstall form under the lead `Package 'ID' cannot be uninstalled` (`… which the uninstall would withdraw — PACKAGE · OBJECT · RULE (reads FIELD). Switch off or change that rule first, or keep the package installed.`), naming every such rule with the fields it reads. `DELETE /api/v1/packages/:id` answers the same refusal through the same function, so the door and the verb cannot drift. The stored rows, the running registry, the environment's disable record and the uninstall cleanups are untouched. An uninstall no rule depends on, and an uninstall of a package that is already disabled, proceed exactly as before.
17+
- **The seam.** `ObjectStackProtocolImplementation#registerUninstallGuard(name, guard)` is the before-half of `registerUninstallCleanup`: a guard answers `undefined` to let the uninstall proceed, or `{ code, status, message }` to refuse it; guards are asked in registration order and the first refusal is thrown; a guard that throws refuses with its throw. The types `UninstallGuard` and `UninstallGuardRefusal` are exported from `@objectstack/metadata-protocol`. A protocol with no guard registered uninstalls exactly as before. `AppPlugin` registers the runtime's guard (`runtime.package-dependent-rules`) at start, beside the disable-record clear.
18+
- **Who moves.** An in-process caller that uninstalls a package whose field another package's active rule reads. The pair-install undo and the unmanaged-import rollback report the refusal as residue in their own failure answer, as they report any refused uninstall.
19+
20+
**The remedy.** Switch off or change the rules the message names first (a packaged rule can be switched off through the tenant overlay), then uninstall.

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ export type {
135135
SeedTenancySplit,
136136
SeedTenancyCollision,
137137
} from './migrations/seed-tenancy-backfill.js';
138-
export type { UninstallCleanup, UninstallCleanupOutcome, UninstallDeclared } from './protocol.js';
138+
export type { UninstallCleanup, UninstallCleanupOutcome, UninstallDeclared, UninstallGuard, UninstallGuardRefusal } from './protocol.js';
139139
export { captureUninstallDeclared } from './protocol.js';
140140
// [#9960] The ONE declared shape of the `deletePackage` seam, exported so the
141141
// two consumers that speak it (`@objectstack/rest`'s direct-mount package

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@
1212
* `onMetadataMutation`, `emitMetadataMutation`, `attachMetadataMutationPubSub`,
1313
* `detachMetadataMutationPubSub`, `applyRemoteMetadataMutation`,
1414
* `onMetaItemPublished`, `getOverlayRepo` moved out of `protocol.ts` whole by
15-
* `scripts/codemod-split-protocol.mjs`.
15+
* `scripts/codemod-split-protocol.mjs`; `registerUninstallGuard` (#31232) was
16+
* written here in the same shape, beside `registerUninstallCleanup`.
1617
*
1718
* A MOVE, not a redesign. `ProtocolClassHeadFacet` is a stateless facet over the host
1819
* (ruling 6624345766): each static method below is the body of the class
@@ -52,6 +53,7 @@ import type {
5253
UninstallCleanup,
5354
UninstallCleanupOutcome,
5455
UninstallDeclared,
56+
UninstallGuard,
5557
} from './protocol-package-lifecycle-types.js';
5658
import { METADATA_MUTATION_CLUSTER_CHANNEL } from './protocol-mutation-events.js';
5759
import type {
@@ -104,6 +106,11 @@ export class ProtocolClassHeadFacet {
104106
host.uninstallCleanups.set(name, cleanup);
105107
}
106108

109+
/** The body of `ObjectStackProtocolImplementation#registerUninstallGuard`; the method's docblock there is its contract. */
110+
static registerUninstallGuard(host: Pick<ProtocolHost, 'uninstallGuards'>, name: string, guard: UninstallGuard): void {
111+
host.uninstallGuards.set(name, guard);
112+
}
113+
107114
/** The body of `ObjectStackProtocolImplementation#runUninstallCleanups`; the method's docblock there is its contract. */
108115
static async runUninstallCleanups(host: Pick<ProtocolHost, 'uninstallCleanups'>, request: Pick<DeletePackageRequest, 'packageId' | 'actor'> & { declared?: UninstallDeclared },): Promise<UninstallCleanupOutcome[]> {
109116
const cleanups: UninstallCleanupOutcome[] = [];

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,7 @@ export interface ProtocolHost {
259259
readonly tenantAuthoredWriteRefusal: ObjectStackProtocolImplementation['tenantAuthoredWriteRefusal'];
260260
readonly throwMetadataServiceUnavailable: ObjectStackProtocolImplementation['throwMetadataServiceUnavailable'];
261261
readonly uninstallCleanups: ObjectStackProtocolImplementation['uninstallCleanups'];
262+
readonly uninstallGuards: ObjectStackProtocolImplementation['uninstallGuards'];
262263
readonly uninstalledNamedBaseRefusal: ObjectStackProtocolImplementation['uninstalledNamedBaseRefusal'];
263264
readonly unknownDatasourceSaveRefusal: ObjectStackProtocolImplementation['unknownDatasourceSaveRefusal'];
264265
readonly unknownGrantObjectSaveRefusal: ObjectStackProtocolImplementation['unknownGrantObjectSaveRefusal'];

‎packages/metadata-protocol/src/protocol-package-lifecycle-types.ts‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,37 @@ export type UninstallCleanup = (args: {
5454
declared?: UninstallDeclared;
5555
}) => Promise<{ success: boolean; removed: number; error?: string }>;
5656

57+
/**
58+
* [#31232] A guard's answer to "may this package be uninstalled now?": the
59+
* refusal {@link ObjectStackProtocolImplementation.deletePackage} throws, as
60+
* the guard spelled it — `code` (a ledgered error code), `status` (its HTTP
61+
* status) and `message` (the sentence the caller is shown). `undefined` lets
62+
* the uninstall proceed.
63+
*/
64+
export interface UninstallGuardRefusal {
65+
code: string;
66+
status: number;
67+
message: string;
68+
}
69+
70+
/**
71+
* [#31232] Pre-uninstall guard: the BEFORE-half of {@link UninstallCleanup}.
72+
* A plugin that owns a verdict this package must not know (the runtime's
73+
* dependent-rule scan reads the schema registry's fold, which lives above this
74+
* package and must not be imported here) registers it by name, and
75+
* {@link ObjectStackProtocolImplementation.deletePackage} asks every guard,
76+
* in registration order, before it reads or moves anything; the first refusal
77+
* is thrown as the verb's refusal, with nothing removed.
78+
*
79+
* A guard only reads: it is asked before the uninstall is decided, so it must
80+
* not write. A guard that throws instead of answering refuses the uninstall
81+
* with its throw — an uninstall a guard could not judge does not proceed.
82+
*/
83+
export type UninstallGuard = (args: {
84+
packageId: string;
85+
actor?: string;
86+
}) => UninstallGuardRefusal | undefined | Promise<UninstallGuardRefusal | undefined>;
87+
5788
/**
5889
* [ADR-0090 D5, ADR-0131 D4] The security-catalog names an uninstalled package
5990
* held. A grant names its set, and the package's sets are served from the

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

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,11 @@ export interface PackageUninstallHost {
5757
readonly packagelessAddress: ObjectStackProtocolImplementation['packagelessAddress'];
5858
readonly runUninstallCleanups: ObjectStackProtocolImplementation['runUninstallCleanups'];
5959
readonly shouldDropStorage: ObjectStackProtocolImplementation['shouldDropStorage'];
60+
readonly uninstallGuards: ObjectStackProtocolImplementation['uninstallGuards'];
6061
}
6162

6263
/** The body of `ObjectStackProtocolImplementation#deletePackage`; the method's docblock there is its contract. */
63-
export async function deletePackage(this: Pick<PackageUninstallHost, 'deleteMetaItem' | 'engine' | 'environmentId' | 'findEnvironmentRows' | 'getServicesRegistry' | 'packagelessAddress' | 'runUninstallCleanups' | 'shouldDropStorage'>, request: DeletePackageRequest): Promise<DeletePackageResponse> {
64+
export async function deletePackage(this: Pick<PackageUninstallHost, 'deleteMetaItem' | 'engine' | 'environmentId' | 'findEnvironmentRows' | 'getServicesRegistry' | 'packagelessAddress' | 'runUninstallCleanups' | 'shouldDropStorage' | 'uninstallGuards'>, request: DeletePackageRequest): Promise<DeletePackageResponse> {
6465
// [ADR-0131 D6/D12] An uninstall is environment-wide by construction:
6566
// every `sys_metadata` row bound to the package, in this environment,
6667
// whatever organization a legacy row was stored under. The
@@ -83,6 +84,25 @@ export async function deletePackage(this: Pick<PackageUninstallHost, 'deleteMeta
8384
throw err;
8485
}
8586
}
87+
88+
// [#31232] THE PRE-UNINSTALL GUARDS, ASKED BEFORE ANYTHING IS READ OR
89+
// MOVED. A plugin that owns a verdict this package must not know
90+
// registers it as a guard (`registerUninstallGuard`); the runtime
91+
// registers its dependent-rule scan, so an uninstall that would withdraw
92+
// a field an active validation rule of another enabled package reads is
93+
// refused here for EVERY caller of this verb (a host's own uninstall
94+
// tool, install-local's pair-install undo, the unmanaged-import
95+
// rollback), not only at the package door that asks it first. This
96+
// package holds no copy of the rule.
97+
//
98+
// Each guard is asked in registration order and the first refusal is
99+
// thrown, spelled as the guard answered it (`code`, `status`, `message`):
100+
// nothing has been read or removed yet, so there is nothing to undo. A
101+
// guard's own throw propagates as is — an uninstall a guard could not
102+
// judge does not proceed. With no guard registered nothing is asked, and
103+
// the verb behaves exactly as it did before guards existed.
104+
await askUninstallGuards(this.uninstallGuards, request);
105+
86106
const where: Record<string, unknown> = { package_id: request.packageId };
87107
// [#8136] This read is the uninstall's FIRST database touch, and until
88108
// now it sat outside every `try` in this method — the per-item `catch`
@@ -392,6 +412,26 @@ export async function deletePackage(this: Pick<PackageUninstallHost, 'deleteMeta
392412
};
393413
}
394414

415+
/**
416+
* [#31232] Ask every registered pre-uninstall guard, in registration order,
417+
* and throw the first refusal one answers: an `Error` carrying the guard's
418+
* `message`, with its `code` and `status`. Asked by {@link deletePackage}
419+
* before it reads or moves anything; a guard's own throw propagates.
420+
*/
421+
async function askUninstallGuards(
422+
guards: PackageUninstallHost['uninstallGuards'],
423+
request: DeletePackageRequest,
424+
): Promise<void> {
425+
for (const guard of guards.values()) {
426+
const refusal = await guard({
427+
packageId: request.packageId,
428+
...(request.actor ? { actor: request.actor } : {}),
429+
});
430+
if (!refusal) continue;
431+
throw Object.assign(new Error(refusal.message), { code: refusal.code, status: refusal.status });
432+
}
433+
}
434+
395435
/**
396436
* [#31186] The event the uninstall verb hands the `audit` slot's
397437
* `recordPackageLifecycleEvent` — declared here, structurally, so this package

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

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -527,6 +527,7 @@ import type {
527527
UninstallCleanup,
528528
UninstallCleanupOutcome,
529529
UninstallDeclared,
530+
UninstallGuard,
530531
} from './protocol-package-lifecycle-types.js';
531532
export { captureUninstallDeclared } from './protocol-package-lifecycle-types.js';
532533
export type {
@@ -537,6 +538,8 @@ export type {
537538
UninstallCleanup,
538539
UninstallCleanupOutcome,
539540
UninstallDeclared,
541+
UninstallGuard,
542+
UninstallGuardRefusal,
540543
} from './protocol-package-lifecycle-types.js';
541544
import { METADATA_MUTATION_CLUSTER_CHANNEL } from './protocol-mutation-events.js';
542545
import type {
@@ -761,6 +764,9 @@ export class ObjectStackProtocolImplementation implements
761764
/** [#2747] Named uninstall cleanups, run by {@link deletePackage}. */
762765
private uninstallCleanups = new Map<string, UninstallCleanup>();
763766

767+
/** [#31232] Named pre-uninstall guards, asked by {@link deletePackage} before it reads or moves anything. */
768+
private uninstallGuards = new Map<string, UninstallGuard>();
769+
764770
/**
765771
* Awaited per-type mutation projectors (ADR-0094), keyed by singular
766772
* metadata type. Unlike {@link publishMaterializers} (publish-only,
@@ -1379,6 +1385,7 @@ export class ObjectStackProtocolImplementation implements
13791385
tenantAuthoredWriteRefusal: (...a) => protocol.tenantAuthoredWriteRefusal(...a),
13801386
throwMetadataServiceUnavailable: (...a) => protocol.throwMetadataServiceUnavailable(...a),
13811387
get uninstallCleanups() { return protocol.uninstallCleanups; },
1388+
get uninstallGuards() { return protocol.uninstallGuards; },
13821389
uninstalledNamedBaseRefusal: (...a) => protocol.uninstalledNamedBaseRefusal(...a),
13831390
unknownDatasourceSaveRefusal: (...a) => protocol.unknownDatasourceSaveRefusal(...a),
13841391
unknownGrantObjectSaveRefusal: (...a) => protocol.unknownGrantObjectSaveRefusal(...a),
@@ -1413,6 +1420,27 @@ export class ObjectStackProtocolImplementation implements
14131420
return ProtocolClassHeadFacet.registerUninstallCleanup(protocolHost(this), name, cleanup);
14141421
}
14151422

1423+
/**
1424+
* [#31232] Register a named pre-uninstall guard: the before-half of
1425+
* {@link registerUninstallCleanup}. {@link deletePackage} asks every
1426+
* registered guard, in registration order, before it reads or moves
1427+
* anything, and throws the first refusal one answers as its own, with
1428+
* `code` and `status` as the guard spelled them; nothing is removed. A
1429+
* guard that throws refuses with its throw. With no guard registered the
1430+
* verb behaves exactly as it did before guards existed.
1431+
*
1432+
* Called by a plugin that owns a verdict this package must not know — the
1433+
* runtime registers its dependent-rule scan (an active validation rule of
1434+
* another enabled package reading a field the package adds through
1435+
* `objectExtensions`), the one the package doors ask, so every caller of
1436+
* the verb is refused on the verdict those doors answer, and this package
1437+
* holds no copy of it. One guard per name; a second registration replaces
1438+
* the first (idempotent re-init).
1439+
*/
1440+
registerUninstallGuard(name: string, guard: UninstallGuard): void {
1441+
return ProtocolClassHeadFacet.registerUninstallGuard(protocolHost(this), name, guard);
1442+
}
1443+
14161444
/**
14171445
* Run every registered uninstall cleanup for one package and report each
14181446
* outcome (ADR-0086 D3, #2747) — THE one runner of the registry above.
@@ -10915,7 +10943,10 @@ export class ObjectStackProtocolImplementation implements
1091510943
*
1091610944
* [#21276] The steps, in order. Nothing durable happens before step 4, so
1091710945
* a refusal at any of steps 1–4 leaves everything as it was:
10918-
* 1. the retired-key refusal (`INVALID_REQUEST`) — pure;
10946+
* 1. the retired-key refusal (`INVALID_REQUEST`) — pure; then [#31232]
10947+
* every registered pre-uninstall guard ({@link registerUninstallGuard}),
10948+
* in registration order — the first refusal is thrown with its own
10949+
* `code` and `status`, a guard's throw as is;
1091910950
* 2. the `sys_metadata` read — a read; a failure is thrown;
1092010951
* 3. the registry's uninstall refusal (another package extends an object
1092110952
* this one owns, ADR-0029), asked through
@@ -10938,6 +10969,7 @@ export class ObjectStackProtocolImplementation implements
1093810969
const readEngine = () => this.engine;
1093910970
const readGetServicesRegistry = () => this.getServicesRegistry;
1094010971
const readEnvironmentId = () => this.environmentId;
10972+
const readUninstallGuards = () => this.uninstallGuards;
1094110973
const host: ThisParameterType<typeof deletePackage> = {
1094210974
deleteMetaItem: (...a) => this.deleteMetaItem(...a),
1094310975
get engine() { return readEngine(); },
@@ -10947,6 +10979,7 @@ export class ObjectStackProtocolImplementation implements
1094710979
packagelessAddress: (...a) => this.packagelessAddress(...a),
1094810980
runUninstallCleanups: (...a) => this.runUninstallCleanups(...a),
1094910981
shouldDropStorage: (...a) => this.shouldDropStorage(...a),
10982+
get uninstallGuards() { return readUninstallGuards(); },
1095010983
};
1095110984
return deletePackage.call(host, request);
1095210985
}

0 commit comments

Comments
 (0)