From 69cb2dd5f5541ebec4d11803717708424602ba52 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 01:07:50 +0000 Subject: [PATCH 1/4] fix(lint)!: refuse an option visibleWhen member of ctx/os the option check never binds (#22274) WIP: the member arm of the option-predicate verdict; pins follow. Claude-Session: https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK Co-authored-by: Claude --- packages/lint/src/validate-expressions.ts | 130 +++++++++++++++++++++- 1 file changed, 124 insertions(+), 6 deletions(-) diff --git a/packages/lint/src/validate-expressions.ts b/packages/lint/src/validate-expressions.ts index 95452c572b..256380b4d7 100644 --- a/packages/lint/src/validate-expressions.ts +++ b/packages/lint/src/validate-expressions.ts @@ -579,9 +579,11 @@ function rulePredicates(rule: AnyRec, path: string, depth = 0): RulePredicate[] * set in #6713 — it is the fourth name `buildScope` mounts the same object * under, and the allowlist would have rejected it anyway; what the user tier * decides is only WHICH prescription it gets, and the user one is right for - * `os.user`. `os.org` / `os.env` land in the same tier because the option - * surface named by the first prescription binds the whole `os` namespace, not - * only its `user` member.) + * `os.user`. `os.org` / `os.env` land in the same tier too. The option + * surface named by the first prescription does NOT bind them: the server's + * option check fills `os` with its `user` member only, so a predicate moved + * there meets that surface's member verdict (#22274), which names what is + * bound instead.) * * `ctx` is judged as a WHOLE root, not only in `ctx.user` form, because at * this surface that is simply what is true: `buildScope` creates the `ctx` @@ -1107,9 +1109,55 @@ function fieldTraversalMessage( * * ⛔ A root joins this list in the same change that binds it in * `evaluateOptionVisibility`, never before. + * + * Two of these roots are accepted WHOLE here but bound only IN PART: `ctx` and + * `os` carry just their `user` member at the option check. What they carry is + * {@link OPTION_VISIBLE_WHEN_BOUND_MEMBERS}'s to say (#22274). */ const OPTION_VISIBLE_WHEN_BOUND_ROOTS: readonly string[] = ['record', 'previous', 'current_user', 'user', 'ctx', 'os']; +/** + * [#22274] The MEMBERS the option check binds under the two namespace roots + * {@link OPTION_VISIBLE_WHEN_BOUND_ROOTS} accepts whole, read off the same + * call as that list. + * + * `evaluateOptionVisibility` (`rule-validator.ts`) hands the evaluator + * `{ record, previous, user, permissions }`. `@objectstack/formula`'s + * `buildScope` builds `ctx` and `os` as containers, and from that context it + * fills each with one member, `user` (ADR-0068 D1's alias of `current_user`, + * the same `EvalUser` object). `os` gains `org` only from an `org` in the + * context, and `os.env` only from an `env`. The option check passes neither, + * and `ctx` has no other source in `buildScope` at all. So `os.org.id`, + * `os.env` and `ctx.locale` are unbound at the option check, though `os.org` + * is bound at other sites (a `formula` field, an expression `defaultValue`, a + * seed value) and `os.env` at the seed loader. Measured through the built + * `evaluateValidationRules` with an authenticated caller, each faults + * (`No such key: org` / `env` / `locale`) and the value is admitted. + * + * Only `ctx` and `os` are listed. `current_user` and `user` ARE the `EvalUser`; + * their members are that object's own fields, the same at every site that + * binds a user, so no member of theirs is unbound here in particular. That is + * also why this verdict stops at the first member: `ctx.user.positions` reads + * the `EvalUser` and is judged like `current_user.positions`, not here. + * + * The test suite derives this list from the real `buildScope` and evaluator, + * given the option check's context, so a member `buildScope` starts mounting + * there, or one this list accepts that it never mounts, turns that test red. + * ⛔ A member joins this list in the same change that binds it in + * `evaluateOptionVisibility`, never before. + */ +const OPTION_VISIBLE_WHEN_BOUND_MEMBERS: Readonly> = { + ctx: ['user'], + os: ['user'], +}; + +/** A CEL member path, spelled for a message: `` `root.member` ``. */ +function celMemberPath(root: string, member: string): string { + // Assembled with `+`: #5017's receiver scan reads string literals too, so a + // literal CEL path in a message would register its root as a read receiver. + return '`' + root + '.' + member + '`'; +} + /** * [#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}) @@ -1137,8 +1185,19 @@ const OPTION_VISIBLE_WHEN_BOUND_ROOTS: readonly string[] = ['record', 'previous' * records (#6713): a root added to `SCOPE_ROOTS` is judged here the day it * lands, with no second list to copy it into. * + * ## Members of a bound root (#22274) + * + * `ctx` and `os` pass the root test, but the option check fills them with + * their `user` member only ({@link OPTION_VISIBLE_WHEN_BOUND_MEMBERS}). A + * predicate that reads any other member of either (`os.org.id`, `os.env`, + * `ctx.locale`) is refused by the same verdict, located at the same option, + * when it reads no unbound root. The members a source reads are + * `@objectstack/formula`'s `analyzeRelationshipTraversals` reading of that + * root, so `os.org`, `os.?org`, `os['org']` and `has(os.org)` are one read. A + * computed key (`os[k]`) names no member and is not judged. + * * `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. + * that), or every root and member it reads is one the option check binds. */ function optionVisibleWhenRootIssue( objectName: string | undefined, @@ -1151,7 +1210,7 @@ function optionVisibleWhenRootIssue( const kept = SCOPE_ROOTS.filter( (r) => !OPTION_VISIBLE_WHEN_BOUND_ROOTS.includes(r) && roots.roots.includes(r), ); - if (kept.length === 0) return null; + if (kept.length === 0) return optionVisibleWhenMemberIssue(objectName, field, option, source, roots.roots); // 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. @@ -1175,6 +1234,64 @@ function optionVisibleWhenRootIssue( }; } +/** + * [#22274] The member half of {@link optionVisibleWhenRootIssue}: a member of + * `ctx` or `os` the option check does not bind + * ({@link OPTION_VISIBLE_WHEN_BOUND_MEMBERS}). One finding per option, the + * first unbound member in `SCOPE_ROOTS` order and then in name order: stable, + * never AST walk order. + * + * The message names the member, says what the option check DOES bind under + * that root, and gives the remedy that is true for the member. For `os.org` + * that is `current_user.organizationId`: the engine builds the acting user + * with the caller's organization id (`null` outside one), so that one + * organization fact IS bound at the option check. + */ +function optionVisibleWhenMemberIssue( + objectName: string | undefined, + field: string, + option: string, + source: string, + roots: readonly string[], +): { root: string; message: string } | null { + for (const root of SCOPE_ROOTS) { + if (OPTION_VISIBLE_WHEN_BOUND_MEMBERS[root] === undefined || !roots.includes(root)) continue; + const found = analyzeRelationshipTraversals(source, root); + if (found === null) continue; + const { traversals, bareFields, multiHopFields } = found; + const member = [...new Set([...Array.from(traversals, ([m]) => m), ...bareFields, ...multiHopFields])] + .sort() + .find((m) => !OPTION_VISIBLE_WHEN_BOUND_MEMBERS[root]!.includes(m)); + if (member === undefined) continue; + const path = celMemberPath(root, member); + const owner = objectName ? `'${objectName}'` : 'this object'; + const prescription = root === 'os' && member === 'org' + ? `The option check binds no organization. The one organization fact it carries is the acting ` + + `user's: ${celMemberPath('current_user', 'organizationId')} holds the caller's organization ` + + `id (\`null\` outside one), so compare that instead. Any other fact about the organization ` + + `cannot gate an option; read a column ${owner} declares.` + : root === 'os' && member === 'env' + ? `The option check binds no deployment environment (${path} is bound only where an ` + + `evaluation is given one, such as a seed value), so the choices a record offers cannot ` + + `depend on it there. Gate on a column ${owner} declares, or on the acting user as ` + + `\`current_user\`.` + : `Whatever other evaluation sites bind under \`${root}\`, the option check binds only its ` + + `\`user\` member. 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 ${path}, 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\`), and under \`${root}\` it binds the ` + + `\`user\` member and nothing else, so ${path} is unbound there. ` + + `${FIELD_TRAVERSAL_CONSEQUENCE['option visibleWhen']}; a \`has()\` test or an optional read ` + + `of it finds it unset on every write. ${prescription}`, + }; + } + return null; +} + /** * [#20078] The master object of an object with exactly ONE `master_detail` * relationship — the record the field level binds as `parent` — or `undefined` @@ -2139,7 +2256,8 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression // 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. + // the other way round. [#22274] The same verdict judges the members of + // `ctx` and `os`, which the option check fills with `user` only. // // [#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 From f270d45a7eff207700fe3d5194765fa3e82343be Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 01:10:52 +0000 Subject: [PATCH 2/4] test(lint): pin the option visibleWhen member verdict at build and at the save door (#22274) Adds the refused/accepted pins, the positive control at a formula field, the buildScope-derived allowlist parity test, and the changeset. Claude-Session: https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK Co-authored-by: Claude --- .../22274-option-visible-when-members.md | 35 +++++ .../lint/src/validate-expressions.test.ts | 139 +++++++++++++++++- .../protocol.runtime-authoring-gate.test.ts | 115 +++++++++++++++ 3 files changed, 288 insertions(+), 1 deletion(-) create mode 100644 .changeset/22274-option-visible-when-members.md diff --git a/.changeset/22274-option-visible-when-members.md b/.changeset/22274-option-visible-when-members.md new file mode 100644 index 0000000000..a560f8a2ba --- /dev/null +++ b/.changeset/22274-option-visible-when-members.md @@ -0,0 +1,35 @@ +--- +"@objectstack/lint": minor +--- + +fix(lint)!: `os build` and the object save door refuse a select option's `visibleWhen` that reads a member of `ctx` or `os` the server's option check never binds, such as `os.org.id`, `os.env` or `ctx.locale` (#22274) + +Clause-②: no (narrowing: a select option's `visibleWhen` that reads an unbound member of a bound root is refused at build and at the object save door) + +A select option's `visibleWhen` is a gate the server enforces on write. The option check binds `record`, `previous` and the acting user, as `current_user` and its ADR-0068 aliases `user`, `ctx.user` and `os.user`. Under `ctx` and `os` it binds the `user` member and nothing else: it passes no organization and no environment. The build already refused a root the option check does not bind, such as `parent`, but it judged `ctx` and `os` as whole roots. So an option predicate that read `os.org.id != ''`, `os.env == 'prod'` or `ctx.locale == 'en'` passed `os build` and the object save door with no finding. On every write that picked the option the predicate then faulted (`No such key: org`, `env` or `locale`), 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 judges the members of `ctx` and `os` in an option's `visibleWhen`, in the same verdict that judges its roots. A member other than `user` is refused at `error` and located at the option (`object 'NAME' · field 'FIELD' option 'VALUE' visibleWhen`). The message names the member, says that the option check binds only the `user` member under that root, and gives the remedy. Every spelling of the read is judged the same: `os.org`, `os.?org`, `os['org']` and `has(os.org)`. 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 member of `ctx` or `os` other than `user`, such as `os.org.id`, `os.org.tier`, `os.env` or `ctx.locale`. +- 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.** + +- For the caller's organization, compare `current_user.organizationId`, which the option check does bind: it holds the acting user's organization id, or `null` when the caller acts outside an organization. `os.org.id != ''` becomes `current_user.organizationId != null`. No other organization fact, such as its tier, is available to an option predicate; read a column the object declares instead. +- `os.env`, `ctx.locale` and any other member: rewrite the predicate against `record.FIELD`, `previous.FIELD`, or the acting user as `current_user`. +- 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 a member such as `os.org` for an option, this refusal is lifted for that member in the same change. +- The acting user is still accepted under all four ADR-0068 spellings (`current_user`, `user`, `ctx.user`, `os.user`), and so are its fields (`current_user.positions`, `ctx.user.id`) and a grant check such as `current_user.can('OBJECT', 'edit')`. +- The same members are still accepted where they are bound, such as `os.org.id` in a `formula` field's `expression`. The refusal is the option slot's alone. +- A computed key such as `os[name]` names no member, so it is not judged. +- 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: the objects this repository ships carry 5 option predicates, all on `showcase_cascade`, which read `record` (four) and `current_user` (one). None reads `ctx` or `os`. 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 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. + + diff --git a/packages/lint/src/validate-expressions.test.ts b/packages/lint/src/validate-expressions.test.ts index 85de72b6c5..837a2f4767 100644 --- a/packages/lint/src/validate-expressions.test.ts +++ b/packages/lint/src/validate-expressions.test.ts @@ -5,7 +5,7 @@ import { describe, it, expect } from 'vitest'; // a copy of it: "a future `SCOPE_ROOTS` member is covered for free" is the whole // argument for the allowlist, and a hand-copied list would go green on exactly // the root the rule never saw. -import { SCOPE_ROOTS } from '@objectstack/formula'; +import { SCOPE_ROOTS, buildScope, ExpressionEngine } from '@objectstack/formula'; import { EVALUATED_EXPRESSION_SOURCE_REQUIRED, ExpressionInputSchema, ObjectStackSchema } from '@objectstack/spec'; import { FieldSchema, ObjectSchema, SelectOptionSchema } from '@objectstack/spec/data'; import { SharingRuleSchema } from '@objectstack/spec/security'; @@ -2088,6 +2088,143 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => { }); }); + /** + * ── The members of `ctx` / `os` the option check never fills (#22274) ─── + * + * `ctx` and `os` pass the root verdict above, but the option check fills + * each with its `user` member only: `evaluateOptionVisibility` hands the + * evaluator `{ record, previous, user, permissions }`, and `buildScope` + * mounts `os.org` / `os.env` only from an `org` / `env` in that context. + * Measured through the built `evaluateValidationRules` with an + * authenticated caller, `os.org.id`, `os.env` and `ctx.locale` each fault + * (`No such key`) and the value is admitted, so the build refuses them. + */ + describe('a per-option `visibleWhen` member of `ctx` / `os` the option check does not bind is refused (#22274)', () => { + const detail = (visibleWhen: unknown, extra: Record = {}) => ({ + objects: [ + { + name: 'fx_line', + fields: { + x: { type: 'text' }, + locale: { type: 'text' }, + tier: { + type: 'select', + options: [{ label: 'Standard', value: 'standard' }, { label: 'Gold', value: 'gold', visibleWhen }], + }, + ...extra, + }, + }, + ], + }); + const WHERE = "object 'fx_line' · field 'tier' option 'gold' visibleWhen"; + /** + * The context the option check hands the evaluator, mirrored from its one + * call (`evaluateOptionVisibility` in ObjectQL's `rule-validator.ts`): the + * merged record, `previous`, the acting user as the engine builds it (with + * the caller's organization id) and the permission map. Nothing else. + */ + const OPTION_CHECK_CONTEXT = { + record: { x: 'a' }, + previous: { x: 'a' }, + user: { id: 'u1', positions: ['member'], organizationId: 'org_1' }, + permissions: {}, + }; + + it.each([ + ["os.org.id != ''", '`os.org`'], + ["os.env == 'prod'", '`os.env`'], + ["ctx.locale == 'en'", '`ctx.locale`'], + ])('⭐ refuses %s at error, located at the option, naming the member and what is bound', (body, path) => { + const issues = validateStackExpressions(detail(body)); + expect(issues, JSON.stringify(issues, null, 2)).toHaveLength(1); + expect(issues[0]).toMatchObject({ where: WHERE, severity: 'error', source: body }); + expect(issues[0]!.message).toContain(`option 'gold' on field 'tier' reads ${path}`); + expect(issues[0]!.message).toContain('the `user` member and nothing else'); + }); + + it('⭐ the `os.org` refusal names the bound replacement, and that replacement passes and evaluates', () => { + expect(validateStackExpressions(detail("os.org.id != ''"))[0]!.message).toContain('`current_user.organizationId`'); + const body = "current_user.organizationId == 'org_1'"; + expect(validateStackExpressions(detail(body))).toEqual([]); + expect(ExpressionEngine.evaluate({ dialect: 'cel', source: body }, OPTION_CHECK_CONTEXT)).toEqual({ ok: true, value: true }); + }); + + it('⭐ CONTROL — the acting user under every ADR-0068 spelling, `record`, `previous` and `can` pass', () => { + for (const body of [ + "current_user.id != ''", + "os.user.id != ''", + "'org_admin' in ctx.user.positions", + "user.id != ''", + "record.x == 'a'", + "previous.x == 'a'", + "current_user.can('fx_line', 'edit')", + // A `record` member merely spelled like a refused one. + "record.locale == 'en'", + ]) { + expect(validateStackExpressions(detail(body)), body).toEqual([]); + } + }); + + it('⭐ POSITIVE CONTROL — the same `os.org.id` on a `formula` field, which binds `os.org`, is not refused', () => { + const atFormula = detail("record.x == 'a'", { in_org: { type: 'formula', expression: "os.org.id != ''" } }); + expect(validateStackExpressions(atFormula)).toEqual([]); + }); + + it('judges every member spelling as one read: optional, indexed and `has()`', () => { + for (const body of ['has(os.org)', 'os.?org.orValue({}) == {}', "os['org'].id != ''", "has(ctx.locale)"]) { + const issues = validateStackExpressions(detail(body)); + expect(issues, body).toHaveLength(1); + expect(issues[0]!.where, body).toBe(WHERE); + } + }); + + it('one finding per option: an unbound root before a member, then members in `SCOPE_ROOTS` order', () => { + const withRoot = validateStackExpressions(detail("ctx.locale == 'en' && input.k == 1")); + expect(withRoot).toHaveLength(1); + expect(withRoot[0]!.message).toContain('reads `input`'); + const twoMembers = validateStackExpressions(detail("ctx.locale == 'en' && os.env == 'prod'")); + expect(twoMembers).toHaveLength(1); + expect(twoMembers[0]!.message).toContain('reads `os.env`'); + }); + + /** + * The allowlist is derived from the code, both ways. The real + * `buildScope` and evaluator, given the option check's context: every + * member they mount under `ctx` / `os` is accepted and evaluates, and + * every member they mount there only when ALSO given an organization and + * an environment is refused and faults. A member `buildScope` starts + * mounting from the option check's context, or one the verdict accepts + * that it no longer mounts, turns this red. + */ + it('the accepted members are exactly the ones `buildScope` mounts for the option check', () => { + const optionScope = buildScope(OPTION_CHECK_CONTEXT); + const fullScope = buildScope({ ...OPTION_CHECK_CONTEXT, org: { id: 'org_1' }, env: 'prod' }); + const accepted: string[] = []; + const refused: string[] = []; + for (const root of ['ctx', 'os']) { + const mounted = Object.keys(optionScope[root] as Record); + for (const member of Object.keys(fullScope[root] as Record)) { + const body = `${root}.${member} != null`; + const issues = validateStackExpressions(detail(body)); + const evaluated = ExpressionEngine.evaluate({ dialect: 'cel', source: body }, OPTION_CHECK_CONTEXT); + if (mounted.includes(member)) { + expect(issues, body).toEqual([]); + expect(evaluated.ok, body).toBe(true); + accepted.push(body); + } else { + expect(issues, body).toHaveLength(1); + expect(evaluated.ok, body).toBe(false); + refused.push(body); + } + } + } + expect({ accepted, refused }).toEqual({ + accepted: ['ctx.user != null', 'os.user != null'], + refused: ['os.org != null', 'os.env != null'], + }); + }); + }); + 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 ab06e526ae..18f14ee6ae 100644 --- a/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts +++ b/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts @@ -2354,6 +2354,121 @@ describe('runtime authoring gate on OBJECT writes — an option visibleWhen read }); }); +/** + * [#22274] The object save door refuses a field option's `visibleWhen` that + * reads a member of `ctx` or `os` the server's option check never binds, as + * `os build` does. + * + * The option check (`evaluateOptionVisibility` in ObjectQL) fills `ctx` and + * `os` with their `user` member only. Measured before this change: an option + * whose `visibleWhen` read `os.org.id`, `os.env` or `ctx.locale` saved through + * this door, and every write that picked the option faulted (`No such key`) + * and was admitted unchecked. + * + * The refusal is in `@objectstack/lint` (the option pass's member verdict); no + * code here moves. Pinned through the REAL `saveMetaItem`: + * + * (a) each measured body is refused on an active save, a 422 + * `INVALID_METADATA` carrying the build's located finding, and nothing + * lands; + * (b) control: the acting user under every ADR-0068 spelling, `record`, + * `previous` and `current_user.can(…)` save, and the row lands; + * (c) 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 an unbound ctx/os member (#22274)', () => { + const item = (visibleWhen: unknown) => ({ + name: 'fx_item', + label: 'Item', + sharingModel: 'private', + fields: { + x: { type: 'text', label: 'X' }, + tier: { + type: 'select', + label: 'Tier', + options: [{ label: 'Standard', value: 'standard' }, { label: 'Gold', value: 'gold', visibleWhen }], + }, + }, + }); + /** The card's measured bodies, each with the member path the finding names. */ + const REFUSED: Array<[string, string]> = [ + ["os.org.id != ''", '`os.org`'], + ["os.env == 'prod'", '`os.env`'], + ["ctx.locale == 'en'", '`ctx.locale`'], + ]; + /** Where the build locates it — the option the author edits. */ + const WHERE = "object 'fx_item' · field 'tier' option 'gold' visibleWhen"; + + const host = () => { + const { engine, rows } = makeStubEngine(); + const protocol = new ObjectStackProtocolImplementation(engine, () => new Map(), 'env_test') as any; + return { protocol, rows }; + }; + const itemRows = (rows: Map) => + Array.from(rows.values()).filter((r) => r.type === 'object' && r.name === 'fx_item'); + const save = (protocol: any, visibleWhen: unknown) => + protocol.saveMetaItem({ type: 'object', name: 'fx_item', item: item(visibleWhen) }); + const buildFindings = (visibleWhen: unknown) => { + const stack = { objects: [item(visibleWhen)] }; + return runAuthoringRules('build', { normalized: stack, parsed: stack }) + .filter((f) => f.rule === EXPRESSION_INVALID); + }; + + it.each(REFUSED)('(a) REFUSES %s on an active save with a 422 carrying the located finding, and nothing lands', async (body, path) => { + const { protocol, rows } = host(); + + const err = await save(protocol, body).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 }); + 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, the member, and what IS bound under its root. + expect(issues[0].message).toContain(`option 'gold' on field 'tier' reads ${path}`); + expect(issues[0].message).toContain('the `user` member and nothing else'); + expect(itemRows(rows)).toEqual([]); + }); + + it('(b) control: the acting user under every ADR-0068 spelling, `record`, `previous` and `can` save, and the row lands', async () => { + for (const body of [ + "current_user.id != ''", + "os.user.id != ''", + "'org_admin' in ctx.user.positions", + "user.id != ''", + "record.x == 'a'", + "previous.x == 'a'", + "current_user.can('fx_item', 'edit')", + "current_user.organizationId != ''", + ]) { + const { protocol, rows } = host(); + const result = await save(protocol, body); + expect(result.success, body).toBe(true); + expect(itemRows(rows).map((r) => r.state), body).toEqual(['active']); + } + }); + + it.each(REFUSED)('(c) the door and `os build` give the SAME finding for %s', async (body) => { + const { protocol } = host(); + const err = await save(protocol, body).catch((e: any) => e); + const atDoor = (err.issues ?? []).filter((i: any) => i.rule === EXPRESSION_INVALID); + + const atBuild = buildFindings(body); + + // 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]); + } + }); +}); + /** * [#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 65ac7df278ede369d260ed195b0690a4e34f866d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 01:28:56 +0000 Subject: [PATCH 3/4] docs(changeset): state the corpus reading without tree-dependent counts (#22274) Claude-Session: https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK Co-authored-by: Claude --- .changeset/22274-option-visible-when-members.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/22274-option-visible-when-members.md b/.changeset/22274-option-visible-when-members.md index a560f8a2ba..cb9a3b3996 100644 --- a/.changeset/22274-option-visible-when-members.md +++ b/.changeset/22274-option-visible-when-members.md @@ -29,7 +29,7 @@ The build's expression rule (`validateStackExpressions`) now judges the members - A computed key such as `os[name]` names no member, so it is not judged. - 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: the objects this repository ships carry 5 option predicates, all on `showcase_cascade`, which read `record` (four) and `current_user` (one). None reads `ctx` or `os`. 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 at the build and at the door, before this change and after it. +- Measured before crossing: the objects this repository ships carry 5 option predicates, all on `showcase_cascade`, which read `record` (four) and `current_user` (one). None reads `ctx` or `os`. That holds over every object in its `*.object.ts` files and the two `app-multi-package` sub-stacks, and over the example stacks as `defineStack` composes them. They have 0 refusals 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. From dffc512cf2bef65a8e3af54dd4350f0c017bcec0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 02:14:32 +0000 Subject: [PATCH 4/4] fix(lint): list metadata-protocol in the changeset; state both directions of a has()/optional member read (#22274) The changeset names metadata-protocol's doors in its BREAKING section, so that package carries the entry beside lint, as the door-crossing changesets in this seam do. The member refusal's tail no longer says a has() test or an optional read is admitted: it is refused on every write, or admitted on every write when negated or defaulted to a passing value. Claude-Session: https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK Co-authored-by: Claude --- .changeset/22274-option-visible-when-members.md | 1 + packages/lint/src/validate-expressions.ts | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.changeset/22274-option-visible-when-members.md b/.changeset/22274-option-visible-when-members.md index cb9a3b3996..0ecf4e4e58 100644 --- a/.changeset/22274-option-visible-when-members.md +++ b/.changeset/22274-option-visible-when-members.md @@ -1,5 +1,6 @@ --- "@objectstack/lint": minor +"@objectstack/metadata-protocol": minor --- fix(lint)!: `os build` and the object save door refuse a select option's `visibleWhen` that reads a member of `ctx` or `os` the server's option check never binds, such as `os.org.id`, `os.env` or `ctx.locale` (#22274) diff --git a/packages/lint/src/validate-expressions.ts b/packages/lint/src/validate-expressions.ts index 256380b4d7..72a3bd4c10 100644 --- a/packages/lint/src/validate-expressions.ts +++ b/packages/lint/src/validate-expressions.ts @@ -1286,7 +1286,8 @@ function optionVisibleWhenMemberIssue( `its ADR-0068 aliases \`user\`, \`ctx\` and \`os\`), and under \`${root}\` it binds the ` + `\`user\` member and nothing else, so ${path} is unbound there. ` + `${FIELD_TRAVERSAL_CONSEQUENCE['option visibleWhen']}; a \`has()\` test or an optional read ` + - `of it finds it unset on every write. ${prescription}`, + `of it never finds it set, so the option is refused on every write instead, or admitted on ` + + `every write when the test is negated or the read's default passes. ${prescription}`, }; } return null;