From 631cf4b72902255126dfcaa43bf11dd91b17d9c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 13:31:39 +0000 Subject: [PATCH] =?UTF-8?q?docs(spec):=20=E5=AD=97=E6=AE=B5=E7=BA=A7/secti?= =?UTF-8?q?on=20=E7=BA=A7=20visibleWhen=20=E4=B8=8D=E5=86=8D=E5=AE=A3?= =?UTF-8?q?=E7=A7=B0=E7=BB=91=E5=AE=9A=20current=5Fuser=20(#6146)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `FormField.visibleWhen` 与 `FormSection.visibleWhen` 把 runtime 绑定根写作 「`record` + `current_user`」。后半截在该层从来不成立,且失败是静默的: FROM「Root: `record`+`current_user` (runtime forms) or `data` (metadata forms)」 TO「Root: `record` (+ `previous`, `parent`) in runtime forms, or `data` in metadata forms」,并写明 `current_user` 在此层未绑定。 字段级/section 级规则由 @object-ui/core 的 evalFieldPredicate / resolveFieldRuleState 求值,只绑 `record`、`previous` 与 `extra` scope (master-detail 行项的 `parent`),再无其他。全部生产调用点都不传用户 scope; objectui#1582 对作者端自动补全钉的是同一套 (FIELD_RULE_ROOTS = ['record','previous','parent'],注释明写 「nothing else (no `current_user`)」)。 为什么这不只是措辞问题:未绑定标识符会让求值 fault,而 fault 一律落到调用方 fallback —— 可见性的 fallback 是 `true`。于是完全照文档写出的谓词 (`'admin' in current_user.positions`)不但没隐藏字段,反而让字段**恒可见**。 照文档办事的作者拿到的是自己所写的反面,且发生在最难察觉的那一面。 `current_user` 在**选项级** visibleWhen(SelectOption)仍有文档、且仍然正确: 选项走另一套求值器 resolveCascadingOptions,对宿主 predicate scope 求值,确实 绑定它(ADR-0068 / objectui#2284)。该处 JSDoc 原先声称选项环境「与字段级 visibleWhen 是 SAME binding environment」—— 恰是那条为假的等式 —— 现改为写明 两者不对称。 仅文档,无 schema / 校验 / 运行时行为变化。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011M7UwH25Unfi73UHim7ajY --- .changeset/fieldrule-no-current-user.md | 40 +++++++++++++++++++++++++ content/docs/references/data/field.mdx | 2 +- content/docs/references/ui/view.mdx | 4 +-- content/docs/ui/views.mdx | 2 +- packages/spec/src/data/field.zod.ts | 17 +++++++---- packages/spec/src/ui/view.zod.ts | 24 +++++++++++---- 6 files changed, 75 insertions(+), 14 deletions(-) create mode 100644 .changeset/fieldrule-no-current-user.md diff --git a/.changeset/fieldrule-no-current-user.md b/.changeset/fieldrule-no-current-user.md new file mode 100644 index 0000000000..b9cc6a07fd --- /dev/null +++ b/.changeset/fieldrule-no-current-user.md @@ -0,0 +1,40 @@ +--- +"@objectstack/spec": patch +--- + +docs(spec): field- and section-level `visibleWhen` stop advertising `current_user` (#6146) + +`FormField.visibleWhen` and `FormSection.visibleWhen` documented their runtime +binding root as "`record` + `current_user`". The second half was never true at +that level, and the failure mode is silent: + +- FROM: "Root: `record`+`current_user` (runtime forms) or `data` (metadata forms)" +- TO: "Root: `record` (+ `previous`, `parent`) in runtime forms, or `data` in + metadata forms" — plus an explicit note that `current_user` is **unbound** here. + +Field- and section-level rules are evaluated by `evalFieldPredicate` / +`resolveFieldRuleState` in `@object-ui/core`, which binds `record`, `previous`, +and an `extra` scope (`parent`, for master-detail line items) — nothing else. +Every production call site passes no user scope, and objectui#1582 pins the same +set for the authoring autocomplete (`FIELD_RULE_ROOTS = ['record','previous','parent']`, +commented "nothing else (no `current_user`)"). + +Why this mattered more than a wording slip: an unbound identifier makes the +evaluation fault, and every fault resolves to the caller's fallback — which for +visibility is `true`. So a predicate written exactly as the spec described it +(`'admin' in current_user.positions`) does not hide the field, it makes the +field **permanently visible**. Authors following the documentation got the +inverse of what they wrote, on the surface where the mistake is least visible. + +`current_user` remains documented, and remains correct, for **per-option** +`visibleWhen` (`SelectOption`): options resolve through a different evaluator, +`resolveCascadingOptions` against the host's predicate scope, which does bind it +(ADR-0068 / objectui#2284). That JSDoc previously claimed the per-option +environment was "the SAME binding environment as field-level `visibleWhen`" — +the very equality that is false — so it now states the asymmetry instead of +asserting it away. + +Documentation only: no schema, validation, or runtime behaviour change. Authors +whose field-level predicates reference `current_user` should know those +predicates are already faulting open today; this change does not alter that, it +stops the spec from recommending it. diff --git a/content/docs/references/data/field.mdx b/content/docs/references/data/field.mdx index 67317405d1..af563840c8 100644 --- a/content/docs/references/data/field.mdx +++ b/content/docs/references/data/field.mdx @@ -222,7 +222,7 @@ const result = AddressSchema.parse(data); | **value** | `string` | ✅ | Stored value (lowercase machine identifier) | | **color** | `string` | optional | Color code for badges/charts | | **default** | `boolean` | optional | Is default option | -| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Per-option visibility predicate (CEL) — option is offered only when TRUE (else omitted). Same env as field visibleWhen (record + current_user). e.g. P`record.country == 'cn'` or P`'admin' in current_user.positions` | +| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Per-option visibility predicate (CEL) — option is offered only when TRUE (else omitted). Env: the live `record` plus the host predicate scope, which binds `current_user` — wider than field-level visibleWhen, which has no `current_user`. e.g. P`record.country == 'cn'` or P`'admin' in current_user.positions` | --- diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index 103ebb5534..9f08cf5ae6 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -159,7 +159,7 @@ Column footer summary configuration | **language** | `string` | optional | Code editor language (for type=code) | | **keyField** | `{ field?: string; label?: string; placeholder?: string; helpText?: string; … }` | 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`+`current_user` (runtime forms) or `data` (metadata forms). 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. 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). 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 | @@ -178,7 +178,7 @@ Column footer summary configuration | **description** | `string` | optional | Optional description rendered under the section header. | | **collapsible** | `boolean` | optional | | | **collapsed** | `boolean` | optional | | -| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — section shown only when TRUE. Root: `record`+`current_user` (runtime forms) or `data` (metadata forms). | +| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — section shown only when TRUE. Root: `record` (+ `previous`, `parent`) in runtime forms, or `data` in metadata forms. No `current_user` at section level — it is unbound here and the predicate would fault open. | | **visibleOn** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Hides the whole section when false. Normalized to `visibleWhen` at parse. | | **columns** | `Enum<'1' \| '2' \| '3' \| '4'> \| 1 \| 2 \| 3 \| 4` | optional | | | **pane** | `Enum<'primary' \| 'secondary'>` | optional | Split pane this section renders in (split forms only; a parse error elsewhere). Omitted → first section 'primary', others 'secondary'. | diff --git a/content/docs/ui/views.mdx b/content/docs/ui/views.mdx index 8d4f6826cd..02351aad3b 100644 --- a/content/docs/ui/views.mdx +++ b/content/docs/ui/views.mdx @@ -370,7 +370,7 @@ fields: [ | `colSpan` | `1-4` | Legacy absolute column span — prefer `span` | | `widget` | `string` | Custom widget/component name | | `dependsOn` | `string` | Parent field for cascading | -| `visibleWhen` | `string` | Visibility predicate (CEL); runtime forms bind `record` + `current_user` (was `visibleOn`, ADR-0089) | +| `visibleWhen` | `string` | Visibility predicate (CEL); runtime forms bind `record` (+ `previous`, `parent`) — **not** `current_user`, which is unbound at field level and would fault the predicate open (was `visibleOn`, ADR-0089) | ## Complete Example diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index 3ec21153e8..ea541099d2 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -121,10 +121,17 @@ export const SelectOptionSchema = lazySchema(() => strictObject({ default: z.boolean().optional().describe('Is default option'), /** * Per-option visibility predicate (CEL) — the option is offered only when this - * evaluates TRUE. Omit = always available. Evaluated against the SAME binding - * environment as field-level `visibleWhen` (live `record` + `current_user`), so - * it expresses BOTH cascading/dependent options (`record.country == 'cn'`) AND - * role/context gating (`'admin' in current_user.positions`). When it references + * evaluates TRUE. Omit = always available. Evaluated against the live `record` + * PLUS the host's global predicate scope, which carries `current_user` — so it + * expresses BOTH cascading/dependent options (`record.country == 'cn'`) AND + * role/context gating (`'admin' in current_user.positions`). + * + * This scope is WIDER than field-level `visibleWhen`, not the same (#6146): + * options resolve through `resolveCascadingOptions` against the predicate + * scope (ADR-0068 / objectui#2284), while field- and section-level rules go + * through `evalFieldPredicate`, which binds `record` + `previous` + `parent` + * and never `current_user` (objectui#1582). Per-option is the one `*When` + * surface where a `current_user` test actually resolves. When it references * sibling fields, declare those on the field's `dependsOn` so the form can gate * and re-evaluate the option list as the parent changes. * @@ -133,7 +140,7 @@ export const SelectOptionSchema = lazySchema(() => strictObject({ * rule-validator evaluates the picked value's `visibleWhen`) — hiding it in the * dropdown alone is bypassable. */ - visibleWhen: ExpressionInputSchema.optional().describe("Per-option visibility predicate (CEL) — option is offered only when TRUE (else omitted). Same env as field visibleWhen (record + current_user). e.g. P`record.country == 'cn'` or P`'admin' in current_user.positions`"), + visibleWhen: ExpressionInputSchema.optional().describe("Per-option visibility predicate (CEL) — option is offered only when TRUE (else omitted). Env: the live `record` plus the host predicate scope, which binds `current_user` — wider than field-level visibleWhen, which has no `current_user`. e.g. P`record.country == 'cn'` or P`'admin' in current_user.positions`"), })); /** diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 7c625e4865..fcc1116c1d 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -1410,10 +1410,22 @@ const FormFieldBaseSchema = lazySchema(() => z.object({ /** * Conditional-visibility predicate (CEL) — the field is shown only when TRUE * (ADR-0089, canonical `*When` name). Binding root depends on the surface: - * runtime record forms bind `record` + `current_user`; metadata-editing forms + * runtime record forms bind `record` (plus `previous`, the saved record, and + * `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. */ - visibleWhen: ExpressionInputSchema.optional().describe("Visibility predicate (CEL) — field shown only when TRUE. Root: `record`+`current_user` (runtime forms) or `data` (metadata forms). 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. 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). 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).'), @@ -1504,10 +1516,12 @@ export const FormSectionSchema = lazySchema(() => z.object({ /** * Conditional-visibility predicate (CEL) — the whole section is shown only * when TRUE (ADR-0089, canonical `*When` name). Same per-layer binding root as - * {@link FormFieldSchema.visibleWhen}: `record`+`current_user` in runtime - * forms, `data` in metadata-editing forms. + * {@link FormFieldSchema.visibleWhen}: `record` (+ `previous`, `parent`) in + * runtime forms, `data` in metadata-editing forms — and, as there, **no + * `current_user`**: it is unbound at this level, so such a predicate faults + * and falls back to visible (#6146). */ - visibleWhen: ExpressionInputSchema.optional().describe('Visibility predicate (CEL) — section shown only when TRUE. Root: `record`+`current_user` (runtime forms) or `data` (metadata forms).'), + visibleWhen: ExpressionInputSchema.optional().describe('Visibility predicate (CEL) — section shown only when TRUE. Root: `record` (+ `previous`, `parent`) in runtime forms, or `data` in metadata forms. No `current_user` at section level — it is unbound here and the predicate would fault open.'), /** @deprecated ADR-0089 — use `visibleWhen`. Accepted and normalized to `visibleWhen` at parse. */ visibleOn: ExpressionInputSchema.optional().describe('[DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Hides the whole section when false. Normalized to `visibleWhen` at parse.'), columns: z.union([