Repository navigation
Commit 9052337
fix(runtime)!: the package enable door refuses while a rule it would serve reads a field a disabled package withholds (#31018)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} -->
_Requested by **Tesla** · [project
thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6JaoMtM8khPb6BVweLak3Dt7Fg)_
Fixes #30756
domain:server#2 · os-tesla · session_01L9qibxjrKo69n5BEsgi5qA
Clause-②: no (narrowing)
Metadata-protocol: none
Skills-docs: updated here — `content/docs/api/metadata-api.mdx` (the
enable door's entry), `content/docs/deployment/marketplace-handbook.mdx`
(the Enable-and-disable step and the Disabling-a-module list) and
`content/docs/api/error-catalog.mdx` (the code's 409 entry) name the
refusal; `skills/**` teaches no package enable door (grep for
`packages/:id/enable`, `enablePackage`, `enableOnInstall`: none), so no
skill or eval moves.
Claim: 6689904888 (amended by the seat: `registry.ts` `asEnabled`, the
`enableOnInstall: true` overwrite arm, the three docs pages) · Branch:
`claude/project-thread-fyy60n` · Mirror of: #30379 ruling A (record
6670643454), PR #30825 · Error code: reused,
`PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE` (#30724) · Serial:
#30962 (same handlers, persistence lines untouched here)
## 维护者速读
- 改了什么:模块 X 停用时,再启用一个"有校验规则读 X 加到别的对象上的字段"的包 P,现在会被拒绝(409,沿用停用门的错误码
`PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE`),提示逐条列出「包 · 对象 ·
规则」并点名要先启用的 X,什么都不改。客户在自己那份对象上关掉的规则算关闭;只判这次启用新带上线的规则。`POST /packages` 以
`enableOnInstall: true` 覆盖一个已停用的包同样判断:装上,但保持停用,答同一个
409。启动时若持久化状态已经落在这种状态(状态文件只记停用),记一条 warn 列出规则和补救办法,不拒绝启动、不改运营者的选择。
- 为什么改:以前这种启用答 200,之后该对象的每一次写入都 400
VALIDATION_FAILED,而且报错说"对象没声明这个字段",管理员看不出是 X
停用造成的。停用门(#30379)已经拦了反方向,这是对称的那一半。
- 风险与回滚:收窄(BREAKING):原来能启用的这一种情况现在被拒。判断读注册表自己的折叠结果(新增 `asEnabled`
选项,不传时折叠结果与现在逐字节相同,有测试钉住)。回滚=revert 本 PR。
- 先看:`packages/runtime/src/domains/packages.ts` 的
`readEnableDependentRules` /
`readWithheldFieldReaders`(启用前后两次折叠求差)和启用门、安装门 `true`
分支;`packages/objectql/src/registry.ts` 的
`asEnabled`;`packages/runtime/src/app-plugin.ts` 的
`reportWithheldFieldReadersAtBoot`。
- 两分钟验证:`pnpm --filter @objectstack/runtime exec vitest run
src/domains/packages-disable-module-extension-fields.test.ts`,预期 27 条全绿。
- 需要你做的:无,席位落地。
## What changes
**`@objectstack/runtime`: `PATCH /api/v1/packages/:id/enable`**
(`domains/packages.ts`)
- After the walled-catalog and query checks, and before
`registry.enablePackage` and `setPackageDisabled`, the door asks
`readEnableDependentRules(registry, id)`. If that is non-empty it
answers `409` with `error.code`
`PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE` and nothing changes
(row, served objects, state file). The message names every rule as
`package · object · rule (reads FIELD)` and the disabled package(s) to
enable first: `Package 'P' cannot be enabled: 3 active validation rules
the enable would serve read fields that a disabled package adds through
objectExtensions, which stay withheld — … . Switch off or change those
rules first, or enable 'X' first.` No `error.details` beyond the code,
as on the disable door.
- **Which rules count.** Judged on the registry's own fold, twice: the
fold the enable would leave (`getAllObjects(undefined, { asEnabled: id
})`: P's `extend` contributions folded in, every other disabled
package's withheld) against the fold served now (`getAllObjects()`). A
field is withheld from an object when the storage fold
(`getStorageObject`, every contribution) declares it and the served fold
does not, which is the registry's own reading
(`withholdDisabledExtensions`), not re-assembled. A rule counts only
when it reads a withheld field after the enable and did not before, so
an enable is never refused over a rule it does not bring into service.
That is P's own rules (a copy that replaces a served rule by name
included) and every rule on an object P owns (which comes into service
with it). One rule per name, the copy that wins; a tenant overlay's
`active: false` honoured; a rule is named after the last layer that
declares it (`ruleLayerLabel`, shared with the disable door). Reads use
the disable scan's `addRuleFieldReads` (own column and one hop through a
reference field).
- **Error code: reused, no new code.** The refused state is the one the
disable door refuses: a disabled package's withheld field under an
active rule. The enable door refuses entering it from the other side.
The code is held as the same wire pair `{ code, status: 409 }`.
**`@objectstack/runtime`: `POST /api/v1/packages` with `enableOnInstall:
true`** (mirror of the #30825 `false` arm)
- The `true` arm asks the same scan before `enablePackage`. It is
non-empty only on an overwrite of a DISABLED row (the scan is empty for
a package that is not disabled, which covers a fresh install). On a
refusal the install has landed, the package stays disabled (row and
state file follow `pkg.enabled`), and the door answers the same `409`
with the lead `Package 'ID' was installed and stays disabled:
enableOnInstall: true cannot enable it`. The shared variable is renamed
`installDisableRefusal` → `installLifecycleRefusal`.
**`@objectstack/runtime`: the boot** (`app-plugin.ts`, the card's "boot
replay path")
- Measured reachable: the environment's state file records only DISABLES
(`loadDisabledPackageIds` → `setInitialDisabledPackageIds`), so a
package that is not listed boots enabled. A state file listing X, with P
booting enabled and P's rule reading X's field (a release that added the
rule, or a state file written before this release), lands in the refused
state without passing any door. Pinned: the harness boots exactly that
and a write then answers `400 VALIDATION_FAILED` with the validator's
own text "The predicate reads 'qx_credit', which this object does not
declare — fix the rule's condition, or declare the field", which points
the operator at the wrong repair.
- **Decision: say so once, loudly; neither refuse nor rewrite.** The
boot has no enable act to refuse, refusing the whole boot would trade
one object's writes for the whole runtime, and re-enabling X or
disabling P would silently override the operator's recorded choice.
`AppPlugin` registers one `kernel:bootstrapped` read per registry (after
the `kernel:ready` rehydrates have registered every package, so it is
the boot's final reading, not a verdict a later registration
contradicts) of `readServedWithheldFieldReaders`, and warns once naming
every rule, the objects whose writes are refused, and the three
remedies. `warn`, not `error`: every refused write tells its requester,
so nothing claims to persist that did not (AGENTS.md degradation rule).
**`@objectstack/objectql`: `ObjectExtensionFoldOptions.asEnabled`**
(`registry.ts`)
- Optional key: `getAllObjects(undefined, { asEnabled: id })` folds a
DISABLED package's `extend` contributions in as if it were enabled
(`withholdDisabledExtensions(contributors, asEnabled)` exempts it),
every other disabled package's stay withheld, uncached, and no registry
state moves. Unset, or naming a package that is not disabled, the fold
is the cached one, byte for byte (pinned).
**Docs**: `metadata-api.mdx` (enable door entry),
`marketplace-handbook.mdx` (Enable-and-disable step, Disabling-a-module
list), `error-catalog.mdx` (the code's Cause and Fix name the enable
door and the install `true` arm). The wire-code count does not move (no
new code).
**Changeset**: `@objectstack/runtime` and `@objectstack/objectql` patch,
BREAKING banner, `Clause-②: no (narrowing)`, `Metadata-protocol: none`,
ADR-0087 disposition `not-required (no-migration-prescription)`.
## Tests
`packages/runtime/src/domains/packages-disable-module-extension-fields.test.ts`
(the #30362/#30379 harness: real `HttpDispatcher` doors over a
file-backed SQLite kernel), 27 cases, 7 new or flipped:
- **Flipped** (`[#30379]` describe): the pin that measured "a reading
package enabled while the field stays withheld refuses writes (400)" now
asserts the enable answers `409` with the code, the package stays
disabled, and a write lands.
- `[#30756] the enable door refuses …`:
- **CONTROL**: with the field's package enabled, disable then enable of
the rules package answers `200` as before, state file not listing it.
- **Refusal**: rules package off, then the field's package off (the
order the disable door allows); the enable answers `409` + code, the
message is asserted whole (three rules in order, including the hop
`account.qx_credit` on the visit, the switched-off rule absent, `enable
'com.qa.extsuite.orders' first`), `error.details` absent, the row stays
disabled, the rule is not served, a write lands, the state file still
lists the package.
- **CONTROL**: tenant overlays switch the three rules off (`active:
false`); the enable then answers `200`, the packaged rule is served
switched off, a write lands.
- `[#30756] POST /packages with enableOnInstall: true …`: an overwrite
of a disabled reader with `enableOnInstall: true` answers `409` (message
asserted whole), stays disabled, state file lists it, the rule is not
served; **CONTROL** with no reading rule: `201`, enabled, state file
clear.
- `[#30756] a boot whose state file leaves …`: state file disables the
field's package, the reader boots enabled; exactly one warning, asserted
whole; a write answers `400 VALIDATION_FAILED`; enabling the field's
package (one named remedy) lets a write land. **CONTROL**: a boot with
nothing disabled warns nothing.
`packages/objectql/src/registry-disabled-package-extensions.test.ts`,
new top-level describe `[#30756] the asEnabled fold` (19 cases in the
file): the named disabled package's field and rule fold in, another
disabled package's stay withheld, the package stays disabled and
`getObject` is unchanged; **CONTROL**: unset, `{}`, a package that is
not disabled and an unknown id each serve `JSON.stringify`-identical
folds.
**Ablation** (the refusal, once, through `scripts/ablation-replace.mjs`,
runtime source resolved directly by the relative import, so no dist
leg): `const dependents = readEnableDependentRules(registry, id);` →
`const dependents: EnableDependentRule[] = []; void
readEnableDependentRules;`, anchor 1 → 0, blob `c38a69765e94` →
`072561ead485`. Result: 2 failed | 25 passed, both reading `expected {
status: 200, code: undefined } to deeply equal { status: 409, … }` (the
flipped pin and the refusal pin; the two controls stay green, as they
must). Restored: blob == HEAD `c38a69765e94`, `git diff HEAD` empty.
## Acceptance notes
- **Out of scope, not measured:** the cloud route
`/api/v1/cloud/environments/:env/packages/:pkg/disable` (and any cloud
enable route) — named on the card as unmeasured; carrier: cloud.
- **Not covered by this door (noted, not filed):** a FRESH install of a
package whose rule reads a field a disabled package withholds lands
enabled (an install with no `enableOnInstall`, or `true` on a new id)
and reaches the refused state through the install door, not the enable
door. The install has already registered the package when any scan could
run, so the honest answer there is its own decision (refuse after
landing, or land disabled). The boot warning names the state on the next
restart. Carrier: seat.
- **Spec prose (spec lane):** the ledger comment on
`PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE` in
`packages/spec/src/api/error-code-ledger.zod.ts` says it "names WHY a
disable is refused"; it now also answers the mirror enable. No spec edit
here (claim). Carrier: spec lane.
- **Duplication:** `readWithheldFieldReaders` repeats the disable scan's
per-rule judging loop (about 25 lines) rather than refactoring
`readDisableDependentRules`, which the #30379 contract review settled;
the two differ in how the withheld set is read (the disable door's
fields of one package plus its taken-off rules, here the storage/served
difference of the whole state).
- **Serial with #30962:** the persistence lines (`setPackageDisabled`
try/catch) in both handlers are untouched; whichever PR opens second
merges `origin/main` after the first lands.
- The assignee (os-tesla, the claim's account) rides on the relay
`pr_create`; no reviewers are requested, and the harness's MCP
assignee/reviewer step is skipped per the frame.
## Verification
<details><summary>Local gates on 08be854 (pre-merge head), 95 derived
families</summary>
`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` → 95 commands, run each with stdin
redirected from /dev/null after a full build (`env -u OS_SKIP_DTS pnpm
turbo run build --filter='!@objectstack/docs' --filter='!@object-ui/*'`,
82/82). 95 exit 0; `check-plugin-teardown-shape.mjs --self-test` first
exited 3 (pinned commit missing from the shallow clone), exit 0 after
`git fetch origin 621a487 --depth=1`.
`dispatch-gates.mjs --ran` → "95 derived famil(ies) accounted for — 95
run, 0 NOT-MEASURED".
Lint: `npx eslint --no-inline-config packages/runtime packages/objectql`
exit 0.
Typecheck: `pnpm --filter @objectstack/objectql typecheck && pnpm
--filter @objectstack/runtime typecheck` VERDICT command-exit 0.
Suites: objectql `Test Files 559 passed (559)`, `Tests 11202 passed`;
runtime `Test Files 550 passed (550)`, `Tests 8823 passed | 19 skipped`.
</details>
<details><summary>After merging origin/main (9788f1d)</summary>
Reinstall, full rebuild (82/82), both typechecks exit 0,
`registry-disabled-package-extensions.test.ts` 19/19,
`packages-disable-module-extension-fields.test.ts` 27/27. Full dogfood
(`pnpm --filter @objectstack/dogfood test` under the verify lock, no
shard, no path) was still running on 9788f1d when this PR opened; its
VERDICT line and counts are in the os-dev-report comment on the card.
</details>
---
_Generated by [Claude
Code](https://claude.ai/code/session_01L9qibxjrKo69n5BEsgi5qA)_
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01L9qibxjrKo69n5BEsgi5qA
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 468e0c1 commit 9052337
9 files changed
Lines changed: 754 additions & 38 deletions
File tree
- .changeset
- content/docs
- api
- deployment
- packages
- objectql/src
- 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 | + | |
| 24 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
547 | 547 | | |
548 | 548 | | |
549 | 549 | | |
550 | | - | |
551 | | - | |
| 550 | + | |
| 551 | + | |
552 | 552 | | |
553 | 553 | | |
554 | 554 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
178 | 178 | | |
179 | 179 | | |
180 | 180 | | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
181 | 195 | | |
182 | 196 | | |
183 | 197 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
236 | 236 | | |
237 | 237 | | |
238 | 238 | | |
239 | | - | |
| 239 | + | |
| 240 | + | |
240 | 241 | | |
241 | 242 | | |
242 | 243 | | |
| |||
326 | 327 | | |
327 | 328 | | |
328 | 329 | | |
| 330 | + | |
| 331 | + | |
| 332 | + | |
| 333 | + | |
| 334 | + | |
| 335 | + | |
| 336 | + | |
| 337 | + | |
329 | 338 | | |
330 | 339 | | |
331 | 340 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
156 | 156 | | |
157 | 157 | | |
158 | 158 | | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
748 | 748 | | |
749 | 749 | | |
750 | 750 | | |
| 751 | + | |
| 752 | + | |
| 753 | + | |
| 754 | + | |
| 755 | + | |
| 756 | + | |
| 757 | + | |
751 | 758 | | |
752 | 759 | | |
753 | 760 | | |
754 | 761 | | |
| 762 | + | |
755 | 763 | | |
756 | 764 | | |
757 | 765 | | |
| |||
5275 | 5283 | | |
5276 | 5284 | | |
5277 | 5285 | | |
| 5286 | + | |
| 5287 | + | |
| 5288 | + | |
| 5289 | + | |
5278 | 5290 | | |
5279 | | - | |
5280 | | - | |
| 5291 | + | |
| 5292 | + | |
| 5293 | + | |
| 5294 | + | |
5281 | 5295 | | |
5282 | 5296 | | |
5283 | 5297 | | |
| |||
5319 | 5333 | | |
5320 | 5334 | | |
5321 | 5335 | | |
5322 | | - | |
5323 | | - | |
| 5336 | + | |
| 5337 | + | |
| 5338 | + | |
| 5339 | + | |
| 5340 | + | |
| 5341 | + | |
| 5342 | + | |
| 5343 | + | |
5324 | 5344 | | |
5325 | 5345 | | |
5326 | 5346 | | |
5327 | 5347 | | |
5328 | | - | |
5329 | | - | |
| 5348 | + | |
| 5349 | + | |
| 5350 | + | |
5330 | 5351 | | |
5331 | 5352 | | |
5332 | 5353 | | |
| |||
0 commit comments