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
44 changes: 44 additions & 0 deletions .changeset/record-alert-visiblewhen-migration.md
Original file line numberDiff line numberDiff line change
@@ -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.
18 changes: 16 additions & 2 deletions examples/app-showcase/src/ui/pages/task-detail.page.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand DownExpand Up@@ -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,
},
},
Expand Down
28 changes: 27 additions & 1 deletion packages/platform-objects/src/pages/sys-user.page.ts
Original file line numberDiff line numberDiff line change
@@ -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';

/**
Expand DownExpand Up@@ -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',
Expand All@@ -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',
Expand Down
Loading