From 6f9e6bda555d8ae993211b3845847e37e08ad8cb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 03:24:12 +0000 Subject: [PATCH 1/4] fix(lint)!: a validations[] predicate that reads parent is refused at error Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz --- packages/lint/src/validate-expressions.ts | 29 +++++---- ...expressions.validation-rule-parent.test.ts | 60 ++++++++++++------- 2 files changed, 52 insertions(+), 37 deletions(-) diff --git a/packages/lint/src/validate-expressions.ts b/packages/lint/src/validate-expressions.ts index eff83c96b2e..ca189c104a1 100644 --- a/packages/lint/src/validate-expressions.ts +++ b/packages/lint/src/validate-expressions.ts @@ -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 * @@ -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 * @@ -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 ` + @@ -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. 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 1031c85ae69..6c84825acf7 100644 --- a/packages/lint/src/validate-expressions.validation-rule-parent.test.ts +++ b/packages/lint/src/validate-expressions.validation-rule-parent.test.ts @@ -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 * @@ -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. */ @@ -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`"); }); @@ -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' }); } }); @@ -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', () => { @@ -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`'); } }); @@ -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'); } }); @@ -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); From 65d699d654d532c964e552b9622e644f21b25e2d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 03:46:45 +0000 Subject: [PATCH 2/4] test(metadata-protocol): the object save door refuses a validations[] predicate that reads parent (422) Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz --- .../protocol.runtime-authoring-gate.test.ts | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) 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 e07d8cee31e..9b6db1243c5 100644 --- a/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts +++ b/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts @@ -4003,3 +4003,119 @@ describe('runtime authoring gate on VIEW writes that name no package — the who expect(JSON.parse(activeViewRows(rows)[0]!.metadata)).toEqual(stored); }); }); + +/** + * [#24957] The object save door refuses a `validations[]` rule whose predicate + * reads `parent`, as `os build` does. + * + * ObjectQL evaluates every `validations[]` predicate against `record` and + * `previous` only; the master-detail header is bound as `parent` for a field's + * own `readonlyWhen` / `requiredWhen` alone. Such a rule faults on every write + * it judges and fails closed. #24720 reported it at `warning` (the door listed + * it among its advisories and admitted the save); this card makes it the + * refusal. + * + * The refusal is in `@objectstack/lint` (the validation pass's root verdict); + * no code here moves. Pinned through the REAL `saveMetaItem`, the header stored + * in the registry: + * + * (a) the measured body is refused on an active save — a 422 + * `INVALID_METADATA` carrying the build's located finding — and nothing + * lands; + * (b) the same body still saves as a DRAFT (drafts are never gated), and the + * draft's promotion is refused; + * (c) control: `parent` on the field's own `readonlyWhen` saves, and so does a + * rule reading `record`. + * + * ⚠️ As in the blocks 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 — a validations[] predicate reading parent (#24957)', () => { + const header = { + name: 'fx_header', + label: 'Header', + sharingModel: 'private', + fields: { name: { type: 'text', label: 'Name' }, status: { type: 'text', label: 'Status' } }, + }; + const line = (validations: unknown[], extraFields: Record = {}) => ({ + name: 'fx_line', + label: 'Line', + sharingModel: 'private', + fields: { + hdr: { type: 'master_detail', label: 'Header', reference: 'fx_header' }, + amount: { type: 'number', label: 'Amount' }, + ...extraFields, + }, + validations, + }); + /** The measured body. */ + const PARENT = "parent.status == 'paid'"; + const rule = (condition: string) => ({ name: 'paid_lock', type: 'script', condition, message: 'Locked.' }); + /** Where the build locates it — the rule the author edits. */ + const WHERE = "object 'fx_line' · validation 'paid_lock'"; + + const hostWithHeader = () => { + const { engine, rows } = makeStubEngine(); + engine.registry.listItems = (type: string) => (type === 'object' ? [header] : []); + const protocol = new ObjectStackProtocolImplementation(engine, () => new Map(), 'env_test') as any; + return { protocol, rows }; + }; + const lineRows = (rows: Map) => + Array.from(rows.values()).filter((r) => r.type === 'object' && r.name === 'fx_line'); + const saveLine = (protocol: any, item: unknown, extra: Record = {}) => + protocol.saveMetaItem({ type: 'object', name: 'fx_line', item, ...extra }); + + it('(a) REFUSES an active save with a 422 carrying the build\'s located finding, and nothing lands', async () => { + const { protocol, rows } = hostWithHeader(); + + const err = await saveLine(protocol, line([rule(PARENT)])).catch((e: any) => e); + + expect(err, 'the save resolved — the door still admits the rule').toBeInstanceOf(Error); + expect({ code: err.code, status: err.status }).toEqual({ code: 'INVALID_METADATA', status: 422 }); + const issues = err.issues.filter((i: any) => i.rule === EXPRESSION_INVALID); + expect(issues, `issues: ${JSON.stringify(err.issues)}`).toHaveLength(1); + expect(issues[0].path).toBe(WHERE); + expect(issues[0].severity).toBe('error'); + expect(issues[0].message).toContain("validation rule 'paid_lock' reads `parent`"); + expect(lineRows(rows)).toEqual([]); + + // The door's issue IS the build's finding. + const stack = { objects: [header, line([rule(PARENT)])] }; + const atBuild = runAuthoringRules('build', { normalized: stack, parsed: stack }) + .filter((f) => f.rule === EXPRESSION_INVALID); + expect(atBuild).toHaveLength(1); + for (const key of ['rule', 'where', 'path', 'message', 'severity'] as const) { + expect(issues[0][key], `door and build disagree on '${key}'`).toBe(atBuild[0]![key]); + } + }); + + it('(b) the same body still saves as a DRAFT, and its PROMOTION is refused', async () => { + const { protocol, rows } = hostWithHeader(); + + await expect(saveLine(protocol, line([rule(PARENT)]), { mode: 'draft' })).resolves.toMatchObject({ success: true }); + expect(lineRows(rows).map((r) => r.state)).toEqual(['draft']); + + const err = await protocol.publishMetaItem({ type: 'object', name: 'fx_line' }).catch((e: any) => e); + + expect({ code: err?.code, status: err?.status }).toEqual({ code: 'INVALID_METADATA', status: 422 }); + 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(lineRows(rows).map((r) => r.state)).toEqual(['draft']); + }); + + it('(c) control: `parent` on the field\'s own `readonlyWhen`, and a rule reading `record`, save and land', async () => { + for (const body of [ + line([], { note: { type: 'text', label: 'Note', readonlyWhen: PARENT } }), + line([rule('record.amount != null && record.amount > 0')]), + ]) { + const { protocol, rows } = hostWithHeader(); + + const result = await saveLine(protocol, body); + + expect(result.success, JSON.stringify(body)).toBe(true); + expect(lineRows(rows).map((r) => r.state)).toEqual(['active']); + } + }); +}); From 66e0df6213bb42fca8254b8d3d47d766650d39e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 03:52:57 +0000 Subject: [PATCH 3/4] chore(changeset): the validations[] parent refusal is a breaking lint minor; the warning entry stops promising 19.0 Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz --- .changeset/24720-validation-rule-parent.md | 2 +- .../24957-validation-rule-parent-error.md | 29 +++++++++++++++++++ .../protocol.runtime-authoring-gate.test.ts | 16 +++++++++- 3 files changed, 45 insertions(+), 2 deletions(-) create mode 100644 .changeset/24957-validation-rule-parent-error.md diff --git a/.changeset/24720-validation-rule-parent.md b/.changeset/24720-validation-rule-parent.md index 2efe9027f54..cebf91f511b 100644 --- a/.changeset/24720-validation-rule-parent.md +++ b/.changeset/24720-validation-rule-parent.md @@ -10,7 +10,7 @@ A `validations[]` rule's predicates — a `script` or `cross_field` rule's `cond 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. diff --git a/.changeset/24957-validation-rule-parent-error.md b/.changeset/24957-validation-rule-parent-error.md new file mode 100644 index 00000000000..dd9b2f7fee2 --- /dev/null +++ b/.changeset/24957-validation-rule-parent-error.md @@ -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. + + 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 9b6db1243c5..d7182d1f0e0 100644 --- a/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts +++ b/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts @@ -4023,7 +4023,7 @@ describe('runtime authoring gate on VIEW writes that name no package — the who * `INVALID_METADATA` carrying the build's located finding — and nothing * lands; * (b) the same body still saves as a DRAFT (drafts are never gated), and the - * draft's promotion is refused; + * draft's promotion and a package's draft publish are refused; * (c) control: `parent` on the field's own `readonlyWhen` saves, and so does a * rule reading `record`. * @@ -4105,6 +4105,20 @@ describe('runtime authoring gate on OBJECT writes — a validations[] predicate expect(lineRows(rows).map((r) => r.state)).toEqual(['draft']); }); + it("(b) a PACKAGE's draft publish of the same body is refused — nothing goes live", async () => { + const { protocol, rows } = hostWithHeader(); + await expect( + saveLine(protocol, line([rule(PARENT)]), { mode: 'draft', packageId: 'app.fx' }), + ).resolves.toMatchObject({ success: true }); + + const res = await protocol.publishPackageDrafts({ packageId: 'app.fx' }); + + expect(res.outcome, JSON.stringify(res)).toBe('refused'); + expect(res.publishedCount).toBe(0); + expect(res.failed).toEqual([expect.objectContaining({ type: 'object', name: 'fx_line', code: 'INVALID_METADATA' })]); + expect(lineRows(rows).map((r) => r.state)).toEqual(['draft']); + }); + it('(c) control: `parent` on the field\'s own `readonlyWhen`, and a rule reading `record`, save and land', async () => { for (const body of [ line([], { note: { type: 'text', label: 'Note', readonlyWhen: PARENT } }), From bf78cec3f72208f0c6746369716b096c007d11e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 05:13:09 +0000 Subject: [PATCH 4/4] chore(changeset): leave the 24720 entry as released; the 24957 entry declares Metadata-protocol: narrowed and supersedes its 19.0 plan --- .changeset/24720-validation-rule-parent.md | 2 +- .changeset/24957-validation-rule-parent-error.md | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.changeset/24720-validation-rule-parent.md b/.changeset/24720-validation-rule-parent.md index cebf91f511b..2efe9027f54 100644 --- a/.changeset/24720-validation-rule-parent.md +++ b/.changeset/24720-validation-rule-parent.md @@ -10,7 +10,7 @@ A `validations[]` rule's predicates — a `script` or `cross_field` rule's `cond 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 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. +**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. **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. diff --git a/.changeset/24957-validation-rule-parent-error.md b/.changeset/24957-validation-rule-parent-error.md index dd9b2f7fee2..2064cad8e1e 100644 --- a/.changeset/24957-validation-rule-parent-error.md +++ b/.changeset/24957-validation-rule-parent-error.md @@ -5,10 +5,11 @@ 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) +Metadata-protocol: narrowed 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. +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. **BREAKING — what moves for consumers.**