From 161245069b2657bb5b62133eeed715893297995f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 16:34:04 +0000 Subject: [PATCH 1/2] docs(ui,protocol,lint): retire the 'no current_user at section level' claim from the three prose sites #13074 left unswept --- content/docs/protocol/objectui/layout-dsl.mdx | 44 +++++++++------ content/docs/ui/views.mdx | 2 +- .../scripts/check-doc-formula-expressions.mjs | 55 +++++++++++++++++-- 3 files changed, 79 insertions(+), 22 deletions(-) diff --git a/content/docs/protocol/objectui/layout-dsl.mdx b/content/docs/protocol/objectui/layout-dsl.mdx index f23d0b1d4e..81037238f7 100644 --- a/content/docs/protocol/objectui/layout-dsl.mdx +++ b/content/docs/protocol/objectui/layout-dsl.mdx @@ -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'" ``` @@ -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.`) | | 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 role 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 @@ -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) diff --git a/content/docs/ui/views.mdx b/content/docs/ui/views.mdx index dd8d6d7618..9f03833fc4 100644 --- a/content/docs/ui/views.mdx +++ b/content/docs/ui/views.mdx @@ -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 role 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) | **Form-view predicates may not name the `features.*` scope root** (ruled 2026-08-27, objectui#6262). diff --git a/packages/lint/scripts/check-doc-formula-expressions.mjs b/packages/lint/scripts/check-doc-formula-expressions.mjs index 3218430ba0..9cb7159e4d 100644 --- a/packages/lint/scripts/check-doc-formula-expressions.mjs +++ b/packages/lint/scripts/check-doc-formula-expressions.mjs @@ -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: () => { @@ -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); } From 8ae8385bfe833a3f8bdfdbbe93bcecf51218ffd4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 16:43:17 +0000 Subject: [PATCH 2/2] docs(ui,protocol,lint): re-measure the section-level current_user claim; pin the gate epilogue --- ...on-visiblewhen-binds-current-user-prose.md | 54 +++++++++++++++++++ content/docs/protocol/objectui/layout-dsl.mdx | 4 +- content/docs/ui/views.mdx | 2 +- 3 files changed, 57 insertions(+), 3 deletions(-) create mode 100644 .changeset/section-visiblewhen-binds-current-user-prose.md diff --git a/.changeset/section-visiblewhen-binds-current-user-prose.md b/.changeset/section-visiblewhen-binds-current-user-prose.md new file mode 100644 index 0000000000..4c41eb58cb --- /dev/null +++ b/.changeset/section-visiblewhen-binds-current-user-prose.md @@ -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. diff --git a/content/docs/protocol/objectui/layout-dsl.mdx b/content/docs/protocol/objectui/layout-dsl.mdx index 81037238f7..40a4e69015 100644 --- a/content/docs/protocol/objectui/layout-dsl.mdx +++ b/content/docs/protocol/objectui/layout-dsl.mdx @@ -892,8 +892,8 @@ any provider deliberately — an anonymous visitor has no principal — so 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 role test written here hides -controls and protects no data. +`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 diff --git a/content/docs/ui/views.mdx b/content/docs/ui/views.mdx index 9f03833fc4..c5d4635cbc 100644 --- a/content/docs/ui/views.mdx +++ b/content/docs/ui/views.mdx @@ -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). 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 role 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) | +| `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) | **Form-view predicates may not name the `features.*` scope root** (ruled 2026-08-27, objectui#6262).