|
| 1 | +# ADR-0058: Cascading & role-gated select options (`option.visibleWhen` + `dependsOn`) |
| 2 | + |
| 3 | +**Status**: Accepted — implemented (2026-07-15) |
| 4 | +**Author**: ObjectUI renderer team |
| 5 | +**Consumers**: `@object-ui/core`, `@object-ui/fields` (`SelectField`), `@object-ui/components` (form renderer), `@object-ui/plugin-form`, `@objectstack/spec`, `@objectstack/objectql`, every app whose forms need a select/lookup whose choices depend on another field or on who is editing |
| 6 | +**Companion to**: ADR-0036 (field-level conditional rules) — this applies the same dual-side, one-dialect philosophy to *option* sets. Supersedes the `optionsWhen` framing in issue #1583. |
| 7 | + |
| 8 | +--- |
| 9 | + |
| 10 | +## TL;DR |
| 11 | + |
| 12 | +A select's **available options frequently depend on the rest of the record or on |
| 13 | +the actor**: pick a Country, then State lists only that country's states; a |
| 14 | +"visibility" field offers *Public* only to admins. These are not widget concerns |
| 15 | +— they are data-model rules, authored once on the option and honored everywhere |
| 16 | +the object is edited. |
| 17 | + |
| 18 | +We express them by reusing the two primitives ADR-0036 and the dependent-lookup |
| 19 | +work (#2215) already established, **not** a new mechanism: |
| 20 | + |
| 21 | +| Knob | Meaning | Enforced on | |
| 22 | +| ----------------------- | ------------------------------------------------------------- | ------------------- | |
| 23 | +| `option.visibleWhen` | the option is offered when the CEL predicate is TRUE | client (UX) + server (for authorization) | |
| 24 | +| `field.dependsOn` | which sibling field(s) the option list reacts to (gate/recompute) | client | |
| 25 | + |
| 26 | +A picklist cascade is therefore `dependsOn` (declares the dependency edge) + per-option |
| 27 | +`visibleWhen` (the condition) — structurally identical to a lookup cascade. |
| 28 | + |
| 29 | +## Decision: per-option `visibleWhen`, not a field-level `optionsWhen` or `validFor` |
| 30 | + |
| 31 | +Issue #1583 floated a field-level `optionsWhen` predicate; #2284 settled the |
| 32 | +authoring shape as **per-option `visibleWhen` + `dependsOn`**, explicitly |
| 33 | +rejecting Salesforce-style `validFor` / `controllingField` / dependency matrices. |
| 34 | +Rationale: |
| 35 | + |
| 36 | +1. **Minimal, unified vocabulary.** `dependsOn` is already how a lookup declares |
| 37 | + its cascade; `visibleWhen` is already the field-level CEL predicate (ADR-0036). |
| 38 | + Reusing both introduces no new category and no bespoke matrix format. |
| 39 | +2. **`visibleWhen` is strictly more expressive than a value cascade.** It sees |
| 40 | + `current_user` (roles/tenant) and `now()`, so the *same* knob covers cascades |
| 41 | + (`record.country == 'cn'`) **and** role/context gating |
| 42 | + (`'admin' in current_user.roles`). `validFor` can only express "varies with |
| 43 | + another field's value" — it cannot gate by role. `current_user` is already |
| 44 | + bound on every predicate surface (server formula, RLS, client UI gates), so a |
| 45 | + role-referencing option predicate needs **zero new binding**. |
| 46 | +3. **AI-authoring friendly.** Schemas are increasingly model-generated and |
| 47 | + human-reviewed. `visibleWhen: "record.country == 'cn'"` reads as prose and is |
| 48 | + a primitive the model has seen countless times; a `validFor` matrix's main |
| 49 | + value (a visual editor) does not exist under this authoring model. |
| 50 | +4. **`dependsOn` ⟂ `visibleWhen`.** `dependsOn` only declares which sibling |
| 51 | + fields drive the list (gating UX + recompute); `visibleWhen` is the predicate. |
| 52 | + An option may carry `visibleWhen` with **no** `dependsOn` (a pure role gate). |
| 53 | + The two are never coupled. |
| 54 | + |
| 55 | +## Why CEL, and the same engine on both ends |
| 56 | + |
| 57 | +As in ADR-0036, the point of a dual-side rule is that the **client UX and the |
| 58 | +persisted server verdict agree** for a given record. Both ends evaluate the |
| 59 | +option predicate with the canonical `@objectstack/formula` `ExpressionEngine` |
| 60 | +(CEL via `@marcbachmann/cel-js`) — the same dialect, stdlib, and null/missing |
| 61 | +semantics the field-level rules use — via the shared `evalFieldPredicate` path. |
| 62 | +No parallel client DSL, so no drift. |
| 63 | + |
| 64 | +## Client implementation (objectui) |
| 65 | + |
| 66 | +- **`@object-ui/core` — `evaluator/optionRules.ts`** exposes four zero-React helpers, |
| 67 | + all reusing `evalFieldPredicate`: |
| 68 | + - `resolveVisibleOptions(options, record, scope?)` — keep options whose |
| 69 | + `visibleWhen` is TRUE against the live record (+ `{ current_user }` scope); |
| 70 | + predicate-less options are always kept; a faulting predicate **fails open** |
| 71 | + (option kept), matching field-visibility posture. |
| 72 | + - `resolveDependsOnFields(dependsOn)` — normalize the spec `dependsOn` |
| 73 | + (`string | Array<string | {field,param}>`) to sibling field names. |
| 74 | + - `isOptionGroupGated(dependsOn, record)` — TRUE while any `dependsOn` field is |
| 75 | + empty; the list is withheld rather than shown unfiltered. |
| 76 | + - `isValueStillOffered(value, visibleOptions)` — cascade-clear decision |
| 77 | + (scalar and multi-select), so a parent change drops a now-invalid child value. |
| 78 | +- **The form renderer** (`@object-ui/components`, `renderers/form/form.tsx`) |
| 79 | + watches the live record (`ruleRecord`, every declared field seeded to `null` |
| 80 | + first — the missing-key gotcha from ADR-0036), narrows each dependent select's |
| 81 | + options, gates the control with a `Select {parent} first` hint while a |
| 82 | + `dependsOn` field is empty, and **cascade-clears** a stale value in a reactive |
| 83 | + effect (no "China + California" pair ever submits). |
| 84 | +- **`SelectField`** (`@object-ui/fields`) applies the identical resolution |
| 85 | + standalone via `dependentValues` (the channel dependent lookups use) + the |
| 86 | + global `usePredicateScope()` (so `current_user` resolves). Filtered-out options |
| 87 | + are absent from the DOM (`data-testid="select-option-<value>"` only for offered |
| 88 | + values); a gated field renders `select-empty-<name>`. |
| 89 | +- **`ObjectForm`** (`@object-ui/plugin-form`) carries `options` (and thus their |
| 90 | + `visibleWhen`) and `dependsOn` through from object metadata onto the generated |
| 91 | + `FormField`s. |
| 92 | + |
| 93 | +## Static options vs. query-backed lookups |
| 94 | + |
| 95 | +Two paths, one declaration style: |
| 96 | + |
| 97 | +- **Small, stable dictionaries** (category → subcategory, a handful of statuses, |
| 98 | + role gates) → static `options` + `option.visibleWhen`. This ADR. |
| 99 | +- **Large / changing / shared data** (countries, org trees, catalogs, |
| 100 | + account → contact) → `lookup` + `dependsOn`, with the dependency folded into |
| 101 | + the query `$filter` (#2215). The choice is a data-modeling decision, documented |
| 102 | + in `skills/objectui/guides/schema-expressions.md`; the authoring surface |
| 103 | + (`dependsOn`) is the same either way. |
| 104 | + |
| 105 | +## Build-time guardrail |
| 106 | + |
| 107 | +An option `visibleWhen` fails **closed**: a wrong predicate makes its option |
| 108 | +silently never appear, which runtime fail-open cannot surface. The headline case |
| 109 | +(#2284) is an AI-authored literal typo — `record.country == 'chna'` when |
| 110 | +`country`'s options are `cn`/`us`, so the option is unreachable although the |
| 111 | +expression parses and the field exists. |
| 112 | + |
| 113 | +`@object-ui/core`'s `lintOptionPredicates(fields)` (`evaluator/optionLint.ts`) |
| 114 | +closes this at authoring/CI time with three conservative checks: `syntax` (via |
| 115 | +`@objectstack/formula`'s `validateExpression`, no schema hint so a legitimate |
| 116 | +`current_user.roles` reference is never flagged), `unknown-field` (a `record.<name>` |
| 117 | +naming no declared sibling), and `option-literal-not-in-domain` (a literal |
| 118 | +compared against an *enum* sibling that is outside its declared value set). It |
| 119 | +only flags what it can statically prove — non-`record.` roots, open-domain |
| 120 | +fields, and unrecognized shapes are left alone, so there are no false positives. |
| 121 | +The schema catalog runs it over every shipped example |
| 122 | +(`examples/schema-catalog/test/option-predicates.test.tsx`). |
| 123 | + |
| 124 | +## Server enforcement (framework) — the authorization half |
| 125 | + |
| 126 | +Hiding an option client-side is **UX, not a security boundary**: a caller can |
| 127 | +still submit a hidden value. When an option is gated for **authorization**, the |
| 128 | +server must also reject writes of that value. The contract (framework |
| 129 | +`@objectstack/objectql`, alongside ADR-0036's `requiredWhen`/`readonlyWhen`): |
| 130 | + |
| 131 | +- On write, evaluate the submitted option value's `visibleWhen` over the merged |
| 132 | + record (`{ ...previous, ...patch }`) plus the actor; reject with a |
| 133 | + `{ field, code }` violation when FALSE — mirroring how `stripReadonlyWhenFields` |
| 134 | + already walks conditional fields (`needsPriorRecord`). |
| 135 | +- A predicate that fails to evaluate is **fail-open** and logged (a broken rule |
| 136 | + must never block a legitimate write), matching the client and ADR-0036. |
| 137 | +- Pure cascades / UX-only gating do not require this; it is specifically the |
| 138 | + authorization path. |
| 139 | + |
| 140 | +This ADR records the objectui (client + guardrail) work as shipped; the |
| 141 | +server-side option-value enforcement and its live e2e are the framework-side |
| 142 | +remainder tracked in #1583. |
| 143 | + |
| 144 | +## Consequences |
| 145 | + |
| 146 | +- Authors express dependent and role-gated choices once, on the option, in the |
| 147 | + same CEL they already use for field rules and formulas — no widget wiring, no |
| 148 | + new matrix format. |
| 149 | +- Client and server cannot drift: identical engine and dialect. |
| 150 | +- `option.visibleWhen` is UX by default; for authorization it must be paired with |
| 151 | + server enforcement — never rely on client hiding for a security guarantee. |
| 152 | +- A wrong option predicate is caught before it ships by `lintOptionPredicates`, |
| 153 | + not discovered as a silently-missing choice in production. |
0 commit comments