Repository navigation
Commit d9f8f7f
feat(runtime): a host can supply the package-state store (createPackageStateStorePlugin); a refused write fails the enable/disable door with nothing changed (#31008)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} -->
_Requested by **Tesla** · [project
thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6JaoNFPj8KV1KqL2puViUBBKLC)_
Fixes #30962
Clause-②: yes (widening)
Metadata-protocol: none
## 维护者速读
- 改了什么:模块启用/停用状态的存放处可由宿主提供。宿主在内核里加
`createPackageStateStorePlugin(store)`,启动种子和启用/停用/安装/删除各门都改读写这个存储;不加则仍是
`OS_HOME` 下的那个文件,字节不变。
- 为什么改:托管云上容器一换,`OS_HOME` 里的文件就没了,管理员在设置里关掉的模块会重新出现(cloud#3636,裁决「AAAA +
三条验收(推荐)」)。平台补上缺的宿主接缝,cloud 只需实现托管存储。
- 风险与回滚:启用/停用门改成先写存储、再改内存;宿主存储拒写时门答 503 且什么都不变。未注入时行为与文件内容不变。回滚即 revert
本 PR。
- 先看:`packages/runtime/src/domains/packages.ts` 的启用、停用两门(先写存储的顺序与 503
分支);`packages/runtime/src/app-plugin.ts` 的
`seedPersistedDisabledPackages` 与 `refuseLatePackageStateStore`;新文件
`packages/runtime/src/package-state-host-store.ts`。
- 两分钟验证:`pnpm --filter @objectstack/runtime exec vitest run
--maxWorkers=2 src/package-state-host-store.test.ts`,预期 11 个用例全绿。
- 需要你做的:无,席位落地。
## What changes
**The seam (`packages/runtime/src/package-state-host-store.ts`, exported
from `@objectstack/runtime`).** `PackageStateStore` is
`loadDisabledPackageIds(environmentId)` plus
`setPackageDisabled(environmentId, packageId, disabled)`, either may
return a promise. A host adds `createPackageStateStorePlugin(store)` to
its kernel; that plugin (`com.objectstack.package-state-store`)
registers the store as the `package-state-store` kernel service. This is
the door a host already uses to hand the runtime anything:
`createStandaloneStack` returns a `plugins` list the host composes into
its kernel, so no new top-level option was added. `AppPlugin` lists the
plugin name in `optionalDependencies` (ADR-0116), so the store is
registered before the boot seed reads it whatever slot the host puts it
in; `check:init-service-contract` now holds that init-time read to the
declaration.
**Boot seed (`AppPlugin.seedPersistedDisabledPackages`).** Reads the
host store when one is registered, else the file exactly as before
(best-effort, warns). A host store whose read throws fails the boot with
a named error instead of seeding nothing, which would bring every
disabled module back enabled with nothing looking wrong (the ADR-0025
§3.5 M1 call the installed-plugin loader already makes).
`AppPlugin.start()` refuses a boot that seeded from the file while a
store is registered by then (a host registering under the service name
from its own, unordered plugin), naming `createPackageStateStorePlugin`
as the fix.
**Doors (`domains/packages.ts`).** Every write goes through one
`writePackageState`, which looks the store up through the classified
lookup (`resolveServiceOrLoud`), so a store factory that throws refuses
the write rather than reading as "no host store".
- `PATCH /packages/:id/enable` and `/disable` now write the store FIRST
and switch the registry second. A host store that refuses: nothing
changes in memory and the door answers `503 SERVICE_UNAVAILABLE` (the
standard code, no new code), message opening `Package 'ID' was not
disabled: the package-state store refused to record it, so nothing
changed.` The file default keeps its best-effort `warn` and still
switches.
- Install and delete, whose change has already landed when they write,
keep their best-effort `try`; a host store's refusal there is logged at
`error` naming what the next boot will read, the file's failure keeps
the existing `warn`.
- The disable door's dependent-rule scan (#30825, merged) runs first and
is untouched; the store write follows it.
## Tests
New `packages/runtime/src/package-state-host-store.test.ts`, a real
`ObjectKernel` + `ObjectQLPlugin` + `AppPlugin` and the real
`HttpDispatcher` on the package doors, the store plugin composed AFTER
`AppPlugin` in `use()` order:
- the boot seeds from the host store and not from the file (the file
says the opposite); every enable and disable write reaches the store and
the `OS_HOME` state directory is never created; a disable written in one
boot is what the next boot (fresh `OS_HOME`) seeds;
- a refusing store: disable answers 503 `SERVICE_UNAVAILABLE` and the
package stays enabled; enable answers 503 and it stays disabled; an
unknown id still answers 404 and writes nothing;
- a store whose boot read throws fails the boot; an unordered
registration is refused at start; a store missing a method is a
`TypeError`;
- without a host store: the file bytes after disable and enable are
pinned exactly, and a file that cannot be written still only warns while
the disable lands (200).
Head `8e94a195` (after merging `origin/main` at `c77dd17f`):
- `pnpm --filter @objectstack/runtime typecheck`: exit 0
(`check:test-typecheck: OK`).
- `pnpm --filter @objectstack/runtime test`: Test Files 551 passed
(551), Tests 8827 passed, 19 skipped.
- eslint `packages/runtime --no-inline-config`: exit 0.
<details><summary>Ablations (each once, through
scripts/ablation-replace.mjs, each restored with blob == HEAD and git
diff HEAD empty; taken on 83af516, before the merge, which did not
touch these lines)</summary>
A1, the refusal answered, removed (a refusing host store warns and
disables anyway):
```
ablation-replace: ok mutation landed: anchor 1 -> 0, blob fd9ac19 -> d9c4bc8efdb9
× a refusing store fails the disable door visibly, and the package stays enabled
AssertionError: expected { status: 200, code: undefined, …(1) } to match object { status: 503, …(1) }
Tests 1 failed | 10 passed (11)
```
(The first A1 attempt used an empty `--replacement`; the tool refused it
as a non-landing mutation before running anything, and the leg was
re-run with `--delete`.)
A2, the old order back (registry switched before the store write,
refusal kept):
```
ablation-replace: ok mutation landed: anchor 1 -> 0, blob fd9ac19 -> d54cbb48382c
× a refusing store fails the disable door visibly, and the package stays enabled
× an unknown package still answers 404 and writes nothing to the store
AssertionError: expected { manifest: { …(4) }, …(7) } to match object { enabled: true, status: 'installed' }
Tests 2 failed | 9 passed (11)
```
A3, no host store and the file not written:
```
ablation-replace: ok mutation landed: anchor 1 -> 0, blob fd9ac19 -> a97613d63a2c
× the disable and enable doors write the file under OS_HOME exactly as before
× a file that cannot be written still only warns: the disable lands in memory and answers 200
Tests 2 failed | 9 passed (11)
```
(Its first attempt was refused by the tool, the replacement text already
occurring elsewhere; re-run with `--delete`.)
A4, no host store and a file failure made loud:
```
ablation-replace: ok mutation landed: anchor 1 -> 0, blob fd9ac19 -> b4cf3ef91361
× a file that cannot be written still only warns: the disable lands in memory and answers 200
AssertionError: expected { status: 503, …(2) } to match object { status: 200 }
Tests 1 failed | 10 passed (11)
```
A5, the start-time refusal of an unordered store removed:
```
ablation-replace: ok mutation landed: anchor 1 -> 0, blob a7b1c99 -> 3b2717f6436c
× a store registered under the service name by a plugin AppPlugin is not ordered after is refused at start
AssertionError: promise resolved "{ kernel: ObjectKernel{ …(14) }, …(1) }" instead of rejecting
```
A6, the store plugin dropped from `AppPlugin.optionalDependencies`:
```
ablation-replace: ok mutation landed: anchor 1 -> 0, blob a7b1c99 -> 5fa7328f99d5
× seeds the boot from the host store — not from the file — even when composed after AppPlugin
… 7 failed | 4 passed (11)
```
</details>
## Acceptance notes
- **Card acceptance, line by line.** ① A host-injected store sees every
enable/disable write (install, enable, disable, delete) and seeds boot:
pinned. ② A refusing store makes the enable and disable doors fail
visibly (503) with in-memory state unchanged: pinned and ablated (A1,
A2). ③ Without injection behaviour is unchanged: file bytes and the
warn-only failure pinned and ablated (A3, A4).
- **Cloud C3 (the hosted store, cloud#3636) consumes this**: it
implements `PackageStateStore` and adds
`createPackageStateStorePlugin(store)` to the hosted kernel.
- **Choices the card did not rule, made here; the seat may overrule.**
(a) A host store's boot read that throws fails the boot, not a warn. (b)
Install and delete stay best-effort on a host refusal (their change has
landed), logged at `error`; only enable and disable, which can write
first, answer the refusal. (c) The 503 message embeds the store's own
error text; the dispatcher's 5xx leak guard still replaces it if it
looks like an internal leak.
- **Not routed through the seam:** `plugin-installation-store.ts` (the
local `os plugin disable | enable` commands and the boot-time
installed-plugin loader, ADR-0025 §3.5 M1) still reads and writes the
file directly. That loop is local-first CLI state; a hosted container
that wants it host-backed is a separate card.
- **Overlap:** #30825 merged and is integrated (its dependent scan runs
before the store write, unchanged). #30756 (seat thread, branch
`fyy60n`) is adding a refusal pre-check to the enable door; whichever PR
opens second merges `origin/main` after the first lands.
- No `packages/spec` edit, no new error code. Size: 6 files, +681 / −30.
## Verification
Derived gates (`dispatch-gates.mjs --commands`, 63 families) plus
`check:init-service-contract`, `check:startup-registry-verdict`,
`check:error-code-casing`, `check:route-ledger-census`,
`check:authz-resolver`, `check:changeset-fixed`,
`check:filter-alias-parity` and `check-platform-checklist.mjs
--changed-from origin/main`: all 71 exit 0 on `8e94a195`;
`dispatch-gates --ran`: 63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN.
Record: `server-seat-2/r2026-11-06/out/30962-gates-ran-8e94a195.list` in
the project files.
<details><summary>Notes on the gate run</summary>
- On the pre-merge head, `check:test-source-alias` was red on the new
test (a clocked lazy `import('@objectstack/metadata')` reached through
`AppPlugin`); fixed with a module-top `import '@objectstack/metadata'`
in the test, the gate's own prescription.
- `check-plugin-teardown-shape --self-test` and
`check:dual-build-cjs-loads` first answered exit 3 (prerequisite: pinned
commit 621a487 absent from the shallow clone, and unbuilt dists); after
`git fetch` of that commit and a full `turbo run build
--filter='./packages/**'`, both exit 0.
</details>
---
_Generated by [Claude
Code](https://claude.ai/code/session_01L9qibvSvYrx9SiZMbSoehj)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 6106b73 commit d9f8f7f
6 files changed
Lines changed: 681 additions & 30 deletions
File tree
- .changeset
- packages/runtime/src
- domains
| 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 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
30 | 30 | | |
31 | 31 | | |
32 | 32 | | |
| 33 | + | |
33 | 34 | | |
34 | 35 | | |
35 | 36 | | |
| |||
171 | 172 | | |
172 | 173 | | |
173 | 174 | | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
174 | 181 | | |
175 | 182 | | |
176 | 183 | | |
177 | 184 | | |
178 | 185 | | |
179 | 186 | | |
| 187 | + | |
180 | 188 | | |
181 | 189 | | |
182 | 190 | | |
| |||
233 | 241 | | |
234 | 242 | | |
235 | 243 | | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
236 | 252 | | |
237 | 253 | | |
238 | 254 | | |
| |||
561 | 577 | | |
562 | 578 | | |
563 | 579 | | |
564 | | - | |
| 580 | + | |
565 | 581 | | |
566 | 582 | | |
567 | 583 | | |
| |||
686 | 702 | | |
687 | 703 | | |
688 | 704 | | |
689 | | - | |
690 | | - | |
| 705 | + | |
| 706 | + | |
| 707 | + | |
| 708 | + | |
| 709 | + | |
| 710 | + | |
| 711 | + | |
| 712 | + | |
| 713 | + | |
| 714 | + | |
| 715 | + | |
| 716 | + | |
691 | 717 | | |
692 | | - | |
| 718 | + | |
| 719 | + | |
| 720 | + | |
693 | 721 | | |
694 | | - | |
695 | | - | |
696 | | - | |
697 | | - | |
698 | | - | |
699 | | - | |
700 | | - | |
701 | | - | |
702 | | - | |
703 | | - | |
704 | | - | |
| 722 | + | |
| 723 | + | |
| 724 | + | |
| 725 | + | |
| 726 | + | |
| 727 | + | |
| 728 | + | |
| 729 | + | |
| 730 | + | |
| 731 | + | |
| 732 | + | |
| 733 | + | |
| 734 | + | |
| 735 | + | |
| 736 | + | |
| 737 | + | |
| 738 | + | |
| 739 | + | |
| 740 | + | |
| 741 | + | |
| 742 | + | |
| 743 | + | |
| 744 | + | |
| 745 | + | |
| 746 | + | |
| 747 | + | |
| 748 | + | |
| 749 | + | |
| 750 | + | |
| 751 | + | |
| 752 | + | |
| 753 | + | |
| 754 | + | |
| 755 | + | |
| 756 | + | |
| 757 | + | |
| 758 | + | |
| 759 | + | |
| 760 | + | |
| 761 | + | |
705 | 762 | | |
706 | 763 | | |
707 | 764 | | |
| |||
710 | 767 | | |
711 | 768 | | |
712 | 769 | | |
| 770 | + | |
| 771 | + | |
| 772 | + | |
| 773 | + | |
| 774 | + | |
| 775 | + | |
| 776 | + | |
| 777 | + | |
| 778 | + | |
| 779 | + | |
| 780 | + | |
| 781 | + | |
| 782 | + | |
| 783 | + | |
| 784 | + | |
| 785 | + | |
| 786 | + | |
| 787 | + | |
| 788 | + | |
| 789 | + | |
| 790 | + | |
| 791 | + | |
| 792 | + | |
| 793 | + | |
| 794 | + | |
| 795 | + | |
| 796 | + | |
| 797 | + | |
| 798 | + | |
| 799 | + | |
| 800 | + | |
| 801 | + | |
| 802 | + | |
713 | 803 | | |
714 | 804 | | |
715 | 805 | | |
| |||
1240 | 1330 | | |
1241 | 1331 | | |
1242 | 1332 | | |
| 1333 | + | |
1243 | 1334 | | |
1244 | 1335 | | |
1245 | 1336 | | |
| |||
0 commit comments