Skip to content

Commit b2dea86

Browse files
docs(ui,protocol,lint): a form SECTION visibleWhen binds current_user — retire the claim from the three prose sites #13074 left unswept (#13532)
* docs(ui,protocol,lint): retire the 'no current_user at section level' claim from the three prose sites #13074 left unswept * docs(ui,protocol,lint): re-measure the section-level current_user claim; pin the gate epilogue --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9dac1ae commit b2dea86

4 files changed

Lines changed: 133 additions & 22 deletions

File tree

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
---
2+
'@objectstack/lint': patch
3+
---
4+
5+
Retire the "no `current_user` at section level" claim from the three prose sites the
6+
re-measurement left unswept
7+
8+
A form-view **section** `visibleWhen` binds `current_user` today. That was measured and
9+
landed for the schema text and the field-rule lint message, but three hand-written sites
10+
still taught the retired claim, so an author reading the docs or hitting the gate was told
11+
the opposite of what the platform does. Text only — no schema, no verdict and no runtime
12+
behaviour moves.
13+
14+
Re-verified at source in `objectui` before trimming anything, because a prose trim applied
15+
to a claim someone had since fixed would silently regress their work:
16+
17+
- `apps/console/src/components/FormPage.tsx` threads the host shell's scope into
18+
`isSectionVisible`, which forwards it to `evalFieldPredicate` (objectui#6110).
19+
- `packages/plugin-form/src/ObjectForm.tsx` copies an authored section `visibleWhen` onto
20+
the `section-divider` pseudo-field the renderer evaluates with that scope bound
21+
(objectui#6111); `SplitForm` / `ModalForm` / `DrawerForm` carry the same line.
22+
23+
The three sites:
24+
25+
- `content/docs/ui/views.mdx` listed *"section-level predicates (objectui#6111)"* as a
26+
surface that still evaluates the predicate unbound — naming as evidence the very PR that
27+
bound it. The same sentence also listed `/forms/:name` as unbound; that route renders
28+
inside `InternalFormRoute`, which publishes the session principal and binds normally, so
29+
the public `/f/:slug` route is now the only unbound surface named.
30+
- `content/docs/protocol/objectui/layout-dsl.mdx` carried the claim four times — a code
31+
comment, the binding-root table row, the paragraph under it, and the "two limits" prose —
32+
where the card recorded three. All four are re-measured together.
33+
- `packages/lint/scripts/check-doc-formula-expressions.mjs`'s field-rule epilogue still said
34+
a faulting field-level `visibleWhen` is simply fail-OPEN. Under a host that publishes a
35+
scope the predicate RESOLVES instead: the control is hidden in that one form while no
36+
server-side gate evaluates a field-level `visibleWhen` at all, so every other reader still
37+
returns the value — a silent enforcement gap, and the worse of the two outcomes. The
38+
fault-open leg is kept rather than replaced, because it is still what happens wherever no
39+
host publishes a scope. The verdict is untouched and the message says why it is now *more*
40+
justified.
41+
42+
Both replacement texts carry the two qualifications the retired claim's correction needs, so
43+
"sections bind `current_user`" cannot be read as an authorization primitive: the binding is
44+
**client-side only** (nothing on the write path evaluates a form-view field or section
45+
`visibleWhen` — it evaluates field `readonlyWhen` / `requiredWhen` and per-option
46+
`visibleWhen`, and that is the whole list), and **the scope belongs to the host**, so it is
47+
empty on the public `/f/:slug` route and the predicate faults open there.
48+
49+
The epilogue is a plain string nobody else read — deleting the re-measured clause broke no
50+
assertion and turned no gate red, which is exactly how the stale claim outlived its sibling.
51+
It is now pinned by a `--self-test` case that scopes itself to the real epilogue (so it
52+
cannot satisfy itself from its own literal) and asserts both outcomes plus the surviving
53+
fault-open leg. Proven capable of failing by ablation: reverting the clause on disk turns
54+
the self-test red, and restoring returns it to green.

content/docs/protocol/objectui/layout-dsl.mdx

Lines changed: 28 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -860,8 +860,8 @@ expression evaluates truthy.
860860
// e.g. on a PageComponent — `record` and `current_user` are both bound:
861861
visibleWhen: "record.account_type == 'premium'"
862862

863-
// e.g. on a view FormField — `record`, `previous` and (since objectui#6010)
864-
// `current_user` are bound; on a FormSection, `current_user` is not:
863+
// e.g. on a view FormField or FormSection — `record`, `previous` and
864+
// `current_user` are all bound (objectui#6010, then #6110 + #6111):
865865
visibleWhen: "record.status != 'closed'"
866866
```
867867
@@ -871,17 +871,29 @@ The predicate's **binding root** is set by the layer, not the key:
871871
|---|---|
872872
| Page components (`*.page.ts`) | `record` + `current_user` (plus `page.<var>`) |
873873
| Runtime record form **fields** (`*.view.ts`) | `record` + `previous` + `current_user` (objectui#6010) |
874-
| Runtime record form **sections** (`*.view.ts`) | `record` + `previous` — **not** `current_user` |
874+
| Runtime record form **sections** (`*.view.ts`) | `record` + `previous` + `current_user` (objectui#6110 + #6111) |
875875
| Metadata-editing forms (`*.form.ts`) | `data` — the row under edit |
876876

877877
The two form rows were one row until objectui#6010 bound the host predicate scope
878-
on the form renderer's authored-predicate call sites — only the field half moved.
879-
Two measured caveats travel with them: a **field** predicate is still evaluated
880-
with `current_user` unbound on the console's standalone form routes
881-
(`/forms/:name`, and the public `/f/:slug`), which run a second form renderer
882-
(objectui#6110); and an authored **section** predicate is read by that second
883-
renderer alone, because the object-view chain drops the key before any evaluator
884-
sees it (objectui#6111).
878+
on the SDUI form renderer's authored-predicate call sites — at that point only the
879+
field half moved. The section half has since caught up: objectui#6110 threads the
880+
host shell's scope into the console form renderer's `isSectionVisible`, where it
881+
used to pass `undefined`, and objectui#6111 stopped the object-view chain dropping
882+
the key before an evaluator sees it — `ObjectForm` / `SplitForm` / `ModalForm` /
883+
`DrawerForm` copy an authored section `visibleWhen` onto the `section-divider`
884+
pseudo-field, whose predicate the renderer evaluates with the same scope bound.
885+
886+
Two measured caveats travel with **both** rows, and both fail in the direction an
887+
author will not notice. **First, the scope belongs to the host, so it is empty
888+
wherever no host publishes one.** The public `/f/:slug` route is mounted outside
889+
any provider deliberately — an anonymous visitor has no principal — so
890+
`current_user` is unbound there, the predicate faults, and visibility's fallback is
891+
*visible*. The authed `/forms/:name` route renders inside a shell that publishes
892+
the session principal and binds normally. **Second, the binding is client-side
893+
only.** Nothing on the write path evaluates a form-view field or section
894+
`visibleWhen` — it evaluates field `readonlyWhen` / `requiredWhen` and per-option
895+
`visibleWhen`, and that is the whole list — so a `current_user.positions` test
896+
written here hides controls and protects no data.
885897

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

931943
Two limits come with it. **First, the binding is per surface, not per key.** A
932-
view form **field** predicate binds this scope since objectui#6010; a form
933-
**section** predicate does not (objectui#6111), and neither does an object-level
934-
field rule (`Field.*({ visibleWhen })`, ADR-0036) — that one is evaluated by the
935-
server as well as by the client, binds `record` (plus `previous`, `parent`) only,
936-
and naming `current_user` there is refused by `@objectstack/lint` at build time
937-
rather than faulting at runtime. **Second, `visibleWhen` is presentation, not
944+
view form **field** predicate binds this scope since objectui#6010 and a form
945+
**section** predicate since objectui#6110 + #6111; an object-level field rule
946+
(`Field.*({ visibleWhen })`, ADR-0036) is the exception — that one is evaluated by
947+
the server as well as by the client, its write-path evaluator binds `record` (plus
948+
`previous`, `parent`) only, and naming `current_user` there is refused by
949+
`@objectstack/lint` at build time rather than faulting at runtime. **Second, `visibleWhen` is presentation, not
938950
access control**: it decides what a client draws from data it already holds. To
939951
stop someone from *reading* something,
940952
use the permission layer — [field-level security](/docs/permissions/field-level-security)

content/docs/ui/views.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -428,7 +428,7 @@ fields: [
428428
| `colSpan` | `1-4` | Legacy absolute column spanprefer `span` |
429429
| `widget` | `string` | Custom widget/component name |
430430
| `dependsOn` | `string` | Parent field for cascading |
431-
| `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) |
431+
| `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) |
432432

433433
<Callout type="warn">
434434
**Form-view predicates may not name the `features.*` scope root** (ruled 2026-08-27, objectui#6262).

packages/lint/scripts/check-doc-formula-expressions.mjs

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1860,6 +1860,36 @@ const FIELD_RULE_REPORT_SELF_TEST_CASES = [
18601860
return !/\bthis (?:site|fragment|example) (?:is|documents|describes) (?:a|an|the)\b/i.test(out);
18611861
},
18621862
},
1863+
{
1864+
// The epilogue an author actually reads at refusal time. It is a plain
1865+
// string nobody else reads — deleting the re-measured clause breaks no
1866+
// other assertion here and no gate anywhere goes red, which is exactly how
1867+
// the stale causal claim outlived its sibling in `validate-expressions.ts`
1868+
// after that one was corrected. Pinned so a revert cannot hide behind the
1869+
// surviving fault-open sentence, and so the two texts cannot drift apart
1870+
// silently again.
1871+
name: 'REPORT — the field-rule epilogue names BOTH outcomes, not just the fault-open one',
1872+
holds: () => {
1873+
const self = readFileSync(fileURLToPath(import.meta.url), 'utf8');
1874+
// Scoped to the real epilogue. `lastIndexOf` skips this case's own
1875+
// literal above, so the assertion cannot satisfy itself.
1876+
const start = self.lastIndexOf('if (ruleViolations.length > 0) {');
1877+
const epilogue = self.slice(start, self.indexOf('// ── Surface 2', start));
1878+
return /RESOLVES/.test(epilogue)
1879+
// the mechanism is named, and named without a file extension (#5017)
1880+
&& /`sectionFields`/.test(epilogue)
1881+
&& !/sectionFields\.[a-z]+/.test(epilogue)
1882+
&& /objectui#6010/.test(epilogue)
1883+
// the outcome under a bound scope, stated as the gap it is
1884+
&& /SILENT/.test(epilogue)
1885+
&& /enforcement gap/.test(epilogue)
1886+
&& /WORSE of the two/.test(epilogue)
1887+
// ⛔ and the fault-open leg SURVIVES rather than being replaced: it is
1888+
// still what happens wherever no host publishes a scope.
1889+
&& /falls back/.test(epilogue)
1890+
&& /VISIBLE/.test(epilogue);
1891+
},
1892+
},
18631893
{
18641894
name: 'REPORT — the GREEN summary path still PRINTS the skip list, not merely its count',
18651895
holds: () => {
@@ -2015,11 +2045,26 @@ if (ruleViolations.length > 0) {
20152045
const skipReport = renderFieldRuleSkips(ruleSkips);
20162046
if (skipReport) console.error(`${skipReport}\n`);
20172047
console.error(
2018-
` A field-level \`visibleWhen\` that faults is fail-OPEN — the renderer falls back to VISIBLE —\n` +
2019-
` so a wrong example does not merely not work, it shows the thing it was written to hide to\n` +
2020-
` everyone who copies it. The verdict above is \`@objectstack/formula\`'s \`validateExpression\`\n` +
2021-
` plus \`@objectstack/lint\`'s \`fieldRuleRootIssue\` — the same two the metadata walk applies to\n` +
2022-
` this slot, imported rather than restated.`,
2048+
` A wrong field-level \`visibleWhen\` does not merely not work, and since objectui#6010 it\n` +
2049+
` fails in TWO directions rather than one. Under a host that publishes a predicate scope the\n` +
2050+
// `sectionFields` is spelled WITHOUT a source-file extension on purpose —
2051+
// the same reason the sibling message in `validate-expressions.ts` carries:
2052+
// this is a STRING literal, and #5017's receiver scan strips comments but
2053+
// not strings, so naming the module with its extension inside the text
2054+
// would register `sectionFields` as a read receiver of this gate. The
2055+
// self-test below pins the bare spelling.
2056+
` renderer RESOLVES it (plugin-form's \`sectionFields\` copies this object rule onto the runtime\n` +
2057+
` form field and \`resolveFieldRuleState\` evaluates it with that scope bound) — the control is\n` +
2058+
` hidden in that one form while NO server-side gate evaluates a field-level \`visibleWhen\` at\n` +
2059+
` all, so the record still carries the value and every other reader still returns it: a SILENT\n` +
2060+
` enforcement gap. Where no host publishes a scope (the console's public \`/f/:slug\` route, and\n` +
2061+
` every non-form reader) the root is unbound, the predicate faults and the renderer falls back\n` +
2062+
` to VISIBLE, showing the thing it was written to hide to everyone who copies it. The gap is\n` +
2063+
` the WORSE of the two — a visible fail-open gets reported, a silent one does not — so the\n` +
2064+
` verdict below is more justified than when it was written, not less. That verdict is\n` +
2065+
` \`@objectstack/formula\`'s \`validateExpression\` plus \`@objectstack/lint\`'s\n` +
2066+
` \`fieldRuleRootIssue\` — the same two the metadata walk applies to this slot, imported rather\n` +
2067+
` than restated.`,
20232068
);
20242069
process.exit(1);
20252070
}

0 commit comments

Comments
 (0)