From f83b37c106897807bb2c1c4f9ee9c120a8572c89 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 01:52:41 +0000 Subject: [PATCH 1/4] docs(spec,lint): re-measure four stale `current_user` binding-text sites (#12914) --- .../four-stale-current-user-binding-sites.md | 33 +++++++++++++ .../lint/src/validate-expressions.test.ts | 46 +++++++++++++++++-- packages/lint/src/validate-expressions.ts | 40 ++++++++++++---- packages/spec/src/data/field.zod.ts | 22 +++++---- .../src/shared/editability-boundary.test.ts | 9 +++- .../spec/src/shared/editability-boundary.ts | 4 +- packages/spec/src/ui/view.zod.ts | 43 +++++++++++++++-- 7 files changed, 169 insertions(+), 28 deletions(-) create mode 100644 .changeset/four-stale-current-user-binding-sites.md diff --git a/.changeset/four-stale-current-user-binding-sites.md b/.changeset/four-stale-current-user-binding-sites.md new file mode 100644 index 0000000000..6bcb088d1a --- /dev/null +++ b/.changeset/four-stale-current-user-binding-sites.md @@ -0,0 +1,33 @@ +--- +'@objectstack/spec': patch +'@objectstack/lint': patch +--- + +Re-measure four stale `current_user` binding-text sites, including the form SECTION slot + +The claim that `current_user` is unbound on a form-view **section** predicate was true when +it was written and is not any more: the console form renderer threads the host shell's +predicate scope into `isSectionVisible` (objectui#6110), and the object-view chain now +carries an authored `section.visibleWhen` through to an evaluator via the `section-divider` +pseudo-field (objectui#6111). Text only — no schema, no verdict and no runtime behaviour +moves. + +- `FormSectionSchema.visibleWhen` (`ui/view.zod.ts`) — the JSDoc and `describe()` now say the + root resolves, carrying the two qualifications the field-slot text already carried: the + binding is **client-side only** (no write-path evaluator reads a form-view section or field + `visibleWhen` — the rule validator's list is field `readonlyWhen` / `requiredWhen` and + per-option `visibleWhen`), and the scope is **empty on the public `/f/:slug` route**, which + is mounted outside any provider on purpose. The `features.*` refusal sentence is unchanged: + that root is unbound on both standalone form routes. +- `SelectOptionSchema.visibleWhen` (`data/field.zod.ts`) and + `SELECT_OPTION_EDITABILITY_GUIDANCE` (`shared/editability-boundary.ts`) — the retired + exclusivity claim ("the one `*When` surface where `current_user` resolves") is trimmed. The + durable grounding stays and is now what the prescription rests on: per-option is the one + visibility predicate the **server** enforces, so the rule validator refuses a write of a + value whose predicate is false. +- `@objectstack/lint`'s field-rule message — the `visibleWhen` consequence clause is + re-measured. Under a scope-publishing host the predicate no longer faults: it resolves, the + control is hidden client-side, and the server still returns the value to every other reader + — a silent enforcement gap. The fault-open leg survives wherever no host publishes a scope. + The verdict is unchanged and the message says why it is now *more* justified: trading a loud + lint error for a gap nobody can see is worse than the error. diff --git a/packages/lint/src/validate-expressions.test.ts b/packages/lint/src/validate-expressions.test.ts index d34f0c353e..11cb9fb22c 100644 --- a/packages/lint/src/validate-expressions.test.ts +++ b/packages/lint/src/validate-expressions.test.ts @@ -10,7 +10,7 @@ import { ExpressionInputSchema, ObjectStackSchema } from '@objectstack/spec'; import { FieldSchema, ObjectSchema, SelectOptionSchema } from '@objectstack/spec/data'; import { SharingRuleSchema } from '@objectstack/spec/security'; -import { validateStackExpressions } from './validate-expressions.js'; +import { validateStackExpressions, FIELD_RULE_BOUND_ROOTS } from './validate-expressions.js'; import type { ExprIssue } from './validate-expressions.js'; // [#8405] Cross-site pin only — see the describe block at the bottom of this // file. Not otherwise used here; validate-semantic-roles.test.ts owns the @@ -1148,7 +1148,20 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => { * visibleWhen client `fallback: true` ⇒ VISIBLE; server never evaluates * a FIELD-level `visibleWhen` at all (`hasFieldRules` gates * on `requiredWhen || readonlyWhen || option visibility`). - * ⇒ the old sentence was RIGHT here. + * ⇒ the old sentence was RIGHT here — and RE-MEASURED since, + * because the renderer half moved. `plugin-form`'s + * `sectionFields.ts` copies an object field's ADR-0036 rules + * onto the runtime form field and the SDUI renderer resolves + * them with `predicateScope` BOUND (objectui#6010), so under + * a scope-publishing host the predicate does not fault at + * all: it resolves, the control is hidden in that one form, + * and the server still returns the value to every other + * reader — a silent enforcement gap. The fault-open leg + * survives wherever no host publishes a scope (`/f/:slug`, + * every non-form reader), which is why BOTH halves are + * pinned below rather than one replacing the other. The + * verdict is untouched: `FIELD_RULE_BOUND_ROOTS` still + * rejects the root, and more justifiably than before. * readonlyWhen server `isReadonlyWhenLocked` ⇒ LOCKED (#4889's carve-out, * whose trigger IS the unbound-root case) and * `stripReadonlyWhenFields` deletes the value from the @@ -1176,12 +1189,39 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => { }], }).filter((i) => i.where === `object 'showcase_deal' · field 'gate' ${slot}`)[0]!.message; - it('`visibleWhen` — fail-OPEN to visible, the one slot the shared sentence fitted', () => { + it('`visibleWhen` — fail-OPEN to visible where no host publishes a scope', () => { const m = messageFor('visibleWhen'); expect(m).toMatch(/falls back to VISIBLE/); expect(m).toMatch(/showing for everyone/); }); + /** + * The half the re-measurement ADDED, pinned on its own so a revert of the + * new clause cannot hide behind the surviving fault-open sentence — which + * is exactly the blind spot the block comment above owns up to for the + * original per-slot split. + */ + it('`visibleWhen` — names the SILENT enforcement gap, the outcome under a bound scope', () => { + const m = messageFor('visibleWhen'); + // it RESOLVES rather than faulting, and the mechanism is named + expect(m).toMatch(/RESOLVES/); + expect(m).toMatch(/sectionFields\.ts/); + expect(m).toMatch(/objectui#6010/); + // …and the consequence is stated as the gap, not as a fail-open + expect(m).toMatch(/SILENT enforcement gap/); + expect(m).toMatch(/every other reader still returns it/); + expect(m).toMatch(/WORSE of the two/); + }); + + it('the verdict is unchanged — the field level still binds only record/previous/parent', () => { + // ⛔ The re-measured causal clause is not a relaxation. Pinned here + // beside the new wording so the two can never drift apart silently. + expect([...FIELD_RULE_BOUND_ROOTS]).toEqual(['record', 'previous', 'parent']); + expect(messageFor('visibleWhen')).toMatch( + /a field-level conditional rule binds only `record`/, + ); + }); + it('`readonlyWhen` — says LOCKED, and never says the field stays visible', () => { const m = messageFor('readonlyWhen'); expect(m).toMatch(/LOCKED/); diff --git a/packages/lint/src/validate-expressions.ts b/packages/lint/src/validate-expressions.ts index 18a871d550..df1efa3909 100644 --- a/packages/lint/src/validate-expressions.ts +++ b/packages/lint/src/validate-expressions.ts @@ -546,14 +546,28 @@ function rulePredicates(rule: AnyRec, path: string): Array<{ label: string; raw: * hide showing for everyone". That is precise for exactly one of them. The * measurement, at both ends of each slot: * - * - **`visibleWhen` — client-only, fail-OPEN, sentence was correct.** The - * server never evaluates a FIELD-level `visibleWhen` at all: + * - **`visibleWhen` — client-only, and since objectui#6010 it fails in TWO + * ways rather than one.** The server half is unchanged and still exactly + * right: it never evaluates a FIELD-level `visibleWhen` at all — * `rule-validator.ts`'s `ConditionalFieldDef` has no such member, and * `hasFieldRules` gates on `requiredWhen || readonlyWhen || * fieldHasOptionVisibility` — the `visibleWhen` it does evaluate is the - * per-OPTION one. So the only verdict is the renderer's, and - * `resolveFieldRuleState` passes `fallback: true` for visibility. Field - * visible to everyone, exactly as the old sentence said. + * per-OPTION one. The RENDERER half moved. `plugin-form`'s + * `sectionFields.ts` (`fromObjectSchema`) copies an object field's ADR-0036 + * `visibleWhen` / `readonlyWhen` / `requiredWhen` straight onto the runtime + * form field, and the SDUI form renderer resolves those through + * `resolveFieldRuleState` **with `predicateScope` bound**. So under a host + * that publishes a scope the predicate does not fault: it RESOLVES, the + * control is hidden in that one form, and the server still returns the value + * to every other reader — a silent enforcement gap. Only where no host + * publishes a scope (the public `/f/:slug` route, every non-form reader) + * does the old sentence still describe what happens: unbound root, fault, + * `fallback: true`, field visible to everyone. + * + * ⛔ This is not a reason to relax {@link FIELD_RULE_BOUND_ROOTS}. The + * verdict is MORE justified than when it was written, not less: trading a + * loud lint error for a gap nobody can see is the one outcome worse than + * the error. Only the causal clause was re-measured. * - **`readonlyWhen` — the two ends fault in OPPOSITE directions, and the * server wins.** Server: `isReadonlyWhenLocked` matches the fault with * `unknownVariableOf` and returns `true` — "the declared lock is not @@ -628,10 +642,18 @@ const FIELD_RULE_SLOT_CONSEQUENCE_GENERIC = /** Axis 1 — see "the consequence, by SLOT" above. Every cell is measured. */ const FIELD_RULE_SLOT_CONSEQUENCE: Record = { visibleWhen: - 'the predicate faults and the renderer falls back to VISIBLE ' + - '(`resolveFieldRuleState` evaluates visibility with `fallback: true`, and no server-side ' + - 'gate evaluates a field-level `visibleWhen` at all), leaving the field the test was meant ' + - 'to hide showing for everyone (#6146)', + 'the predicate no longer merely faults — and BOTH of its outcomes are wrong, in opposite ' + + 'directions. Under a host that publishes a predicate scope the renderer RESOLVES it ' + + '(`sectionFields.ts` copies this object rule onto the runtime form field and ' + + '`resolveFieldRuleState` evaluates it with the host scope bound, objectui#6010) — so the ' + + 'control is hidden in that one form while NO server-side gate evaluates a field-level ' + + '`visibleWhen` at all: the record still carries the value and every other reader still ' + + 'returns it, a SILENT enforcement gap. Where no host publishes a scope (the console public ' + + '`/f/:slug` route, and every non-form reader) the root is unbound, the predicate faults and ' + + 'the renderer falls back to VISIBLE (`resolveFieldRuleState` evaluates visibility with ' + + '`fallback: true`), leaving the field the test was meant to hide showing for everyone ' + + '(#6146). The gap is the WORSE of the two — a visible fail-open gets reported, and a ' + + 'silent one does not', readonlyWhen: 'the predicate faults — and the two ends fault in OPPOSITE directions. The server treats ' + 'the field as LOCKED (`isReadonlyWhenLocked` will not waive a declared lock it could not ' + diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index cdeccbf646..d6c5b1a6b5 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -236,21 +236,25 @@ export const SelectOptionSchema = lazySchema(() => strictObject({ * expresses BOTH cascading/dependent options (`record.country == 'cn'`) AND * role/context gating (`'admin' in current_user.positions`). * - * This scope is WIDER than field-level `visibleWhen`, not the same (#6146): - * options resolve through `resolveCascadingOptions` against the predicate - * scope (ADR-0068 / objectui#2284), while field- and section-level rules go - * through `evalFieldPredicate`, which binds `record` + `previous` + `parent` - * and never `current_user` (objectui#1582). Per-option is the one `*When` - * surface where a `current_user` test actually resolves. When it references - * sibling fields, declare those on the field's `dependsOn` so the form can gate - * and re-evaluate the option list as the parent changes. + * Options resolve through `resolveCascadingOptions` against that scope + * (ADR-0068 / objectui#2284), while field- and section-level rules go through + * `evalFieldPredicate` — a different evaluator, but since objectui#6010 (field) + * and objectui#6110 + #6111 (section) it is handed the same host scope, so + * `current_user` resolves on those surfaces too. What still separates this one + * is ENFORCEMENT, not vocabulary: per-option `visibleWhen` is the only + * VISIBILITY predicate the SERVER also evaluates — the rule validator refuses + * a write of a value whose predicate is false — while a field or section + * predicate is a rendering rule and nothing more. So a user-gated CHOICE + * belongs here; a user-gated FIELD belongs on a permission set. When the + * predicate references sibling fields, declare those on the field's `dependsOn` + * so the form can gate and re-evaluate the option list as the parent changes. * * ⚠️ Client-side hiding is UX, not authorization. When an option is gated for * access-control reasons the server MUST also reject writes of its value (the * rule-validator evaluates the picked value's `visibleWhen`) — hiding it in the * dropdown alone is bypassable. */ - visibleWhen: ExpressionInputSchema.optional().describe("Per-option visibility predicate (CEL) — option is offered only when TRUE (else omitted). Env: the live `record` plus the host predicate scope, which binds `current_user` — wider than field-level visibleWhen, which has no `current_user`. e.g. P`record.country == 'cn'` or P`'admin' in current_user.positions`"), + visibleWhen: ExpressionInputSchema.optional().describe("Per-option visibility predicate (CEL) — option is offered only when TRUE (else omitted). Env: the live `record` plus the host predicate scope, which binds `current_user`. The one VISIBILITY predicate the SERVER also enforces — the rule validator refuses a write of a value whose predicate is false — so a user-gated CHOICE belongs here. e.g. P`record.country == 'cn'` or P`'admin' in current_user.positions`"), })); /** diff --git a/packages/spec/src/shared/editability-boundary.test.ts b/packages/spec/src/shared/editability-boundary.test.ts index 34f695eac4..6e864fefd5 100644 --- a/packages/spec/src/shared/editability-boundary.test.ts +++ b/packages/spec/src/shared/editability-boundary.test.ts @@ -386,10 +386,17 @@ describe('#8201 — an option is offered or withheld, never shown-but-unselectab it('it points at per-option `visibleWhen`, and that predicate really parses HERE', () => { // Prose must not claim what the schema does not honour. Both readings the // prescription advertises are parsed back: a record-dependent predicate and - // the `current_user` one that only this surface binds (ADR-0068). + // a `current_user` one (ADR-0068). ⚠️ The prescription no longer calls this + // "the one `*When` surface that also binds `current_user`" — a form view's + // field and section predicates bind those roots too (objectui#6010 / #6110 + // / #6111), client-side. What is still unique here, and what the + // prescription now rests on, is the clause below it: the rule validator + // refuses the WRITE. Pinned so the retired exclusivity cannot return. const m = unknownKeyMessage(SelectOptionSchema, { ...OPTION, disabled: true }); expect(m).toContain('per-option `visibleWhen`'); expect(m).toContain('`current_user` (ADR-0068)'); + expect(m).not.toContain('the one `*When` surface'); + expect(m).toContain('rule validator refuses a write'); expect(SelectOptionSchema.safeParse({ ...OPTION, visibleWhen: 'record.country == "cn"' }).success).toBe(true); expect(SelectOptionSchema.safeParse({ ...OPTION, visibleWhen: '"admin" in current_user.positions' }).success).toBe(true); }); diff --git a/packages/spec/src/shared/editability-boundary.ts b/packages/spec/src/shared/editability-boundary.ts index 4fde0b49e9..3023ea6fba 100644 --- a/packages/spec/src/shared/editability-boundary.ts +++ b/packages/spec/src/shared/editability-boundary.ts @@ -137,8 +137,8 @@ export const SELECT_OPTION_EDITABILITY_GUIDANCE: KeySetGuidance = { + '(the select and radio widgets treat the FIELD-level state as the single ' + 'authority), so a key here would be metadata the renderer never honours ' + '(ADR-0049). To withdraw ONE option, give it the per-option `visibleWhen` ' - + 'predicate — the one `*When` surface that also binds `current_user` (ADR-0068), ' - + 'so an option can be withheld per record or per role, and the rule validator ' + + 'predicate — it binds `current_user` (ADR-0068), so an option can be withheld ' + + 'per record or per role, and the rule validator ' + 'refuses a write of a value whose predicate is false. To freeze the WHOLE picker, ' + 'write `readonly: true` (or the conditional `readonlyWhen` predicate) on the field ' + 'that owns these options. If a shown-but-unselectable option ever earns a reader, ' diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index ec68b44956..9459266aac 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -2251,13 +2251,48 @@ export const FormSectionSchema = lazySchema(() => strictObject({ * Conditional-visibility predicate (CEL) — the whole section is shown only * when TRUE (ADR-0089, canonical `*When` name). Same per-layer binding root as * {@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). ⛔ No `features.*` either — that one is + * runtime forms, `data` in metadata-editing forms. + * + * ## `current_user` DOES bind here — client-side, and only under a host that + * publishes a scope (objectui#6110 + #6111) + * + * This block said "no `current_user` at section level" (#6146) for as long as + * that held, and it held for TWO reasons, both since discharged. The console + * form renderer now reads the host shell's predicate scope and threads it into + * `isSectionVisible` (objectui#6110), where it used to pass `undefined`; and + * the object-view chain no longer drops the key before a renderer sees it — + * `ObjectForm` / `SplitForm` / `ModalForm` / `DrawerForm` copy the authored + * `visibleWhen` onto the `section-divider` pseudo-field and the SDUI form + * renderer evaluates that pseudo-field's predicate with the same scope bound + * (objectui#6111). So a section predicate resolves `current_user` and its + * ADR-0068 D1 aliases (`user`, `ctx.user`, `os.user`) alongside `record` + + * `previous` + `parent`. The text above is a re-measurement of a claim that + * was true when it was written, not a relaxation of anything. + * + * The two limits {@link FormFieldSchema.visibleWhen} records apply here + * verbatim — 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 section `visibleWhen` — nor a field + * one: the write path evaluates field `readonlyWhen` / `requiredWhen` and + * per-option `visibleWhen`, and that is the whole list. A role test written + * here hides a group of controls and protects no data — the record still + * carries every value in the section and every other read surface still + * returns them. 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 section the test was meant to + * hide is shown to everyone. The authed standalone route (`/forms/:name`) + * publishes the session principal and binds normally. + * + * ⛔ 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. No `features.*` on ANY form-view predicate — refused at parse (ruled 2026-08-27): unbound on the standalone form routes, where 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. `current_user` (and the ADR-0068 aliases `user` / `ctx.user` / `os.user`) resolves here too since objectui#6110 + #6111 — CLIENT-SIDE only: nothing server-side evaluates a form-view section `visibleWhen`, so a role test here hides the controls 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): 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([ From ca185840c1b398f13616b757ec454645b795279d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 02:05:00 +0000 Subject: [PATCH 2/4] docs(spec): regenerate reference docs for the re-measured binding text (#12914) --- content/docs/references/data/field.mdx | 4 ++-- content/docs/references/ui/view.mdx | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/content/docs/references/data/field.mdx b/content/docs/references/data/field.mdx index 2117768f19..fc5369a127 100644 --- a/content/docs/references/data/field.mdx +++ b/content/docs/references/data/field.mdx @@ -193,7 +193,7 @@ const result = CurrencyConfigSchema.parse(data); | **value** | `string` | ✅ | Stored value (lowercase machine identifier) | | **color** | `string` | optional | Color code for badges/charts | | **default** | `boolean` | optional | Is default option | -| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Per-option visibility predicate (CEL) — option is offered only when TRUE (else omitted). Env: the live `record` plus the host predicate scope, which binds `current_user` — wider than field-level visibleWhen, which has no `current_user`. e.g. P`record.country == 'cn'` or P`'admin' in current_user.positions` | +| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Per-option visibility predicate (CEL) — option is offered only when TRUE (else omitted). Env: the live `record` plus the host predicate scope, which binds `current_user`. The one VISIBILITY predicate the SERVER also enforces — the rule validator refuses a write of a value whose predicate is false — so a user-gated CHOICE belongs here. e.g. P`record.country == 'cn'` or P`'admin' in current_user.positions` | ### Nested Shape: `Field.inlineColumns[number]` @@ -404,7 +404,7 @@ Allowed Values: `phone`, `id_card`, `bank_account`, `email`, `name` | **value** | `string` | ✅ | Stored value (lowercase machine identifier) | | **color** | `string` | optional | Color code for badges/charts | | **default** | `boolean` | optional | Is default option | -| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Per-option visibility predicate (CEL) — option is offered only when TRUE (else omitted). Env: the live `record` plus the host predicate scope, which binds `current_user` — wider than field-level visibleWhen, which has no `current_user`. e.g. P`record.country == 'cn'` or P`'admin' in current_user.positions` | +| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Per-option visibility predicate (CEL) — option is offered only when TRUE (else omitted). Env: the live `record` plus the host predicate scope, which binds `current_user`. The one VISIBILITY predicate the SERVER also enforces — the rule validator refuses a write of a value whose predicate is false — so a user-gated CHOICE belongs here. e.g. P`record.country == 'cn'` or P`'admin' in current_user.positions` | --- diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index 050b332374..d2b2aacb29 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -226,7 +226,7 @@ Form-view select option — the object-field option shape minus the per-option ` | **label** | `string` | ✅ | Display label (human-readable, any case allowed) | | **value** | `string` | ✅ | Stored value (lowercase machine identifier) | | **color** | `string` | optional | Color code for badges/charts | -| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Per-option visibility predicate (CEL) — option is offered only when TRUE (else omitted). Env: the live `record` plus the host predicate scope, which binds `current_user` — wider than field-level visibleWhen, which has no `current_user`. e.g. P`record.country == 'cn'` or P`'admin' in current_user.positions` | +| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Per-option visibility predicate (CEL) — option is offered only when TRUE (else omitted). Env: the live `record` plus the host predicate scope, which binds `current_user`. The one VISIBILITY predicate the SERVER also enforces — the rule validator refuses a write of a value whose predicate is false — so a user-gated CHOICE belongs here. e.g. P`record.country == 'cn'` or P`'admin' in current_user.positions` | ### Nested Shape: `FormField.publicPicker` @@ -288,7 +288,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. No `features.*` on ANY form-view predicate — refused at parse (ruled 2026-08-27): unbound on the standalone form routes, where 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. `current_user` (and the ADR-0068 aliases `user` / `ctx.user` / `os.user`) resolves here too since objectui#6110 + #6111 — CLIENT-SIDE only: nothing server-side evaluates a form-view section `visibleWhen`, so a role test here hides the controls 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): 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'. | @@ -342,7 +342,7 @@ Form-view select option — the object-field option shape minus the per-option ` | **label** | `string` | ✅ | Display label (human-readable, any case allowed) | | **value** | `string` | ✅ | Stored value (lowercase machine identifier) | | **color** | `string` | optional | Color code for badges/charts | -| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Per-option visibility predicate (CEL) — option is offered only when TRUE (else omitted). Env: the live `record` plus the host predicate scope, which binds `current_user` — wider than field-level visibleWhen, which has no `current_user`. e.g. P`record.country == 'cn'` or P`'admin' in current_user.positions` | +| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Per-option visibility predicate (CEL) — option is offered only when TRUE (else omitted). Env: the live `record` plus the host predicate scope, which binds `current_user`. The one VISIBILITY predicate the SERVER also enforces — the rule validator refuses a write of a value whose predicate is false — so a user-gated CHOICE belongs here. e.g. P`record.country == 'cn'` or P`'admin' in current_user.positions` | --- @@ -418,7 +418,7 @@ Form-view select option — the object-field option shape minus the per-option ` | **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. No `features.*` on ANY form-view predicate — refused at parse (ruled 2026-08-27): unbound on the standalone form routes, where 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. `current_user` (and the ADR-0068 aliases `user` / `ctx.user` / `os.user`) resolves here too since objectui#6110 + #6111 — CLIENT-SIDE only: nothing server-side evaluates a form-view section `visibleWhen`, so a role test here hides the controls 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): 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'. | @@ -433,7 +433,7 @@ Form-view select option — the object-field option shape minus the per-option ` | **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. No `features.*` on ANY form-view predicate — refused at parse (ruled 2026-08-27): unbound on the standalone form routes, where 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. `current_user` (and the ADR-0068 aliases `user` / `ctx.user` / `os.user`) resolves here too since objectui#6110 + #6111 — CLIENT-SIDE only: nothing server-side evaluates a form-view section `visibleWhen`, so a role test here hides the controls 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): 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'. | From c3b954d74544d93dbf08ff764a3b2d7dfc09b678 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 02:24:28 +0000 Subject: [PATCH 3/4] fix(lint): spell sectionFields without .ts in the message string (#5017 receiver scan) --- packages/lint/src/validate-expressions.test.ts | 6 +++++- packages/lint/src/validate-expressions.ts | 7 ++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/lint/src/validate-expressions.test.ts b/packages/lint/src/validate-expressions.test.ts index 11cb9fb22c..1a5c4291d4 100644 --- a/packages/lint/src/validate-expressions.test.ts +++ b/packages/lint/src/validate-expressions.test.ts @@ -1205,7 +1205,11 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => { const m = messageFor('visibleWhen'); // it RESOLVES rather than faulting, and the mechanism is named expect(m).toMatch(/RESOLVES/); - expect(m).toMatch(/sectionFields\.ts/); + // Spelled without the `.ts` extension on purpose — see the note at the + // cell: a `.ts` inside a message string registers `` as a + // read receiver in #5017's scan, and it went red on the first run. + expect(m).toMatch(/`sectionFields` copies this object rule/); + expect(m).not.toMatch(/sectionFields\.ts/); expect(m).toMatch(/objectui#6010/); // …and the consequence is stated as the gap, not as a fail-open expect(m).toMatch(/SILENT enforcement gap/); diff --git a/packages/lint/src/validate-expressions.ts b/packages/lint/src/validate-expressions.ts index df1efa3909..5546233821 100644 --- a/packages/lint/src/validate-expressions.ts +++ b/packages/lint/src/validate-expressions.ts @@ -644,7 +644,12 @@ const FIELD_RULE_SLOT_CONSEQUENCE: Record = { visibleWhen: 'the predicate no longer merely faults — and BOTH of its outcomes are wrong, in opposite ' + 'directions. Under a host that publishes a predicate scope the renderer RESOLVES it ' + - '(`sectionFields.ts` copies this object rule onto the runtime form field and ' + + // `sectionFields` is spelled WITHOUT its `.ts` extension for the same + // reason the `*.form` spelling below is: this is a STRING literal, and + // #5017's receiver scan strips comments but not strings, so `sectionFields.ts` + // inside the message registers `sectionFields` as a read receiver of this + // rule. Measured — it went red on the first run, exactly as the sibling did. + '(plugin-form\'s `sectionFields` copies this object rule onto the runtime form field and ' + '`resolveFieldRuleState` evaluates it with the host scope bound, objectui#6010) — so the ' + 'control is hidden in that one form while NO server-side gate evaluates a field-level ' + '`visibleWhen` at all: the record still carries the value and every other reader still ' + From ce3a9732b1a56026d825c51a78762934a5b9d62e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 02:28:54 +0000 Subject: [PATCH 4/4] docs(spec): strip internal issue ids from the section describe() (check:doc-authoring) --- content/docs/references/ui/view.mdx | 6 +++--- packages/spec/src/ui/view.zod.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index d2b2aacb29..4e0c8b2343 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -288,7 +288,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. `current_user` (and the ADR-0068 aliases `user` / `ctx.user` / `os.user`) resolves here too since objectui#6110 + #6111 — CLIENT-SIDE only: nothing server-side evaluates a form-view section `visibleWhen`, so a role test here hides the controls 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): unbound on the standalone form routes, where 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. `current_user` (and the ADR-0068 aliases `user` / `ctx.user` / `os.user`) resolves here too — CLIENT-SIDE only: nothing server-side evaluates a form-view section `visibleWhen`, so a role test here hides the controls 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): 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'. | @@ -418,7 +418,7 @@ Form-view select option — the object-field option shape minus the per-option ` | **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. `current_user` (and the ADR-0068 aliases `user` / `ctx.user` / `os.user`) resolves here too since objectui#6110 + #6111 — CLIENT-SIDE only: nothing server-side evaluates a form-view section `visibleWhen`, so a role test here hides the controls 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): unbound on the standalone form routes, where 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. `current_user` (and the ADR-0068 aliases `user` / `ctx.user` / `os.user`) resolves here too — CLIENT-SIDE only: nothing server-side evaluates a form-view section `visibleWhen`, so a role test here hides the controls 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): 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'. | @@ -433,7 +433,7 @@ Form-view select option — the object-field option shape minus the per-option ` | **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. `current_user` (and the ADR-0068 aliases `user` / `ctx.user` / `os.user`) resolves here too since objectui#6110 + #6111 — CLIENT-SIDE only: nothing server-side evaluates a form-view section `visibleWhen`, so a role test here hides the controls 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): unbound on the standalone form routes, where 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. `current_user` (and the ADR-0068 aliases `user` / `ctx.user` / `os.user`) resolves here too — CLIENT-SIDE only: nothing server-side evaluates a form-view section `visibleWhen`, so a role test here hides the controls 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): 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/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 9459266aac..59b9d67419 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -2292,7 +2292,7 @@ export const FormSectionSchema = lazySchema(() => strictObject({ * 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. `current_user` (and the ADR-0068 aliases `user` / `ctx.user` / `os.user`) resolves here too since objectui#6110 + #6111 — CLIENT-SIDE only: nothing server-side evaluates a form-view section `visibleWhen`, so a role test here hides the controls 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): unbound on the standalone form routes, where 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. `current_user` (and the ADR-0068 aliases `user` / `ctx.user` / `os.user`) resolves here too — CLIENT-SIDE only: nothing server-side evaluates a form-view section `visibleWhen`, so a role test here hides the controls 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): 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([