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
33 changes: 33 additions & 0 deletions .changeset/four-stale-current-user-binding-sites.md
Original file line numberDiff line numberDiff line change
@@ -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.
4 changes: 2 additions & 2 deletions content/docs/references/data/field.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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]`

Expand DownExpand Up@@ -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` |


---
Expand Down
10 changes: 5 additions & 5 deletions content/docs/references/ui/view.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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`

Expand DownExpand Up@@ -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 — 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'. |
Expand DownExpand Up@@ -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` |


---
Expand DownExpand Up@@ -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 — 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'. |
Expand All@@ -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 — 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'. |
Expand Down
50 changes: 47 additions & 3 deletions packages/lint/src/validate-expressions.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -1176,12 +1189,43 @@ 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/);
// Spelled without the `.ts` extension on purpose — see the note at the
// cell: a `<name>.ts` inside a message string registers `<name>` 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/);
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/);
Expand Down
Loading
Loading