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
54 changes: 54 additions & 0 deletions .changeset/section-visiblewhen-binds-current-user-prose.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
---
'@objectstack/lint': patch
---

Retire the "no `current_user` at section level" claim from the three prose sites the
re-measurement left unswept

A form-view **section** `visibleWhen` binds `current_user` today. That was measured and
landed for the schema text and the field-rule lint message, but three hand-written sites
still taught the retired claim, so an author reading the docs or hitting the gate was told
the opposite of what the platform does. Text only — no schema, no verdict and no runtime
behaviour moves.

Re-verified at source in `objectui` before trimming anything, because a prose trim applied
to a claim someone had since fixed would silently regress their work:

- `apps/console/src/components/FormPage.tsx` threads the host shell's scope into
`isSectionVisible`, which forwards it to `evalFieldPredicate` (objectui#6110).
- `packages/plugin-form/src/ObjectForm.tsx` copies an authored section `visibleWhen` onto
the `section-divider` pseudo-field the renderer evaluates with that scope bound
(objectui#6111); `SplitForm` / `ModalForm` / `DrawerForm` carry the same line.

The three sites:

- `content/docs/ui/views.mdx` listed *"section-level predicates (objectui#6111)"* as a
surface that still evaluates the predicate unbound — naming as evidence the very PR that
bound it. The same sentence also listed `/forms/:name` as unbound; that route renders
inside `InternalFormRoute`, which publishes the session principal and binds normally, so
the public `/f/:slug` route is now the only unbound surface named.
- `content/docs/protocol/objectui/layout-dsl.mdx` carried the claim four times — a code
comment, the binding-root table row, the paragraph under it, and the "two limits" prose —
where the card recorded three. All four are re-measured together.
- `packages/lint/scripts/check-doc-formula-expressions.mjs`'s field-rule epilogue still said
a faulting field-level `visibleWhen` is simply fail-OPEN. Under a host that publishes a
scope the predicate RESOLVES instead: the control is hidden in that one form while no
server-side gate evaluates a field-level `visibleWhen` at all, so every other reader still
returns the value — a silent enforcement gap, and the worse of the two outcomes. The
fault-open leg is kept rather than replaced, because it is still what happens wherever no
host publishes a scope. The verdict is untouched and the message says why it is now *more*
justified.

Both replacement texts carry the two qualifications the retired claim's correction needs, so
"sections bind `current_user`" cannot be read as an authorization primitive: the binding is
**client-side only** (nothing on the write path evaluates a form-view field or section
`visibleWhen` — it evaluates field `readonlyWhen` / `requiredWhen` and per-option
`visibleWhen`, and that is the whole list), and **the scope belongs to the host**, so it is
empty on the public `/f/:slug` route and the predicate faults open there.

The epilogue is a plain string nobody else read — deleting the re-measured clause broke no
assertion and turned no gate red, which is exactly how the stale claim outlived its sibling.
It is now pinned by a `--self-test` case that scopes itself to the real epilogue (so it
cannot satisfy itself from its own literal) and asserts both outcomes plus the surviving
fault-open leg. Proven capable of failing by ablation: reverting the clause on disk turns
the self-test red, and restoring returns it to green.
44 changes: 28 additions & 16 deletions content/docs/protocol/objectui/layout-dsl.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -860,8 +860,8 @@ expression evaluates truthy.
// e.g. on a PageComponent — `record` and `current_user` are both bound:
visibleWhen: "record.account_type == 'premium'"

// e.g. on a view FormField — `record`, `previous` and (since objectui#6010)
// `current_user` are bound; on a FormSection, `current_user` is not:
// e.g. on a view FormField or FormSection — `record`, `previous` and
// `current_user` are all bound (objectui#6010, then #6110 + #6111):
visibleWhen: "record.status != 'closed'"
```

Expand All @@ -871,17 +871,29 @@ The predicate's **binding root** is set by the layer, not the key:
|---|---|
| Page components (`*.page.ts`) | `record` + `current_user` (plus `page.<var>`) |
| Runtime record form **fields** (`*.view.ts`) | `record` + `previous` + `current_user` (objectui#6010) |
| Runtime record form **sections** (`*.view.ts`) | `record` + `previous` — **not** `current_user` |
| Runtime record form **sections** (`*.view.ts`) | `record` + `previous` + `current_user` (objectui#6110 + #6111) |
| Metadata-editing forms (`*.form.ts`) | `data` — the row under edit |

The two form rows were one row until objectui#6010 bound the host predicate scope
on the form renderer's authored-predicate call sites — only the field half moved.
Two measured caveats travel with them: a **field** predicate is still evaluated
with `current_user` unbound on the console's standalone form routes
(`/forms/:name`, and the public `/f/:slug`), which run a second form renderer
(objectui#6110); and an authored **section** predicate is read by that second
renderer alone, because the object-view chain drops the key before any evaluator
sees it (objectui#6111).
on the SDUI form renderer's authored-predicate call sites — at that point only the
field half moved. The section half has since caught up: objectui#6110 threads the
host shell's scope into the console form renderer's `isSectionVisible`, where it
used to pass `undefined`, and objectui#6111 stopped the object-view chain dropping
the key before an evaluator sees it — `ObjectForm` / `SplitForm` / `ModalForm` /
`DrawerForm` copy an authored section `visibleWhen` onto the `section-divider`
pseudo-field, whose predicate the renderer evaluates with the same scope bound.

Two measured caveats travel with **both** rows, and both fail in the direction an
author will not notice. **First, the scope belongs to the host, so it is empty
wherever no host publishes one.** The public `/f/:slug` route 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
*visible*. The authed `/forms/:name` route renders inside a shell that publishes
the session principal and binds normally. **Second, the binding is client-side
only.** Nothing on the write path evaluates a form-view field or section
`visibleWhen` — it evaluates field `readonlyWhen` / `requiredWhen` and per-option
`visibleWhen`, and that is the whole list — so a `current_user.positions` test
written here hides controls and protects no data.

The legacy spellings `visibleOn` (view) and `visibility` (page) are `@deprecated`
aliases: still accepted and folded into `visibleWhen` at parse time, so existing
Expand Down Expand Up @@ -929,12 +941,12 @@ deliberate and documented (objectui#2661); the envelope is what makes one
predicate text mean one thing on every surface.

Two limits come with it. **First, the binding is per surface, not per key.** A
view form **field** predicate binds this scope since objectui#6010; a form
**section** predicate does not (objectui#6111), and neither does an object-level
field rule (`Field.*({ visibleWhen })`, ADR-0036) — that one is evaluated by the
server as well as by the client, binds `record` (plus `previous`, `parent`) only,
and naming `current_user` there is refused by `@objectstack/lint` at build time
rather than faulting at runtime. **Second, `visibleWhen` is presentation, not
view form **field** predicate binds this scope since objectui#6010 and a form
**section** predicate since objectui#6110 + #6111; an object-level field rule
(`Field.*({ visibleWhen })`, ADR-0036) is the exception — that one is evaluated by
the server as well as by the client, its write-path evaluator binds `record` (plus
`previous`, `parent`) only, and naming `current_user` there is refused by
`@objectstack/lint` at build time rather than faulting at runtime. **Second, `visibleWhen` is presentation, not
access control**: it decides what a client draws from data it already holds. To
stop someone from *reading* something,
use the permission layer — [field-level security](/docs/permissions/field-level-security)
Expand Down
2 changes: 1 addition & 1 deletion content/docs/ui/views.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -428,7 +428,7 @@ 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). No `features.*` on any form-view predicate — refused at parse, see the boundary note below. (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). Form **sections** bind the same scope since objectui#6110 + objectui#6111. One surface still evaluates it unbound, where the predicate faults open: the public `/f/:slug` route, mounted outside any provider on purpose because an anonymous visitor has no principal — the authed `/forms/:name` publishes the session principal and binds normally. ⚠️ Either way the binding is **client-side only**: nothing on the write path evaluates a form-view field or section `visibleWhen`, so a `current_user.positions` test here hides controls and protects no data — declare permission-set [field-level security](/docs/permissions/field-level-security) for that. No `features.*` on any form-view predicate — refused at parse, see the boundary note below. (was `visibleOn`, ADR-0089) |

<Callout type="warn">
**Form-view predicates may not name the `features.*` scope root** (ruled 2026-08-27, objectui#6262).
Expand Down
55 changes: 50 additions & 5 deletions packages/lint/scripts/check-doc-formula-expressions.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1860,6 +1860,36 @@ const FIELD_RULE_REPORT_SELF_TEST_CASES = [
return !/\bthis (?:site|fragment|example) (?:is|documents|describes) (?:a|an|the)\b/i.test(out);
},
},
{
// The epilogue an author actually reads at refusal time. It is a plain
// string nobody else reads — deleting the re-measured clause breaks no
// other assertion here and no gate anywhere goes red, which is exactly how
// the stale causal claim outlived its sibling in `validate-expressions.ts`
// after that one was corrected. Pinned so a revert cannot hide behind the
// surviving fault-open sentence, and so the two texts cannot drift apart
// silently again.
name: 'REPORT — the field-rule epilogue names BOTH outcomes, not just the fault-open one',
holds: () => {
const self = readFileSync(fileURLToPath(import.meta.url), 'utf8');
// Scoped to the real epilogue. `lastIndexOf` skips this case's own
// literal above, so the assertion cannot satisfy itself.
const start = self.lastIndexOf('if (ruleViolations.length > 0) {');
const epilogue = self.slice(start, self.indexOf('// ── Surface 2', start));
return /RESOLVES/.test(epilogue)
// the mechanism is named, and named without a file extension (#5017)
&& /`sectionFields`/.test(epilogue)
&& !/sectionFields\.[a-z]+/.test(epilogue)
&& /objectui#6010/.test(epilogue)
// the outcome under a bound scope, stated as the gap it is
&& /SILENT/.test(epilogue)
&& /enforcement gap/.test(epilogue)
&& /WORSE of the two/.test(epilogue)
// ⛔ and the fault-open leg SURVIVES rather than being replaced: it is
// still what happens wherever no host publishes a scope.
&& /falls back/.test(epilogue)
&& /VISIBLE/.test(epilogue);
},
},
{
name: 'REPORT — the GREEN summary path still PRINTS the skip list, not merely its count',
holds: () => {
Expand Down Expand Up @@ -2015,11 +2045,26 @@ if (ruleViolations.length > 0) {
const skipReport = renderFieldRuleSkips(ruleSkips);
if (skipReport) console.error(`${skipReport}\n`);
console.error(
` A field-level \`visibleWhen\` that faults is fail-OPEN — the renderer falls back to VISIBLE —\n` +
` so a wrong example does not merely not work, it shows the thing it was written to hide to\n` +
` everyone who copies it. The verdict above is \`@objectstack/formula\`'s \`validateExpression\`\n` +
` plus \`@objectstack/lint\`'s \`fieldRuleRootIssue\` — the same two the metadata walk applies to\n` +
` this slot, imported rather than restated.`,
` A wrong field-level \`visibleWhen\` does not merely not work, and since objectui#6010 it\n` +
` fails in TWO directions rather than one. Under a host that publishes a predicate scope the\n` +
// `sectionFields` is spelled WITHOUT a source-file extension on purpose —
// the same reason the sibling message in `validate-expressions.ts` carries:
// this is a STRING literal, and #5017's receiver scan strips comments but
// not strings, so naming the module with its extension inside the text
// would register `sectionFields` as a read receiver of this gate. The
// self-test below pins the bare spelling.
` renderer RESOLVES it (plugin-form's \`sectionFields\` copies this object rule onto the runtime\n` +
` form field and \`resolveFieldRuleState\` evaluates it with that scope bound) — the control is\n` +
` hidden in that one form while NO server-side gate evaluates a field-level \`visibleWhen\` at\n` +
` all, so the record still carries the value and every other reader still returns it: a SILENT\n` +
` enforcement gap. Where no host publishes a scope (the console's public \`/f/:slug\` route, and\n` +
` every non-form reader) the root is unbound, the predicate faults and the renderer falls back\n` +
` to VISIBLE, showing the thing it was written to hide to everyone who copies it. The gap is\n` +
` the WORSE of the two — a visible fail-open gets reported, a silent one does not — so the\n` +
` verdict below is more justified than when it was written, not less. That verdict is\n` +
` \`@objectstack/formula\`'s \`validateExpression\` plus \`@objectstack/lint\`'s\n` +
` \`fieldRuleRootIssue\` — the same two the metadata walk applies to this slot, imported rather\n` +
` than restated.`,
);
process.exit(1);
}
Expand Down
Loading