diff --git a/.changeset/28448-save-door-validation-rule-copy.md b/.changeset/28448-save-door-validation-rule-copy.md new file mode 100644 index 00000000000..bac1df94877 --- /dev/null +++ b/.changeset/28448-save-door-validation-rule-copy.md @@ -0,0 +1,17 @@ +--- +'@objectstack/metadata-protocol': minor +--- + +feat(metadata-protocol)!: the save door refuses a tenant's copy of a packaged validation rule that changes the rule's condition, type or any other key the package owns (#28448) + +Clause-②: no (narrowing) — an `object` publish that carries a same-named copy of a validation rule a code layer ships (the packaged owner's or an extension's), and changes anything but the copy's `message` and `active`, is now refused with `422 INVALID_METADATA`. The same publish was stored before, and the fold ran the package's rule instead of the copy's. +Metadata-protocol: narrowed + + + +**BREAKING** for a tenant save that changes a packaged validation rule (ADR-0029 D9.2a, 2026-10-30 note; ruling of record 6524229156, letter C′, and ruling of record 6545641964, letter B, which puts this refusal at the save door). + +- **Before.** The registry fold ran the package's rule with the tenant copy's `message` and `active`, and said once at load (`validation_rule_copy_changed`) that the copy's condition does not run. The save that stored the copy answered success, so the admin who changed the condition was never told. +- **Now.** The runtime authoring gate asks the registry's own predicate, `SchemaRegistry.tenantRuleCopyRefusal`, the one the load report reads. A publish of a copy that changes a package-owned key is refused with one issue, rule `validation-rule-copy-changed`, located at the copy's first changed key (for example `validations[1].condition`), with the prescription: "To change the condition, add a rule under a new name, or change it in the package." Nothing is written. +- **Still saved.** A copy that changes only `message` (a reword) or `active` (`active: false` switches the rule off). A rule under a new name, which stays additive. A draft save, which no gate rule refuses; publishing it is judged. +- **Who sees it.** An admin who edits a packaged or extension rule's condition or type in Studio, or an author doing the same through the metadata API of a runtime: the save now answers with the refusal instead of storing a copy that never runs. To keep a different condition, add it as a rule under a new name. diff --git a/packages/metadata-protocol/src/protocol-authoring-gate.ts b/packages/metadata-protocol/src/protocol-authoring-gate.ts index fedb2bf0851..21d745fc184 100644 --- a/packages/metadata-protocol/src/protocol-authoring-gate.ts +++ b/packages/metadata-protocol/src/protocol-authoring-gate.ts @@ -50,7 +50,7 @@ import type { ObjectStackProtocolImplementation } from './protocol.js'; import { permissionSetOwnerBindingRefusal, runtimeHookWithoutBodyRefusal } from './protocol.js'; import { datasourceCredentialsRefSaveRefusal } from './protocol-datasource-credentials-ref.js'; import { evaluateRuntimeAuthoringGate } from './runtime-authoring-gate.js'; -import type { RuntimeAuthoringIssue, RuntimeAuthoringVerdict, RuntimePendingDeclarations } from './runtime-authoring-gate.js'; +import type { PackagedRuleCopyRefusal, RuntimeAuthoringIssue, RuntimeAuthoringVerdict, RuntimePendingDeclarations } from './runtime-authoring-gate.js'; import { savedItemNameRefusal } from '@objectstack/metadata/view-container-name'; import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL } from '@objectstack/spec/shared'; import type { IObjectQLEngine } from '@objectstack/spec/contracts'; @@ -435,6 +435,13 @@ export async function judgeRuntimeAuthoringRules( // read here per publish and passed in so the gate stays pure. const hierarchyResolverMounted = this.hierarchyResolverMounted(); + // [#28448] The registry's verdict on an object body's copies of packaged + // validation rules — a host fact of the same kind, read here per publish + // and passed in so the gate stays pure. + const packagedRuleCopyRefusal = singular === 'object' + ? packagedRuleCopyRefusalOf(this.engine, evt.name, evt.body) + : undefined; + const verdict = evaluateRuntimeAuthoringGate({ type: singular, name: evt.name, @@ -463,10 +470,42 @@ export async function judgeRuntimeAuthoringRules( ...(rlsMembershipResolverKeys !== undefined ? { rlsMembershipResolverKeys } : {}), // [#26188] With it the gate refuses a hierarchy scope no resolver enforces. ...(hierarchyResolverMounted !== undefined ? { hierarchyResolverMounted } : {}), + // [#28448] With it the gate refuses a rule copy that changes what the package owns. + ...(packagedRuleCopyRefusal !== undefined ? { packagedRuleCopyRefusal } : {}), }); return verdict; } +/** + * [#28448 · ADR-0029 D9.2a, 2026-10-30 note; rulings of record 6524229156 + * (letter C′) and 6545641964 (letter B)] Ask the live registry whether a tenant + * `object` body carries a copy of a packaged validation rule that changes what + * the package owns — `SchemaRegistry.tenantRuleCopyRefusal`, the one predicate + * the registry's load report reads too, so the save door and the load judge + * one way. Its refusal is reduced to the fields the gate locates it with. + * + * `undefined` when the engine's registry carries no such predicate (a + * metadata-only host, a test double): nothing is judged, as on the CLI. + */ +function packagedRuleCopyRefusalOf( + engine: { registry?: unknown } | undefined, + name: string, + body: unknown, +): PackagedRuleCopyRefusal | null | undefined { + const registry = engine?.registry as { tenantRuleCopyRefusal?: unknown } | null | undefined; + if (!registry || typeof registry.tenantRuleCopyRefusal !== 'function') return undefined; + const refusal = (registry.tenantRuleCopyRefusal as (name: string, body: unknown) => unknown) + .call(registry, name, body) as { ruleName?: unknown; changedKeys?: unknown; message?: unknown } | null; + if (!refusal) return null; + return { + ruleName: typeof refusal.ruleName === 'string' ? refusal.ruleName : '', + changedKeys: Array.isArray(refusal.changedKeys) + ? refusal.changedKeys.filter((key): key is string => typeof key === 'string') + : [], + message: typeof refusal.message === 'string' ? refusal.message : String(refusal), + }; +} + /** The body of `ObjectStackProtocolImplementation#draftPublishVerdict`; the method's docblock there is its contract. */ export async function draftPublishVerdict( this: RuntimeRulesHost & Pick, diff --git a/packages/metadata-protocol/src/runtime-authoring-gate.ts b/packages/metadata-protocol/src/runtime-authoring-gate.ts index 8c15a32ae61..0692a49cc9b 100644 --- a/packages/metadata-protocol/src/runtime-authoring-gate.ts +++ b/packages/metadata-protocol/src/runtime-authoring-gate.ts @@ -453,6 +453,84 @@ export function findPublicFormIntakeGaps(args: { return issues; } +// ───────────────────────────────────────────────────────────────────────────── +// #28448 — a tenant's copy of a packaged validation rule that changes what the +// package owns. +// ───────────────────────────────────────────────────────────────────────────── + +/** + * The rule id of the finding below. Refused on publish, inside the gate's one + * 422 `INVALID_METADATA` envelope. + * + * [ADR-0029 D9.2a, 2026-10-30 note; ruling of record 6524229156, letter C′; + * ruling of record 6545641964, letter B] A validation rule's identity within an + * object is its `name`. A tenant's copy of a rule a code layer ships (the + * packaged owner's or an extension's) may change its `message` and `active`; + * its condition, its `type` and every other key belong to the package. The + * registry fold already runs the package's rule and reports such a copy at load + * (`validation_rule_copy_changed`), never refusing it there, so an upgrade never + * fails on stored data. This is the other half: the save door refuses the copy + * when the admin writes it, with the prescription. + * + * ## Why it is gate-local + * + * Its input is a fact about the DEPLOYMENT: which rules the code packages + * registered here ship, by name. A build machine cannot know what a tenant's + * copy is a copy of, so `AUTHORING_RULES` must not judge it — the reason the + * #6285 and #26188 rules live here. The judgement itself is the registry's + * (`SchemaRegistry.tenantRuleCopyRefusal`), the same predicate its load report + * reads, so the save and the load judge one way; the host asks it and hands + * the verdict in ({@link PackagedRuleCopyRefusal}), and this function stays + * pure. + */ +export const VALIDATION_RULE_COPY_CHANGED = 'validation-rule-copy-changed'; + +/** The prescription ruling C′ names, word for word. */ +const VALIDATION_RULE_COPY_PRESCRIPTION = + 'To change the condition, add a rule under a new name, or change it in the package.'; + +/** + * The registry's verdict on a tenant `object` body's rule copies, as the host + * reads it from `SchemaRegistry.tenantRuleCopyRefusal`: the first copy that + * changes a key the package owns, the keys it changes (`type` first) and the + * registry's sentence. `null` when every copy changes at most `message` and + * `active`, or no code layer ships a rule of any name the body carries. + */ +export interface PackagedRuleCopyRefusal { + ruleName: string; + changedKeys: readonly string[]; + message: string; +} + +/** + * Judge one about-to-be-published `object` body against the registry's verdict + * on its rule copies: one `error` finding when the verdict refuses, located at + * the copy's first changed key. + * + * PURE — the verdict arrives as an argument. Absent (a host whose engine has no + * registry predicate, such as the CLI) nothing is judged. + */ +export function findPackagedRuleCopyGaps(args: { + type: string; + name: string; + body: unknown; + packagedRuleCopyRefusal?: PackagedRuleCopyRefusal | null; +}): RuntimeAuthoringIssue[] { + if (args.type !== 'object' || !args.packagedRuleCopyRefusal) return []; + const { ruleName, changedKeys, message } = args.packagedRuleCopyRefusal; + const rules = isRec(args.body) && Array.isArray(args.body.validations) ? args.body.validations : []; + const index = rules.findIndex((rule) => isRec(rule) && rule.name === ruleName); + const rulePath = index >= 0 ? `validations[${index}]` : 'validations'; + return [{ + severity: 'error', + rule: VALIDATION_RULE_COPY_CHANGED, + where: `object "${args.name}" · validation rule "${ruleName}"`, + path: changedKeys.length > 0 && index >= 0 ? `${rulePath}.${changedKeys[0]}` : rulePath, + message, + hint: VALIDATION_RULE_COPY_PRESCRIPTION, + }]; +} + // ───────────────────────────────────────────────────────────────────────────── // #26188 — a HIERARCHY scope on a runtime with no hierarchy resolver. // ───────────────────────────────────────────────────────────────────────────── @@ -1118,6 +1196,15 @@ export function evaluateRuntimeAuthoringGate(args: { * service registry), nothing is judged, as on the CLI. */ hierarchyResolverMounted?: boolean; + /** + * [#28448] The registry's verdict on an `object` body's copies of packaged + * validation rules ({@link PackagedRuleCopyRefusal}). A fact only the host + * holds, of the #6285 kind: gathered by the impure caller and passed in so + * this function stays pure. A verdict arms {@link findPackagedRuleCopyGaps}; + * `null` is a clean verdict; absent (a host whose engine has no registry + * predicate), nothing is judged. + */ + packagedRuleCopyRefusal?: PackagedRuleCopyRefusal | null; }): RuntimeAuthoringVerdict { // [#26188] The one finding a draft hears: a hierarchy scope this runtime // cannot enforce. Same rule as the publish refusal below, `warning` here. @@ -1204,8 +1291,19 @@ export function evaluateRuntimeAuthoringGate(args: { ? { tenancyPostureInForce: args.tenancyPostureInForce } : {}), }); + // [#28448] The gate-local refusal of a tenant's rule copy that changes what + // the package owns — the registry's verdict, located on the body. + const packagedRuleCopyGaps = findPackagedRuleCopyGaps({ + type: args.type, + name: args.name, + body: args.body, + ...(args.packagedRuleCopyRefusal !== undefined + ? { packagedRuleCopyRefusal: args.packagedRuleCopyRefusal } + : {}), + }); const localIssues = [ ...scheduleOrgGaps, + ...packagedRuleCopyGaps, ...hierarchyScopeGaps, ...(pageSourceFindings ?? []).filter((f) => f.severity === 'error').map(toIssue), ]; @@ -1272,6 +1370,7 @@ export function evaluateRuntimeAuthoringGate(args: { ? [HIERARCHY_SCOPE_CAPABILITY_REQUIRED] : []), ...(pageSourceFindings !== null ? [HTML_PAGE_SOURCE_COMPILE, PAGE_REQUIRES_DISAGREES_WITH_SOURCE] : []), + ...(args.type === 'object' && args.packagedRuleCopyRefusal !== undefined ? [VALIDATION_RULE_COPY_CHANGED] : []), ]; if (unlintedWritesAllowed()) { diff --git a/packages/metadata-protocol/src/runtime-authoring-gate.validation-rule-copy.test.ts b/packages/metadata-protocol/src/runtime-authoring-gate.validation-rule-copy.test.ts new file mode 100644 index 00000000000..95f6f3cf256 --- /dev/null +++ b/packages/metadata-protocol/src/runtime-authoring-gate.validation-rule-copy.test.ts @@ -0,0 +1,71 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#28448] The gate-local `validation-rule-copy-changed` rule, on the pure gate: + * the registry's verdict on a tenant object's copies of packaged validation + * rules arrives as a host fact (`packagedRuleCopyRefusal`) and is folded into + * the gate's one 422 envelope, located on the body. The end-to-end pins (a real + * `SchemaRegistry` behind a real save door) live in `@objectstack/objectql`'s + * `protocol-validation-rule-copy-save-refusal.test.ts`, the one package that + * has both. + */ + +import { describe, expect, it } from 'vitest'; +import { evaluateRuntimeAuthoringGate, VALIDATION_RULE_COPY_CHANGED } from './runtime-authoring-gate.js'; + +const PRESCRIPTION = 'To change the condition, add a rule under a new name, or change it in the package.'; + +const body = { + name: 'vr_duty', + label: 'Duty', + sharingModel: 'private', + fields: { status: { name: 'status', label: 'Status', type: 'text' } }, + validations: [ + { type: 'script', name: 'own_rule', message: 'Own.', condition: 'false' }, + { type: 'script', name: 'ext_rule', message: 'Ext.', condition: "record.status == 'x'" }, + ], +}; + +const refusal = { + ruleName: 'ext_rule', + changedKeys: ['condition'], + message: `Object "vr_duty": validation rule "ext_rule" is a packaged rule. ${PRESCRIPTION}`, +}; + +describe('[#28448] the gate folds the registry\'s rule-copy verdict into its one refusal', () => { + it('a refusing verdict is one located error, and the rule is disclosed as run', () => { + const verdict = evaluateRuntimeAuthoringGate({ + type: 'object', name: 'vr_duty', state: 'active', body, packagedRuleCopyRefusal: refusal, + }); + expect(verdict.error).toMatchObject({ code: 'INVALID_METADATA', status: 422 }); + const err = verdict.error as Error & { issues: any[]; rulesRun: string[] }; + expect(err.issues).toEqual([{ + severity: 'error', + rule: VALIDATION_RULE_COPY_CHANGED, + where: 'object "vr_duty" · validation rule "ext_rule"', + path: 'validations[1].condition', + message: refusal.message, + hint: PRESCRIPTION, + }]); + expect(err.rulesRun).toContain(VALIDATION_RULE_COPY_CHANGED); + }); + + it('a clean verdict refuses nothing', () => { + const verdict = evaluateRuntimeAuthoringGate({ + type: 'object', name: 'vr_duty', state: 'active', body, packagedRuleCopyRefusal: null, + }); + expect(verdict.error).toBeNull(); + }); + + it('no verdict (a host without the registry predicate) judges nothing', () => { + const verdict = evaluateRuntimeAuthoringGate({ type: 'object', name: 'vr_duty', state: 'active', body }); + expect(verdict.error).toBeNull(); + }); + + it('a draft is not gated (D1), whatever the verdict', () => { + const verdict = evaluateRuntimeAuthoringGate({ + type: 'object', name: 'vr_duty', state: 'draft', body, packagedRuleCopyRefusal: refusal, + }); + expect(verdict.error).toBeNull(); + }); +}); diff --git a/packages/objectql/src/protocol-validation-rule-copy-save-refusal.test.ts b/packages/objectql/src/protocol-validation-rule-copy-save-refusal.test.ts new file mode 100644 index 00000000000..594fc9e9e73 --- /dev/null +++ b/packages/objectql/src/protocol-validation-rule-copy-save-refusal.test.ts @@ -0,0 +1,231 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#28448] The save door refuses a tenant's copy of a packaged validation rule + * that changes what the package owns — ADR-0029 D9.2a, 2026-10-30 note; ruling + * of record 6524229156 (letter C′: `message` and `active` are the tenant's, + * everything else is the package's) and ruling of record 6545641964 (letter B: + * the loud refusal sits at the save door, the load path only reports). + * + * The shape that reaches it: a tenant's own object, which a code package extends + * with a rule through `objectExtensions`. The Studio GET serves the folded + * object, so the PUT that follows carries a copy of the extension's rule. A + * reworded or switched-off copy is the tenant's to save; a copy that changes the + * condition or the type is refused with the ruling's prescription, and nothing + * is written. + * + * Both halves are real: the `SchemaRegistry` whose `tenantRuleCopyRefusal` the + * load report also reads, and the `ObjectStackProtocolImplementation` save + * door, whose runtime authoring gate asks it. It lives in this package for the + * reason its `meta-object-*-roundtrip` siblings do: `@objectstack/objectql` + * depends on `@objectstack/metadata-protocol`, never the reverse. + */ + +import { describe, it, expect } from 'vitest'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +// [#5619] The producer's OWN write-verb dispatch decisions, so the fake engine +// below cannot accept a call ObjectQL refuses. +import { assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objectstack/metadata-core'; +import { SchemaRegistry } from './registry.js'; +import { assertEngineFindOnePredicate } from './engine-findone-predicate.js'; + +interface Row { + id: string; + type: string; + name: string; + organization_id: string | null; + package_id: string | null; + state: string; + metadata: string; +} + +const OBJ = 'vr_duty'; +const EXT_PKG = 'com.objectstack.test.vr-extension'; +const BLOCKED = "record.status == 'blocked' && record.note == null"; +const PRESCRIPTION = 'To change the condition, add a rule under a new name, or change it in the package.'; + +/** The tenant's own object, as first saved. */ +const AUTHORED = { + name: OBJ, + label: 'Duty', + // [#8310] The runtime object door requires an authored OWD. + sharingModel: 'private', + fields: { + status: { name: 'status', label: 'Status', type: 'text' }, + note: { name: 'note', label: 'Note', type: 'text' }, + }, +}; + +/** The rule a code package contributes through `objectExtensions`. */ +const extRule = () => ({ + type: 'script', + name: 'ext_blocked_needs_reason', + message: 'Say what is blocking the duty.', + condition: BLOCKED, +}); + +const clone = (v: T): T => JSON.parse(JSON.stringify(v)) as T; + +function matches(r: Row, where: Record): boolean { + for (const [k, v] of Object.entries(where)) { + if (v === undefined) continue; + if ((r as any)[k] !== v) return false; + } + return true; +} + +function keyOf(w: Record) { + return `${w.type}|${w.name}|${w.organization_id ?? '__env__'}|${w.state ?? 'active'}|${w.package_id ?? '__nopkg__'}`; +} + +/** A `sys_metadata` store over a REAL {@link SchemaRegistry}. */ +function makeHost() { + const registry = new SchemaRegistry({ multiTenant: false, searchCompanion: false } as never); + const rows = new Map(); + let nextId = 0; + const findRow = (w: Record) => { + for (const [k, r] of rows) if (matches(r, w)) return { key: k, row: r }; + return null; + }; + const engine: any = { + registry, + async findOne(_t: string, o: { where: Record }) { + assertEngineFindOnePredicate(_t, o); + return findRow(o.where)?.row ?? null; + }, + async find(_t: string, o: { where: Record; limit?: number }) { + const found = Array.from(rows.values()).filter((r) => matches(r, o.where)); + return typeof o?.limit === 'number' ? found.slice(0, o.limit) : found; + }, + async insert(table: string, data: Record) { + if (table !== 'sys_metadata') return { id: 'side_table' }; + const row = { id: `r_${++nextId}`, ...(data as any) } as Row; + rows.set(keyOf(data), row); + return { id: row.id }; + }, + async update(table: string, data: Record, o: { where: Record }) { + assertEngineUpdateDispatch(data, o); + if (table !== 'sys_metadata') return { id: null }; + const found = findRow(o.where); + if (!found) return { id: null }; + const merged = { ...found.row, ...(data as any) }; + rows.delete(found.key); + rows.set(keyOf(merged), merged); + return { id: found.row.id }; + }, + async delete(_t: string, o?: Record) { + assertEngineDeleteDispatch(o); + return { deleted: 0 }; + }, + async transaction(cb: (c: any, i: { owned: boolean }) => Promise): Promise { + return cb(undefined, { owned: true }); + }, + async syncObjectSchema() { /* no DDL in this stub */ }, + async count() { return 0; }, + async aggregate() { return []; }, + }; + const protocol = new ObjectStackProtocolImplementation(engine as never, () => new Map() as never); + const storedBody = () => { + const r = Array.from(rows.values()).find((row) => row.name === OBJ && row.type === 'object'); + return r ? (JSON.parse(r.metadata) as Record) : undefined; + }; + return { protocol, registry, storedBody }; +} + +/** The tenant's object saved, then extended by a code package with one rule. */ +async function seeded() { + const host = makeHost(); + await host.protocol.saveMetaItem({ type: 'object', name: OBJ, item: clone(AUTHORED) } as never); + host.registry.registerObject({ name: OBJ, validations: [extRule()] } as never, EXT_PKG, undefined, 'extend'); + return host; +} + +/** + * The served object, as Studio holds it before the PUT: a copy, as the wire + * gives it. An in-process read hands back the registry's own rule objects, so + * editing it in place would edit the extension's rule too. + */ +async function served(host: { protocol: ObjectStackProtocolImplementation }) { + return clone((await host.protocol.getMetaItem({ type: 'object', name: OBJ } as never)).item) as any; +} + +/** The served object with the extension's rule copy edited by `edit`. */ +async function withCopy(host: { protocol: ObjectStackProtocolImplementation }, edit: (rule: any) => void) { + const item = await served(host); + const copy = (item.validations as any[]).find((r) => r.name === 'ext_blocked_needs_reason'); + expect(copy, 'the served object carries the extension rule').toBeDefined(); + edit(copy); + return item; +} + +async function thrownBy(p: Promise): Promise { + try { + await p; + } catch (e) { + return e; + } + throw new Error('expected the save to be refused'); +} + +describe('[#28448] the save door judges a tenant copy of a packaged validation rule', () => { + it('a copy that changes the condition is refused with the ruling\'s prescription, and nothing is written', async () => { + const host = await seeded(); + const before = host.storedBody(); + const item = await withCopy(host, (rule) => { rule.condition = "record.status == 'blocked'"; }); + const err = await thrownBy(host.protocol.saveMetaItem({ type: 'object', name: OBJ, item } as never)); + expect(err).toMatchObject({ code: 'INVALID_METADATA', status: 422 }); + const index = (item.validations as any[]).findIndex((r) => r.name === 'ext_blocked_needs_reason'); + expect(err.issues).toEqual([expect.objectContaining({ + rule: 'validation-rule-copy-changed', + severity: 'error', + path: `validations[${index}].condition`, + hint: PRESCRIPTION, + })]); + expect(err.issues[0].message).toContain('"ext_blocked_needs_reason"'); + expect(err.issues[0].message).toContain(PRESCRIPTION); + expect(err.rulesRun).toContain('validation-rule-copy-changed'); + expect(host.storedBody()).toEqual(before); + }); + + it('a copy that changes the type is refused, `type` named', async () => { + const host = await seeded(); + // `cross_field` shares `script`'s condition and requires `fields`, so the + // copy parses: what refuses it is the package owning the type. + const item = await withCopy(host, (rule) => { rule.type = 'cross_field'; rule.fields = ['note']; }); + const err = await thrownBy(host.protocol.saveMetaItem({ type: 'object', name: OBJ, item } as never)); + expect(err).toMatchObject({ code: 'INVALID_METADATA', status: 422 }); + expect(err.issues).toEqual([expect.objectContaining({ rule: 'validation-rule-copy-changed' })]); + expect(err.issues[0].path).toMatch(/^validations\[\d+\]\.type$/); + expect(err.issues[0].message).toContain('`type`'); + }); + + it('a reworded copy saves, and the tenant\'s sentence is stored', async () => { + const host = await seeded(); + const item = await withCopy(host, (rule) => { rule.message = 'Tell us what blocks this duty.'; }); + const result = await host.protocol.saveMetaItem({ type: 'object', name: OBJ, item } as never); + expect(result.success).toBe(true); + const stored = (host.storedBody()!.validations as any[]).find((r) => r.name === 'ext_blocked_needs_reason'); + expect(stored.message).toBe('Tell us what blocks this duty.'); + }); + + it('a copy switched off with `active: false` saves', async () => { + const host = await seeded(); + const item = await withCopy(host, (rule) => { rule.active = false; }); + const result = await host.protocol.saveMetaItem({ type: 'object', name: OBJ, item } as never); + expect(result.success).toBe(true); + const stored = (host.storedBody()!.validations as any[]).find((r) => r.name === 'ext_blocked_needs_reason'); + expect(stored.active).toBe(false); + }); + + it('a rule under a new name saves, even with the changed condition', async () => { + const host = await seeded(); + const item = await served(host); + item.validations = [ + ...(item.validations as any[]), + { ...extRule(), name: 'tenant_blocked_needs_reason', condition: "record.status == 'blocked'" }, + ]; + const result = await host.protocol.saveMetaItem({ type: 'object', name: OBJ, item } as never); + expect(result.success).toBe(true); + expect((host.storedBody()!.validations as any[]).map((r) => r.name)).toContain('tenant_blocked_needs_reason'); + }); +}); diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 7c773a384d2..fcb3c93755e 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -2786,6 +2786,21 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/objectql/src/protocol-validation-rule-copy-save-refusal.test.ts", + "verb": "delete", + "pinned": 1 + }, + { + "file": "packages/objectql/src/protocol-validation-rule-copy-save-refusal.test.ts", + "verb": "findOne", + "pinned": 1 + }, + { + "file": "packages/objectql/src/protocol-validation-rule-copy-save-refusal.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/objectql/src/protocol-view-identity-overlay.test.ts", "verb": "delete",