Skip to content
24 changes: 24 additions & 0 deletions .changeset/30756-enable-door-dependent-rule.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
'@objectstack/runtime': patch
'@objectstack/objectql': patch
---

fix(runtime)!: `PATCH /api/v1/packages/:id/enable` refuses while a validation rule it would serve reads a field a disabled package adds through `objectExtensions`

Clause-②: no (narrowing) — the package enable door, and the install door's `enableOnInstall: true` arm on an overwrite of a disabled package, refuse (`409` `PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE`) an enable they accepted before: one that would put into service an active validation rule reading a field another, still disabled package withholds. The only addition is an optional `asEnabled` key on `@objectstack/objectql`'s object-fold options, read by the door; no request shape, response member or authorable key is added, and the code is the disable door's.
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.

<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped: an enable 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 enable door and the install door's `enableOnInstall: true` arm on an overwrite of a disabled package (`@objectstack/runtime`, `domains/packages.ts`), for one case only: the mirror of the disable door's refusal.

- **Before.** With package X disabled, its `objectExtensions` fields are withheld from the object they extend. Enabling a package P whose validation rule reads one of those fields answered `200`, and from then on every write to that object was refused (`400 VALIDATION_FAILED`, the rule failing closed on a field "this object does not declare") for as long as X stayed off.
- **Now.** The door refuses such an enable with `409` and `error.code` `PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE`, before anything changes: the package row, the served objects and the environment's stored disable state stay as they were. The message names every rule as `package · object · rule (reads FIELD)` and the disabled package to enable first. The refusal carries no other `error.details`.
- **Which rules count.** The rules the enable puts into service: judged on the fold the object will serve once P is enabled (`getAllObjects(undefined, { asEnabled: P })`, the registry's own fold with P's extensions folded in and every other disabled package's withheld) against the fold served now, so a rule counts only when it reads a withheld field after the enable and did not before. That is P's own rules (including a copy that replaces a served rule by name) 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` honoured, so a rule the tenant switched off does not refuse the enable. A field P itself also adds stops being withheld and refuses nothing.
- **The install door too, on an overwrite of a disabled package.** `POST /api/v1/packages` with `overwrite` and `enableOnInstall: true` over a disabled package asks the same scan before it enables: the overwrite still lands, the package stays disabled, and the door answers the same `409` instead of a `201` claiming an enable. A fresh install is not disabled and is not judged.
- **The boot.** The stored state records only disables, so a reading package boots enabled beside a disabled field package without passing any door (a release that added the rule, or a state written before this release). The boot neither refuses nor rewrites that state: at `kernel:bootstrapped` it logs one warning naming every such rule, the objects whose writes are refused, and the remedies.
- **`@objectstack/objectql`.** `ObjectExtensionFoldOptions` gains the optional `asEnabled` key: `getAllObjects(undefined, { asEnabled: id })` folds a disabled package's `extend` contributions in as if it were enabled. Unset, or naming a package that is not disabled, the fold is unchanged.
- **Who moves.** An administrator or client enabling such a package, or overwriting a disabled one with `enableOnInstall: true`: a refusal naming the rules and the package to enable first, instead of an enable that left the object refusing every write.

**The remedy.** Enable the named disabled package first, or switch off or change each named rule (a rule switched off on the tenant's copy of the object counts as off), then enable the package.
4 changes: 2 additions & 2 deletions content/docs/api/error-catalog.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -547,8 +547,8 @@ spelling never crosses HTTP.
**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`.
**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. The enable door answers the same code for the mirror order: `PATCH /api/v1/packages/:id/enable` is refused while a rule the enable would put into service (the package's own, or one on an object it owns) reads a field a still-disabled package withholds, the message naming each rule and the disabled package to enable first; `POST /api/v1/packages` with `enableOnInstall: true` on an overwrite of a disabled package answers it too, and the package stays disabled.
**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`. On the enable door: enable the named disabled package first, or switch off or change each named rule.
**Retry:** `no_retry`

---
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 @@ -178,6 +178,20 @@ before.

Enable a disabled package.

The mirror of the disable door's refusal below: the enable is refused
`409 PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE` while a validation rule it
would put into service (the package's own, or one on an object it owns) reads a
field that another, still disabled package adds through `objectExtensions`:
that field stays withheld, and every write to the object would fail the rule.
The message names each rule as `package · object · rule (reads FIELD)` and the
disabled package to enable first, and nothing changes. A rule is judged as the
object will serve it once the package is enabled: one rule per name, a tenant
overlay's switch honoured. A rule the enable does not bring into service is not
counted. `POST /packages` answers the same `409` for an overwrite of a disabled
package with `enableOnInstall: true`; the overwrite lands and the package stays
disabled. A boot whose stored state leaves such a rule served (the state records
only disables) logs one warning naming every rule and the remedies.

### `PATCH /packages/:id/disable`

Disable a package without uninstalling.
Expand Down
11 changes: 10 additions & 1 deletion content/docs/deployment/marketplace-handbook.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,8 @@ the detail.
`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)).
([below](#disabling-a-module)), and so is an enable that would serve a
rule reading a field a still-disabled package adds.
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 @@ -320,6 +321,14 @@ its data is kept.
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.
- **Enabling a rule over a disabled module's field.** The mirror order is
refused too: while the module is disabled, enabling a package whose
validation rule reads one of the module's fields (or one on an object that
package owns) answers the same `409`, naming each rule and the module to
enable first; nothing changes. Enable the module first, or switch the rule
off on the customer's copy of the object. A boot that lands in that state
anyway (the stored choice records only disables, so a newly shipped rule
boots enabled) logs one warning naming every rule and the remedies.

**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
61 changes: 61 additions & 0 deletions packages/objectql/src/registry-disabled-package-extensions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,3 +156,64 @@ describe('a disabled package\'s objectExtensions', () => {
expect(fieldsOf(registry.getObject(OBJECT))).toEqual(['name', 'qe_credit', 'qe_score', 'qe_shared']);
});
});

/**
* [#30756] `getAllObjects(undefined, { asEnabled })`: the fold the enable of a
* DISABLED package would leave, which the enable door's pre-check reads. That
* package's extension is folded in; every other disabled package's stays
* withheld; the registry's own state does not move; and the option changes
* nothing for a package that is not disabled, or when it is unset.
*/
describe('[#30756] the asEnabled fold', () => {
const APP_PKG = 'com.qa.asen.app';
const MODULE_PKG = 'com.qa.asen.orders';
const OTHER_PKG = 'com.qa.asen.scoring';
const OBJECT = 'qa_account';
const manifest = (id: string, type: string) =>
({ id, name: id, version: '1.0.0', type, namespace: 'qa', ...(type === 'module' ? { dependencies: { [APP_PKG]: '^1.0.0' } } : {}) }) as any;

let registry: SchemaRegistry;
beforeEach(() => {
registry = new SchemaRegistry({ multiTenant: false });
(registry as any).logLevel = 'silent';
registry.installPackage(manifest(APP_PKG, 'app'), 'code');
registry.installPackage(manifest(MODULE_PKG, 'module'), 'code');
registry.installPackage(manifest(OTHER_PKG, 'module'), 'code');
registry.registerObject({
name: OBJECT, label: 'Account', fields: { name: { name: 'name', type: 'text', label: 'Name' } },
} as any, APP_PKG, 'qa', 'own');
registry.registerObject({
name: OBJECT,
fields: { qa_credit: { name: 'qa_credit', type: 'number', label: 'Credit' } },
validations: [{ name: 'qa_credit_positive', type: 'script', severity: 'error', message: 'Credit', condition: 'record.qa_credit < 0' }],
} as any, MODULE_PKG, 'qa', 'extend');
registry.registerObject({
name: OBJECT,
fields: { qa_score: { name: 'qa_score', type: 'number', label: 'Score' } },
validations: [{ name: 'qa_score_capped', type: 'script', severity: 'error', message: 'Score', condition: 'record.qa_score > 10' }],
} as any, OTHER_PKG, 'qa', 'extend');
});

const served = (options?: { asEnabled?: string }) => registry.getAllObjects(undefined, options).find((o) => o.name === OBJECT) as any;
const fieldsOf = (body: any): string[] => Object.keys(body?.fields ?? {}).filter((f) => f.startsWith('qa_') || f === 'name').sort();
const rulesOf = (body: any): string[] => (body?.validations ?? []).map((r: any) => r.name).sort();

it('folds the named disabled package in, keeps every other disabled package withheld, and moves no state', () => {
registry.disablePackage(MODULE_PKG);
registry.disablePackage(OTHER_PKG);
const body = served({ asEnabled: MODULE_PKG });
expect(fieldsOf(body)).toEqual(['name', 'qa_credit']);
expect(rulesOf(body)).toEqual(['qa_credit_positive']);
expect(registry.isPackageDisabled(MODULE_PKG)).toBe(true);
expect(fieldsOf(registry.getObject(OBJECT)), 'the served fold is unchanged by the read').toEqual(['name']);
});

it('control: unset, or naming a package that is not disabled, the fold is today\'s, byte for byte', () => {
registry.disablePackage(MODULE_PKG);
const today = JSON.stringify(served());
expect(JSON.stringify(served({}))).toBe(today);
expect(JSON.stringify(served({ asEnabled: OTHER_PKG }))).toBe(today);
expect(JSON.stringify(served({ asEnabled: 'com.qa.asen.unknown' }))).toBe(today);
expect(fieldsOf(served({ asEnabled: OTHER_PKG }))).toEqual(['name', 'qa_score']);
});
});
33 changes: 27 additions & 6 deletions packages/objectql/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -748,10 +748,18 @@ type ServedObjectLayer = 'served' | 'code';
*
* Both may be given; a contribution either one leaves out is left out. The
* base layer (`own` / `overlay`) is never left out.
*
* [#30756] `asEnabled` asks the opposite question of a DISABLED package: the
* fold as it will serve once that package is enabled, its `extend`
* contributions folded in instead of withheld ({@link SchemaRegistry.withholdDisabledExtensions}).
* Every other disabled package's stay withheld. The enable door's pre-check
* reads it, as the disable door's reads `withoutExtensionsOf`; for a package
* that is not disabled it changes nothing.
*/
export interface ObjectExtensionFoldOptions {
withoutExtensionsOf?: string;
onlyExtensionsOf?: string;
asEnabled?: string;
}

/** Whether `options` leaves any contribution out (otherwise the fold is the ordinary one). */
Expand Down Expand Up @@ -5275,9 +5283,15 @@ export class SchemaRegistry {
*
* `undefined` when no extension of the object is withheld, so the ordinary
* fold runs unchanged.
*
* [#30756] `asEnabled` names a package whose contributions are kept although
* it is disabled: the fold the enable of that package would leave
* ({@link ObjectExtensionFoldOptions}).
*/
private withholdDisabledExtensions(contributors: readonly ObjectContributor[]): WithheldExtensions | undefined {
const withheld = contributors.filter((c) => c.ownership === 'extend' && this.isPackageDisabled(c.packageId));
private withholdDisabledExtensions(contributors: readonly ObjectContributor[], asEnabled?: string): WithheldExtensions | undefined {
const withheld = contributors.filter((c) => c.ownership === 'extend'
&& this.isPackageDisabled(c.packageId)
&& (asEnabled === undefined || c.packageId !== asEnabled));
if (withheld.length === 0) return undefined;
const kept = contributors.filter((c) => !withheld.includes(c));
const owner = contributors.find((c) => c.ownership === 'own')?.definition;
Expand Down Expand Up @@ -5319,14 +5333,21 @@ export class SchemaRegistry {
* patches travel twice, the second time as if the owner shipped them.
*/
private resolveObjectLeavingExtensionsOut(fqn: string, options: ObjectExtensionFoldOptions | undefined): ServiceObject | undefined {
const contributors = leavesExtensionsOut(options) ? this.objectContributors.get(fqn) : undefined;
if (!contributors || !contributors.some((c) => isLeftOutExtension(c, options!))) return this.resolveObject(fqn);
// [#30756] A disabled package folded in as if enabled; one that is not
// disabled changes nothing, so it does not leave the cached fold.
const asEnabled = options?.asEnabled !== undefined && this.isPackageDisabled(options.asEnabled)
? options.asEnabled
: undefined;
const contributors = leavesExtensionsOut(options) || asEnabled !== undefined ? this.objectContributors.get(fqn) : undefined;
if (!contributors || !contributors.some((c) => isLeftOutExtension(c, options!)
|| (asEnabled !== undefined && c.ownership === 'extend' && c.packageId === asEnabled))) return this.resolveObject(fqn);
const kept = contributors.filter((c) => !isLeftOutExtension(c, options!));
const ownerContrib = kept.find(c => c.ownership === 'own');
if (!ownerContrib) return undefined;
const base = kept.find(c => c.ownership === 'overlay') ?? ownerContrib;
// …and a disabled package's, as on every served fold ({@link withholdDisabledExtensions}).
const withheld = this.withholdDisabledExtensions(kept);
// …and a disabled package's, as on every served fold ({@link withholdDisabledExtensions}),
// except the one `asEnabled` names.
const withheld = this.withholdDisabledExtensions(kept, asEnabled);
return withheld
? this.foldExtendersOntoDefinition(withheld.kept, withoutWithheldMembers(base.definition, withheld))
: this.foldExtenders(kept, base);
Expand Down
Loading
Loading