Skip to content

Commit 064d484

Browse files
os-zhuangclaude
andauthored
Move both record:alert gates onto the has()-guarded node visibleWhen, as a CEL envelope (#9167) (#11254)
* fix(platform-objects,example-showcase): move both record:alert gates onto the has()-guarded node visibleWhen (#9167) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RfyXxZ2WPjcjhuXpiQQc3y * fix(platform-objects): serve the sys_user alert gate as a CEL envelope so has() actually runs (#9167) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RfyXxZ2WPjcjhuXpiQQc3y --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8da6368 commit 064d484

3 files changed

Lines changed: 87 additions & 3 deletions

File tree

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
"@objectstack/platform-objects": patch
3+
---
4+
5+
Move both authored `record:alert` gates off `properties.visible` onto the
6+
component-node `visibleWhen`, `has()`-guarded and served as a CEL envelope
7+
(#9167) — the `sys_user` detail page's "Email not verified" banner, and the
8+
showcase Task Detail page's "Awaiting review" banner.
9+
10+
`record:alert` is the one record component that declares a props-level
11+
`visible` predicate, but `PageComponentSchema.properties` is an opaque record:
12+
the bag is served verbatim, so a bare string in `visible` never reaches
13+
`ExpressionInputSchema` and is evaluated by the console's **legacy JS**
14+
evaluator, which has no `has()`. The node-level `visibleWhen` declared at
15+
`page.zod.ts:189` *is* an `ExpressionInputSchema`, so a page that goes through
16+
the spec's transform serves `{ dialect: 'cel', source }` and runs on CEL — the
17+
same engine, and the same `has()` semantics, every other predicate face was
18+
migrated to.
19+
20+
Three properties of that move were measured in a real console at the pinned
21+
objectui SHA rather than reasoned about, and all three are load-bearing:
22+
23+
- The `visible` key is **deleted**, not left beside the new gate. A node
24+
`visibleWhen` and `properties.visible` compose as **AND**, so keeping both
25+
would leave the legacy predicate load-bearing and make the migration
26+
cosmetic.
27+
- The `has()` guards are **mandatory**. On the CEL face an absent key is a
28+
*fault*, and that face is fail-soft: measured, an unguarded gate with its key
29+
stripped from the read left the banner VISIBLE, where the guarded gate hid
30+
it.
31+
- On `sys_user` the predicate is authored through `P` so it reaches the wire as
32+
a CEL **envelope**. `SysUserDetailPage` is a raw `Page` object literal, so —
33+
unlike a page built with `definePage()` — nothing normalizes it, and the
34+
renderer keeps bare strings on the legacy path by design. Measured: the bare
35+
form left "Email not verified" showing on *every* profile, including other
36+
people's; the envelope restores every polarity.
37+
38+
Behaviour for real users is unchanged in every polarity measured — a `todo`
39+
task hides the banner and an `in_review` task shows it; a verified user hides
40+
"Email not verified", an unverified user viewing their own profile shows it,
41+
and another user's profile shows nothing. What changes is that a genuine fault
42+
is now **loud** (CEL names the missing key) instead of silently answering
43+
`false`, and that both predicates sit on the declared slot the platform teaches
44+
everywhere else.

examples/app-showcase/src/ui/pages/task-detail.page.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ import { definePage } from '@objectstack/spec/ui';
88
* • `record:path` — Salesforce-style status stepper across the task
99
* lifecycle (Backlog → … → Done).
1010
* • `record:alert` — a conditional banner shown only while the task is
11-
* In Review (demonstrates `visible` expressions).
11+
* In Review (demonstrates the ADR-0089 component-node
12+
* `visibleWhen` predicate, `has()`-guarded).
1213
* • `record:quick_actions` — object Actions surfaced as inline buttons.
1314
* • `record:highlights` + `record:details` — the standard compact + section
1415
* layout.
@@ -41,14 +42,27 @@ export const TaskDetailPage = definePage({
4142
],
4243
},
4344
},
45+
// The banner's gate is the ADR-0089 canonical, component-NODE
46+
// `visibleWhen` — a sibling of `properties`, never a key inside it.
47+
// `properties.visible` is declared on `record:alert` too, but the page
48+
// metadata serves that bag verbatim (`properties` is an opaque record
49+
// on `PageComponentSchema`), so a bare string there reaches the
50+
// renderer's LEGACY JS evaluator, which has no `has()`. The node key is
51+
// `ExpressionInputSchema`, normalized to `{ dialect: 'cel', source }`
52+
// before it is served, so it runs on CEL — where an absent key is a
53+
// FAULT and the surface is fail-soft, i.e. an unguarded predicate would
54+
// leave this banner permanently shown. Hence the `has()` guard, and
55+
// hence no `visible` beside it: a node `visibleWhen` and
56+
// `properties.visible` compose as AND, so leaving both would keep the
57+
// legacy predicate load-bearing.
4458
{
4559
type: 'record:alert',
60+
visibleWhen: "has(record.status) && record.status == 'in_review'",
4661
properties: {
4762
severity: 'warning',
4863
icon: 'eye',
4964
title: 'Awaiting review',
5065
body: 'This task is in review — confirm the work before marking it done.',
51-
visible: "record.status == 'in_review'",
5266
dismissible: true,
5367
},
5468
},

packages/platform-objects/src/pages/sys-user.page.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

3+
import { P } from '@objectstack/spec/shared';
34
import type { Page } from '@objectstack/spec/ui';
45

56
/**
@@ -57,8 +58,34 @@ export const SysUserDetailPage: Page = {
5758
// current user viewing their own profile (admins looking at other
5859
// users see nothing — they can use Setup actions instead).
5960
alerts: [
61+
// The gate is the ADR-0089 canonical, component-NODE `visibleWhen` — a
62+
// sibling of `properties`, never a key inside it. `record:alert` also
63+
// DECLARES `properties.visible`, but `PageComponentSchema.properties` is
64+
// an opaque record served verbatim, so a predicate there never reaches
65+
// `ExpressionInputSchema` and is evaluated by the console's LEGACY JS
66+
// evaluator, which has no `has()`.
67+
//
68+
// ⚠️ The envelope is load-bearing, and `P` is not decoration. This page is
69+
// authored as a RAW `Page` object literal, so — unlike a page built with
70+
// `definePage()` — nothing runs `ExpressionInputSchema`'s transform over
71+
// it and whatever is written here reaches the wire verbatim. Measured in
72+
// the real console at the pinned objectui SHA: a BARE string in
73+
// `visibleWhen` also stays on that legacy evaluator ("bare strings and
74+
// `${…}` templates stay on the legacy path … only an explicit
75+
// `{ dialect: 'cel' }` envelope is rerouted"), so `has()` throws, the
76+
// surface is fail-soft, and the banner shows on EVERY user — including
77+
// other people's profiles. `P` emits the `{ dialect: 'cel', source }`
78+
// envelope that routes to CEL, where `has()` is a real function.
79+
//
80+
// On CEL an absent key is a FAULT and that face is fail-soft too, hence
81+
// the `has()` guards (the same shape the sibling
82+
// `resend_verification_email` action predicate already carries). And
83+
// hence no `visible` beside it: a node `visibleWhen` and
84+
// `properties.visible` compose as AND, so leaving both would keep the
85+
// legacy predicate load-bearing.
6086
{
6187
type: 'record:alert',
88+
visibleWhen: P`has(record.id) && has(record.email_verified) && record.id == ctx.user.id && record.email_verified == false`,
6289
properties: {
6390
severity: 'warning',
6491
icon: 'mail',
@@ -76,7 +103,6 @@ export const SysUserDetailPage: Page = {
76103
'ja-JP': 'パスワードリセットや重要なシステム通知を受け取るには、メールアドレスを認証してください。',
77104
'es-ES': 'Verifica tu correo para recibir restablecimientos de contraseña y notificaciones importantes del sistema.',
78105
},
79-
visible: 'record.id == ctx.user.id && record.email_verified == false',
80106
dismissible: false,
81107
action: {
82108
actionName: 'resend_verification_email',

0 commit comments

Comments
 (0)