diff --git a/.changeset/record-alert-visiblewhen-migration.md b/.changeset/record-alert-visiblewhen-migration.md new file mode 100644 index 0000000000..92799566ac --- /dev/null +++ b/.changeset/record-alert-visiblewhen-migration.md @@ -0,0 +1,44 @@ +--- +"@objectstack/platform-objects": patch +--- + +Move both authored `record:alert` gates off `properties.visible` onto the +component-node `visibleWhen`, `has()`-guarded and served as a CEL envelope +(#9167) — the `sys_user` detail page's "Email not verified" banner, and the +showcase Task Detail page's "Awaiting review" banner. + +`record:alert` is the one record component that declares a props-level +`visible` predicate, but `PageComponentSchema.properties` is an opaque record: +the bag is served verbatim, so a bare string in `visible` never reaches +`ExpressionInputSchema` and is evaluated by the console's **legacy JS** +evaluator, which has no `has()`. The node-level `visibleWhen` declared at +`page.zod.ts:189` *is* an `ExpressionInputSchema`, so a page that goes through +the spec's transform serves `{ dialect: 'cel', source }` and runs on CEL — the +same engine, and the same `has()` semantics, every other predicate face was +migrated to. + +Three properties of that move were measured in a real console at the pinned +objectui SHA rather than reasoned about, and all three are load-bearing: + +- The `visible` key is **deleted**, not left beside the new gate. A node + `visibleWhen` and `properties.visible` compose as **AND**, so keeping both + would leave the legacy predicate load-bearing and make the migration + cosmetic. +- The `has()` guards are **mandatory**. On the CEL face an absent key is a + *fault*, and that face is fail-soft: measured, an unguarded gate with its key + stripped from the read left the banner VISIBLE, where the guarded gate hid + it. +- On `sys_user` the predicate is authored through `P` so it reaches the wire as + a CEL **envelope**. `SysUserDetailPage` is a raw `Page` object literal, so — + unlike a page built with `definePage()` — nothing normalizes it, and the + renderer keeps bare strings on the legacy path by design. Measured: the bare + form left "Email not verified" showing on *every* profile, including other + people's; the envelope restores every polarity. + +Behaviour for real users is unchanged in every polarity measured — a `todo` +task hides the banner and an `in_review` task shows it; a verified user hides +"Email not verified", an unverified user viewing their own profile shows it, +and another user's profile shows nothing. What changes is that a genuine fault +is now **loud** (CEL names the missing key) instead of silently answering +`false`, and that both predicates sit on the declared slot the platform teaches +everywhere else. diff --git a/examples/app-showcase/src/ui/pages/task-detail.page.ts b/examples/app-showcase/src/ui/pages/task-detail.page.ts index 2eb8b1dfcb..36fd40a3ac 100644 --- a/examples/app-showcase/src/ui/pages/task-detail.page.ts +++ b/examples/app-showcase/src/ui/pages/task-detail.page.ts @@ -8,7 +8,8 @@ import { definePage } from '@objectstack/spec/ui'; * • `record:path` — Salesforce-style status stepper across the task * lifecycle (Backlog → … → Done). * • `record:alert` — a conditional banner shown only while the task is - * In Review (demonstrates `visible` expressions). + * In Review (demonstrates the ADR-0089 component-node + * `visibleWhen` predicate, `has()`-guarded). * • `record:quick_actions` — object Actions surfaced as inline buttons. * • `record:highlights` + `record:details` — the standard compact + section * layout. @@ -41,14 +42,27 @@ export const TaskDetailPage = definePage({ ], }, }, + // The banner's gate is the ADR-0089 canonical, component-NODE + // `visibleWhen` — a sibling of `properties`, never a key inside it. + // `properties.visible` is declared on `record:alert` too, but the page + // metadata serves that bag verbatim (`properties` is an opaque record + // on `PageComponentSchema`), so a bare string there reaches the + // renderer's LEGACY JS evaluator, which has no `has()`. The node key is + // `ExpressionInputSchema`, normalized to `{ dialect: 'cel', source }` + // before it is served, so it runs on CEL — where an absent key is a + // FAULT and the surface is fail-soft, i.e. an unguarded predicate would + // leave this banner permanently shown. Hence the `has()` guard, and + // hence no `visible` beside it: a node `visibleWhen` and + // `properties.visible` compose as AND, so leaving both would keep the + // legacy predicate load-bearing. { type: 'record:alert', + visibleWhen: "has(record.status) && record.status == 'in_review'", properties: { severity: 'warning', icon: 'eye', title: 'Awaiting review', body: 'This task is in review — confirm the work before marking it done.', - visible: "record.status == 'in_review'", dismissible: true, }, }, diff --git a/packages/platform-objects/src/pages/sys-user.page.ts b/packages/platform-objects/src/pages/sys-user.page.ts index 3000612fb3..4dc9894837 100644 --- a/packages/platform-objects/src/pages/sys-user.page.ts +++ b/packages/platform-objects/src/pages/sys-user.page.ts @@ -1,5 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. +import { P } from '@objectstack/spec/shared'; import type { Page } from '@objectstack/spec/ui'; /** @@ -57,8 +58,34 @@ export const SysUserDetailPage: Page = { // current user viewing their own profile (admins looking at other // users see nothing — they can use Setup actions instead). alerts: [ + // The gate is the ADR-0089 canonical, component-NODE `visibleWhen` — a + // sibling of `properties`, never a key inside it. `record:alert` also + // DECLARES `properties.visible`, but `PageComponentSchema.properties` is + // an opaque record served verbatim, so a predicate there never reaches + // `ExpressionInputSchema` and is evaluated by the console's LEGACY JS + // evaluator, which has no `has()`. + // + // ⚠️ The envelope is load-bearing, and `P` is not decoration. This page is + // authored as a RAW `Page` object literal, so — unlike a page built with + // `definePage()` — nothing runs `ExpressionInputSchema`'s transform over + // it and whatever is written here reaches the wire verbatim. Measured in + // the real console at the pinned objectui SHA: a BARE string in + // `visibleWhen` also stays on that legacy evaluator ("bare strings and + // `${…}` templates stay on the legacy path … only an explicit + // `{ dialect: 'cel' }` envelope is rerouted"), so `has()` throws, the + // surface is fail-soft, and the banner shows on EVERY user — including + // other people's profiles. `P` emits the `{ dialect: 'cel', source }` + // envelope that routes to CEL, where `has()` is a real function. + // + // On CEL an absent key is a FAULT and that face is fail-soft too, hence + // the `has()` guards (the same shape the sibling + // `resend_verification_email` action predicate already carries). And + // hence no `visible` beside it: a node `visibleWhen` and + // `properties.visible` compose as AND, so leaving both would keep the + // legacy predicate load-bearing. { type: 'record:alert', + visibleWhen: P`has(record.id) && has(record.email_verified) && record.id == ctx.user.id && record.email_verified == false`, properties: { severity: 'warning', icon: 'mail', @@ -76,7 +103,6 @@ export const SysUserDetailPage: Page = { 'ja-JP': 'パスワードリセットや重要なシステム通知を受け取るには、メールアドレスを認証してください。', 'es-ES': 'Verifica tu correo para recibir restablecimientos de contraseña y notificaciones importantes del sistema.', }, - visible: 'record.id == ctx.user.id && record.email_verified == false', dismissible: false, action: { actionName: 'resend_verification_email',