From 24659a0b164ace0ddead15afe777b5969e3f7cee Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 21:55:35 +0000 Subject: [PATCH 1/3] fix(lint)!: the object save door gives the build's validation-rule verdict On an object write the expression rule's fence now admits the validation-rule pass beside the field-formula pass: every validations[] condition and when, with the null-guard gate over the nested then / otherwise branches, judged at the build's own position in the walk. The field-rule slots, option visibleWhen and the object's action predicates stay fenced, and the fence pin now names one site of each. Claude-Session: https://claude.ai/code/session_01GV6oYwgc1kWiUCb1YaprQ7 Co-authored-by: Claude --- packages/lint/src/authoring-rules.ts | 24 ++- ...runtime-gate.object-formula-writes.test.ts | 63 ++++--- ...time-gate.object-validation-writes.test.ts | 175 ++++++++++++++++++ .../src/runtime-gate.object-writes.test.ts | 9 +- packages/lint/src/validate-expressions.ts | 71 ++++--- 5 files changed, 284 insertions(+), 58 deletions(-) create mode 100644 packages/lint/src/runtime-gate.object-validation-writes.test.ts diff --git a/packages/lint/src/authoring-rules.ts b/packages/lint/src/authoring-rules.ts index 5dd25034091..fe33e8bdb57 100644 --- a/packages/lint/src/authoring-rules.ts +++ b/packages/lint/src/authoring-rules.ts @@ -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; /** @@ -588,9 +589,9 @@ 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 @@ -598,6 +599,19 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ // 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, against 2 refusals for the card's two bodies in the same + // harness. surfaces: CLI_AND_RUNTIME, runtimeTypes: ['flow', 'action', 'hook', 'object'], run: (stack, ctx) => diff --git a/packages/lint/src/runtime-gate.object-formula-writes.test.ts b/packages/lint/src/runtime-gate.object-formula-writes.test.ts index 46d0a0edac7..ca3016c63e2 100644 --- a/packages/lint/src/runtime-gate.object-formula-writes.test.ts +++ b/packages/lint/src/runtime-gate.object-formula-writes.test.ts @@ -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 @@ -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: [ @@ -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).name = { - type: 'text', label: 'Name', requiredWhen: 'amount > 1', + const fields = body.fields as Record; + 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([]); }); @@ -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)); }); }); diff --git a/packages/lint/src/runtime-gate.object-validation-writes.test.ts b/packages/lint/src/runtime-gate.object-validation-writes.test.ts new file mode 100644 index 00000000000..37eb58640ad --- /dev/null +++ b/packages/lint/src/runtime-gate.object-validation-writes.test.ts @@ -0,0 +1,175 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #22032, pass 1 — the OBJECT write door runs the build's validation-rule + * pass. + * + * ## The state this closes + * + * `validateStackExpressions` is the build's expression rule. Its + * validation-rule pass judges every `validations[]` predicate: the + * `condition` (record-scoped, with the relationship-traversal checks the rule + * validator serves), the `conditional` rule's `when`, and the #4763 null-guard + * gate over every predicate the rule carries, its nested `then` / `otherwise` + * branches included. #22019 put that rule on the object door for its + * field-formula pass alone and fenced the rest off by name, so a rule whose + * `condition` called an unregistered function (`sqrt(record.amount) > 1`) or + * read a bare field (`amount > 1`) published clean — and then faulted on every + * write the rule judged. + * + * ## The crossing + * + * No registry change: the entry already declares `object`. The fence in + * `runStackExpressionPasses` admits this pass on an object write, at the + * build's own position in the walk, so the door's finding IS the build's + * finding — rule, location, message and hint. The other object-borne passes + * (the field-rule slots, option `visibleWhen`, the object's own action + * predicates) stay fenced; that pin is in + * `runtime-gate.object-formula-writes.test.ts`. + * + * The protocol-level half — the same verdict through the real `saveMetaItem` + * and `publishMetaItem` — is the #22032 block of + * `packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts`. + */ +import { describe, expect, it } from 'vitest'; +import { EXPRESSION_INVALID, runAuthoringRules } from './authoring-rules.js'; +import { runRuntimeAuthoringRules, runtimeAuthoringRulesFor } from './runtime-gate.js'; + +/** `sharingModel` keeps `security-owd-unset` quiet, so the refusal is the rule's. */ +const fxRule = (validations: unknown[]) => ({ + name: 'fx_rule', + label: 'Rule Probe', + sharingModel: 'private', + fields: { + name: { type: 'text', label: 'Name' }, + amount: { type: 'number', label: 'Amount' }, + status: { + type: 'select', + label: 'Status', + options: [{ label: 'Open', value: 'open' }, { label: 'Closed', value: 'closed' }], + }, + }, + validations, +}); + +/** The card's first body: a `condition` calling a function the stdlib does not register. */ +const UNREGISTERED = { name: 'amount_root', type: 'script', condition: 'sqrt(record.amount) > 1', message: 'x' }; +/** The card's second body, on this pass's own key: a bare field reference. */ +const BARE = { name: 'amount_bare', type: 'script', condition: 'amount > 1', message: 'x' }; +/** A `conditional` rule whose `when` is the faulting predicate (its `then` is clean). */ +const WHEN = { + type: 'conditional', + name: 'gate', + when: 'sqrt(record.amount) > 1', + message: 'x', + then: { type: 'script', name: 'child', condition: 'record.amount != null && record.amount > 100', message: 'y' }, +}; +/** The null-guard gate's reach into the nested branches: `amount` is nullable, and `has()` is no guard on a total binding. */ +const NESTED = { + type: 'conditional', + name: 'gate', + when: "record.status == 'open'", + message: 'x', + then: { type: 'script', name: 'child', condition: 'record.amount > 100', message: 'y' }, + otherwise: { type: 'script', name: 'other', condition: 'has(record.amount) && record.amount < 0', message: 'z' }, +}; +/** Valid, guarded predicates — at the top level and inside a branch. */ +const VALID = [ + { name: 'cap', type: 'script', condition: 'record.amount != null && record.amount > 100', message: 'x' }, + { + type: 'conditional', + name: 'gate', + when: "record.status == 'open'", + message: 'x', + then: { type: 'script', name: 'child', condition: 'record.amount != null && record.amount > 100', message: 'y' }, + }, +]; + +const gateObject = (item: unknown) => + runRuntimeAuthoringRules({ type: 'object', item, context: { objects: [] } }); + +const expressionFindings = (fs: readonly T[]): T[] => + fs.filter((f) => f.rule === EXPRESSION_INVALID); + +const buildFindings = (body: unknown) => { + const stack = { objects: [body] }; + return expressionFindings(runAuthoringRules('build', { normalized: stack, parsed: stack })); +}; + +const dump = (r: unknown) => JSON.stringify(r, null, 2); + +describe('#22032 pass 1 — the object door gives the build\'s validation-rule verdict', () => { + it('needs no registry change: `validateStackExpressions` is already on the object door', () => { + expect(runtimeAuthoringRulesFor('object').map((r) => r.name)).toContain('validateStackExpressions'); + }); + + it('⭐ LIT — a `condition` calling an unregistered function (`sqrt`) is REFUSED, located at the rule', () => { + const result = gateObject(fxRule([UNREGISTERED])); + + expect(result.rulesRun).toContain('validateStackExpressions'); + const errs = expressionFindings(result.errors); + const where = "object 'fx_rule' · validation 'amount_root'"; + expect(errs, dump(result)).toHaveLength(1); + expect(errs[0]).toMatchObject({ severity: 'error', where, path: where }); + expect(errs[0]!.message).toContain('`sqrt` is not a callable name here'); + }); + + it('⭐ LIT — a bare field reference in a `condition` (`amount > 1`) is REFUSED', () => { + const errs = expressionFindings(gateObject(fxRule([BARE])).errors); + + expect(errs, dump(errs)).toHaveLength(1); + expect(errs[0]).toMatchObject({ where: "object 'fx_rule' · validation 'amount_bare'" }); + expect(errs[0]!.message).toContain('bare reference `amount`'); + }); + + it('⭐ LIT — a `conditional` rule\'s `when` is judged', () => { + const errs = expressionFindings(gateObject(fxRule([WHEN])).errors); + + expect(errs, dump(errs)).toHaveLength(1); + expect(errs[0]).toMatchObject({ where: "object 'fx_rule' · validation 'gate' when" }); + expect(errs[0]!.message).toContain('`sqrt` is not a callable name here'); + }); + + it('⭐ LIT — the null-guard gate reaches the nested `then` / `otherwise` predicates', () => { + const errs = expressionFindings(gateObject(fxRule([NESTED])).errors); + + expect(errs.map((e) => e.where), dump(errs)).toEqual([ + "object 'fx_rule' · validation rule 'gate' then → 'child'", + "object 'fx_rule' · validation rule 'gate' otherwise → 'other'", + ]); + for (const e of errs) expect(e.message).toContain('`record.amount`'); + }); + + it('⭐ CONTROL — valid, guarded predicates publish clean, top level and nested', () => { + const result = gateObject(fxRule(VALID)); + + expect(result.rulesRun).toContain('validateStackExpressions'); + expect(expressionFindings(result.errors), dump(result)).toEqual([]); + expect(expressionFindings(result.advisories), dump(result)).toEqual([]); + // And the build agrees: the control is clean at both doors, not only this one. + expect(buildFindings(fxRule(VALID))).toEqual([]); + }); + + it('⭐ PARITY — for each refused body the door findings ARE the build findings', () => { + for (const rule of [UNREGISTERED, BARE, WHEN, NESTED]) { + const body = fxRule([rule]); + const atBuild = buildFindings(body); + const atDoor = expressionFindings(gateObject(body).errors); + + // Non-vacuous: the build refuses each of them. + expect(atBuild.length, dump(rule)).toBeGreaterThan(0); + expect(atDoor, dump(rule)).toEqual(atBuild); + } + }); + + it('a stored sibling\'s broken rule is not this write\'s to answer for (the differential)', () => { + const sibling = { ...fxRule([UNREGISTERED, BARE]), name: 'fx_sibling' }; + const result = runRuntimeAuthoringRules({ + type: 'object', + item: fxRule(VALID), + context: { objects: [sibling] }, + }); + + expect(expressionFindings(result.errors), dump(result)).toEqual([]); + }); +}); diff --git a/packages/lint/src/runtime-gate.object-writes.test.ts b/packages/lint/src/runtime-gate.object-writes.test.ts index cdb7b856ccf..fdc60e01da5 100644 --- a/packages/lint/src/runtime-gate.object-writes.test.ts +++ b/packages/lint/src/runtime-gate.object-writes.test.ts @@ -111,11 +111,12 @@ describe('the object write door dispatches at the adjudicated scope (#4716)', () // distinguishable, and a rule silently joining or leaving this door is // precisely the drift this pin exists to catch. expect(runtimeAuthoringRulesFor('object').map((r) => r.name)).toEqual([ - // [#22019] The build's expression rule joins, for ONE of its passes: a + // [#22019] The build's expression rule joins, for some of its passes: a // formula field's `expression` — the `validateExpression` verdict the - // docs say backs metadata registration. Its other object-borne passes are - // fenced off this door inside the rule (`StackExpressionOptions`), and - // that fence is pinned in `runtime-gate.object-formula-writes.test.ts`. + // docs say backs metadata registration — and (#22032 pass 1) the + // validation-rule predicates. Its other object-borne passes are fenced + // off this door inside the rule (`StackExpressionOptions`), and that + // fence is pinned in `runtime-gate.object-formula-writes.test.ts`. 'validateStackExpressions', 'validateFunctionalCompleteness', 'validateManagedApiMethods', diff --git a/packages/lint/src/validate-expressions.ts b/packages/lint/src/validate-expressions.ts index b14d8427fcb..55ee216f3d4 100644 --- a/packages/lint/src/validate-expressions.ts +++ b/packages/lint/src/validate-expressions.ts @@ -1182,22 +1182,33 @@ export interface StackExpressionOptions { * through by this rule's registry entry). ABSENT on `os build`, `os lint` * and `os validate`, which run every pass below. * - * On an `object` write exactly ONE pass judges: the field-formula pass over - * `fields[].expression` — the build's own `validateExpression('value', …)` - * call, with its warnings and the unprovisioned-anchor warning on the same - * key. That is the verdict `formulas.mdx` says backs both `os build` and - * metadata registration, and the save door gave none of it: a formula - * calling an unregistered function (`sqrt(record.amount)`) saved with a 200 - * and read `null` on every row. + * On an `object` write exactly TWO passes judge, each the build's own call + * at the build's own position in the walk: + * + * - the field-formula pass over `fields[].expression` — the build's + * `validateExpression('value', …)` call, with its warnings and the + * unprovisioned-anchor warning on the same key. That is the verdict + * `formulas.mdx` says backs both `os build` and metadata registration, + * and the save door gave none of it: a formula calling an unregistered + * function (`sqrt(record.amount)`) saved with a 200 and read `null` on + * every row (#22019); + * - [#22032, pass 1] the validation-rule pass over `validations[]` — the + * `condition` (with the relationship-traversal checks the rule validator + * serves) and the `conditional` rule's `when`, plus the #4763 null-guard + * gate over every predicate the rule carries, its nested `then` / + * `otherwise` branches included. The same sentence of `formulas.mdx` + * covers it, and the door gave none of it either: a rule whose + * `condition` called `sqrt` or read a bare `amount` saved with a 200 and + * then faulted on every write the rule judged. * * Every other pass is fenced off an object write, deliberately and by name: - * the validation-rule predicates, the field-rule slots (`requiredWhen` / - * `readonlyWhen` / `conditionalRequired` / `visibleWhen`) with their - * `parent` and null-guard gates, the per-option `visibleWhen`, and the - * object's own `actions[]` predicates. Each is a build verdict the save - * door still does not give, and each would narrow the accept set further - * than this crossing does — a crossing of its own, measured over the stored - * corpus first, not a rider on this one. + * the field-rule slots (`requiredWhen` / `readonlyWhen` / + * `conditionalRequired` / `visibleWhen`) with their `parent` and null-guard + * gates, the per-option `visibleWhen`, and the object's own `actions[]` + * predicates. Each is a build verdict the save door still does not give, + * and each would narrow the accept set further than the crossings above — + * a crossing of its own, measured over the stored corpus first, not a rider + * on either of them. */ runtimeWriteType?: string; } @@ -1222,10 +1233,11 @@ export function validateStackExpressions(stack: AnyRec): ExprIssue[] { export function runStackExpressionPasses(stack: AnyRec, options: StackExpressionOptions): ExprIssue[] { const issues: ExprIssue[] = []; // [#22019] See {@link StackExpressionOptions.runtimeWriteType}: on an object - // write only the field-formula pass judges. Every other loop below reads an - // empty list under it, so the claim holds by construction rather than by the - // shape of the snapshot the gate happens to build today. - const fieldFormulasOnly = options.runtimeWriteType === 'object'; + // write only the field-formula pass and (#22032) the validation-rule pass + // judge. Every other loop below reads an empty list under it, so the claim + // holds by construction rather than by the shape of the snapshot the gate + // happens to build today. + const objectWrite = options.runtimeWriteType === 'object'; const objects = recordsOf(stack.objects); const fieldIndex = buildFieldIndex(objects); const fieldTypeIndex = buildFieldTypeIndex(objects); @@ -1511,7 +1523,7 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression }; // ── Flows ────────────────────────────────────────────────────────── - for (const flow of fieldFormulasOnly ? [] : recordsOf(stack.flows)) { + for (const flow of objectWrite ? [] : recordsOf(stack.flows)) { const flowName = typeof flow.name === 'string' ? flow.name : '(unnamed flow)'; // `Array.isArray` proves the LIST, never its MEMBERS — the sentence #15742 // removed from one reader below in this same file. A YAML `nodes:` item @@ -1856,7 +1868,10 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression // `validations` is the key `ObjectSchema` declares; `validationRules` is a // rejected alias of it (#5017) — see the `## Scope` table above. const validations = obj.validations; - for (const rule of fieldFormulasOnly ? [] : recordsOf(validations)) { + // [#22032, pass 1] NOT fenced on an object write: this loop is the + // validation-rule pass the object save door runs, at the build's own + // position, so the door's findings and their order are the build's. + for (const rule of recordsOf(validations)) { const where = `object '${objectName}' · validation '${(rule.name as string) ?? '?'}'`; // The declared predicate key is `condition` (see `rulePredicates`). // Validation predicates are `record`-scoped — no field flattening — so @@ -1926,9 +1941,9 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression /** * The field-formula pass — one computed field's `expression`. A closure * rather than inline only so the runtime publish gate's object door - * (#22019) runs exactly this pass and nothing beside it; on the three CLI - * commands it is called at the same point of the field walk it always ran - * at, so the build's findings and their order are unchanged. + * (#22019) runs exactly this pass and no other slot of the field walk; on + * the three CLI commands it is called at the same point of the field walk + * it always ran at, so the build's findings and their order are unchanged. */ const judgeFieldFormula = (fname: string, f: AnyRec): void => { if (f.expression) { @@ -1970,7 +1985,7 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression for (const [fname, f] of fieldList) { // [#22019] The object write door: this field's formula, and no other slot. - if (fieldFormulasOnly) { + if (objectWrite) { judgeFieldFormula(fname, f); continue; } @@ -2163,10 +2178,10 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression // `$select` projection (and, through `&&` short-circuiting, on row data), // neither of which this pass can see. Full reasoning in the ledger. }; - for (const action of fieldFormulasOnly ? [] : recordsOf(stack.actions)) { + for (const action of objectWrite ? [] : recordsOf(stack.actions)) { checkAction('stack', action); } - for (const obj of fieldFormulasOnly ? [] : objects) { + for (const obj of objectWrite ? [] : objects) { const objectName = typeof obj.name === 'string' ? obj.name : undefined; for (const action of recordsOf(obj.actions)) { checkAction(`object '${objectName}'`, action, objectName); @@ -2180,7 +2195,7 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression // test can tell this receiver apart from the VALIDATION rule one — the two // are governed by different schemas, and a scan that merged them would let a // key declared by either schema pass on both (#5017). - for (const sharingRule of fieldFormulasOnly ? [] : recordsOf(stack.sharingRules)) { + for (const sharingRule of objectWrite ? [] : recordsOf(stack.sharingRules)) { const ruleObj = typeof sharingRule.object === 'string' ? sharingRule.object : undefined; const where = `sharingRule '${(sharingRule.name as string) ?? '?'}'${ruleObj ? ` (${ruleObj})` : ''} condition`; // `condition` is the authored key `SharingRuleSchema` declares. `criteria` @@ -2195,7 +2210,7 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression // A lifecycle hook's `condition` skips the handler when false; it is // evaluated against the record, so a bare ref silently makes the hook // run on every record (or never) instead of the intended subset. - for (const hook of fieldFormulasOnly ? [] : recordsOf(stack.hooks)) { + for (const hook of objectWrite ? [] : recordsOf(stack.hooks)) { const hookName = (hook.name as string) ?? '?'; if (typeof hook.object === 'string') { check(`hook '${hookName}' (${hook.object}) condition`, hook.condition, hook.object, 'record'); From 0d6b8ee131834fdc3660a427c28553b631273c8c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 21:56:34 +0000 Subject: [PATCH 2/3] test(metadata-protocol): pin the validation-rule verdict at the object save door Through the real saveMetaItem and publishMetaItem: both card bodies are refused with a 422 INVALID_METADATA carrying the build's located finding (active save and draft promotion), a valid guarded condition still saves, and the door's issue equals the build's finding key by key. The registry comment records the corpus reading taken before the crossing. Claude-Session: https://claude.ai/code/session_01GV6oYwgc1kWiUCb1YaprQ7 Co-authored-by: Claude --- packages/lint/src/authoring-rules.ts | 3 +- .../protocol.runtime-authoring-gate.test.ts | 136 ++++++++++++++++++ 2 files changed, 138 insertions(+), 1 deletion(-) diff --git a/packages/lint/src/authoring-rules.ts b/packages/lint/src/authoring-rules.ts index fe33e8bdb57..2670ad6d5d7 100644 --- a/packages/lint/src/authoring-rules.ts +++ b/packages/lint/src/authoring-rules.ts @@ -610,7 +610,8 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ // 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, against 2 refusals for the card's two bodies in the same + // 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'], diff --git a/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts b/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts index 30be6f80c35..3e342f6d008 100644 --- a/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts +++ b/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts @@ -1722,3 +1722,139 @@ describe('runtime authoring gate on OBJECT writes — the formula field verdict expect(buildFindings(fxSqrt(REGISTERED))).toEqual([]); }); }); + +/** + * [#22032, pass 1] The object save door gives the build's verdict on a + * validation rule's predicates. + * + * The same `formulas.mdx` sentence covers a validation rule's `condition`: + * the shared validator backs `os build` and metadata registration. #22019's + * crossing put the build's expression rule on this door for formula fields + * alone, so a rule whose `condition` called an unregistered function + * (`sqrt(record.amount) > 1`) or read a bare field (`amount > 1`) still saved + * with a 200, while `os build` refused both at `error`. + * + * The lift is in `@objectstack/lint` (the rule's object-write fence admits its + * validation-rule pass); no code here moves. Pinned through the REAL + * `saveMetaItem` / `publishMetaItem`: + * + * (a) the door refuses both card bodies — a 422 `INVALID_METADATA` carrying + * the build's located finding — on an active save AND on a draft's + * promotion, and nothing lands; + * (b) a valid, guarded `condition` still saves; + * (d) for each refused body the door's issues and the build's findings are + * the same findings: rule, location, message and hint. + * + * The `when` / nested `then` / `otherwise` reach and the fence over the other + * object-borne passes are pinned in `@objectstack/lint`'s + * `runtime-gate.object-validation-writes.test.ts` and + * `runtime-gate.object-formula-writes.test.ts`. + * + * ⚠️ As in the #22019 block above: this package reaches `@objectstack/lint` + * through its built `dist/`, so an edit to the rule is invisible here until + * `pnpm --filter @objectstack/lint build` has run. + */ +describe('runtime authoring gate on OBJECT writes — the validation-rule verdict (#22032)', () => { + /** `sharingModel` is authored so `security-owd-unset` stays quiet and the refusal is the rule's. */ + const fxRule = (condition: string) => ({ + name: 'fx_rule', + label: 'Rule Probe', + sharingModel: 'private', + fields: { + name: { type: 'text', label: 'Name' }, + amount: { type: 'number', label: 'Amount' }, + }, + validations: [{ name: 'amount_rule', type: 'script', condition, message: 'Amount rule' }], + }); + /** The card's two bodies, each refused by `os build` at `error`. */ + const REFUSED = [ + { condition: 'sqrt(record.amount) > 1', subject: '`sqrt` is not a callable name here' }, + { condition: 'amount > 1', subject: 'bare reference `amount`' }, + ] as const; + const VALID = 'record.amount != null && record.amount > 100'; + /** Where the build locates a validation-rule finding — the rule the author edits. */ + const WHERE = "object 'fx_rule' · validation 'amount_rule'"; + + const ruleRows = (rows: Map) => + Array.from(rows.values()).filter((r) => r.type === 'object' && r.name === 'fx_rule'); + + /** The build's findings for one object, through the build's own entry. */ + const buildFindings = (obj: unknown) => { + const stack = { objects: [obj] }; + return runAuthoringRules('build', { normalized: stack, parsed: stack }) + .filter((f) => f.rule === EXPRESSION_INVALID); + }; + + for (const { condition, subject } of REFUSED) { + it(`(a) REFUSES an active save of \`${condition}\` with a 422 carrying the build's located finding`, async () => { + const { protocol, rows } = makeProtocol(); + + const err = await protocol + .saveMetaItem({ type: 'object', name: 'fx_rule', item: fxRule(condition) }) + .catch((e: any) => e); + + expect(err, 'the save resolved — the door still accepts the rule').toBeInstanceOf(Error); + expect(err.status).toBe(422); + expect(err.code).toBe('INVALID_METADATA'); + expect(err.rulesRun).toContain('validateStackExpressions'); + const issue = err.issues.find((i: any) => i.rule === EXPRESSION_INVALID); + expect(issue, `issues: ${JSON.stringify(err.issues)}`).toBeDefined(); + expect(issue.path).toBe(WHERE); + expect(issue.where).toBe(WHERE); + expect(issue.severity).toBe('error'); + // The named subject: what the author typed, as the build names it. + expect(issue.message).toContain(subject); + // And nothing landed — a gate that refuses after persisting is a log line. + expect(ruleRows(rows)).toEqual([]); + }); + } + + it("(a) REFUSES the same body on a draft's PROMOTION — the draft door is not a bypass", async () => { + const { protocol } = makeProtocol(); + // A draft save is never gated (#4463 D1): the author may keep a half-finished object. + await expect( + protocol.saveMetaItem({ type: 'object', name: 'fx_rule', item: fxRule(REFUSED[0].condition), mode: 'draft' }), + ).resolves.toMatchObject({ success: true }); + + const err = await protocol.publishMetaItem({ type: 'object', name: 'fx_rule' }).catch((e: any) => e); + + expect(err?.status).toBe(422); + expect(err.code).toBe('INVALID_METADATA'); + const issue = err.issues.find((i: any) => i.rule === EXPRESSION_INVALID); + expect(issue, `issues: ${JSON.stringify(err.issues)}`).toBeDefined(); + expect(issue.path).toBe(WHERE); + }); + + it('(b) a valid, guarded `condition` still saves, and the row lands', async () => { + const { protocol, rows } = makeProtocol(); + + const result = await protocol.saveMetaItem({ type: 'object', name: 'fx_rule', item: fxRule(VALID) }); + + expect(result.success).toBe(true); + expect(ruleRows(rows).map((r) => r.state)).toEqual(['active']); + }); + + it('(d) the door and `os build` give the SAME findings for each refused body', async () => { + for (const { condition } of REFUSED) { + const { protocol } = makeProtocol(); + const err = await protocol + .saveMetaItem({ type: 'object', name: 'fx_rule', item: fxRule(condition) }) + .catch((e: any) => e); + const atDoor = (err.issues ?? []).filter((i: any) => i.rule === EXPRESSION_INVALID); + + const atBuild = buildFindings(fxRule(condition)); + + // Non-vacuous on both sides: one finding each, and an error at the build. + expect(atBuild, condition).toHaveLength(1); + expect(atBuild[0]!.severity).toBe('error'); + expect(atDoor, condition).toHaveLength(1); + // Compared key by key — the door reuses the build's call, so a reworded + // or relocated door verdict is a second dialect, and red. + for (const key of ['rule', 'where', 'path', 'message', 'hint'] as const) { + expect(atDoor[0][key], `door and build disagree on '${key}' for ${condition}`).toBe(atBuild[0]![key]); + } + } + // And the valid rule is clean at the build too, not just at the door. + expect(buildFindings(fxRule(VALID))).toEqual([]); + }); +}); From fcc1ae0c4121532be3b4c335edb0e2323dcf2294 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 22:00:12 +0000 Subject: [PATCH 3/3] chore(changeset): the object save door refuses a validation rule whose predicate os build refuses Claude-Session: https://claude.ai/code/session_01GV6oYwgc1kWiUCb1YaprQ7 Co-authored-by: Claude --- ...-object-save-door-validation-predicates.md | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 .changeset/22032-object-save-door-validation-predicates.md diff --git a/.changeset/22032-object-save-door-validation-predicates.md b/.changeset/22032-object-save-door-validation-predicates.md new file mode 100644 index 00000000000..b6630ed55bf --- /dev/null +++ b/.changeset/22032-object-save-door-validation-predicates.md @@ -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`. + +