From e2eded4636ce5556953d2681617e300702bf6857 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 05:59:41 +0000 Subject: [PATCH 1/2] fix(platform-objects,example-showcase): move both record:alert gates onto the has()-guarded node visibleWhen (#9167) Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RfyXxZ2WPjcjhuXpiQQc3y --- .../record-alert-visiblewhen-migration.md | 36 +++++++++++++++++++ .../src/ui/pages/task-detail.page.ts | 18 ++++++++-- .../src/pages/sys-user.page.ts | 18 +++++++++- 3 files changed, 69 insertions(+), 3 deletions(-) create mode 100644 .changeset/record-alert-visiblewhen-migration.md diff --git a/.changeset/record-alert-visiblewhen-migration.md b/.changeset/record-alert-visiblewhen-migration.md new file mode 100644 index 0000000000..f9cbf7bf2b --- /dev/null +++ b/.changeset/record-alert-visiblewhen-migration.md @@ -0,0 +1,36 @@ +--- +"@objectstack/platform-objects": patch +--- + +Move both authored `record:alert` gates off `properties.visible` onto the +component-node `visibleWhen`, `has()`-guarded (#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. The node-level `visibleWhen` declared at `page.zod.ts:189` *is* an +`ExpressionInputSchema`, so it normalizes to `{ dialect: 'cel', source }` +before it is served and runs on CEL — the same engine, and the same `has()` +semantics, every other predicate face was migrated to. + +Two properties of that move were measured in a real console at the pinned +objectui SHA rather than reasoned about, and both 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: unguarded, a stripped gate key logs + `[runtime] No such key: …` and leaves the banner permanently VISIBLE. The + guards are what make the migration safe rather than a regression. + +Behaviour for real users is unchanged in every polarity — a `todo` task hides +the banner and an `in_review` task shows it; a verified user hides "Email not +verified" and an unverified user viewing their own profile shows it. What +changes is that a genuine fault is now **loud** (CEL names the missing key) +instead of silently answering `false`, and that the two predicates now 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..d5c2ea789b 100644 --- a/packages/platform-objects/src/pages/sys-user.page.ts +++ b/packages/platform-objects/src/pages/sys-user.page.ts @@ -57,8 +57,25 @@ 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 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 "Email not verified" + // permanently shown, even beside the page's own "Email Verified: Yes" + // chip. 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: + 'has(record.id) && has(record.email_verified) && record.id == ctx.user.id && record.email_verified == false', properties: { severity: 'warning', icon: 'mail', @@ -76,7 +93,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', From 3d812516afa0e42f6246947f180de31b5b478cfc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 06:32:10 +0000 Subject: [PATCH 2/2] fix(platform-objects): serve the sys_user alert gate as a CEL envelope so has() actually runs (#9167) Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RfyXxZ2WPjcjhuXpiQQc3y --- .../record-alert-visiblewhen-migration.md | 44 +++++++++++-------- .../src/pages/sys-user.page.ts | 34 +++++++++----- 2 files changed, 48 insertions(+), 30 deletions(-) diff --git a/.changeset/record-alert-visiblewhen-migration.md b/.changeset/record-alert-visiblewhen-migration.md index f9cbf7bf2b..92799566ac 100644 --- a/.changeset/record-alert-visiblewhen-migration.md +++ b/.changeset/record-alert-visiblewhen-migration.md @@ -3,34 +3,42 @@ --- Move both authored `record:alert` gates off `properties.visible` onto the -component-node `visibleWhen`, `has()`-guarded (#9167) — the `sys_user` detail -page's "Email not verified" banner, and the showcase Task Detail page's -"Awaiting review" banner. +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. The node-level `visibleWhen` declared at `page.zod.ts:189` *is* an -`ExpressionInputSchema`, so it normalizes to `{ dialect: 'cel', source }` -before it is served and runs on CEL — the same engine, and the same `has()` -semantics, every other predicate face was migrated to. +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. -Two properties of that move were measured in a real console at the pinned -objectui SHA rather than reasoned about, and both are load-bearing: +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: unguarded, a stripped gate key logs - `[runtime] No such key: …` and leaves the banner permanently VISIBLE. The - guards are what make the migration safe rather than a regression. + *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 — a `todo` task hides -the banner and an `in_review` task shows it; a verified user hides "Email not -verified" and an unverified user viewing their own profile shows it. What -changes is that a genuine fault is now **loud** (CEL names the missing key) -instead of silently answering `false`, and that the two predicates now sit on -the declared slot the platform teaches everywhere else. +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/packages/platform-objects/src/pages/sys-user.page.ts b/packages/platform-objects/src/pages/sys-user.page.ts index d5c2ea789b..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'; /** @@ -59,23 +60,32 @@ export const SysUserDetailPage: Page = { 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 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 "Email not verified" - // permanently shown, even beside the page's own "Email Verified: Yes" - // chip. Hence the `has()` guards (the same shape the sibling - // `resend_verification_email` action predicate already carries), and + // 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: - 'has(record.id) && has(record.email_verified) && record.id == ctx.user.id && record.email_verified == false', + visibleWhen: P`has(record.id) && has(record.email_verified) && record.id == ctx.user.id && record.email_verified == false`, properties: { severity: 'warning', icon: 'mail',