Skip to content

Commit c5915b2

Browse files
fix(lint)!: os build and the object save door refuse a validations[] rule whose predicate reads parent (#25052)
Closes #24957 Clause-②: no (narrowing: a `validations[]` rule that reads `parent` is refused at `os build` and the object save door; it ships now under the amended 「甲」, #6015 `6303865025`) Metadata-protocol: narrowed ## What changes A `validations[]` rule's predicates (a `script` / `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 ObjectQL in `ruleScope(record, previous)`, which binds no `parent`. Such a predicate faults (`Unknown variable: parent`) on every write it judges and fails closed. The 18.x verdict shipped at `warning` (PR #24763, `8cfe1f94cb`). This PR ships the refusal half, re-timed to 18.x as a BREAKING minor. - `packages/lint/src/validate-expressions.ts`: `validationRuleParentIssue`'s finding is pushed at `error` instead of `warning`. The message drops "This is a warning today; `os build` and the object save door will refuse it from 19.0." The docblock's "Why a warning, and when it becomes a refusal" section becomes "Why an error". The location, rule id, prescription and the field-rule controls are unchanged. - The object save door runs the same pass (`runStackExpressionPasses`), so the door's verdict moves from advisory to error with no door code change. A publish-mode object save now answers `422 INVALID_METADATA`. - Changeset `.changeset/24957-validation-rule-parent-error.md`: `@objectstack/lint` `minor`, BREAKING banner, `Clause-②: no (narrowing …)`, ADR-0087 marker `not-required (no-migration-prescription)` (shape of the `bb4f5cc005` precedent). ## The gate on #24684 (withdrawal condition) #24684 closed `completed` through PR #24731 (`576d0f3733`). It landed route B: three lifecycle hooks on `showcase_invoice_line`. It made no objectql, lint or spec change. On `origin/main` `3b3830ca22`, `checkPredicate` / `checkConditional` still call `ruleScope(record, previous)` (`rule-validator.ts` lines 4511 / 4695), and `ruleScope` adds no `parent`. So `parent` is still unbound for `validations[]`, and the withdrawal condition does not hold. ## Tests - `packages/lint/src/validate-expressions.validation-rule-parent.test.ts`: every severity assertion now reads `error`. `os validate` / `os build` carry the finding as their only error. The door finding is in `errors`, not `advisories`, and the door/build parity is unchanged. New: the message promises no later release, and an `active: false` rule or an update-only rule is refused too (the rule is judged as written). Controls unchanged: `parent` on a field's own `readonlyWhen` / `requiredWhen`, `record` / `previous` reads, `record.parent_code`. - `packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts`, new block `(#24957)` through the real `saveMetaItem`: (a) an active save is refused `422 INVALID_METADATA` with the build's located finding, and nothing lands; (b) a draft save still succeeds, and its promotion and a package draft publish are refused; (c) control: `parent` on `readonlyWhen`, and a `record` rule, save and land. - Reverse verification, through `dist/`: `scripts/ablation-replace.mjs` put the severity back to `warning` with a marker, then rebuilt lint. `ablation-dist-preflight` found the marker in 4 built files. The door block went red, 3 failed (a, b, b-package) and 1 passed (c control). After the restore (blob equal to HEAD, `git diff HEAD` empty), lint was rebuilt, preflight `--absent` passed, and the block was green, 4 of 4. - `pnpm --filter @objectstack/lint test`: 153 files, 7045 tests passed. `pnpm --filter @objectstack/lint typecheck`: exit 0. `pnpm --filter @objectstack/metadata-protocol typecheck`: exit 0. `protocol.runtime-authoring-gate.test.ts` in full: 179 passed (before the package-publish leg was added; the `-t 24957` run after it passed 4 of 4). - Dispatch-gates at head `66e0df6213`: 66 derived, 63 run, 3 NOT MEASURED (`check:dual-build-cjs-loads`, `check:lean-entry-closure`, `check:type-check-debt`; each exited 3, prerequisite not met: a full-workspace build). Those are declared to CI. ## Producers measured - In-repo, at `3b3830ca22`: 0 `validations[]` predicates read `parent` across `examples/`, `packages/create-objectstack`, `skills/` and package objects. The 10 files under `examples` / `packages/create-objectstack` / `skills` that author `validations:` were grepped for `parent`. Control leg: the same grep finds the field-rule reads (`examples/app-showcase/src/data/objects/invoice.object.ts`, 3 `readonlyWhen` hits), so it does reach the corpus. Nothing to fix. - HotCRM: NOT MEASURED, because no checkout is in this container. ## Acceptance notes - **Release text (route B, seat decision 2026-10-22).** This PR no longer touches the pending `.changeset/24720-validation-rule-parent.md`. That file is restored byte-identical to `main`, so `check:empty-changeset` is green. Instead, this PR's own `.changeset/24957-validation-rule-parent-error.md` says it supersedes the 24720 entry's "Nothing new is refused in 18.x" sentence and its 19.0 plan, so a CHANGELOG reader can tell which entry is current. - Open PR #24947 also edits `validate-expressions.ts`, in different hunks (imports, `StackExpressionOptions` docs, the bulk `confirmText` pass). There is no textual overlap with the verdict lines. - The claim's file surface did not name the metadata-protocol door block. It is declared here. - Unchanged and out of scope: `current_user` / `input` / `os` / `trigger` in a `validations[]` condition (the family the card notes for triage). --- _Generated by [Claude Code](https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c4b49c3 commit c5915b2

4 files changed

Lines changed: 212 additions & 37 deletions

File tree

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
"@objectstack/lint": minor
3+
---
4+
5+
fix(lint)!: `os build` and the object save door refuse a `validations[]` rule whose predicate reads `parent` (#24957)
6+
7+
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)
8+
Metadata-protocol: narrowed
9+
10+
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.
11+
12+
The build's expression rule (`validateStackExpressions`) reported such a predicate at `warning` (#24720). This entry supersedes that entry's sentence "Nothing new is refused in 18.x" and its plan to refuse in 19.0: the refusal ships now, in this release, and this entry is the current statement. The rule now refuses such a predicate 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.
13+
14+
**BREAKING — what moves for consumers.**
15+
16+
- `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.
17+
- 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`).
18+
19+
**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.
20+
21+
**Unchanged.**
22+
23+
- 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.
24+
- `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`).
25+
- 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.
26+
- `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write.
27+
- No example, template or package object in this repository authors `parent` in a `validations[]` rule.
28+
- No public export or signature moves.
29+
30+
<!-- 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). -->

‎packages/lint/src/validate-expressions.ts‎

Lines changed: 14 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1715,10 +1715,10 @@ function optionVisibleWhenUserMemberIssue(
17151715
}
17161716

17171717
/**
1718-
* [#24720] The root verdict for a `validations[]` rule's own predicates — a
1718+
* [#24720, #24957] The root verdict for a `validations[]` rule's own predicates — a
17191719
* `script` / `cross_field` rule's `condition`, a `conditional` rule's `when`,
17201720
* and the same two slots on every rule nested in its `then` / `otherwise`: a
1721-
* predicate that reads `parent` is reported, at `warning` (18.x).
1721+
* predicate that reads `parent` is refused, at `error`.
17221722
*
17231723
* ## Why it is reported
17241724
*
@@ -1734,17 +1734,17 @@ function optionVisibleWhenUserMemberIssue(
17341734
* bare-reference check stays silent. The field-rule slots one level up accept
17351735
* the same text, which is what makes it the shape an author copies.
17361736
*
1737-
* ## Why a warning, and when it becomes a refusal
1737+
* ## Why an error
17381738
*
17391739
* A rule the runtime cannot evaluate must not pass a door (ADR-0049), so the
1740-
* end state is `error`. But refusing it narrows what `os build` and the object
1741-
* save door accept (a rule marked `active: false`, or a `conditional` branch
1742-
* whose `when` is never true, builds and writes today), and no ADR-0087
1743-
* conversion can rewrite a predicate that reads `parent`. Under the standing
1744-
* post-GA ruling (甲), a narrowing no conversion carries ships only its loud
1745-
* half in 18.x: this verdict reports at `warning`, which neither fails
1746-
* `os build` nor refuses a save (the door lists it among its advisories). The
1747-
* refusal — the same finding at `error` — is planned for 19.0.
1740+
* verdict is `error`: it fails `os build` and the object save door refuses a
1741+
* publish-mode write that carries it. The rule is judged as written, so a rule
1742+
* marked `active: false`, a rule whose `events` a stack never fires, and a
1743+
* `conditional` branch whose `when` is never true are refused too. No ADR-0087
1744+
* conversion can rewrite a predicate that reads `parent`; the repair is the
1745+
* author's. The refusal ships without a deprecation window under the standing
1746+
* ruling (甲, as amended: a protocol narrowing ships as soon as it can, with no
1747+
* retirement release), which first shipped this verdict at `warning`.
17481748
*
17491749
* ## Why `parent` alone
17501750
*
@@ -1772,8 +1772,7 @@ function validationRuleParentIssue(
17721772
`\`${slot}\` of validation rule '${ruleName}' reads \`parent\`, but the server evaluates a ` +
17731773
`\`validations[]\` rule against \`record\` and \`previous\` only, so \`parent\` is unbound there. ` +
17741774
`The rule can never be evaluated, and the server REFUSES every write it judges — a rule that ` +
1775-
`cannot be evaluated fails closed, seed rows included. This is a warning today; ` +
1776-
`\`os build\` and the object save door will refuse it from 19.0. The master-detail header is bound as ` +
1775+
`cannot be evaluated fails closed, seed rows included. The master-detail header is bound as ` +
17771776
`\`parent\` only for a field's own \`readonlyWhen\` and \`requiredWhen\`, on an object with exactly ` +
17781777
`one \`master_detail\`: to lock or require a field on the header's state, write the predicate on ` +
17791778
`that field's \`readonlyWhen\` or \`requiredWhen\` instead. For any other check, read a column ` +
@@ -2931,8 +2930,8 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression
29312930
const at = p.depth > 0
29322931
? `object '${objectName}' · ${p.label}`
29332932
: p.slot === 'when' ? `${where} when` : where;
2934-
// `warning` in 18.x, `error` from 19.0: see the docblock's ruling.
2935-
issues.push({ where: at, message, source: source!, severity: 'warning' });
2933+
// `error`: a rule the runtime cannot evaluate must not pass a door; see the docblock.
2934+
issues.push({ where: at, message, source: source!, severity: 'error' });
29362935
}
29372936
// #4763 — null-guard gate over every predicate the rule carries, nested
29382937
// `then`/`otherwise` branches included.

‎packages/lint/src/validate-expressions.validation-rule-parent.test.ts‎

Lines changed: 38 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

33
/**
4-
* #24720 — a `validations[]` rule's predicate that reads `parent` is reported
5-
* at authoring, at `os validate` / `os build` and at the object save door, at
6-
* `warning` in 18.x (the standing post-GA ruling 甲: a narrowing no ADR-0087
7-
* conversion carries ships its loud half first; the refusal comes in 19.0).
4+
* #24720, #24957 — a `validations[]` rule's predicate that reads `parent` is
5+
* refused at authoring, at `os validate` / `os build` and at the object save
6+
* door, at `error`. #24720 shipped the verdict at `warning`; #24957 makes it
7+
* the refusal (the standing ruling 甲, as amended: a narrowing ships now).
88
*
99
* ## The state this closes
1010
*
@@ -22,7 +22,7 @@
2222
* ## The verdict
2323
*
2424
* One root verdict over every predicate the rule carries, located where
25-
* `check()` locates the same predicate, reporting `parent` at warning, with the
25+
* `check()` locates the same predicate, refusing `parent` at error, with the
2626
* prescription that is true for it: the field-rule slots where `parent` IS
2727
* bound, or a denormalised column. The runtime is unchanged.
2828
*/
@@ -78,12 +78,12 @@ const dump = (r: unknown) => JSON.stringify(r, null, 2);
7878

7979
const WHERE = "object 'fx_line' · validation 'paid_lock'";
8080

81-
describe('#24720 — a `validations[]` predicate reading `parent` is reported at authoring', () => {
82-
it('⭐ LIT — a `script` rule\'s `condition` reading `parent` is reported at warning, located at the rule', () => {
81+
describe('#24720 — a `validations[]` predicate reading `parent` is refused at authoring', () => {
82+
it('⭐ LIT — a `script` rule\'s `condition` reading `parent` is refused at error, located at the rule', () => {
8383
const issues = validateStackExpressions(stackOf(header, line([script(PARENT)])));
8484

8585
expect(issues, dump(issues)).toHaveLength(1);
86-
expect(issues[0]).toMatchObject({ where: WHERE, severity: 'warning', source: PARENT });
86+
expect(issues[0]).toMatchObject({ where: WHERE, severity: 'error', source: PARENT });
8787
expect(issues[0]!.message).toContain("validation rule 'paid_lock' reads `parent`");
8888
});
8989

@@ -95,15 +95,30 @@ describe('#24720 — a `validations[]` predicate reading `parent` is reported at
9595
expect(issue!.message).toContain("denormalise the header value you need onto 'fx_line'");
9696
});
9797

98-
it('⭐ the `os validate` and `os build` commands both give the finding, at warning: nothing they gate on', () => {
98+
it('⭐ the `os validate` and `os build` commands both give the finding at error: the one the command fails on', () => {
9999
for (const command of ['validate', 'build'] as const) {
100100
const stack = stackOf(header, line([script(PARENT)]));
101101
const all = runAuthoringRules(command, { normalized: stack, parsed: stack });
102102
const found = expressionFindings(all);
103103
expect(found, `${command}: ${dump(found)}`).toHaveLength(1);
104-
expect(found[0]).toMatchObject({ severity: 'warning', where: WHERE });
105-
// The 18.x half of the ruling: no `error` anywhere, so the command does not fail.
106-
expect(all.filter((f) => f.severity === 'error'), `${command}: ${dump(all)}`).toEqual([]);
104+
expect(found[0]).toMatchObject({ severity: 'error', where: WHERE });
105+
// It is the ONLY error the stack carries, so it is what fails the command.
106+
expect(all.filter((f) => f.severity === 'error'), `${command}: ${dump(all)}`).toEqual(found);
107+
}
108+
});
109+
110+
it('⭐ the message promises no later release: it IS the refusal', () => {
111+
const [issue] = validateStackExpressions(stackOf(header, line([script(PARENT)])));
112+
113+
// The retired promise is gone: the finding is the refusal, not a notice of one.
114+
expect(issue!.message).not.toMatch(/warning|19\.0/);
115+
});
116+
117+
it('⭐ the rule is judged as written: an inactive rule, or an update-only one, is refused too', () => {
118+
for (const extra of [{ active: false }, { events: ['update'] }]) {
119+
const issues = validateStackExpressions(stackOf(header, line([{ ...script(PARENT), ...extra }])));
120+
expect(issues, `${JSON.stringify(extra)}: ${dump(issues)}`).toHaveLength(1);
121+
expect(issues[0]).toMatchObject({ where: WHERE, severity: 'error' });
107122
}
108123
});
109124

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

114129
expect(issues, dump(issues)).toHaveLength(1);
115-
expect(issues[0]).toMatchObject({ where: WHERE, severity: 'warning' });
130+
expect(issues[0]).toMatchObject({ where: WHERE, severity: 'error' });
116131
});
117132

118133
it('⭐ a `conditional` rule\'s `when`, and the rules in its `then` / `otherwise`, are judged at their own locations', () => {
@@ -131,7 +146,7 @@ describe('#24720 — a `validations[]` predicate reading `parent` is reported at
131146
"object 'fx_line' · validation rule 'gate' then → 'inner'",
132147
]);
133148
for (const i of issues) {
134-
expect(i.severity).toBe('warning');
149+
expect(i.severity).toBe('error');
135150
expect(i.message).toContain('reads `parent`');
136151
}
137152
});
@@ -148,7 +163,7 @@ describe('#24720 — a `validations[]` predicate reading `parent` is reported at
148163
const issues = validateStackExpressions(stackOf(header, line([script(PARENT)], {}, masters)));
149164
const here = issues.filter((i) => i.where === WHERE);
150165
expect(here, `${masters} masters: ${dump(issues)}`).toHaveLength(1);
151-
expect(here[0]!.severity).toBe('warning');
166+
expect(here[0]!.severity).toBe('error');
152167
}
153168
});
154169

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

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

181196
expect(result.rulesRun).toContain('validateStackExpressions');
182-
expect(result.errors, dump(result)).toEqual([]);
183-
const advs = expressionFindings(result.advisories);
184-
expect(advs, dump(result)).toHaveLength(1);
185-
expect(advs[0]).toMatchObject({ severity: 'warning', where: WHERE, path: WHERE });
186-
expect(advs[0]!.message).toContain("validation rule 'paid_lock' reads `parent`");
197+
const errs = expressionFindings(result.errors);
198+
expect(errs, dump(result)).toHaveLength(1);
199+
expect(errs[0]).toMatchObject({ severity: 'error', where: WHERE, path: WHERE });
200+
expect(errs[0]!.message).toContain("validation rule 'paid_lock' reads `parent`");
201+
// No longer an advisory: the finding moved, it was not duplicated.
202+
expect(expressionFindings(result.advisories), dump(result)).toEqual([]);
187203
});
188204

189205
it('⭐ PARITY — the door finding IS the build finding', () => {
190206
const atBuild = commandFindings('build', header, line([script(PARENT)]));
191-
const atDoor = expressionFindings(gateObject(line([script(PARENT)]), [header]).advisories);
207+
const atDoor = expressionFindings(gateObject(line([script(PARENT)]), [header]).errors);
192208

193209
expect(atBuild, dump(atBuild)).toHaveLength(1);
194210
expect(atDoor, dump(atDoor)).toEqual(atBuild);

0 commit comments

Comments
 (0)