diff --git a/.changeset/30379-disable-door-dependent-rule.md b/.changeset/30379-disable-door-dependent-rule.md new file mode 100644 index 00000000000..0eb2b99f99a --- /dev/null +++ b/.changeset/30379-disable-door-dependent-rule.md @@ -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. + + + +**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 `.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. diff --git a/content/docs/api/error-catalog.mdx b/content/docs/api/error-catalog.mdx index c24afb9dbff..3c8a2fa1a2a 100644 --- a/content/docs/api/error-catalog.mdx +++ b/content/docs/api/error-catalog.mdx @@ -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 @@ -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) diff --git a/content/docs/api/metadata-api.mdx b/content/docs/api/metadata-api.mdx index 5aef4d4a82c..bfe4b0b97aa 100644 --- a/content/docs/api/metadata-api.mdx +++ b/content/docs/api/metadata-api.mdx @@ -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 diff --git a/content/docs/deployment/marketplace-handbook.mdx b/content/docs/deployment/marketplace-handbook.mdx index 309e5c1fc06..b555a68b59c 100644 --- a/content/docs/deployment/marketplace-handbook.mdx +++ b/content/docs/deployment/marketplace-handbook.mdx @@ -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 @@ -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('')` runs inside the server, diff --git a/packages/core/src/security/second-object-read-exposure.pin.test.ts b/packages/core/src/security/second-object-read-exposure.pin.test.ts index 3002024fb8a..39154b10658 100644 --- a/packages/core/src/security/second-object-read-exposure.pin.test.ts +++ b/packages/core/src/security/second-object-read-exposure.pin.test.ts @@ -313,6 +313,15 @@ const CALLERS: Record = { "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; 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 ca56714e8c5..fda07dc90d8 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 @@ -33,6 +33,16 @@ * disable never makes the storage layer lose a column. Pinned on a boot that * starts with the module already disabled (the environment's state file): the * column is created anyway, so an enable needs no re-sync before a write. + * + * ## An enabled dependent rule refuses the disable (#30379 ruling A) + * + * While a validation rule of any other ENABLED package (declared dependent or + * not; any severity; an app's `.custom` pair included) reads one of the + * module's extension fields, the disable door answers `409` + * `PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE`, its message naming every + * such rule as package · object · rule, and nothing changes: once withheld, + * the field could not be read and every write to the object would be refused. + * A formula over the field does not refuse the disable: it answers `null`. */ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; @@ -41,13 +51,13 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { ObjectKernel, type Plugin, type PluginContext } from '@objectstack/core'; import { MetadataPlugin } from '@objectstack/metadata'; -import { ObjectQL, ObjectQLPlugin } from '@objectstack/objectql'; +import { ObjectQL, ObjectQLPlugin, SchemaRegistry } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { composeStacks, defineStack } from '@objectstack/spec'; import { resolveThrownHttpError } from '@objectstack/types'; import { AppPlugin } from '../app-plugin.js'; import { HttpDispatcher } from '../http-dispatcher.js'; -import { setPackageDisabled } from '../package-state-store.js'; +import { loadDisabledPackageIds, setPackageDisabled } from '../package-state-store.js'; const ENVIRONMENT_ID = 'env_suite_extension_fields'; const ADMIN = { @@ -65,22 +75,55 @@ const CREDIT = 'qx_credit'; /** The module's roll-up summary of its own orders, on the app's object. */ const ORDER_TOTAL = 'qx_order_total'; const MODULE_FIELDS = [CREDIT, ORDER_TOTAL] as const; -/** A second, ENABLED module whose formula and rule read the first module's field. */ +/** A second, ENABLED module whose formula reads the first module's field. */ const SCORING = 'com.qa.extsuite.scoring'; const CREDIT_BAND = 'qx_credit_band'; /** [#30377] A third module whose roll-up, added to the app's object by extension, sums a child the APP owns. */ const ROLLUP = 'com.qa.extsuite.visits'; const VISIT = 'qx_visit'; const VISIT_TOTAL = 'qx_visit_total'; +/** + * [#30379] A fourth, ENABLED module whose validation rules read the first + * module's field: on the app's object, at error and warning severity (the + * warning a `conditional` whose `when` reads it), one switched off, and one + * on the app's visit reading it one hop through the visit's account lookup. + */ +const RULES = 'com.qa.extsuite.rules'; +const RULE_NOT_NEGATIVE = 'qx_credit_not_negative'; +const RULE_REVIEW = 'qx_credit_review'; +const RULE_SWITCHED_OFF = 'qx_credit_switched_off'; +const RULE_VISIT_HOP = 'qx_visit_account_credit'; +/** The app's paired customization package, where a tenant-written rule lives. */ +const PAIR = `${APP}.custom`; +const RULE_TENANT = 'qx_credit_tenant_cap'; +/** + * [#30379 contract review] A fifth, ENABLED module folded AFTER {@link RULES} + * (priority 300) that redeclares two of its rules by name: one copy that no + * longer reads the field, one that still does. + */ +const OVERRIDE = 'com.qa.extsuite.rules-override'; +/** A rule a tenant overlay declares itself, reading the module's field. */ +const RULE_OVERLAY_OWN = 'qx_credit_overlay_cap'; + +interface SuiteOptions { + /** {@link SCORING}: a formula over the module's field. */ + scoring?: boolean; + /** {@link ROLLUP}: a roll-up over the app's visits. */ + rollup?: boolean; + /** {@link RULES}: validation rules over the module's field. */ + rules?: boolean; + /** {@link OVERRIDE}: two of {@link RULES}' rules redeclared by name, folded later. */ + override?: boolean; +} -function suiteArtifact(withScoring = false, withRollup = false) { +function suiteArtifact({ scoring = false, rollup = false, rules = false, override = false }: SuiteOptions = {}) { const app = defineStack({ manifest: { id: APP, name: 'QA Ext Suite', namespace: 'qx', version: '1.0.0', type: 'app' }, objects: [{ name: ACCOUNT, label: 'Account', fields: { name: { name: 'name', type: 'text', label: 'Name' } }, - }, ...(withRollup ? [{ + }, ...(rollup || rules ? [{ name: VISIT, label: 'Visit', fields: { @@ -115,7 +158,7 @@ function suiteArtifact(withScoring = false, withRollup = false) { }, }], } as any); - const scoring = defineStack({ + const scoringModule = defineStack({ manifest: { id: SCORING, name: 'QA Ext Suite Scoring', namespace: 'qx', version: '1.0.0', type: 'module', dependencies: { [APP]: '^1.0.0' }, @@ -128,13 +171,54 @@ function suiteArtifact(withScoring = false, withRollup = false) { expression: `record.${CREDIT} > 100 ? "high" : "low"`, }, }, + }], + } as any); + const ruleModule = defineStack({ + manifest: { + id: RULES, name: 'QA Ext Suite Rules', namespace: 'qx', version: '1.0.0', type: 'module', + dependencies: { [APP]: '^1.0.0' }, + }, + objectExtensions: [{ + extend: ACCOUNT, validations: [{ - name: 'qx_credit_not_negative', type: 'script', severity: 'error', message: 'Credit cannot be negative', + name: RULE_NOT_NEGATIVE, type: 'script', severity: 'error', message: 'Credit cannot be negative', condition: `record.${CREDIT} < 0`, + }, { + name: RULE_REVIEW, type: 'conditional', severity: 'warning', message: 'A large credit needs a capitalised name', + when: `record.${CREDIT} > 1000`, + then: { name: 'qx_credit_review_name', type: 'format', field: 'name', regex: '^[A-Z]', message: 'Capitalise the name' }, + }, { + name: RULE_SWITCHED_OFF, type: 'script', active: false, message: 'Switched off', + condition: `record.${CREDIT} > 99999999`, + }], + }, { + extend: VISIT, + validations: [{ + name: RULE_VISIT_HOP, type: 'script', severity: 'info', message: 'The account carries no credit', + condition: `record.account.${CREDIT} < 0`, + }], + }], + } as any); + const overrideModule = defineStack({ + manifest: { + id: OVERRIDE, name: 'QA Ext Suite Rules Override', namespace: 'qx', version: '1.0.0', type: 'module', + dependencies: { [APP]: '^1.0.0' }, + }, + objectExtensions: [{ + extend: ACCOUNT, + priority: 300, + validations: [{ + // Replaces RULES' copy, which reads the field, with one that does not. + name: RULE_NOT_NEGATIVE, type: 'script', severity: 'error', message: 'The name is blocked', + condition: "record.name == 'blocked'", + }, { + // Replaces RULES' conditional with a script that still reads it. + name: RULE_REVIEW, type: 'script', severity: 'warning', message: 'A very large credit needs review', + condition: `record.${CREDIT} > 5000`, }], }], } as any); - const rollup = defineStack({ + const rollupModule = defineStack({ manifest: { id: ROLLUP, name: 'QA Ext Suite Visits', namespace: 'qx', version: '1.0.0', type: 'module', dependencies: { [APP]: '^1.0.0' }, @@ -149,7 +233,14 @@ function suiteArtifact(withScoring = false, withRollup = false) { }, }], } as any); - return composeStacks([orders, ...(withScoring ? [scoring] : []), ...(withRollup ? [rollup] : []), app], { manifest: 'preserve' }); + return composeStacks([ + orders, + ...(scoring ? [scoringModule] : []), + ...(rollup ? [rollupModule] : []), + ...(rules ? [ruleModule] : []), + ...(override ? [overrideModule] : []), + app, + ], { manifest: 'preserve' }); } interface Booted { @@ -157,7 +248,7 @@ interface Booted { engine: ObjectQL; } -async function bootKernel(root: string, withScoring = false, withRollup = false): Promise { +async function bootKernel(root: string, options: SuiteOptions = {}): Promise { const kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false }); const engine = new ObjectQL(); engine.registerDriver(new SqlDriver({ @@ -167,7 +258,7 @@ async function bootKernel(root: string, withScoring = false, withRollup = false) }) as any, true); await kernel.use(new MetadataPlugin({ watch: false, rootDir: root })); await kernel.use(new ObjectQLPlugin({ ql: engine })); - await kernel.use(new AppPlugin(suiteArtifact(withScoring, withRollup), { environmentId: ENVIRONMENT_ID, organizationId: 'org_qa' })); + await kernel.use(new AppPlugin(suiteArtifact(options), { environmentId: ENVIRONMENT_ID, organizationId: 'org_qa' })); const host: Plugin = { name: 'qa.extsuite-host', dependencies: ['com.objectstack.engine.objectql'], @@ -197,10 +288,10 @@ async function dispatchAs(kernel: ObjectKernel, method: string, path: string, bo try { const out: any = await dispatcher.dispatch(method, path, body, query, { request: raw, environmentId: ENVIRONMENT_ID } as any, '/api/v1'); const answer = out.response?.body; - return { status: out.response?.status as number | undefined, code: codeOf(answer), message: messageOf(answer), data: answer?.data }; + return { status: out.response?.status as number | undefined, code: codeOf(answer), message: messageOf(answer), data: answer?.data, details: answer?.error?.details }; } catch (e) { const thrown = resolveThrownHttpError(e); - return { status: thrown.status as number | undefined, code: thrown.code as unknown, message: (e as Error)?.message, data: undefined }; + return { status: thrown.status as number | undefined, code: thrown.code as unknown, message: (e as Error)?.message, data: undefined, details: undefined }; } } @@ -370,7 +461,7 @@ describe('a boot that starts with the module disabled still creates the module\' }, 120_000); }); -describe('an enabled module whose formula and rule read a field a disabled module contributes', () => { +describe('an enabled module whose formula reads a field a disabled module contributes (the disable proceeds)', () => { let root: string; let booted: Booted | undefined; let accountId: string; @@ -381,11 +472,13 @@ describe('an enabled module whose formula and rule read a field a disabled modul root = mkdtempSync(join(tmpdir(), 'os-suite-ext-ref-')); writeFileSync(join(root, '.keep'), ''); process.env.OS_HOME = mkdtempSync(join(home, 'ref-')); - booted = await bootKernel(root, true); + booted = await bootKernel(root, { scoring: 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); expect(await readAccount(booted.kernel, accountId)).toMatchObject({ [CREDIT]: 500, [CREDIT_BAND]: 'high' }); + // [#30379 ruling A] The CONTROL of the dependent-rule refusal: a formula + // over the field is not a dependent, so the disable proceeds. expect((await dispatchAs(booted.kernel, 'PATCH', `/packages/${MODULE}/disable`)).status).toBe(200); }, 60_000); afterAll(async () => { @@ -413,13 +506,9 @@ describe('an enabled module whose formula and rule read a field a disabled modul } }); - it('measures the rule over the withheld field: it cannot be evaluated, so a write is refused 400 VALIDATION_FAILED', async () => { - // The answer a rule reading a field its object does not declare already - // gets (fail closed, never a 5xx). Recorded as measured: the module the - // rule belongs to reads a field of a module it does not depend on. - const updated = await dispatchAs(booted!.kernel, 'PATCH', `/data/${ACCOUNT}/${accountId}`, { name: 'renamed' }); - expect({ status: updated.status, code: updated.code }).toEqual({ status: 400, code: 'VALIDATION_FAILED' }); - expect(String(updated.message)).toContain('qx_credit_not_negative'); + it('lets a write to the object land while the module is disabled: no rule reads the withheld field', async () => { + const updated = await dispatchAs(booted!.kernel, 'PATCH', `/data/${ACCOUNT}/${accountId}`, { name: 'acme' }); + expect(updated.status).toBe(200); }); it('serves the formula over the stored value again once the module is re-enabled', async () => { @@ -436,7 +525,7 @@ describe('[#30377] a disabled module\'s roll-up over a child the enabled app own root = mkdtempSync(join(tmpdir(), 'os-suite-ext-rollup-')); writeFileSync(join(root, '.keep'), ''); process.env.OS_HOME = mkdtempSync(join(home, 'rollup-')); - booted = await bootKernel(root, false, true); + booted = await bootKernel(root, { rollup: true }); }, 60_000); afterAll(async () => { await shutdown(booted); @@ -463,3 +552,298 @@ describe('[#30377] a disabled module\'s roll-up over a child the enabled app own expect(await readAccount(kernel, beta)).toMatchObject({ name: 'beta', [VISIT_TOTAL]: 7 }); }); }); + +describe('[#30379] the disable door refuses while an enabled package\'s validation rule reads a field the package adds', () => { + let root: string; + let booted: Booted | undefined; + let accountId: string; + // Ledgered under `@objectstack/runtime` by #30724; asserted by value. + const CODE = 'PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE'; + /** The listing the refusal's message carries, in its order (package, object, rule). */ + const listingOf = (rules: ReadonlyArray<{ packageId: string; object: string; rule: string; fields: string[] }>) => + rules.map((d) => `${d.packageId} · ${d.object} · ${d.rule} (reads ${d.fields.join(', ')})`).join('; '); + + beforeAll(async () => { + root = mkdtempSync(join(tmpdir(), 'os-suite-ext-dep-')); + writeFileSync(join(root, '.keep'), ''); + process.env.OS_HOME = mkdtempSync(join(home, 'dep-')); + booted = await bootKernel(root, { scoring: true, rules: true }); + const { kernel } = booted; + // The app's `.custom` pair, through its one creation door, and a tenant + // rule saved and published into it as an extension of the app's object. + const pair = await dispatchAs(kernel, 'POST', `/packages/${APP}/ensure-customization`); + expect(pair.status, `precondition: the pair is created (${String(pair.message)})`).toBe(201); + const protocol = kernel.getService('protocol'); + await protocol.saveMetaItem({ + type: 'object_extension', name: ACCOUNT, mode: 'draft', packageId: PAIR, + item: { + extend: ACCOUNT, + validations: [{ + name: RULE_TENANT, type: 'script', severity: 'error', message: 'Credit is capped', + condition: `record.${CREDIT} > 5000000`, + }], + }, + }); + await protocol.publishMetaItem({ type: 'object_extension', name: ACCOUNT, packageId: PAIR }); + const rules = ((booted.engine as any).registry.getObject(ACCOUNT)?.validations ?? []).map((v: any) => v?.name); + expect(rules, 'precondition: the tenant rule is served').toContain(RULE_TENANT); + + const created = await dispatchAs(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('answers 409 with the code, naming every reading rule as package · object · rule, and changes nothing', async () => { + const { kernel, engine } = booted!; + const refused = await dispatchAs(kernel, 'PATCH', `/packages/${MODULE}/disable`); + expect({ status: refused.status, code: refused.code }).toEqual({ status: 409, code: CODE }); + const dependents = [ + { packageId: PAIR, object: ACCOUNT, rule: RULE_TENANT, fields: [CREDIT] }, + { 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}`] }, + ]; + // The message lists exactly these four, in order; the refusal fixes no + // `error.details` shape (a machine-readable one is a spec contract first). + expect(String(refused.message)).toContain(`Package '${MODULE}' cannot be disabled: 4 active validation rules read fields`); + expect(String(refused.message)).toContain(`— ${listingOf(dependents)}. Switch off or change those rules first`); + expect(refused.details).toBeUndefined(); + // A rule switched off reads nothing, and a formula is no dependent. + expect(String(refused.message)).not.toContain(RULE_SWITCHED_OFF); + expect(String(refused.message)).not.toContain(SCORING); + + // Nothing changed: the row, the served object, the data plane and the state file. + expect(engine.registry.getPackage(MODULE)?.enabled).not.toBe(false); + expect(await servedFieldNames(kernel)).toEqual(expect.arrayContaining([...MODULE_FIELDS])); + const written = await dispatchAs(kernel, 'PATCH', `/data/${ACCOUNT}/${accountId}`, { [CREDIT]: 700 }); + expect(written.status).toBe(200); + expect(await readAccount(kernel, accountId)).toMatchObject({ [CREDIT]: 700 }); + expect(loadDisabledPackageIds(ENVIRONMENT_ID).has(MODULE)).toBe(false); + }); + + it('names only the rules of packages still enabled, and proceeds once none reads the field', async () => { + const { kernel } = booted!; + expect((await dispatchAs(kernel, 'PATCH', `/packages/${RULES}/disable`)).status).toBe(200); + const narrowed = await dispatchAs(kernel, 'PATCH', `/packages/${MODULE}/disable`); + expect({ status: narrowed.status, code: narrowed.code }).toEqual({ status: 409, code: CODE }); + expect(String(narrowed.message)).toContain( + `an active validation rule reads a field it adds through objectExtensions, which the disable would withhold — ${listingOf([{ packageId: PAIR, object: ACCOUNT, rule: RULE_TENANT, fields: [CREDIT] }])}. Switch off or change that rule first`, + ); + expect(String(narrowed.message)).not.toContain(RULES); + + expect((await dispatchAs(kernel, 'PATCH', `/packages/${PAIR}/disable`)).status).toBe(200); + const disabled = await dispatchAs(kernel, 'PATCH', `/packages/${MODULE}/disable`); + expect(disabled.status).toBe(200); + const served = await servedFieldNames(kernel); + for (const field of MODULE_FIELDS) expect(served).not.toContain(field); + // The formula module stays enabled throughout: its formula answers null. + expect(await readAccount(kernel, accountId)).toMatchObject({ name: 'Acme', [CREDIT_BAND]: null }); + 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); + 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); + }); +}); + +/** The listing a dependent-rule refusal's message carries, in its order (package, object, rule). */ +const listingOfRules = (rules: ReadonlyArray<{ packageId: string; object: string; rule: string; fields: string[] }>) => + rules.map((d) => `${d.packageId} · ${d.object} · ${d.rule} (reads ${d.fields.join(', ')})`).join('; '); + +describe('[#30379 contract review] the scan judges the rules the served fold serves', () => { + let root: string; + let booted: Booted | undefined; + let accountId: string; + const CODE = 'PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE'; + + /** The app's object as a tenant saved it from a served read: the module's fields ride along, with the overlay's rules. */ + const accountOverlay = (validations: unknown[]) => ({ + name: ACCOUNT, + label: 'Account', + fields: { + name: { name: 'name', type: 'text', label: 'Name' }, + [CREDIT]: { name: CREDIT, type: 'number', label: 'Credit' }, + [ORDER_TOTAL]: { + name: ORDER_TOTAL, type: 'summary', label: 'Order total', + summaryOperations: { object: ORDER, field: 'amount', function: 'sum' }, + }, + }, + validations, + _provenance: 'org', + }); + const visitOverlay = (validations: unknown[]) => ({ + 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, + _provenance: 'org', + }); + /** A served predicate's CEL source (the loader may wrap it as `{ dialect, source }`). */ + const sourceOf = (slot: unknown): unknown => (slot && typeof slot === 'object' ? (slot as { source?: unknown }).source : slot); + /** A tenant copy of a packaged rule: only its sentence and its switch are the tenant's. */ + const switchedOff = (name: string, message: string) => ({ name, type: 'script', message, condition: 'true', active: false }); + + beforeAll(async () => { + root = mkdtempSync(join(tmpdir(), 'os-suite-ext-fold-')); + writeFileSync(join(root, '.keep'), ''); + process.env.OS_HOME = mkdtempSync(join(home, 'fold-')); + booted = await bootKernel(root, { rules: true, override: 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('a rule a later layer replaced by name is judged once, from the copy that wins, and named after that layer', async () => { + const { kernel, engine } = booted!; + const served = ((engine.registry.getObject(ACCOUNT) as any)?.validations ?? []) as Array<{ name?: string; condition?: unknown }>; + expect(served.filter((r) => r.name === RULE_REVIEW), 'precondition: one served copy per name').toHaveLength(1); + expect(sourceOf(served.find((r) => r.name === RULE_NOT_NEGATIVE)?.condition), 'precondition: the override\'s copy wins').toBe("record.name == 'blocked'"); + + const refused = await dispatchAs(kernel, 'PATCH', `/packages/${MODULE}/disable`); + expect({ status: refused.status, code: refused.code }).toEqual({ status: 409, code: CODE }); + // RULES' copy of RULE_NOT_NEGATIVE read the field, but the override's + // copy is the one served, and it does not. RULE_REVIEW is listed once, + // from the override's copy, which still reads it. + expect(String(refused.message)).toContain(`cannot be disabled: 2 active validation rules read fields`); + expect(String(refused.message)).toContain(`— ${listingOfRules([ + { packageId: RULES, object: VISIT, rule: RULE_VISIT_HOP, fields: [`account.${CREDIT}`] }, + { packageId: OVERRIDE, object: ACCOUNT, rule: RULE_REVIEW, fields: [CREDIT] }, + ])}. Switch off or change those rules first`); + expect(String(refused.message)).not.toContain(RULE_NOT_NEGATIVE); + }); + + it('a tenant overlay\'s switch is honoured, its own rule is named after the overlay, and the disable proceeds once every served reader is off', async () => { + const { kernel, engine } = booted!; + // The tenant switches the packaged rule off through the overlay (the + // one switch a tenant admin has on a read-only module), and adds a rule + // of its own that reads the field. + engine.registry.registerObject(accountOverlay([ + switchedOff(RULE_REVIEW, 'Switched off by the tenant'), + { name: RULE_OVERLAY_OWN, type: 'script', severity: 'error', message: 'Credit is capped', condition: `record.${CREDIT} > 9000000` }, + ]) as any, APP, undefined, 'overlay'); + const served = ((engine.registry.getObject(ACCOUNT) as any)?.validations ?? []) as Array<{ name?: string; active?: unknown; condition?: unknown }>; + const review = served.find((r) => r.name === RULE_REVIEW); + expect({ active: review?.active, condition: sourceOf(review?.condition) }, 'precondition: the packaged copy runs, switched off') + .toEqual({ active: false, condition: `record.${CREDIT} > 5000` }); + + const overlayOwn = await dispatchAs(kernel, 'PATCH', `/packages/${MODULE}/disable`); + expect({ status: overlayOwn.status, code: overlayOwn.code }).toEqual({ status: 409, code: CODE }); + expect(String(overlayOwn.message)).toContain(`— ${listingOfRules([ + { packageId: RULES, object: VISIT, rule: RULE_VISIT_HOP, fields: [`account.${CREDIT}`] }, + { packageId: `tenant overlay of ${APP}`, object: ACCOUNT, rule: RULE_OVERLAY_OWN, fields: [CREDIT] }, + ])}. Switch off or change those rules first`); + expect(String(overlayOwn.message)).not.toContain(RULE_REVIEW); + expect(String(overlayOwn.message)).not.toContain('(no package)'); + + // Every served reader switched off through the overlays: the disable proceeds. + engine.registry.registerObject(accountOverlay([switchedOff(RULE_REVIEW, 'Switched off by the tenant')]) as any, APP, undefined, 'overlay'); + engine.registry.registerObject(visitOverlay([switchedOff(RULE_VISIT_HOP, 'Switched off by the tenant')]) as any, APP, undefined, 'overlay'); + const disabled = await dispatchAs(kernel, 'PATCH', `/packages/${MODULE}/disable`); + expect(disabled.status, String(disabled.message)).toBe(200); + const fields = await servedFieldNames(kernel); + for (const field of MODULE_FIELDS) expect(fields, 'the overlay\'s copies of the module\'s fields are withheld too').not.toContain(field); + const written = await dispatchAs(kernel, 'PATCH', `/data/${ACCOUNT}/${accountId}`, { name: 'Acme Ltd' }); + expect(written.status, String(written.message)).toBe(200); + }); +}); + +describe('[#30379 contract review] POST /packages with enableOnInstall: false asks the same scan', () => { + const OWNER = 'com.qa.inst.owner'; + const EXTENDER = 'com.qa.inst.flags'; + const READER = 'com.qa.inst.reader'; + const THING = 'qz_thing'; + const FLAG = 'qz_flag'; + const RULE = 'qz_flag_required'; + const CODE = 'PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE'; + const manifestOf = (id: string) => ({ id, name: id, namespace: 'qz', version: '1.0.0', type: 'module' }); + + let previousHome: string | undefined; + beforeAll(() => { + previousHome = process.env.OS_HOME; + process.env.OS_HOME = mkdtempSync(join(home, 'install-')); + }); + afterAll(() => { + process.env.OS_HOME = previousHome; + }); + + /** + * A registry with the extender's field on the owner's object and (optionally) a rule of another package that + * reads it; the extender is installed and enabled first unless `installed: false` (a fresh install, then). + */ + async function door(withReader: boolean, { installed = true }: { installed?: 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); + if (installed) { + const first = await install({ manifest: manifestOf(EXTENDER) }); + expect(first.response?.status, 'precondition: the extender 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'); + } + return { registry, install }; + } + + it('an overwrite that asks to come back disabled is refused while a rule reads its field, and the package stays enabled', async () => { + const { registry, install } = await door(true); + const result = await install({ manifest: manifestOf(EXTENDER), overwrite: true, enableOnInstall: false }); + expect({ status: result.response?.status, code: result.response?.body?.error?.code }).toEqual({ status: 409, code: CODE }); + expect(String(result.response?.body?.error?.message)).toContain( + `Package '${EXTENDER}' was installed and stays enabled: \`enableOnInstall: false\` cannot disable it: an active validation rule reads a field it adds through objectExtensions, which the disable would withhold — ${READER} · ${THING} · ${RULE} (reads ${FLAG})`, + ); + expect(registry.getPackage(EXTENDER)?.enabled).toBe(true); + expect(loadDisabledPackageIds(undefined).has(EXTENDER)).toBe(false); + expect(Object.keys((registry.getObject(THING) as any)?.fields ?? {})).toContain(FLAG); + }); + + it('[delta review, must-fix 3] a FRESH install with enableOnInstall: false lands disabled as asked, though a rule names its field', async () => { + // No row before the install, so nothing served is withheld: #18877's 「有旗 = 设置」 holds, and the scan is not asked. + const { registry, install } = await door(true, { installed: false }); + expect(registry.getPackage(EXTENDER), 'precondition: no row before the install').toBeUndefined(); + const result = await install({ manifest: manifestOf(EXTENDER), enableOnInstall: false }); + expect(result.response?.status, String(result.response?.body?.error?.message)).toBe(201); + expect(registry.getPackage(EXTENDER)?.enabled).toBe(false); + expect(loadDisabledPackageIds(undefined).has(EXTENDER)).toBe(true); + }); + + it('control: with no rule reading the field the same overwrite installs it disabled', async () => { + const { registry, install } = await door(false); + const result = await install({ manifest: manifestOf(EXTENDER), overwrite: true, enableOnInstall: false }); + expect(result.response?.status).toBe(201); + expect(registry.getPackage(EXTENDER)?.enabled).toBe(false); + expect(loadDisabledPackageIds(undefined).has(EXTENDER)).toBe(true); + }); +}); diff --git a/packages/runtime/src/domains/packages.ts b/packages/runtime/src/domains/packages.ts index c4bcab531c8..b1cec174829 100644 --- a/packages/runtime/src/domains/packages.ts +++ b/packages/runtime/src/domains/packages.ts @@ -172,6 +172,12 @@ import { // withheld: the declaration of that list, and the redactor that decided it. import { PackageExportManifestSchema, type PackageExportManifest } from '@objectstack/spec/api'; import { getMetadataTypeRedactor } from '@objectstack/spec/kernel'; +// [#30379 ruling A] The disable door's dependent-rule scan reads a rule's +// fields with the platform's one member reader, the reader the rule validator +// asks before it refuses a write (`readsRecordColumn`), and judges a hop with +// the spec's one reference arbiter: never a second parser. +import { analyzeRelationshipTraversals } from '@objectstack/formula'; +import { referenceTargetOf } from '@objectstack/spec/data'; /** * [#13598] The `protocol` service slot **as this domain reaches it** — one @@ -783,6 +789,305 @@ function isDisableablePackage(engine: unknown, id: string | undefined): boolean return (id !== undefined && isDisableableAppModule(engine, id)) || isWritablePackage(engine, id); } +/** + * [#30379 ruling A, ADR-0141 Open items 4] The code `PATCH /packages/:id/disable` + * answers, with `409`, while a validation rule of any enabled package (declared + * dependent or not, an app's `.custom` pair included) reads a field the package + * adds through `objectExtensions` ({@link readDisableDependentRules}). Registered + * under `@objectstack/runtime` in `ERROR_CODE_LEDGER` (#30724); asserted by value. + * The refusal carries the code and a message naming every rule, and no + * `error.details` beyond the code: a machine-readable shape for the + * package · object · rule triples would be a `packages/spec` contract first. + * Held as its wire pair, the shape `check:error-status-conformance` reads. + */ +const PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE = { code: 'PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE', status: 409 } as const; + +/** + * One validation rule a disable would leave reading a field it withholds: the + * package that serves the rule, the object the rule is declared on, the rule's + * 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 { + packageId: string; + object: string; + rule: string; + fields: string[]; +} + +/** The fields one validation rule reads: on its own object, and one hop through a reference field. */ +interface RuleFieldReads { + readonly own: Set; + readonly hops: Map>; +} + +/** + * The CEL source of a rule's predicate slot, or `undefined` when it is not CEL: + * the dialect is checked, not assumed (a bare string is CEL by the envelope's + * own default), as the rule validator reads the slot it evaluates. + */ +function celSourceOfRuleSlot(slot: unknown): string | undefined { + if (typeof slot === 'string') return slot.trim() ? slot : undefined; + if (!slot || typeof slot !== 'object') return undefined; + const { dialect, source } = slot as { dialect?: unknown; source?: unknown }; + if (dialect !== undefined && dialect !== 'cel') return undefined; + return typeof source === 'string' && source.trim() ? source : undefined; +} + +/** + * Fold what `rule` reads into `reads`, at any `conditional` depth. + * + * - A predicate (`script` / `cross_field` `condition`, a `conditional`'s + * `when`) is read with `analyzeRelationshipTraversals` (`@objectstack/formula`) + * over both roots the evaluator binds, `record` and `previous`: every member + * of the root is a read of that column (the rule validator's own reading, + * `readsRecordColumn`), and a `record.REF.FIELD` read is also a hop the + * engine hydrates from REF's target. A `previous` hop is never hydrated, so + * it reads only its own column. A source that does not parse reads nothing + * here: such a rule already refuses every write, whatever is enabled. + * - A `state_machine` / `format` / `json_schema` rule reads the one `field` it + * names. A `cross_field` rule's `fields` list is advisory (only its first + * entry labels the violation), so its `condition` is what it reads. + */ +function addRuleFieldReads(rule: unknown, reads: RuleFieldReads, depth: number): void { + if (!rule || typeof rule !== 'object' || depth > 8) return; + const r = rule as { type?: unknown; condition?: unknown; when?: unknown; field?: unknown; then?: unknown; otherwise?: unknown }; + const addPredicate = (slot: unknown): void => { + const source = celSourceOfRuleSlot(slot); + if (!source) return; + for (const root of ['record', 'previous'] as const) { + const analysis = analyzeRelationshipTraversals(source, root); + if (!analysis) continue; + for (const field of analysis.bareFields) reads.own.add(field); + for (const field of analysis.multiHopFields) reads.own.add(field); + for (const [field, related] of analysis.traversals) { + reads.own.add(field); + if (root !== 'record') continue; + let hop = reads.hops.get(field); + if (!hop) reads.hops.set(field, (hop = new Set())); + for (const name of related) hop.add(name); + } + } + }; + if (r.type === 'script' || r.type === 'cross_field') addPredicate(r.condition); + if ((r.type === 'state_machine' || r.type === 'format' || r.type === 'json_schema') && typeof r.field === 'string') { + reads.own.add(r.field); + } + if (r.type === 'conditional') { + addPredicate(r.when); + addRuleFieldReads(r.then, reads, depth + 1); + addRuleFieldReads(r.otherwise, reads, depth + 1); + } +} + +/** The field names a contribution body declares (`fields` keys holding a definition). */ +function declaredFieldNamesOf(body: unknown): string[] { + const fields = (body as { fields?: unknown } | null | undefined)?.fields; + if (!fields || typeof fields !== 'object' || Array.isArray(fields)) return []; + return Object.keys(fields).filter((name) => { + const def = (fields as Record)[name]; + return !!def && typeof def === 'object' && !Array.isArray(def); + }); +} + +/** The validation rules a contribution body declares. */ +function declaredRulesOf(body: unknown): Array<{ name?: unknown; active?: unknown }> { + const rules = (body as { validations?: unknown } | null | undefined)?.validations; + return Array.isArray(rules) ? rules.filter((rule) => !!rule && typeof rule === 'object') : []; +} + +/** + * [#30379 ruling A, ADR-0141 Open items 4] The validation rules that would read + * a field the disable of `packageId` withholds, while the package that serves + * each rule stays enabled: any enabled package, declared dependent or not (no + * `manifest.dependencies` entry is read), an app's `.custom` pair included. + * `PATCH /packages/:id/disable` refuses while this is + * not empty: a disabled package's `objectExtensions` fields are withheld from + * the served object (`SchemaRegistry.resolveObject`), and a rule that reads a + * field its object no longer serves cannot be evaluated, so every write to that + * object would be refused for as long as the package stays off. + * + * ## What the disable withholds + * + * Per object the package extends: the fields its `extend` contributions add + * that neither the owner's own declaration nor an extension that stays served + * also declares. The registry's reading of a withheld member + * (`withholdDisabledExtensions`), asked of the state the disable would leave: + * the package's own extensions and every already-disabled package's leave the + * fold; everything else stays. + * + * ## Which rules are judged + * + * The rules the served object would carry once the disable lands, of every + * severity, on every object whose owner is enabled and is not the package + * itself (a disabled owner's object is refused whole, so no write reaches its + * rules), read off the registry's own fold + * (`getAllObjects(undefined, { withoutExtensionsOf })`), never re-assembled + * here: + * + * - ONE rule per name, the copy the fold serves: a later layer's rule replaces + * an earlier one of its name (`mergeValidationsByName`); + * - with a tenant overlay's `message` and `active` on it (ADR-0029 D9.2a): a + * packaged rule the tenant switched off through the overlay is off; + * - from the base layer (the tenant overlay when one is registered, the owner + * otherwise) and each `extend` contribution of any enabled package other + * than this one, declared dependent or not, an app's `.custom` pair + * (where tenant-written rules live) included; less the rules the disable + * withholds with the fields, which it also takes off a base that carries a + * copy of them. + * + * Each rule is named after the LAST layer in fold order that declares its name + * (the layer its text comes from; a tenant overlay by that name). A rule + * switched off (`active: false`) reads nothing: switching the rule off is one + * of the remedies the refusal names. Formula fields are not judged: a formula + * over a withheld field answers `null`, never a refused write. + * + * A rule reads a withheld field when it names it on its own object, or names + * it one hop through a reference field whose target is an object the package + * extends ({@link addRuleFieldReads}). + * + * Ordered by package, object and rule. Empty for a package already disabled + * (the disable withholds nothing more) and for one that extends nothing. + */ +function readDisableDependentRules(registry: any, packageId: string): DisableDependentRule[] { + if (typeof registry?.getObjectContributors !== 'function' || typeof registry?.getAllObjects !== 'function') return []; + const disabled = (id: unknown): boolean => + typeof registry.isPackageDisabled === 'function' && registry.isPackageDisabled(id); + if (disabled(packageId)) return []; + + type Contributor = { packageId?: unknown; ownership?: unknown; definition?: unknown }; + const leaves = (c: Contributor): boolean => + c.ownership === 'extend' && (c.packageId === packageId || disabled(c.packageId)); + + const names: string[] = []; + for (const object of registry.getAllObjects() as Array<{ name?: unknown }>) { + if (typeof object?.name === 'string') names.push(object.name); + } + + // Object → the fields the disable withholds from it; and the rules the fold + // takes off its base layer once the disable lands, on every object. + const withheldFields = new Map>(); + const withheldRules = new Map>(); + for (const name of names) { + const contributors = registry.getObjectContributors(name) as Contributor[]; + const own = contributors.filter((c) => c.ownership === 'extend' && c.packageId === packageId); + const kept = contributors.filter((c) => !leaves(c)); + const keptFields = new Set(); + const keptRules = new Set(); + for (const c of kept) { + if (c.ownership !== 'own' && c.ownership !== 'extend') continue; + for (const field of declaredFieldNamesOf(c.definition)) keptFields.add(field); + for (const rule of declaredRulesOf(c.definition)) if (typeof rule.name === 'string') keptRules.add(rule.name); + } + // The fields THIS disable withholds: the package's own additions. A field + // only an already-disabled package adds is withheld already, whatever + // this disable does. + const fields = new Set(); + for (const c of own) { + for (const field of declaredFieldNamesOf(c.definition)) if (!keptFields.has(field)) fields.add(field); + } + // The rules the fold would withhold once the disable lands — every + // leaving extension's, as the fold takes them off a base that carries them. + const rules = new Set(); + for (const c of contributors.filter(leaves)) { + for (const rule of declaredRulesOf(c.definition)) { + if (typeof rule.name === 'string' && !keptRules.has(rule.name)) rules.add(rule.name); + } + } + if (fields.size > 0) withheldFields.set(name, fields); + if (rules.size > 0) withheldRules.set(name, rules); + } + if (withheldFields.size === 0) return []; + + // [#30379 contract review, must-fix 1] Every object as the served fold will + // serve it once the disable lands: `getAllObjects`' own fold with this + // package's `extend` contributions left out (an already-disabled package's + // are withheld on every fold), so a rule is judged ONCE per name, from the + // copy that wins (`mergeValidationsByName`), carrying the tenant overlay's + // `message` and `active` (`tenantRuleSettingsOf` / `withPackagedOwnerRules`). + // The one step a left-out fold does not take, a disable does: a base that + // carries a copy of a rule only the leaving extensions add has it taken off + // (`withoutWithheldMembers`), so those names are dropped below. + const after = new Map; validations?: unknown }>(); + for (const object of registry.getAllObjects(undefined, { withoutExtensionsOf: packageId }) as Array<{ name?: unknown }>) { + if (typeof object?.name === 'string') after.set(object.name, object as { fields?: Record; validations?: unknown }); + } + + const dependents: DisableDependentRule[] = []; + for (const name of names) { + const contributors = registry.getObjectContributors(name) as Contributor[]; + const owner = contributors.find((c) => c.ownership === 'own'); + if (!owner || owner.packageId === packageId || disabled(owner.packageId)) continue; + const served = after.get(name); + if (!served) continue; + const base = contributors.find((c) => c.ownership === 'overlay') ?? owner; + const servedFields = served.fields ?? {}; + const ownWithheld = withheldFields.get(name); + const rulesWithheld = withheldRules.get(name) ?? new Set(); + + // The layer each served rule's text comes from: the LAST layer, in fold + // order (the base, then each kept `extend` contribution), that declares + // a rule of its name — the copy `mergeValidationsByName` serves. + const layerOf = new Map(); + for (const layer of [base, ...contributors.filter((c) => c.ownership === 'extend' && !leaves(c))]) { + for (const rule of declaredRulesOf(layer.definition)) { + if (typeof rule.name === 'string') layerOf.set(rule.name, layer); + } + } + + for (const rule of declaredRulesOf(served)) { + if (rule.active === false || typeof rule.name !== 'string' || rulesWithheld.has(rule.name)) continue; + const reads: RuleFieldReads = { own: new Set(), hops: new Map() }; + addRuleFieldReads(rule, reads, 0); + const hit: string[] = []; + if (ownWithheld) for (const field of reads.own) if (ownWithheld.has(field)) hit.push(field); + for (const [ref, related] of reads.hops) { + const target = referenceTargetOf(servedFields[ref]); + const targetWithheld = target === undefined ? undefined : withheldFields.get(target); + if (!targetWithheld) continue; + for (const field of related) if (targetWithheld.has(field)) hit.push(`${ref}.${field}`); + } + if (hit.length === 0) continue; + dependents.push({ + packageId: ruleLayerLabel(layerOf.get(rule.name) ?? base), + object: name, + rule: rule.name, + fields: [...new Set(hit)].sort(), + }); + } + } + return dependents.sort((a, b) => a.packageId.localeCompare(b.packageId) + || a.object.localeCompare(b.object) + || a.rule.localeCompare(b.rule)); +} + +/** + * The name a served rule's layer goes by in the refusal: its package, or, for + * a tenant overlay (whose `packageId` is the provenance it was saved under, when + * there is one), the overlay itself — never a placeholder. + */ +function ruleLayerLabel(layer: { packageId?: unknown; ownership?: unknown }): string { + const pkg = typeof layer.packageId === 'string' && layer.packageId !== '' ? layer.packageId : undefined; + if (layer.ownership === 'overlay') return pkg === undefined ? 'tenant overlay' : `tenant overlay of ${pkg}`; + return pkg ?? (layer.ownership === 'own' ? 'unpackaged object' : 'unpackaged extension'); +} + +/** + * The sentence a dependent-rule refusal carries: `lead` (what was refused), + * then every rule as `package · object · rule (reads FIELD, …)`, then the remedy. + */ +function disableDependentRulesMessage(lead: string, dependents: readonly DisableDependentRule[]): string { + const one = dependents.length === 1; + const listed = dependents + .map((d) => `${d.packageId} · ${d.object} · ${d.rule} (reads ${d.fields.join(', ')})`) + .join('; '); + return `${lead}: ${one + ? 'an active validation rule reads a field' + : `${dependents.length} active validation rules read fields`} it adds through objectExtensions, ` + + `which the disable would withhold — ${listed}. Switch off or change ${one ? 'that rule' : 'those rules'} first, ` + + 'or keep the package enabled.'; +} + /** * [#14451] `POST /packages/:id/duplicate` — the SOURCE must be a BASE. * @@ -1757,6 +2062,13 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin // mode is stored as sent — unchanged by this gate, and not a class // this door answers for.) const settings = request?.settings; + // [#30379 delta review, must-fix 3] Whether this install OVERWRITES a + // row that is enabled right now, read before either limb writes. Only + // then can `enableOnInstall: false` withhold a field a rule is served + // against; a fresh install (or a reinstall after a delete) and an + // overwrite of a disabled row withhold nothing served, so they honour + // `false` exactly as #18877 rules. + const overwritesEnabledRow = (registry.getPackage(pkgId) as { enabled?: unknown } | undefined)?.enabled === true; let pkg: any; const protocolSvc = await resolveProtocol(deps, _context).catch(() => null); try { @@ -1869,13 +2181,35 @@ 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; if (requestedEnabled === true) { const enabled = registry.enablePackage(pkgId); if (enabled) pkg = enabled; await summaryRefreshSettled(qlService); } else if (requestedEnabled === false) { - const disabled = registry.disablePackage(pkgId); - if (disabled) pkg = disabled; + // [#30379 ruling A, contract review should-fix] The same refusal + // as `PATCH /packages/:id/disable`, asked of the state the + // install just left: an overwrite of an enabled package that + // asks to come back disabled would withhold its extension + // fields from rules that stay active, and every write to their + // objects would be refused. The install itself has landed, so + // the package stays ENABLED (the row, the registry and the + // state file below all follow `pkg.enabled`) and the door + // answers the refusal instead of a 201 that claims a disable. + // [delta review, must-fix 3] Asked ONLY on an overwrite of a row + // that was enabled before this install: a fresh install, a + // reinstall after a delete and an overwrite of a disabled row + // withhold nothing served, and land disabled as asked. + const dependents = overwritesEnabledRow ? readDisableDependentRules(registry, pkgId) : []; + if (dependents.length > 0) { + installDisableRefusal = disableDependentRulesMessage( + `Package '${pkgId}' was installed and stays enabled: \`enableOnInstall: false\` cannot disable it`, + dependents, + ); + } else { + const disabled = registry.disablePackage(pkgId); + if (disabled) pkg = disabled; + } } // ⭐ The DURABLE half, and it follows THE ROW THIS DOOR RETURNED — // never the request's intent. Written unconditionally rather than @@ -1923,6 +2257,16 @@ 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) { + return { + handled: true, + response: deps.error( + installDisableRefusal, + PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE.status, + { code: PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE.code }, + ), + }; + } const res = deps.success(pkg); res.status = 201; return { handled: true, response: res }; @@ -1970,6 +2314,27 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin ? null : requireWritablePackage(deps, qlService, id, 'disable'); if (readOnly) return readOnly; + // [#30379 ruling A, ADR-0141 Open items 4] An enabled dependent + // refuses the disable: a validation rule of any enabled package + // (declared dependent or not, an app's `.custom` pair included) + // that reads a field this package adds through `objectExtensions` + // would refuse every write to its object once the field is + // withheld. Asked after the read-only check and BEFORE + // `disablePackage` and `setPackageDisabled`, so a refused disable + // leaves the row, the served objects and the environment's state + // file exactly as they were. The message names every rule; the + // refusal carries no `error.details` beyond the code. + const dependents = readDisableDependentRules(registry, id); + if (dependents.length > 0) { + return { + handled: true, + response: deps.error( + disableDependentRulesMessage(`Package '${id}' cannot be disabled`, dependents), + PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE.status, + { code: PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE.code }, + ), + }; + } const pkg = registry.disablePackage(id); if (!pkg) return { handled: true, response: deps.error(`Package '${id}' not found`, 404) }; try {