From cbecc19f44add6741ef8b00d112b5bce1bd498c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 16:33:25 +0000 Subject: [PATCH 1/5] fix(runtime)!: the package enable door refuses while a rule it would serve reads a withheld field The enable door (and the install door's enableOnInstall: true arm on an overwrite of a disabled row) asks readEnableDependentRules before enablePackage: the rules the enable would put into service that read a field a disabled package withholds, judged on the registry's own fold with the package folded in (the new asEnabled fold option) against the fold served now. Refused with the disable door's code and wire pair. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibxjrKo69n5BEsgi5qA --- packages/objectql/src/registry.ts | 33 +++- packages/runtime/src/domains/packages.ts | 218 ++++++++++++++++++++++- 2 files changed, 238 insertions(+), 13 deletions(-) diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index 08310c37bf6..1c833ed82e5 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -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). */ @@ -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; @@ -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!) + || (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); diff --git a/packages/runtime/src/domains/packages.ts b/packages/runtime/src/domains/packages.ts index b1cec174829..e7ef8586cd6 100644 --- a/packages/runtime/src/domains/packages.ts +++ b/packages/runtime/src/domains/packages.ts @@ -1088,6 +1088,174 @@ function disableDependentRulesMessage(lead: string, dependents: readonly Disable + 'or keep the package enabled.'; } +/** + * One validation rule an enable would put into service reading a field that + * stays withheld: {@link DisableDependentRule}, plus the disabled packages that + * add the fields it reads (`withheldBy`), whose enable is the other remedy. + */ +interface EnableDependentRule extends DisableDependentRule { + withheldBy: string[]; +} + +/** + * The rules a served fold carries that read a field the fold withholds, on + * every object whose owner `isOff` does not name (a disabled owner's object is + * refused whole, so no write reaches its rules). `served` is the registry's + * own fold for the state being judged; a field is withheld from an object + * when the storage fold (`getStorageObject`: every `extend` contribution, a + * disabled package's included) declares it and `served` does not, which is + * the registry's own reading of a withheld member, never re-assembled here. + * Each rule is judged as {@link readDisableDependentRules} judges one: the + * served copy (one per name, a tenant overlay's `active` on it), its reads by + * {@link addRuleFieldReads}, named after the last layer in fold order that + * declares its name. + */ +function readWithheldFieldReaders( + registry: any, + served: ReadonlyMap; validations?: unknown }>, + isOff: (packageId: unknown) => boolean, +): EnableDependentRule[] { + type Contributor = { packageId?: unknown; ownership?: unknown; definition?: unknown }; + const withheldCache = new Map>(); + const withheldOf = (object: string): Set => { + let withheld = withheldCache.get(object); + if (withheld) return withheld; + withheld = new Set(); + const storage = registry.getStorageObject(object) as { fields?: Record } | undefined; + const servedFields = served.get(object)?.fields ?? {}; + for (const field of Object.keys(storage?.fields ?? {})) if (!(field in servedFields)) withheld.add(field); + withheldCache.set(object, withheld); + return withheld; + }; + /** The disabled packages whose `extend` contributions to `object` add `field`. */ + const addersOf = (object: string, field: string): string[] => + (registry.getObjectContributors(object) as Contributor[]) + .filter((c) => c.ownership === 'extend' && isOff(c.packageId) && typeof c.packageId === 'string') + .filter((c) => declaredFieldNamesOf(c.definition).includes(field)) + .map((c) => c.packageId as string); + + const readers: EnableDependentRule[] = []; + for (const [name, object] of served) { + const contributors = registry.getObjectContributors(name) as Contributor[]; + const owner = contributors.find((c) => c.ownership === 'own'); + if (!owner || isOff(owner.packageId)) continue; + const base = contributors.find((c) => c.ownership === 'overlay') ?? owner; + const layerOf = new Map(); + for (const layer of [base, ...contributors.filter((c) => c.ownership === 'extend' && !isOff(c.packageId))]) { + for (const rule of declaredRulesOf(layer.definition)) { + if (typeof rule.name === 'string') layerOf.set(rule.name, layer); + } + } + const servedFields = object.fields ?? {}; + for (const rule of declaredRulesOf(object)) { + if (rule.active === false || typeof rule.name !== 'string') continue; + const reads: RuleFieldReads = { own: new Set(), hops: new Map() }; + addRuleFieldReads(rule, reads, 0); + const hit: string[] = []; + const withheldBy = new Set(); + const ownWithheld = withheldOf(name); + for (const field of reads.own) { + if (!ownWithheld.has(field)) continue; + hit.push(field); + for (const adder of addersOf(name, field)) withheldBy.add(adder); + } + for (const [ref, related] of reads.hops) { + const target = referenceTargetOf(servedFields[ref]); + if (target === undefined || !served.has(target)) continue; + const targetWithheld = withheldOf(target); + for (const field of related) { + if (!targetWithheld.has(field)) continue; + hit.push(`${ref}.${field}`); + for (const adder of addersOf(target, field)) withheldBy.add(adder); + } + } + if (hit.length === 0) continue; + readers.push({ + packageId: ruleLayerLabel(layerOf.get(rule.name) ?? base), + object: name, + rule: rule.name, + fields: [...new Set(hit)].sort(), + withheldBy: [...withheldBy].sort(), + }); + } + } + return readers; +} + +/** Every object of a fold, by name. */ +function foldByName(objects: unknown): Map; validations?: unknown }> { + const byName = new Map; validations?: unknown }>(); + for (const object of (Array.isArray(objects) ? objects : []) as Array<{ name?: unknown }>) { + if (typeof object?.name === 'string') byName.set(object.name, object as { fields?: Record; validations?: unknown }); + } + return byName; +} + +/** + * [#30756, the mirror of #30379 ruling A] The validation rules the enable of + * `packageId` would put into service reading a field that stays withheld: + * `PATCH /packages/:id/enable` (and the install door's `enableOnInstall: true` + * on an overwrite of a disabled row) refuses while this is not empty, because + * the rule validator fails closed on a field its object does not serve, so + * every write to that object would be refused from the moment of the enable. + * + * Judged on the fold the enable would leave, the registry's own + * (`getAllObjects(undefined, { asEnabled })`: this package's `extend` + * contributions folded in, every other disabled package's withheld), against + * the fold served now (`getAllObjects()`): a rule counts only when it reads a + * withheld field AFTER the enable and did not already BEFORE it, so an enable + * is never refused over a rule it does not bring into service. That covers the + * package's own `extend` rules (and a rule of its name that replaces a served + * one), the rules on the objects it owns, which come into service with them + * (other enabled packages' included), and nothing the tenant switched off + * through an overlay (`active: false` reads nothing, as on the disable side). + * A field the package itself adds stops being withheld, so a rule reading it + * is no dependent. + * + * Empty for a package that is not disabled (its enable changes no fold). + * Ordered by package, object and rule. + */ +function readEnableDependentRules(registry: any, packageId: string): EnableDependentRule[] { + if (typeof registry?.getObjectContributors !== 'function' + || typeof registry?.getAllObjects !== 'function' + || typeof registry?.getStorageObject !== 'function' + || typeof registry?.isPackageDisabled !== 'function') return []; + if (!registry.isPackageDisabled(packageId)) return []; + const offNow = (id: unknown): boolean => registry.isPackageDisabled(id); + const offAfter = (id: unknown): boolean => id !== packageId && registry.isPackageDisabled(id); + + const after = readWithheldFieldReaders(registry, foldByName(registry.getAllObjects(undefined, { asEnabled: packageId })), offAfter); + if (after.length === 0) return []; + const keyOf = (d: DisableDependentRule): string => `${d.object}\u0000${d.rule}\u0000${d.fields.join(',')}`; + const before = new Set(readWithheldFieldReaders(registry, foldByName(registry.getAllObjects()), offNow).map(keyOf)); + return after + .filter((d) => !before.has(keyOf(d))) + .sort((a, b) => a.packageId.localeCompare(b.packageId) + || a.object.localeCompare(b.object) + || a.rule.localeCompare(b.rule)); +} + +/** + * The sentence an enable-side dependent-rule refusal carries: `lead` (what was + * refused), then every rule as `package · object · rule (reads FIELD, …)`, then + * the two remedies, the second naming the disabled packages that add the fields. + */ +function enableDependentRulesMessage(lead: string, dependents: readonly EnableDependentRule[]): string { + const one = dependents.length === 1; + const listed = dependents + .map((d) => `${d.packageId} · ${d.object} · ${d.rule} (reads ${d.fields.join(', ')})`) + .join('; '); + const adders = [...new Set(dependents.flatMap((d) => d.withheldBy))].sort(); + const enableFirst = adders.length === 0 + ? 'enable the package that adds the field first' + : `enable ${adders.map((id) => `'${id}'`).join(', ')} first`; + return `${lead}: ${one + ? 'an active validation rule the enable would serve reads a field' + : `${dependents.length} active validation rules the enable would serve read fields`} that a disabled package adds ` + + `through objectExtensions, which ${one ? 'stays' : 'stay'} withheld — ${listed}. ` + + `Switch off or change ${one ? 'that rule' : 'those rules'} first, or ${enableFirst}.`; +} + /** * [#14451] `POST /packages/:id/duplicate` — the SOURCE must be a BASE. * @@ -2181,11 +2349,26 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin // declaration carries no `.default()` for the key, so absence // survives the parse and is read as absence. const requestedEnabled = request?.enableOnInstall; - let installDisableRefusal: string | undefined; + let installLifecycleRefusal: string | undefined; if (requestedEnabled === true) { - const enabled = registry.enablePackage(pkgId); - if (enabled) pkg = enabled; - await summaryRefreshSettled(qlService); + // [#30756] The same refusal as `PATCH /packages/:id/enable`, + // asked of the state the install just left: only an overwrite + // of a DISABLED row reaches it (the scan is empty for a package + // that is not disabled). The install itself has landed, so the + // package stays DISABLED (the row and the state file below + // follow `pkg.enabled`) and the door answers the refusal + // instead of a 201 that claims an enable. + const dependents = readEnableDependentRules(registry, pkgId); + if (dependents.length > 0) { + installLifecycleRefusal = enableDependentRulesMessage( + `Package '${pkgId}' was installed and stays disabled: \`enableOnInstall: true\` cannot enable it`, + dependents, + ); + } else { + const enabled = registry.enablePackage(pkgId); + if (enabled) pkg = enabled; + await summaryRefreshSettled(qlService); + } } else if (requestedEnabled === false) { // [#30379 ruling A, contract review should-fix] The same refusal // as `PATCH /packages/:id/disable`, asked of the state the @@ -2202,7 +2385,7 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin // withhold nothing served, and land disabled as asked. const dependents = overwritesEnabledRow ? readDisableDependentRules(registry, pkgId) : []; if (dependents.length > 0) { - installDisableRefusal = disableDependentRulesMessage( + installLifecycleRefusal = disableDependentRulesMessage( `Package '${pkgId}' was installed and stays enabled: \`enableOnInstall: false\` cannot disable it`, dependents, ); @@ -2257,11 +2440,11 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin } catch (err) { console.warn('[handlePackages] failed to persist enableOnInstall', { id: pkgId, disabled: rowDisabled, error: (err as Error)?.message }); } - if (installDisableRefusal !== undefined) { + if (installLifecycleRefusal !== undefined) { return { handled: true, response: deps.error( - installDisableRefusal, + installLifecycleRefusal, PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE.status, { code: PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE.code }, ), @@ -2280,6 +2463,27 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin const walled = await refuseWalledCatalogPackageWrite(deps, _context, `Enabling package "${id}"`); if (walled) return walled; const queryRefused = refusePackageWriteDoorQuery(deps, query, PACKAGE_WRITE_NO_QUERY_PARAMS); if (queryRefused) return queryRefused; + // [#30756, the mirror of #30379 ruling A] A rule the enable would + // put into service that reads a field a disabled package withholds + // refuses the enable: the rule validator fails closed on a field its + // object does not serve, so every write to that object would be + // refused for as long as the field's package stays off. Asked + // BEFORE `enablePackage` and `setPackageDisabled`, so a refused + // enable leaves the row, the served objects and the environment's + // state file exactly as they were. Same code and wire pair as the + // disable door: the refused state is the same one, a disabled + // package's withheld field under an active rule. + const dependents = readEnableDependentRules(registry, id); + if (dependents.length > 0) { + return { + handled: true, + response: deps.error( + enableDependentRulesMessage(`Package '${id}' cannot be enabled`, dependents), + PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE.status, + { code: PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE.code }, + ), + }; + } const pkg = registry.enablePackage(id); if (!pkg) return { handled: true, response: deps.error(`Package '${id}' not found`, 404) }; await summaryRefreshSettled(qlService); From 121157b76c1b9a65318accdf51be3a4e3bf4bb89 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 16:41:47 +0000 Subject: [PATCH 2/5] test(runtime): pin the enable door's refusal, its two controls, the install arm and the asEnabled fold Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibxjrKo69n5BEsgi5qA --- ...gistry-disabled-package-extensions.test.ts | 61 ++++++ ...es-disable-module-extension-fields.test.ts | 176 +++++++++++++++++- 2 files changed, 228 insertions(+), 9 deletions(-) diff --git a/packages/objectql/src/registry-disabled-package-extensions.test.ts b/packages/objectql/src/registry-disabled-package-extensions.test.ts index 4db39900fb6..afd18a02985 100644 --- a/packages/objectql/src/registry-disabled-package-extensions.test.ts +++ b/packages/objectql/src/registry-disabled-package-extensions.test.ts @@ -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']); + }); +}); diff --git a/packages/runtime/src/domains/packages-disable-module-extension-fields.test.ts b/packages/runtime/src/domains/packages-disable-module-extension-fields.test.ts index fda07dc90d8..39a8930bb9b 100644 --- a/packages/runtime/src/domains/packages-disable-module-extension-fields.test.ts +++ b/packages/runtime/src/domains/packages-disable-module-extension-fields.test.ts @@ -645,16 +645,18 @@ describe('[#30379] the disable door refuses while an enabled package\'s validati expect((await dispatchAs(kernel, 'PATCH', `/data/${ACCOUNT}/${accountId}`, { name: 'Acme Ltd' })).status).toBe(200); }); - it('measures an enable the ruling did not cover: a reading package enabled while the field stays withheld refuses writes (400 VALIDATION_FAILED)', async () => { - // The disable door is the one ruled. Enabling the reading package after - // the field's package is off is not judged by the enable door, and the - // rule then fails closed on every write, naming itself. The enable - // door's mirror is #30756, which flips this pin when it lands. - const { kernel } = booted!; - expect((await dispatchAs(kernel, 'PATCH', `/packages/${RULES}/enable`)).status).toBe(200); + it('[#30756] the mirror enable is refused the same way: the reading package stays disabled while the field stays withheld, and writes still land', async () => { + // Before #30756 this enable answered 200 and every write to the + // account then answered 400 VALIDATION_FAILED, the rule failing closed + // on the withheld field. The full enable-door battery is the + // `[#30756]` describe below. + const { kernel, engine } = booted!; + const refused = await dispatchAs(kernel, 'PATCH', `/packages/${RULES}/enable`); + expect({ status: refused.status, code: refused.code }).toEqual({ status: 409, code: CODE }); + expect(String(refused.message)).toContain(`Package '${RULES}' cannot be enabled: 3 active validation rules`); + expect(engine.registry.getPackage(RULES)?.enabled).toBe(false); const updated = await dispatchAs(kernel, 'PATCH', `/data/${ACCOUNT}/${accountId}`, { name: 'Acme' }); - expect({ status: updated.status, code: updated.code }).toEqual({ status: 400, code: 'VALIDATION_FAILED' }); - expect(String(updated.message)).toContain(RULE_NOT_NEGATIVE); + expect(updated.status, String(updated.message)).toBe(200); }); }); @@ -847,3 +849,159 @@ describe('[#30379 contract review] POST /packages with enableOnInstall: false as expect(loadDisabledPackageIds(undefined).has(EXTENDER)).toBe(true); }); }); + +describe('[#30756] the enable door refuses while a rule it would serve reads a field a disabled package withholds', () => { + let root: string; + let booted: Booted | undefined; + let accountId: string; + // The disable door's code (#30724), reused: the refused state is the same one. + const CODE = 'PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE'; + /** A tenant copy of a packaged rule: only its sentence and its switch are the tenant's. */ + const switchedOff = (name: string) => ({ name, type: 'script', message: 'Switched off by the tenant', condition: 'true', active: false }); + + beforeAll(async () => { + root = mkdtempSync(join(tmpdir(), 'os-suite-ext-enable-')); + writeFileSync(join(root, '.keep'), ''); + process.env.OS_HOME = mkdtempSync(join(home, 'enable-')); + booted = await bootKernel(root, { rules: true }); + const created = await dispatchAs(booted.kernel, 'POST', `/data/${ACCOUNT}`, { name: 'Acme', [CREDIT]: 500 }); + expect(created.status, 'precondition: the account is written').toBe(201); + accountId = idOf(created); + }, 120_000); + afterAll(async () => { + await shutdown(booted); + rmSync(root, { recursive: true, force: true }); + }, 60_000); + + it('control: with the field\'s package enabled, an enable with no dependent rule proceeds as before', async () => { + const { kernel, engine } = booted!; + expect((await dispatchAs(kernel, 'PATCH', `/packages/${RULES}/disable`)).status).toBe(200); + const enabled = await dispatchAs(kernel, 'PATCH', `/packages/${RULES}/enable`); + expect(enabled.status, String(enabled.message)).toBe(200); + expect(engine.registry.getPackage(RULES)?.enabled).not.toBe(false); + expect(loadDisabledPackageIds(ENVIRONMENT_ID).has(RULES)).toBe(false); + }); + + it('answers 409 with the code, naming every rule as package · object · rule and the package to enable first, and changes nothing', async () => { + const { kernel, engine } = booted!; + // The order the disable door allows: the reader off first, then the field's package. + expect((await dispatchAs(kernel, 'PATCH', `/packages/${RULES}/disable`)).status).toBe(200); + expect((await dispatchAs(kernel, 'PATCH', `/packages/${MODULE}/disable`)).status).toBe(200); + + const refused = await dispatchAs(kernel, 'PATCH', `/packages/${RULES}/enable`); + expect({ status: refused.status, code: refused.code }).toEqual({ status: 409, code: CODE }); + expect(String(refused.message)).toBe( + `Package '${RULES}' cannot be enabled: 3 active validation rules the enable would serve read fields that a disabled package adds through objectExtensions, which stay withheld — ${listingOfRules([ + { packageId: RULES, object: ACCOUNT, rule: RULE_NOT_NEGATIVE, fields: [CREDIT] }, + { packageId: RULES, object: ACCOUNT, rule: RULE_REVIEW, fields: [CREDIT] }, + { packageId: RULES, object: VISIT, rule: RULE_VISIT_HOP, fields: [`account.${CREDIT}`] }, + ])}. Switch off or change those rules first, or enable '${MODULE}' first.`, + ); + expect(refused.details).toBeUndefined(); + + // Nothing changed: the row, the served rules, the data plane and the state file. + expect(engine.registry.getPackage(RULES)?.enabled).toBe(false); + const rules = ((engine.registry.getObject(ACCOUNT) as any)?.validations ?? []).map((v: any) => v?.name); + expect(rules).not.toContain(RULE_NOT_NEGATIVE); + const written = await dispatchAs(kernel, 'PATCH', `/data/${ACCOUNT}/${accountId}`, { name: 'Acme Ltd' }); + expect(written.status, String(written.message)).toBe(200); + expect(loadDisabledPackageIds(ENVIRONMENT_ID).has(RULES)).toBe(true); + }); + + it('control: proceeds once every rule it would serve that reads the field is switched off through the tenant overlay', async () => { + const { kernel, engine } = booted!; + engine.registry.registerObject({ + name: ACCOUNT, label: 'Account', fields: { name: { name: 'name', type: 'text', label: 'Name' } }, + validations: [switchedOff(RULE_NOT_NEGATIVE), switchedOff(RULE_REVIEW)], _provenance: 'org', + } as any, APP, undefined, 'overlay'); + engine.registry.registerObject({ + name: VISIT, label: 'Visit', + fields: { + name: { name: 'name', type: 'text', label: 'Name' }, + amount: { name: 'amount', type: 'number', label: 'Amount' }, + account: { name: 'account', type: 'lookup', label: 'Account', reference: ACCOUNT }, + }, + validations: [switchedOff(RULE_VISIT_HOP)], _provenance: 'org', + } as any, APP, undefined, 'overlay'); + + const enabled = await dispatchAs(kernel, 'PATCH', `/packages/${RULES}/enable`); + expect(enabled.status, String(enabled.message)).toBe(200); + expect(engine.registry.getPackage(RULES)?.enabled).not.toBe(false); + const served = ((engine.registry.getObject(ACCOUNT) as any)?.validations ?? []) as Array<{ name?: string; active?: unknown }>; + expect(served.find((r) => r.name === RULE_NOT_NEGATIVE)?.active, 'the packaged rule is served, switched off').toBe(false); + const written = await dispatchAs(kernel, 'PATCH', `/data/${ACCOUNT}/${accountId}`, { name: 'Acme' }); + expect(written.status, String(written.message)).toBe(200); + expect(loadDisabledPackageIds(ENVIRONMENT_ID).has(RULES)).toBe(false); + }); +}); + +describe('[#30756] POST /packages with enableOnInstall: true asks the same scan', () => { + const OWNER = 'com.qa.inst2.owner'; + const EXTENDER = 'com.qa.inst2.flags'; + const READER = 'com.qa.inst2.reader'; + const THING = 'qy_thing'; + const FLAG = 'qy_flag'; + const RULE = 'qy_flag_required'; + const CODE = 'PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE'; + /** Each package its own namespace: the two install side by side. */ + const manifestOf = (id: string) => ({ id, name: id, namespace: id === READER ? 'qr' : 'qy', version: '1.0.0', type: 'module' }); + + let previousHome: string | undefined; + beforeAll(() => { + previousHome = process.env.OS_HOME; + process.env.OS_HOME = mkdtempSync(join(home, 'install-enable-')); + }); + afterAll(() => { + process.env.OS_HOME = previousHome; + }); + + /** The extender's field on the owner's object, (optionally) the reader's rule over it, both packages installed and disabled. */ + async function door(withReader: boolean) { + const registry = new SchemaRegistry({ multiTenant: false, collisionPolicy: 'error' } as any); + (registry as any).logLevel = 'silent'; + const kernel: any = { + getService: (name: string) => (name === 'objectql' ? Promise.resolve({ registry }) : null), + context: { getService: () => null }, + }; + const dispatcher = new HttpDispatcher(kernel); + const install = (body: unknown) => dispatcher.handlePackages('', 'POST', body, {}, { + request: {}, + executionContext: { userId: 'u_admin', systemPermissions: ['manage_metadata', 'studio.access', 'setup.access'] }, + } as any); + for (const id of [EXTENDER, READER]) { + const first = await install({ manifest: manifestOf(id) }); + expect(first.response?.status, `precondition: ${id} installs`).toBe(201); + } + registry.registerObject({ name: THING, label: 'Thing', fields: { name: { name: 'name', type: 'text', label: 'Name' } } } as any, OWNER); + registry.registerObject({ name: THING, fields: { [FLAG]: { name: FLAG, type: 'boolean', label: 'Flag' } } } as any, EXTENDER, undefined, 'extend'); + if (withReader) { + registry.registerObject({ + name: THING, + validations: [{ name: RULE, type: 'script', severity: 'error', message: 'Flag it', condition: `record.${FLAG} == null` }], + } as any, READER, undefined, 'extend'); + } + registry.disablePackage(READER); + registry.disablePackage(EXTENDER); + return { registry, install }; + } + + it('an overwrite of a disabled package that asks to come back enabled is refused while a rule it would serve reads a withheld field, and the package stays disabled', async () => { + const { registry, install } = await door(true); + const result = await install({ manifest: manifestOf(READER), overwrite: true, enableOnInstall: true }); + expect({ status: result.response?.status, code: result.response?.body?.error?.code }).toEqual({ status: 409, code: CODE }); + expect(String(result.response?.body?.error?.message)).toBe( + `Package '${READER}' was installed and stays disabled: \`enableOnInstall: true\` cannot enable it: an active validation rule the enable would serve reads a field that a disabled package adds through objectExtensions, which stays withheld — ${READER} · ${THING} · ${RULE} (reads ${FLAG}). Switch off or change that rule first, or enable '${EXTENDER}' first.`, + ); + expect(registry.getPackage(READER)?.enabled).toBe(false); + expect(loadDisabledPackageIds(undefined).has(READER)).toBe(true); + expect(((registry.getObject(THING) as any)?.validations ?? []).map((v: any) => v?.name)).not.toContain(RULE); + }); + + it('control: with no rule reading the field the same overwrite installs it enabled', async () => { + const { registry, install } = await door(false); + const result = await install({ manifest: manifestOf(READER), overwrite: true, enableOnInstall: true }); + expect(result.response?.status, String(result.response?.body?.error?.message)).toBe(201); + expect(registry.getPackage(READER)?.enabled).toBe(true); + expect(loadDisabledPackageIds(undefined).has(READER)).toBe(false); + }); +}); From 7a8a90a21b40001c2030f5de6382b860c1ade584 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 16:44:44 +0000 Subject: [PATCH 3/5] fix(runtime): a boot that lands an enabled rule reading a withheld field says so once The state file records only disables, so a reading package that boots enabled beside a disabled field package reaches the state the enable door refuses without passing any door. AppPlugin reads it once per registry at kernel:bootstrapped and warns with every rule and the remedies. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibxjrKo69n5BEsgi5qA --- packages/runtime/src/app-plugin.ts | 53 +++++++++++++++++ ...es-disable-module-extension-fields.test.ts | 59 ++++++++++++++++++- packages/runtime/src/domains/packages.ts | 48 ++++++++++++++- 3 files changed, 157 insertions(+), 3 deletions(-) diff --git a/packages/runtime/src/app-plugin.ts b/packages/runtime/src/app-plugin.ts index d87dbf37b52..42a2cf248b3 100644 --- a/packages/runtime/src/app-plugin.ts +++ b/packages/runtime/src/app-plugin.ts @@ -30,6 +30,7 @@ import { mergeSeedDatasets, readSeedDatasets, registerSeedReplayerOnce } from '. import { describeSeedLocale, resolveSeedLocaleForLoad } from './seed-locale.js'; import { declareSeedSource, recordOrganizationReplay, type SeedLandingReport } from './seed-settlement.js'; import { loadDisabledPackageIds } from './package-state-store.js'; +import { readServedWithheldFieldReaders, servedWithheldFieldReadersMessage } from './domains/packages.js'; import type { IFieldFileService, IMetadataService, IObjectQLEngine, II18nService } from '@objectstack/spec/contracts'; import { FIELD_FILE_SERVICE } from '@objectstack/spec/contracts'; import { normalizeFlowFunctionEntry, type NormalizedFlowFunction } from '@objectstack/spec/automation'; @@ -58,6 +59,13 @@ import { countServerTiming } from '@objectstack/observability'; */ const SEED_WRITE_OPTIONS = { context: SEED_WRITE_EXECUTION_CONTEXT } as const; +/** + * [#30756] The registries whose boot already reads the rules a disabled package + * leaves reading a withheld field ({@link AppPlugin.reportWithheldFieldReadersAtBoot}): + * one reading per registry, however many `AppPlugin`s share it. + */ +const WITHHELD_FIELD_READERS_REPORTED = new WeakSet(); + /** * [#25779] What every seed loader this plugin builds is given beyond its * engine: the `field-files` service a seed file (`seedFile(path)`) is stored @@ -708,6 +716,51 @@ export class AppPlugin implements Plugin { error: (err as Error)?.message ?? String(err), }); } + this.reportWithheldFieldReadersAtBoot(ctx); + } + + /** + * [#30756] The boot half of the enable door's refusal. The state file + * records only DISABLES, so a boot has no enable to refuse: a package that + * is not listed boots enabled. A reading package that boots enabled beside + * a disabled package whose `objectExtensions` field its active rule reads + * (a release that added the rule, a state file written before the enable + * door judged this) therefore lands in the state the door refuses, and + * every write to that object answers `400 VALIDATION_FAILED`. The boot + * neither refuses (one object's writes are not worth the whole runtime) + * nor rewrites the operator's recorded state: it says so once, naming + * every rule and the remedies (`readServedWithheldFieldReaders`). + * + * Read at `kernel:bootstrapped`, after the `kernel:ready` rehydrates have + * registered every package, so the reading is the boot's final one and not + * a verdict a later registration contradicts. Once per registry, however + * many `AppPlugin`s share it. At `warn`: the requester of every refused + * write is told, so nothing claims to persist that did not; the log is the + * operator's remedy, not a durability alarm. + */ + private reportWithheldFieldReadersAtBoot(ctx: PluginContext): void { + let registry: unknown; + try { + registry = ctx.getService<{ registry?: unknown }>('objectql')?.registry; + } catch { + return; // no engine on this kernel — nothing to read + } + if (!registry || typeof registry !== 'object' || WITHHELD_FIELD_READERS_REPORTED.has(registry)) return; + WITHHELD_FIELD_READERS_REPORTED.add(registry); + ctx.hook('kernel:bootstrapped', async () => { + try { + const readers = readServedWithheldFieldReaders(registry); + if (readers.length === 0) return; + ctx.logger.warn(`[AppPlugin] ${servedWithheldFieldReadersMessage(readers)}`, { + environmentId: this.projectContext?.environmentId, + rules: readers.map((d) => ({ package: d.packageId, object: d.object, rule: d.rule, reads: d.fields, withheldBy: d.withheldBy })), + }); + } catch (err) { + ctx.logger.warn('[AppPlugin] failed to read the rules a disabled package leaves reading a withheld field', { + error: (err as Error)?.message ?? String(err), + }); + } + }); } /** diff --git a/packages/runtime/src/domains/packages-disable-module-extension-fields.test.ts b/packages/runtime/src/domains/packages-disable-module-extension-fields.test.ts index 39a8930bb9b..ccd73b5164a 100644 --- a/packages/runtime/src/domains/packages-disable-module-extension-fields.test.ts +++ b/packages/runtime/src/domains/packages-disable-module-extension-fields.test.ts @@ -248,8 +248,9 @@ interface Booted { engine: ObjectQL; } -async function bootKernel(root: string, options: SuiteOptions = {}): Promise { +async function bootKernel(root: string, options: SuiteOptions = {}, beforeBootstrap?: (kernel: ObjectKernel) => void): Promise { const kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false }); + beforeBootstrap?.(kernel); const engine = new ObjectQL(); engine.registerDriver(new SqlDriver({ client: 'better-sqlite3', @@ -1005,3 +1006,59 @@ describe('[#30756] POST /packages with enableOnInstall: true asks the same scan' expect(loadDisabledPackageIds(undefined).has(READER)).toBe(false); }); }); + +describe('[#30756] a boot whose state file leaves an enabled package\'s rule reading a withheld field says so once', () => { + /** Boot with the kernel logger's `warn` lines captured, and return them beside the kernel. */ + async function bootCapturingWarnings(disableModule: boolean) { + const root = mkdtempSync(join(tmpdir(), 'os-suite-ext-bootread-')); + writeFileSync(join(root, '.keep'), ''); + process.env.OS_HOME = mkdtempSync(join(home, 'bootread-')); + // The state file records the field's package as disabled; the reading + // package is not listed, so it boots enabled: no door is passed. + if (disableModule) setPackageDisabled(ENVIRONMENT_ID, MODULE, true); + const warnings: string[] = []; + const booted = await bootKernel(root, { rules: true }, (kernel) => { + const logger = (kernel as any).logger; + const warn = logger.warn.bind(logger); + logger.warn = (message: unknown, ...rest: unknown[]) => { warnings.push(String(message)); return warn(message, ...rest); }; + }); + return { root, booted, readerWarnings: () => warnings.filter((w) => w.includes('through objectExtensions')) }; + } + + it('names every rule, the objects whose writes are refused and the package to enable, once; the remedy then lets writes land', async () => { + const { root, booted, readerWarnings } = await bootCapturingWarnings(true); + try { + const { kernel, engine } = booted; + expect(engine.registry.getPackage(MODULE)?.enabled, 'precondition: the state file disabled the field\'s package').toBe(false); + expect(engine.registry.getPackage(RULES)?.enabled, 'precondition: the reading package boots enabled').not.toBe(false); + expect(readerWarnings()).toEqual([ + `[AppPlugin] 3 active validation rules read fields that a disabled package adds through objectExtensions, so every write to ${ACCOUNT}, ${VISIT} is refused (400 VALIDATION_FAILED) until this is resolved — ${listingOfRules([ + { packageId: RULES, object: ACCOUNT, rule: RULE_NOT_NEGATIVE, fields: [CREDIT] }, + { packageId: RULES, object: ACCOUNT, rule: RULE_REVIEW, fields: [CREDIT] }, + { packageId: RULES, object: VISIT, rule: RULE_VISIT_HOP, fields: [`account.${CREDIT}`] }, + ])}. Switch off or change those rules, disable the package that serves them, or enable '${MODULE}'.`, + ]); + // What the sentence says: the write is refused while the state stands. + const refused = await dispatchAs(kernel, 'POST', `/data/${ACCOUNT}`, { name: 'Delta' }); + expect({ status: refused.status, code: refused.code }).toEqual({ status: 400, code: 'VALIDATION_FAILED' }); + // One of the remedies it names: the field's package enabled. + expect((await dispatchAs(kernel, 'PATCH', `/packages/${MODULE}/enable`)).status).toBe(200); + const written = await dispatchAs(kernel, 'POST', `/data/${ACCOUNT}`, { name: 'Delta', [CREDIT]: 10 }); + expect(written.status, String(written.message)).toBe(201); + } finally { + await shutdown(booted); + rmSync(root, { recursive: true, force: true }); + } + }, 120_000); + + it('control: a boot with nothing disabled says nothing', async () => { + const { root, booted, readerWarnings } = await bootCapturingWarnings(false); + try { + expect(booted.engine.registry.getPackage(MODULE)?.enabled).not.toBe(false); + expect(readerWarnings()).toEqual([]); + } finally { + await shutdown(booted); + rmSync(root, { recursive: true, force: true }); + } + }, 120_000); +}); diff --git a/packages/runtime/src/domains/packages.ts b/packages/runtime/src/domains/packages.ts index e7ef8586cd6..c38a69765e9 100644 --- a/packages/runtime/src/domains/packages.ts +++ b/packages/runtime/src/domains/packages.ts @@ -808,7 +808,7 @@ const PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE = { code: 'PACKAGE_DISABLE_R * name, and the withheld fields it reads (`FIELD`, or `REF.FIELD` for a read * one hop through a reference field of the rule's object). */ -interface DisableDependentRule { +export interface DisableDependentRule { packageId: string; object: string; rule: string; @@ -1093,7 +1093,7 @@ function disableDependentRulesMessage(lead: string, dependents: readonly Disable * stays withheld: {@link DisableDependentRule}, plus the disabled packages that * add the fields it reads (`withheldBy`), whose enable is the other remedy. */ -interface EnableDependentRule extends DisableDependentRule { +export interface EnableDependentRule extends DisableDependentRule { withheldBy: string[]; } @@ -1235,6 +1235,50 @@ function readEnableDependentRules(registry: any, packageId: string): EnableDepen || a.rule.localeCompare(b.rule)); } +/** + * [#30756] The served state the enable door now refuses to enter, read as it + * stands: every active rule served right now that reads a field a disabled + * package withholds, on an object whose owner is enabled. Empty when no package + * is disabled. The boot reads it once its registry is complete + * (`AppPlugin`, `kernel:bootstrapped`): the environment's state file records + * only disables, so a reading package that boots enabled beside a disabled + * field package (a release that added the rule, or a state file written + * before the enable door judged this) lands here without passing any door. + * Ordered by package, object and rule. + */ +export function readServedWithheldFieldReaders(registry: any): EnableDependentRule[] { + if (typeof registry?.getObjectContributors !== 'function' + || typeof registry?.getAllObjects !== 'function' + || typeof registry?.getStorageObject !== 'function' + || typeof registry?.isPackageDisabled !== 'function' + || typeof registry?.getAllPackages !== 'function') return []; + const off = (id: unknown): boolean => registry.isPackageDisabled(id); + const packages = registry.getAllPackages() as Array<{ manifest?: { id?: unknown } }>; + if (!packages.some((pkg) => off(pkg?.manifest?.id))) return []; + return readWithheldFieldReaders(registry, foldByName(registry.getAllObjects()), off) + .sort((a, b) => a.packageId.localeCompare(b.packageId) + || a.object.localeCompare(b.object) + || a.rule.localeCompare(b.rule)); +} + +/** + * The boot's one sentence for {@link readServedWithheldFieldReaders}: what every + * write to the named objects now answers, every rule, and the remedies. + */ +export function servedWithheldFieldReadersMessage(readers: readonly EnableDependentRule[]): string { + const one = readers.length === 1; + const listed = readers + .map((d) => `${d.packageId} · ${d.object} · ${d.rule} (reads ${d.fields.join(', ')})`) + .join('; '); + const objects = [...new Set(readers.map((d) => d.object))].sort(); + const adders = [...new Set(readers.flatMap((d) => d.withheldBy))].sort(); + return `${one ? 'An active validation rule reads a field' : `${readers.length} active validation rules read fields`} ` + + `that a disabled package adds through objectExtensions, so every write to ${objects.join(', ')} is refused ` + + `(400 VALIDATION_FAILED) until this is resolved — ${listed}. Switch off or change ${one ? 'that rule' : 'those rules'}, ` + + `disable the package that serves ${one ? 'it' : 'them'}, or enable ` + + `${adders.length === 0 ? 'the package that adds the field' : adders.map((id) => `'${id}'`).join(', ')}.`; +} + /** * The sentence an enable-side dependent-rule refusal carries: `lead` (what was * refused), then every rule as `package · object · rule (reads FIELD, …)`, then From 08be854f00f9597998b191bd83918fd5e5f63f64 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 16:45:26 +0000 Subject: [PATCH 4/5] docs: name the enable door's refusal; changeset for the enable-door narrowing Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibxjrKo69n5BEsgi5qA --- .../30756-enable-door-dependent-rule.md | 24 +++++++++++++++++++ content/docs/api/error-catalog.mdx | 4 ++-- content/docs/api/metadata-api.mdx | 14 +++++++++++ .../docs/deployment/marketplace-handbook.mdx | 11 ++++++++- 4 files changed, 50 insertions(+), 3 deletions(-) create mode 100644 .changeset/30756-enable-door-dependent-rule.md diff --git a/.changeset/30756-enable-door-dependent-rule.md b/.changeset/30756-enable-door-dependent-rule.md new file mode 100644 index 00000000000..fda062467a1 --- /dev/null +++ b/.changeset/30756-enable-door-dependent-rule.md @@ -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. + + + +**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. diff --git a/content/docs/api/error-catalog.mdx b/content/docs/api/error-catalog.mdx index 3c8a2fa1a2a..be30a1b8773 100644 --- a/content/docs/api/error-catalog.mdx +++ b/content/docs/api/error-catalog.mdx @@ -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` --- diff --git a/content/docs/api/metadata-api.mdx b/content/docs/api/metadata-api.mdx index bfe4b0b97aa..3e7b62b00cf 100644 --- a/content/docs/api/metadata-api.mdx +++ b/content/docs/api/metadata-api.mdx @@ -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. diff --git a/content/docs/deployment/marketplace-handbook.mdx b/content/docs/deployment/marketplace-handbook.mdx index b555a68b59c..df6a5ef2fc1 100644 --- a/content/docs/deployment/marketplace-handbook.mdx +++ b/content/docs/deployment/marketplace-handbook.mdx @@ -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 @@ -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('')` runs inside the server, From 239fd9db8acb9a9d407e8c3a8c31169aebd8c42b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 18:07:05 +0000 Subject: [PATCH 5/5] test(runtime): pin the enable door on an object the package owns; guard the asEnabled exemption on its key Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibxjrKo69n5BEsgi5qA --- packages/objectql/src/registry.ts | 2 +- ...es-disable-module-extension-fields.test.ts | 70 +++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index 1c833ed82e5..56d265c5459 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -5340,7 +5340,7 @@ export class SchemaRegistry { : undefined; const contributors = leavesExtensionsOut(options) || asEnabled !== undefined ? this.objectContributors.get(fqn) : undefined; if (!contributors || !contributors.some((c) => isLeftOutExtension(c, options!) - || (c.ownership === 'extend' && c.packageId === asEnabled))) return this.resolveObject(fqn); + || (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; diff --git a/packages/runtime/src/domains/packages-disable-module-extension-fields.test.ts b/packages/runtime/src/domains/packages-disable-module-extension-fields.test.ts index ccd73b5164a..8d12ecd6931 100644 --- a/packages/runtime/src/domains/packages-disable-module-extension-fields.test.ts +++ b/packages/runtime/src/domains/packages-disable-module-extension-fields.test.ts @@ -1062,3 +1062,73 @@ describe('[#30756] a boot whose state file leaves an enabled package\'s rule rea } }, 120_000); }); + +describe('[#30756] the enable door judges the rules on an object the package owns', () => { + const OWNER = 'com.qa.own.owner'; + const READER = 'com.qa.own.reader'; + const EXTENDER = 'com.qa.own.flags'; + const THING = 'qo_thing'; + const FLAG = 'qo_flag'; + const RULE = 'qo_flag_required'; + const CODE = 'PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE'; + const NAMESPACES: Record = { [OWNER]: 'qo', [READER]: 'qr', [EXTENDER]: 'qf' }; + const manifestOf = (id: string) => ({ id, name: id, namespace: NAMESPACES[id], version: '1.0.0', type: 'module' }); + + let previousHome: string | undefined; + beforeAll(() => { + previousHome = process.env.OS_HOME; + process.env.OS_HOME = mkdtempSync(join(home, 'enable-owned-')); + }); + afterAll(() => { + process.env.OS_HOME = previousHome; + }); + + /** + * The disabled owner's object, an ENABLED reader's rule on it reading the extender's field, and the extender + * disabled unless `extenderEnabled`: the object comes into service only with the owner's enable. + */ + async function door({ extenderEnabled }: { extenderEnabled: boolean }) { + const registry = new SchemaRegistry({ multiTenant: false, collisionPolicy: 'error' } as any); + (registry as any).logLevel = 'silent'; + const kernel: any = { + getService: (name: string) => (name === 'objectql' ? Promise.resolve({ registry }) : null), + context: { getService: () => null }, + }; + const dispatcher = new HttpDispatcher(kernel); + const context = { + request: {}, + executionContext: { userId: 'u_admin', systemPermissions: ['manage_metadata', 'studio.access', 'setup.access'] }, + } as any; + for (const id of [OWNER, READER, EXTENDER]) { + const installed = await dispatcher.handlePackages('', 'POST', { manifest: manifestOf(id) }, {}, context); + expect(installed.response?.status, `precondition: ${id} installs`).toBe(201); + } + registry.registerObject({ name: THING, label: 'Thing', fields: { name: { name: 'name', type: 'text', label: 'Name' } } } as any, OWNER); + registry.registerObject({ name: THING, fields: { [FLAG]: { name: FLAG, type: 'boolean', label: 'Flag' } } } as any, EXTENDER, undefined, 'extend'); + registry.registerObject({ + name: THING, + validations: [{ name: RULE, type: 'script', severity: 'error', message: 'Flag it', condition: `record.${FLAG} == null` }], + } as any, READER, undefined, 'extend'); + registry.disablePackage(OWNER); + if (!extenderEnabled) registry.disablePackage(EXTENDER); + const enable = (id: string) => dispatcher.handlePackages(`/${id}/enable`, 'PATCH', {}, {}, context); + return { registry, enable }; + } + + it('refuses the owner\'s enable while an enabled package\'s rule on its object reads a field a disabled package withholds', async () => { + const { registry, enable } = await door({ extenderEnabled: false }); + const result: any = await enable(OWNER); + expect({ status: result.response?.status, code: result.response?.body?.error?.code }).toEqual({ status: 409, code: CODE }); + expect(String(result.response?.body?.error?.message)).toBe( + `Package '${OWNER}' cannot be enabled: an active validation rule the enable would serve reads a field that a disabled package adds through objectExtensions, which stays withheld — ${READER} · ${THING} · ${RULE} (reads ${FLAG}). Switch off or change that rule first, or enable '${EXTENDER}' first.`, + ); + expect(registry.getPackage(OWNER)?.enabled).toBe(false); + }); + + it('control: with the field\'s package enabled, the owner\'s enable proceeds', async () => { + const { registry, enable } = await door({ extenderEnabled: true }); + const result: any = await enable(OWNER); + expect(result.response?.status, String(result.response?.body?.error?.message)).toBe(200); + expect(registry.getPackage(OWNER)?.enabled).toBe(true); + }); +});