Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .changeset/fieldrule-no-current-user.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion content/docs/references/data/field.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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` |


---
Expand Down
4 changes: 2 additions & 2 deletions content/docs/references/ui/view.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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'. |
Expand Down
2 changes: 1 addition & 1 deletion content/docs/ui/views.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
17 changes: 12 additions & 5 deletions packages/spec/src/data/field.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -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`"),
}));

/**
Expand Down
24 changes: 19 additions & 5 deletions packages/spec/src/ui/view.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).'),
Expand Down Expand Up @@ -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([
Expand Down
Loading