diff --git a/.changeset/22032-object-save-door-field-rule-slots.md b/.changeset/22032-object-save-door-field-rule-slots.md new file mode 100644 index 00000000000..b15fe741224 --- /dev/null +++ b/.changeset/22032-object-save-door-field-rule-slots.md @@ -0,0 +1,31 @@ +--- +"@objectstack/lint": minor +"@objectstack/metadata-protocol": minor +--- + +fix(lint)!: the object save door refuses a field-rule slot whose predicate `os build` refuses (#22032) + +Clause-②: no (narrowing) + +`formulas.mdx` says the same `validateExpression` validator backs `os build` and metadata registration. For a field's rule slots it did not, at the object save door. A field whose `requiredWhen` read a bare field, such as `amount > 1`, or whose `visibleWhen` called an unregistered function, such as `sqrt(record.amount) > 1`, was refused by `os build` at error, but `PUT /api/v1/meta/object/:name` answered 200 and stored it. + +The runtime publish gate now runs the build's field-rule-slot check on an object write. The build's expression rule (`validateStackExpressions`) was already on the object door for formula fields and validation-rule predicates. On an object write it now also judges each field's `requiredWhen`, `readonlyWhen` and `visibleWhen` the way the build does, with the build's three gates on them: the `parent` gate, the null-guard check over `requiredWhen`, and the refusal of a `requiredWhen` or `readonlyWhen` that reads through a reference field. The door's verdict is the build's finding: the same rule id (`expression-invalid`), location (`object 'NAME' · field 'FIELD' SLOT`), message and hint. + +**BREAKING — what moves for consumers.** + +- An object write in publish mode answered 200 for a field whose `requiredWhen`, `readonlyWhen` or `visibleWhen` the shared validator refuses. It now answers `422 INVALID_METADATA`, with an `expression-invalid` issue located at that slot. This covers `PUT /api/v1/meta/object/:name` (and `saveMetaItem` in publish mode), the promotion of a draft (`POST /api/v1/meta/object/:name/publish`, `publishMetaItem`), and a package draft publish (`publishPackageDrafts`). +- The verdict is the one `os build`, `os validate` and `os lint` already gave: an unknown function, a field the object does not declare, a bare field reference (`amount` instead of `record.amount`), a syntax error, a root a field-level rule never binds (such as `current_user`), a `parent` read on an object that does not declare exactly one `master_detail` relationship, an ordering or arithmetic operator in `requiredWhen` applied to a nullable field with no `!= null` guard, and a `requiredWhen` or `readonlyWhen` that reads through a reference field (`record.account.tier`, or `parent.REF.FIELD`). Its warnings now ride the save response as advisories. +- A detail object's `requiredWhen` or `readonlyWhen` that reads through one of its master's reference fields (`parent.REF.FIELD`) is judged whenever the master is in the write's context, and that includes a save of the master itself. So a master save can answer 422 with an issue located at a stored detail's field. Fix the detail's predicate, then save the master again. + +**Remedy.** Fix the predicate: the message names the unknown function or field, the unbound root, the unguarded operand or the reference read, and the position, as `os build` already requires. Qualify field reads as `record.FIELD`, use one of the functions `introspectScope` lists, guard a nullable operand in `requiredWhen` with `record.FIELD != null && …`, and move a check that must read through `record.REF` into a `validations[]` `script` rule, whose `condition` is read one hop through a reference; a read through `parent.REF` has no such surface, so read a column the master declares instead (denormalise the value onto it). Saving it as a draft (`mode: 'draft'`) is still allowed, because drafts are never gated; publishing that draft is judged. + +**Unchanged.** + +- Stored rows are not migrated, and they are not refused on read. An object stored before this change keeps loading until it is next saved. At that save the gate judges it, because the differential compares the write against the stored universe without its own stored row. +- `conditionalRequired` is still refused at the save door's schema step, before this gate, as a key retired in protocol 17; `os build` judges it as a field-rule slot as before. +- Option `visibleWhen` and the object's own action predicates are still not judged at this door. `os build` judges them, and the door does not, as before. +- `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write. +- Measured before crossing: every field-rule slot this repository ships has 0 refusals and 0 advisories, at the build and at the door. That is 9 slots on 8 fields of 3 objects (examples: 8 on `showcase_invoice` and `showcase_invoice_line`, three of them `parent`-scoped; the platform: 1 on `sys_permission_set`), over the 118 objects this repository ships. +- No public export or signature moves. `validateStackExpressions(stack)` keeps its signature, and no registry entry changes: the expression rule already declared `object`. + + diff --git a/packages/lint/src/authoring-rules.ts b/packages/lint/src/authoring-rules.ts index 2670ad6d5d7..fd1b6670d9c 100644 --- a/packages/lint/src/authoring-rules.ts +++ b/packages/lint/src/authoring-rules.ts @@ -309,8 +309,8 @@ export interface AuthoringRuleContext { * * [#22019] One other rule reads it, on that argument: `validateStackExpressions` * is one entry over several PASSES, and an `object` write is admitted for its - * field-formula pass and (#22032) its validation-rule pass alone - * (`runStackExpressionPasses`, `StackExpressionOptions`). The entry-level + * field-formula pass and (#22032) its validation-rule and field-rule-slot + * passes alone (`runStackExpressionPasses`, `StackExpressionOptions`). The entry-level * `runtimeTypes` can say that an object write reaches the rule; it cannot say * which of the rule's passes judge that write. */ @@ -613,6 +613,21 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ // the pass, and 0 door errors and 0 advisories at the door's own snapshot // shape, against 2 refusals at each for the card's two bodies in the same // harness. + // + // [#22032, pass 2] The field-rule-slot pass joins the object door: every + // field's `requiredWhen` / `readonlyWhen` / `conditionalRequired` / + // `visibleWhen`, with the `parent` gate, the `requiredWhen` null guard and + // the reference-traversal refusal — the same sentence of `formulas.mdx`, + // and the gap the card measured (a bare `requiredWhen: 'amount > 1'` saved + // with a 200). The per-option `visibleWhen` stays fenced. No entry-level + // change. MEASURED first, at both the raw and the parsed shape: every + // field-rule slot the repository ships — 9 slots on 8 fields of 3 objects + // (examples: app-showcase 8 slots on 2 objects, three of them + // `parent`-scoped; platform: plugin-security 1 on `sys_permission_set`), + // over 118 objects → 0 build + // errors and 0 warnings for the pass, and 0 door errors and 0 advisories + // at the door's own snapshot shape, against a refusal at each for the + // card's body in the same harness. surfaces: CLI_AND_RUNTIME, runtimeTypes: ['flow', 'action', 'hook', 'object'], run: (stack, ctx) => diff --git a/packages/lint/src/runtime-gate.object-field-rule-writes.test.ts b/packages/lint/src/runtime-gate.object-field-rule-writes.test.ts new file mode 100644 index 00000000000..373e49281d8 --- /dev/null +++ b/packages/lint/src/runtime-gate.object-field-rule-writes.test.ts @@ -0,0 +1,174 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #22032, pass 2 — the OBJECT write door runs the build's field-rule-slot + * pass. + * + * ## The state this closes + * + * `validateStackExpressions` is the build's expression rule. Its field walk + * judges every field's `requiredWhen` / `readonlyWhen` / `conditionalRequired` + * / `visibleWhen` as a `record`-scoped predicate, with the root verdict, the + * `parent` gate (a slot reading `parent` on an object that does not declare + * exactly one `master_detail`), the #4811 null-guard gate over `requiredWhen`, + * and the #20078 refusal of a `requiredWhen` / `readonlyWhen` read through a + * reference field. #22019 put that rule on the object door for its + * field-formula pass alone and fenced the rest off by name, so a bare + * `requiredWhen: 'amount > 1'` — refused by `os build` — published clean, and + * the write path then refused every write whose requirement it could not + * evaluate (ADR-0137 D2). + * + * ## The crossing + * + * No registry change: the entry already declares `object`. The fence in + * `runStackExpressionPasses` admits the field-rule slots on an object write, + * at the build's own position in the field walk, so the door's finding IS the + * build's finding — rule, location, message and hint. The per-option + * `visibleWhen` (pass 3) and the object's own action predicates (pass 4) stay + * fenced; that pin is in `runtime-gate.object-formula-writes.test.ts`. + * + * The protocol-level half — the same verdict through the real `saveMetaItem`, + * `publishMetaItem` and `publishPackageDrafts` — is the #22032 pass 2 block of + * `packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts`. + */ +import { describe, expect, it } from 'vitest'; +import { EXPRESSION_INVALID, runAuthoringRules } from './authoring-rules.js'; +import { runRuntimeAuthoringRules, runtimeAuthoringRulesFor } from './runtime-gate.js'; + +/** + * The probe object; `slots` lands on its `name` field. `sharingModel` keeps + * `security-owd-unset` quiet, so a refusal is the rule's. + */ +const fxField = (slots: Record) => ({ + name: 'fx_field', + label: 'Field Rule Probe', + sharingModel: 'private', + fields: { + name: { type: 'text', label: 'Name', ...slots }, + amount: { type: 'number', label: 'Amount' }, + status: { + type: 'select', + label: 'Status', + options: [{ label: 'Open', value: 'open' }, { label: 'Closed', value: 'closed' }], + }, + account: { type: 'lookup', label: 'Account', reference: 'fx_account' }, + }, +}); + +/** + * One refused body per slot and per gate of the pass. Each `subject` is the + * named subject of the build's finding (what the author typed), not its prose. + */ +const REFUSED = [ + // The card's body: a bare field reference. + { slot: 'requiredWhen', slots: { requiredWhen: 'amount > 1' }, subject: 'bare reference `amount`' }, + { slot: 'readonlyWhen', slots: { readonlyWhen: 'sqrt(record.amount) > 1' }, subject: '`sqrt`' }, + { slot: 'visibleWhen', slots: { visibleWhen: 'sqrt(record.amount) > 1' }, subject: '`sqrt`' }, + { slot: 'conditionalRequired', slots: { conditionalRequired: 'amount > 1' }, subject: 'bare reference `amount`' }, + // The root verdict: a root a field-level rule never binds. + { slot: 'requiredWhen', slots: { requiredWhen: 'current_user.id != null' }, subject: 'reads `current_user`' }, + // The `parent` gate: `fx_field` declares no `master_detail`. + { slot: 'readonlyWhen', slots: { readonlyWhen: "parent.status == 'paid'" }, subject: 'reads `parent`' }, + // The null-guard gate: `amount` is nullable, and the binding is total. + { slot: 'requiredWhen', slots: { requiredWhen: 'record.amount > 100' }, subject: '`record.amount`' }, + // The traversal refusal: a field-level predicate never reads the related record. + { slot: 'requiredWhen', slots: { requiredWhen: "record.account.name == 'x'" }, subject: 'through `record.account`' }, +] as const; + +/** Valid predicates on all four declared slots of the field walk. */ +const VALID = { + requiredWhen: 'record.amount != null && record.amount > 100', + readonlyWhen: "record.status == 'closed'", + visibleWhen: "record.status == 'open'", +}; + +/** A detail of `fx_field`: exactly one `master_detail`, so `parent` binds. */ +const fxLine = () => ({ + name: 'fx_line', + label: 'Line Probe', + sharingModel: 'private', + fields: { + header: { type: 'master_detail', label: 'Header', reference: 'fx_field' }, + qty: { + type: 'number', + label: 'Quantity', + readonlyWhen: "parent.status == 'closed'", + requiredWhen: "parent.status == 'open'", + }, + }, +}); + +const gateObject = (item: unknown, objects: unknown[] = []) => + runRuntimeAuthoringRules({ type: 'object', item, context: { objects } }); + +const expressionFindings = (fs: readonly T[]): T[] => + fs.filter((f) => f.rule === EXPRESSION_INVALID); + +const buildFindings = (...objects: unknown[]) => { + const stack = { objects }; + return expressionFindings(runAuthoringRules('build', { normalized: stack, parsed: stack })); +}; + +const dump = (r: unknown) => JSON.stringify(r, null, 2); + +describe('#22032 pass 2 — the object door gives the build\'s field-rule-slot verdict', () => { + it('needs no registry change: `validateStackExpressions` is already on the object door', () => { + expect(runtimeAuthoringRulesFor('object').map((r) => r.name)).toContain('validateStackExpressions'); + }); + + for (const { slot, slots, subject } of REFUSED) { + it(`⭐ LIT — \`${slot}: ${Object.values(slots)[0]}\` is REFUSED, located at the slot the author edits`, () => { + const result = gateObject(fxField(slots)); + + expect(result.rulesRun).toContain('validateStackExpressions'); + const errs = expressionFindings(result.errors); + const where = `object 'fx_field' · field 'name' ${slot}`; + expect(errs, dump(result)).toHaveLength(1); + expect(errs[0]).toMatchObject({ severity: 'error', where, path: where }); + expect(errs[0]!.message).toContain(subject); + }); + } + + it('⭐ CONTROL — valid predicates on every slot publish clean, and so does a `parent`-scoped detail', () => { + const result = gateObject(fxField(VALID)); + + expect(result.rulesRun).toContain('validateStackExpressions'); + expect(expressionFindings(result.errors), dump(result)).toEqual([]); + expect(expressionFindings(result.advisories), dump(result)).toEqual([]); + // The detail reads `parent` with its one master stored beside it. + const line = gateObject(fxLine(), [fxField(VALID)]); + expect(expressionFindings(line.errors), dump(line)).toEqual([]); + expect(expressionFindings(line.advisories), dump(line)).toEqual([]); + // And the build agrees: the control is clean at both doors, not only this one. + expect(buildFindings(fxField(VALID), fxLine())).toEqual([]); + }); + + it('⭐ PARITY — for each refused body the door findings ARE the build findings', () => { + for (const { slots } of REFUSED) { + const body = fxField(slots); + const atBuild = buildFindings(body); + const atDoor = expressionFindings(gateObject(body).errors); + + // Non-vacuous: the build refuses each of them. + expect(atBuild.length, dump(slots)).toBeGreaterThan(0); + expect(atDoor, dump(slots)).toEqual(atBuild); + } + }); + + it('a stored sibling\'s broken field rules are not this write\'s to answer for (the differential)', () => { + const sibling = { + ...fxField({}), + name: 'fx_sibling', + fields: { + ...fxField({}).fields, + ...Object.fromEntries(REFUSED.map(({ slots }, i) => [`f${i}`, { type: 'text', label: `F${i}`, ...slots }])), + }, + }; + // Non-vacuous: the sibling is refused at the build. + expect(buildFindings(sibling).length).toBeGreaterThan(0); + + const result = gateObject(fxField(VALID), [sibling]); + + expect(expressionFindings(result.errors), dump(result)).toEqual([]); + }); +}); diff --git a/packages/lint/src/runtime-gate.object-formula-writes.test.ts b/packages/lint/src/runtime-gate.object-formula-writes.test.ts index ca3016c63e2..c6411b7f5a5 100644 --- a/packages/lint/src/runtime-gate.object-formula-writes.test.ts +++ b/packages/lint/src/runtime-gate.object-formula-writes.test.ts @@ -19,12 +19,12 @@ * `object` joins `runtimeTypes`, and the gate's `runtimeWriteType` reaches the * rule (`runStackExpressionPasses`), which on an object write runs the * field-formula pass — and, since #22032's pass 1, the validation-rule pass - * (its pins: `runtime-gate.object-validation-writes.test.ts`). Every other - * object-borne pass the build runs — the field-rule slots, option - * `visibleWhen`, the object's own action predicates — is FENCED off this door - * by name, and the fence is pinned below with the build still flagging the - * same body, so a later widening moves that line consciously rather than by - * drift. + * (its pins: `runtime-gate.object-validation-writes.test.ts`), and since its + * pass 2 the field-rule slots (`runtime-gate.object-field-rule-writes.test.ts`). + * Every other object-borne pass the build runs — option `visibleWhen`, the + * object's own action predicates — is FENCED off this door by name, and the + * fence is pinned below with the build still flagging the same body, so a + * later widening moves that line consciously rather than by drift. * * The protocol-level half — the same verdict through the real `saveMetaItem` * and `publishMetaItem`, and the door/build equality of the finding — is @@ -114,13 +114,14 @@ describe('#22019 — the object door dispatches the build\'s expression rule', ( describe('#22019 — the fence: every other object-borne expression pass stays off this door', () => { /** - * One body carrying a fault in each FENCED pass — #22032's passes 2 to 4, - * one site each: a field-rule slot (`requiredWhen`), an option's - * `visibleWhen`, an object action's `visible` — beside a fault in the - * validation-rule pass (#22032 pass 1, LIFTED) and a CLEAN formula. The - * build flags every fault; the object door flags the lifted pass's alone. - * Each fault is one the build refuses at `error`, so "the door is silent on - * a fenced site" cannot be read as "there was nothing to say". + * One body carrying a fault in each FENCED pass — #22032's passes 3 and 4, + * one site each: an option's `visibleWhen`, an object action's `visible` — + * beside a fault in each LIFTED pass, the validation-rule pass (#22032 pass + * 1) and a field-rule slot (`requiredWhen`, #22032 pass 2), and a CLEAN + * formula. The build flags every fault; the object door flags the lifted + * passes' alone. Each fault is one the build refuses at `error`, so "the + * door is silent on a fenced site" cannot be read as "there was nothing to + * say". */ const fenced = () => fxSqrt('floor(record.amount)', { validations: [ @@ -141,31 +142,33 @@ describe('#22019 — the fence: every other object-borne expression pass stays o }; return body; }; - /** The four fenced sites (passes 2–4) and the lifted one (pass 1), by the build's `where`. */ + /** The fenced sites (passes 3–4) and the lifted ones (passes 1–2), by the build's `where`, in the build's order. */ const FENCED_SITES = [ - "object 'fx_sqrt' · field 'name' requiredWhen", "object 'fx_sqrt' · field 'tier' option 'gold' visibleWhen", "object 'fx_sqrt' · action 'fx_close' visible", ]; - const LIFTED_SITE = "object 'fx_sqrt' · validation 'amount_root'"; + const LIFTED_SITES = [ + "object 'fx_sqrt' · validation 'amount_root'", + "object 'fx_sqrt' · field 'name' requiredWhen", + ]; - it('the build (no `runtimeWriteType`) still flags each fenced site, and the lifted one', () => { + it('the build (no `runtimeWriteType`) still flags each fenced site, and the lifted ones', () => { const wheres = validateStackExpressions({ objects: [withFieldRule()] }) .filter((i) => (i.severity ?? 'error') === 'error') .map((i) => i.where); - for (const site of [...FENCED_SITES, LIFTED_SITE]) { + for (const site of [...FENCED_SITES, ...LIFTED_SITES]) { expect(wheres.includes(site), `${site}\n${dump(wheres)}`).toBe(true); } expect(wheres.some((w) => w === WHERE), 'the clean formula must not be flagged').toBe(false); }); - it('the object door flags none of the fenced sites — only the formula and validation-rule passes judge there', () => { + it('the object door flags none of the fenced sites — only the formula, validation-rule and field-rule-slot passes judge there', () => { const result = gateObject(withFieldRule()); expect(result.rulesRun).toContain('validateStackExpressions'); - // [#22032 pass 1] The lifted pass's finding, and nothing else. - expect(expressionFindings(result.errors).map((f) => f.where), dump(result)).toEqual([LIFTED_SITE]); + // [#22032 passes 1–2] The lifted passes' findings, and nothing else. + expect(expressionFindings(result.errors).map((f) => f.where), dump(result)).toEqual(LIFTED_SITES); expect(expressionFindings(result.advisories), dump(result)).toEqual([]); }); @@ -179,7 +182,7 @@ describe('#22019 — the fence: every other object-borne expression pass stays o // And the object pass set is the build's own findings on the admitted // passes — a strict subset, in the build's order, none from a fenced site. const onObjectWrite = runStackExpressionPasses(stack, { runtimeWriteType: 'object' }); - expect(onObjectWrite.map((i) => i.where)).toEqual([LIFTED_SITE]); - expect(onObjectWrite).toEqual(all.filter((i) => i.where === LIFTED_SITE || i.where === WHERE)); + expect(onObjectWrite.map((i) => i.where)).toEqual(LIFTED_SITES); + expect(onObjectWrite).toEqual(all.filter((i) => LIFTED_SITES.includes(i.where) || i.where === WHERE)); }); }); diff --git a/packages/lint/src/runtime-gate.object-writes.test.ts b/packages/lint/src/runtime-gate.object-writes.test.ts index fdc60e01da5..1f29cfa5524 100644 --- a/packages/lint/src/runtime-gate.object-writes.test.ts +++ b/packages/lint/src/runtime-gate.object-writes.test.ts @@ -113,8 +113,9 @@ describe('the object write door dispatches at the adjudicated scope (#4716)', () expect(runtimeAuthoringRulesFor('object').map((r) => r.name)).toEqual([ // [#22019] The build's expression rule joins, for some of its passes: a // formula field's `expression` — the `validateExpression` verdict the - // docs say backs metadata registration — and (#22032 pass 1) the - // validation-rule predicates. Its other object-borne passes are fenced + // docs say backs metadata registration — and (#22032 passes 1 and 2) the + // validation-rule predicates and the field-rule slots. Its other + // object-borne passes (option `visibleWhen`, action predicates) are fenced // off this door inside the rule (`StackExpressionOptions`), and that // fence is pinned in `runtime-gate.object-formula-writes.test.ts`. 'validateStackExpressions', diff --git a/packages/lint/src/validate-expressions.ts b/packages/lint/src/validate-expressions.ts index 55ee216f3d4..758396f8e4e 100644 --- a/packages/lint/src/validate-expressions.ts +++ b/packages/lint/src/validate-expressions.ts @@ -1182,7 +1182,7 @@ export interface StackExpressionOptions { * through by this rule's registry entry). ABSENT on `os build`, `os lint` * and `os validate`, which run every pass below. * - * On an `object` write exactly TWO passes judge, each the build's own call + * On an `object` write exactly THREE passes judge, each the build's own call * at the build's own position in the walk: * * - the field-formula pass over `fields[].expression` — the build's @@ -1199,16 +1199,24 @@ export interface StackExpressionOptions { * `otherwise` branches included. The same sentence of `formulas.mdx` * covers it, and the door gave none of it either: a rule whose * `condition` called `sqrt` or read a bare `amount` saved with a 200 and - * then faulted on every write the rule judged. + * then faulted on every write the rule judged; + * - [#22032, pass 2] the field-rule-slot pass over `fields[]` — each of + * `requiredWhen` / `readonlyWhen` / `conditionalRequired` / `visibleWhen` + * as a `record`-scoped predicate with its root verdict, plus the `parent` + * gate (a `readonlyWhen` / `requiredWhen` reading `parent` on an object + * without exactly one `master_detail`), the #4811 null-guard gate over + * `requiredWhen`, and the #20078 refusal of a `requiredWhen` / + * `readonlyWhen` read through a reference field. The same sentence of + * `formulas.mdx` covers it, and the door gave none of it either: a + * `requiredWhen` reading a bare `amount` saved with a 200, and the server + * refuses a write whose requirement it cannot evaluate (ADR-0137 D2). * * Every other pass is fenced off an object write, deliberately and by name: - * the field-rule slots (`requiredWhen` / `readonlyWhen` / - * `conditionalRequired` / `visibleWhen`) with their `parent` and null-guard - * gates, the per-option `visibleWhen`, and the object's own `actions[]` - * predicates. Each is a build verdict the save door still does not give, - * and each would narrow the accept set further than the crossings above — - * a crossing of its own, measured over the stored corpus first, not a rider - * on either of them. + * the per-option `visibleWhen` (inside the field walk, by its own guard) and + * the object's own `actions[]` predicates. Each is a build verdict the save + * door still does not give, and each would narrow the accept set further + * than the crossings above — a crossing of its own, measured over the stored + * corpus first, not a rider on any of them. */ runtimeWriteType?: string; } @@ -1233,10 +1241,11 @@ export function validateStackExpressions(stack: AnyRec): ExprIssue[] { export function runStackExpressionPasses(stack: AnyRec, options: StackExpressionOptions): ExprIssue[] { const issues: ExprIssue[] = []; // [#22019] See {@link StackExpressionOptions.runtimeWriteType}: on an object - // write only the field-formula pass and (#22032) the validation-rule pass - // judge. Every other loop below reads an empty list under it, so the claim - // holds by construction rather than by the shape of the snapshot the gate - // happens to build today. + // write only the field-formula pass and (#22032) the validation-rule and + // field-rule-slot passes judge. Every other loop below — the per-option + // `visibleWhen` loop inside the field walk included — reads an empty list + // under it, so the claim holds by construction rather than by the shape of + // the snapshot the gate happens to build today. const objectWrite = options.runtimeWriteType === 'object'; const objects = recordsOf(stack.objects); const fieldIndex = buildFieldIndex(objects); @@ -1940,10 +1949,11 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression /** * The field-formula pass — one computed field's `expression`. A closure - * rather than inline only so the runtime publish gate's object door - * (#22019) runs exactly this pass and no other slot of the field walk; on - * the three CLI commands it is called at the same point of the field walk - * it always ran at, so the build's findings and their order are unchanged. + * since #22019, whose object door ran this pass and no other slot of the + * field walk; since #22032's pass 2 the door runs the field-rule slots as + * well, and this is called at one point only — the same point of the field + * walk it always ran at — so the build's findings and their order are + * unchanged. */ const judgeFieldFormula = (fname: string, f: AnyRec): void => { if (f.expression) { @@ -1984,11 +1994,14 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression }; for (const [fname, f] of fieldList) { - // [#22019] The object write door: this field's formula, and no other slot. - if (objectWrite) { - judgeFieldFormula(fname, f); - continue; - } + // [#22032, pass 2] NOT fenced on an object write: the field-rule slots + // below — the four slots' root verdict, the `parent` gate, the + // `requiredWhen` null guard and the traversal refusal — are the pass the + // object save door runs, at the build's own position in this walk, so + // the door's findings and their order are the build's. The per-option + // `visibleWhen` loop between them is the one slot of this walk still + // fenced (pass 3), by its own guard. + // // Field-level conditional rules are server-enforced (rule-validator) and // record-scoped — a bare ref silently fails the rule (required/readonly // not enforced = data-integrity hole). #1928 class, same as actions. @@ -2022,7 +2035,10 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression // `checkFieldRuleRoot` above rejects it one level up, where nothing // binds it. Same helper, two verdicts, because the two surfaces have two // evaluators; neither verdict is a side effect of a shared root list. - for (const [oi, opt] of recordsOf(f.options).entries()) { + // + // [#22032] FENCED on an object write (pass 3 of that card, not lifted + // yet): see {@link StackExpressionOptions.runtimeWriteType}. + for (const [oi, opt] of (objectWrite ? [] : recordsOf(f.options)).entries()) { const label = typeof opt.value === 'string' ? `'${opt.value}'` : `#${oi}`; const optionWhere = `object '${objectName}' · field '${fname}' option ${label} visibleWhen`; check(optionWhere, opt.visibleWhen, objectName, 'record'); 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 3e342f6d008..73cf232f878 100644 --- a/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts +++ b/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts @@ -1858,3 +1858,169 @@ describe('runtime authoring gate on OBJECT writes — the validation-rule verdic expect(buildFindings(fxRule(VALID))).toEqual([]); }); }); + +/** + * [#22032, pass 2] The object save door gives the build's verdict on a + * field's rule slots. + * + * The same `formulas.mdx` sentence covers a field's `requiredWhen`, + * `readonlyWhen` and `visibleWhen`: the shared validator backs `os build` and + * metadata registration. After pass 1 the build's expression rule judged + * formula fields and validation-rule predicates on this door and fenced the + * field-rule slots off, so a bare `requiredWhen: 'amount > 1'` — the card's + * measured body — still saved with a 200, while `os build` refused it at + * `error`. + * + * The lift is in `@objectstack/lint` (the rule's object-write fence admits its + * field-rule-slot pass); no code here moves. Pinned through the REAL + * `saveMetaItem` / `publishMetaItem` / `publishPackageDrafts`: + * + * (a) the door refuses a bare reference in `requiredWhen`, an unregistered + * function in `visibleWhen` and a `parent` read on an object with no + * master in `readonlyWhen` — a 422 `INVALID_METADATA` carrying the + * build's located finding — on an active save, on a draft's promotion and + * on a package's draft publish, and nothing lands; + * (b) valid predicates on the three slots still save; + * (d) for each refused body the door's issues and the build's findings are + * the same findings: rule, location, message and hint. + * + * Every gate of the pass (the root verdict, the null guard, the traversal + * refusal, the retired `conditionalRequired`) and the fence over option + * `visibleWhen` and the object's action predicates are pinned in + * `@objectstack/lint`'s `runtime-gate.object-field-rule-writes.test.ts` and + * `runtime-gate.object-formula-writes.test.ts`. + * + * ⚠️ As in the #22019 block above: this package reaches `@objectstack/lint` + * through its built `dist/`, so an edit to the rule is invisible here until + * `pnpm --filter @objectstack/lint build` has run. + */ +describe('runtime authoring gate on OBJECT writes — the field-rule-slot verdict (#22032 pass 2)', () => { + /** `sharingModel` is authored so `security-owd-unset` stays quiet and the refusal is the slot's. */ + const fxField = (slots: Record) => ({ + name: 'fx_field', + label: 'Field Rule Probe', + sharingModel: 'private', + fields: { + name: { type: 'text', label: 'Name', ...slots }, + amount: { type: 'number', label: 'Amount' }, + status: { + type: 'select', + label: 'Status', + options: [{ label: 'Open', value: 'open' }, { label: 'Closed', value: 'closed' }], + }, + }, + }); + /** One refused body per slot, each refused by `os build` at `error`; the first is the card's. */ + const REFUSED = [ + { slot: 'requiredWhen', slots: { requiredWhen: 'amount > 1' }, subject: 'bare reference `amount`' }, + { slot: 'visibleWhen', slots: { visibleWhen: 'sqrt(record.amount) > 1' }, subject: '`sqrt`' }, + { slot: 'readonlyWhen', slots: { readonlyWhen: "parent.status == 'paid'" }, subject: 'reads `parent`' }, + ] as const; + const VALID = { + requiredWhen: 'record.amount != null && record.amount > 100', + readonlyWhen: "record.status == 'closed'", + visibleWhen: "record.status == 'open'", + }; + /** Where the build locates a field-rule finding — the slot the author edits. */ + const whereOf = (slot: string) => `object 'fx_field' · field 'name' ${slot}`; + + const fieldRows = (rows: Map) => + Array.from(rows.values()).filter((r) => r.type === 'object' && r.name === 'fx_field'); + + /** The build's findings for one object, through the build's own entry. */ + const buildFindings = (obj: unknown) => { + const stack = { objects: [obj] }; + return runAuthoringRules('build', { normalized: stack, parsed: stack }) + .filter((f) => f.rule === EXPRESSION_INVALID); + }; + + for (const { slot, slots, subject } of REFUSED) { + it(`(a) REFUSES an active save of \`${slot}: ${Object.values(slots)[0]}\` with a 422 carrying the build's located finding`, async () => { + const { protocol, rows } = makeProtocol(); + + const err = await protocol + .saveMetaItem({ type: 'object', name: 'fx_field', item: fxField(slots) }) + .catch((e: any) => e); + + expect(err, 'the save resolved — the door still accepts the slot').toBeInstanceOf(Error); + expect(err.status).toBe(422); + expect(err.code).toBe('INVALID_METADATA'); + expect(err.rulesRun).toContain('validateStackExpressions'); + const issue = err.issues.find((i: any) => i.rule === EXPRESSION_INVALID); + expect(issue, `issues: ${JSON.stringify(err.issues)}`).toBeDefined(); + expect(issue.path).toBe(whereOf(slot)); + expect(issue.where).toBe(whereOf(slot)); + expect(issue.severity).toBe('error'); + // The named subject: what the author typed, as the build names it. + expect(issue.message).toContain(subject); + // And nothing landed — a gate that refuses after persisting is a log line. + expect(fieldRows(rows)).toEqual([]); + }); + } + + it("(a) REFUSES the card's body on a draft's PROMOTION — the draft door is not a bypass", async () => { + const { protocol } = makeProtocol(); + // A draft save is never gated (#4463 D1): the author may keep a half-finished object. + await expect( + protocol.saveMetaItem({ type: 'object', name: 'fx_field', item: fxField(REFUSED[0].slots), mode: 'draft' }), + ).resolves.toMatchObject({ success: true }); + + const err = await protocol.publishMetaItem({ type: 'object', name: 'fx_field' }).catch((e: any) => e); + + expect(err?.status).toBe(422); + expect(err.code).toBe('INVALID_METADATA'); + const issue = err.issues.find((i: any) => i.rule === EXPRESSION_INVALID); + expect(issue, `issues: ${JSON.stringify(err.issues)}`).toBeDefined(); + expect(issue.path).toBe(whereOf('requiredWhen')); + }); + + it("(a) REFUSES the card's body on a PACKAGE's draft publish — nothing goes live", async () => { + const { protocol, rows } = makeProtocol(); + await expect( + protocol.saveMetaItem({ + type: 'object', name: 'fx_field', item: fxField(REFUSED[0].slots), 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_field', code: 'INVALID_METADATA' })]); + expect(fieldRows(rows).map((r) => r.state)).toEqual(['draft']); + }); + + it('(b) valid predicates on the three slots still save, and the row lands', async () => { + const { protocol, rows } = makeProtocol(); + + const result = await protocol.saveMetaItem({ type: 'object', name: 'fx_field', item: fxField(VALID) }); + + expect(result.success).toBe(true); + expect(fieldRows(rows).map((r) => r.state)).toEqual(['active']); + }); + + it('(d) the door and `os build` give the SAME findings for each refused body', async () => { + for (const { slots } of REFUSED) { + const { protocol } = makeProtocol(); + const err = await protocol + .saveMetaItem({ type: 'object', name: 'fx_field', item: fxField(slots) }) + .catch((e: any) => e); + const atDoor = (err.issues ?? []).filter((i: any) => i.rule === EXPRESSION_INVALID); + + const atBuild = buildFindings(fxField(slots)); + + const label = JSON.stringify(slots); + // Non-vacuous on both sides: one finding each, and an error at the build. + expect(atBuild, label).toHaveLength(1); + expect(atBuild[0]!.severity).toBe('error'); + expect(atDoor, label).toHaveLength(1); + // Compared key by key — the door reuses the build's call, so a reworded + // or relocated door verdict is a second dialect, and red. + for (const key of ['rule', 'where', 'path', 'message', 'hint'] as const) { + expect(atDoor[0][key], `door and build disagree on '${key}' for ${label}`).toBe(atBuild[0]![key]); + } + } + // And the valid slots are clean at the build too, not just at the door. + expect(buildFindings(fxField(VALID))).toEqual([]); + }); +});