From a363c0426b606a52be84522c7f12f516752b57bf Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 03:00:56 +0000 Subject: [PATCH] feat(spec): form-view predicates loudly reject the features.* scope root (#12665) One authored form view is served on two kinds of route, and a features.* predicate got two verdicts from the same text: bound to real auth-config flags inside an app, UNBOUND on the standalone form routes (/forms/:name, public /f/:slug) - where the predicate faults and visibleWhen fails OPEN, showing the feature-gated field to everyone. Ruled 2026-08-27 on objectui#6262 (option B): the vocabulary is narrowed at the authoring door. - FormViewSchema superRefine scans every predicate the form view carries (section-level, field-level at any depth, per-option) for features in root position, string literals stripped; prescriptive refusal names the root, the surface, the fail-open reason and the ruling. - ADR-0087 semantic entry ui-form-view-predicate-features-root-refused; registry regenerated. - Docs: ui/views.mdx boundary callout + describe() strings (references page regenerated). - App-context predicate surfaces are untouched and pinned untouched. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JvjTCjJQn9zSTXEhUKgT7s --- .changeset/form-view-features-root-refused.md | 9 + content/docs/references/ui/view.mdx | 10 +- content/docs/ui/views.mdx | 16 +- ...rm-view-predicate-features-root-refused.ts | 40 +++ packages/spec/src/migrations/registry.ts | 36 +++ .../src/ui/view-form-features-root.test.ts | 231 ++++++++++++++++++ packages/spec/src/ui/view.zod.ts | 153 +++++++++++- 7 files changed, 480 insertions(+), 15 deletions(-) create mode 100644 .changeset/form-view-features-root-refused.md create mode 100644 packages/spec/src/migrations/entries/semantic/18.ui-form-view-predicate-features-root-refused.ts create mode 100644 packages/spec/src/ui/view-form-features-root.test.ts diff --git a/.changeset/form-view-features-root-refused.md b/.changeset/form-view-features-root-refused.md new file mode 100644 index 0000000000..7324c8ccdf --- /dev/null +++ b/.changeset/form-view-features-root-refused.md @@ -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. + + diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index a88e24990d..dea958c218 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -160,7 +160,7 @@ Column footer summary configuration | **language** | `string` | optional | Code editor language (for type=code) | | **keyField** | `{ field?: string; label?: string \| Record; placeholder?: string \| Record; helpText?: string \| Record; … }` | 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 | @@ -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'. | @@ -322,7 +322,7 @@ View filter rule | **language** | `string` | optional | Code editor language (for type=code) | | **keyField** | `{ field?: string; label?: string \| Record; placeholder?: string \| Record; helpText?: string \| Record; … }` | 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 | @@ -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'. | @@ -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'. | diff --git a/content/docs/ui/views.mdx b/content/docs/ui/views.mdx index ff0068ccae..dd8d6d7618 100644 --- a/content/docs/ui/views.mdx +++ b/content/docs/ui/views.mdx @@ -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 @@ -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) | + + +**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. + ## Complete Example diff --git a/packages/spec/src/migrations/entries/semantic/18.ui-form-view-predicate-features-root-refused.ts b/packages/spec/src/migrations/entries/semantic/18.ui-form-view-predicate-features-root-refused.ts new file mode 100644 index 0000000000..69b333249b --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.ui-form-view-predicate-features-root-refused.ts @@ -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.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 2c2d5b775c..2a24a43d72 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -7055,6 +7055,42 @@ const step18: MigrationStep = { + 'next authoring-path save with a prescriptive per-key issue; the author deletes the ' + 'key or re-declares the integer they meant.', }, + { + 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.', + }, { id: 'ui-mcp-connect-agent-unknown-keys-refused', surface: 'page `mcp:connect-agent` component — `properties` (any key at all: the widget ' diff --git a/packages/spec/src/ui/view-form-features-root.test.ts b/packages/spec/src/ui/view-form-features-root.test.ts new file mode 100644 index 0000000000..600acc8828 --- /dev/null +++ b/packages/spec/src/ui/view-form-features-root.test.ts @@ -0,0 +1,231 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#12665 — form-view predicates loudly reject the `features.*` + * scope root (ruled 2026-08-27 on objectui#6262, option B: vocabulary + * narrowing). + * + * ## What is being pinned + * + * One authored form view is served on two kinds of route, and `features.*` + * used to get two verdicts from the same predicate text: bound to the real + * auth-config flags inside an app, UNBOUND on the standalone form routes + * (`/forms/:name`, `/f/:slug`) — where the predicate faults and `visibleWhen` + * fails OPEN, showing the feature-gated field to everyone. The ruling narrows + * the form-view predicate vocabulary at the authoring door instead of + * building app-context parity machinery for a root with zero measured + * consumers. + * + * ## Why every refusal asserts the MESSAGE, not just failure + * + * Same rule as `view-submit-redirect-url.test.ts`: a bare + * `expect(success).toBe(false)` carries one bit, and this narrowing has two — + * *that* the root is refused, and *what the author is told to write instead*. + * Each refusal pins the issue's `code`/`path` (the identity a machine + * consumer branches on) plus the prescription and the ruling reference (what + * an author — human or AI — acts on). + * + * ## Acceptance is pinned as hard as refusal + * + * A root scanner written slightly too wide is indistinguishable from a + * correct one until `record.features.enabled` (a record FIELD named + * `features`) stops parsing. The accepted shapes below are the half that says + * the scanner narrowed exactly as far as the ruling and no further — and the + * app-context cases pin that no other predicate surface moved. + */ + +import { describe, it, expect } from 'vitest'; + +import { FormViewSchema } from './view.zod'; +import { PageComponentSchema } from './page.zod'; +import { BulkActionDefSchema } from './bulk-action.zod'; + +/** Reject `value` through FormViewSchema and return its issues (parsed, not stringified). */ +function reject(value: unknown): Array<{ code: string; path: Array; message: string }> { + const r = FormViewSchema.safeParse(value); + expect(r.success, `expected REJECTION, got a successful parse of ${JSON.stringify(value)}`).toBe(false); + return (r.error?.issues ?? []) as Array<{ code: string; path: Array; message: string }>; +} + +/** Parse `value` through FormViewSchema and fail loudly (with the issues) if it does not succeed. */ +function accept(value: unknown): unknown { + const r = FormViewSchema.safeParse(value); + expect(r.success, `expected ACCEPTANCE, got ${JSON.stringify(r.error?.issues ?? '')}`).toBe(true); + return r.data!; +} + +/** The one refusal this narrowing emits, located at `path`. */ +function expectFeaturesRefusal( + issues: Array<{ code: string; path: Array; message: string }>, + path: Array, +) { + const issue = issues.find(i => JSON.stringify(i.path) === JSON.stringify(path)); + expect(issue, `expected a refusal at ${JSON.stringify(path)}, got ${JSON.stringify(issues)}`).toBeDefined(); + expect(issue!.code).toBe('custom'); + // The identity an author (and an AI author's retry loop) acts on: the root, + // the surface, the fail-open reason, the ruling, and the prescription. + expect(issue!.message).toContain('Form-view predicates may not name the `features.*` scope root'); + expect(issue!.message).toContain('ruled 2026-08-27 on objectui#6262'); + expect(issue!.message).toContain('UNBOUND'); + expect(issue!.message).toContain('fails OPEN'); + expect(issue!.message).toContain('`record.*`'); +} + +/** A minimal form view with one predicate-bearing field. */ +const formWithFieldPredicate = (visibleWhen: unknown) => ({ + type: 'simple', + sections: [{ fields: [{ field: 'phone', visibleWhen }] }], +}); + +describe('form-view predicates reject the features.* root (objectui#6262)', () => { + it('refuses a field-level visibleWhen naming features.* (bare-string shorthand)', () => { + const issues = reject(formWithFieldPredicate('features.multiOrgEnabled == true')); + expectFeaturesRefusal(issues, ['sections', 0, 'fields', 0, 'visibleWhen']); + }); + + it('refuses the full Expression envelope form too', () => { + const issues = reject( + formWithFieldPredicate({ dialect: 'cel', source: 'features.phoneNumber' }), + ); + expectFeaturesRefusal(issues, ['sections', 0, 'fields', 0, 'visibleWhen']); + }); + + it('refuses a section-level visibleWhen naming features.*', () => { + const issues = reject({ + type: 'simple', + sections: [{ visibleWhen: 'features.beta == true', fields: ['name'] }], + }); + expectFeaturesRefusal(issues, ['sections', 0, 'visibleWhen']); + }); + + it('refuses the deprecated visibleOn alias — the fold cannot smuggle the root past the scan', () => { + // ADR-0089 D2 folds `visibleOn` into `visibleWhen` at the field parse, + // BEFORE this refinement runs — so the alias spelling arrives at the + // scanner as the canonical key and is refused at that key's path. + const issues = reject({ + type: 'simple', + sections: [{ fields: [{ field: 'phone', visibleOn: 'features.phoneNumber == true' }] }], + }); + expectFeaturesRefusal(issues, ['sections', 0, 'fields', 0, 'visibleWhen']); + }); + + it('refuses the root inside the legacy `groups` bucket (pre-fold)', () => { + const issues = reject({ + type: 'simple', + groups: [{ fields: [{ field: 'phone', visibleWhen: 'features.phoneNumber' }] }], + }); + expectFeaturesRefusal(issues, ['groups', 0, 'fields', 0, 'visibleWhen']); + }); + + it('refuses the root on a nested sub-field (repeater/composite recursion)', () => { + const issues = reject({ + type: 'simple', + sections: [{ + fields: [{ + field: 'lines', + type: 'repeater', + fields: [{ field: 'discount', visibleWhen: 'features.discounting == true' }], + }], + }], + }); + expectFeaturesRefusal( + issues, + ['sections', 0, 'fields', 0, 'fields', 0, 'visibleWhen'], + ); + }); + + it('refuses the root on a per-option visibleWhen authored inline in the form view', () => { + const issues = reject({ + type: 'simple', + sections: [{ + fields: [{ + field: 'channel', + type: 'select', + options: [ + { value: 'email', label: 'Email' }, + { value: 'sms', label: 'SMS', visibleWhen: 'features.smsEnabled == true' }, + ], + }], + }], + }); + expectFeaturesRefusal( + issues, + ['sections', 0, 'fields', 0, 'options', 1, 'visibleWhen'], + ); + }); + + it('refuses index access and the bare root, not only dotted member access', () => { + const bracket = reject(formWithFieldPredicate("features['beta'] == true")); + expectFeaturesRefusal(bracket, ['sections', 0, 'fields', 0, 'visibleWhen']); + + const bare = reject(formWithFieldPredicate('features != null')); + expectFeaturesRefusal(bare, ['sections', 0, 'fields', 0, 'visibleWhen']); + + const inCall = reject(formWithFieldPredicate('has(features.beta) && record.active')); + expectFeaturesRefusal(inCall, ['sections', 0, 'fields', 0, 'visibleWhen']); + }); + + it("refuses on type: 'split' forms too — the exclusion has no per-type gap", () => { + // The same refinement hosts the split-only `pane` check behind a type + // guard; this pins that the features scan did NOT inherit that guard. + const issues = reject({ + type: 'split', + sections: [ + { pane: 'primary', fields: ['name'] }, + { pane: 'secondary', fields: [{ field: 'phone', visibleWhen: 'features.x' }] }, + ], + }); + expectFeaturesRefusal(issues, ['sections', 1, 'fields', 0, 'visibleWhen']); + }); +}); + +describe('positive controls — the scanner narrows exactly as far as the ruling', () => { + it('an equivalent predicate on a permitted root still validates', () => { + accept(formWithFieldPredicate("record.priority == 'urgent'")); + accept({ + type: 'simple', + sections: [{ visibleWhen: "record.status != 'cancelled'", fields: ['name'] }], + }); + }); + + it('a record FIELD named `features` is member access, not the scope root — accepted', () => { + accept(formWithFieldPredicate('record.features.enabled == true')); + accept(formWithFieldPredicate("data.features == 'on'")); + }); + + it('a longer identifier that merely starts with `features` is not the root — accepted', () => { + accept(formWithFieldPredicate('record.features_enabled == true')); + }); + + it('the word inside a string literal is data, not a root reference — accepted', () => { + accept(formWithFieldPredicate("record.tag == 'features.beta'")); + accept(formWithFieldPredicate('record.tag == "features"')); + }); + + it('an AST-only envelope is opaque at this layer and passes (documented boundary)', () => { + // The authoring shape is the source string; build emits ASTs from sources + // this gate already accepted. An AST-only envelope has nothing to scan. + accept(formWithFieldPredicate({ dialect: 'cel', ast: { kind: 'ident', name: 'record' } })); + }); +}); + +describe('app-context surfaces keep features.* exactly as-is', () => { + it('a page component visibleWhen naming features.* still parses', () => { + const r = PageComponentSchema.safeParse({ + type: 'record:detail', + visibleWhen: 'features.multiOrgEnabled == true', + }); + expect(r.success, JSON.stringify(r.success ? '' : r.error?.issues)).toBe(true); + }); + + it('a bulk-action eligibility predicate naming features.* still parses', () => { + const r = BulkActionDefSchema.safeParse({ + name: 'notify', + label: 'Notify', + operation: 'update', + patch: { notified: true }, + visible: 'features.notifications != false', + }); + expect(r.success, JSON.stringify(r.success ? '' : r.error?.issues)).toBe(true); + }); +}); diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 3097068e8d..bc2074d30c 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -1972,8 +1972,13 @@ const FormFieldBaseSchema = lazySchema(() => { * `current_user` note above describes, arriving from the other end. Prefix * every reference with `data.` whether the field sits at the top level or * inside a repeater. + * + * ⛔ **No `features.*` on any form-view predicate — refused at parse** + * (ruled 2026-08-27, objectui#6262). Unlike the two fault-open notes above, + * this one is ENFORCED: see {@link checkFormViewPredicateFeaturesRoot} for + * the ruling and the scanner. */ - 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). 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: 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). 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'`"), /** @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).'), @@ -2131,9 +2136,11 @@ export const FormSectionSchema = lazySchema(() => strictObject({ * {@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). + * and falls back to visible (#6146). ⛔ No `features.*` either — that one is + * refused at parse (ruled 2026-08-27, objectui#6262; see + * {@link checkFormViewPredicateFeaturesRoot}). */ - 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.'), + 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. 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.'), /** @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([ @@ -2407,6 +2414,124 @@ function checkSubmitRedirectUrl(raw: string): string | undefined { return undefined; } +/** + * ## Form-view predicates may not name the `features.*` scope root + * + * Ruled 2026-08-27 on objectui#6262 (maintainer, decision-inbox batch 2 — + * option B, vocabulary narrowing): **form views may not name `features.*` in + * predicates**, and the exclusion is declared and enforced HERE, at the + * authoring/publish door, with a loud rejection. + * + * ### Why the root is excluded rather than wired up + * + * A form view is one authored artifact served on two kinds of route, and + * `features.*` got two different verdicts from the same predicate text: + * + * - inside an app (`/apps/:appName/*`) the `features` scope root resolves + * against the real auth-config flags (`GET /api/v1/auth/config`); + * - on the console's 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 `true`, so the field or + * section a feature flag was meant to hide is shown to **everyone**. + * + * That is a fail-OPEN asymmetry on an access-shaped key. It was measured + * before being ruled on (objectui#6262, 2026-08-25, re-verified at dispatch): + * ZERO authored `features.*` form-view predicates exist across + * apps/examples/content, against an 18-hit positive control on authored + * `visibleWhen` predicates generally — so the honest fix is to narrow the + * vocabulary at the door, not to build an auth-config fetch + pre-load + * semantics on a route with zero consumers. + * + * ### Exactly this surface, nothing else + * + * The exclusion covers every predicate a FORM VIEW carries: section-level + * `visibleWhen`, field-level `visibleWhen` (including sub-fields of + * composite/repeater/record fields at any depth), and per-option + * `visibleWhen` on options authored inline in the form view. App-context + * predicate surfaces (`action.visible`, bulk-action `visible`, page/app + * predicates) keep `features.*` exactly as before — those render only where + * the root is bound. + * + * ### What the scanner can and cannot decide + * + * Parse time sees the CEL *source string*. The scanner strips string literals + * (so `"features"` inside quoted text cannot false-positive) and then looks + * for `features` in ROOT position — not preceded by `.` or an identifier + * character — so `record.features_enabled` and `record.features.x` (a record + * field that happens to be named `features`) stay legal. An AST-only envelope + * (`{ dialect, ast }`, no `source`) is opaque at this layer and passes — the + * authoring shape is the source string, and build emits the AST from sources + * this gate has already accepted. + */ +const FORM_VIEW_FEATURES_RULING = 'ruled 2026-08-27 on objectui#6262'; + +/** CEL string literals (both quote styles, with escapes) — stripped before the root scan. */ +const CEL_STRING_LITERAL_RE = /'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"/g; + +/** + * `features` in root position: start-of-source or a non-identifier, + * non-member-access character before it, and no identifier character after — + * catches `features.x`, `features['x']`, bare `features` and `has(features.x)` + * while leaving `record.features` (member access) and `features_enabled` + * (longer identifier) alone. + */ +const FEATURES_ROOT_RE = /(?:^|[^.\w])features(?![\w])/; + +/** + * Scan one parsed form-view predicate (an {@link Expression} envelope — the + * bare-string shorthand is already normalized by the time a refinement sees + * it) for the excluded `features.*` root. Returns the author-facing refusal, + * or `undefined` when the predicate conforms. + */ +function checkFormViewPredicateFeaturesRoot(predicate: unknown): string | undefined { + if (predicate === null || typeof predicate !== 'object') return undefined; + const { dialect, source } = predicate as { dialect?: unknown; source?: unknown }; + if (dialect !== 'cel' || typeof source !== 'string') return undefined; + if (!FEATURES_ROOT_RE.test(source.replace(CEL_STRING_LITERAL_RE, ''))) return undefined; + return 'Form-view predicates may not name the `features.*` scope root ' + + `(${FORM_VIEW_FEATURES_RULING}). 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. Gate by record state instead ' + + '(`record.*`), or put the feature-gated surface on an app page or action — the surfaces ' + + 'where `features.*` stays bound.'; +} + +/** The subset of a parsed form field the `features.*` root scan reads. */ +type FormFieldPredicateCarrier = { + visibleWhen?: unknown; + options?: ReadonlyArray<{ visibleWhen?: unknown } | null | undefined>; + fields?: ReadonlyArray; +}; + +/** + * Walk one form-view field (and its sub-fields, and its inline options) and + * report every predicate naming the excluded `features.*` root. Legacy + * bare-string field entries carry no predicate and are skipped. + */ +function refineFormFieldFeaturesRoot( + field: string | FormFieldPredicateCarrier | null | undefined, + path: ReadonlyArray, + ctx: z.RefinementCtx, +): void { + if (field === null || field === undefined || typeof field === 'string') return; + const refusal = checkFormViewPredicateFeaturesRoot(field.visibleWhen); + if (refusal) ctx.addIssue({ code: 'custom', path: [...path, 'visibleWhen'], message: refusal }); + field.options?.forEach((option, optionIndex) => { + const optionRefusal = checkFormViewPredicateFeaturesRoot(option?.visibleWhen); + if (optionRefusal) { + ctx.addIssue({ + code: 'custom', + path: [...path, 'options', optionIndex, 'visibleWhen'], + message: optionRefusal, + }); + } + }); + field.fields?.forEach((sub, subIndex) => { + refineFormFieldFeaturesRoot(sub, [...path, 'fields', subIndex], ctx); + }); +} + /** * Form View Schema * Defines the layout for creating or editing a single record. @@ -2669,10 +2794,13 @@ export const FormViewSchema = lazySchema(() => strictObject({ // Reject it loudly at parse instead. `.extend()` keeps this check (zod 4 // attaches refinements to the schema), so the flattened runtime-overlay // variant in ViewMetadataSchema enforces it too. - if (view.type === 'split') return; + // + // ⚠️ This refinement runs BEFORE the `.overwrite()` fold — see + // {@link foldFormGroupsIntoSections} — so every check here must keep + // reading BOTH buckets (`sections` AND the legacy `groups` alias). for (const [key, sections] of [['sections', view.sections], ['groups', view.groups]] as const) { sections?.forEach((section, index) => { - if (section?.pane != null) { + if (view.type !== 'split' && section?.pane != null) { ctx.addIssue({ code: 'custom', path: [key, index, 'pane'], @@ -2681,11 +2809,20 @@ export const FormViewSchema = lazySchema(() => strictObject({ `form views (this form is '${view.type}'). Remove the key or change the form type.`, }); } + // The `features.*` scope-root exclusion (ruled 2026-08-27, objectui#6262 + // — see {@link checkFormViewPredicateFeaturesRoot}): every predicate + // this form view carries — section-level, field-level at any depth, + // per-option — is refused when it names the root that is unbound on the + // standalone form routes. Runs on every form `type`, split included. + const sectionRefusal = checkFormViewPredicateFeaturesRoot(section?.visibleWhen); + if (sectionRefusal) { + ctx.addIssue({ code: 'custom', path: [key, index, 'visibleWhen'], message: sectionRefusal }); + } + section?.fields?.forEach((field, fieldIndex) => { + refineFormFieldFeaturesRoot(field, [key, index, 'fields', fieldIndex], ctx); + }); }); } - // ⚠️ Anything added below this loop still runs BEFORE the `.overwrite()` - // fold — see {@link foldFormGroupsIntoSections} — so a refinement that - // reads sections must keep reading BOTH buckets. }).overwrite(foldFormGroupsIntoSections)); /**