From 90ddb98c05def01d05180977e5314beabddd4735 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 20:49:21 +0000 Subject: [PATCH 1/3] wip(metadata-protocol): the save door asks the registry's rule-copy predicate Claude-Session: https://claude.ai/code/session_01L9qibx3A7AeHSnSr39oxaV Co-authored-by: Claude --- .../src/protocol-authoring-gate.ts | 41 +++- .../src/runtime-authoring-gate.ts | 99 ++++++++ ...-validation-rule-copy-save-refusal.test.ts | 225 ++++++++++++++++++ 3 files changed, 364 insertions(+), 1 deletion(-) create mode 100644 packages/objectql/src/protocol-validation-rule-copy-save-refusal.test.ts 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/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..d5f86ef05af --- /dev/null +++ b/packages/objectql/src/protocol-validation-rule-copy-save-refusal.test.ts @@ -0,0 +1,225 @@ +// 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, + fields: ['note'], +}); + +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 }) { + return Array.from(rows.values()).filter((r) => matches(r, o.where)); + }, + 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. */ +async function served(host: { protocol: ObjectStackProtocolImplementation }) { + return (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(); + const item = await withCopy(host, (rule) => { rule.type = 'cross_field'; }); + 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'); + }); +}); From f78357758b37c026ef68795a3ee7e16b49e70ca8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 20:55:19 +0000 Subject: [PATCH 2/3] feat(metadata-protocol)!: the save door refuses a tenant's copy of a packaged validation rule that changes what the package owns The runtime authoring gate asks the registry's own predicate, SchemaRegistry.tenantRuleCopyRefusal, the one the load report reads, and folds its verdict into the gate's one 422 as the gate-local rule validation-rule-copy-changed, located at the copy's first changed key, with the ruling's prescription. A reworded or switched-off copy and a rule under a new name still save. Claude-Session: https://claude.ai/code/session_01L9qibx3A7AeHSnSr39oxaV Co-authored-by: Claude --- .../28448-save-door-validation-rule-copy.md | 17 +++++ ...uthoring-gate.validation-rule-copy.test.ts | 71 +++++++++++++++++++ ...-validation-rule-copy-save-refusal.test.ts | 13 ++-- 3 files changed, 97 insertions(+), 4 deletions(-) create mode 100644 .changeset/28448-save-door-validation-rule-copy.md create mode 100644 packages/metadata-protocol/src/runtime-authoring-gate.validation-rule-copy.test.ts 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/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 index d5f86ef05af..d0222b6d004 100644 --- a/packages/objectql/src/protocol-validation-rule-copy-save-refusal.test.ts +++ b/packages/objectql/src/protocol-validation-rule-copy-save-refusal.test.ts @@ -62,7 +62,6 @@ const extRule = () => ({ name: 'ext_blocked_needs_reason', message: 'Say what is blocking the duty.', condition: BLOCKED, - fields: ['note'], }); const clone = (v: T): T => JSON.parse(JSON.stringify(v)) as T; @@ -140,9 +139,13 @@ async function seeded() { return host; } -/** The served object, as Studio holds it before the PUT. */ +/** + * 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 (await host.protocol.getMetaItem({ type: 'object', name: OBJ } as never)).item as any; + 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`. */ @@ -185,7 +188,9 @@ describe('[#28448] the save door judges a tenant copy of a packaged validation r it('a copy that changes the type is refused, `type` named', async () => { const host = await seeded(); - const item = await withCopy(host, (rule) => { rule.type = 'cross_field'; }); + // `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' })]); From 5b370a280976465a0b2087338d42c618854bb698 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 21:26:28 +0000 Subject: [PATCH 3/3] test(objectql): the rule-copy save-door double holds the caller's find bound, and the engine-double ledger records it Claude-Session: https://claude.ai/code/session_01L9qibx3A7AeHSnSr39oxaV Co-authored-by: Claude --- ...ocol-validation-rule-copy-save-refusal.test.ts | 5 +++-- scripts/engine-double-contract.pinned.json | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) 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 index d0222b6d004..594fc9e9e73 100644 --- a/packages/objectql/src/protocol-validation-rule-copy-save-refusal.test.ts +++ b/packages/objectql/src/protocol-validation-rule-copy-save-refusal.test.ts @@ -93,8 +93,9 @@ function makeHost() { assertEngineFindOnePredicate(_t, o); return findRow(o.where)?.row ?? null; }, - async find(_t: string, o: { where: Record }) { - return Array.from(rows.values()).filter((r) => matches(r, o.where)); + 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' }; 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",