Skip to content

Commit 8a88885

Browse files
qq9340100claude
andauthored
docs(spec): 字段级/section 级 visibleWhen 不再宣称绑定 current_user (#6146) (#6315)
`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 / 校验 / 运行时行为变化。 Claude-Session: https://claude.ai/code/session_011M7UwH25Unfi73UHim7ajY Co-authored-by: Claude <noreply@anthropic.com>
1 parent 042b9ee commit 8a88885

6 files changed

Lines changed: 75 additions & 14 deletions

File tree

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
docs(spec): field- and section-level `visibleWhen` stop advertising `current_user` (#6146)
6+
7+
`FormField.visibleWhen` and `FormSection.visibleWhen` documented their runtime
8+
binding root as "`record` + `current_user`". The second half was never true at
9+
that level, and the failure mode is silent:
10+
11+
- FROM: "Root: `record`+`current_user` (runtime forms) or `data` (metadata forms)"
12+
- TO: "Root: `record` (+ `previous`, `parent`) in runtime forms, or `data` in
13+
metadata forms" — plus an explicit note that `current_user` is **unbound** here.
14+
15+
Field- and section-level rules are evaluated by `evalFieldPredicate` /
16+
`resolveFieldRuleState` in `@object-ui/core`, which binds `record`, `previous`,
17+
and an `extra` scope (`parent`, for master-detail line items) — nothing else.
18+
Every production call site passes no user scope, and objectui#1582 pins the same
19+
set for the authoring autocomplete (`FIELD_RULE_ROOTS = ['record','previous','parent']`,
20+
commented "nothing else (no `current_user`)").
21+
22+
Why this mattered more than a wording slip: an unbound identifier makes the
23+
evaluation fault, and every fault resolves to the caller's fallback — which for
24+
visibility is `true`. So a predicate written exactly as the spec described it
25+
(`'admin' in current_user.positions`) does not hide the field, it makes the
26+
field **permanently visible**. Authors following the documentation got the
27+
inverse of what they wrote, on the surface where the mistake is least visible.
28+
29+
`current_user` remains documented, and remains correct, for **per-option**
30+
`visibleWhen` (`SelectOption`): options resolve through a different evaluator,
31+
`resolveCascadingOptions` against the host's predicate scope, which does bind it
32+
(ADR-0068 / objectui#2284). That JSDoc previously claimed the per-option
33+
environment was "the SAME binding environment as field-level `visibleWhen`" —
34+
the very equality that is false — so it now states the asymmetry instead of
35+
asserting it away.
36+
37+
Documentation only: no schema, validation, or runtime behaviour change. Authors
38+
whose field-level predicates reference `current_user` should know those
39+
predicates are already faulting open today; this change does not alter that, it
40+
stops the spec from recommending it.

content/docs/references/data/field.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,7 @@ const result = AddressSchema.parse(data);
222222
| **value** | `string` || Stored value (lowercase machine identifier) |
223223
| **color** | `string` | optional | Color code for badges/charts |
224224
| **default** | `boolean` | optional | Is default option |
225-
| **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` |
225+
| **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` |
226226

227227

228228
---

content/docs/references/ui/view.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ Column footer summary configuration
159159
| **language** | `string` | optional | Code editor language (for type=code) |
160160
| **keyField** | `{ field?: string; label?: string; placeholder?: string; helpText?: string; … }` | optional | Key column config for record-typed fields |
161161
| **dependsOn** | `string` | optional | Parent field name for cascading |
162-
| **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'` |
162+
| **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'` |
163163
| **visibleOn** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Normalized to `visibleWhen` at parse. |
164164
| **disclosure** | `Enum<'inline' \| 'popover'>` | optional | Composite rendering: inline bordered box (default) or a summary line + gear popover (progressive disclosure). |
165165
| **fields** | `[FormField](#formfield)[]` | optional | Sub-fields for composite/repeater/record types |
@@ -178,7 +178,7 @@ Column footer summary configuration
178178
| **description** | `string` | optional | Optional description rendered under the section header. |
179179
| **collapsible** | `boolean` | optional | |
180180
| **collapsed** | `boolean` | optional | |
181-
| **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). |
181+
| **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. |
182182
| **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. |
183183
| **columns** | `Enum<'1' \| '2' \| '3' \| '4'> \| 1 \| 2 \| 3 \| 4` | optional | |
184184
| **pane** | `Enum<'primary' \| 'secondary'>` | optional | Split pane this section renders in (split forms only; a parse error elsewhere). Omitted → first section 'primary', others 'secondary'. |

content/docs/ui/views.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -380,7 +380,7 @@ fields: [
380380
| `colSpan` | `1-4` | Legacy absolute column spanprefer `span` |
381381
| `widget` | `string` | Custom widget/component name |
382382
| `dependsOn` | `string` | Parent field for cascading |
383-
| `visibleWhen` | `string` | Visibility predicate (CEL); runtime forms bind `record` + `current_user` (was `visibleOn`, ADR-0089) |
383+
| `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) |
384384

385385
## Complete Example
386386

packages/spec/src/data/field.zod.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -121,10 +121,17 @@ export const SelectOptionSchema = lazySchema(() => strictObject({
121121
default: z.boolean().optional().describe('Is default option'),
122122
/**
123123
* Per-option visibility predicate (CEL) — the option is offered only when this
124-
* evaluates TRUE. Omit = always available. Evaluated against the SAME binding
125-
* environment as field-level `visibleWhen` (live `record` + `current_user`), so
126-
* it expresses BOTH cascading/dependent options (`record.country == 'cn'`) AND
127-
* role/context gating (`'admin' in current_user.positions`). When it references
124+
* evaluates TRUE. Omit = always available. Evaluated against the live `record`
125+
* PLUS the host's global predicate scope, which carries `current_user` — so it
126+
* expresses BOTH cascading/dependent options (`record.country == 'cn'`) AND
127+
* role/context gating (`'admin' in current_user.positions`).
128+
*
129+
* This scope is WIDER than field-level `visibleWhen`, not the same (#6146):
130+
* options resolve through `resolveCascadingOptions` against the predicate
131+
* scope (ADR-0068 / objectui#2284), while field- and section-level rules go
132+
* through `evalFieldPredicate`, which binds `record` + `previous` + `parent`
133+
* and never `current_user` (objectui#1582). Per-option is the one `*When`
134+
* surface where a `current_user` test actually resolves. When it references
128135
* sibling fields, declare those on the field's `dependsOn` so the form can gate
129136
* and re-evaluate the option list as the parent changes.
130137
*
@@ -133,7 +140,7 @@ export const SelectOptionSchema = lazySchema(() => strictObject({
133140
* rule-validator evaluates the picked value's `visibleWhen`) — hiding it in the
134141
* dropdown alone is bypassable.
135142
*/
136-
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`"),
143+
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`"),
137144
}));
138145

139146
/**

packages/spec/src/ui/view.zod.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1410,10 +1410,22 @@ const FormFieldBaseSchema = lazySchema(() => z.object({
14101410
/**
14111411
* Conditional-visibility predicate (CEL) — the field is shown only when TRUE
14121412
* (ADR-0089, canonical `*When` name). Binding root depends on the surface:
1413-
* runtime record forms bind `record` + `current_user`; metadata-editing forms
1413+
* runtime record forms bind `record` (plus `previous`, the saved record, and
1414+
* `parent` for master-detail line items); metadata-editing forms
14141415
* (`*.form.ts`) bind the row under edit as `data`.
1416+
*
1417+
* ⚠️ **No `current_user` here** (#6146). Field-level rules are evaluated by
1418+
* `evalFieldPredicate` / `resolveFieldRuleState` (`@object-ui/core`), which
1419+
* binds `record` + `previous` + an `extra` scope and nothing else — the
1420+
* autocomplete pins the same set (objectui#1582). A predicate referencing
1421+
* `current_user` is an UNBOUND identifier: the evaluation faults and falls
1422+
* back, and visibility's fallback is `true`, so the field a `current_user`
1423+
* test was meant to hide stays **permanently visible**. `current_user` IS
1424+
* bound for **per-option** `visibleWhen` (a different evaluator —
1425+
* `resolveCascadingOptions` against the host's predicate scope, ADR-0068 /
1426+
* objectui#2284); that is the only `*When` surface where it resolves.
14151427
*/
1416-
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'`"),
1428+
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'`"),
14171429
/** @deprecated ADR-0089 — use `visibleWhen`. Accepted and normalized to `visibleWhen` at parse. */
14181430
visibleOn: ExpressionInputSchema.optional().describe('[DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Normalized to `visibleWhen` at parse.'),
14191431
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({
15041516
/**
15051517
* Conditional-visibility predicate (CEL) — the whole section is shown only
15061518
* when TRUE (ADR-0089, canonical `*When` name). Same per-layer binding root as
1507-
* {@link FormFieldSchema.visibleWhen}: `record`+`current_user` in runtime
1508-
* forms, `data` in metadata-editing forms.
1519+
* {@link FormFieldSchema.visibleWhen}: `record` (+ `previous`, `parent`) in
1520+
* runtime forms, `data` in metadata-editing forms — and, as there, **no
1521+
* `current_user`**: it is unbound at this level, so such a predicate faults
1522+
* and falls back to visible (#6146).
15091523
*/
1510-
visibleWhen: ExpressionInputSchema.optional().describe('Visibility predicate (CEL) — section shown only when TRUE. Root: `record`+`current_user` (runtime forms) or `data` (metadata forms).'),
1524+
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.'),
15111525
/** @deprecated ADR-0089 — use `visibleWhen`. Accepted and normalized to `visibleWhen` at parse. */
15121526
visibleOn: ExpressionInputSchema.optional().describe('[DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Hides the whole section when false. Normalized to `visibleWhen` at parse.'),
15131527
columns: z.union([

0 commit comments

Comments
 (0)