Skip to content

Commit 1de7def

Browse files
fix(runtime)!: the package disable door refuses while an enabled package's validation rule reads a field the package adds (#30825)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> Fixes #30379 _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9DRdR8tDhhemV41rvCmTN1MR)_ domain:server#4 · os-elon-musk · session_016WV4cwCWp9qZ5SgMof1qLa Clause-②: no (narrowing) Metadata-protocol: none Skills-docs: updated here — `content/docs/api/metadata-api.mdx` (the disable 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 and the wire-code count) name the refusal; `skills/**` teaches no package disable door (grep for `packages/:id/disable`, `disableable`, `WRITABLE_PACKAGE_REQUIRED`: none), so no skill or eval moves. Claim: 6672353180 (amended 6672467575) · Branch: `claude/project-thread-mwjmre` · Ruling-ref: 6670643454 (letter A, items 1–3) · ADR line: #30729 (merged) · Error code: #30724 / PR #30775 (merged) · Carry note: 6675092387 · Contract review: 6677642547 (FAIL) and delta review 6679292152 (FAIL), both addressed at `c15ba57084` ## 维护者速读 - 改了什么:设置里停用一个模块时,如果停用后对象上仍然生效的某条校验规则读了这个模块加到别的对象上的字段,停用会被拒绝(409,错误码 `PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE`),提示里逐条列出「包 · 对象 · 规则」,什么都不改。规则按停用后对象实际生效的样子判断:同名规则只判胜出的那一份,客户在自己那份对象上关掉的规则算关闭,规则归到它生效文本所在的那一层(客户覆写层写作 `tenant overlay`)。用 `enableOnInstall: false` 覆盖一个当前启用的包时同样判断:装上,但保持启用,答同一个 409;全新安装、删除后重装、覆盖已停用的包照旧装成停用。公式读这些字段不拦(停用后答空值)。 - 为什么改:以前能停用,但停用后那条规则读不到字段,该对象的每一次写入都被拒(400 VALIDATION_FAILED),直到重新启用。这是 #30379 裁决 A(你 2026-11-05 21:56Z 的「同意」);本版按合同复审 6677642547 和增量复审 6679292152 改过。 - 风险与回滚:小。只多了一种拒绝,只在会把对象写死的停用上出现;读写接口、元数据和导出都不变。回滚即撤 PR。 - 先看:`packages/runtime/src/domains/packages.ts` 的 `readDisableDependentRules`(第二遍改为读注册表自己的折叠结果)、`ruleLayerLabel`、停用门和安装门的 409 分支;测试文件最后三个 describe。 - 两分钟验证:`pnpm --filter @objectstack/runtime exec vitest run src/domains/packages-disable-module-extension-fields.test.ts`,预期 20 条全绿。 - 需要你做的:无,席位落地。 ## What changes **`@objectstack/runtime`: `PATCH /api/v1/packages/:id/disable`** (`domains/packages.ts`) - After the read-only check (`isDisableablePackage` / `requireWritablePackage`), and before `registry.disablePackage` and `setPackageDisabled`, the door asks `readDisableDependentRules(registry, id)`. If that is non-empty, it answers `409` with `error.code` `PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE` (ledgered under `@objectstack/runtime` by PR #30775). The message names every rule as `package · object · rule (reads FIELD)`, one or several. Nothing changes on a refusal. The refusal carries no `error.details` beyond the code (the seat's ruling on carry-note item 2, see Out of scope). - **What the disable withholds**, per object the package extends: the fields its `extend` contributions add that neither the owner's own declaration nor an extension that stays served also declares. This is the registry's own reading of a withheld member (`withholdDisabledExtensions`), asked of the state the disable would leave. - **Which rules count** (ruling items 1–2, contract review must-fix 1): the rules the served object will carry once the disable lands, of every severity, from any enabled package, declared dependent or not (no `manifest.dependencies` entry is read), an app's `BASE.custom` pair included. They are read off the registry's own fold, `getAllObjects(undefined, { withoutExtensionsOf: id })`, never re-assembled in the door. So: - **one rule per name**, the copy the fold serves: a later layer's rule replaces an earlier one of its name (`mergeValidationsByName`), and only the winning copy is judged; - **the tenant overlay's switch is honoured**: its `message` and `active` sit on the packaged rule it names (`tenantRuleSettingsOf` / `withPackagedOwnerRules`), so a rule the tenant switched off through the overlay is off; - **less the rules the disable takes off a base that carries them** (`withoutWithheldMembers`), the one step a left-out fold does not take, which the scan mirrors. On an object whose owner is disabled or is the package itself, no write reaches any rule, so none counts. A package already disabled withholds nothing more, so the scan answers empty. - **Who a rule is named after** (must-fix 1): the LAST layer in fold order (the base, then each `extend` contribution that stays) that declares the rule's name, which is the layer its served text comes from. A tenant overlay is named `tenant overlay of PKG` (or `tenant overlay` when it carries no package); the `(no package)` placeholder is gone (an unpackaged layer is `unpackaged object` / `unpackaged extension`). - **What "reads" means:** - A predicate is read with `analyzeRelationshipTraversals` (`@objectstack/formula`) over `record` and `previous`. That covers `script` / `cross_field` `condition` and a `conditional`'s `when`, at any depth. It is the reading the rule validator itself asks before it refuses a write (`readsRecordColumn`). - A `record.REF.FIELD` read is also a hop into REF's target, judged with `referenceTargetOf` on the served field. - A `state_machine` / `format` / `json_schema` rule reads the `field` it names. - No second parser. - **Not counted:** - a formula field over the withheld field (it answers `null`, PR #30362, ruling item 1); - a rule switched off (`active: false`, on its own copy or through the tenant overlay; the remedy the refusal names); - a rule a later layer replaced by name (only the winning copy is judged); - a rule of a disabled package. - The code is held as its wire pair `{ code, status: 409 }`, the shape `check:error-status-conformance` reads as a producer. **`@objectstack/runtime`: `POST /api/v1/packages` with `enableOnInstall: false`, on an overwrite of an enabled package** (contract review should-fix, delta review must-fix 3) - Before either install limb writes, the door reads whether the id's row exists and is enabled (`registry.getPackage(pkgId)?.enabled === true`). Only then can the install's `enableOnInstall: false` arm withhold a field a rule is served against, so only then does it ask the same `readDisableDependentRules(registry, pkgId)` before it disables. - When a rule would read a field the disable withholds, the overwrite still lands, the package stays enabled on the row, in the registry and in the state file (all three follow the row the door returns), and the door answers the same `409` and code, with the message lead `Package 'ID' was installed and stays enabled: enableOnInstall: false cannot disable it`, instead of a `201` that claims a disable. - A fresh install (or a reinstall after `DELETE /packages/:id`) and an overwrite of a disabled package withhold nothing served, so they honour `enableOnInstall: false` exactly as #18877 rules (「有旗 = 设置」): `201`, disabled. With no reading rule, an overwrite of an enabled package disables as before. **`@objectstack/core`: the second-object read census** (`src/security/second-object-read-exposure.pin.test.ts`) - The scan follows a rule's `record.REF.FIELD` hop with `referenceTargetOf`, which makes `packages/runtime/src/domains/packages.ts` a new caller of the reference-target arbiter. The census names it `not-a-served-read`: it reads no record of the target and serves none of its fields, it only asks whether FIELD is one the disable would withhold, from declared metadata. (Unclassified, this pin turned Test Core 7/8 red on `d315efca97`.) **Docs** (carry-note item 3, must-fix 2) - `content/docs/api/error-catalog.mdx`: the advertised count moves from 57 to **58 error codes reachable on the wire** (L7; `packages/spec/src/api/error-catalog-docs.test.ts` pins it against the wire face). A `### PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE` entry under *Conflict Errors (409)*, with cause (the served-fold judgment, the layer naming and the install arm on an overwrite of an enabled package), fix and `no_retry`. The status-conformance gate reconciles it against the producer above (`packages.ts: { code, status }`, 409). - `content/docs/deployment/marketplace-handbook.mdx`: the Enable-and-disable step and the Disabling-a-module list gain the 409 row, a customer's switched-off copy counting as off, and the install arm on an overwrite of an enabled module (a fresh install still lands disabled). Before, they named only `422 WRITABLE_PACKAGE_REQUIRED`. - `content/docs/api/metadata-api.mdx`: the `PATCH /packages/:id/disable` entry names the refusal, the served-fold judgment, its remedy and the install arm on an overwrite of an enabled package. **Changeset**: `@objectstack/runtime` patch, with the BREAKING banner (accept-set narrowing on the disable door, and on the install door for an overwrite of an enabled package), `Clause-②: no (narrowing)`, `Metadata-protocol: none` and the ADR-0087 disposition `not-required (no-migration-prescription)`. ## Tests `packages/runtime/src/domains/packages-disable-module-extension-fields.test.ts` is the PR #30362 harness: real `HttpDispatcher` doors over a file-backed SQLite kernel. It has 20 cases. - `[#30379] the disable door refuses …` (3 cases). The fixture has: - a rules module with an error `script`, a warning `conditional`, a switched-off rule, and an info rule on the app's visit that reads `record.account.qx_credit`; - a formula module; - a tenant rule saved and published into the app's `.custom` pair, through `POST /packages/:app/ensure-customization` and the protocol's save and publish. The cases: - **Disable:** `409` with `PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE`. The message reads `4 active validation rules read fields …` and lists exactly the four reading rules, in order (pair, two on the account, the hop on the visit), each with what it reads. The switched-off rule and the formula module are not named, and `error.details` is absent. The row stays enabled, the fields stay served, a write still lands and the state file does not list the package. - **Reading module disabled first:** the refusal names only the pair's rule (`an active validation rule reads a field …`). Once the pair is disabled, the disable proceeds (200), the fields are withheld, the formula answers `null` and writes land. - **Measured, not ruled:** re-enabling the reading module while the field stays withheld refuses writes with `400 VALIDATION_FAILED` naming the rule. That is the enable door, #30756; it flips this pin. - `[#30379 contract review] the scan judges the rules the served fold serves` (2 cases). A third module extends the account at a later priority and redeclares two of the rules module's rules by name: one so it no longer reads the field, one so it does. - **Replaced by name (pin b):** `409` naming exactly 2 rules: the visit's hop, and the redeclared reader named after the override module. The rule whose winning copy no longer reads the field is not named, though the rules module's own copy still reads it. - **Tenant overlay (pin a):** a tenant overlay of the account (registered as `overlay`) switches the override's reader off and declares its own reader. The refusal names the visit's hop and the overlay's own rule as `tenant overlay of com.qa.extsuite`, and no `(no package)`. Once overlays switch every served reader off (`active: false`), the disable answers `200`, the module's fields are withheld (the overlay's copies of them included) and a write lands. - `[#30379 contract review] POST /packages with enableOnInstall: false asks the same scan` (3 cases, a real `SchemaRegistry` and `HttpDispatcher` over a stub kernel): - an overwrite with `enableOnInstall: false` of an enabled extender whose field another package's rule reads answers `409` with the code and the exact message; the package stays enabled, the state file does not list it and the field is still served; - **fresh install (delta review must-fix 3):** with no row before the install and the same reading rule registered, `enableOnInstall: false` answers `201` and the package is disabled, in the registry and in the state file; - **control:** with no rule reading the field, the overwrite answers `201` and the package is disabled. - The formula describe stays the CONTROL: a formula alone over the field ⇒ the disable proceeds (200) and writes land. <details><summary>Run and ablation</summary> All at head `c15ba57084` (merge base `origin/main` `c17719dd6c`), under `scripts/pm/os-verify-lock.sh`, after a whole-tree `pnpm turbo run build --concurrency=2` at that head (119/119 tasks, VERDICT command-exit 0). ```text pnpm --filter @objectstack/runtime exec vitest run src/domains/packages-disable-module-extension-fields.test.ts Test Files 1 passed (1) Tests 20 passed (20) os-verify-lock: VERDICT command-exit 0 · held the lock 19s ``` Six ablations, each `node scripts/ablation-replace.mjs --file packages/runtime/src/domains/packages.ts --anchor A --replacement R -- (the same vitest run)`, all on the committed head under one lock (VERDICT command-exit 0, 116s). Each mutation landed (anchor 1 → 0), every one went red, and every restore left the file at HEAD's blob `b1cec1748292` (`git diff HEAD` empty). | Ablation | Anchor → replacement | Red | | --- | --- | --- | | A0 the disable door's scan | `readDisableDependentRules(registry, id);` → the same call with an always-false `.filter` | 4 failed / 20 | | A1 `active` ignored | drop `rule.active === false \|\|` from the served-rule filter | 3 failed / 20 | | A2 named after the base | `ruleLayerLabel(layerOf.get(rule.name) ?? base)` → `ruleLayerLabel(base)` | 6 failed / 20 | | A3 the install arm's scan | `overwritesEnabledRow ? readDisableDependentRules(registry, pkgId) : [];` → `[]` | 1 failed / 20 (the overwrite-of-enabled pin) | | A4 every contributor's copy judged | `declaredRulesOf(served)` → every contributor's `declaredRulesOf(c.definition)` | 2 failed / 20 | | A5 the scan on every install | `overwritesEnabledRow ? readDisableDependentRules(registry, pkgId) : [];` → `readDisableDependentRules(registry, pkgId);` | 1 failed / 20 (the fresh-install pin) | A4 is the reading the contract review measured: judged per copy, the replaced rule and the overlay-switched rule both refuse again. A5 is the delta review's: scanned on a fresh install, the package lands enabled against `enableOnInstall: false`. The second-object census row, before and after, with the tree built: ```text pnpm --filter @objectstack/core run test:repo (at d315efc, without the row) × every caller of the reference-target arbiter is classified, and every classified file still calls it - packages/runtime/src/domains/packages.ts Test Files 1 failed | 5 passed (6) Tests 1 failed | 93 passed (94) pnpm --filter @objectstack/core run test:repo (at c15ba57) Test Files 6 passed (6) Tests 94 passed (94) os-verify-lock: VERDICT command-exit 0 · held the lock 6s ``` ```text pnpm --filter @objectstack/core test Test Files 136 passed (136) Tests 3131 passed (3131) os-verify-lock: VERDICT command-exit 0 · held the lock 36s pnpm --filter @objectstack/spec run test:repo Test Files 90 passed (90) Tests 1539 passed (1539) os-verify-lock: VERDICT command-exit 0 · held the lock 425s (7m05s) pnpm --filter @objectstack/runtime test Test Files 547 passed (547) Tests 8768 passed | 19 skipped (8787) os-verify-lock: VERDICT command-exit 0 · held the lock 675s (11m15s) pnpm --filter @objectstack/runtime typecheck (tsc --noEmit && check:test-typecheck) os-verify-lock: VERDICT command-exit 0 · held the lock 31s pnpm --filter @objectstack/core typecheck (tsc --noEmit, the examples project, check:test-typecheck) os-verify-lock: VERDICT command-exit 0 · held the lock 21s ``` No `@objectstack/objectql` file is touched (the install pins import its `SchemaRegistry`); its suite is CI's. Premise, re-measured on `origin/main` `6ef7f583` before the change: the disable answered 200 and the write after it 400 `VALIDATION_FAILED` (12/12 of the then harness). </details> ## Acceptance notes - **The enable door is not judged here.** With the field's package already disabled, enabling a package whose rule reads the field is accepted, and every write to the object then answers `400 VALIDATION_FAILED` naming the rule. A boot replaying such a state file ends in the same state. Pinned as measured. The mirror is #30756 (pm:blocked behind this card). - **`disableable` is unchanged.** The package row's `disableable` (the door's PACKAGE verdict, ADR-0070 / #28318) still answers `true` for such a module. The dependent rule is an environment-state conflict, answered by the `409`. Showing it on the Settings page is the `domain:ui` card the seat files after this lands. - **The install arm keeps the install.** A refused `enableOnInstall: false` (only ever on an overwrite of an enabled package) does not roll the overwrite back: the artifact the request carried is installed and stays enabled, and the `409` says so. Undoing the install would be a second write the request did not ask for. - **The install arm reads the row before the install.** A fresh install, a reinstall after a delete and an overwrite of a disabled package are not scanned: none of them withholds a field a rule is served against, and #18877's 「有旗 = 设置」 holds for them unchanged. - **`cross_field.fields` is advisory** (per the spec, only its first entry labels the violation), so a `cross_field` rule is judged by its `condition`. - **A source that does not parse reads nothing here.** Such a rule already refuses every write, whatever is enabled. - **The cloud route is not measured.** `/api/v1/cloud/environments/:env/packages/:pkg/disable` (`carrier: cloud`) was not run here. ## Out of scope - **A machine-readable `error.details` shape** for the package · object · rule triples. The seat ruled (A) on carry-note item 2: no structured details, and no `packages/spec`. If the Settings page ever needs the triples machine-readable, that is a `domain:spec` contract card first: a `contracts/` schema plus the ledger row comment, the `ACTION_CONFIRMATION_REQUIRED` pattern. The runtime would stamp it after that card lands. The seat records this on the UI card. - **The enable door's mirror**: #30756. - **The uninstall door.** `DELETE /packages/:id` withdraws the same extension fields with no scan. The seat files it as its own card. ## Verification <details><summary>Local gates (dispatch-gates --commands on this diff)</summary> At head `c15ba57084`: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 92 commands; each ran with its exit code captured before any pipe. ```text node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-adr-links.mjs :: exit 0 node scripts/check-adr-links.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-adoption.mjs :: exit 0 node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-dev-prereqs.mjs --self-test :: exit 0 node scripts/check-doc-route-spelling.mjs --advisory :: exit 0 node scripts/check-doc-route-spelling.mjs --self-test :: exit 0 node scripts/check-docs-section-name.mjs :: exit 0 node scripts/check-docs-section-name.mjs --self-test :: exit 0 node scripts/check-dts-emitted.mjs --self-test :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-issue-citations.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0 node scripts/check-section-landing-index.mjs :: exit 0 node scripts/check-section-landing-index.mjs --self-test :: exit 0 node scripts/ci/assert-turbo-remote-cache.mjs --self-test :: exit 0 node scripts/ci/plan-test-core-shards.mjs --self-test :: exit 0 node scripts/ci/prune-turbo-cache.mjs --self-test :: exit 0 node scripts/docs-audit/check-affected-docs.mjs :: exit 0 node scripts/docs-audit/check-drift-comment.mjs :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm --filter @objectstack/lint run check:doc-formula-expressions :: exit 0 pnpm --filter @objectstack/lint run check:doc-security-posture :: exit 0 pnpm --filter @objectstack/spec run check:docs :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm --filter @objectstack/spec run check:empty-state :: exit 0 pnpm --filter @objectstack/spec run check:liveness :: exit 0 pnpm --filter @objectstack/spec run check:skill-examples :: exit 0 pnpm --filter @objectstack/spec run check:strictness-ledger :: exit 0 pnpm --filter @objectstack/spec run check:variant-docs :: exit 0 pnpm --filter @objectstack/spec run check:yaml-examples :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:ci-filter-parity :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:corpus-claim-drift :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:dispatcher-error-vocabulary :: exit 0 pnpm check:doc-anchors :: exit 0 pnpm check:doc-authoring :: exit 0 pnpm check:doc-frontmatter :: exit 0 pnpm check:docs-audit-scope :: exit 0 pnpm check:docs-redirects :: exit 0 pnpm check:docs-single-h1 :: exit 0 pnpm check:docs-spec-enumerations :: exit 0 pnpm check:docs-transcript-drift :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:dts-closure :: exit 0 pnpm check:dual-build-cjs-loads :: exit 0 pnpm check:durability-log-level :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:error-status-conformance :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:issue-citations :: exit 0 pnpm check:kernel-hook-pairs :: exit 0 pnpm check:keyed-text-bounds :: exit 0 pnpm check:lean-entry-closure :: exit 0 pnpm check:logger-receiver-detach :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:objectql-double-limit :: exit 0 pnpm check:org-identifier :: exit 0 pnpm check:page-declaration-shape :: exit 0 pnpm check:platform-object-tenancy-census :: exit 0 pnpm check:published-files :: exit 0 pnpm check:published-readme-links :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:react-page-adapter-contract :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:registry-log-declared :: exit 0 pnpm check:rest-log-spy-declared :: exit 0 pnpm check:role-word :: exit 0 pnpm check:route-envelope :: exit 0 pnpm check:skill-identifier-liveness :: exit 0 pnpm check:slot-lookup :: exit 0 pnpm check:sourcemap-no-sources-content :: exit 0 pnpm check:system-context-census :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:vendor-version-stamps :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 ``` `node scripts/pm/dispatch-gates.mjs --ran`: 92 derived, 92 run, 0 NOT-MEASURED, 0 UNRUN (exit 0). CI families outside the derivation (the artifact rosters, the wide-population families, the path-scheduled jobs and the type-check lanes) belong to CI; this PR's check-runs are their record. </details> <details><summary>Full dogfood battery (Clause-② carries the (narrowing) arm)</summary> ```text pnpm --filter @objectstack/dogfood test (whole-tree build first; no OS_TEST_SHARD, no path) Test Files 423 passed | 3 skipped (426) Tests 3533 passed | 41 skipped (3574) os-verify-lock: VERDICT command-exit 0 · held the lock 1558s (25m58s) ``` Head `c15ba57084`. No failure. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (not touched) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. (CI's repo-wide run) - [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 by [Claude Code](https://claude.ai/code/session_016WV4cwCWp9qZ5SgMof1qLa)_ <!-- signed: domain:server#4 · os-elon-musk · session_016WV4czvZykUqhYxYXSSDt4 --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 000a4c0 commit 1de7def

7 files changed

Lines changed: 843 additions & 26 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/runtime': patch
3+
---
4+
5+
fix(runtime)!: `PATCH /api/v1/packages/:id/disable` refuses while an enabled package's validation rule reads a field the package adds through `objectExtensions`
6+
7+
Clause-②: no (narrowing) — the package disable door, and the install door's `enableOnInstall: false` arm on an overwrite of an enabled package, refuse (`409` `PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE`) a disable they accepted before: one that would withhold a field an active validation rule reads. Nothing is widened: no export, request shape, response member or authorable key is added, and the code is the one #30724 registered.
8+
Metadata-protocol: none
9+
Skills-docs: updated here — `content/docs/api/metadata-api.mdx` (the disable 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 disable door (grep for `packages/:id/disable`, `disableable`, `WRITABLE_PACKAGE_REQUIRED`: none), so no skill or eval moves.
10+
11+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped, and no export or type of the package root changes: a disable request that would have left an object refusing every write is refused before anything changes, with an error code already registered. Nothing at rest records that answer, so there is nothing for a migration to rewrite and no tombstone. -->
12+
13+
**BREAKING** accept-set narrowing on the package disable door and the install door's `enableOnInstall: false` arm on an overwrite of an enabled package (`@objectstack/runtime`, `domains/packages.ts`), for one case only.
14+
15+
- **Before.** A disabled package's `objectExtensions` fields are withheld from the object they extend. When a validation rule of another package that stayed enabled read one of those fields, the disable answered `200`, and every write to that object was then refused (`400 VALIDATION_FAILED`, naming the rule) for as long as the package stayed off.
16+
- **Now.** The door refuses such a disable with `409` and `error.code` `PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE`, after the read-only check and before anything changes: the package row, the served objects and the environment's stored disable state stay as they were. The message names every reading rule as `package · object · rule (reads FIELD)`, one or several. The refusal carries no other `error.details`.
17+
- **Who is scanned.** Any enabled package, declared dependent or not (no `manifest.dependencies` entry is read), an app's `<base>.custom` pair (where tenant-written rules live) included, at every severity. Each rule is judged as the object will serve it once the disable lands: one rule per name (a later layer's copy replaces an earlier one), with a tenant overlay's `message` and `active` on it, so a packaged rule the tenant switched off through the overlay does not refuse the disable. The message names each rule after the layer whose copy is served (`tenant overlay` for a rule the overlay declares itself). A rule reads a field when its predicate names it on the rule's own object or one hop through a reference field of that object (`record.account.FIELD`), or when it is a `state_machine` / `format` / `json_schema` rule on that field.
18+
- **The install door too, on an overwrite of an enabled package.** `POST /api/v1/packages` with `overwrite` and `enableOnInstall: false`, over a package that is enabled before the install, asks the same scan before it disables: such an overwrite still lands, the package stays enabled, and the door answers the same `409` instead of a `201` claiming a disable. A fresh install (or a reinstall after a delete) and an overwrite of a disabled package withhold nothing a rule is served against, and land disabled as asked, as before.
19+
- **Unchanged.** A formula over the field is no reason to refuse (it answers `null`), nor is a rule switched off (`active: false`) or a rule of a package that is itself disabled. Every other disable answers as before.
20+
- **Who moves.** An administrator or client disabling such a package, or overwriting it, while enabled, with `enableOnInstall: false`: a refusal naming the rules, instead of a disable that left the object refusing every write.
21+
22+
**The remedy.** Switch off or change each named rule first, or disable the package that owns it, then disable the package; or keep the package enabled.

‎content/docs/api/error-catalog.mdx‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ navTitle: Error Code Catalog
44
description: Complete reference for all ObjectStack error codes with causes, fixes, and retry strategies
55
---
66

7-
ObjectStack uses a structured error system of **error categories** and **57 error codes reachable on the wire**, catalogued in full below (that count is pinned by `packages/spec/src/api/error-catalog-docs.test.ts` against the wire face). Every error includes a machine-readable code, HTTP status mapping, and retry guidance.
7+
ObjectStack uses a structured error system of **error categories** and **58 error codes reachable on the wire**, catalogued in full below (that count is pinned by `packages/spec/src/api/error-catalog-docs.test.ts` against the wire face). Every error includes a machine-readable code, HTTP status mapping, and retry guidance.
88

99
This catalog documents the **wire face** — the codes a client can actually receive. That is not quite the
1010
`StandardErrorCode` enum: the enum also carries in-process spellings the REST door translates at the
@@ -546,6 +546,11 @@ spelling never crosses HTTP.
546546
**Fix:** Wait for the lock to be released, or contact the lock holder.
547547
**Retry:** `retry_backoff`
548548

549+
### `PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE`
550+
**Cause:** `PATCH /api/v1/packages/:id/disable` was refused because a validation rule of an enabled package (declared dependent or not, an app's `.custom` pair included, any severity) reads a field this package adds to another object through `objectExtensions`. Disabling it would withhold that field, and every write to the object would then fail the rule. Each rule is judged as the object serves it: one rule per name (the copy a later layer declares wins), with a tenant overlay's switch honoured. The message names each rule as `package · object · rule (reads FIELD)`, one or several, after the layer whose copy is served (`tenant overlay` for a rule the overlay declares itself). Nothing changed. `POST /api/v1/packages` answers the same code for an overwrite of an enabled package with `enableOnInstall: false` that would leave such a package disabled: the overwrite itself lands and the package stays enabled. A fresh install with `enableOnInstall: false` lands disabled, as before.
551+
**Fix:** Switch off or change each named rule first, or disable the package that owns it, then disable this package; or keep it enabled. A formula over the field is no reason to refuse: it answers `null`.
552+
**Retry:** `no_retry`
553+
549554
---
550555

551556
## Request Errors (405/428)

‎content/docs/api/metadata-api.mdx‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,20 @@ Enable a disabled package.
182182

183183
Disable a package without uninstalling.
184184

185+
A package that adds fields to another object through `objectExtensions` is
186+
refused `409 PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE` while a validation
187+
rule of any enabled package reads one of those fields (declared dependent or
188+
not, an app's `.custom` pair included, any severity): withheld, the field could
189+
not be read, and every write to that object would fail the rule. The message
190+
names each rule as `package · object · rule (reads FIELD)`, and nothing changes.
191+
A rule is judged as the object serves it: one rule per name, a tenant overlay's
192+
switch honoured. Switch off or change each named rule first, or disable the
193+
package that owns it. A formula over the field does not refuse the disable; it
194+
answers `null`. `POST /packages` answers the same `409` for an overwrite of an
195+
enabled package with `enableOnInstall: false` that would leave such a package
196+
disabled; the overwrite lands and the package stays enabled. A fresh install
197+
with `enableOnInstall: false` lands disabled, as before.
198+
185199
---
186200

187201
## See also

‎content/docs/deployment/marketplace-handbook.mdx‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,9 @@ the detail.
228228
For every other package provided by code or by an installed app (the app
229229
itself, a plugin, a platform package), the runtime answers
230230
`PATCH /api/v1/packages/:id/disable` with `422 WRITABLE_PACKAGE_REQUIRED`.
231+
A disable that would leave a validation rule reading a field the package
232+
adds is refused with `409 PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE`
233+
([below](#disabling-a-module)).
231234
To take an installed app away, uninstall it.
232235
8. **Upgrading.** You publish a new version; nothing running changes. An
233236
environment installed through the cloud shows **Update available → v…** and
@@ -302,6 +305,21 @@ its data is kept.
302305
- **Restart.** The choice is kept per environment and survives a restart.
303306
- **Authoring.** A disabled module is still read-only: edit, publish, revert
304307
and delete keep answering `422 WRITABLE_PACKAGE_REQUIRED`.
308+
- **A rule that reads its fields.** A module that adds fields to another
309+
object through `objectExtensions` cannot be disabled while a validation rule
310+
of any enabled package reads one of those fields: declared dependent or not,
311+
your app's `.custom` pair (where the customer's own rules live) included, at
312+
any severity. The disable door answers
313+
`409 PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE`, and its message names
314+
each rule as `package · object · rule (reads FIELD)`; nothing changes.
315+
Withheld, the field could not be read, and every write to that object would
316+
fail the rule. Switch off or change each named rule first (a rule the
317+
customer switched off on their copy of the object counts as off), or disable
318+
the package that owns it. A formula over the field does not block the
319+
disable: it answers `null` while the module is off. An overwrite of the
320+
enabled module with `enableOnInstall: false` that would leave it disabled in
321+
that state answers the same `409`, and the module stays enabled; a fresh
322+
install with `enableOnInstall: false` lands disabled, as before.
305323

306324
**What your app's own code meets.** A hook of your app that reads or writes a
307325
module's object through `ctx.api.object('<object>')` runs inside the server,

‎packages/core/src/security/second-object-read-exposure.pin.test.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,15 @@ const CALLERS: Record<string, Classification> = {
313313
"ADDRESSED object, whose record is `getData` behind the route's own `get` exposure gate, and the related rows " +
314314
"are the child's `findData` behind the child's `list` exposure. Pinned by `packages/rest/src/record-page-routes.test.ts`",
315315
},
316+
// [#30379] The package disable door's dependent-rule scan.
317+
'packages/runtime/src/domains/packages.ts': {
318+
kind: 'not-a-served-read',
319+
reason:
320+
"the package disable door's dependent-rule scan (`readDisableDependentRules`, also asked by the install door's " +
321+
'`enableOnInstall: false` arm): a rule\'s `record.REF.FIELD` hop names the object REF points at, only to ask ' +
322+
'whether FIELD is one the disable would withhold from it, from the registry\'s DECLARED metadata. It reads no ' +
323+
'record of the target and serves none of its fields; the door answers a `409` naming the rule, or proceeds',
324+
},
316325
};
317326

318327
const SCAN_TIMEOUT_MS = 60_000;

0 commit comments

Comments
 (0)