Skip to content

Commit f5e6953

Browse files
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 (#28478)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6JaoSvkSSxNZcoCwT3AFsNcUvy)_ Fixes #28448 Clause-②: no (narrowing) Metadata-protocol: narrowed Ruling-ref: 6545641964 (letter B: the loud refusal sits at the save door), under 6524229156 (letter C′: `message` and `active` are the tenant's, everything else is the package's). The load half landed in #28445 (`50015d84`). ## 维护者速读 - 改了什么:保存 `object` 元数据时,若租户覆盖层里有一条与包内(或扩展包)校验规则同名的副本,且改了 `message`、`active` 以外的任何键(条件、类型等),保存门现在用 `422 INVALID_METADATA` 拒绝,并给出裁定的处方:"To change the condition, add a rule under a new name, or change it in the package."。判定直接调用 #28445 留下的 `SchemaRegistry.tenantRuleCopyRefusal`,所以加载报告和保存拒绝是同一个判断。 - 为什么改:#28445 之后,这样的副本在加载时只记日志、照包的规则执行;保存却返回成功,改条件的管理员永远不知道自己的条件不生效。裁定 B 要求在保存门上明确拒绝。 - 风险与回滚:以前能保存的请求现在会被拒(收窄,需合约复审)。只改提示语、`active: false`、新名字的规则照常保存;草稿保存不受影响(发布时才判定);已存数据照旧加载,不需迁移。回滚即 revert 本 PR。 - 先看:`packages/metadata-protocol/src/runtime-authoring-gate.ts` 的 `findPackagedRuleCopyGaps`,以及 `protocol-authoring-gate.ts` 的 `packagedRuleCopyRefusalOf`。`protocol.ts` 未改动(#28451 占用中)。 - 两分钟验证:`pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2 src/protocol-validation-rule-copy-save-refusal.test.ts`,预期 5 个用例全绿(需先构建 `@objectstack/metadata-protocol`)。 - 需要你做的:无,合约复审由席位安排;本 PR 不涉及 Tier H 路径。 ## What changes **Where the refusal sits.** The save door's own refusal set (`protocol-save-door-refusals.ts`) is reached only through calls written in `protocol.ts`, and seat 2's #28451 holds that file. So the refusal joins the **runtime authoring gate**, which `protocol.ts` already calls on every door this needs: the active save, publish, batch publish, the draft-publish verdict and the restore door. No `protocol.ts` line changes. **The host fact** (`packages/metadata-protocol/src/protocol-authoring-gate.ts`). `judgeRuntimeAuthoringRules` gathers host facts impurely and hands them to the pure gate. For an `object` it now also asks `packagedRuleCopyRefusalOf(engine, name, body)`, which calls the registry's `tenantRuleCopyRefusal(name, body)` when the engine's registry has one and reduces the answer to `{ ruleName, changedKeys, message }`. A host whose registry has no such predicate (a test double, a non-ObjectQL engine) answers `undefined`, and the rule is not run. This keeps metadata-protocol free of an objectql import, and the judgement is the registry's own, the same one the load report reads. **The gate rule** (`packages/metadata-protocol/src/runtime-authoring-gate.ts`). A new gate-local rule, `validation-rule-copy-changed` (`VALIDATION_RULE_COPY_CHANGED`), with `findPackagedRuleCopyGaps`. A refusing verdict becomes one error issue that is: - located at the copy's first changed key, for example `validations[1].condition`, or `validations[1].type` (the predicate lists `type` first); - worded with the registry's message, which names the object, the rule and the changed keys; - hinted with the prescription, "To change the condition, add a rule under a new name, or change it in the package." It folds into the gate's one `422 INVALID_METADATA` envelope with `issues[]`, and `rulesRun` names the rule whenever the host answered (clean or not). Nothing is written on a refusal. **What still saves:** a copy that changes only `message` (a reword) or only `active` (`active: false` switches the rule off), a rule under a new name, and a draft save (D1: drafts are not gated; publishing the draft is). **Changeset** `.changeset/28448-save-door-validation-rule-copy.md`: `@objectstack/metadata-protocol` `minor` (ruling A: `Metadata-protocol: narrowed` moves at least one package to `minor`), with the BREAKING paragraph and the ADR-0087 `not-required (no-migration-prescription)` disposition. No key of any schema changes, and stored rows keep loading. ## Tests - New `packages/metadata-protocol/src/runtime-authoring-gate.validation-rule-copy.test.ts` (4 cases, pure gate): a refusing verdict is one located issue with the hint and the rule in `rulesRun`; a clean verdict refuses nothing; no verdict judges nothing; a draft is not gated. - New `packages/objectql/src/protocol-validation-rule-copy-save-refusal.test.ts` (5 cases, a real `SchemaRegistry` behind the real `ObjectStackProtocolImplementation` save door): the tenant's own object, extended by a code package with a rule through `registerObject(..., 'extend')`, then PUT back with an edited copy of the served body. - A condition change is refused: 422, the issue at `validations[i].condition` with the prescription, the stored body unchanged. - A type change (`script` to `cross_field`, which still parses) is refused, `type` named. - A reword saves, and the tenant's sentence is stored. - `active: false` saves. - A rule under a new name saves, even with the changed condition. - It lives in objectql, like its `meta-object-*-roundtrip` siblings, because objectql depends on metadata-protocol and never the reverse. - `scripts/engine-double-contract.pinned.json` records the new test's engine double (`--write`, 3 rows added, 0 lost). - Sibling suites: `pnpm --filter @objectstack/metadata-protocol exec vitest run`: Test Files 287 passed / 4 skipped, Tests 21879 passed / 26 skipped. `@objectstack/metadata-protocol` and `@objectstack/objectql` typecheck: exit 0. <details><summary>4 ablations, one per refusal leg (each through scripts/ablation-replace.mjs, restored, final tree clean)</summary> Run: `pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2 src/protocol-validation-rule-copy-save-refusal.test.ts` (5 cases), under `os-verify-lock`. The objectql test reads metadata-protocol from its dist, so B1 and B2 rebuilt it and proved the marker with `scripts/ablation-dist-preflight.mjs` (present after mutate, absent after restore and rebuild). | # | mutation | red | |:--|:--|:--| | B1 | `packagedRuleCopyRefusalOf` never asks the registry (`protocol-authoring-gate.ts`, dist marker `ablation-b1`) | 2 failed / 5 (condition, type) | | B2 | `findPackagedRuleCopyGaps` drops the finding (`runtime-authoring-gate.ts`, dist marker `ablation-b2`) | 2 failed / 5 (condition, type) | | B3 | `type` not package-owned in the registry predicate (`registry.ts`, source) | 1 failed / 5 (type) | | B4 | `condition` not package-owned in the registry predicate (`registry.ts`, source) | 1 failed / 5 (condition) | The three saving pins stay green under every mutation, so they pin the allowed side rather than the wiring. </details> ## Acceptance notes - `protocol.ts` is untouched (serial constraint, #28451). The dispatch named the save-door refusal set; reaching it needs a `protocol.ts` call, so the runtime authoring gate, already called from every door, carries the rule instead. - The predicate refuses on any package-owned key, not only condition or type. That is C′ ("everything else is the package's"); the card's condition and type are the minimum, and both are pinned. - A draft save is not judged (D1, as for every gate-local rule); publishing it is. The `package-author` channel returns before the gate, as it does for every gate rule, so a package author's own save is not judged here. - `OS_ALLOW_UNLINTED_METADATA_WRITES=1`, the gate's migration hatch, turns this refusal into its loud log like every other gate rule. A copy saved under it still runs the package's rule at load and is reported there (#28445). - An in-process `getMetaItem` returns the registry's own rule objects; the save-door test clones the served body before editing it (a wire client always holds a copy). ## Verification <details><summary>Derived gates and dogfood</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on `5b370a2809` derived 69 commands over 6 paths (474 changed lines). Each ran with stdin from `/dev/null`, its exit captured by redirect before any pipe. All 69 exit 0. `dispatch-gates --ran`: `69 derived famil(ies) accounted for — 69 run, 0 NOT-MEASURED`, exit 0. On the previous head, `check:engine-double-contract` and `check:objectql-double-limit` were red on the new test's engine double (an unrecorded ledger row; a `find` that ignored the caller's `limit`). The fix is the last commit: the double applies the bound after the filter, and the ledger records it. Scoped lint, using the root `lint` script's command over `packages/metadata-protocol` and the new objectql test: exit 0. Full dogfood (owed by the `(narrowing)` arm), on `5b370a2809` after a dts build of `@objectstack/metadata-protocol`, under `os-verify-lock`: `NODE_OPTIONS=--max-old-space-size=4096 pnpm --filter @objectstack/dogfood test`: Test Files 362 passed / 3 skipped (365), Tests 2922 passed / 28 skipped (2950), exit 0. </details> No Tier H path (`check-governed-merges --test` exits 0 on every changed path). It stays a draft until the contract review is on record; the seat lands it. --- _Generated by [Claude Code](https://claude.ai/code/session_01L9qibx3A7AeHSnSr39oxaV)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ee29ac3 commit f5e6953

6 files changed

Lines changed: 473 additions & 1 deletion
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/metadata-protocol': minor
3+
---
4+
5+
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)
6+
7+
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.
8+
Metadata-protocol: narrowed
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped, and no stored row needs rewriting: a copy stored before this change keeps loading, reported once at load, and the package's rule runs for it, as the registry fold already did. Only a new write of such a copy is refused, and its refusal carries the prescription. -->
11+
12+
**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).
13+
14+
- **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.
15+
- **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.
16+
- **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.
17+
- **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.

‎packages/metadata-protocol/src/protocol-authoring-gate.ts‎

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ import type { ObjectStackProtocolImplementation } from './protocol.js';
5050
import { permissionSetOwnerBindingRefusal, runtimeHookWithoutBodyRefusal } from './protocol.js';
5151
import { datasourceCredentialsRefSaveRefusal } from './protocol-datasource-credentials-ref.js';
5252
import { evaluateRuntimeAuthoringGate } from './runtime-authoring-gate.js';
53-
import type { RuntimeAuthoringIssue, RuntimeAuthoringVerdict, RuntimePendingDeclarations } from './runtime-authoring-gate.js';
53+
import type { PackagedRuleCopyRefusal, RuntimeAuthoringIssue, RuntimeAuthoringVerdict, RuntimePendingDeclarations } from './runtime-authoring-gate.js';
5454
import { savedItemNameRefusal } from '@objectstack/metadata/view-container-name';
5555
import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL } from '@objectstack/spec/shared';
5656
import type { IObjectQLEngine } from '@objectstack/spec/contracts';
@@ -435,6 +435,13 @@ export async function judgeRuntimeAuthoringRules(
435435
// read here per publish and passed in so the gate stays pure.
436436
const hierarchyResolverMounted = this.hierarchyResolverMounted();
437437

438+
// [#28448] The registry's verdict on an object body's copies of packaged
439+
// validation rules — a host fact of the same kind, read here per publish
440+
// and passed in so the gate stays pure.
441+
const packagedRuleCopyRefusal = singular === 'object'
442+
? packagedRuleCopyRefusalOf(this.engine, evt.name, evt.body)
443+
: undefined;
444+
438445
const verdict = evaluateRuntimeAuthoringGate({
439446
type: singular,
440447
name: evt.name,
@@ -463,10 +470,42 @@ export async function judgeRuntimeAuthoringRules(
463470
...(rlsMembershipResolverKeys !== undefined ? { rlsMembershipResolverKeys } : {}),
464471
// [#26188] With it the gate refuses a hierarchy scope no resolver enforces.
465472
...(hierarchyResolverMounted !== undefined ? { hierarchyResolverMounted } : {}),
473+
// [#28448] With it the gate refuses a rule copy that changes what the package owns.
474+
...(packagedRuleCopyRefusal !== undefined ? { packagedRuleCopyRefusal } : {}),
466475
});
467476
return verdict;
468477
}
469478

479+
/**
480+
* [#28448 · ADR-0029 D9.2a, 2026-10-30 note; rulings of record 6524229156
481+
* (letter C′) and 6545641964 (letter B)] Ask the live registry whether a tenant
482+
* `object` body carries a copy of a packaged validation rule that changes what
483+
* the package owns — `SchemaRegistry.tenantRuleCopyRefusal`, the one predicate
484+
* the registry's load report reads too, so the save door and the load judge
485+
* one way. Its refusal is reduced to the fields the gate locates it with.
486+
*
487+
* `undefined` when the engine's registry carries no such predicate (a
488+
* metadata-only host, a test double): nothing is judged, as on the CLI.
489+
*/
490+
function packagedRuleCopyRefusalOf(
491+
engine: { registry?: unknown } | undefined,
492+
name: string,
493+
body: unknown,
494+
): PackagedRuleCopyRefusal | null | undefined {
495+
const registry = engine?.registry as { tenantRuleCopyRefusal?: unknown } | null | undefined;
496+
if (!registry || typeof registry.tenantRuleCopyRefusal !== 'function') return undefined;
497+
const refusal = (registry.tenantRuleCopyRefusal as (name: string, body: unknown) => unknown)
498+
.call(registry, name, body) as { ruleName?: unknown; changedKeys?: unknown; message?: unknown } | null;
499+
if (!refusal) return null;
500+
return {
501+
ruleName: typeof refusal.ruleName === 'string' ? refusal.ruleName : '',
502+
changedKeys: Array.isArray(refusal.changedKeys)
503+
? refusal.changedKeys.filter((key): key is string => typeof key === 'string')
504+
: [],
505+
message: typeof refusal.message === 'string' ? refusal.message : String(refusal),
506+
};
507+
}
508+
470509
/** The body of `ObjectStackProtocolImplementation#draftPublishVerdict`; the method's docblock there is its contract. */
471510
export async function draftPublishVerdict(
472511
this: RuntimeRulesHost & Pick<AuthoringGateHost, 'collectBatchPendingDeclarations' | 'credentialChannelFor' | 'getOverlayRepo' | 'objectNamespacePrefixViolation'>,

‎packages/metadata-protocol/src/runtime-authoring-gate.ts‎

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -453,6 +453,84 @@ export function findPublicFormIntakeGaps(args: {
453453
return issues;
454454
}
455455

456+
// ─────────────────────────────────────────────────────────────────────────────
457+
// #28448 — a tenant's copy of a packaged validation rule that changes what the
458+
// package owns.
459+
// ─────────────────────────────────────────────────────────────────────────────
460+
461+
/**
462+
* The rule id of the finding below. Refused on publish, inside the gate's one
463+
* 422 `INVALID_METADATA` envelope.
464+
*
465+
* [ADR-0029 D9.2a, 2026-10-30 note; ruling of record 6524229156, letter C′;
466+
* ruling of record 6545641964, letter B] A validation rule's identity within an
467+
* object is its `name`. A tenant's copy of a rule a code layer ships (the
468+
* packaged owner's or an extension's) may change its `message` and `active`;
469+
* its condition, its `type` and every other key belong to the package. The
470+
* registry fold already runs the package's rule and reports such a copy at load
471+
* (`validation_rule_copy_changed`), never refusing it there, so an upgrade never
472+
* fails on stored data. This is the other half: the save door refuses the copy
473+
* when the admin writes it, with the prescription.
474+
*
475+
* ## Why it is gate-local
476+
*
477+
* Its input is a fact about the DEPLOYMENT: which rules the code packages
478+
* registered here ship, by name. A build machine cannot know what a tenant's
479+
* copy is a copy of, so `AUTHORING_RULES` must not judge it — the reason the
480+
* #6285 and #26188 rules live here. The judgement itself is the registry's
481+
* (`SchemaRegistry.tenantRuleCopyRefusal`), the same predicate its load report
482+
* reads, so the save and the load judge one way; the host asks it and hands
483+
* the verdict in ({@link PackagedRuleCopyRefusal}), and this function stays
484+
* pure.
485+
*/
486+
export const VALIDATION_RULE_COPY_CHANGED = 'validation-rule-copy-changed';
487+
488+
/** The prescription ruling C′ names, word for word. */
489+
const VALIDATION_RULE_COPY_PRESCRIPTION =
490+
'To change the condition, add a rule under a new name, or change it in the package.';
491+
492+
/**
493+
* The registry's verdict on a tenant `object` body's rule copies, as the host
494+
* reads it from `SchemaRegistry.tenantRuleCopyRefusal`: the first copy that
495+
* changes a key the package owns, the keys it changes (`type` first) and the
496+
* registry's sentence. `null` when every copy changes at most `message` and
497+
* `active`, or no code layer ships a rule of any name the body carries.
498+
*/
499+
export interface PackagedRuleCopyRefusal {
500+
ruleName: string;
501+
changedKeys: readonly string[];
502+
message: string;
503+
}
504+
505+
/**
506+
* Judge one about-to-be-published `object` body against the registry's verdict
507+
* on its rule copies: one `error` finding when the verdict refuses, located at
508+
* the copy's first changed key.
509+
*
510+
* PURE — the verdict arrives as an argument. Absent (a host whose engine has no
511+
* registry predicate, such as the CLI) nothing is judged.
512+
*/
513+
export function findPackagedRuleCopyGaps(args: {
514+
type: string;
515+
name: string;
516+
body: unknown;
517+
packagedRuleCopyRefusal?: PackagedRuleCopyRefusal | null;
518+
}): RuntimeAuthoringIssue[] {
519+
if (args.type !== 'object' || !args.packagedRuleCopyRefusal) return [];
520+
const { ruleName, changedKeys, message } = args.packagedRuleCopyRefusal;
521+
const rules = isRec(args.body) && Array.isArray(args.body.validations) ? args.body.validations : [];
522+
const index = rules.findIndex((rule) => isRec(rule) && rule.name === ruleName);
523+
const rulePath = index >= 0 ? `validations[${index}]` : 'validations';
524+
return [{
525+
severity: 'error',
526+
rule: VALIDATION_RULE_COPY_CHANGED,
527+
where: `object "${args.name}" · validation rule "${ruleName}"`,
528+
path: changedKeys.length > 0 && index >= 0 ? `${rulePath}.${changedKeys[0]}` : rulePath,
529+
message,
530+
hint: VALIDATION_RULE_COPY_PRESCRIPTION,
531+
}];
532+
}
533+
456534
// ─────────────────────────────────────────────────────────────────────────────
457535
// #26188 — a HIERARCHY scope on a runtime with no hierarchy resolver.
458536
// ─────────────────────────────────────────────────────────────────────────────
@@ -1118,6 +1196,15 @@ export function evaluateRuntimeAuthoringGate(args: {
11181196
* service registry), nothing is judged, as on the CLI.
11191197
*/
11201198
hierarchyResolverMounted?: boolean;
1199+
/**
1200+
* [#28448] The registry's verdict on an `object` body's copies of packaged
1201+
* validation rules ({@link PackagedRuleCopyRefusal}). A fact only the host
1202+
* holds, of the #6285 kind: gathered by the impure caller and passed in so
1203+
* this function stays pure. A verdict arms {@link findPackagedRuleCopyGaps};
1204+
* `null` is a clean verdict; absent (a host whose engine has no registry
1205+
* predicate), nothing is judged.
1206+
*/
1207+
packagedRuleCopyRefusal?: PackagedRuleCopyRefusal | null;
11211208
}): RuntimeAuthoringVerdict {
11221209
// [#26188] The one finding a draft hears: a hierarchy scope this runtime
11231210
// cannot enforce. Same rule as the publish refusal below, `warning` here.
@@ -1204,8 +1291,19 @@ export function evaluateRuntimeAuthoringGate(args: {
12041291
? { tenancyPostureInForce: args.tenancyPostureInForce }
12051292
: {}),
12061293
});
1294+
// [#28448] The gate-local refusal of a tenant's rule copy that changes what
1295+
// the package owns — the registry's verdict, located on the body.
1296+
const packagedRuleCopyGaps = findPackagedRuleCopyGaps({
1297+
type: args.type,
1298+
name: args.name,
1299+
body: args.body,
1300+
...(args.packagedRuleCopyRefusal !== undefined
1301+
? { packagedRuleCopyRefusal: args.packagedRuleCopyRefusal }
1302+
: {}),
1303+
});
12071304
const localIssues = [
12081305
...scheduleOrgGaps,
1306+
...packagedRuleCopyGaps,
12091307
...hierarchyScopeGaps,
12101308
...(pageSourceFindings ?? []).filter((f) => f.severity === 'error').map(toIssue),
12111309
];
@@ -1272,6 +1370,7 @@ export function evaluateRuntimeAuthoringGate(args: {
12721370
? [HIERARCHY_SCOPE_CAPABILITY_REQUIRED]
12731371
: []),
12741372
...(pageSourceFindings !== null ? [HTML_PAGE_SOURCE_COMPILE, PAGE_REQUIRES_DISAGREES_WITH_SOURCE] : []),
1373+
...(args.type === 'object' && args.packagedRuleCopyRefusal !== undefined ? [VALIDATION_RULE_COPY_CHANGED] : []),
12751374
];
12761375

12771376
if (unlintedWritesAllowed()) {
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#28448] The gate-local `validation-rule-copy-changed` rule, on the pure gate:
5+
* the registry's verdict on a tenant object's copies of packaged validation
6+
* rules arrives as a host fact (`packagedRuleCopyRefusal`) and is folded into
7+
* the gate's one 422 envelope, located on the body. The end-to-end pins (a real
8+
* `SchemaRegistry` behind a real save door) live in `@objectstack/objectql`'s
9+
* `protocol-validation-rule-copy-save-refusal.test.ts`, the one package that
10+
* has both.
11+
*/
12+
13+
import { describe, expect, it } from 'vitest';
14+
import { evaluateRuntimeAuthoringGate, VALIDATION_RULE_COPY_CHANGED } from './runtime-authoring-gate.js';
15+
16+
const PRESCRIPTION = 'To change the condition, add a rule under a new name, or change it in the package.';
17+
18+
const body = {
19+
name: 'vr_duty',
20+
label: 'Duty',
21+
sharingModel: 'private',
22+
fields: { status: { name: 'status', label: 'Status', type: 'text' } },
23+
validations: [
24+
{ type: 'script', name: 'own_rule', message: 'Own.', condition: 'false' },
25+
{ type: 'script', name: 'ext_rule', message: 'Ext.', condition: "record.status == 'x'" },
26+
],
27+
};
28+
29+
const refusal = {
30+
ruleName: 'ext_rule',
31+
changedKeys: ['condition'],
32+
message: `Object "vr_duty": validation rule "ext_rule" is a packaged rule. ${PRESCRIPTION}`,
33+
};
34+
35+
describe('[#28448] the gate folds the registry\'s rule-copy verdict into its one refusal', () => {
36+
it('a refusing verdict is one located error, and the rule is disclosed as run', () => {
37+
const verdict = evaluateRuntimeAuthoringGate({
38+
type: 'object', name: 'vr_duty', state: 'active', body, packagedRuleCopyRefusal: refusal,
39+
});
40+
expect(verdict.error).toMatchObject({ code: 'INVALID_METADATA', status: 422 });
41+
const err = verdict.error as Error & { issues: any[]; rulesRun: string[] };
42+
expect(err.issues).toEqual([{
43+
severity: 'error',
44+
rule: VALIDATION_RULE_COPY_CHANGED,
45+
where: 'object "vr_duty" · validation rule "ext_rule"',
46+
path: 'validations[1].condition',
47+
message: refusal.message,
48+
hint: PRESCRIPTION,
49+
}]);
50+
expect(err.rulesRun).toContain(VALIDATION_RULE_COPY_CHANGED);
51+
});
52+
53+
it('a clean verdict refuses nothing', () => {
54+
const verdict = evaluateRuntimeAuthoringGate({
55+
type: 'object', name: 'vr_duty', state: 'active', body, packagedRuleCopyRefusal: null,
56+
});
57+
expect(verdict.error).toBeNull();
58+
});
59+
60+
it('no verdict (a host without the registry predicate) judges nothing', () => {
61+
const verdict = evaluateRuntimeAuthoringGate({ type: 'object', name: 'vr_duty', state: 'active', body });
62+
expect(verdict.error).toBeNull();
63+
});
64+
65+
it('a draft is not gated (D1), whatever the verdict', () => {
66+
const verdict = evaluateRuntimeAuthoringGate({
67+
type: 'object', name: 'vr_duty', state: 'draft', body, packagedRuleCopyRefusal: refusal,
68+
});
69+
expect(verdict.error).toBeNull();
70+
});
71+
});

0 commit comments

Comments
 (0)