Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/28448-save-door-validation-rule-copy.md
Original file line number Diff line number Diff line change
@@ -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

<!-- 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. -->

**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.
41 changes: 40 additions & 1 deletion packages/metadata-protocol/src/protocol-authoring-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<AuthoringGateHost, 'collectBatchPendingDeclarations' | 'credentialChannelFor' | 'getOverlayRepo' | 'objectNamespacePrefixViolation'>,
Expand Down
99 changes: 99 additions & 0 deletions packages/metadata-protocol/src/runtime-authoring-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
// ─────────────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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),
];
Expand Down Expand Up @@ -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()) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading
Loading