From c3eac7fffb6bbd84b38995ae9f2e5be42021d76f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 01:40:36 +0000 Subject: [PATCH 1/4] fix(lint)!: the object save door gives the build's option visibleWhen verdict Lift the object-write guard on the per-option visibleWhen loop in runStackExpressionPasses, so an object write runs the build's option pass (check on the record scope plus the reference-traversal refusal) at the build's own position. The object's own action predicates stay fenced. Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude --- packages/lint/src/authoring-rules.ts | 16 +++++++- packages/lint/src/validate-expressions.ts | 45 +++++++++++++++-------- 2 files changed, 43 insertions(+), 18 deletions(-) diff --git a/packages/lint/src/authoring-rules.ts b/packages/lint/src/authoring-rules.ts index fd1b6670d9c..d55d233c2e5 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 and field-rule-slot - * passes alone (`runStackExpressionPasses`, `StackExpressionOptions`). The entry-level + * field-formula pass and (#22032) its validation-rule, field-rule-slot and + * per-option `visibleWhen` 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. */ @@ -628,6 +628,18 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ // 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. + // + // [#22032, pass 3] The per-option `visibleWhen` pass joins the object + // door: every `fields[].options[].visibleWhen`, with the reference-traversal + // refusal, and `current_user` accepted there as the build accepts it. The + // object's own `actions[]` predicates stay fenced. No entry-level change. + // MEASURED first, at both the raw and the parsed shape: every option + // predicate the repository ships — 5 options on 2 fields of 1 object + // (examples: app-showcase `showcase_cascade`, four `record.country` + // cascades and one `current_user.positions` role gate), 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 a bare `amount > 1` option in the same harness. surfaces: CLI_AND_RUNTIME, runtimeTypes: ['flow', 'action', 'hook', 'object'], run: (stack, ctx) => diff --git a/packages/lint/src/validate-expressions.ts b/packages/lint/src/validate-expressions.ts index c9e828b47ce..879fcb82803 100644 --- a/packages/lint/src/validate-expressions.ts +++ b/packages/lint/src/validate-expressions.ts @@ -1194,7 +1194,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 THREE passes judge, each the build's own call + * On an `object` write exactly FOUR 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 @@ -1221,13 +1221,22 @@ export interface StackExpressionOptions { * `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). + * refuses a write whose requirement it cannot evaluate (ADR-0137 D2); + * - [#22032, pass 3] the per-option `visibleWhen` pass over + * `fields[].options[]` — each option's predicate on the `record` scope, + * plus the #20078 refusal of a read through a reference field. The build + * accepts `current_user` there (ADR-0068 D1; the option evaluator binds + * it) and refuses it one level up on the field-rule slots, and the door + * gives both verdicts as the build gives them. The same sentence of + * `formulas.mdx` covers it, and the door gave none of it either: an + * option whose `visibleWhen` read a bare `amount` saved with a 200, and + * the server's option check cannot evaluate it and fails open (logged, + * allowed through), so the gate it declares is never enforced. * * Every other pass is fenced off an object write, deliberately and by name: - * 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 + * the object's own `actions[]` predicates. That is a build verdict the save + * door still does not give, and it 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; @@ -1253,11 +1262,12 @@ 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 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. + // write only the field-formula pass and (#22032) the validation-rule, + // field-rule-slot and per-option `visibleWhen` passes judge — the whole + // field walk. Every other loop below — the object's own `actions[]` loop + // 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); @@ -2030,8 +2040,8 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression // `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. + // `visibleWhen` loop between them joined the door in pass 3; no slot of + // this walk is fenced any more. // // Field-level conditional rules are server-enforced (rule-validator) and // record-scoped — a bare ref silently fails the rule (required/readonly @@ -2067,9 +2077,12 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression // binds it. Same helper, two verdicts, because the two surfaces have two // evaluators; neither verdict is a side effect of a shared root list. // - // [#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()) { + // [#22032, pass 3] NOT fenced on an object write: the option's `check` + // and its 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 `current_user` acceptance above + // included. See {@link StackExpressionOptions.runtimeWriteType}. + for (const [oi, opt] of 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'); From 476a5c312e7688e9bcd052e78baed68123de5199 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 01:43:27 +0000 Subject: [PATCH 2/4] test(lint): pin the object door's option visibleWhen verdict; move the option site from fenced to lifted Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude --- ...time-gate.object-field-rule-writes.test.ts | 6 +- ...runtime-gate.object-formula-writes.test.ts | 30 +-- ...te.object-option-visibility-writes.test.ts | 181 ++++++++++++++++++ ...time-gate.object-validation-writes.test.ts | 4 +- .../src/runtime-gate.object-writes.test.ts | 10 +- 5 files changed, 208 insertions(+), 23 deletions(-) create mode 100644 packages/lint/src/runtime-gate.object-option-visibility-writes.test.ts 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 index 373e49281d8..511ebe56c9f 100644 --- a/packages/lint/src/runtime-gate.object-field-rule-writes.test.ts +++ b/packages/lint/src/runtime-gate.object-field-rule-writes.test.ts @@ -24,8 +24,10 @@ * `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`. + * `visibleWhen` joined the door in pass 3 + * (`runtime-gate.object-option-visibility-writes.test.ts`); the object's own + * action predicates (pass 4) stay fenced, and 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 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 c6411b7f5a5..bdd1be64fcb 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,14 @@ * `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`), 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. + * (its pins: `runtime-gate.object-validation-writes.test.ts`), since its + * pass 2 the field-rule slots (`runtime-gate.object-field-rule-writes.test.ts`), + * and since its pass 3 the per-option `visibleWhen` + * (`runtime-gate.object-option-visibility-writes.test.ts`). The one other + * object-borne pass the build runs — 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,10 +116,10 @@ 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 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 + * One body carrying a fault in the FENCED pass — #22032's pass 4, one site: + * an object action's `visible` — beside a fault in each LIFTED pass, the + * validation-rule pass (#22032 pass 1), a field-rule slot (`requiredWhen`, + * #22032 pass 2) and an option's `visibleWhen` (#22032 pass 3), 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 @@ -142,14 +144,14 @@ describe('#22019 — the fence: every other object-borne expression pass stays o }; return body; }; - /** The fenced sites (passes 3–4) and the lifted ones (passes 1–2), by the build's `where`, in the build's order. */ + /** The fenced site (pass 4) and the lifted ones (passes 1–3), by the build's `where`, in the build's order. */ const FENCED_SITES = [ - "object 'fx_sqrt' · field 'tier' option 'gold' visibleWhen", "object 'fx_sqrt' · action 'fx_close' visible", ]; const LIFTED_SITES = [ "object 'fx_sqrt' · validation 'amount_root'", "object 'fx_sqrt' · field 'name' requiredWhen", + "object 'fx_sqrt' · field 'tier' option 'gold' visibleWhen", ]; it('the build (no `runtimeWriteType`) still flags each fenced site, and the lifted ones', () => { @@ -163,11 +165,11 @@ describe('#22019 — the fence: every other object-borne expression pass stays o 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, validation-rule and field-rule-slot passes judge there', () => { + it('the object door flags none of the fenced sites — only the formula, validation-rule, field-rule-slot and option passes judge there', () => { const result = gateObject(withFieldRule()); expect(result.rulesRun).toContain('validateStackExpressions'); - // [#22032 passes 1–2] The lifted passes' findings, and nothing else. + // [#22032 passes 1–3] 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([]); }); diff --git a/packages/lint/src/runtime-gate.object-option-visibility-writes.test.ts b/packages/lint/src/runtime-gate.object-option-visibility-writes.test.ts new file mode 100644 index 00000000000..8ef140e5100 --- /dev/null +++ b/packages/lint/src/runtime-gate.object-option-visibility-writes.test.ts @@ -0,0 +1,181 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #22032, pass 3 — the OBJECT write door runs the build's per-option + * `visibleWhen` pass. + * + * ## The state this closes + * + * `validateStackExpressions` is the build's expression rule. Its field walk + * judges every `fields[].options[].visibleWhen` as a `record`-scoped predicate, + * plus the #20078 refusal of a read through a reference field. After #22032's + * pass 2 the object door ran the rest of the field walk and fenced the option + * loop off by its own guard, so an option whose `visibleWhen` read a bare + * `amount` — refused by `os build` — published clean, and the server's option + * check then could not evaluate it and failed open on every write. + * + * ## The crossing + * + * No registry change: the entry already declares `object`. The option loop's + * guard is gone, so on an object write it runs at the build's own position in + * the field walk, and the door's finding IS the build's finding — rule, + * location, message and hint. The object's own action predicates (pass 4) + * stay fenced; that pin is in `runtime-gate.object-formula-writes.test.ts`. + * + * ## What still publishes + * + * An option's evaluator binds `current_user` (ADR-0068 D1), so the build + * accepts it there while it refuses it on the field-rule slots one level up. + * The door gives the two verdicts the build gives: the showcase's role gate + * (`'org_admin' in current_user.positions`) still publishes on an option, and + * the same text on the field's own `visibleWhen` is refused, at both doors. + * + * The protocol-level half — the same verdict through the real `saveMetaItem`, + * `publishMetaItem` and `publishPackageDrafts` — is the #22032 pass 3 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; `visibleWhen` lands on the `gold` option of `tier`, and + * `fieldVisibleWhen` (when given) on the `name` field's own slot. + * `sharingModel` keeps `security-owd-unset` quiet, so a refusal is the rule's. + */ +const fxOption = (visibleWhen: unknown, fieldVisibleWhen?: unknown) => ({ + name: 'fx_option', + label: 'Option Probe', + sharingModel: 'private', + fields: { + name: { type: 'text', label: 'Name', ...(fieldVisibleWhen === undefined ? {} : { visibleWhen: fieldVisibleWhen }) }, + amount: { type: 'number', label: 'Amount' }, + country: { + type: 'select', + label: 'Country', + options: [{ label: 'China', value: 'cn' }, { label: 'United States', value: 'us' }], + }, + account: { type: 'lookup', label: 'Account', reference: 'fx_account' }, + tier: { + type: 'select', + label: 'Tier', + options: [{ label: 'Standard', value: 'standard' }, { label: 'Gold', value: 'gold', visibleWhen }], + }, + }, +}); + +const WHERE = "object 'fx_option' · field 'tier' option 'gold' visibleWhen"; + +/** + * One refused body per finding the pass gives. Each `subject` is the named + * subject of the build's finding (what the author typed), not its prose. + */ +const REFUSED = [ + // The card's shape: a bare field reference. + { body: 'amount > 1', subject: 'bare reference `amount`' }, + { body: 'sqrt(record.amount) > 1', subject: '`sqrt`' }, + { body: 'record.amont > 1', subject: 'unknown field `amont`' }, + { body: 'record.country ==', subject: 'invalid CEL predicate' }, + // The traversal refusal, on both roots an option binds. + { body: "record.account.name == 'x'", subject: 'reads `name` through `record.account`' }, + { body: "previous.account.name == 'x'", subject: 'reads `name` through `previous.account`' }, +] as const; + +/** + * Bodies the build accepts on an option, and so must the door: the cascade, + * the showcase's role gate, the grant check, and a reference compared as a + * value rather than read through. + */ +const ACCEPTED = [ + "record.country == 'cn'", + "'org_admin' in current_user.positions", + "current_user.can('fx_option', 'edit') && 'org_admin' in current_user.positions", + 'record.account != null', +] as const; + +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 3 — the object door gives the build\'s option `visibleWhen` verdict', () => { + it('needs no registry change: `validateStackExpressions` is already on the object door', () => { + expect(runtimeAuthoringRulesFor('object').map((r) => r.name)).toContain('validateStackExpressions'); + }); + + for (const { body, subject } of REFUSED) { + it(`⭐ LIT — an option's \`visibleWhen: ${body}\` is REFUSED, located at the option the author edits`, () => { + const result = gateObject(fxOption(body)); + + expect(result.rulesRun).toContain('validateStackExpressions'); + const errs = expressionFindings(result.errors); + expect(errs, dump(result)).toHaveLength(1); + expect(errs[0]).toMatchObject({ severity: 'error', where: WHERE, path: WHERE }); + expect(errs[0]!.message).toContain(subject); + }); + } + + for (const body of ACCEPTED) { + it(`⭐ CONTROL — an option's \`visibleWhen: ${body}\` still publishes clean, and the build agrees`, () => { + const result = gateObject(fxOption(body)); + + expect(result.rulesRun).toContain('validateStackExpressions'); + expect(expressionFindings(result.errors), dump(result)).toEqual([]); + expect(expressionFindings(result.advisories), dump(result)).toEqual([]); + // Clean at both doors, not only this one. + expect(buildFindings(fxOption(body))).toEqual([]); + }); + } + + it('⭐ CONTRAST — `current_user` is accepted on the option and refused on the field\'s own slot, at both doors', () => { + const role = "'org_admin' in current_user.positions"; + const body = fxOption(role, role); + const atBuild = buildFindings(body); + const atDoor = expressionFindings(gateObject(body).errors); + + // The field-rule slot's root verdict, and nothing at the option. + expect(atBuild.map((f) => f.where), dump(atBuild)).toEqual(["object 'fx_option' · field 'name' visibleWhen"]); + expect(atBuild[0]!.message).toContain('reads `current_user`'); + expect(atDoor, dump(atDoor)).toEqual(atBuild); + }); + + it('⭐ PARITY — for each refused body the door findings ARE the build findings', () => { + for (const { body } of REFUSED) { + const atBuild = buildFindings(fxOption(body)); + const atDoor = expressionFindings(gateObject(fxOption(body)).errors); + + // Non-vacuous: the build refuses each of them. + expect(atBuild.length, body).toBeGreaterThan(0); + expect(atDoor, body).toEqual(atBuild); + } + }); + + it('a stored sibling\'s broken options are not this write\'s to answer for (the differential)', () => { + const sibling = { + ...fxOption(REFUSED[0].body), + name: 'fx_sibling', + fields: { + ...fxOption(REFUSED[0].body).fields, + grade: { + type: 'select', + label: 'Grade', + options: REFUSED.map(({ body }, i) => ({ label: `G${i}`, value: `g${i}`, visibleWhen: body })), + }, + }, + }; + // Non-vacuous: the sibling is refused at the build. + expect(buildFindings(sibling).length).toBeGreaterThan(0); + + const result = gateObject(fxOption(ACCEPTED[0]), [sibling]); + + expect(expressionFindings(result.errors), dump(result)).toEqual([]); + }); +}); diff --git a/packages/lint/src/runtime-gate.object-validation-writes.test.ts b/packages/lint/src/runtime-gate.object-validation-writes.test.ts index 9bbde35a447..a95bfc7e76c 100644 --- a/packages/lint/src/runtime-gate.object-validation-writes.test.ts +++ b/packages/lint/src/runtime-gate.object-validation-writes.test.ts @@ -23,8 +23,8 @@ * `runStackExpressionPasses` admits this pass on an object write, at the * build's own position in the walk, so the door's finding IS the build's * finding — rule, location, message and hint. The other object-borne passes - * (the field-rule slots, option `visibleWhen`, the object's own action - * predicates) stay fenced; that pin is in + * joined later (the field-rule slots in pass 2, option `visibleWhen` in pass + * 3) or stay fenced (the object's own action predicates); that pin is in * `runtime-gate.object-formula-writes.test.ts`. * * The protocol-level half — the same verdict through the real `saveMetaItem` diff --git a/packages/lint/src/runtime-gate.object-writes.test.ts b/packages/lint/src/runtime-gate.object-writes.test.ts index 1f29cfa5524..eddf745c026 100644 --- a/packages/lint/src/runtime-gate.object-writes.test.ts +++ b/packages/lint/src/runtime-gate.object-writes.test.ts @@ -113,11 +113,11 @@ 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 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`. + // docs say backs metadata registration — and (#22032 passes 1 to 3) the + // validation-rule predicates, the field-rule slots and option + // `visibleWhen`. Its one other object-borne pass (action predicates) is + // fenced off this door inside the rule (`StackExpressionOptions`), and + // that fence is pinned in `runtime-gate.object-formula-writes.test.ts`. 'validateStackExpressions', 'validateFunctionalCompleteness', 'validateManagedApiMethods', From 2ae9d8b51f4829b6171960db7912205921f2f49e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 01:45:10 +0000 Subject: [PATCH 3/4] test(metadata-protocol): pin the object save door's option visibleWhen verdict Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude --- .../protocol.runtime-authoring-gate.test.ts | 190 +++++++++++++++++- 1 file changed, 186 insertions(+), 4 deletions(-) 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 f7022304cbf..04c9347e9f1 100644 --- a/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts +++ b/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts @@ -1885,10 +1885,11 @@ describe('runtime authoring gate on OBJECT writes — the validation-rule verdic * 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`. + * refusal, the retired `conditionalRequired`) and the fence over 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`; option `visibleWhen` joined + * the door in pass 3 (the block below). * * ⚠️ 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 @@ -2025,6 +2026,187 @@ describe('runtime authoring gate on OBJECT writes — the field-rule-slot verdic }); }); +/** + * [#22032, pass 3] The object save door gives the build's verdict on a + * field option's `visibleWhen`. + * + * The same `formulas.mdx` sentence covers an option's `visibleWhen`: the + * shared validator backs `os build` and metadata registration. After pass 2 + * the build's expression rule ran the whole field walk on this door except the + * per-option loop, which kept its own fence, so an option whose `visibleWhen` + * read a bare field (`amount > 1`) still saved with a 200, while `os build` + * refused it at `error`. + * + * The lift is in `@objectstack/lint` (the option loop's object-write guard is + * gone); no code here moves. Pinned through the REAL `saveMetaItem` / + * `publishMetaItem` / `publishPackageDrafts`: + * + * (a) the door refuses a bare reference, an unregistered function and a read + * through a reference field — 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) the showcase's two option shapes still save: a `record.country` + * cascade and the `'org_admin' in current_user.positions` role gate — the + * option evaluator binds `current_user` (ADR-0068 D1), so the build + * accepts it there, and so does this door; + * (d) for each refused body the door's issues and the build's findings are + * the same findings: rule, location, message and hint. + * + * The rest of the pass's findings, the `current_user` contrast with the + * field's own slot, and the fence over the object's action predicates are + * pinned in `@objectstack/lint`'s + * `runtime-gate.object-option-visibility-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 option visibleWhen verdict (#22032 pass 3)', () => { + /** `sharingModel` is authored so `security-owd-unset` stays quiet and the refusal is the option's. */ + const fxOption = (provinceZj: unknown, restricted: unknown = "'org_admin' in current_user.positions") => ({ + name: 'fx_option', + label: 'Option Probe', + sharingModel: 'private', + fields: { + name: { type: 'text', label: 'Name' }, + amount: { type: 'number', label: 'Amount' }, + // A self-reference, so the reference resolves in the write's own snapshot. + account: { type: 'lookup', label: 'Account', reference: 'fx_option' }, + country: { + type: 'select', + label: 'Country', + options: [{ label: 'China', value: 'cn' }, { label: 'United States', value: 'us' }], + }, + province: { + type: 'select', + label: 'Province', + options: [ + { label: 'Zhejiang', value: 'zj', visibleWhen: provinceZj }, + { label: 'California', value: 'ca', visibleWhen: "record.country == 'us'" }, + ], + }, + tier: { + type: 'select', + label: 'Tier', + options: [ + { label: 'Standard', value: 'standard' }, + { label: 'Restricted', value: 'restricted', visibleWhen: restricted }, + ], + }, + }, + }); + /** One refused body per finding kind, each refused by `os build` at `error`; the first is the card's shape. */ + const REFUSED = [ + { body: 'amount > 1', subject: 'bare reference `amount`' }, + { body: 'sqrt(record.amount) > 1', subject: '`sqrt`' }, + { body: "record.account.name == 'x'", subject: 'reads `name` through `record.account`' }, + ] as const; + /** The showcase's cascade; the role gate rides every body as `tier`'s `restricted` option. */ + const CASCADE = "record.country == 'cn'"; + /** Where the build locates the refused option's finding — the option the author edits. */ + const WHERE = "object 'fx_option' · field 'province' option 'zj' visibleWhen"; + + const optionRows = (rows: Map) => + Array.from(rows.values()).filter((r) => r.type === 'object' && r.name === 'fx_option'); + + /** 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 { body, subject } of REFUSED) { + it(`(a) REFUSES an active save of an option's \`visibleWhen: ${body}\` with a 422 carrying the build's located finding`, async () => { + const { protocol, rows } = makeProtocol(); + + const err = await protocol + .saveMetaItem({ type: 'object', name: 'fx_option', item: fxOption(body) }) + .catch((e: any) => e); + + expect(err, 'the save resolved — the door still accepts the option predicate').toBeInstanceOf(Error); + expect(err.status).toBe(422); + expect(err.code).toBe('INVALID_METADATA'); + expect(err.rulesRun).toContain('validateStackExpressions'); + 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].where).toBe(WHERE); + expect(issues[0].severity).toBe('error'); + // The named subject: what the author typed, as the build names it. + expect(issues[0].message).toContain(subject); + // And nothing landed — a gate that refuses after persisting is a log line. + expect(optionRows(rows)).toEqual([]); + }); + } + + it("(a) REFUSES the card-shaped 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_option', item: fxOption(REFUSED[0].body), mode: 'draft' }), + ).resolves.toMatchObject({ success: true }); + + const err = await protocol.publishMetaItem({ type: 'object', name: 'fx_option' }).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(WHERE); + }); + + it("(a) REFUSES the card-shaped body on a PACKAGE's draft publish — nothing goes live", async () => { + const { protocol, rows } = makeProtocol(); + await expect( + protocol.saveMetaItem({ + type: 'object', name: 'fx_option', item: fxOption(REFUSED[0].body), 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_option', code: 'INVALID_METADATA' })]); + expect(optionRows(rows).map((r) => r.state)).toEqual(['draft']); + }); + + it("(b) the showcase's cascade and its `current_user` role gate still save, and the row lands", async () => { + const { protocol, rows } = makeProtocol(); + + const result = await protocol.saveMetaItem({ type: 'object', name: 'fx_option', item: fxOption(CASCADE) }); + + expect(result.success).toBe(true); + expect(optionRows(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 { body } of REFUSED) { + const { protocol } = makeProtocol(); + const err = await protocol + .saveMetaItem({ type: 'object', name: 'fx_option', item: fxOption(body) }) + .catch((e: any) => e); + const atDoor = (err.issues ?? []).filter((i: any) => i.rule === EXPRESSION_INVALID); + + const atBuild = buildFindings(fxOption(body)); + + // Non-vacuous on both sides: one finding each, and an error at the build. + expect(atBuild, body).toHaveLength(1); + expect(atBuild[0]!.severity).toBe('error'); + expect(atDoor, body).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 ${body}`).toBe(atBuild[0]![key]); + } + } + // And the still-accepted options are clean at the build too, not just at the door. + expect(buildFindings(fxOption(CASCADE))).toEqual([]); + }); +}); + /** * [#22042] The object save door gives the build's verdict on a `conditional` * validation rule's NESTED predicates. From 23ce6c494c1b50dc4a8fd711f5f5a29126fcf6e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 01:54:39 +0000 Subject: [PATCH 4/4] chore(changeset): the object save door's option visibleWhen verdict (minor, BREAKING) Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude --- ...32-object-save-door-option-visible-when.md | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 .changeset/22032-object-save-door-option-visible-when.md diff --git a/.changeset/22032-object-save-door-option-visible-when.md b/.changeset/22032-object-save-door-option-visible-when.md new file mode 100644 index 00000000000..a5a2d3ecbaf --- /dev/null +++ b/.changeset/22032-object-save-door-option-visible-when.md @@ -0,0 +1,30 @@ +--- +"@objectstack/lint": minor +"@objectstack/metadata-protocol": minor +--- + +fix(lint)!: the object save door refuses a field option's `visibleWhen` that `os build` refuses (#22032) + +Clause-②: no (narrowing) + +`formulas.mdx` says the same `validateExpression` validator backs `os build` and metadata registration. For a field option's `visibleWhen` it did not, at the object save door. An option whose `visibleWhen` read a bare field, such as `amount > 1`, or 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 server's option check cannot evaluate such a predicate and lets the value through, so the gate it declares is never enforced. + +The runtime publish gate now runs the build's option check on an object write. The build's expression rule (`validateStackExpressions`) was already on the object door for formula fields, validation-rule predicates and the field-rule slots. On an object write it now also judges each `fields[].options[].visibleWhen` the way the build does: as a predicate over `record` and `previous`, and with the build's refusal of a read through a reference field. The door's verdict is the build's finding: the same rule id (`expression-invalid`), location (`object 'NAME' · field 'FIELD' option 'VALUE' visibleWhen`), message and hint. This supersedes the earlier #22032 entries' line that option `visibleWhen` is not judged at this door. + +**BREAKING — what moves for consumers.** + +- An object write in publish mode answered 200 for an option whose `visibleWhen` the shared validator refuses. It now answers `422 INVALID_METADATA`, with an `expression-invalid` issue located at that option. 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, and a read through a reference field (`record.account.tier`, `previous.account.tier`). Its warnings now ride the save response as advisories. + +**Remedy.** Fix the predicate: the message names the unknown function or field, the bare reference 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, and compare a reference field as a value (`record.account != null`) rather than read through it. Saving the object as a draft (`mode: 'draft'`) is still allowed, because drafts are never gated; publishing that draft is judged. + +**Unchanged.** + +- `current_user` is still accepted in an option's `visibleWhen`, as the build accepts it: the option evaluator binds the acting user (ADR-0068 D1). A role gate such as `'org_admin' in current_user.positions`, or a grant check such as `current_user.can('OBJECT', 'edit')`, still saves. On a field's own `requiredWhen`, `readonlyWhen` or `visibleWhen` it is still refused, as before. +- 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. +- The object's own action predicates (`actions[].visible`, `actions[].disabled`) 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: this repository ships 5 option predicates, all on `showcase_cascade` (four `record.country` cascades and one `current_user.positions` role gate), among the 118 objects it ships. They have 0 refusals and 0 advisories, at the build and at the door. +- No public export or signature moves. `validateStackExpressions(stack)` keeps its signature, and no registry entry changes: the expression rule already declared `object`. + +