Skip to content

Commit b0fa4fc

Browse files
os-zhuangclaude
andauthored
fix(example-showcase): guard the authored action predicates against the sparse face (#8990) (#9280)
* fix(example-showcase): guard the authored action predicates against the sparse face (#8990) Every record-scoped `visible` / `disabled` predicate in app-showcase now carries the `has()` guard the sparse action face requires. A row action's predicate binds a LIST ROW carrying only the view's `$select` projection, and CEL aborts with `No such key` on a column that row never projected -- fail-closed, so the button silently is not offered, which is indistinguishable from the gate saying no. Measured against the running app's own payloads (showcase booted, real seeded records, real view projections): 40 of the 53 predicates in predicate-matrix.action.ts aborted on a default-list row before this change and 0 do after, while every verdict on a record-detail binding is unchanged -- so the Full-vs-Minimal contrast the fixture exists to demonstrate is preserved exactly. The guard is minimal per predicate rather than blanket: has() alone where the read is only compared by == / != , the full has(x) && x != null conjunction only where an operand can fault (traversal, method call, ordering, arithmetic, `in`, bare !). Census correction: the remainder was 57, not the 12 carried forward from PR #9166. `zooTypeGate(name, label, visible)` passes its predicate POSITIONALLY, so the `visible:`-key grep behind both the original 34 and the 12 saw 8 of these and missed 45. The new test reads predicates off the exported actions instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NTKPDRoynY8i3HmdSFUxFj * docs(objectql): carry #8990's three sparse-face measurements to the canonical rule The showcase migration (PR #9280) measured three shapes the two-level nested table at :79 reads as already covered and does not reach. Recorded additively at the definition site, because a rule stated only in a reader is how #8975 came to exist: 1. A three-level path needs has() at EVERY segment. Guarding the leaf subsumes the parent's `!= null` but NOT the parent's has(): `has(record.r) && has(record.r.p.s)` still faults `No such key: p` on `{r: null}` / `{r: {}}`. 2. A nested leaf used for ORDERING still needs its own `!= null` -- the leaf has() proves presence, not value, and the equality exception does not extend to `< <= > >=`. 3. Indexing faults on an EMPTY list, not only a null one: `record.b[0]` on `{b: []}` gives `No such key: index out of bounds`, so `!= null` is not the guard for a subscript; a `.size()` test is. Every cell of all three tables was evaluated against the canonical @objectstack/formula engine before being written. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NTKPDRoynY8i3HmdSFUxFj * chore(gates): narrow the showcase source-resolution registries after the formula alias Both shrink-only registries recorded @objectstack/example-showcase as still reaching @objectstack/formula through dist/. It no longer does -- this PR added the vitest alias and the tsconfig `paths` rule so the new sparse-face test evaluates and typechecks against engine SOURCE. Dropping the entry is a SHRINK, the permitted direction; the gates printed the exact replacement lists and both were applied verbatim. Deliberately NOT fixed by removing the alias to make the registries true again: that would trade a green gate for the hazard the gates exist to name -- a test that runs green against a stale engine build, and a typecheck that PASSES over a contract that has since moved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NTKPDRoynY8i3HmdSFUxFj --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 29d055b commit b0fa4fc

14 files changed

Lines changed: 415 additions & 86 deletions
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
"@objectstack/example-showcase": patch
3+
---
4+
5+
Guard the showcase's authored action predicates against the sparse action face (#8990)
6+
7+
Every record-scoped `visible` / `disabled` predicate in `app-showcase` now carries the
8+
`has()` guard the sparse action face requires, closing the remainder of #8990 in this
9+
repo. A row action's predicate binds a LIST ROW carrying only the view's `$select`
10+
projection, and CEL aborts with `No such key` on a column that row never projected —
11+
fail-closed, so the button silently is not offered.
12+
13+
Measured against the running app's own payloads: 40 of the 53 predicates in
14+
`predicate-matrix.action.ts` aborted on a default-list row before this change and 0 do
15+
after, while every verdict on a record-detail binding is unchanged — the Full-vs-Minimal
16+
contrast the fixture exists to demonstrate is preserved exactly.
17+
18+
The guard is minimal per predicate rather than blanket: `has()` alone where the read is
19+
only compared by `==` / `!=` (CEL compares heterogeneously and answers `false` rather
20+
than faulting), the full `has(x) && x != null` conjunction only where an operand can
21+
fault — traversal, method call, ordering, arithmetic, `in`, or a bare `!`.
22+
23+
The teaching surfaces move with the code, since they quote it: `content/docs/ui/actions.mdx`
24+
(whose `visible: '!record.done'` was the exact negation shape that faults on a NULL
25+
column), `quick-start.mdx` and `build-with-claude-code.mdx`.

content/docs/getting-started/build-with-claude-code.mdx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,9 @@ export const ResolveTicketAction = defineAction({
159159
locations: ['record_header', 'list_item'],
160160
// Only offer "Resolve" on tickets that aren't already resolved or closed.
161161
// Predicates are CEL, record-scoped — `record.status`, never bare `status`.
162-
visible: 'record.status != "resolved" && record.status != "closed"',
162+
// `has()` guards the sparse list row: a list projects only the columns it
163+
// shows, and CEL faults on an absent key (which hides the button silently).
164+
visible: 'has(record.status) && record.status != "resolved" && record.status != "closed"',
163165
successMessage: 'Ticket resolved.',
164166
refreshAfter: true,
165167
ai: {

content/docs/getting-started/quick-start.mdx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,11 @@ reviewing an agent's work, these are the two things most worth a close read:
109109
`status`. A bare reference silently hides the action on every record — the single
110110
most common AI mistake, which is exactly why `os validate` rejects it. See it in
111111
action in [Build with Claude Code → the gate](/docs/getting-started/build-with-claude-code#4-the-gate-os-validate-catches-ai-mistakes).
112+
On a row action, also **guard the field with `has()`**
113+
`has(record.status) && record.status != 'sent'` — because a list row carries
114+
only the columns that view projects, and reading an absent one faults and
115+
hides the button. See [Actions](/docs/ui/actions) for when the guard needs
116+
`&& record.x != null` on top.
112117
- **Views & Apps** (`defineView`, `App.create`) — the list/form lenses and the
113118
navigation. Reading these tells you what the user will actually see and click.
114119

content/docs/ui/actions.mdx

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ export const MarkDoneAction = defineAction({
9191
capabilities: ['api.write'],
9292
},
9393
successMessage: 'Task marked done.',
94-
visible: '!record.done',
94+
visible: 'has(record.done) && record.done != true',
9595
locations: ['list_item', 'record_header', 'record_section'],
9696
refreshAfter: true,
9797
});
@@ -276,10 +276,24 @@ is a spec proposal for a properly named key, not a values map under this one.
276276
Unset means no gate beyond object CRUD permissions. Referenced capabilities
277277
must exist — `os lint` checks that.
278278
- **`visible`** is a CEL predicate evaluated **fail-closed**: an expression
279-
that throws hides the action silently. The rule that saves real debugging
280-
time: always prefix record fields (`record.status != "closed"`, never a bare
281-
`status`, which faults as an undeclared identifier). Compound `&&` / `||`
282-
predicates are fully supported — see the
279+
that throws hides the action silently. Two rules save real debugging time:
280+
281+
1. **Always prefix record fields**`record.status != "closed"`, never a bare
282+
`status`, which faults as an undeclared identifier.
283+
2. **Guard with `has()`** — a record-scoped predicate on `list_item` binds a
284+
LIST ROW, which carries only the columns that view projects. Reading a
285+
field the list does not show aborts the expression with `No such key`, and
286+
fail-closed means the button simply is not offered — indistinguishable from
287+
the gate having said no. `has(record.x) && …` answers `false` instead.
288+
289+
Add `&& record.x != null` **only** when the value is then traversed
290+
(`record.x.k`), called (`record.x.size()`), ordered (`<` `<=` `>` `>=`),
291+
negated (`!record.x`) or used with `in` — those fault on a projected NULL. A
292+
plain `==` / `!=` against a literal never does, so `has()` alone is the whole
293+
guard there. Prefer the minimal form: an over-guarded predicate is the pattern
294+
the next author copies.
295+
296+
Compound `&&` / `||` predicates are fully supported — see the
283297
[formulas guide](/docs/data-modeling/formulas) for CEL syntax.
284298
- **`requiresFeature`** ties visibility to a feature flag (compiled into a
285299
`visible` predicate).

examples/app-showcase/src/ui/actions/index.ts

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,18 @@ export const MarkDoneAction = defineAction({
5959
// `record.`-prefix: the ActionEngine evaluates a record-header action's
6060
// `visible` against `{ record, recordId, … }` with fail-closed semantics, so
6161
// a bare `done`/`status` throws (field not at top level) and silently hides
62-
// the action. Single operand, too — the template path throws on `&&`/`||`.
63-
visible: '!record.done',
62+
// the action.
63+
//
64+
// #8990 — `has()` guards the SPARSE action face: this action reaches
65+
// `list_item`, so its predicate binds a list row carrying only the view's
66+
// `$select` projection, and CEL aborts with `No such key: done` on any task
67+
// list that does not project the column (fail-closed, the button silently
68+
// is not offered). The comparison is `!= true` rather than the older
69+
// `!record.done` because a bare `!` on a projected-but-NULL column faults
70+
// `no such overload: !null`, while `!=` against a literal never faults and
71+
// still reads an unset `done` as "not done". `has()` alone is therefore the
72+
// whole guard here. Rule: `packages/objectql/src/declared-fields.ts`.
73+
visible: 'has(record.done) && record.done != true',
6474
// `record_section` so the Task Detail page's `record:quick_actions` bar
6575
// (which names this action) resolves it — the engine location-filters even
6676
// explicitly-named actions, mirroring the platform's own sys-user pages.
@@ -259,9 +269,12 @@ export const SubmitForSignoffAction = defineAction({
259269
capabilities: ['api.write'],
260270
},
261271
successMessage: 'Invoice submitted for finance + legal sign-off.',
262-
// Only on invoices not yet sent. `record.`-prefixed single comparison, per the
272+
// Only on invoices not yet sent. `record.`-prefixed comparison, per the
263273
// ActionEngine's fail-closed CEL evaluation (see MarkDoneAction's note).
264-
visible: "record.status != 'sent'",
274+
// #8990 — `has()` guards the sparse face this reaches via `list_item`; the
275+
// `!=` against a literal never faults on a projected NULL, so `has()` alone
276+
// is the whole guard.
277+
visible: "has(record.status) && record.status != 'sent'",
265278
locations: ['list_item', 'record_header'],
266279
refreshAfter: true,
267280
});
@@ -362,8 +375,9 @@ export const ArchiveTaskAction = defineAction({
362375
capabilities: [],
363376
},
364377
successMessage: 'Task archived (demo — no data changed).',
365-
// Disabled while the task is not done — visible either way.
366-
disabled: 'record.done != true',
378+
// Disabled while the task is not done — visible either way. #8990: `has()`
379+
// for the sparse face; `!=` against a literal cannot fault, so no `!= null`.
380+
disabled: 'has(record.done) && record.done != true',
367381
locations: ['record_header', 'record_section'],
368382
refreshAfter: false,
369383
});

0 commit comments

Comments
 (0)