From 87ca3bb99a3b3b666ebbadf3895bbe1da4e0e4c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 09:43:23 +0000 Subject: [PATCH 1/3] fix(lint)!: a select option's visibleWhen reading a root the option check does not bind is refused (#22157) The option loop in runStackExpressionPasses gains a root verdict over the roots evaluateOptionVisibility binds: record, previous and the acting user (current_user and its ADR-0068 aliases). parent, which the field-rule slots bind on a one-master detail, faulted open on every write that picked the option. The same call runs at the object save door. Claude-Session: https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK Co-authored-by: Claude --- packages/lint/src/validate-expressions.ts | 131 +++++++++++++++++++++- 1 file changed, 125 insertions(+), 6 deletions(-) diff --git a/packages/lint/src/validate-expressions.ts b/packages/lint/src/validate-expressions.ts index 62ee3cc8aec..9781066c15f 100644 --- a/packages/lint/src/validate-expressions.ts +++ b/packages/lint/src/validate-expressions.ts @@ -624,6 +624,9 @@ function rulePredicates(rule: AnyRec, path: string, depth = 0): RulePredicate[] * options resolve against the host's predicate scope, which binds * `current_user` (ADR-0068 / objectui#2284) — that surface is where such a * predicate belongs, which is why it is also the first prescription below. + * The option has its own root verdict (`optionVisibleWhenRootIssue`, #22157), + * over the roots the server's option check binds: the acting user, and never + * `parent`. * * ## The user roots, and why `ctx` is judged whole-root (#6585) * @@ -1140,6 +1143,101 @@ function fieldTraversalMessage( ); } +/** + * [#22157] The roots a select option's own `visibleWhen` binds, read off the + * one server site that evaluates it: ObjectQL's `evaluateOptionVisibility` + * (`rule-validator.ts`). That call hands the expression engine `record` (the + * merged write), `previous`, the acting user and `permissions`, and nothing + * else; `@objectstack/formula`'s `buildScope` turns that into these roots: + * + * - `record`, and `previous` (bound on an update); + * - the acting user, under ADR-0068 D1's canonical `current_user` and the + * three aliases `buildScope` hangs the same object on: `user`, and the + * `user` member of `ctx` and of `os`. They are listed at bare-root + * granularity because that is what {@link collectCelRootIdentifiers} + * reports, the same reading as the runtime's own `USER_SCOPE_ROOTS` beside + * the evaluator. All four are bound only when the write carries a user; a + * system write takes the evaluator's "no acting user" branch by design, + * which is not what this verdict judges. + * + * `permissions` mounts NO root: it answers `current_user.can(…)` through the + * environment (#18783), so it is absent here. + * + * `parent` is NOT bound. The write path binds a master-detail header for a + * field's own `readonlyWhen` / `requiredWhen` only, so the field-rule verdict + * one level up ({@link FIELD_RULE_BOUND_ROOTS}) accepts it and this one does + * not. + * + * ⛔ A root joins this list in the same change that binds it in + * `evaluateOptionVisibility`, never before. + */ +const OPTION_VISIBLE_WHEN_BOUND_ROOTS: readonly string[] = ['record', 'previous', 'current_user', 'user', 'ctx', 'os']; + +/** + * [#22157] The root verdict for a select option's own `visibleWhen`: a root + * the server's option check does not bind ({@link OPTION_VISIBLE_WHEN_BOUND_ROOTS}) + * is refused, located at the option. + * + * ## Why it is refused + * + * The evaluator faults on an unbound root (`Unknown variable: parent`, + * measured through the real `evaluateValidationRules` with an authenticated + * caller), and a faulting option predicate is fail-OPEN: the fault is logged + * and the value admitted, so the gate is never enforced. A declared gate the + * runtime cannot enforce must not pass a door (ADR-0049), and nothing else + * refused it: every root judged here is in `SCOPE_ROOTS`, which the strict env + * declares, so the bare-reference check stays silent on it. The case that made + * the hole credible is `parent`, which the field-rule slots one level up accept + * on an object with exactly one `master_detail`. + * + * ## Why the membership test is `SCOPE_ROOTS`, not the field-rule vocabulary + * + * The gap is exactly the roots that pass the strict env. A root outside + * `SCOPE_ROOTS` (a nowhere-bound root such as `app`) is undeclared there, so + * the bare-reference check already refuses it at error on this slot; judging + * it here as well would give one mistake two verdicts. The test is an + * ALLOWLIST read against that list, for the reason {@link fieldRuleRootIssue} + * records (#6713): a root added to `SCOPE_ROOTS` is judged here the day it + * lands, with no second list to copy it into. + * + * `null` = nothing to report: the source does not parse (the syntax pass owns + * that), or every root it reads is one the option check binds. + */ +function optionVisibleWhenRootIssue( + objectName: string | undefined, + field: string, + option: string, + source: string, +): { root: string; message: string } | null { + const roots = collectCelRootIdentifiers(source); + if (!roots.ok) return null; + const kept = SCOPE_ROOTS.filter( + (r) => !OPTION_VISIBLE_WHEN_BOUND_ROOTS.includes(r) && roots.roots.includes(r), + ); + if (kept.length === 0) return null; + // One issue per option even when the predicate reads two unbound roots, in + // `SCOPE_ROOTS` order: stable, never AST walk order. The author fixes one + // and the next run names the other. + const root = kept[0]!; + const owner = objectName ? `'${objectName}'` : 'this object'; + const prescription = root === 'parent' + ? `An option is judged against the record being written, never against its master-detail ` + + `header: only a field's own \`readonlyWhen\` and \`requiredWhen\` bind \`parent\`, on an ` + + `object with exactly one \`master_detail\`. To gate this choice on the header, read a column ` + + `${owner} declares instead (denormalise the header value you need onto ${owner}).` + : `\`${root}\` is declared platform-wide and bound at OTHER evaluation sites, never by the ` + + `option check. Rewrite the predicate against \`record\` (plus \`previous\`), or against the ` + + `acting user as \`current_user\`.`; + return { + root, + message: + `\`visibleWhen\` of option ${option} on field '${field}' reads \`${root}\`, but the server's ` + + `option check binds only \`record\`, \`previous\` and the acting user (\`current_user\`, and ` + + `its ADR-0068 aliases \`user\`, \`ctx\` and \`os\`), so \`${root}\` is unbound there. ` + + `${FIELD_TRAVERSAL_CONSEQUENCE['option visibleWhen']}. ${prescription}`, + }; +} + /** * [#20078] The master object of an object with exactly ONE `master_detail` * relationship — the record the field level binds as `parent` — or `undefined` @@ -1232,7 +1330,9 @@ export interface StackExpressionOptions { * `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; + * allowed through), so the gate it declares is never enforced. Since + * #22157 the pass also refuses a root that option check does not bind + * (`parent` above all), at both doors through this same call; * - [#22032, pass 4] the object's own `actions[]` pass — each action's * `visible`, and its `disabled` unless that is a boolean literal, as a * `record`-scoped predicate (`checkAction` below). The same sentence of @@ -2097,15 +2197,34 @@ 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, 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}. + // [#22157] …and the option has a root verdict of its own, over the roots + // the SERVER's option check binds (`optionVisibleWhenRootIssue`). The + // shared root list (`SCOPE_ROOTS`) let every platform-wide root through + // here, `parent` among them, though `evaluateOptionVisibility` binds only + // `record`, `previous` and the acting user: such a predicate faulted on + // every write that picked the option and was admitted unchecked. So the + // two surfaces now differ by exactly what their evaluators bind: this + // one accepts the acting user and refuses `parent`, the field-rule slots + // the other way round. + // + // [#22032, pass 3] NOT fenced on an object write: the option's `check`, + // its root verdict (#22157) 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'); + // [#22157] After `check`, before the traversal refusal: every root this + // verdict judges is declared in the strict env, so `check` never + // reports a bare reference to it and the two stay disjoint. + const optionSource = celSourceOf(opt.visibleWhen); + const verdict = optionSource ? optionVisibleWhenRootIssue(objectName, fname, label, optionSource) : null; + if (verdict) { + issues.push({ where: optionWhere, message: verdict.message, source: optionSource!, severity: 'error' }); + } // [#20078] An option's predicate is evaluated against the record as // stored — a read through a reference faults, and the server admits the // value unchecked. `current_user` (and `current_user.can(…)`) is NOT a From 48b93291376b268edc715c21b5b1ec8a81f671ef Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 09:50:39 +0000 Subject: [PATCH 2/3] test(lint,metadata-protocol): pin the option visibleWhen root verdict at the build and the object save door (#22157) Claude-Session: https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK Co-authored-by: Claude --- ...te.object-option-visibility-writes.test.ts | 66 ++++++++++ .../lint/src/validate-expressions.test.ts | 83 ++++++++++++ .../protocol.runtime-authoring-gate.test.ts | 122 ++++++++++++++++++ 3 files changed, 271 insertions(+) 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 index 8711a4b2d99..c8768f11b7e 100644 --- a/packages/lint/src/runtime-gate.object-option-visibility-writes.test.ts +++ b/packages/lint/src/runtime-gate.object-option-visibility-writes.test.ts @@ -181,3 +181,69 @@ describe('#22032 pass 3 — the object door gives the build\'s option `visibleWh expect(expressionFindings(result.errors), dump(result)).toEqual([]); }); }); + +/** + * #22157 — the option's root verdict, at the object door. + * + * The server's option check (`evaluateOptionVisibility`) binds `record`, + * `previous` and the acting user. An option `visibleWhen` reading `parent` (on + * a detail with exactly one `master_detail`, where the field-rule slots DO + * bind it) published clean through this door and then faulted open on every + * write that picked the option. The verdict lives in the build's option pass, + * which this door runs since #22032's pass 3, so the door's finding is the + * build's. The protocol-level half, through the real `saveMetaItem`, is the + * #22157 block of `packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts`. + */ +describe('#22157 — the object door refuses an option `visibleWhen` reading `parent`, as the build does', () => { + const header = { + name: 'fx_header', + label: 'Header', + sharingModel: 'private', + fields: { name: { type: 'text', label: 'Name' }, status: { type: 'text', label: 'Status' } }, + }; + const line = (visibleWhen: unknown) => ({ + name: 'fx_line', + label: 'Line', + sharingModel: 'private', + fields: { + hdr: { type: 'master_detail', label: 'Header', reference: 'fx_header' }, + x: { type: 'text', label: 'X' }, + tier: { + type: 'select', + label: 'Tier', + options: [{ label: 'Standard', value: 'standard' }, { label: 'Gold', value: 'gold', visibleWhen }], + }, + }, + }); + const LINE_WHERE = "object 'fx_line' · field 'tier' option 'gold' visibleWhen"; + const PARENT = "parent.status == 'closed'"; + + it('⭐ LIT — the measured body is REFUSED at the door, located at the option, naming the root', () => { + const result = gateObject(line(PARENT), [header]); + + expect(result.rulesRun).toContain('validateStackExpressions'); + const errs = expressionFindings(result.errors); + expect(errs, dump(result)).toHaveLength(1); + expect(errs[0]).toMatchObject({ severity: 'error', where: LINE_WHERE, path: LINE_WHERE }); + expect(errs[0]!.message).toContain("option 'gold' on field 'tier' reads `parent`"); + }); + + it('⭐ PARITY — the door finding IS the build finding', () => { + const atBuild = buildFindings(header, line(PARENT)); + const atDoor = expressionFindings(gateObject(line(PARENT), [header]).errors); + + // Non-vacuous: the build refuses it. + expect(atBuild, dump(atBuild)).toHaveLength(1); + expect(atDoor, dump(atDoor)).toEqual(atBuild); + }); + + for (const body of ["record.x == 'a'", "'org_admin' in current_user.positions"]) { + it(`⭐ CONTROL — \`${body}\` on the same option publishes clean, and the build agrees`, () => { + const result = gateObject(line(body), [header]); + + expect(expressionFindings(result.errors), dump(result)).toEqual([]); + expect(expressionFindings(result.advisories), dump(result)).toEqual([]); + expect(buildFindings(header, line(body))).toEqual([]); + }); + } +}); diff --git a/packages/lint/src/validate-expressions.test.ts b/packages/lint/src/validate-expressions.test.ts index fa0a3d80a44..822608c1641 100644 --- a/packages/lint/src/validate-expressions.test.ts +++ b/packages/lint/src/validate-expressions.test.ts @@ -2005,6 +2005,89 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => { }); }); + /** + * ── The option's root verdict (#22157) ────────────────────────────────── + * + * The server's option check (`evaluateOptionVisibility`) binds `record`, + * `previous` and the acting user, and nothing else. A predicate reading any + * other root faults on every write that picks the option and is admitted + * unchecked, so the build refuses it. The measured body is `parent` on a + * detail with exactly one `master_detail`: the object shape on which the + * field-rule slots one level up DO bind `parent`, so it is the shape an + * author copies from. + */ + describe('a per-option `visibleWhen` root the option check does not bind is refused (#22157)', () => { + const detail = (visibleWhen: unknown, readonlyWhen?: unknown) => ({ + objects: [ + { name: 'fx_header', fields: { status: { type: 'text' } } }, + { + name: 'fx_line', + fields: { + hdr: { type: 'master_detail', reference: 'fx_header' }, + x: { type: 'text', ...(readonlyWhen === undefined ? {} : { readonlyWhen }) }, + parent_code: { type: 'text' }, + tier: { + type: 'select', + options: [{ label: 'Standard', value: 'standard' }, { label: 'Gold', value: 'gold', visibleWhen }], + }, + }, + }, + ], + }); + const WHERE = "object 'fx_line' · field 'tier' option 'gold' visibleWhen"; + const PARENT = "parent.status == 'closed'"; + /** What `evaluateOptionVisibility` binds, read off its call (`rule-validator.ts`). */ + const BOUND = ['record', 'previous', 'current_user', 'user', 'ctx', 'os']; + + it('⭐ refuses `parent` at error, located at the option, naming the option, the field and the root', () => { + const issues = validateStackExpressions(detail(PARENT)); + expect(issues, JSON.stringify(issues, null, 2)).toHaveLength(1); + expect(issues[0]).toMatchObject({ where: WHERE, severity: 'error', source: PARENT }); + expect(issues[0]!.message).toContain("option 'gold' on field 'tier' reads `parent`"); + }); + + it('⭐ CONTRAST — the same `parent` read on the field\'s own `readonlyWhen` passes: that slot binds it', () => { + expect(validateStackExpressions(detail("record.x == 'a'", PARENT))).toEqual([]); + }); + + it('⭐ CONTROL — `record`, `previous` and the acting user under every ADR-0068 spelling pass', () => { + for (const body of [ + "record.x == 'a'", + "previous.x == 'a'", + "'org_admin' in current_user.positions", + "'org_admin' in user.positions", + "ctx.user.id != ''", + "os.user.id != ''", + "current_user.can('fx_line', 'edit')", + // A `record` member merely spelled like the refused root. + "record.parent_code == 'a'", + ]) { + expect(validateStackExpressions(detail(body)), body).toEqual([]); + } + }); + + /** + * An ALLOWLIST read against the real `SCOPE_ROOTS`, never a copy of it: + * every platform-wide root outside what the option check binds is + * refused, one finding each, so a root added to the baseline later is + * covered the day it lands. + */ + it('refuses every `SCOPE_ROOTS` member the option check does not bind, one finding each', () => { + const unbound = (SCOPE_ROOTS as readonly string[]).filter((r) => !BOUND.includes(r)); + expect(unbound).toContain('parent'); + for (const root of unbound) { + const issues = validateStackExpressions(detail(`${root}.k == 'a'`)); + expect(issues, root).toHaveLength(1); + expect(issues[0]!.where, root).toBe(WHERE); + expect(issues[0]!.message, root).toContain(`reads \`${root}\``); + } + }); + + it('gives one finding when the predicate reads two unbound roots', () => { + expect(validateStackExpressions(detail(`${PARENT} && input.k == 1`))).toHaveLength(1); + }); + }); + it('flags a bare-field sharing-rule condition', () => { const issues = validateStackExpressions({ objects: [{ name: 'crm_account', fields: { region: { type: 'text' } } }], 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 dc3ee83b736..d807276f994 100644 --- a/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts +++ b/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts @@ -2210,6 +2210,128 @@ describe('runtime authoring gate on OBJECT writes — the option visibleWhen ver }); }); +/** + * [#22157] The object save door refuses a field option's `visibleWhen` that + * reads `parent`, as `os build` does. + * + * The server's option check (`evaluateOptionVisibility` in ObjectQL) binds + * `record`, `previous` and the acting user, never `parent`. Measured before + * this change: on a detail with exactly one `master_detail` (where the + * field-rule slots DO bind `parent`), an option whose `visibleWhen` read + * `parent.status` saved through this door with a 200 and landed `active`, and + * every write that then picked the option faulted and was admitted unchecked. + * + * The refusal is in `@objectstack/lint` (the option pass's root verdict); no + * code here moves. Pinned through the REAL `saveMetaItem`, the header stored + * in the registry so the write is judged against the universe it lands in: + * + * (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); + * (c) control: an option reading `record` saves, and the row lands; + * (d) the door's issue and the build's finding are the same finding. + * + * ⚠️ 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 — an option visibleWhen reading parent (#22157)', () => { + const header = { + name: 'fx_header', + label: 'Header', + sharingModel: 'private', + fields: { name: { type: 'text', label: 'Name' }, status: { type: 'text', label: 'Status' } }, + }; + const line = (visibleWhen: unknown) => ({ + name: 'fx_line', + label: 'Line', + sharingModel: 'private', + fields: { + hdr: { type: 'master_detail', label: 'Header', reference: 'fx_header' }, + x: { type: 'text', label: 'X' }, + tier: { + type: 'select', + label: 'Tier', + options: [{ label: 'Standard', value: 'standard' }, { label: 'Gold', value: 'gold', visibleWhen }], + }, + }, + }); + /** The card's measured body. */ + const PARENT = "parent.status == 'closed'"; + /** Where the build locates it — the option the author edits. */ + const WHERE = "object 'fx_line' · field 'tier' option 'gold' visibleWhen"; + + /** A protocol whose live registry holds the header — the stored universe. */ + 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 }); + const buildFindings = (obj: unknown) => { + const stack = { objects: [header, obj] }; + return runAuthoringRules('build', { normalized: stack, parsed: stack }) + .filter((f) => f.rule === EXPRESSION_INVALID); + }; + + 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(PARENT)).catch((e: any) => e); + + expect(err, 'the save resolved — the door still accepts the option predicate').toBeInstanceOf(Error); + expect({ code: err.code, status: err.status }).toEqual({ code: 'INVALID_METADATA', status: 422 }); + 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].severity).toBe('error'); + // The named subject: the option, the field and the root. + expect(issues[0].message).toContain("option 'gold' on field 'tier' reads `parent`"); + expect(lineRows(rows)).toEqual([]); + }); + + it('(b) the same body still saves as a DRAFT — drafts are never gated', async () => { + const { protocol, rows } = hostWithHeader(); + + await expect(saveLine(protocol, line(PARENT), { mode: 'draft' })).resolves.toMatchObject({ success: true }); + + expect(lineRows(rows).map((r) => r.state)).toEqual(['draft']); + }); + + it('(c) control: an option reading `record` saves, and the row lands', async () => { + const { protocol, rows } = hostWithHeader(); + + const result = await saveLine(protocol, line("record.x == 'a'")); + + expect(result.success).toBe(true); + expect(lineRows(rows).map((r) => r.state)).toEqual(['active']); + }); + + it('(d) the door and `os build` give the SAME finding', async () => { + const { protocol } = hostWithHeader(); + const err = await saveLine(protocol, line(PARENT)).catch((e: any) => e); + const atDoor = (err.issues ?? []).filter((i: any) => i.rule === EXPRESSION_INVALID); + + const atBuild = buildFindings(line(PARENT)); + + // Non-vacuous on both sides. + expect(atBuild).toHaveLength(1); + expect(atBuild[0]!.severity).toBe('error'); + expect(atDoor).toHaveLength(1); + for (const key of ['rule', 'where', 'path', 'message', 'hint'] as const) { + expect(atDoor[0][key], `door and build disagree on '${key}'`).toBe(atBuild[0]![key]); + } + // And the control is clean at the build too, not just at the door. + expect(buildFindings(line("record.x == 'a'"))).toEqual([]); + }); +}); + /** * [#22118] The object save door judges a stored sibling's finding against the * STORED universe — the registry's objects WITH the written object's stored From 012e8d7dbee0a85ca14d4cfccc296e77952cb56f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 09:55:08 +0000 Subject: [PATCH 3/3] chore(changeset): the option visibleWhen root verdict is a narrowing at both doors (#22157) Also pins the draft's promotion and a package draft publish at the object save door. Claude-Session: https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK Co-authored-by: Claude --- .../22157-option-visible-when-parent.md | 31 +++++++++++++++++++ .../protocol.runtime-authoring-gate.test.ts | 26 ++++++++++++++-- 2 files changed, 55 insertions(+), 2 deletions(-) create mode 100644 .changeset/22157-option-visible-when-parent.md diff --git a/.changeset/22157-option-visible-when-parent.md b/.changeset/22157-option-visible-when-parent.md new file mode 100644 index 00000000000..6daeb618a45 --- /dev/null +++ b/.changeset/22157-option-visible-when-parent.md @@ -0,0 +1,31 @@ +--- +"@objectstack/lint": minor +"@objectstack/metadata-protocol": minor +--- + +fix(lint)!: `os build` and the object save door refuse a select option's `visibleWhen` that reads `parent`, or any other root the server's option check does not bind (#22157) + +Clause-②: no (narrowing: `os build` and the object save door refuse a select option's `visibleWhen` that reads `parent`, which the runtime's option check cannot bind) + +A select option's `visibleWhen` is a gate the server enforces on write: the rule validator evaluates the predicate for the value a caller picks, and refuses the value when the predicate is false. That option check binds `record`, `previous` and the acting user (`current_user`, and its ADR-0068 aliases `user`, `ctx` and `os`), and nothing else. An option predicate that read `parent`, such as `parent.status == 'closed'`, still passed `os build` and the object save door. `parent` is a root the platform declares, and a field's own `readonlyWhen` and `requiredWhen` bind it on an object with exactly one `master_detail`. The option check does not bind it. So the predicate faulted on every write that picked the option, the server logged "the option's gate was NOT enforced on this write", and the value was admitted. + +The build's expression rule (`validateStackExpressions`) now gives a select option's `visibleWhen` a root verdict over the roots the option check binds. A predicate that reads any other root the platform declares, `parent` above all, is refused at `error` and located at the option (`object 'NAME' · field 'FIELD' option 'VALUE' visibleWhen`). The message names the option, the field and the root. The object save door runs the same pass, so its verdict is the build's finding: the same rule id (`expression-invalid`), location, message and hint. + +**BREAKING — what moves for consumers.** + +- `os build`, `os validate` and `os lint` refuse an option `visibleWhen` that reads a root other than `record`, `previous`, `current_user`, `user`, `ctx` or `os`. Besides `parent`, that covers the roots the platform declares for other evaluation sites, such as `input`, `vars`, `trigger`, `data` and `features`. Each of them faulted at the option check in the same way. +- An object write in publish mode that carries such an option answered 200. 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`). + +**Remedy.** Rewrite the predicate against what the option check binds: `record.FIELD`, `previous.FIELD`, or the acting user as `current_user`. To gate a choice on a master-detail header's state, read a column the detail object declares, and denormalise the header value onto the detail; `parent` is bound only for a field's own `readonlyWhen` and `requiredWhen`. Saving the object as a draft (`mode: 'draft'`) is still allowed, because drafts are never gated; publishing that draft is judged. + +**Unchanged.** + +- The server's option check is unchanged. It binds what it bound before, and an option predicate that faults is still logged and admitted. If the runtime comes to bind `parent` for an option, this refusal is lifted in that same change. +- The acting user is still accepted in an option's `visibleWhen` under all four ADR-0068 spellings, and so is a grant check such as `current_user.can('OBJECT', 'edit')`. On a field's own `requiredWhen`, `readonlyWhen` or `visibleWhen`, the acting user is still refused and `parent` is still accepted, as before. +- A root the platform does not declare at all, such as `app`, was already refused in an option's `visibleWhen` by the bare-reference check, and it still is, with the same message. +- 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. +- Measured before crossing: every object this repository ships carries 5 option predicates, all on `showcase_cascade`: four `record.country` cascades and one `current_user.positions` role gate. That is over the 119 objects from its `*.object.ts` files and the two `app-multi-package` sub-stacks, and over the example stacks as `defineStack` composes them (33 objects). They have 0 refusals and 0 advisories, at the build and at the door, before this change and after it. +- 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/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts b/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts index d807276f994..ab06e526ae5 100644 --- a/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts +++ b/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts @@ -2228,7 +2228,8 @@ describe('runtime authoring gate on OBJECT writes — the option visibleWhen ver * (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); + * (b) the same body still saves as a DRAFT (drafts are never gated), and the + * draft's promotion and a package's draft publish are refused; * (c) control: an option reading `record` saves, and the row lands; * (d) the door's issue and the build's finding are the same finding. * @@ -2296,11 +2297,32 @@ describe('runtime authoring gate on OBJECT writes — an option visibleWhen read expect(lineRows(rows)).toEqual([]); }); - it('(b) the same body still saves as a DRAFT — drafts are never gated', async () => { + it('(b) the same body still saves as a DRAFT — drafts are never gated — and its PROMOTION is refused', async () => { const { protocol, rows } = hostWithHeader(); await expect(saveLine(protocol, line(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("(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(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']); });