Skip to content

Commit e82bf64

Browse files
docs(skills): the formula skill names action visible / disabled as the sparse, client-side exception to the total-record rule (#29439)
<!-- claude-projects-attribution: {"github_login":"marchtian"} --> _Requested by **田騏** · [project thread](https://claude.ai/code/project/chan_01VmuUhXU9SSUzqJYyXSmYSi?thread=cmsg_01VmuUhXU9SSUzqJYyXSmYSi9paTx1Lu8ENYwwssthfK3h)_ Fixes #29094 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:formula 技能「`has()` is NOT a null check」一节把「每个谓词读到的 record 对已声明字段全量」限定为服务端求值面(校验规则、`requiredWhen`、option `visibleWhen`、`readonlyWhen`、hook `condition`、flow 触发记录),那里 `!= null` 就是全部守卫;新增一段「动作 `visible` / `disabled` 例外」:它们在客户端绑定已取到的行,列表行可能缺键,守卫照抄 `declared-fields.ts` 的规范写法。Surfaces 表补上 `Action.visible`,标为客户端、稀疏。教程页 Resolve 动作的注释改为指向 `declared-fields.ts` 的规则。 - 为什么改:旧句子让 AI 以为列表行按钮的 `has()` 守卫没用而删掉它,结果列不在视图投影里时谓词报错、按钮静默消失。裁决(6579342813,「同意」选项 A)定了 `declared-fields.ts` 是正确一方。 - 风险与回滚:只改技能文字、一行文档注释和 changeset,无代码;技能 +16 行、+234 token,仍在上限内(5920/5997),上限未动。回滚 = revert 本 PR。 - 先看:① `skills/objectstack-formula/SKILL.md` 第 124-136 行的例外段;② 同文件第 98-104 行改窄的那句;③ 第 426 行 Surfaces 表的 `Action` 行。 - 两分钟验证:`node scripts/check-skills-token-ratchet.mjs`,预期全绿,`skills/objectstack-formula/SKILL.md` 为 5920/5997。 - 需要你做的:完成 Fable 全量复审后审批并合并本 PR(Tier H `skills/**`)。 ## What changes Text only, inside the claim's file surface. The binding ruling is comment 6579342813 (option A); the guard spellings are copied from the CANONICAL rule in `packages/objectql/src/declared-fields.ts` (lines 28-135 on this head). - `skills/objectstack-formula/SKILL.md`, section "`has()` is NOT a null check": 1. "Every predicate reads a `record` … total over the object's declared fields" now reads "Every server-evaluated predicate — validation rules, `requiredWhen`, option `visibleWhen`, `readonlyWhen`, hook `condition`, the flow trigger record — …", and says `!= null` is the whole guard there. The list is the one in the `declared-fields.ts` header (lines 6-13). 2. New paragraph: action `visible` / `disabled` are client-side and sparse. They bind the row the client already holds, and a `list_item` row lacks a key when the column is outside the view's projection, when field-level security refuses it, or when it was never written (the mongo and memory drivers). Reading an absent key faults `No such key` and the action silently vanishes. The guard is `has(record.x) && record.x != null`; `has()` alone is enough beside a literal `==` / `!=`; a nested path takes one `has()` per segment. Three example lines follow, each shaped as a row of the canonical tables. 3. The Surfaces table row `Action | disabled` is now `Action | visible / disabled — client-side, sparse`. 4. `record.?x.orValue(…)` is not taught. - `content/docs/getting-started/build-with-claude-code.mdx`: only the comment above the Resolve action's `visible` changed. It now points at the rule in `packages/objectql/src/declared-fields.ts` and no longer restates it. The guard itself is unchanged. - `.changeset/29094-formula-skill-action-sparse.md`: `@objectstack/skills` patch. The skills bundle publishes: `packages/skills` ships `dist`, synced from `skills/**` by `scripts/sync-catalog.mjs`, so a `skills/**` text change owes a patch changeset. The other seven places that state the sparse rule (`declared-fields.ts`, the `validate-null-guards.ts` ledger, `predicate-matrix.action.ts`, the CRM action and its test, `quick-start.mdx`, `protocol/objectui/concept.mdx`, `ui/actions.mdx`) are untouched. **Skills readings** (lines, then tokens from `check-skills-token-ratchet`, base `7f277a86f6` vs this head): | | before | after | |:--|--:|--:| | `skills/objectstack-formula/SKILL.md` lines | 473 | 489 | | all `skills/*/SKILL.md` lines | 4529 | 4545 | | `skills/objectstack-formula/SKILL.md` tokens (ceiling 5997) | 5686 | 5920 | | bundle total tokens | 224069 | 224303 | No ceiling moved and no line was re-wrapped. The addition is the ruling's four points; nothing else in the skill was rewritten. ## Tests No code changed, so no unit test applies. The gates below are the evidence. ## Acceptance notes - No eval was added. The repo owes a skill eval for a protocol addition; this change is `Metadata-protocol: none`, and evals for `skills/objectstack-*` have no mechanical requirement (`check:skill-eval-tokens` covers `ui/skills` only). The seat may still want one; see the report. - Eval 5 of `skills/objectstack-formula/evals/cel-predicates-formulas.json` teaches `has(record.discount)` as always true for a field `visibleWhen`. A field `visibleWhen` is not on the ruling's server-evaluated list and not on the action surface, so this PR leaves it as is. Noted, not filed. - `origin/main` was merged once (at `d93acbbe55`) before this PR was opened. `main` has moved since; `dispatch-gates` reports none of the newer commits touch this card's gate inputs. ## Verification All gate readings are from head `a39d022314`. <details><summary>Derived gate families: 69 derived, 68 run green, 1 NOT MEASURED</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` exit 0 derived 69 commands for the 3 changed paths. Every one was run with its exit code captured before any pipe. 68 exited 0, including: - `node scripts/check-skills-token-ratchet.mjs` exit 0: `skills/objectstack-formula/SKILL.md is 5920 tokens (ceiling 5997; headroom 77)` - `node scripts/check-skills-token-ratchet.mjs --self-test` exit 0 - `pnpm --filter @objectstack/spec run check:skill-examples` exit 0: `281 prose examples type-check across 3 surface(s)` - `pnpm --filter @objectstack/spec run check:skill-docs` exit 0 - `pnpm check:pm-skill-id-lint` exit 0 (no issue number in the published catalog) - `pnpm check:skill-identifier-liveness`, `check:skill-frame-sync`, `check:skill-compatibility` exit 0 - `pnpm check:doc-authoring`, `check:doc-anchors`, `check:nul-bytes` exit 0 - `pnpm --filter @objectstack/lint run check:doc-formula-expressions` exit 0 - `node scripts/check-empty-changeset.mjs --base origin/main`, `check-changeset-no-major.mjs --base origin/main`, `check-adr-0087-registration.mjs --base origin/main` exit 0 NOT MEASURED: `pnpm check:dual-build-cjs-loads` exit 3, PREREQUISITE NOT MET. It reads every package's built `dist/`, and this worktree built only the spec, lint, objectql and client-react closures. The diff touches no package source; CI runs it on a full build. `node scripts/pm/dispatch-gates.mjs --ran ran.list --repo objectstack-ai/objectstack` exit 0: `Run reconciliation — 69 derived, 68 run, 1 NOT-MEASURED, 0 UNRUN.` </details> --- 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 50c4ec0 commit e82bf64

3 files changed

Lines changed: 39 additions & 9 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/skills': patch
3+
---
4+
5+
docs(skills): objectstack-formula names action `visible` / `disabled` as the sparse, client-side exception to the total-record rule, and teaches the `has()` guard that surface needs
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
- **Before.** The "`has()` is NOT a null check" section said every predicate reads a `record` that is total over the object's declared fields, so `has(record.<declared_field>)` is always `true` and tells you nothing. An agent following it dropped the `has()` guard from an action's `visible` predicate on a `list_item` location, where the row can lack the key: the predicate faults `No such key` and the button silently vanishes. The Surfaces table listed only `Action.disabled`.
11+
- **Now.** The total-record statement is scoped to the server-evaluated surfaces (validation rules, `requiredWhen`, option `visibleWhen`, `readonlyWhen`, hook `condition`, the flow trigger record), where `!= null` is the whole guard. A new paragraph says action `visible` / `disabled` bind the row the client already holds, which lacks a key when the column is outside the view's projection, refused by field-level security, or never written (the mongo and memory drivers), and teaches the guard: `has(record.x) && record.x != null`; `has()` alone beside a literal `==` / `!=`; one `has()` per segment of a nested path. The Surfaces table lists `visible` beside `disabled`, marked client-side and sparse.
12+
- `SKILL.md` stays under its token ceiling (no ceiling moves). No schema, key, command or behaviour changes.

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -172,8 +172,8 @@ export const ResolveTicketAction = defineAction({
172172
locations: ['record_header', 'list_item'],
173173
// Only offer "Resolve" on tickets that aren't already resolved or closed.
174174
// Predicates are CEL, record-scoped — `record.status`, never bare `status`.
175-
// `has()` guards the sparse list row: a list projects only the columns it
176-
// shows, and CEL faults on an absent key (which hides the button silently).
175+
// `has()` guards the sparse list row; the rule and its guard table live in
176+
// `packages/objectql/src/declared-fields.ts`.
177177
visible: 'has(record.status) && record.status != "resolved" && record.status != "closed"',
178178
successMessage: 'Ticket resolved.',
179179
refreshAfter: true,

‎skills/objectstack-formula/SKILL.md‎

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -95,11 +95,13 @@ ADR-0068 — spec field docs write predicates like `current_user.positions`.
9595
`null`. To check for "value present and non-blank" use the stdlib helper
9696
`isBlank()` or compare to `null` explicitly.
9797

98-
Every predicate reads a `record` — and, where §5 binds it, a `previous` — that
99-
is **total over the object's declared fields**: a declared column the driver
100-
never returned reads as `null`, not as a fault. So `has(record.<declared_field>)`
101-
and `has(previous.<declared_field>)` are uniformly `true` and tell you nothing at
102-
all. The idiom that reads like a guard is not one:
98+
Every server-evaluated predicate — validation rules, `requiredWhen`, option
99+
`visibleWhen`, `readonlyWhen`, hook `condition`, the flow trigger record —
100+
reads a `record` (and, where §5 binds it, a `previous`) that is **total over
101+
the object's declared fields**: a declared column the driver never returned
102+
reads as `null`, not as a fault. There `has(record.<declared_field>)` and
103+
`has(previous.<declared_field>)` are uniformly `true`, and `!= null` is the
104+
whole guard. The idiom that reads like a guard is not one:
103105

104106
```text
105107
# WRONG — both has() calls are true on a NULL row, so this reaches `null < null`,
@@ -117,7 +119,23 @@ or hook predicate that applies an ordering (`< <= > >=`) or arithmetic
117119
no `defaultValue`, no default option — unless an explicit `!= null` / `== null` /
118120
`!isBlank()` test dominates it in the same boolean branch. `has()` deliberately
119121
does not satisfy that gate. `has()` over an **undeclared** key stays legal: that
120-
is its real use — telling "absent from this PATCH" apart from "explicitly null".
122+
is its use there — telling "absent from this PATCH" apart from "explicitly null".
123+
124+
**The exception: action `visible` / `disabled` are client-side and sparse.**
125+
They bind the row the client already holds, and that row — a `list_item` row
126+
above all — lacks a key when the column is outside the view's projection, when
127+
field-level security refuses it, or when it was never written (the mongo and
128+
memory drivers).
129+
Reading an absent key faults `No such key`: a `visible` predicate hides the
130+
action, a `disabled` one leaves it refused, silently either way.
131+
There the guard is `has(record.x) && record.x != null`; `has()` alone is enough
132+
beside a literal `==` / `!=`; a nested path takes one `has()` per segment:
133+
134+
```text
135+
has(record.status) && record.status != 'resolved'
136+
has(record.amount) && record.amount != null && record.amount > 1000
137+
has(record.viewer) && has(record.viewer.can_approve) && record.viewer.can_approve == true
138+
```
121139

122140
### Null + string throws
123141

@@ -407,7 +425,7 @@ to the envelope.
407425
| `View` / `Page` | `visibleWhen` (form section/field, page component) | cel |
408426
| `Field` | `defaultValue` (envelope only; bare string = literal) | cel |
409427
| `ConditionalValidation` | `when` | cel |
410-
| `Action` | `disabled` | cel (or boolean) |
428+
| `Action` | `visible` / `disabled` — client-side, sparse | cel (or boolean) |
411429
| `Hook` | `condition` | cel |
412430
| `SharingRule` | `condition` | cel |
413431
| `Flow.decision` | `expression` / edge `condition` | cel (use `vars.<step>.<key>`) |

0 commit comments

Comments
 (0)