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
9 changes: 9 additions & 0 deletions .changeset/form-view-features-root-refused.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
---
'@objectstack/spec': minor
---

Form-view predicates loudly reject the `features.*` scope root (objectstack#12665; ruled 2026-08-27 on objectui#6262, option B — vocabulary narrowing). One authored form view is served on two kinds of route, and a `features.*` predicate got two verdicts from the same text: inside an app (`/apps/:appName/*`) the root resolves against the real auth-config flags, while on the standalone form routes (`/forms/:name`, public `/f/:slug`) no app context exists, the root is unbound, the predicate faults — and `visibleWhen`'s fault fallback is visible, so the field or section a feature flag was meant to hide was shown to everyone (fail-open, on an access-shaped key). Measured before ruling and re-verified at dispatch: zero authored `features.*` form-view predicates exist across objectui apps/examples/content (18-hit positive control on authored `visibleWhen` generally), so the vocabulary is narrowed at the authoring door instead of building auth-config parity machinery for zero consumers.

What newly gets rejected: any form-view predicate naming `features` in root position — dotted member access (`features.x`), index access (`features['x']`), or the bare root — outside string literals; on section-level `visibleWhen`, field-level `visibleWhen` at any nesting depth, and per-option `visibleWhen` authored inline in the form view, including the deprecated `visibleOn` alias spellings and the flattened runtime form overlay. The refusal is a prescriptive parse issue naming the root, the surface, the fail-open reason and the ruling. Member access on a record field that happens to be named `features` (`record.features.x`) stays legal, as does every other root; app-context predicate surfaces (page components, actions, bulk-action eligibility) keep `features.*` exactly as before.

<!-- adr-0087: registered ui-form-view-predicate-features-root-refused -->
10 changes: 5 additions & 5 deletions content/docs/references/ui/view.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -160,7 +160,7 @@ Column footer summary configuration
| **language** | `string` | optional | Code editor language (for type=code) |
| **keyField** | `{ field?: string; label?: string \| Record<string, string>; placeholder?: string \| Record<string, string>; helpText?: string \| Record<string, 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` (+ `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). Inside a repeater `data` is the ROW, but it is still spelled `data` — a bare identifier is unbound and faults open too. 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). No `features.*` on ANY form-view predicate — refused at parse (ruled 2026-08-27, objectui#6262): the root is unbound on the standalone form routes (`/forms/:name`, `/f/:slug`) and the predicate would fault open there. Inside a repeater `data` is the ROW, but it is still spelled `data` — a bare identifier is unbound and faults open too. 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 DownExpand Up@@ -287,7 +287,7 @@ View filter rule
| **description** | `string` | optional | Optional description rendered under the section header. |
| **collapsible** | `boolean` | optional (default: `false`) | |
| **collapsed** | `boolean` | optional (default: `false`) | |
| **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. |
| **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. No `features.*` on ANY form-view predicate — refused at parse (ruled 2026-08-27, objectui#6262): unbound on the standalone form routes, where 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 (default: `1`) | |
| **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 DownExpand Up@@ -322,7 +322,7 @@ View filter rule
| **language** | `string` | optional | Code editor language (for type=code) |
| **keyField** | `{ field?: string; label?: string \| Record<string, string>; placeholder?: string \| Record<string, string>; helpText?: string \| Record<string, 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` (+ `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). Inside a repeater `data` is the ROW, but it is still spelled `data` — a bare identifier is unbound and faults open too. 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). No `features.*` on ANY form-view predicate — refused at parse (ruled 2026-08-27, objectui#6262): the root is unbound on the standalone form routes (`/forms/:name`, `/f/:slug`) and the predicate would fault open there. Inside a repeater `data` is the ROW, but it is still spelled `data` — a bare identifier is unbound and faults open too. 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** | `{ field: string; type?: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; options?: object[]; reference?: string; … }[]` | optional | Sub-fields for composite/repeater/record types |
Expand DownExpand Up@@ -401,7 +401,7 @@ View filter rule
| **description** | `string` | optional | Optional description rendered under the section header. |
| **collapsible** | `boolean` | optional (default: `false`) | |
| **collapsed** | `boolean` | optional (default: `false`) | |
| **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. |
| **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. No `features.*` on ANY form-view predicate — refused at parse (ruled 2026-08-27, objectui#6262): unbound on the standalone form routes, where 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 (default: `1`) | |
| **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 All@@ -416,7 +416,7 @@ View filter rule
| **description** | `string` | optional | Optional description rendered under the section header. |
| **collapsible** | `boolean` | optional (default: `false`) | |
| **collapsed** | `boolean` | optional (default: `false`) | |
| **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. |
| **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. No `features.*` on ANY form-view predicate — refused at parse (ruled 2026-08-27, objectui#6262): unbound on the standalone form routes, where 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 (default: `1`) | |
| **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
16 changes: 14 additions & 2 deletions content/docs/ui/views.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -393,7 +393,7 @@ formViews: {
| `columns` | `1-4` | Grid column count |
| `collapsible` | `boolean` | Can section be collapsed |
| `collapsed` | `boolean` | Initially collapsed |
| `visibleWhen` | `string` | CEL predicate — section shown only when TRUE |
| `visibleWhen` | `string` | CEL predicate — section shown only when TRUE. Same scope boundary as field-level predicates: no `features.*` (refused at parse — see below) |
| `fields` | `(string \| FormField)[]` | Fields in the section |

### Form Field Configuration
Expand DownExpand Up@@ -428,7 +428,19 @@ 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 form fields bind `record` (+ `previous`, `parent`) and, since objectui#6010, `current_user` — the identity scope page components and per-option predicates already bound (ADR-0089 D1). Two surfaces still evaluate it unbound, where the predicate faults open: the console's standalone form routes `/forms/:name` and `/f/:slug` (objectui#6110), and section-level predicates (objectui#6111). (was `visibleOn`, ADR-0089) |
| `visibleWhen` | `string` | Visibility predicate (CEL); runtime form fields bind `record` (+ `previous`, `parent`) and, since objectui#6010, `current_user` — the identity scope page components and per-option predicates already bound (ADR-0089 D1). Two surfaces still evaluate it unbound, where the predicate faults open: the console's standalone form routes `/forms/:name` and `/f/:slug` (objectui#6110), and section-level predicates (objectui#6111). No `features.*` on any form-view predicate — refused at parse, see the boundary note below. (was `visibleOn`, ADR-0089) |

<Callout type="warn">
**Form-view predicates may not name the `features.*` scope root** (ruled 2026-08-27, objectui#6262).
A form view also renders on routes with no app context — the console's standalone `/forms/:name`
and the public `/f/:slug` — where `features` is unbound: the predicate faults and `visibleWhen`
fails **open**, so the field or section a feature flag was meant to hide is shown to everyone.
The spec therefore refuses `features.*` loudly at parse on **every** form-view predicate —
section-level, field-level at any nesting depth, and per-option `visibleWhen` authored inline in
the form view. Gate by record state instead (`record.*`), or put the feature-gated surface on an
app page or action, where `features.*` stays bound and stays legal. A record *field* named
`features` is unaffected — `record.features.x` is member access, not the scope root.
</Callout>

## Complete Example

Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import type { SemanticMigration } from '../../types.js';

export const entry: SemanticMigration = {
id: 'ui-form-view-predicate-features-root-refused',
surface: 'form-view predicates naming the `features.*` scope root — section-level '
+ '`visibleWhen`, field-level `visibleWhen` at any nesting depth, and per-option '
+ '`visibleWhen` authored inline in the form view (`FormViewSchema`, including the '
+ 'flattened runtime form overlay and the deprecated `visibleOn` alias spellings)',
replacement: 'gate by record state (`record.*` in runtime forms, `data.*` in metadata '
+ 'forms), or move the feature-gated surface onto an app page component or action — '
+ 'the predicate surfaces where `features.*` stays bound and stays legal. No rewrite '
+ 'is mechanical: a feature-flag gate and a record-state gate answer different '
+ 'questions, so the author chooses which surface the gate belongs on.',
reason:
'objectstack#12665, ruled 2026-08-27 on objectui#6262 (option B — vocabulary '
+ 'narrowing): one authored form view is served on two kinds of route, and a '
+ '`features.*` predicate got two verdicts from the same text. Inside an app '
+ '(`/apps/:appName/*`) the root resolves against the real auth-config flags; on the '
+ 'standalone form routes (`/forms/:name`, public `/f/:slug`) no app context exists, '
+ 'the root is UNBOUND, the predicate faults — and `visibleWhen`\'s fault fallback is '
+ 'visible, so the field or section a feature flag was meant to hide is shown to '
+ 'everyone (fail-open, on an access-shaped key). Measured before ruling and '
+ 're-verified at dispatch (2026-08-28): ZERO authored `features.*` form-view '
+ 'predicates exist across objectui apps/examples/content, against an 18-hit positive '
+ 'control on authored `visibleWhen` predicates — so the vocabulary is narrowed at '
+ 'the authoring door instead of building an auth-config fetch plus pre-load '
+ 'semantics on a route with zero consumers. App-context predicate surfaces '
+ '(page components, actions, bulk-action eligibility) keep `features.*` unchanged.',
acceptanceCriteria:
'A form view carrying a predicate that names `features` in root position (dotted '
+ 'member access, index access, or the bare root — outside string literals) is '
+ 'refused at parse with a prescriptive issue naming the root, the surface, the '
+ 'fail-open reason and the ruling. Predicates on permitted roots parse unchanged, '
+ 'including member access on a record field that happens to be named `features` '
+ '(`record.features.x`). Stored form views are unaffected until their next '
+ 'authoring-path save (zero such documents were measured to exist); on refusal the '
+ 'author re-gates by record state or moves the gate to an app surface.',
};
Loading
Loading