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
24 changes: 24 additions & 0 deletions .changeset/33154-object-extension-validation-rules-judged.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
'@objectstack/lint': minor
'@objectstack/spec': minor
---

fix(lint): `os build` and the runtime `object_extension` publish door judge each validation rule an object extension adds by the object's own rule pass, against the extended object as served

Clause-②: no (narrowing) — `os build`, `os validate`, `os lint` and the `object_extension` publish door (Studio, `PUT /api/v1/meta/object_extension/TARGET`, the MCP and AI authoring doors) now refuse an extension whose validation rule carries a `condition` or `when` the same rule on the object itself is refused for. No exported type or signature changes. Measured first-party: 0 extension entries carry a validation rule (`examples/app-showcase` one entry, three plain fields; `packages/plugins/plugin-auth` one, a `secret` field; HotCRM none), so nothing first-party is newly refused.
Metadata-protocol: narrowed
Skills-docs: none owed — this PR edits `content/docs/deployment/validating-metadata.mdx` (the object extension door paragraph names the rule pass); `skills/objectstack-data` teaches validation rules on the object and `defineObjectExtension({ extend, fields })` without saying an extension's rules are exempt, so the build and the door now give the verdict the object's own rules already get.

<!-- adr-0087: registered object-extension-validation-rule-expression-judged -->

**BREAKING** accept-set narrowing of `os build`, `os validate`, `os lint` and the runtime `object_extension` publish door.

**Before:** the registry merges an extension's `validations` onto its target object by name, so every save of the target runs them, but the expression rule judged only the rules an object declares. A rule an extension added with a misspelt field (`record.nmae`), an unregistered function (`sqrt(…)`) or an unguarded nullable operand was accepted by `os build`, `os validate` and `os lint` and published with a 200, and failed only when an end user saved a record.

**Now:** each rule an `objectExtensions` entry adds is judged by the object's own rule pass (its `condition` and `when`, every nested `then` / `otherwise` branch, the `parent` root verdict and the null-guard gate), with the same rule id `expression-invalid`, in `os build`, `os validate` and `os lint` as at the door. A `record.FIELD` read resolves against the extended object as served (its own fields and every field an extension adds). The door reads the objects without the system columns the registry injects, as the build does, so a column such as `created_at` is a known field and is not judged nullable at either. A finding is located at the entry: `object 'crm_account' · objectExtensions[0] · validation 'no_blocked__c'`, key path `objectExtensions[0].validations[0].condition`. At the door the refusal is `422 INVALID_METADATA`, the door still runs one pass (the extension walk), and a republish is judged without the package's own stored extension, which the write replaces.

The null-guard gate is part of that pass, and it is the narrowing most likely to meet a working tenant: an extension rule that applies an ordering or arithmetic operator (`>`, `<`, `+`, …) to a nullable field with no guard, such as `record.credit > 5000000` on a declared `number` field with no `required: true` and no `defaultValue`, is now refused. An equality such as `record.credit == null` is not. At runtime that predicate aborts on every save where the field is empty, and the save is refused fail-closed. The repository's own suite carried one such rule and now guards it.

**Unchanged:** the object save door, an object's own rules, a draft save (never gated), a stored row (judged only when it is published again), and the regex and JSON-schema checks of a `format` / `json_schema` rule, which still judge an object's own rules only. The leaf of a one-hop read through a reference an extension adds (`record.manager__c.nmae`) is still not judged, at the build or the door: the hop checks read the declared objects. A hop through a reference the target declares is judged.

**Fix:** before building or publishing again, repair each refused rule the way you would on a rule declared on the same object: read `record.FIELD` for a field the object serves, call only registered functions, guard a nullable operand of an ordering or arithmetic operator with `!= null` (`record.credit != null && record.credit > 5000000`), and read through a reference only in the shape a declared rule may.
4 changes: 4 additions & 0 deletions content/docs/deployment/validating-metadata.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -610,6 +610,10 @@ declarations, not words an app adds. The same door runs the build's
field-formula pass on each computed field the written entry adds (#33072),
against the target as served without the package's own stored extension, which
the write replaces, and refuses with the build's rule id (`expression-invalid`).
Each validation rule the entry adds is judged the same way (#33154), by the
object's own rule pass, at the door and in `os build` alike: the registry merges
an extension's `validations` onto the target, so every save of the target runs
them (`object 'TARGET' · objectExtensions[0] · validation 'RULE'`).

**Declared enforcement that cannot run** is two rows for the mirror-image
reason, and #4716 is where it split. That heading covers four rule ids, and
Expand Down
11 changes: 6 additions & 5 deletions packages/lint/src/authored-site-expression.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,6 @@ function walkPushes(code: string): string[] {
const SITE_MEMBER_CALLS: Record<ObjectSite, Record<string, string>> = {
validation: {
get: 'a Map read (the object index, the field definitions)',
indexOf: 'a string offset, reading the slot key',
isArray: '`Array.isArray`',
push: "appends a finding to the walk's list",
},
formula: {
Expand Down Expand Up @@ -139,14 +137,17 @@ const CYCLE = {
},
};

/** Where each site's calls are read: the rule loop and the `check` helper it calls; the field-formula pass and what follows the fields loop. */
/** Where each site's calls are read: the rule pass (`judgeValidationRule`) and the `check` helper it calls; the field-formula pass and what follows the fields loop. */
const SITE_REGIONS: Record<ObjectSite, Array<[string, string]>> = {
validation: [
[' const check = (', ' const fieldRuleRootVerdict = ('],
[' for (const rule of recordsOf(validations)) {', ' // Field-level formulas'],
// [#33154] The rule pass, hoisted out of the object walk so the extension
// walk judges an extension's rules by the same body; the object walk's
// loop now calls it and nothing else.
[' const judgeValidationRule = (', ' // ── Object validation-rule'],
],
formula: [
[' const judgeFieldFormula = (', ' // ── Object validation-rule'],
[' const judgeFieldFormula = (', ' const judgeValidationRule = ('],
[
' judgeFieldFormula(fname, f, { objectName, optionFields: optionFieldNames, fieldDefs });\n }',
" // [#26646] A field group's section predicate",
Expand Down
7 changes: 7 additions & 0 deletions packages/lint/src/authoring-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -838,6 +838,13 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
// extension draft is refused, because that batch's pending set carries no
// extension drafts (#33152); publishing the extension first through the
// per-item door works.
//
// [#33154] The same walk judges each `validations[]` rule the written
// extension adds, by the object walk's own rule pass, so a rule the
// registry merges onto the target is refused at this door and in the
// build alike (`object 'TARGET' · objectExtensions[0] · validation 'N'`).
// MEASURED first: 0 first-party `objectExtensions` entries carry a
// validation rule (examples/**, packages/**, HotCRM).
surfaces: CLI_AND_RUNTIME,
runtimeTypes: ['flow', 'action', 'hook', 'object', 'view', 'page', 'object_extension'],
run: (stack, ctx) =>
Expand Down
146 changes: 146 additions & 0 deletions packages/lint/src/runtime-gate.object-formula-writes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
* the #22019 block of `packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts`.
*/
import { describe, expect, it } from 'vitest';
import { applyInjectedSystemColumns } from '@objectstack/metadata-core';
import { EXPRESSION_INVALID, runAuthoringRules } from './authoring-rules.js';
import { runRuntimeAuthoringRules, runtimeAuthoringRulesFor } from './runtime-gate.js';
import { runStackExpressionPasses, validateStackExpressions } from './validate-expressions.js';
Expand Down Expand Up @@ -452,3 +453,148 @@ describe('#33072 — the object_extension door judges an extension field\'s form
expect(runtimeAuthoringRulesFor('object').map((r) => r.name)).toContain('validateStackExpressions');
});
});

describe('#33154 — an extension\'s validation rules are judged at the build and the object_extension door by the object\'s own rule pass', () => {
/** The extended object, as the door's live universe carries it. */
const target = {
name: 'fx_ticket',
label: 'Ticket',
sharingModel: 'private',
fields: {
employee: { type: 'lookup', label: 'Employee', reference: 'fx_staff' },
amount: { type: 'number', label: 'Amount' },
},
};
const staff = {
name: 'fx_staff', label: 'Staff', sharingModel: 'private',
fields: { daily_hours: { type: 'number', label: 'Daily Hours' } },
};
const script = (name: string, condition: string) => ({ name, type: 'script', condition, message: 'Refused.' });
/** One extension of `fx_ticket` adding the given rules (and fields). */
const extension = (validations: unknown[], fields: Record<string, unknown> = {}) => ({ extend: 'fx_ticket', fields, validations });
const RULE_WHERE = (rule: string) => `object 'fx_ticket' · objectExtensions[0] · validation '${rule}'`;
const gateExtension = (item: unknown, objects: unknown[] = [target, staff]) =>
runRuntimeAuthoringRules({ type: 'object_extension', item, context: { objects } });
const buildFindings = (stack: Record<string, unknown>) =>
runAuthoringRules('build', { normalized: stack, parsed: stack }).filter((f) => f.where.includes('objectExtensions'));

it('⭐ LIT — a rule reading a field the extended object does not serve is REFUSED, at the entry, with the slot\'s key path', () => {
const result = gateExtension(extension([script('amount_positive', 'record.amont < 0')]));

const errs = expressionFindings(result.errors);
expect(errs, dump(result)).toHaveLength(1);
expect(errs[0]).toMatchObject({
severity: 'error',
where: RULE_WHERE('amount_positive'),
path: 'objectExtensions[0].validations[0].condition',
});
expect(errs[0]!.message).toContain('amont');
});

it('⭐ LIT — a rule calling an unregistered function (`sqrt`) is REFUSED, and a nested `then` branch is judged too', () => {
const result = gateExtension(extension([
script('first_ok', 'record.amount != null && record.amount < 0'),
{
name: 'big_ticket', type: 'conditional', message: 'Refused.',
when: 'record.amount != null && record.amount > 100',
then: script('root_check', 'record.amount != null && sqrt(record.amount) > 10'),
},
]));

const errs = expressionFindings(result.errors);
expect(errs.map((f) => [f.where, f.path]), dump(result)).toEqual([
[`object 'fx_ticket' · objectExtensions[0] · validation rule 'big_ticket' then → 'root_check'`, 'objectExtensions[0].validations[1].then.condition'],
]);
});

it('⭐ PARITY — on one shared input set, the door\'s findings deep-equal the build\'s', () => {
for (const item of [
extension([script('typo', 'record.amont < 0')]),
extension([script('fn', 'sqrt(record.amount) > 1')]),
extension([script('bare', 'amount < 0')]),
]) {
const atDoor = gateExtension(item);
const atBuild = buildFindings({ objects: [target, staff], objectExtensions: [item] });

expect(atBuild.length, dump(atBuild)).toBeGreaterThan(0);
expect([...atDoor.errors, ...atDoor.advisories]).toEqual(atBuild);
}
});

it('⭐ PARITY — the door reads the objects as served, injected system columns and all: an unguarded ordering over `created_at` is accepted there as at the build, one over a declared nullable field is refused at both', () => {
// As the registry serves them: `created_at`, `owner_id`, … declared.
const served = [applyInjectedSystemColumns(target), applyInjectedSystemColumns(staff)];
expect(Object.keys(served[0]!.fields)).toContain('created_at');
for (const [condition, refused] of [
["record.created_at > timestamp('2020-01-01T00:00:00Z')", false],
['record.amount > 0', true],
] as const) {
const item = extension([script('since', condition)]);
const atDoor = gateExtension(item, served);
const atBuild = buildFindings({ objects: [target, staff], objectExtensions: [item] });

expect([...atDoor.errors, ...atDoor.advisories], condition).toEqual(atBuild);
expect(atBuild.length > 0, `${condition}: ${dump(atBuild)}`).toBe(refused);
}
});

it('⭐ CONTROL — a valid rule, a rule reading a field the same extension adds, and a one-hop read publish clean', () => {
for (const item of [
extension([script('ok', 'record.amount != null && record.amount < 0')]),
extension([script('bonus_ok', 'record.bonus != null && record.amount != null && record.bonus > record.amount')], { bonus: { type: 'number', label: 'Bonus' } }),
// The one-hop read the rule pass serves, in the shape its traversal
// check prescribes: gated on the reference being set.
extension([{
name: 'hop_ok', type: 'conditional', message: 'Refused.',
when: 'record.employee != null',
then: script('long_day', 'record.employee.daily_hours > 12'),
}]),
]) {
const result = gateExtension(item);
expect(result.errors, dump(result)).toEqual([]);
expect(result.advisories, dump(result)).toEqual([]);
expect(buildFindings({ objects: [target, staff], objectExtensions: [item] }), dump(item)).toEqual([]);
}
});

it('a rule reading a field a SIBLING package\'s extension adds resolves at the build; a misspelling of it is refused', () => {
const sibling = { extend: 'fx_ticket', fields: { bonus: { type: 'number', label: 'Bonus' } } };
// An artifact: its top level repeats its bodies' objects, and the fold
// reads each body's extensions.
const stackWith = (item: unknown) => ({
objects: [target, staff],
packages: [
{ manifest: { objects: [target, staff] } },
{ manifest: { objectExtensions: [sibling] } },
{ manifest: { objectExtensions: [item] } },
],
});

expect(buildFindings(stackWith(extension([script('sib', 'record.bonus != null && record.bonus > 0')])))).toEqual([]);
const typo = buildFindings(stackWith(extension([script('sib', 'record.bonsu != null && record.bonsu > 0')])));
expect(typo.map((f) => [f.rule, f.where]), dump(typo)).toEqual([
[EXPRESSION_INVALID, "object 'fx_ticket' · packages[2].manifest.objectExtensions[0] · validation 'sib'"],
]);
});

it('an extension of an object the door does not carry is judged on what does not depend on its fields', () => {
expect(expressionFindings(gateExtension(extension([script('fn', 'sqrt(record.amount) > 1')]), []).errors)).toHaveLength(1);
const read = gateExtension(extension([script('read', 'record.amount != null && record.amount < 0')]), []);
expect(read.errors, dump(read)).toEqual([]);
});

it('on an extension write only the extension walk judges: the target\'s own broken rule is not this write\'s to answer for', () => {
const broken = { ...target, validations: [script('own_typo', 'record.amont < 0')] };
const stack = { objects: [broken, staff], objectExtensions: [extension([script('ok', 'record.amount != null && record.amount < 0')])] };
// The build judges the object's own rule…
expect(validateStackExpressions(stack).map((i) => i.where)).toEqual(["object 'fx_ticket' · validation 'own_typo'"]);
// …the extension door's pass reads only the written entry.
expect(runStackExpressionPasses(stack, { runtimeWriteType: 'object_extension' })).toEqual([]);
});

it('CONTROL — the object save door still judges the object\'s own rule at its own location, and no extension rule', () => {
const own = { ...target, validations: [script('own_typo', 'record.amont < 0')] };
const result = gateObject(own);
expect(expressionFindings(result.errors).map((f) => f.where)).toEqual(["object 'fx_ticket' · validation 'own_typo'"]);
});
});
Loading
Loading