From fe73510c5a61cf7f62a773e1765e8fe55bd6c6a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 03:41:07 +0000 Subject: [PATCH 1/6] fix(lint): refuse a validations[] predicate that reads parent The rule evaluator binds record and previous only; parent is bound for a field's own readonlyWhen / requiredWhen. Refuse it at os validate / build and the object save door, with the field-rule-slot prescription. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz --- packages/lint/src/validate-expressions.ts | 77 ++++++- ...expressions.validation-rule-parent.test.ts | 198 ++++++++++++++++++ 2 files changed, 273 insertions(+), 2 deletions(-) create mode 100644 packages/lint/src/validate-expressions.validation-rule-parent.test.ts diff --git a/packages/lint/src/validate-expressions.ts b/packages/lint/src/validate-expressions.ts index a8c1b422784..f015a1b7419 100644 --- a/packages/lint/src/validate-expressions.ts +++ b/packages/lint/src/validate-expressions.ts @@ -546,6 +546,10 @@ interface RulePredicate { raw: unknown; /** 0 for the rule itself; 1 inside its `then` / `otherwise`, and so on down. */ depth: number; + /** The key the predicate is written under on its own rule. */ + slot: 'condition' | 'when'; + /** The name of the rule that carries it (the nested rule's own, below depth 0). */ + ruleName: string; } /** @@ -566,9 +570,9 @@ function rulePredicates(rule: AnyRec, path: string, depth = 0): RulePredicate[] // author who wrote both `condition` and a rejected alias had their canonical // predicate short-circuited away and the alias validated instead (#5017). const main = rule.condition; - if (main != null) out.push({ label: `validation rule ${here}`, raw: main, depth }); + if (main != null) out.push({ label: `validation rule ${here}`, raw: main, depth, slot: 'condition', ruleName: name }); if (rule.when != null) { - out.push({ label: `validation rule ${here} when-predicate`, raw: rule.when, depth }); + out.push({ label: `validation rule ${here} when-predicate`, raw: rule.when, depth, slot: 'when', ruleName: name }); } for (const branch of ['then', 'otherwise'] as const) { const nested = rule[branch]; @@ -1704,6 +1708,61 @@ function optionVisibleWhenUserMemberIssue( }; } +/** + * [#24720] The root verdict for a `validations[]` rule's own predicates — a + * `script` / `cross_field` rule's `condition`, a `conditional` rule's `when`, + * and the same two slots on every rule nested in its `then` / `otherwise`: a + * predicate that reads `parent` is refused. + * + * ## Why it is refused + * + * ObjectQL evaluates every one of those predicates in `ruleScope(record, + * previous)` (`rule-validator.ts`: `checkPredicate`, `checkConditional`), which + * binds `record` and `previous` and no master-detail header. The header is + * bound as `parent` for a field's own `readonlyWhen` / `requiredWhen` only + * ({@link FIELD_RULE_BOUND_ROOTS}), on an object with exactly one + * `master_detail`. So the predicate faults (`Unknown variable: parent`) on + * every write it judges, and an unevaluable rule fails closed: the write is + * refused, the seed's own rows at boot included. A rule the runtime cannot + * evaluate must not pass a door (ADR-0049); nothing else refused it, because + * `parent` is in `SCOPE_ROOTS`, which the strict env declares, so the + * bare-reference check stays silent. The field-rule slots one level up accept + * the same text, which is what makes it the shape an author copies. + * + * ## Why `parent` alone + * + * This is the one root the record's own field rules bind and its validation + * rules do not: the credible copy, the one the runtime's own diagnostics name. + * Whether the other platform-wide roots the rule evaluator binds no more of + * (the acting user's among them) earn the same verdict is not decided here, + * because neither the runtime nor its callers were measured for them. + * + * ⛔ This verdict is withdrawn in the same change that binds `parent` for a + * `validations[]` rule, never before. + * + * `null` = nothing to report: the source does not parse (the syntax pass owns + * that), or it reads no `parent`. + */ +function validationRuleParentIssue( + objectName: string | undefined, + ruleName: string, + slot: 'condition' | 'when', + source: string, +): string | null { + if (!readsParentRoot(source)) return null; + const owner = objectName ? `'${objectName}'` : 'this object'; + return ( + `\`${slot}\` of validation rule '${ruleName}' reads \`parent\`, but the server evaluates a ` + + `\`validations[]\` rule against \`record\` and \`previous\` only, so \`parent\` is unbound there. ` + + `The rule can never be evaluated, and the server REFUSES every write it judges — a rule that ` + + `cannot be evaluated fails closed, seed rows included. The master-detail header is bound as ` + + `\`parent\` only for a field's own \`readonlyWhen\` and \`requiredWhen\`, on an object with exactly ` + + `one \`master_detail\`: to lock or require a field on the header's state, write the predicate on ` + + `that field's \`readonlyWhen\` or \`requiredWhen\` instead. For any other check, read a column ` + + `${owner} declares (denormalise the header value you need onto ${owner}).` + ); +} + /** * [#20078] The master object of an object with exactly ONE `master_detail` * relationship — the record the field level binds as `parent` — or `undefined` @@ -2809,6 +2868,20 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression if (p.depth === 0) continue; check(`object '${objectName}' · ${p.label}`, p.raw, objectName, 'record', undefined, true); } + // [#24720] The rule's root verdict, over every predicate it carries, + // located where `check()` above located the same predicate. `parent` is + // a `SCOPE_ROOTS` member, so `check()` never reports a bare reference to + // it and the two verdicts stay disjoint; see + // {@link validationRuleParentIssue}. + for (const p of predicates) { + const source = celSourceOf(p.raw); + const message = source ? validationRuleParentIssue(objectName, p.ruleName, p.slot, source) : null; + if (!message) continue; + const at = p.depth > 0 + ? `object '${objectName}' · ${p.label}` + : p.slot === 'when' ? `${where} when` : where; + issues.push({ where: at, message, source: source!, severity: 'error' }); + } // #4763 — null-guard gate over every predicate the rule carries, nested // `then`/`otherwise` branches included. for (const p of predicates) { diff --git a/packages/lint/src/validate-expressions.validation-rule-parent.test.ts b/packages/lint/src/validate-expressions.validation-rule-parent.test.ts new file mode 100644 index 00000000000..ce41f8ba784 --- /dev/null +++ b/packages/lint/src/validate-expressions.validation-rule-parent.test.ts @@ -0,0 +1,198 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #24720 — a `validations[]` rule's predicate that reads `parent` is refused + * at authoring, at `os validate` / `os build` and at the object save door. + * + * ## The state this closes + * + * ObjectQL evaluates every `validations[]` predicate — a `script` / + * `cross_field` rule's `condition` (`checkPredicate`), a `conditional` rule's + * `when` (`checkConditional`), and the rules nested in its `then` / + * `otherwise` — in `ruleScope(record, previous)`: `record` and `previous`, and + * no master-detail header. The header is bound as `parent` for a field's own + * `readonlyWhen` / `requiredWhen` only. `parent` is a `SCOPE_ROOTS` member, so + * the bare-reference check was silent on it, and a rule reading + * `parent.status` passed every door — then faulted (`Unknown variable: + * parent`) on every write it judged, which the server refuses as unevaluable, + * the seed's own rows included. + * + * ## The verdict + * + * One root verdict over every predicate the rule carries, located where + * `check()` locates the same predicate, refusing `parent` at error, with the + * prescription that is true for it: the field-rule slots where `parent` IS + * bound, or a denormalised column. The runtime is unchanged. + */ +import { describe, expect, it } from 'vitest'; +import { EXPRESSION_INVALID, runAuthoringRules } from './authoring-rules.js'; +import { runRuntimeAuthoringRules } from './runtime-gate.js'; +import { validateStackExpressions } from './validate-expressions.js'; + +const PARENT = "parent.status == 'paid'"; + +const header = { + name: 'fx_header', + label: 'Header', + sharingModel: 'private', + fields: { name: { type: 'text', label: 'Name' }, status: { type: 'text', label: 'Status' } }, +}; + +/** A detail with exactly ONE `master_detail` — the shape on which the field-rule slots bind `parent`. */ +const line = ( + validations: unknown[], + extraFields: Record = {}, + masters: number = 1, +) => ({ + name: 'fx_line', + label: 'Line', + sharingModel: 'private', + fields: { + ...(masters >= 1 ? { hdr: { type: 'master_detail', label: 'Header', reference: 'fx_header' } } : {}), + ...(masters >= 2 ? { hdr2: { type: 'master_detail', label: 'Header 2', reference: 'fx_header' } } : {}), + amount: { type: 'number', label: 'Amount' }, + parent_code: { type: 'text', label: 'Parent code' }, + ...extraFields, + }, + validations, +}); + +const script = (condition: string, name = 'paid_lock') => ({ name, type: 'script', condition, message: 'x' }); + +const stackOf = (...objects: unknown[]) => ({ objects }); + +const expressionFindings = (fs: readonly T[]): T[] => + fs.filter((f) => f.rule === EXPRESSION_INVALID); + +const commandFindings = (command: 'validate' | 'build', ...objects: unknown[]) => { + const stack = stackOf(...objects); + return expressionFindings(runAuthoringRules(command, { normalized: stack, parsed: stack })); +}; + +const gateObject = (item: unknown, context: unknown[]) => + runRuntimeAuthoringRules({ type: 'object', item, context: { objects: context } }); + +const dump = (r: unknown) => JSON.stringify(r, null, 2); + +const WHERE = "object 'fx_line' · validation 'paid_lock'"; + +describe('#24720 — a `validations[]` predicate reading `parent` is refused at authoring', () => { + it('⭐ LIT — a `script` rule\'s `condition` reading `parent` is refused at error, located at the rule', () => { + const issues = validateStackExpressions(stackOf(header, line([script(PARENT)]))); + + expect(issues, dump(issues)).toHaveLength(1); + expect(issues[0]).toMatchObject({ where: WHERE, severity: 'error', source: PARENT }); + expect(issues[0]!.message).toContain("validation rule 'paid_lock' reads `parent`"); + }); + + it('⭐ the prescription names the field-rule slots where `parent` IS bound, and the denormalise remedy', () => { + const [issue] = validateStackExpressions(stackOf(header, line([script(PARENT)]))); + + expect(issue!.message).toContain('`readonlyWhen`'); + expect(issue!.message).toContain('`requiredWhen`'); + expect(issue!.message).toContain("denormalise the header value you need onto 'fx_line'"); + }); + + it('⭐ the `os validate` and `os build` commands both give the finding', () => { + for (const command of ['validate', 'build'] as const) { + const found = commandFindings(command, header, line([script(PARENT)])); + expect(found, `${command}: ${dump(found)}`).toHaveLength(1); + expect(found[0]).toMatchObject({ severity: 'error', where: WHERE }); + } + }); + + it('⭐ a `cross_field` rule\'s `condition` is judged the same way', () => { + const rule = { name: 'paid_lock', type: 'cross_field', fields: ['amount'], condition: PARENT, message: 'x' }; + const issues = validateStackExpressions(stackOf(header, line([rule]))); + + expect(issues, dump(issues)).toHaveLength(1); + expect(issues[0]).toMatchObject({ where: WHERE, severity: 'error' }); + }); + + it('⭐ a `conditional` rule\'s `when`, and the rules in its `then` / `otherwise`, are judged at their own locations', () => { + const rule = { + type: 'conditional', + name: 'gate', + when: PARENT, + message: 'x', + then: { type: 'script', name: 'inner', condition: "parent.status != 'void'", message: 'y' }, + otherwise: { type: 'script', name: 'other', condition: 'record.amount != null && record.amount > 0', message: 'z' }, + }; + const issues = validateStackExpressions(stackOf(header, line([rule]))); + + expect(issues.map((i) => i.where), dump(issues)).toEqual([ + "object 'fx_line' · validation 'gate' when", + "object 'fx_line' · validation rule 'gate' then → 'inner'", + ]); + for (const i of issues) { + expect(i.severity).toBe('error'); + expect(i.message).toContain('reads `parent`'); + } + }); + + it('gives one finding per predicate however often it reads `parent`', () => { + const issues = validateStackExpressions( + stackOf(header, line([script(`${PARENT} && parent.name != ''`)])), + ); + expect(issues, dump(issues)).toHaveLength(1); + }); + + it('refuses it whatever the object\'s master count: no validation rule binds `parent` on any object', () => { + for (const masters of [0, 2]) { + const issues = validateStackExpressions(stackOf(header, line([script(PARENT)], {}, masters))); + const here = issues.filter((i) => i.where === WHERE); + expect(here, `${masters} masters: ${dump(issues)}`).toHaveLength(1); + expect(here[0]!.severity).toBe('error'); + } + }); + + it('⭐ CONTROL — `parent` on the field\'s own `readonlyWhen` / `requiredWhen` is still accepted', () => { + for (const key of ['readonlyWhen', 'requiredWhen']) { + const issues = validateStackExpressions( + stackOf(header, line([], { note: { type: 'text', label: 'Note', [key]: PARENT } })), + ); + expect(issues, `${key}: ${dump(issues)}`).toEqual([]); + } + }); + + it('⭐ CONTROL — `record` / `previous` reads, a column spelled like the root and the string `parent` pass', () => { + for (const condition of [ + 'record.amount != null && record.amount > 100', + 'previous != null && previous.amount != null && previous.amount > 100', + "record.parent_code == 'a'", + "record.parent_code == 'parent'", + ]) { + const issues = validateStackExpressions(stackOf(header, line([script(condition)]))); + expect(issues, `${condition}: ${dump(issues)}`).toEqual([]); + } + }); +}); + +describe('#24720 — the object door gives the build\'s verdict', () => { + it('⭐ LIT — the measured body is REFUSED at the door, located at the rule, naming the root', () => { + const result = gateObject(line([script(PARENT)]), [header]); + + expect(result.rulesRun).toContain('validateStackExpressions'); + const errs = expressionFindings(result.errors); + expect(errs, dump(result)).toHaveLength(1); + expect(errs[0]).toMatchObject({ severity: 'error', where: WHERE, path: WHERE }); + expect(errs[0]!.message).toContain("validation rule 'paid_lock' reads `parent`"); + }); + + it('⭐ PARITY — the door finding IS the build finding', () => { + const atBuild = commandFindings('build', header, line([script(PARENT)])); + const atDoor = expressionFindings(gateObject(line([script(PARENT)]), [header]).errors); + + expect(atBuild, dump(atBuild)).toHaveLength(1); + expect(atDoor, dump(atDoor)).toEqual(atBuild); + }); + + it('⭐ CONTROL — `parent` on a field\'s own `readonlyWhen` publishes clean, and the build agrees', () => { + const body = line([], { note: { type: 'text', label: 'Note', readonlyWhen: PARENT } }); + const result = gateObject(body, [header]); + + expect(expressionFindings(result.errors), dump(result)).toEqual([]); + expect(expressionFindings(result.advisories), dump(result)).toEqual([]); + expect(commandFindings('build', header, body)).toEqual([]); + }); +}); From 1f44fdfa0abe786655ee1112db6da628a14d7841 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 03:43:54 +0000 Subject: [PATCH 2/6] chore(changeset): validations[] parent refusal Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz --- .changeset/24720-validation-rule-parent.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .changeset/24720-validation-rule-parent.md diff --git a/.changeset/24720-validation-rule-parent.md b/.changeset/24720-validation-rule-parent.md new file mode 100644 index 00000000000..23f440418c3 --- /dev/null +++ b/.changeset/24720-validation-rule-parent.md @@ -0,0 +1,20 @@ +--- +"@objectstack/lint": patch +--- + +fix(lint): `os validate`, `os build` and the object save door refuse a `validations[]` rule whose predicate reads `parent` (#24720) + +Clause-②: no + +A `validations[]` rule's predicates — a `script` or `cross_field` rule's `condition`, a `conditional` rule's `when`, and the same slots on the rules nested in its `then` / `otherwise` — are evaluated by the server against `record` and `previous` only. The master-detail header is bound as `parent` for a field's own `readonlyWhen` and `requiredWhen` alone, on an object with exactly one `master_detail`. A rule that read `parent`, such as `parent.status == 'paid'`, still passed `os validate`, because `parent` is a root the platform declares. At runtime the predicate faulted (`Unknown variable: parent`) and every write the rule judged was refused as unevaluable, the seed's own rows at boot included. + +The build's expression rule (`validateStackExpressions`) now refuses such a predicate at `error`, located where the rule's other findings are (`object 'NAME' · validation 'RULE'`, `… when`, or the nested rule's label). The message names the slot, the rule and the root. The object save door runs the same pass, so its verdict is the build's finding: the same rule id (`expression-invalid`), location and message. + +**Remedy.** To lock or require a field on the header's state, write the predicate on that field's own `readonlyWhen` or `requiredWhen`, where `parent` is bound. For any other check, read a column the detail object declares, and denormalise the header value you need onto the detail. + +**Unchanged.** + +- The runtime is unchanged: a `validations[]` rule still binds `record` and `previous` and no `parent`. If the runtime comes to bind `parent` there, this refusal is lifted in that same change. +- `parent` on a field's own `readonlyWhen` / `requiredWhen` is still accepted, as before, and so is a `record` column spelled like the root (`record.parent_code`). +- Such a rule could never pass a single write, so no working stack moves. No example or package object in this repository authors `parent` in a `validations[]` rule. +- No public export or signature moves. From ba3518ed71ff56e522b0bdf7e88785e33b98bd35 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 04:13:01 +0000 Subject: [PATCH 3/6] chore(changeset): state which never-reached rules the parent verdict also refuses Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz --- .changeset/24720-validation-rule-parent.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/24720-validation-rule-parent.md b/.changeset/24720-validation-rule-parent.md index 23f440418c3..952b23eecb5 100644 --- a/.changeset/24720-validation-rule-parent.md +++ b/.changeset/24720-validation-rule-parent.md @@ -16,5 +16,5 @@ The build's expression rule (`validateStackExpressions`) now refuses such a pred - The runtime is unchanged: a `validations[]` rule still binds `record` and `previous` and no `parent`. If the runtime comes to bind `parent` there, this refusal is lifted in that same change. - `parent` on a field's own `readonlyWhen` / `requiredWhen` is still accepted, as before, and so is a `record` column spelled like the root (`record.parent_code`). -- Such a rule could never pass a single write, so no working stack moves. No example or package object in this repository authors `parent` in a `validations[]` rule. +- Such a rule refused every write it judged, so a stack that wrote through it never worked. The verdict judges the rule as written, the way the rest of the expression rule does: a rule marked `active: false`, a rule whose `events` a stack never fires, and a `conditional` branch whose `when` is never true are refused too, although the server never reached their `parent` read. No example or package object in this repository authors `parent` in a `validations[]` rule. - No public export or signature moves. From 8aa8af7310fcd234c598e9d7aa29ad8e4ed26135 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 04:59:10 +0000 Subject: [PATCH 4/6] chore(changeset): declare the parent verdict a breaking narrowing, as the option visibleWhen verdict was Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz --- .changeset/24720-validation-rule-parent.md | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/.changeset/24720-validation-rule-parent.md b/.changeset/24720-validation-rule-parent.md index 952b23eecb5..93c5301dfc6 100644 --- a/.changeset/24720-validation-rule-parent.md +++ b/.changeset/24720-validation-rule-parent.md @@ -1,20 +1,29 @@ --- -"@objectstack/lint": patch +"@objectstack/lint": minor --- -fix(lint): `os validate`, `os build` and the object save door refuse a `validations[]` rule whose predicate reads `parent` (#24720) +fix(lint)!: `os validate`, `os build` and the object save door refuse a `validations[]` rule whose predicate reads `parent` (#24720) -Clause-②: no +Clause-②: no (narrowing: `os build` and the object save door refuse a `validations[]` rule whose `condition` or `when` — nested `then` / `otherwise` rules included — reads `parent`, which the runtime's rule evaluator does not bind) A `validations[]` rule's predicates — a `script` or `cross_field` rule's `condition`, a `conditional` rule's `when`, and the same slots on the rules nested in its `then` / `otherwise` — are evaluated by the server against `record` and `previous` only. The master-detail header is bound as `parent` for a field's own `readonlyWhen` and `requiredWhen` alone, on an object with exactly one `master_detail`. A rule that read `parent`, such as `parent.status == 'paid'`, still passed `os validate`, because `parent` is a root the platform declares. At runtime the predicate faulted (`Unknown variable: parent`) and every write the rule judged was refused as unevaluable, the seed's own rows at boot included. The build's expression rule (`validateStackExpressions`) now refuses such a predicate at `error`, located where the rule's other findings are (`object 'NAME' · validation 'RULE'`, `… when`, or the nested rule's label). The message names the slot, the rule and the root. The object save door runs the same pass, so its verdict is the build's finding: the same rule id (`expression-invalid`), location and message. -**Remedy.** To lock or require a field on the header's state, write the predicate on that field's own `readonlyWhen` or `requiredWhen`, where `parent` is bound. For any other check, read a column the detail object declares, and denormalise the header value you need onto the detail. +**BREAKING — what moves for consumers.** + +- `os build`, `os validate` and `os lint` refuse a `validations[]` rule whose predicate reads `parent`. The verdict judges the rule as written, the way the rest of the expression rule does: a rule marked `active: false`, a rule whose `events` a stack never fires, and a `conditional` branch whose `when` is never true are refused too, although the server never reached their `parent` read. Those stacks built and wrote before this change. +- An object write in publish mode that carries such a rule answered 200. 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`). + +**Remedy.** To lock or require a field on the header's state, write the predicate on that field's own `readonlyWhen` or `requiredWhen`, where `parent` is bound. For any other check, read a column the detail object declares, and denormalise the header value you need onto the detail. Saving the object as a draft (`mode: 'draft'`) is still allowed, because drafts are never gated; publishing that draft is judged. **Unchanged.** - The runtime is unchanged: a `validations[]` rule still binds `record` and `previous` and no `parent`. If the runtime comes to bind `parent` there, this refusal is lifted in that same change. - `parent` on a field's own `readonlyWhen` / `requiredWhen` is still accepted, as before, and so is a `record` column spelled like the root (`record.parent_code`). -- Such a rule refused every write it judged, so a stack that wrote through it never worked. The verdict judges the rule as written, the way the rest of the expression rule does: a rule marked `active: false`, a rule whose `events` a stack never fires, and a `conditional` branch whose `when` is never true are refused too, although the server never reached their `parent` read. No example or package object in this repository authors `parent` in a `validations[]` rule. -- No public export or signature moves. +- 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, and that save is judged. +- `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write. +- No example or package object in this repository authors `parent` in a `validations[]` rule: the stock showcase passes `os validate` before and after this change. +- No public export or signature moves. `validateStackExpressions(stack)` keeps its signature, and no registry entry changes: the expression rule already declared `object`. + + From 669007ba720678293df209decf5a900de2dd34b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 14:26:04 +0000 Subject: [PATCH 5/6] fix(lint): report a validations[] parent read at warning in 18.x, refusal planned for 19.0 Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz --- packages/lint/src/validate-expressions.ts | 27 +++++++++--- ...expressions.validation-rule-parent.test.ts | 43 +++++++++++-------- 2 files changed, 45 insertions(+), 25 deletions(-) diff --git a/packages/lint/src/validate-expressions.ts b/packages/lint/src/validate-expressions.ts index 3286d09816e..eff83c96b2e 100644 --- a/packages/lint/src/validate-expressions.ts +++ b/packages/lint/src/validate-expressions.ts @@ -1718,9 +1718,9 @@ function optionVisibleWhenUserMemberIssue( * [#24720] The root verdict for a `validations[]` rule's own predicates — a * `script` / `cross_field` rule's `condition`, a `conditional` rule's `when`, * and the same two slots on every rule nested in its `then` / `otherwise`: a - * predicate that reads `parent` is refused. + * predicate that reads `parent` is reported, at `warning` (18.x). * - * ## Why it is refused + * ## Why it is reported * * ObjectQL evaluates every one of those predicates in `ruleScope(record, * previous)` (`rule-validator.ts`: `checkPredicate`, `checkConditional`), which @@ -1729,12 +1729,23 @@ function optionVisibleWhenUserMemberIssue( * ({@link FIELD_RULE_BOUND_ROOTS}), on an object with exactly one * `master_detail`. So the predicate faults (`Unknown variable: parent`) on * every write it judges, and an unevaluable rule fails closed: the write is - * refused, the seed's own rows at boot included. A rule the runtime cannot - * evaluate must not pass a door (ADR-0049); nothing else refused it, because - * `parent` is in `SCOPE_ROOTS`, which the strict env declares, so the + * refused, the seed's own rows at boot included. Nothing else named it, + * because `parent` is in `SCOPE_ROOTS`, which the strict env declares, so the * bare-reference check stays silent. The field-rule slots one level up accept * the same text, which is what makes it the shape an author copies. * + * ## Why a warning, and when it becomes a refusal + * + * A rule the runtime cannot evaluate must not pass a door (ADR-0049), so the + * end state is `error`. But refusing it narrows what `os build` and the object + * save door accept (a rule marked `active: false`, or a `conditional` branch + * whose `when` is never true, builds and writes today), and no ADR-0087 + * conversion can rewrite a predicate that reads `parent`. Under the standing + * post-GA ruling (甲), a narrowing no conversion carries ships only its loud + * half in 18.x: this verdict reports at `warning`, which neither fails + * `os build` nor refuses a save (the door lists it among its advisories). The + * refusal — the same finding at `error` — is planned for 19.0. + * * ## Why `parent` alone * * This is the one root the record's own field rules bind and its validation @@ -1761,7 +1772,8 @@ function validationRuleParentIssue( `\`${slot}\` of validation rule '${ruleName}' reads \`parent\`, but the server evaluates a ` + `\`validations[]\` rule against \`record\` and \`previous\` only, so \`parent\` is unbound there. ` + `The rule can never be evaluated, and the server REFUSES every write it judges — a rule that ` + - `cannot be evaluated fails closed, seed rows included. The master-detail header is bound as ` + + `cannot be evaluated fails closed, seed rows included. This is a warning today; ` + + `\`os build\` and the object save door will refuse it from 19.0. The master-detail header is bound as ` + `\`parent\` only for a field's own \`readonlyWhen\` and \`requiredWhen\`, on an object with exactly ` + `one \`master_detail\`: to lock or require a field on the header's state, write the predicate on ` + `that field's \`readonlyWhen\` or \`requiredWhen\` instead. For any other check, read a column ` + @@ -2919,7 +2931,8 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression const at = p.depth > 0 ? `object '${objectName}' · ${p.label}` : p.slot === 'when' ? `${where} when` : where; - issues.push({ where: at, message, source: source!, severity: 'error' }); + // `warning` in 18.x, `error` from 19.0: see the docblock's ruling. + issues.push({ where: at, message, source: source!, severity: 'warning' }); } // #4763 — null-guard gate over every predicate the rule carries, nested // `then`/`otherwise` branches included. diff --git a/packages/lint/src/validate-expressions.validation-rule-parent.test.ts b/packages/lint/src/validate-expressions.validation-rule-parent.test.ts index ce41f8ba784..1031c85ae69 100644 --- a/packages/lint/src/validate-expressions.validation-rule-parent.test.ts +++ b/packages/lint/src/validate-expressions.validation-rule-parent.test.ts @@ -1,8 +1,10 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * #24720 — a `validations[]` rule's predicate that reads `parent` is refused - * at authoring, at `os validate` / `os build` and at the object save door. + * #24720 — a `validations[]` rule's predicate that reads `parent` is reported + * at authoring, at `os validate` / `os build` and at the object save door, at + * `warning` in 18.x (the standing post-GA ruling 甲: a narrowing no ADR-0087 + * conversion carries ships its loud half first; the refusal comes in 19.0). * * ## The state this closes * @@ -20,7 +22,7 @@ * ## The verdict * * One root verdict over every predicate the rule carries, located where - * `check()` locates the same predicate, refusing `parent` at error, with the + * `check()` locates the same predicate, reporting `parent` at warning, with the * prescription that is true for it: the field-rule slots where `parent` IS * bound, or a denormalised column. The runtime is unchanged. */ @@ -76,12 +78,12 @@ const dump = (r: unknown) => JSON.stringify(r, null, 2); const WHERE = "object 'fx_line' · validation 'paid_lock'"; -describe('#24720 — a `validations[]` predicate reading `parent` is refused at authoring', () => { - it('⭐ LIT — a `script` rule\'s `condition` reading `parent` is refused at error, located at the rule', () => { +describe('#24720 — a `validations[]` predicate reading `parent` is reported at authoring', () => { + it('⭐ LIT — a `script` rule\'s `condition` reading `parent` is reported at warning, located at the rule', () => { const issues = validateStackExpressions(stackOf(header, line([script(PARENT)]))); expect(issues, dump(issues)).toHaveLength(1); - expect(issues[0]).toMatchObject({ where: WHERE, severity: 'error', source: PARENT }); + expect(issues[0]).toMatchObject({ where: WHERE, severity: 'warning', source: PARENT }); expect(issues[0]!.message).toContain("validation rule 'paid_lock' reads `parent`"); }); @@ -93,11 +95,15 @@ describe('#24720 — a `validations[]` predicate reading `parent` is refused at expect(issue!.message).toContain("denormalise the header value you need onto 'fx_line'"); }); - it('⭐ the `os validate` and `os build` commands both give the finding', () => { + it('⭐ the `os validate` and `os build` commands both give the finding, at warning: nothing they gate on', () => { for (const command of ['validate', 'build'] as const) { - const found = commandFindings(command, header, line([script(PARENT)])); + const stack = stackOf(header, line([script(PARENT)])); + const all = runAuthoringRules(command, { normalized: stack, parsed: stack }); + const found = expressionFindings(all); expect(found, `${command}: ${dump(found)}`).toHaveLength(1); - expect(found[0]).toMatchObject({ severity: 'error', where: WHERE }); + expect(found[0]).toMatchObject({ severity: 'warning', where: WHERE }); + // The 18.x half of the ruling: no `error` anywhere, so the command does not fail. + expect(all.filter((f) => f.severity === 'error'), `${command}: ${dump(all)}`).toEqual([]); } }); @@ -106,7 +112,7 @@ describe('#24720 — a `validations[]` predicate reading `parent` is refused at const issues = validateStackExpressions(stackOf(header, line([rule]))); expect(issues, dump(issues)).toHaveLength(1); - expect(issues[0]).toMatchObject({ where: WHERE, severity: 'error' }); + expect(issues[0]).toMatchObject({ where: WHERE, severity: 'warning' }); }); it('⭐ a `conditional` rule\'s `when`, and the rules in its `then` / `otherwise`, are judged at their own locations', () => { @@ -125,7 +131,7 @@ describe('#24720 — a `validations[]` predicate reading `parent` is refused at "object 'fx_line' · validation rule 'gate' then → 'inner'", ]); for (const i of issues) { - expect(i.severity).toBe('error'); + expect(i.severity).toBe('warning'); expect(i.message).toContain('reads `parent`'); } }); @@ -142,7 +148,7 @@ describe('#24720 — a `validations[]` predicate reading `parent` is refused at const issues = validateStackExpressions(stackOf(header, line([script(PARENT)], {}, masters))); const here = issues.filter((i) => i.where === WHERE); expect(here, `${masters} masters: ${dump(issues)}`).toHaveLength(1); - expect(here[0]!.severity).toBe('error'); + expect(here[0]!.severity).toBe('warning'); } }); @@ -169,19 +175,20 @@ describe('#24720 — a `validations[]` predicate reading `parent` is refused at }); describe('#24720 — the object door gives the build\'s verdict', () => { - it('⭐ LIT — the measured body is REFUSED at the door, located at the rule, naming the root', () => { + it('⭐ LIT — the measured body is ADMITTED at the door with the finding as an advisory, located at the rule', () => { const result = gateObject(line([script(PARENT)]), [header]); expect(result.rulesRun).toContain('validateStackExpressions'); - const errs = expressionFindings(result.errors); - expect(errs, dump(result)).toHaveLength(1); - expect(errs[0]).toMatchObject({ severity: 'error', where: WHERE, path: WHERE }); - expect(errs[0]!.message).toContain("validation rule 'paid_lock' reads `parent`"); + expect(result.errors, dump(result)).toEqual([]); + const advs = expressionFindings(result.advisories); + expect(advs, dump(result)).toHaveLength(1); + expect(advs[0]).toMatchObject({ severity: 'warning', where: WHERE, path: WHERE }); + expect(advs[0]!.message).toContain("validation rule 'paid_lock' reads `parent`"); }); it('⭐ PARITY — the door finding IS the build finding', () => { const atBuild = commandFindings('build', header, line([script(PARENT)])); - const atDoor = expressionFindings(gateObject(line([script(PARENT)]), [header]).errors); + const atDoor = expressionFindings(gateObject(line([script(PARENT)]), [header]).advisories); expect(atBuild, dump(atBuild)).toHaveLength(1); expect(atDoor, dump(atDoor)).toEqual(atBuild); From b784487c01141fb5e7c134e09701d547b0287f86 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 14:35:35 +0000 Subject: [PATCH 6/6] chore(changeset): the parent verdict ships at warning in 18.x, a patch with no narrowing Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz --- .changeset/24720-validation-rule-parent.md | 29 ++++++++-------------- 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/.changeset/24720-validation-rule-parent.md b/.changeset/24720-validation-rule-parent.md index 93c5301dfc6..2efe9027f54 100644 --- a/.changeset/24720-validation-rule-parent.md +++ b/.changeset/24720-validation-rule-parent.md @@ -1,29 +1,22 @@ --- -"@objectstack/lint": minor +"@objectstack/lint": patch --- -fix(lint)!: `os validate`, `os build` and the object save door refuse a `validations[]` rule whose predicate reads `parent` (#24720) +fix(lint): `os validate`, `os build` and the object save door warn on a `validations[]` rule whose predicate reads `parent` (#24720) -Clause-②: no (narrowing: `os build` and the object save door refuse a `validations[]` rule whose `condition` or `when` — nested `then` / `otherwise` rules included — reads `parent`, which the runtime's rule evaluator does not bind) +Clause-②: no -A `validations[]` rule's predicates — a `script` or `cross_field` rule's `condition`, a `conditional` rule's `when`, and the same slots on the rules nested in its `then` / `otherwise` — are evaluated by the server against `record` and `previous` only. The master-detail header is bound as `parent` for a field's own `readonlyWhen` and `requiredWhen` alone, on an object with exactly one `master_detail`. A rule that read `parent`, such as `parent.status == 'paid'`, still passed `os validate`, because `parent` is a root the platform declares. At runtime the predicate faulted (`Unknown variable: parent`) and every write the rule judged was refused as unevaluable, the seed's own rows at boot included. +A `validations[]` rule's predicates — a `script` or `cross_field` rule's `condition`, a `conditional` rule's `when`, and the same slots on the rules nested in its `then` / `otherwise` — are evaluated by the server against `record` and `previous` only. The master-detail header is bound as `parent` for a field's own `readonlyWhen` and `requiredWhen` alone, on an object with exactly one `master_detail`. A rule that read `parent`, such as `parent.status == 'paid'`, passed `os validate` silently, because `parent` is a root the platform declares. At runtime the predicate faulted (`Unknown variable: parent`) and every write the rule judged was refused as unevaluable, the seed's own rows at boot included. -The build's expression rule (`validateStackExpressions`) now refuses such a predicate at `error`, located where the rule's other findings are (`object 'NAME' · validation 'RULE'`, `… when`, or the nested rule's label). The message names the slot, the rule and the root. The object save door runs the same pass, so its verdict is the build's finding: the same rule id (`expression-invalid`), location and message. +The build's expression rule (`validateStackExpressions`) now reports such a predicate at `warning`, located where the rule's other findings are (`object 'NAME' · validation 'RULE'`, `… when`, or the nested rule's label). The message names the slot, the rule and the root. The object save door runs the same pass and lists the same finding among its advisories: the same rule id (`expression-invalid`), location and message. -**BREAKING — what moves for consumers.** +**Nothing new is refused in 18.x.** A warning fails neither `os build` nor `os validate`, and a publish-mode object save that carries such a rule is still admitted. No ADR-0087 conversion can rewrite a predicate that reads `parent`, so under the post-GA rule for such narrowings only this warning ships in 18.x. The refusal, the same finding at `error`, is planned for 19.0. It will also cover a rule marked `active: false`, a rule whose `events` a stack never fires, and a `conditional` branch whose `when` is never true, because the verdict judges the rule as written. -- `os build`, `os validate` and `os lint` refuse a `validations[]` rule whose predicate reads `parent`. The verdict judges the rule as written, the way the rest of the expression rule does: a rule marked `active: false`, a rule whose `events` a stack never fires, and a `conditional` branch whose `when` is never true are refused too, although the server never reached their `parent` read. Those stacks built and wrote before this change. -- An object write in publish mode that carries such a rule answered 200. 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`). - -**Remedy.** To lock or require a field on the header's state, write the predicate on that field's own `readonlyWhen` or `requiredWhen`, where `parent` is bound. For any other check, read a column the detail object declares, and denormalise the header value you need onto the detail. Saving the object as a draft (`mode: 'draft'`) is still allowed, because drafts are never gated; publishing that draft is judged. +**Remedy.** To lock or require a field on the header's state, write the predicate on that field's own `readonlyWhen` or `requiredWhen`, where `parent` is bound. For any other check, read a column the detail object declares, and denormalise the header value you need onto the detail. **Unchanged.** -- The runtime is unchanged: a `validations[]` rule still binds `record` and `previous` and no `parent`. If the runtime comes to bind `parent` there, this refusal is lifted in that same change. -- `parent` on a field's own `readonlyWhen` / `requiredWhen` is still accepted, as before, and so is a `record` column spelled like the root (`record.parent_code`). -- 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, and that save is judged. -- `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write. -- No example or package object in this repository authors `parent` in a `validations[]` rule: the stock showcase passes `os validate` before and after this change. -- No public export or signature moves. `validateStackExpressions(stack)` keeps its signature, and no registry entry changes: the expression rule already declared `object`. - - +- The runtime is unchanged: a `validations[]` rule still binds `record` and `previous` and no `parent`. If the runtime comes to bind `parent` there, this verdict is withdrawn in that same change. +- `parent` on a field's own `readonlyWhen` / `requiredWhen` is still accepted with no finding, as before, and so is a `record` column spelled like the root (`record.parent_code`). +- No example or package object in this repository authors `parent` in a `validations[]` rule. +- No public export or signature moves.