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
25 changes: 25 additions & 0 deletions .changeset/b3-option-predicate-guardrail.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
"@object-ui/core": minor
---

feat(core): build-time guardrail for cascading select option predicates (#1583)

`@object-ui/core` now exports `lintOptionPredicates(fields)` — a static,
conservative validator for the per-option `visibleWhen` CEL predicates that
drive cascading / role-gated `select` options (#2284). An option predicate fails
*closed* — a wrong one makes its option silently never appear — so this catches
the class of bug runtime fail-open can't surface:

- `syntax` — invalid CEL, delegated to `@objectstack/formula`'s
`validateExpression` (no schema hint, so a legitimate `current_user.roles`
reference is never mistaken for an error);
- `unknown-field` — a `record.<name>` reference to a field the form never
declares (a sibling typo);
- `option-literal-not-in-domain` — a literal compared against an *enum* sibling
that is outside its declared option values, e.g. `record.country == 'chna'`
when `country` is `cn`/`us` (the AI-authoring typo #2284 called out).

It only flags what it can statically prove — non-`record.` roots
(`current_user.*`), open-domain fields, and unrecognized shapes are left alone,
so there are no false positives. The schema catalog runs it over every shipped
example. Design recorded in ADR-0058.
10 changes: 7 additions & 3 deletions content/docs/fields/select.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,14 @@ cascade-clear propagate down the chain.

### Role-gated option

<SchemaExample id="fields-select/role-gated-options" />

```json
{ "name": "tier", "type": "select", "options": [
{ "label": "Standard", "value": "standard" },
{ "label": "Admin only", "value": "admin_only", "visibleWhen": "'admin' in current_user.roles" }
{ "name": "visibility", "type": "select", "options": [
{ "label": "Private (only me)", "value": "private" },
{ "label": "My team", "value": "team" },
{ "label": "Whole organization", "value": "org" },
{ "label": "Public — external", "value": "public", "visibleWhen": "'admin' in current_user.roles" }
]}
```

Expand Down
153 changes: 153 additions & 0 deletions docs/adr/0058-cascading-select-options.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
# ADR-0058: Cascading & role-gated select options (`option.visibleWhen` + `dependsOn`)

**Status**: Accepted — implemented (2026-07-15)
**Author**: ObjectUI renderer team
**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
**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.

---

## TL;DR

A select's **available options frequently depend on the rest of the record or on
the actor**: pick a Country, then State lists only that country's states; a
"visibility" field offers *Public* only to admins. These are not widget concerns
— they are data-model rules, authored once on the option and honored everywhere
the object is edited.

We express them by reusing the two primitives ADR-0036 and the dependent-lookup
work (#2215) already established, **not** a new mechanism:

| Knob | Meaning | Enforced on |
| ----------------------- | ------------------------------------------------------------- | ------------------- |
| `option.visibleWhen` | the option is offered when the CEL predicate is TRUE | client (UX) + server (for authorization) |
| `field.dependsOn` | which sibling field(s) the option list reacts to (gate/recompute) | client |

A picklist cascade is therefore `dependsOn` (declares the dependency edge) + per-option
`visibleWhen` (the condition) — structurally identical to a lookup cascade.

## Decision: per-option `visibleWhen`, not a field-level `optionsWhen` or `validFor`

Issue #1583 floated a field-level `optionsWhen` predicate; #2284 settled the
authoring shape as **per-option `visibleWhen` + `dependsOn`**, explicitly
rejecting Salesforce-style `validFor` / `controllingField` / dependency matrices.
Rationale:

1. **Minimal, unified vocabulary.** `dependsOn` is already how a lookup declares
its cascade; `visibleWhen` is already the field-level CEL predicate (ADR-0036).
Reusing both introduces no new category and no bespoke matrix format.
2. **`visibleWhen` is strictly more expressive than a value cascade.** It sees
`current_user` (roles/tenant) and `now()`, so the *same* knob covers cascades
(`record.country == 'cn'`) **and** role/context gating
(`'admin' in current_user.roles`). `validFor` can only express "varies with
another field's value" — it cannot gate by role. `current_user` is already
bound on every predicate surface (server formula, RLS, client UI gates), so a
role-referencing option predicate needs **zero new binding**.
3. **AI-authoring friendly.** Schemas are increasingly model-generated and
human-reviewed. `visibleWhen: "record.country == 'cn'"` reads as prose and is
a primitive the model has seen countless times; a `validFor` matrix's main
value (a visual editor) does not exist under this authoring model.
4. **`dependsOn` ⟂ `visibleWhen`.** `dependsOn` only declares which sibling
fields drive the list (gating UX + recompute); `visibleWhen` is the predicate.
An option may carry `visibleWhen` with **no** `dependsOn` (a pure role gate).
The two are never coupled.

## Why CEL, and the same engine on both ends

As in ADR-0036, the point of a dual-side rule is that the **client UX and the
persisted server verdict agree** for a given record. Both ends evaluate the
option predicate with the canonical `@objectstack/formula` `ExpressionEngine`
(CEL via `@marcbachmann/cel-js`) — the same dialect, stdlib, and null/missing
semantics the field-level rules use — via the shared `evalFieldPredicate` path.
No parallel client DSL, so no drift.

## Client implementation (objectui)

- **`@object-ui/core` — `evaluator/optionRules.ts`** exposes four zero-React helpers,
all reusing `evalFieldPredicate`:
- `resolveVisibleOptions(options, record, scope?)` — keep options whose
`visibleWhen` is TRUE against the live record (+ `{ current_user }` scope);
predicate-less options are always kept; a faulting predicate **fails open**
(option kept), matching field-visibility posture.
- `resolveDependsOnFields(dependsOn)` — normalize the spec `dependsOn`
(`string | Array<string | {field,param}>`) to sibling field names.
- `isOptionGroupGated(dependsOn, record)` — TRUE while any `dependsOn` field is
empty; the list is withheld rather than shown unfiltered.
- `isValueStillOffered(value, visibleOptions)` — cascade-clear decision
(scalar and multi-select), so a parent change drops a now-invalid child value.
- **The form renderer** (`@object-ui/components`, `renderers/form/form.tsx`)
watches the live record (`ruleRecord`, every declared field seeded to `null`
first — the missing-key gotcha from ADR-0036), narrows each dependent select's
options, gates the control with a `Select {parent} first` hint while a
`dependsOn` field is empty, and **cascade-clears** a stale value in a reactive
effect (no "China + California" pair ever submits).
- **`SelectField`** (`@object-ui/fields`) applies the identical resolution
standalone via `dependentValues` (the channel dependent lookups use) + the
global `usePredicateScope()` (so `current_user` resolves). Filtered-out options
are absent from the DOM (`data-testid="select-option-<value>"` only for offered
values); a gated field renders `select-empty-<name>`.
- **`ObjectForm`** (`@object-ui/plugin-form`) carries `options` (and thus their
`visibleWhen`) and `dependsOn` through from object metadata onto the generated
`FormField`s.

## Static options vs. query-backed lookups

Two paths, one declaration style:

- **Small, stable dictionaries** (category → subcategory, a handful of statuses,
role gates) → static `options` + `option.visibleWhen`. This ADR.
- **Large / changing / shared data** (countries, org trees, catalogs,
account → contact) → `lookup` + `dependsOn`, with the dependency folded into
the query `$filter` (#2215). The choice is a data-modeling decision, documented
in `skills/objectui/guides/schema-expressions.md`; the authoring surface
(`dependsOn`) is the same either way.

## Build-time guardrail

An option `visibleWhen` fails **closed**: a wrong predicate makes its option
silently never appear, which runtime fail-open cannot surface. The headline case
(#2284) is an AI-authored literal typo — `record.country == 'chna'` when
`country`'s options are `cn`/`us`, so the option is unreachable although the
expression parses and the field exists.

`@object-ui/core`'s `lintOptionPredicates(fields)` (`evaluator/optionLint.ts`)
closes this at authoring/CI time with three conservative checks: `syntax` (via
`@objectstack/formula`'s `validateExpression`, no schema hint so a legitimate
`current_user.roles` reference is never flagged), `unknown-field` (a `record.<name>`
naming no declared sibling), and `option-literal-not-in-domain` (a literal
compared against an *enum* sibling that is outside its declared value set). It
only flags what it can statically prove — non-`record.` roots, open-domain
fields, and unrecognized shapes are left alone, so there are no false positives.
The schema catalog runs it over every shipped example
(`examples/schema-catalog/test/option-predicates.test.tsx`).

## Server enforcement (framework) — the authorization half

Hiding an option client-side is **UX, not a security boundary**: a caller can
still submit a hidden value. When an option is gated for **authorization**, the
server must also reject writes of that value. The contract (framework
`@objectstack/objectql`, alongside ADR-0036's `requiredWhen`/`readonlyWhen`):

- On write, evaluate the submitted option value's `visibleWhen` over the merged
record (`{ ...previous, ...patch }`) plus the actor; reject with a
`{ field, code }` violation when FALSE — mirroring how `stripReadonlyWhenFields`
already walks conditional fields (`needsPriorRecord`).
- A predicate that fails to evaluate is **fail-open** and logged (a broken rule
must never block a legitimate write), matching the client and ADR-0036.
- Pure cascades / UX-only gating do not require this; it is specifically the
authorization path.

This ADR records the objectui (client + guardrail) work as shipped; the
server-side option-value enforcement and its live e2e are the framework-side
remainder tracked in #1583.

## Consequences

- Authors express dependent and role-gated choices once, on the option, in the
same CEL they already use for field rules and formulas — no widget wiring, no
new matrix format.
- Client and server cannot drift: identical engine and dialect.
- `option.visibleWhen` is UX by default; for authorization it must be paired with
server enforcement — never rely on client hiding for a security guarantee.
- A wrong option predicate is caught before it ships by `lintOptionPredicates`,
not discovered as a silently-missing choice in production.
92 changes: 92 additions & 0 deletions e2e/cascading-options.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { test, expect, type Page } from '@playwright/test';

/**
* B3 browser e2e: cascading / dependent `select` options (#1583).
*
* Drives the shipped `fields-select/cascading-options` example
* (`country` → `province`) rendered live on the docs site — the same form
* renderer and the same `@objectstack/formula` per-option `visibleWhen`
* filtering an app uses, with **no backend**. This is the piece JSDOM component
* tests can't reach: opening a real dropdown and asserting the *offered set*
* changes as the controlling field changes, plus the cascade-clear of a
* now-invalid child value.
*
* The docs site (Next.js) must be served separately — e.g.
* `pnpm --filter @object-ui/site dev` — and pointed at via `DOCS_BASE_URL`
* (default `http://localhost:3000`). If it's unreachable the suite auto-skips,
* mirroring `docs-smoke.spec.ts`. The equivalent live-backend e2e (a cascading
* pair on a real showcase object, with the server rejecting an out-of-set
* value) is tracked framework-side in #1583.
*/

const DOCS_BASE = process.env.DOCS_BASE_URL || 'http://localhost:3000';
const SELECT_PAGE = `${DOCS_BASE}/docs/fields/select`;

let docsAvailable = false;
test.beforeAll(async () => {
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5_000);
const response = await fetch(`${DOCS_BASE}/docs`, { signal: controller.signal });
clearTimeout(timer);
docsAvailable = response.ok;
} catch {
docsAvailable = false;
}
});

/** The Radix Select trigger for a form field, scoped by its `field:<name>` wrapper. */
const trigger = (page: Page, field: string) =>
page.locator(`[data-testid="field:${field}"] [role="combobox"]`);

/** Open `field`'s dropdown, return the offered option labels (sorted), then close it. */
async function offeredOptions(page: Page, field: string): Promise<string[]> {
await trigger(page, field).click();
const listbox = page.getByRole('listbox');
await expect(listbox).toBeVisible();
const labels = (await page.getByRole('option').allInnerTexts()).map((s) => s.trim());
await page.keyboard.press('Escape');
await expect(listbox).toBeHidden();
return labels.sort();
}

/** Open `field`'s dropdown and pick the option named `name`. */
async function pick(page: Page, field: string, name: RegExp): Promise<void> {
await trigger(page, field).click();
const listbox = page.getByRole('listbox');
await expect(listbox).toBeVisible();
await page.getByRole('option', { name }).click();
await expect(listbox).toBeHidden();
}

test('province options re-filter live as country changes, and a stale value clears', async ({ page }) => {
test.skip(!docsAvailable, 'Docs site is not reachable (set DOCS_BASE_URL)');

await page.goto(SELECT_PAGE, { waitUntil: 'domcontentloaded', timeout: 30_000 });

const country = trigger(page, 'country');
await country.scrollIntoViewIfNeeded();
await expect(country).toBeVisible();
// Let the client-side renderer hydrate before driving the Radix control — a
// pre-hydration click is dropped and the dropdown never opens.
await expect(country).toBeEnabled();
await page.waitForTimeout(1500);

// --- Gated: while country is unset the dependent province control is withheld. ---
await expect(trigger(page, 'province')).toHaveCount(0);

// --- country = China → only Chinese provinces are offered. ---
await pick(page, 'country', /china/i);
await expect(trigger(page, 'province')).toBeVisible();
expect(await offeredOptions(page, 'province')).toEqual(['Guangdong', 'Zhejiang']);

// Choose one so we can prove the cascade-clear on the next parent change.
await pick(page, 'province', /zhejiang/i);
await expect(trigger(page, 'province')).toContainText(/zhejiang/i);

// --- country = United States → the offered set flips and the stale value clears. ---
await pick(page, 'country', /united states/i);
expect(await offeredOptions(page, 'province')).toEqual(['California', 'Texas']);
// 'Zhejiang' is no longer offered under country=us, so the widget dropped it.
await expect(trigger(page, 'province')).not.toContainText(/zhejiang/i);
});
12 changes: 11 additions & 1 deletion examples/schema-catalog/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,7 @@ import fields_select_basic_select from './schemas/fields-select/basic-select.jso
import fields_select_cascading_options from './schemas/fields-select/cascading-options.json' with { type: 'json' };
import fields_select_colored_options from './schemas/fields-select/colored-options.json' with { type: 'json' };
import fields_select_multi_select from './schemas/fields-select/multi-select.json' with { type: 'json' };
import fields_select_role_gated_options from './schemas/fields-select/role-gated-options.json' with { type: 'json' };
import fields_summary_average_of_field_values from './schemas/fields-summary/average-of-field-values.json' with { type: 'json' };
import fields_summary_count_of_related_records from './schemas/fields-summary/count-of-related-records.json' with { type: 'json' };
import fields_summary_sum_of_field_values from './schemas/fields-summary/sum-of-field-values.json' with { type: 'json' };
Expand Down Expand Up @@ -3526,7 +3527,7 @@ const REGISTRY: Record<string, Example> = {
id: 'fields-select/cascading-options',
meta: {
title: "Cascading Options",
description: "Dependent select — province options narrow to the chosen country via per-option visibleWhen + dependsOn",
description: "",
category: 'fields-select',
},
schema: fields_select_cascading_options,
Expand All @@ -3549,6 +3550,15 @@ const REGISTRY: Record<string, Example> = {
},
schema: fields_select_multi_select,
},
'fields-select/role-gated-options': {
id: 'fields-select/role-gated-options',
meta: {
title: "Role Gated Options",
description: "",
category: 'fields-select',
},
schema: fields_select_role_gated_options,
},
'fields-summary/average-of-field-values': {
id: 'fields-summary/average-of-field-values',
meta: {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"type": "form",
"showSubmit": false,
"showCancel": false,
"fields": [
{
"name": "subject",
"label": "Subject",
"type": "text",
"placeholder": "Short summary..."
},
{
"name": "visibility",
"label": "Visibility",
"type": "select",
"placeholder": "Select visibility...",
"options": [
{ "label": "Private (only me)", "value": "private" },
{ "label": "My team", "value": "team" },
{ "label": "Whole organization", "value": "org" },
{
"label": "Public — external",
"value": "public",
"visibleWhen": "'admin' in current_user.roles"
}
]
}
]
}
Loading
Loading