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
29 changes: 29 additions & 0 deletions .changeset/22032-object-save-door-validation-predicates.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
"@objectstack/lint": minor
"@objectstack/metadata-protocol": minor
---

fix(lint)!: the object save door refuses a validation rule whose predicate `os build` refuses (#22032)

Clause-②: no (narrowing)

`formulas.mdx` says the same `validateExpression` validator backs `os build` and metadata registration. For a validation rule's predicates it did not, at the object save door. A rule whose `condition` called an unregistered function, such as `sqrt(record.amount) > 1`, or read a bare field, such as `amount > 1`, was refused by `os build` at error, but `PUT /api/v1/meta/object/:name` answered 200 and stored it. The rule then faulted on every write it judged.

The runtime publish gate now runs the build's validation-rule check on an object write. The build's expression rule (`validateStackExpressions`) was already on the object door for formula fields alone. On an object write it now also runs its validation-rule pass: each `validations[]` rule's `condition` and a `conditional` rule's `when`, plus the null-guard check over every predicate the rule carries, its nested `then` and `otherwise` rules included. The door's verdict is the build's finding: the same rule id (`expression-invalid`), location (`object 'NAME' · validation 'RULE'`, or `… validation rule 'RULE' then → 'CHILD'` for a nested predicate), message and hint.

**BREAKING — what moves for consumers.**

- An object write in publish mode answered 200 for a validation rule whose predicate the shared validator refuses. It now answers `422 INVALID_METADATA`, with an `expression-invalid` issue located at that rule. This covers `PUT /api/v1/meta/object/:name` (and `saveMetaItem` in publish mode), the promotion of a draft (`POST /api/v1/meta/object/:name/publish`, `publishMetaItem`), and a package draft publish (`publishPackageDrafts`).
- The verdict is the one `os build`, `os validate` and `os lint` already gave: an unknown function, a field the object does not declare, a bare field reference (`amount` instead of `record.amount`), a syntax error, an ordering or arithmetic operator applied to a nullable field with no `!= null` guard (`has()` is no guard here), and the other errors in the build's validation-rule check. Its warnings now ride the save response as advisories.

**Remedy.** Fix the predicate: the message names the unknown function or field, or the unguarded operand, and the position, as `os build` already requires. Qualify field reads as `record.FIELD`, use one of the functions `introspectScope` lists, and guard a nullable operand with `record.FIELD != null && …`. Saving it as a draft (`mode: 'draft'`) is still allowed, because drafts are never gated; publishing that draft is judged.

**Unchanged.**

- Stored rows are not migrated, and they are not refused on read. An object stored before this change keeps loading until it is next saved. At that save the gate judges it, because the differential compares the write against the stored universe without its own stored row.
- The other expressions an object carries are still not judged at this door: the field-rule slots (`requiredWhen`, `readonlyWhen`, `conditionalRequired`, `visibleWhen`), option `visibleWhen`, and the object's own action predicates. `os build` judges them, and the door does not, as before.
- `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write.
- Measured before crossing: every validation rule this repository ships has 0 refusals and 0 advisories, at the build and at the door. That is 21 rules carrying 13 predicates on 10 objects: examples 11 predicates on 7 objects, and the platform objects 2 on 3 (one rule on `sys_user` carries no predicate).
- No public export or signature moves. `validateStackExpressions(stack)` keeps its signature, and no registry entry changes: the expression rule already declared `object`.

<!-- adr-0087: not-required (no-migration-prescription) a refusal at the object save door of a validation-rule predicate the published validator already refuses at `os build`: no authorable key, spelling, export or stored shape moves, and no stored row is read, rewritten or converted. A stored object whose rule predicate the validator refuses keeps loading until it is next saved, and the repair is the author's edit of the predicate, which no ledger entry can derive. The other categories are closed on facts: the packages publish (not unpublished); no ADR-0087 id covers this door (not already-registered); and the change is a door verdict, not a declaration (not runtime-interface-only or type-surface-only). -->
25 changes: 20 additions & 5 deletions packages/lint/src/authoring-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,9 +309,10 @@ export interface AuthoringRuleContext {
*
* [#22019] One other rule reads it, on that argument: `validateStackExpressions`
* is one entry over several PASSES, and an `object` write is admitted for its
* field-formula pass alone (`runStackExpressionPasses`, `StackExpressionOptions`). The
* entry-level `runtimeTypes` can say that an object write reaches the rule; it
* cannot say which of the rule's passes judge that write.
* field-formula pass and (#22032) its validation-rule pass alone
* (`runStackExpressionPasses`, `StackExpressionOptions`). The entry-level
* `runtimeTypes` can say that an object write reaches the rule; it cannot say
* which of the rule's passes judge that write.
*/
runtimeWriteType?: string;
/**
Expand Down Expand Up @@ -588,16 +589,30 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
// NARROW by construction, not by snapshot shape: `ctx.runtimeWriteType`
// reaches `runStackExpressionPasses` — the body `validateStackExpressions`
// runs, whose public signature is unchanged — which on an object write runs
// the field-formula pass and fences every other object-borne expression
// the passes admitted there and fences every other object-borne expression
// pass off by name (`StackExpressionOptions.runtimeWriteType`) — each of
// those is a crossing of its own, not a rider on this one.
// those is a crossing of its own, not a rider on another.
//
// MEASURED over the stored corpus at the door's own snapshot shape before
// crossing: every formula field the repository ships — 29 fields on 28
// objects (examples: app-crm 4 on 3, app-showcase 2 on 2, app-todo 1 on 1,
// app-multi-package none; platform `display_title` formulas: 22 on 22) →
// 0 differential errors and 0 advisories, against 1 refusal for the card's
// own `sqrt(record.amount)` body under the same harness.
//
// [#22032, pass 1] The validation-rule pass joins the object door: every
// `validations[]` `condition` and `when`, with the null-guard gate over
// the nested `then` / `otherwise` branches — the same sentence of
// `formulas.mdx`, and the same gap (`sqrt(record.amount) > 1` and a bare
// `amount > 1` saved with a 200). No entry-level change: `object` was
// already declared above. MEASURED first, at both the raw and the parsed
// shape: every validation rule the repository ships — 21 rules carrying 13
// predicates on 10 objects (examples: app-crm 3 on 2, app-showcase 6 on 4,
// app-todo 2 on 1; platform: plugin-security 2 on 2, and one
// predicate-less rule on `sys_user`) → 0 build errors and 0 warnings for
// the pass, and 0 door errors and 0 advisories at the door's own snapshot
// shape, against 2 refusals at each for the card's two bodies in the same
// harness.
surfaces: CLI_AND_RUNTIME,
runtimeTypes: ['flow', 'action', 'hook', 'object'],
run: (stack, ctx) =>
Expand Down
63 changes: 42 additions & 21 deletions packages/lint/src/runtime-gate.object-formula-writes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,13 @@
*
* `object` joins `runtimeTypes`, and the gate's `runtimeWriteType` reaches the
* rule (`runStackExpressionPasses`), which on an object write runs the
* field-formula pass alone. Every other object-borne pass the build runs —
* validation-rule predicates, the field-rule slots, option `visibleWhen`, the
* object's own action predicates — is FENCED off this door by name, and the
* fence is pinned below with the build still flagging the same body, so a
* later widening moves that line consciously rather than by drift.
* field-formula pass — and, since #22032's pass 1, the validation-rule pass
* (its pins: `runtime-gate.object-validation-writes.test.ts`). Every other
* object-borne pass the build runs — the field-rule slots, option
* `visibleWhen`, the object's own action predicates — is FENCED off this door
* by name, and the fence is pinned below with the build still flagging the
* same body, so a later widening moves that line consciously rather than by
* drift.
*
* The protocol-level half — the same verdict through the real `saveMetaItem`
* and `publishMetaItem`, and the door/build equality of the finding — is
Expand Down Expand Up @@ -112,10 +114,13 @@ describe('#22019 — the object door dispatches the build\'s expression rule', (

describe('#22019 — the fence: every other object-borne expression pass stays off this door', () => {
/**
* One body carrying a fault in each fenced pass, and a CLEAN formula. The
* build flags every one of them; the object door flags none. Each fault is
* one the build refuses at `error`, so "the door is silent" cannot be read
* as "there was nothing to say".
* One body carrying a fault in each FENCED pass — #22032's passes 2 to 4,
* one site each: a field-rule slot (`requiredWhen`), an option's
* `visibleWhen`, an object action's `visible` — beside a fault in the
* validation-rule pass (#22032 pass 1, LIFTED) and a CLEAN formula. The
* build flags every fault; the object door flags the lifted pass's alone.
* Each fault is one the build refuses at `error`, so "the door is silent on
* a fenced site" cannot be read as "there was nothing to say".
*/
const fenced = () => fxSqrt('floor(record.amount)', {
validations: [
Expand All @@ -127,28 +132,40 @@ describe('#22019 — the fence: every other object-borne expression pass stays o
});
const withFieldRule = () => {
const body = fenced();
(body.fields as Record<string, unknown>).name = {
type: 'text', label: 'Name', requiredWhen: 'amount > 1',
const fields = body.fields as Record<string, unknown>;
fields.name = { type: 'text', label: 'Name', requiredWhen: 'amount > 1' };
fields.tier = {
type: 'select',
label: 'Tier',
options: [{ label: 'Gold', value: 'gold', visibleWhen: 'amount > 1' }],
};
return body;
};

it('the build (no `runtimeWriteType`) still flags each fenced site', () => {
/** The four fenced sites (passes 2–4) and the lifted one (pass 1), by the build's `where`. */
const FENCED_SITES = [
"object 'fx_sqrt' · field 'name' requiredWhen",
"object 'fx_sqrt' · field 'tier' option 'gold' visibleWhen",
"object 'fx_sqrt' · action 'fx_close' visible",
];
const LIFTED_SITE = "object 'fx_sqrt' · validation 'amount_root'";

it('the build (no `runtimeWriteType`) still flags each fenced site, and the lifted one', () => {
const wheres = validateStackExpressions({ objects: [withFieldRule()] })
.filter((i) => (i.severity ?? 'error') === 'error')
.map((i) => i.where);

expect(wheres.some((w) => w.includes("validation 'amount_root'")), dump(wheres)).toBe(true);
expect(wheres.some((w) => w.includes("field 'name' requiredWhen")), dump(wheres)).toBe(true);
expect(wheres.some((w) => w.includes("action 'fx_close'")), dump(wheres)).toBe(true);
for (const site of [...FENCED_SITES, LIFTED_SITE]) {
expect(wheres.includes(site), `${site}\n${dump(wheres)}`).toBe(true);
}
expect(wheres.some((w) => w === WHERE), 'the clean formula must not be flagged').toBe(false);
});

it('the object door flags none of them — only a formula field\'s `expression` is judged there', () => {
it('the object door flags none of the fenced sites — only the formula and validation-rule passes judge there', () => {
const result = gateObject(withFieldRule());

expect(result.rulesRun).toContain('validateStackExpressions');
expect(expressionFindings(result.errors), dump(result)).toEqual([]);
// [#22032 pass 1] The lifted pass's finding, and nothing else.
expect(expressionFindings(result.errors).map((f) => f.where), dump(result)).toEqual([LIFTED_SITE]);
expect(expressionFindings(result.advisories), dump(result)).toEqual([]);
});

Expand All @@ -157,8 +174,12 @@ describe('#22019 — the fence: every other object-borne expression pass stays o
// option narrows ONLY on `object`, so nothing about the three existing
// doors moves.
const stack = { objects: [withFieldRule()] };
expect(runStackExpressionPasses(stack, { runtimeWriteType: 'flow' })).toEqual(validateStackExpressions(stack));
// And the object pass set is a strict subset of what the build reports.
expect(runStackExpressionPasses(stack, { runtimeWriteType: 'object' })).toEqual([]);
const all = validateStackExpressions(stack);
expect(runStackExpressionPasses(stack, { runtimeWriteType: 'flow' })).toEqual(all);
// And the object pass set is the build's own findings on the admitted
// passes — a strict subset, in the build's order, none from a fenced site.
const onObjectWrite = runStackExpressionPasses(stack, { runtimeWriteType: 'object' });
expect(onObjectWrite.map((i) => i.where)).toEqual([LIFTED_SITE]);
expect(onObjectWrite).toEqual(all.filter((i) => i.where === LIFTED_SITE || i.where === WHERE));
});
});
Loading
Loading