Repository navigation
Commit 6fcecb2
fix(cloud-connection,metadata-protocol)!: install-local's uninstall door asks the uninstall verb's guards and writes its audit row (#31439)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"}
-->
_Requested by **Leehom** · [project
thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XTVkGrf2CjQTF79hBERgqiJ)_
domain:server#5 · os-project-manager · session_01FEkQ5ktJb5rQ5n2GUXSQMR
Fixes #31283
Clause-②: yes (narrowing) — both arms. Narrowing: `DELETE
/api/v1/marketplace/install-local/:manifestId` refuses, with the
uninstall verb's guard refusal (today the runtime's `409
PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE`), an uninstall it
answered `200` before. Widening: two new public members of
`ObjectStackProtocolImplementation` (`runUninstallGuards`,
`recordPackageUninstall`) and one new exported type
(`PackageUninstallRecord`), `@objectstack/metadata-protocol`.
Metadata-protocol: none
Skills-docs: one move —
`content/docs/deployment/marketplace-handbook.mdx` (Uninstalling) states
the refusal and the audit row; no `skills/**` page teaches this door's
uninstall.
## 维护者速读
- 改了什么:本地安装自己的卸载入口 `DELETE
/api/v1/marketplace/install-local/:manifestId`
现在和平台统一卸载动作走同一套守卫与审计:卸载前先问卸载守卫(其中有"别的包的启用校验规则正读着这个包扩展进来的字段"这条),被拒就回守卫给的
409 和原话、什么都不动;卸载成功后写一条 `package_uninstall` 审计记录。
- 为什么改:这个入口不能直接调用 `deletePackage`(没有存储行时它回 `success:
false`,而且会删对象表),所以以前两件事都漏了:有依赖规则也照样卸载(字段消失后那条规则对该对象每次写入都失败关闭),也没有审计记录。
- 风险与回滚:协议上新增两个公开方法 `runUninstallGuards`、`recordPackageUninstall`,它们就是
`deletePackage`
自己调用的那一个守卫执行器和那一个审计写入器,裁决和写入都只有一份,入口里没有复制。老版本协议没有这两个方法时入口照旧。回滚即还原本 PR。
- 先看:`packages/cloud-connection/src/marketplace-install-local-plugin.ts`
的 `handleUninstall`(新的守卫块和末尾的 `recordUninstall`)与 `askUninstallGuards` /
`recordUninstall`;`packages/metadata-protocol/src/protocol-package-uninstall.ts`
的 `runUninstallGuards` / `recordPackageUninstall`;`protocol.ts` 两个新成员。
- 两分钟验证:`pnpm --filter @objectstack/cloud-connection exec vitest run
src/marketplace-install-local-uninstall-guards-audit.test.ts`,预期 2
个用例全绿(拒绝 409 + 对照写一条审计)。
- 需要你做的:无,席位落地。
## What changes
**Route.** The order prefers delegating the door to `deletePackage` if
that keeps the door's withdrawal semantics. It does not: the verb
answers `success: false` for a package with no stored `sys_metadata`
rows (every install-local package; this is why `runUninstallCleanups`
was extracted for this door, #21490, and its docblock says so), it
deletes the `sys_packages` row and drops the tables of the objects it
removes, and it does not do the door's own steps (the ledger, the
object-copy withdrawal, the extended-copy restore, the
`metadata:reloaded` re-sync). So the door calls public entry points
instead, as the order's second route reads. Neither is a `packages/spec`
member: like `runUninstallCleanups`, both live on the class only, so no
`needs_decision`.
1. **`@objectstack/metadata-protocol`.**
- `runUninstallGuards(request)`: the one runner of the pre-uninstall
guard registry (#31232). `deletePackage` now asks its guards through it,
so the verb and the door run the same function. It throws the first
refusal with the guard's `code`, `status` and `message`; a guard's own
throw propagates.
- `recordPackageUninstall(record)`: the one writer of the
`package_uninstall` row (#31186). `deletePackage` now writes its row
through it. It adds the protocol's environment and hands the `audit`
slot one event; no slot writes nothing, and a slot that throws is
logged, never thrown.
- `PackageUninstallRecord`, the record type, exported beside
`UninstallGuardRefusal`. The module-private writer is renamed
`writePackageUninstallEvent`; nothing else in `deletePackage` moves.
2. **`@objectstack/cloud-connection`, `handleUninstall`.**
- After the wall check and before the registry's own uninstall refusal
(#28511), the door asks `protocol.runUninstallGuards({ packageId, actor
})`. A coded 4xx refusal is the request's answer, as the guard spelled
it, with nothing moved. Any other throw propagates, as it does out of
the verb.
- After the uninstall is durable (ledger entry gone, withdrawal,
cleanups, re-sync), the door calls `protocol.recordPackageUninstall({
packageId, version, actor, itemsRemoved: 0, itemsFailed: 0 })`. The
version is the ledger entry's, read before its removal. The two counts
are of stored `sys_metadata` items, which an install-local package does
not have.
- A protocol without either member (an older build) is not asked, and
the door behaves as before: the capability question the door already
asks of `runUninstallCleanups`. The narrowing `UninstallCleanupRunner`
type gains the two members as indexed accesses on the class, never
restated.
3. **Docs.** `marketplace-handbook.mdx`, step 9 (Uninstalling): the
refusal and the audit row, one short paragraph.
## Tests
- New
`packages/cloud-connection/src/marketplace-install-local-uninstall-guards-audit.test.ts`.
Real `ObjectKernel` + `ObjectQLPlugin` (the real registry and the real
`ObjectStackProtocolImplementation` it registers as `protocol`), plus
the runtime's `AppPlugin`, whose `start()` registers the real
dependent-rule guard. The `audit` slot is a recorder. Packages are
registered as the runtime's #30999 rig registers them; ledger entries as
an install writes them.
- A package whose `objectExtensions` field an active rule of another
package reads: `409 PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE` with
exactly the sentence `formatPackageDisableDependents(...)` builds under
the uninstall lead. Nothing moved: ledger entry kept, package still
registered, field still served, no cleanup run, no audit event.
- CONTROL: a package whose field no rule reads uninstalls, `200`. The
audit slot receives exactly one event: `{ action: 'package_uninstall',
packageId, version: '1.0.0', actor, itemsRemoved: 0, itemsFailed: 0 }`.
- `packages/metadata-protocol/src/protocol.uninstall-guards.test.ts`,
four new cases. `runUninstallGuards` throws the first refusal, hands the
guard the actor, and reads or moves nothing. It resolves with no refusal
and on a bare protocol. `recordPackageUninstall` hands the slot one
event carrying the protocol's environment, and writes nothing and does
not throw with no slot. The existing #31232 / #31186 `deletePackage`
cases stay green through the refactor.
<details><summary>Red on main (new door test run at base 405a78e,
before the fix)</summary>
```
× a package whose extension field an active rule of another package reads is refused with the verb's 409; nothing moves
× CONTROL — a package whose field no rule reads uninstalls and the protocol records one package_uninstall event
AssertionError: {"success":true,"data":{"manifestId":"com.acme.tiers","cleanups":[{"name":"runtime.package-state","success":true,"removed":0}],"note":"Cached manifest removed, the package withdrawn from the running kernel, …"}}: expected 200 to be 409
AssertionError: expected [] to deeply equal [ { …(6) } ]
Tests 2 failed (2)
```
</details>
<details><summary>Ablations (fix committed at ef3daa6, each
restored)</summary>
Both through `node scripts/ablation-replace.mjs` on
`marketplace-install-local-plugin.ts`. The suite imports the plugin from
`src/` relatively, so no build applies.
1. Guard ask removed (`const refused = await this.askUninstallGuards(…)`
→ `const refused = undefined;`):
```
ok mutation landed: anchor 1 -> 0, blob e9f4cc2 -> 2e788c35b432
× a package whose extension field an active rule of another package reads is refused with the verb's 409; nothing moves
AssertionError: {"success":true,…}: expected 200 to be 409
Tests 1 failed | 1 passed (2)
ok restored: blob == HEAD (e9f4cc2) and `git diff HEAD` is empty
```
2. Audit write removed (`await this.recordUninstall(…)` → `void
removedVersion;`):
```
ok mutation landed: anchor 1 -> 0, blob e9f4cc2 -> 1a0037367d2a
× CONTROL — a package whose field no rule reads uninstalls and the protocol records one package_uninstall event
AssertionError: expected [] to deeply equal [ { …(6) } ]
Tests 1 failed | 1 passed (2)
ok restored: blob == HEAD (e9f4cc2) and `git diff HEAD` is empty
```
</details>
## Verification
All on head `645c9512` (the fix, the changeset and docs, and one merge
of `origin/main`), each exit code captured before any pipe:
- **Full build** `pnpm turbo run build --concurrency=2` under the verify
lock: VERDICT command-exit 0.
- **Derived gate union**, `node scripts/pm/dispatch-gates.mjs --commands
--repo objectstack-ai/objectstack`: 102 families, every one exit 0 on
its first run. `--ran`: "102 derived famil(ies) accounted for — 102 run,
0 NOT-MEASURED (a DERIVED zero — all 102 recorded an exit code and none
of them is 3)". `check:type-check-debt` was also rerun after the dogfood
run released the lock: exit 0.
- **Packages**: `pnpm --filter @objectstack/cloud-connection test`: Test
Files 75 passed, Tests 975 passed (VERDICT 0). `pnpm --filter
@objectstack/metadata-protocol test`: Test Files 326 passed / 4 skipped,
Tests 22563 passed / 26 skipped (VERDICT 0). `typecheck` on both: exit
0.
- **Lint**: `eslint --no-inline-config` on the seven touched `.ts`
files: exit 0.
- **Full dogfood battery** (`(narrowing)` arm), `pnpm --filter
@objectstack/dogfood test` under the verify lock: VERDICT command-exit
0, Test Files 438 passed / 3 skipped (441), Tests 3622 passed / 41
skipped (3663).
- CI-only families (Test Core shards, the type-check lanes, Build Docs)
are read from this PR's check-runs, not re-run here.
## Acceptance notes
- **Why not delegate.** See *Route* above: `deletePackage` would answer
`success: false`, delete a `sys_packages` row the package never had, and
drop object tables the door has always kept ("Records stored in the
app's objects stay in the database", marketplace handbook).
- **Order of the door's refusals.** The wall check (ADR-0131 D12), then
the verb's guards (this PR), then the registry's own uninstall refusal
(`assertPackageUninstallable`, 409 `RESOURCE_CONFLICT`). The verb asks
guards first too.
- **`itemsRemoved: 0`.** The row's counts are of stored `sys_metadata`
items removed, which an install-local package has none of. The door says
so in the comment beside the call.
- **Serial note.** Open PRs #31402 and #31398 touch the same file
outside the uninstall door. `origin/main` was merged once (merge
`645c9512`); neither had landed.
- **Governed surface.** None: two packages' sources and tests, one
changeset, one docs page (`content/docs/**`, not a register row).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01FEkQ5ktJb5rQ5n2GUXSQMR
---
_Generated by [Claude
Code](https://claude.ai/code/session_01FEkQ5ktJb5rQ5n2GUXSQMR)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 0e1311d commit 6fcecb2
9 files changed
Lines changed: 492 additions & 10 deletions
File tree
- .changeset
- content/docs/deployment
- packages
- cloud-connection/src
- metadata-protocol/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
254 | 254 | | |
255 | 255 | | |
256 | 256 | | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
257 | 262 | | |
258 | 263 | | |
259 | 264 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
427 | 427 | | |
428 | 428 | | |
429 | 429 | | |
| 430 | + | |
| 431 | + | |
| 432 | + | |
| 433 | + | |
| 434 | + | |
| 435 | + | |
| 436 | + | |
| 437 | + | |
| 438 | + | |
| 439 | + | |
| 440 | + | |
430 | 441 | | |
431 | 442 | | |
432 | 443 | | |
| |||
3343 | 3354 | | |
3344 | 3355 | | |
3345 | 3356 | | |
| 3357 | + | |
| 3358 | + | |
| 3359 | + | |
| 3360 | + | |
| 3361 | + | |
| 3362 | + | |
| 3363 | + | |
| 3364 | + | |
| 3365 | + | |
| 3366 | + | |
| 3367 | + | |
| 3368 | + | |
| 3369 | + | |
| 3370 | + | |
3346 | 3371 | | |
3347 | 3372 | | |
3348 | 3373 | | |
| |||
3383 | 3408 | | |
3384 | 3409 | | |
3385 | 3410 | | |
| 3411 | + | |
| 3412 | + | |
3386 | 3413 | | |
3387 | 3414 | | |
3388 | 3415 | | |
| |||
3444 | 3471 | | |
3445 | 3472 | | |
3446 | 3473 | | |
| 3474 | + | |
| 3475 | + | |
| 3476 | + | |
| 3477 | + | |
| 3478 | + | |
| 3479 | + | |
| 3480 | + | |
3447 | 3481 | | |
3448 | 3482 | | |
3449 | 3483 | | |
| |||
3696 | 3730 | | |
3697 | 3731 | | |
3698 | 3732 | | |
| 3733 | + | |
| 3734 | + | |
| 3735 | + | |
| 3736 | + | |
| 3737 | + | |
| 3738 | + | |
| 3739 | + | |
| 3740 | + | |
| 3741 | + | |
| 3742 | + | |
| 3743 | + | |
| 3744 | + | |
| 3745 | + | |
| 3746 | + | |
| 3747 | + | |
| 3748 | + | |
| 3749 | + | |
| 3750 | + | |
| 3751 | + | |
| 3752 | + | |
| 3753 | + | |
| 3754 | + | |
| 3755 | + | |
| 3756 | + | |
| 3757 | + | |
| 3758 | + | |
| 3759 | + | |
| 3760 | + | |
| 3761 | + | |
| 3762 | + | |
| 3763 | + | |
| 3764 | + | |
| 3765 | + | |
| 3766 | + | |
| 3767 | + | |
| 3768 | + | |
| 3769 | + | |
| 3770 | + | |
| 3771 | + | |
| 3772 | + | |
| 3773 | + | |
| 3774 | + | |
| 3775 | + | |
| 3776 | + | |
| 3777 | + | |
| 3778 | + | |
| 3779 | + | |
| 3780 | + | |
| 3781 | + | |
| 3782 | + | |
| 3783 | + | |
| 3784 | + | |
| 3785 | + | |
| 3786 | + | |
| 3787 | + | |
| 3788 | + | |
| 3789 | + | |
| 3790 | + | |
| 3791 | + | |
3699 | 3792 | | |
3700 | 3793 | | |
3701 | 3794 | | |
| |||
0 commit comments