Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .changeset/30379-disable-door-dependent-rule.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
'@objectstack/runtime': patch
---

fix(runtime)!: `PATCH /api/v1/packages/:id/disable` refuses while an enabled package's validation rule reads a field the package adds through `objectExtensions`

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.
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) 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.

<!-- 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. -->

**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.

- **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.
- **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`.
- **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.
- **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.
- **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.
- **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.

**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.
7 changes: 6 additions & 1 deletion content/docs/api/error-catalog.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ navTitle: Error Code Catalog
description: Complete reference for all ObjectStack error codes with causes, fixes, and retry strategies
---

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.
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.

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

### `PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE`
**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.
**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`.
**Retry:** `no_retry`

---

## Request Errors (405/428)
Expand Down
14 changes: 14 additions & 0 deletions content/docs/api/metadata-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,20 @@ Enable a disabled package.

Disable a package without uninstalling.

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

---

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

**What your app's own code meets.** A hook of your app that reads or writes a
module's object through `ctx.api.object('<object>')` runs inside the server,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,15 @@ const CALLERS: Record<string, Classification> = {
"ADDRESSED object, whose record is `getData` behind the route's own `get` exposure gate, and the related rows " +
"are the child's `findData` behind the child's `list` exposure. Pinned by `packages/rest/src/record-page-routes.test.ts`",
},
// [#30379] The package disable door's dependent-rule scan.
'packages/runtime/src/domains/packages.ts': {
kind: 'not-a-served-read',
reason:
"the package disable door's dependent-rule scan (`readDisableDependentRules`, also asked by the install door's " +
'`enableOnInstall: false` arm): a rule\'s `record.REF.FIELD` hop names the object REF points at, only to ask ' +
'whether FIELD is one the disable would withhold from it, from the registry\'s DECLARED metadata. It reads no ' +
'record of the target and serves none of its fields; the door answers a `409` naming the rule, or proceeds',
},
};

const SCAN_TIMEOUT_MS = 60_000;
Expand Down
Loading
Loading