Skip to content
Draft
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
2 changes: 1 addition & 1 deletion .changeset/24720-validation-rule-parent.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
---
"@objectstack/lint": patch
---
Expand All @@ -10,7 +10,7 @@

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.

**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.
**Nothing new is refused by this change.** 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 this change ships the warning first. The refusal, the same finding at `error`, follows in its own breaking entry (#24957). It also covers 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.

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

Expand Down
29 changes: 29 additions & 0 deletions .changeset/24957-validation-rule-parent-error.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
"@objectstack/lint": minor
---

fix(lint)!: `os build` and the object save door refuse a `validations[]` rule whose predicate reads `parent` (#24957)

Clause-②: no (narrowing: `os build` and the object save door refuse a `validations[]` rule whose predicate reads `parent`, which the server's rule evaluator never binds)

A `validations[]` rule's predicates are 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`. The server evaluates them 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 reads `parent`, such as `parent.status == 'paid'`, faults (`Unknown variable: parent`) on every write it judges, and the server refuses that write as unevaluable, the seed's own rows at boot included.

The build's expression rule (`validateStackExpressions`) reported such a predicate at `warning` (#24720). It now refuses it at `error`, at the same location (`object 'NAME' · validation 'RULE'`, `… when`, or the nested rule's label), with the same rule id (`expression-invalid`). The message no longer says it is a warning. The object save door runs the same pass, so its verdict is the build's finding.

**BREAKING — what moves for consumers.**

- `os build`, `os validate` and `os lint` fail on a `validations[]` rule whose predicate reads `parent`. The rule is judged as written, so this also covers a rule marked `active: false`, a rule whose `events` a stack never fires, and a `conditional` branch whose `when` is never true.
- An object write in publish mode that carries such a rule answered 200, with the finding among its advisories. It now answers `422 INVALID_METADATA`, with an `expression-invalid` issue located at the 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. To refuse a whole write on the header's state (for example a new line under a paid invoice), use a lifecycle hook (`beforeInsert` / `beforeUpdate` / `beforeDelete`) that reads the header. 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`, and such a rule already refused every write it judged. If the runtime comes to bind `parent` there, this refusal is withdrawn in that same change.
- `parent` on a field's own `readonlyWhen` / `requiredWhen` is still accepted with no finding, 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, template or package object in this repository authors `parent` in a `validations[]` rule.
- No public export or signature moves.

<!-- adr-0087: not-required (no-migration-prescription) a refusal at `os build` and at the object save door of a validations[] rule predicate that reads `parent`, which the server's rule evaluator never binds: no authorable key, spelling, export or stored shape moves, and no stored row is read, rewritten or converted. A stored object carrying such a rule keeps loading until it is next saved, and the repair is the author's rewrite of the predicate (a field rule, a lifecycle hook, or a denormalised column), which no ledger entry can derive. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers this verdict (not already-registered); and the change is a build and door verdict, not a declaration (not runtime-interface-only or type-surface-only). -->
29 changes: 14 additions & 15 deletions packages/lint/src/validate-expressions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1715,10 +1715,10 @@ function optionVisibleWhenUserMemberIssue(
}

/**
* [#24720] The root verdict for a `validations[]` rule's own predicates — a
* [#24720, #24957] 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 reported, at `warning` (18.x).
* predicate that reads `parent` is refused, at `error`.
*
* ## Why it is reported
*
Expand All @@ -1734,17 +1734,17 @@ function optionVisibleWhenUserMemberIssue(
* 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
* ## Why an error
*
* 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.
* verdict is `error`: it fails `os build` and the object save door refuses a
* publish-mode write that carries it. The rule is judged as written, so 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. No ADR-0087
* conversion can rewrite a predicate that reads `parent`; the repair is the
* author's. The refusal ships without a deprecation window under the standing
* ruling (甲, as amended: a protocol narrowing ships as soon as it can, with no
* retirement release), which first shipped this verdict at `warning`.
*
* ## Why `parent` alone
*
Expand Down Expand Up @@ -1772,8 +1772,7 @@ 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. 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 ` +
`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 ` +
Expand Down Expand Up @@ -2931,8 +2930,8 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression
const at = p.depth > 0
? `object '${objectName}' · ${p.label}`
: p.slot === 'when' ? `${where} when` : where;
// `warning` in 18.x, `error` from 19.0: see the docblock's ruling.
issues.push({ where: at, message, source: source!, severity: 'warning' });
// `error`: a rule the runtime cannot evaluate must not pass a door; see the docblock.
issues.push({ where: at, message, source: source!, severity: 'error' });
}
// #4763 — null-guard gate over every predicate the rule carries, nested
// `then`/`otherwise` branches included.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #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).
* #24720, #24957 — a `validations[]` rule's predicate that reads `parent` is
* refused at authoring, at `os validate` / `os build` and at the object save
* door, at `error`. #24720 shipped the verdict at `warning`; #24957 makes it
* the refusal (the standing ruling 甲, as amended: a narrowing ships now).
*
* ## The state this closes
*
Expand All @@ -22,7 +22,7 @@
* ## The verdict
*
* One root verdict over every predicate the rule carries, located where
* `check()` locates the same predicate, reporting `parent` at warning, with the
* `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.
*/
Expand Down Expand Up @@ -78,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 reported at authoring', () => {
it('⭐ LIT — a `script` rule\'s `condition` reading `parent` is reported at warning, located at the rule', () => {
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: 'warning', source: PARENT });
expect(issues[0]).toMatchObject({ where: WHERE, severity: 'error', source: PARENT });
expect(issues[0]!.message).toContain("validation rule 'paid_lock' reads `parent`");
});

Expand All @@ -95,15 +95,30 @@ describe('#24720 — a `validations[]` predicate reading `parent` is reported 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, at warning: nothing they gate on', () => {
it('⭐ the `os validate` and `os build` commands both give the finding at error: the one the command fails on', () => {
for (const command of ['validate', 'build'] as const) {
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: '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([]);
expect(found[0]).toMatchObject({ severity: 'error', where: WHERE });
// It is the ONLY error the stack carries, so it is what fails the command.
expect(all.filter((f) => f.severity === 'error'), `${command}: ${dump(all)}`).toEqual(found);
}
});

it('⭐ the message promises no later release: it IS the refusal', () => {
const [issue] = validateStackExpressions(stackOf(header, line([script(PARENT)])));

// The retired promise is gone: the finding is the refusal, not a notice of one.
expect(issue!.message).not.toMatch(/warning|19\.0/);
});

it('⭐ the rule is judged as written: an inactive rule, or an update-only one, is refused too', () => {
for (const extra of [{ active: false }, { events: ['update'] }]) {
const issues = validateStackExpressions(stackOf(header, line([{ ...script(PARENT), ...extra }])));
expect(issues, `${JSON.stringify(extra)}: ${dump(issues)}`).toHaveLength(1);
expect(issues[0]).toMatchObject({ where: WHERE, severity: 'error' });
}
});

Expand All @@ -112,7 +127,7 @@ describe('#24720 — a `validations[]` predicate reading `parent` is reported at
const issues = validateStackExpressions(stackOf(header, line([rule])));

expect(issues, dump(issues)).toHaveLength(1);
expect(issues[0]).toMatchObject({ where: WHERE, severity: 'warning' });
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', () => {
Expand All @@ -131,7 +146,7 @@ describe('#24720 — a `validations[]` predicate reading `parent` is reported at
"object 'fx_line' · validation rule 'gate' then → 'inner'",
]);
for (const i of issues) {
expect(i.severity).toBe('warning');
expect(i.severity).toBe('error');
expect(i.message).toContain('reads `parent`');
}
});
Expand All @@ -148,7 +163,7 @@ describe('#24720 — a `validations[]` predicate reading `parent` is reported 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('warning');
expect(here[0]!.severity).toBe('error');
}
});

Expand All @@ -175,20 +190,21 @@ describe('#24720 — a `validations[]` predicate reading `parent` is reported at
});

describe('#24720 — the object door gives the build\'s verdict', () => {
it('⭐ LIT — the measured body is ADMITTED at the door with the finding as an advisory, located at the rule', () => {
it('⭐ LIT — the measured body is REFUSED at the door with the finding as an error, located at the rule', () => {
const result = gateObject(line([script(PARENT)]), [header]);

expect(result.rulesRun).toContain('validateStackExpressions');
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`");
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`");
// No longer an advisory: the finding moved, it was not duplicated.
expect(expressionFindings(result.advisories), dump(result)).toEqual([]);
});

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]).advisories);
const atDoor = expressionFindings(gateObject(line([script(PARENT)]), [header]).errors);

expect(atBuild, dump(atBuild)).toHaveLength(1);
expect(atDoor, dump(atDoor)).toEqual(atBuild);
Expand Down
Loading
Loading