From 44db8605b987830526a47b9a3b0c815fd91c1ab8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 08:32:46 +0000 Subject: [PATCH 1/2] wip: reword the three stale visibleWhen binding text sites (#11813) --- .../scripts/check-doc-formula-expressions.mjs | 26 ++++++++-- .../lint/src/validate-expressions.test.ts | 10 +++- packages/lint/src/validate-expressions.ts | 24 +++++++--- packages/spec/src/ui/view.zod.ts | 47 ++++++++++++++----- 4 files changed, 86 insertions(+), 21 deletions(-) diff --git a/packages/lint/scripts/check-doc-formula-expressions.mjs b/packages/lint/scripts/check-doc-formula-expressions.mjs index de76802145..3218430ba0 100644 --- a/packages/lint/scripts/check-doc-formula-expressions.mjs +++ b/packages/lint/scripts/check-doc-formula-expressions.mjs @@ -556,16 +556,36 @@ function scan(files) { * * `visibleWhen` is one key spelling several unrelated contracts, and the binding * root genuinely differs by layer. Re-measured on objectui `origin/main` - * @ `2aff580` (the card's table was taken at `365e334`, a sibling repo on its - * own cadence — the PR body records what had drifted): + * @ `f241a4d` for #11813 (the previous reading was `2aff580`; the card's + * original table was `365e334` — a sibling repo on its own cadence, so each + * re-measure records what had drifted since the last one): * * | Layer | Binds | * |-----------------------------------------|-------------------------------------------| - * | object field / form section `*When` | `record` + `previous` (+ `parent`) | + * | object field `*When` (ADR-0036) | `record` + `previous` (+ `parent`) | + * | view form FIELD `visibleWhen` | `record` + the HOST predicate scope | * | per-option `visibleWhen` | `record` + the HOST predicate scope | * | page component / app-nav `visible` | `current_user`/`user`/`ctx`/`os`/`app`/… | * | flow-screen field `visibleWhen` | the screen's own field names, FLATTENED | * + * Row 1 states the WRITE-PATH binding, and that is deliberate rather than + * partial: an object field rule is enforced by the rule validator, whose + * evaluator binds no user root, and this is the only layer the gate judges. + * Since objectui#6010 the renderer ALSO hands the host scope to an object + * field's rules, so a user root written there resolves client-side — the form + * hides the control while every other reader still returns the value. That is a + * silent enforcement gap, which is why the verdict is unchanged and the row is + * qualified rather than widened (#12914 tracks the message text that describes + * the mechanism). + * + * Row 2 is the split #11813 asked for. It used to share row 1 with the object + * field layer, and since objectui#6010 that was two verdicts in one cell. The + * form SECTION layer shared the same cell and its verdict moved as well, with + * objectui#6110 / objectui#6111; it is absent from the table rather than + * re-stated because it lands in the SKIPPED class here either way — neither + * discriminator arm below reaches it — and its re-measurement is #12914, not + * something this gate turns on. + * * The last row is not hypothetical and it is in this very corpus: * `content/docs/automation/flows.mdx` teaches * `visibleWhen: 'createOpportunity == true'` — a BARE reference, which is diff --git a/packages/lint/src/validate-expressions.test.ts b/packages/lint/src/validate-expressions.test.ts index 3331a9c0e9..d34f0c353e 100644 --- a/packages/lint/src/validate-expressions.test.ts +++ b/packages/lint/src/validate-expressions.test.ts @@ -707,8 +707,16 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => { expect(issue!.message).not.toContain('record.current_user'); // 1. what actually goes wrong, in the direction it goes wrong expect(issue!.message).toMatch(/falls back to VISIBLE/); - // 2. the one `*When` surface that binds `current_user` + // 2. the surface whose `current_user` gate the SERVER enforces — the + // rule validator evaluates a per-option `visibleWhen` on every + // write. It stopped being the ONLY `*When` surface that binds the + // root when objectui#6010 bound a form view's field predicate + // (client-side only), so the prescription is grounded on + // enforcement rather than on exclusivity. expect(issue!.message).toMatch(/option's own `visibleWhen`/); + expect(issue!.message).toMatch(/rule validator/); + // …and the retired exclusivity claim does not come back. + expect(issue!.message).not.toMatch(/the one `\*When` surface/); // 3. the surface that hides a whole field by role — FLS on a permission // set (`PermissionSetSchema.fields`, `permission.zod.ts:455`) expect(issue!.message).toMatch(/readable: false/); diff --git a/packages/lint/src/validate-expressions.ts b/packages/lint/src/validate-expressions.ts index 4bbde99a50..18a871d550 100644 --- a/packages/lint/src/validate-expressions.ts +++ b/packages/lint/src/validate-expressions.ts @@ -590,7 +590,16 @@ function rulePredicates(rule: AnyRec, path: string): Array<{ label: string; raw: * the option's `visibleWhen`" answers a question nobody asked. Three tiers: * * - **user roots** (`current_user` / `user` / `ctx` / `os`) keep the existing - * two user-specific prescriptions plus the `record` rewrite; + * two user-specific prescriptions plus the `record` rewrite. What changed is + * the GROUNDING of the first one, not the prescription: it used to read + * "per-option is the one `*When` surface that binds `current_user`", and + * since objectui#6010 a form VIEW's field predicate binds those roots too — + * client-side only, because nothing on the write path evaluates a form-view + * field `visibleWhen`. So per-option is recommended for the reason that + * survives the change: the rule validator enforces it on write. The message + * now says that, and names the view surface only to refuse it as a + * destination for a server-enforced object rule (trading a loud lint error + * for a silent enforcement gap is the one outcome worse than the error); * - **`data`** gets the metadata-form-vs-runtime-form explanation, because * that is what the mistake IS — the same key name, the other form kind's * root; @@ -676,11 +685,14 @@ export function fieldRuleRootIssue( const root = FIELD_RULE_USER_ROOTS.find((r) => kept.includes(r)) ?? kept[0]!; const prescription = (FIELD_RULE_USER_ROOTS as readonly string[]).includes(root) ? `To gate the CHOICES of a select by user, move the predicate to the option's own ` + - `\`visibleWhen\` (\`options: [{ …, visibleWhen: … }]\`) — per-option is the one \`*When\` ` + - `surface that binds \`current_user\` and its ADR-0068 aliases. To hide the FIELD by role, ` + - `declare field-level security on a permission set ` + - `(\`fields: { '.': { readable: false } }\`), which the server enforces. ` + - `To gate on record state, rewrite the predicate against \`record\`.` + `\`visibleWhen\` (\`options: [{ …, visibleWhen: … }]\`) — the rule validator evaluates ` + + `that one against \`current_user\` and its ADR-0068 aliases on every write, so the gate ` + + `holds server-side. To hide the FIELD by role, declare field-level security on a ` + + `permission set (\`fields: { '.': { readable: false } }\`), which the ` + + `server enforces. To gate on record state, rewrite the predicate against \`record\`. ` + + `Note that a form VIEW's own field predicate HAS bound these roots since objectui#6010 — ` + + `but only in the renderer, so moving a server-enforced object rule there swaps a loud ` + + `error for a silent enforcement gap; it is not a fourth answer.` : root === 'data' // The `*.form` spelling is deliberately not `*.form.ts`: this is a // STRING literal, and #5017's receiver scan strips comments but not diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index bc2074d30c..1153c4924b 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -1950,16 +1950,41 @@ const FormFieldBaseSchema = lazySchema(() => { * `parent` for master-detail line items); metadata-editing forms * (`*.form.ts`) bind the row under edit as `data`. * - * ⚠️ **No `current_user` here** (#6146). Field-level rules are evaluated by - * `evalFieldPredicate` / `resolveFieldRuleState` (`@object-ui/core`), which - * binds `record` + `previous` + an `extra` scope and nothing else — the - * autocomplete pins the same set (objectui#1582). A predicate referencing - * `current_user` is an UNBOUND identifier: the evaluation faults and falls - * back, and visibility's fallback is `true`, so the field a `current_user` - * test was meant to hide stays **permanently visible**. `current_user` IS - * bound for **per-option** `visibleWhen` (a different evaluator — - * `resolveCascadingOptions` against the host's predicate scope, ADR-0068 / - * objectui#2284); that is the only `*When` surface where it resolves. + * ## `current_user` DOES bind here — client-side, and only under a host that + * publishes a scope (objectui#6010) + * + * The form renderer passes the host shell's predicate scope into + * `evalFieldPredicate` / `resolveFieldRuleState` (`@object-ui/core`) as their + * `extra` scope, so a field-level predicate resolves `current_user` and its + * ADR-0068 D1 aliases (`user`, `ctx.user`, `os.user`) alongside `record` + + * `previous` + `parent`. The root really was unbound here (#6146) and this + * block said so for as long as that held; objectui#6010 bound it, and the + * text above is a re-measurement rather than a relaxation of anything. + * + * Two limits the binding does **not** remove — both fail in the direction an + * author will not notice: + * + * 1. ⚠️ **This is a rendering rule, never authorization.** Nothing + * server-side evaluates a form-view field `visibleWhen`: the write path + * evaluates field `readonlyWhen` / `requiredWhen` and per-option + * `visibleWhen`, and that is the whole list. A role test written here + * hides a control and protects no data — the record still carries the + * value and every other read surface still returns it. To withhold a field + * by role, declare field-level security on a permission set, which the + * server enforces. + * 2. ⚠️ **The scope belongs to the HOST, so it is empty wherever no host + * publishes one.** The console's public form route (`/f/:slug`) is mounted + * outside any provider deliberately — an anonymous visitor has no + * principal — so `current_user` is unbound there, the predicate faults, + * and visibility's fallback is `true`: the field the test was meant to + * hide is shown to everyone. The authed standalone route (`/forms/:name`) + * publishes the session principal and binds normally. + * + * `current_user` is bound for **per-option** `visibleWhen` as well, through a + * different evaluator (`resolveCascadingOptions` against the same host scope, + * ADR-0068 / objectui#2284) — and that surface the rule validator ALSO + * enforces on write, which is why it stays the destination for a user-gated + * *choice* rather than a user-gated field. * * **Inside a repeater, `data` is the ROW, not the whole document** (#6254). * A sub-field of a `type: 'record'` / repeater field is rendered with its own @@ -1978,7 +2003,7 @@ const FormFieldBaseSchema = lazySchema(() => { * this one is ENFORCED: see {@link checkFormViewPredicateFeaturesRoot} for * the ruling and the scanner. */ - visibleWhen: ExpressionInputSchema.optional().describe("Visibility predicate (CEL) — field shown only when TRUE. Root: `record` (+ `previous`, `parent`) in runtime forms, or `data` in metadata forms. No `current_user` at field level — it is unbound here and the predicate would fault open (per-option `visibleWhen` is the surface that binds it). No `features.*` on ANY form-view predicate — refused at parse (ruled 2026-08-27, objectui#6262): the root is unbound on the standalone form routes (`/forms/:name`, `/f/:slug`) and the predicate would fault open there. Inside a repeater `data` is the ROW, but it is still spelled `data` — a bare identifier is unbound and faults open too. e.g. P`record.priority == 'urgent'`"), + visibleWhen: ExpressionInputSchema.optional().describe("Visibility predicate (CEL) — field shown only when TRUE. Root: `record` (+ `previous`, `parent`) in runtime forms, or `data` in metadata forms. `current_user` (and the ADR-0068 aliases `user` / `ctx.user` / `os.user`) resolves here since objectui#6010 — CLIENT-SIDE only: nothing server-side evaluates a form-view field `visibleWhen`, so a role test here hides the control and protects no data (declare permission-set field-level security for that), and on the public `/f/:slug` route no host publishes a scope, so the root is unbound and the predicate faults open. No `features.*` on ANY form-view predicate — refused at parse (ruled 2026-08-27, objectui#6262): the root is unbound on the standalone form routes (`/forms/:name`, `/f/:slug`) and the predicate would fault open there. Inside a repeater `data` is the ROW, but it is still spelled `data` — a bare identifier is unbound and faults open too. e.g. P`record.priority == 'urgent'`"), /** @deprecated ADR-0089 — use `visibleWhen`. Accepted and normalized to `visibleWhen` at parse. */ visibleOn: ExpressionInputSchema.optional().describe('[DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Normalized to `visibleWhen` at parse.'), disclosure: z.enum(['inline', 'popover']).optional().describe('Composite rendering: inline bordered box (default) or a summary line + gear popover (progressive disclosure).'), From a2c6806865edd91cc5c40e5cf9acbf899888041c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 09:12:59 +0000 Subject: [PATCH 2/2] =?UTF-8?q?docs(spec,lint):=20a=20form=20FIELD=20`visi?= =?UTF-8?q?bleWhen`=20binds=20`current=5Fuser`=20=E2=80=94=20re-measure=20?= =?UTF-8?q?the=20three=20stale=20text=20sites?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit objectui#6010 bound the host predicate scope on the form renderer's authored- predicate call sites, so a runtime form FIELD `visibleWhen` / `visibleOn` now resolves `current_user` and the ADR-0068 D1 aliases. Three texts shipped by this repo still asserted the opposite, and that per-option `visibleWhen` was "the only `*When` surface where it resolves". - `packages/spec/src/ui/view.zod.ts`: `FormFieldSchema.visibleWhen`'s doc block and its `describe()` state the binding plus the two limits it does not remove — it is a rendering rule nothing on the write path evaluates, and the scope is the host's, so it is empty (and fails open) on the public standalone form route. - `content/docs/references/ui/view.mdx`: regenerated from the above, never hand-edited (`gen:docs`; two `FormField` rows). - `packages/lint`: the field-rule prescription is re-grounded on ENFORCEMENT — the rule validator evaluates a per-option predicate on every write — instead of on exclusivity, and names the form-view field predicate only to refuse it as a destination for a server-enforced object rule. The gate script's layer table splits the folded cell and is re-measured at objectui `f241a4d`. The `FormSectionSchema.visibleWhen` slot is deliberately NOT swept: its verdict was re-measured and has changed too (objectui#6110 / objectui#6111 both landed), which is a different card — the evidence and three further stale sites are recorded in the follow-up issue rather than fixed here. No schema, validation or verdict change: `check:authorable-surface` is green and `FIELD_RULE_BOUND_ROOTS` is untouched. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LpRNHxWZgSUgVnFT9mQQo4 --- .changeset/quiet-pugs-shave.md | 13 +++++++++++++ content/docs/references/ui/view.mdx | 4 ++-- 2 files changed, 15 insertions(+), 2 deletions(-) create mode 100644 .changeset/quiet-pugs-shave.md diff --git a/.changeset/quiet-pugs-shave.md b/.changeset/quiet-pugs-shave.md new file mode 100644 index 0000000000..29f5d301c2 --- /dev/null +++ b/.changeset/quiet-pugs-shave.md @@ -0,0 +1,13 @@ +--- +"@objectstack/spec": patch +"@objectstack/lint": patch +--- + +Correct three stale `current_user` binding claims about a form FIELD `visibleWhen` + +A runtime form field's `visibleWhen` has resolved `current_user` — and the ADR-0068 D1 aliases `user` / `ctx.user` / `os.user` — since objectui#6010, but three texts shipped by these two packages still told authors the root was unbound there, and that per-option `visibleWhen` was "the only `*When` surface where it resolves". + +- `@objectstack/spec`: `FormFieldSchema.visibleWhen`'s doc block and its `describe()` now state the binding together with the two limits it does not remove — it is a rendering rule that nothing on the write path evaluates, so a role test written there protects no data; and the scope belongs to the host, so it is empty on the console's public standalone form route, where the predicate faults and visibility fails open. The generated `content/docs/references/ui/view.mdx` rows follow from the `describe()`. +- `@objectstack/lint`: the field-rule prescription no longer grounds "move it to the option's own `visibleWhen`" on exclusivity. It grounds it on enforcement — the rule validator evaluates a per-option predicate on every write — and names the form-view field predicate only to refuse it as a destination for a server-enforced object rule, since moving one there would trade a loud lint error for a silent enforcement gap. + +No schema, validation or verdict change: the set of accepted metadata is byte-identical, and the rule still refuses a user root on an object field-level `*When`. diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index e1e17ee671..f1ab4e9aa8 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -160,7 +160,7 @@ Column footer summary configuration | **language** | `string` | optional | Code editor language (for type=code) | | **keyField** | `{ field?: string; label?: string \| Record; placeholder?: string \| Record; helpText?: string \| Record; … }` | optional | Key column config for record-typed fields | | **dependsOn** | `string` | optional | Parent field name for cascading | -| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — field shown only when TRUE. Root: `record` (+ `previous`, `parent`) in runtime forms, or `data` in metadata forms. No `current_user` at field level — it is unbound here and the predicate would fault open (per-option `visibleWhen` is the surface that binds it). No `features.*` on ANY form-view predicate — refused at parse (ruled 2026-08-27, objectui#6262): the root is unbound on the standalone form routes (`/forms/:name`, `/f/:slug`) and the predicate would fault open there. Inside a repeater `data` is the ROW, but it is still spelled `data` — a bare identifier is unbound and faults open too. e.g. P`record.priority == 'urgent'` | +| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — field shown only when TRUE. Root: `record` (+ `previous`, `parent`) in runtime forms, or `data` in metadata forms. `current_user` (and the ADR-0068 aliases `user` / `ctx.user` / `os.user`) resolves here since objectui#6010 — CLIENT-SIDE only: nothing server-side evaluates a form-view field `visibleWhen`, so a role test here hides the control and protects no data (declare permission-set field-level security for that), and on the public `/f/:slug` route no host publishes a scope, so the root is unbound and the predicate faults open. No `features.*` on ANY form-view predicate — refused at parse (ruled 2026-08-27, objectui#6262): the root is unbound on the standalone form routes (`/forms/:name`, `/f/:slug`) and the predicate would fault open there. Inside a repeater `data` is the ROW, but it is still spelled `data` — a bare identifier is unbound and faults open too. e.g. P`record.priority == 'urgent'` | | **visibleOn** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Normalized to `visibleWhen` at parse. | | **disclosure** | `Enum<'inline' \| 'popover'>` | optional | Composite rendering: inline bordered box (default) or a summary line + gear popover (progressive disclosure). | | **fields** | `[FormField](#formfield)[]` | optional | Sub-fields for composite/repeater/record types | @@ -322,7 +322,7 @@ View filter rule | **language** | `string` | optional | Code editor language (for type=code) | | **keyField** | `{ field?: string; label?: string \| Record; placeholder?: string \| Record; helpText?: string \| Record; … }` | optional | Key column config for record-typed fields | | **dependsOn** | `string` | optional | Parent field name for cascading | -| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — field shown only when TRUE. Root: `record` (+ `previous`, `parent`) in runtime forms, or `data` in metadata forms. No `current_user` at field level — it is unbound here and the predicate would fault open (per-option `visibleWhen` is the surface that binds it). No `features.*` on ANY form-view predicate — refused at parse (ruled 2026-08-27, objectui#6262): the root is unbound on the standalone form routes (`/forms/:name`, `/f/:slug`) and the predicate would fault open there. Inside a repeater `data` is the ROW, but it is still spelled `data` — a bare identifier is unbound and faults open too. e.g. P`record.priority == 'urgent'` | +| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — field shown only when TRUE. Root: `record` (+ `previous`, `parent`) in runtime forms, or `data` in metadata forms. `current_user` (and the ADR-0068 aliases `user` / `ctx.user` / `os.user`) resolves here since objectui#6010 — CLIENT-SIDE only: nothing server-side evaluates a form-view field `visibleWhen`, so a role test here hides the control and protects no data (declare permission-set field-level security for that), and on the public `/f/:slug` route no host publishes a scope, so the root is unbound and the predicate faults open. No `features.*` on ANY form-view predicate — refused at parse (ruled 2026-08-27, objectui#6262): the root is unbound on the standalone form routes (`/forms/:name`, `/f/:slug`) and the predicate would fault open there. Inside a repeater `data` is the ROW, but it is still spelled `data` — a bare identifier is unbound and faults open too. e.g. P`record.priority == 'urgent'` | | **visibleOn** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Normalized to `visibleWhen` at parse. | | **disclosure** | `Enum<'inline' \| 'popover'>` | optional | Composite rendering: inline bordered box (default) or a summary line + gear popover (progressive disclosure). | | **fields** | `{ field: string; type?: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; options?: object[]; reference?: string; … }[]` | optional | Sub-fields for composite/repeater/record types |