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
13 changes: 13 additions & 0 deletions .changeset/quiet-pugs-shave.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
---
"@objectstack/spec": patch
"@objectstack/lint": patch
---

Correct three stale `current_user` binding claims about a form FIELD `visibleWhen`

A runtime form field's `visibleWhen` has resolved `current_user` — and the ADR-0068 D1 aliases `user` / `ctx.user` / `os.user` — since objectui#6010, but three texts shipped by these two packages still told authors the root was unbound there, and that per-option `visibleWhen` was "the only `*When` surface where it resolves".

- `@objectstack/spec`: `FormFieldSchema.visibleWhen`'s doc block and its `describe()` now state the binding together with the two limits it does not remove — it is a rendering rule that nothing on the write path evaluates, so a role test written there protects no data; and the scope belongs to the host, so it is empty on the console's public standalone form route, where the predicate faults and visibility fails open. The generated `content/docs/references/ui/view.mdx` rows follow from the `describe()`.
- `@objectstack/lint`: the field-rule prescription no longer grounds "move it to the option's own `visibleWhen`" on exclusivity. It grounds it on enforcement — the rule validator evaluates a per-option predicate on every write — and names the form-view field predicate only to refuse it as a destination for a server-enforced object rule, since moving one there would trade a loud lint error for a silent enforcement gap.

No schema, validation or verdict change: the set of accepted metadata is byte-identical, and the rule still refuses a user root on an object field-level `*When`.
4 changes: 2 additions & 2 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). 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'` |
| **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. `current_user` (and the ADR-0068 aliases `user` / `ctx.user` / `os.user`) resolves here since objectui#6010 — CLIENT-SIDE only: nothing server-side evaluates a form-view field `visibleWhen`, so a role test here hides the control and protects no data (declare permission-set field-level security for that), and on the public `/f/:slug` route no host publishes a scope, so the root is unbound and the predicate faults open. 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@@ -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). 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'` |
| **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. `current_user` (and the ADR-0068 aliases `user` / `ctx.user` / `os.user`) resolves here since objectui#6010 — CLIENT-SIDE only: nothing server-side evaluates a form-view field `visibleWhen`, so a role test here hides the control and protects no data (declare permission-set field-level security for that), and on the public `/f/:slug` route no host publishes a scope, so the root is unbound and the predicate faults open. 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 Down
26 changes: 23 additions & 3 deletions packages/lint/scripts/check-doc-formula-expressions.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -556,16 +556,36 @@ function scan(files) {
*
* `visibleWhen` is one key spelling several unrelated contracts, and the binding
* root genuinely differs by layer. Re-measured on objectui `origin/main`
* @ `2aff580` (the card's table was taken at `365e334`, a sibling repo on its
* own cadence — the PR body records what had drifted):
* @ `f241a4d` for #11813 (the previous reading was `2aff580`; the card's
* original table was `365e334` — a sibling repo on its own cadence, so each
* re-measure records what had drifted since the last one):
*
* | Layer | Binds |
* |-----------------------------------------|-------------------------------------------|
* | object field / form section `*When` | `record` + `previous` (+ `parent`) |
* | object field `*When` (ADR-0036) | `record` + `previous` (+ `parent`) |
* | view form FIELD `visibleWhen` | `record` + the HOST predicate scope |
* | per-option `visibleWhen` | `record` + the HOST predicate scope |
* | page component / app-nav `visible` | `current_user`/`user`/`ctx`/`os`/`app`/… |
* | flow-screen field `visibleWhen` | the screen's own field names, FLATTENED |
*
* Row 1 states the WRITE-PATH binding, and that is deliberate rather than
* partial: an object field rule is enforced by the rule validator, whose
* evaluator binds no user root, and this is the only layer the gate judges.
* Since objectui#6010 the renderer ALSO hands the host scope to an object
* field's rules, so a user root written there resolves client-side — the form
* hides the control while every other reader still returns the value. That is a
* silent enforcement gap, which is why the verdict is unchanged and the row is
* qualified rather than widened (#12914 tracks the message text that describes
* the mechanism).
*
* Row 2 is the split #11813 asked for. It used to share row 1 with the object
* field layer, and since objectui#6010 that was two verdicts in one cell. The
* form SECTION layer shared the same cell and its verdict moved as well, with
* objectui#6110 / objectui#6111; it is absent from the table rather than
* re-stated because it lands in the SKIPPED class here either way — neither
* discriminator arm below reaches it — and its re-measurement is #12914, not
* something this gate turns on.
*
* The last row is not hypothetical and it is in this very corpus:
* `content/docs/automation/flows.mdx` teaches
* `visibleWhen: 'createOpportunity == true'` — a BARE reference, which is
Expand Down
10 changes: 9 additions & 1 deletion packages/lint/src/validate-expressions.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -707,8 +707,16 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => {
expect(issue!.message).not.toContain('record.current_user');
// 1. what actually goes wrong, in the direction it goes wrong
expect(issue!.message).toMatch(/falls back to VISIBLE/);
// 2. the one `*When` surface that binds `current_user`
// 2. the surface whose `current_user` gate the SERVER enforces — the
// rule validator evaluates a per-option `visibleWhen` on every
// write. It stopped being the ONLY `*When` surface that binds the
// root when objectui#6010 bound a form view's field predicate
// (client-side only), so the prescription is grounded on
// enforcement rather than on exclusivity.
expect(issue!.message).toMatch(/option's own `visibleWhen`/);
expect(issue!.message).toMatch(/rule validator/);
// …and the retired exclusivity claim does not come back.
expect(issue!.message).not.toMatch(/the one `\*When` surface/);
// 3. the surface that hides a whole field by role — FLS on a permission
// set (`PermissionSetSchema.fields`, `permission.zod.ts:455`)
expect(issue!.message).toMatch(/readable: false/);
Expand Down
24 changes: 18 additions & 6 deletions packages/lint/src/validate-expressions.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -590,7 +590,16 @@ function rulePredicates(rule: AnyRec, path: string): Array<{ label: string; raw:
* the option's `visibleWhen`" answers a question nobody asked. Three tiers:
*
* - **user roots** (`current_user` / `user` / `ctx` / `os`) keep the existing
* two user-specific prescriptions plus the `record` rewrite;
* two user-specific prescriptions plus the `record` rewrite. What changed is
* the GROUNDING of the first one, not the prescription: it used to read
* "per-option is the one `*When` surface that binds `current_user`", and
* since objectui#6010 a form VIEW's field predicate binds those roots too —
* client-side only, because nothing on the write path evaluates a form-view
* field `visibleWhen`. So per-option is recommended for the reason that
* survives the change: the rule validator enforces it on write. The message
* now says that, and names the view surface only to refuse it as a
* destination for a server-enforced object rule (trading a loud lint error
* for a silent enforcement gap is the one outcome worse than the error);
* - **`data`** gets the metadata-form-vs-runtime-form explanation, because
* that is what the mistake IS — the same key name, the other form kind's
* root;
Expand DownExpand Up@@ -676,11 +685,14 @@ export function fieldRuleRootIssue(
const root = FIELD_RULE_USER_ROOTS.find((r) => kept.includes(r)) ?? kept[0]!;
const prescription = (FIELD_RULE_USER_ROOTS as readonly string[]).includes(root)
? `To gate the CHOICES of a select by user, move the predicate to the option's own ` +
`\`visibleWhen\` (\`options: [{ …, visibleWhen: … }]\`) — per-option is the one \`*When\` ` +
`surface that binds \`current_user\` and its ADR-0068 aliases. To hide the FIELD by role, ` +
`declare field-level security on a permission set ` +
`(\`fields: { '<object>.<field>': { readable: false } }\`), which the server enforces. ` +
`To gate on record state, rewrite the predicate against \`record\`.`
`\`visibleWhen\` (\`options: [{ …, visibleWhen: … }]\`) — the rule validator evaluates ` +
`that one against \`current_user\` and its ADR-0068 aliases on every write, so the gate ` +
`holds server-side. To hide the FIELD by role, declare field-level security on a ` +
`permission set (\`fields: { '<object>.<field>': { readable: false } }\`), which the ` +
`server enforces. To gate on record state, rewrite the predicate against \`record\`. ` +
`Note that a form VIEW's own field predicate HAS bound these roots since objectui#6010 — ` +
`but only in the renderer, so moving a server-enforced object rule there swaps a loud ` +
`error for a silent enforcement gap; it is not a fourth answer.`
: root === 'data'
// The `*.form` spelling is deliberately not `*.form.ts`: this is a
// STRING literal, and #5017's receiver scan strips comments but not
Expand Down
47 changes: 36 additions & 11 deletions packages/spec/src/ui/view.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1950,16 +1950,41 @@ const FormFieldBaseSchema = lazySchema(() => {
* `parent` for master-detail line items); metadata-editing forms
* (`*.form.ts`) bind the row under edit as `data`.
*
* ⚠️ **No `current_user` here** (#6146). Field-level rules are evaluated by
* `evalFieldPredicate` / `resolveFieldRuleState` (`@object-ui/core`), which
* binds `record` + `previous` + an `extra` scope and nothing else — the
* autocomplete pins the same set (objectui#1582). A predicate referencing
* `current_user` is an UNBOUND identifier: the evaluation faults and falls
* back, and visibility's fallback is `true`, so the field a `current_user`
* test was meant to hide stays **permanently visible**. `current_user` IS
* bound for **per-option** `visibleWhen` (a different evaluator —
* `resolveCascadingOptions` against the host's predicate scope, ADR-0068 /
* objectui#2284); that is the only `*When` surface where it resolves.
* ## `current_user` DOES bind here — client-side, and only under a host that
* publishes a scope (objectui#6010)
*
* The form renderer passes the host shell's predicate scope into
* `evalFieldPredicate` / `resolveFieldRuleState` (`@object-ui/core`) as their
* `extra` scope, so a field-level predicate resolves `current_user` and its
* ADR-0068 D1 aliases (`user`, `ctx.user`, `os.user`) alongside `record` +
* `previous` + `parent`. The root really was unbound here (#6146) and this
* block said so for as long as that held; objectui#6010 bound it, and the
* text above is a re-measurement rather than a relaxation of anything.
*
* Two limits the binding does **not** remove — both fail in the direction an
* author will not notice:
*
* 1. ⚠️ **This is a rendering rule, never authorization.** Nothing
* server-side evaluates a form-view field `visibleWhen`: the write path
* evaluates field `readonlyWhen` / `requiredWhen` and per-option
* `visibleWhen`, and that is the whole list. A role test written here
* hides a control and protects no data — the record still carries the
* value and every other read surface still returns it. To withhold a field
* by role, declare field-level security on a permission set, which the
* server enforces.
* 2. ⚠️ **The scope belongs to the HOST, so it is empty wherever no host
* publishes one.** The console's public form route (`/f/:slug`) is mounted
* outside any provider deliberately — an anonymous visitor has no
* principal — so `current_user` is unbound there, the predicate faults,
* and visibility's fallback is `true`: the field the test was meant to
* hide is shown to everyone. The authed standalone route (`/forms/:name`)
* publishes the session principal and binds normally.
*
* `current_user` is bound for **per-option** `visibleWhen` as well, through a
* different evaluator (`resolveCascadingOptions` against the same host scope,
* ADR-0068 / objectui#2284) — and that surface the rule validator ALSO
* enforces on write, which is why it stays the destination for a user-gated
* *choice* rather than a user-gated field.
*
* **Inside a repeater, `data` is the ROW, not the whole document** (#6254).
* A sub-field of a `type: 'record'` / repeater field is rendered with its own
Expand All@@ -1978,7 +2003,7 @@ const FormFieldBaseSchema = lazySchema(() => {
* 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). 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'`"),
visibleWhen: ExpressionInputSchema.optional().describe("Visibility predicate (CEL) — field shown only when TRUE. Root: `record` (+ `previous`, `parent`) in runtime forms, or `data` in metadata forms. `current_user` (and the ADR-0068 aliases `user` / `ctx.user` / `os.user`) resolves here since objectui#6010 — CLIENT-SIDE only: nothing server-side evaluates a form-view field `visibleWhen`, so a role test here hides the control and protects no data (declare permission-set field-level security for that), and on the public `/f/:slug` route no host publishes a scope, so the root is unbound and the predicate faults open. 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).'),
Expand Down
Loading