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
25 changes: 25 additions & 0 deletions .changeset/showcase-predicate-sparse-face-remainder.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
---
"@objectstack/example-showcase": patch
---

Guard the showcase's authored action predicates against the sparse action face (#8990)

Every record-scoped `visible` / `disabled` predicate in `app-showcase` now carries the
`has()` guard the sparse action face requires, closing the remainder of #8990 in this
repo. 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.

Measured against the running app's own payloads: 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 — 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 `==` / `!=` (CEL compares heterogeneously and answers `false` rather
than faulting), the full `has(x) && x != null` conjunction only where an operand can
fault — traversal, method call, ordering, arithmetic, `in`, or a bare `!`.

The teaching surfaces move with the code, since they quote it: `content/docs/ui/actions.mdx`
(whose `visible: '!record.done'` was the exact negation shape that faults on a NULL
column), `quick-start.mdx` and `build-with-claude-code.mdx`.
4 changes: 3 additions & 1 deletion content/docs/getting-started/build-with-claude-code.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -159,7 +159,9 @@ export const ResolveTicketAction = defineAction({
locations: ['record_header', 'list_item'],
// Only offer "Resolve" on tickets that aren't already resolved or closed.
// Predicates are CEL, record-scoped — `record.status`, never bare `status`.
visible: 'record.status != "resolved" && record.status != "closed"',
// `has()` guards the sparse list row: a list projects only the columns it
// shows, and CEL faults on an absent key (which hides the button silently).
visible: 'has(record.status) && record.status != "resolved" && record.status != "closed"',
successMessage: 'Ticket resolved.',
refreshAfter: true,
ai: {
Expand Down
5 changes: 5 additions & 0 deletions content/docs/getting-started/quick-start.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -109,6 +109,11 @@ reviewing an agent's work, these are the two things most worth a close read:
`status`. A bare reference silently hides the action on every record — the single
most common AI mistake, which is exactly why `os validate` rejects it. See it in
action in [Build with Claude Code → the gate](/docs/getting-started/build-with-claude-code#4-the-gate-os-validate-catches-ai-mistakes).
On a row action, also **guard the field with `has()`** —
`has(record.status) && record.status != 'sent'` — because a list row carries
only the columns that view projects, and reading an absent one faults and
hides the button. See [Actions](/docs/ui/actions) for when the guard needs
`&& record.x != null` on top.
- **Views & Apps** (`defineView`, `App.create`) — the list/form lenses and the
navigation. Reading these tells you what the user will actually see and click.

Expand Down
24 changes: 19 additions & 5 deletions content/docs/ui/actions.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -91,7 +91,7 @@ export const MarkDoneAction = defineAction({
capabilities: ['api.write'],
},
successMessage: 'Task marked done.',
visible: '!record.done',
visible: 'has(record.done) && record.done != true',
locations: ['list_item', 'record_header', 'record_section'],
refreshAfter: true,
});
Expand DownExpand Up@@ -276,10 +276,24 @@ is a spec proposal for a properly named key, not a values map under this one.
Unset means no gate beyond object CRUD permissions. Referenced capabilities
must exist — `os lint` checks that.
- **`visible`** is a CEL predicate evaluated **fail-closed**: an expression
that throws hides the action silently. The rule that saves real debugging
time: always prefix record fields (`record.status != "closed"`, never a bare
`status`, which faults as an undeclared identifier). Compound `&&` / `||`
predicates are fully supported — see the
that throws hides the action silently. Two rules save real debugging time:

1. **Always prefix record fields** — `record.status != "closed"`, never a bare
`status`, which faults as an undeclared identifier.
2. **Guard with `has()`** — a record-scoped predicate on `list_item` binds a
LIST ROW, which carries only the columns that view projects. Reading a
field the list does not show aborts the expression with `No such key`, and
fail-closed means the button simply is not offered — indistinguishable from
the gate having said no. `has(record.x) && …` answers `false` instead.

Add `&& record.x != null` **only** when the value is then traversed
(`record.x.k`), called (`record.x.size()`), ordered (`<` `<=` `>` `>=`),
negated (`!record.x`) or used with `in` — those fault on a projected NULL. A
plain `==` / `!=` against a literal never does, so `has()` alone is the whole
guard there. Prefer the minimal form: an over-guarded predicate is the pattern
the next author copies.

Compound `&&` / `||` predicates are fully supported — see the
[formulas guide](/docs/data-modeling/formulas) for CEL syntax.
- **`requiresFeature`** ties visibility to a feature flag (compiled into a
`visible` predicate).
Expand Down
26 changes: 20 additions & 6 deletions examples/app-showcase/src/ui/actions/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -59,8 +59,18 @@ export const MarkDoneAction = defineAction({
// `record.`-prefix: the ActionEngine evaluates a record-header action's
// `visible` against `{ record, recordId, … }` with fail-closed semantics, so
// a bare `done`/`status` throws (field not at top level) and silently hides
// the action. Single operand, too — the template path throws on `&&`/`||`.
visible: '!record.done',
// the action.
//
// #8990 — `has()` guards the SPARSE action face: this action reaches
// `list_item`, so its predicate binds a list row carrying only the view's
// `$select` projection, and CEL aborts with `No such key: done` on any task
// list that does not project the column (fail-closed, the button silently
// is not offered). The comparison is `!= true` rather than the older
// `!record.done` because a bare `!` on a projected-but-NULL column faults
// `no such overload: !null`, while `!=` against a literal never faults and
// still reads an unset `done` as "not done". `has()` alone is therefore the
// whole guard here. Rule: `packages/objectql/src/declared-fields.ts`.
visible: 'has(record.done) && record.done != true',
// `record_section` so the Task Detail page's `record:quick_actions` bar
// (which names this action) resolves it — the engine location-filters even
// explicitly-named actions, mirroring the platform's own sys-user pages.
Expand DownExpand Up@@ -259,9 +269,12 @@ export const SubmitForSignoffAction = defineAction({
capabilities: ['api.write'],
},
successMessage: 'Invoice submitted for finance + legal sign-off.',
// Only on invoices not yet sent. `record.`-prefixed single comparison, per the
// Only on invoices not yet sent. `record.`-prefixed comparison, per the
// ActionEngine's fail-closed CEL evaluation (see MarkDoneAction's note).
visible: "record.status != 'sent'",
// #8990 — `has()` guards the sparse face this reaches via `list_item`; the
// `!=` against a literal never faults on a projected NULL, so `has()` alone
// is the whole guard.
visible: "has(record.status) && record.status != 'sent'",
locations: ['list_item', 'record_header'],
refreshAfter: true,
});
Expand DownExpand Up@@ -362,8 +375,9 @@ export const ArchiveTaskAction = defineAction({
capabilities: [],
},
successMessage: 'Task archived (demo — no data changed).',
// Disabled while the task is not done — visible either way.
disabled: 'record.done != true',
// Disabled while the task is not done — visible either way. #8990: `has()`
// for the sparse face; `!=` against a literal cannot fault, so no `!= null`.
disabled: 'has(record.done) && record.done != true',
locations: ['record_header', 'record_section'],
refreshAfter: false,
});
Expand Down
Loading
Loading