Skip to content

Commit 322b6bf

Browse files
docs(automation): a flow's record nodes treat a computed filter operand as a value, never as a query operator (#29925)
domain:skills#1 · huangyiirene · session_01J8VVAnMUWA9rufXzFaEh4J <!-- claude-projects-attribution: {"github_login":"huangyiirene"} --> _Requested by **Irene** · [project thread](https://claude.ai/code/project/chan_01TJXtQ1tVD26ecLDZxFS67x?thread=cmsg_01TJXtQ1tVD26ecLDZxFS67xVJQu45A8HpRuFiXyhJtTnG)_ Fixes #29418 Clause-②: no Metadata-protocol: none Model: claude-fable-5-1 mandate waived by the maintainer (Fable quota exhausted); developed on Opus; contract review by triage. ## 维护者速读 - 改了什么:自动化技能的「Filter operands」一节与 flows 文档的 CRUD filter 段落各加同一句规则:flow 记录节点(`get_record` / `update_record` / `delete_record`)把计算出的 filter 操作数当作值,永不当查询运算符;并写明作者的修法。 - 为什么改:#29404 已合并这条运行时规则(changeset 的 `Skills-docs:` 欠账),技能与文档还没讲,AI 写 flow 时会把对象或列表塞进 filter 操作数而在运行时失败。 - 风险与回滚:只改文本,外加一个 `@objectstack/skills` patch changeset(`skills/**` 随该包发布)。技能文件的 token 上限 6449 未动,新增由同文件删除重述付账(6449 → 6449,余量 0)。回滚即 revert。 - 先看:`skills/objectstack-automation/SKILL.md` 的 Filter operands 新条目与「Valid-but-silently-wrong」删掉的第 4 条;`content/docs/automation/flows.mdx` L434–438。 - 两分钟验证:`node scripts/check-skills-token-ratchet.mjs`,预期 `objectstack-automation/SKILL.md 6449 / 6449` 且 exit 0。 - 需要你做的:Tier H 受管面(`skills/**`):契约复审 PASS 后,请审阅并批准。 ## What changes 1. **`skills/objectstack-automation/SKILL.md`, § Filter operands (`config.filter`).** A new bullet after the `null` bullet states the rule from #29404's changeset and the author's fix: > **A computed operand is a value, never a query operator**: a string, number, bigint, boolean or date, or at an `$in` / `$nin` / `$between` comparand a list of them with no empty entry. Anything else fails the node as a guard before the query runs. Make the expression evaluate to the compared value (for a record, its `id`), and write conditions in the filter itself. Every fact is from the merged code: `operandValueRefusal` and `LIST_COMPARAND_OPERATORS` in `packages/services/service-automation/src/builtin/crud-nodes.ts` (a value is `isAcceptedFilterComparand`, i.e. string, number, bigint, boolean or `Date`; a list only where the operand's path ends in `$in` / `$nin` / `$between`, with no `null` entry), and `refuseNode` makes it a guard before the data engine is asked. No refused-value example and no "before" behaviour. 2. **`content/docs/automation/flows.mdx`, the CRUD-node filter paragraph** ("A CRUD node's filter: each operand computes before the query…"). The same sentence, as its own paragraph after the `null`-operand paragraph. 3. **The lists of failures a `fault` edge does not route** (contract review should-fixes). The skill's ROUTES table cell now reads "filter operand that evaluated to `null` or a non-value", and the guard list in flows.mdx § Fault edges adds "a filter operand that evaluated to something other than a value". Without these, the new bullet's word "guard" was tied to no-fault-routing nowhere in the skill. 4. **`.changeset/29418-record-node-operand-value-docs.md`**: `'@objectstack/skills': patch`, with `Clause-②: no` and `Metadata-protocol: none` in its body (contract review must-fix). `skills/**` ships in `@objectstack/skills`, so the change releases that package and never takes `skip-changeset` (`.github/workflows/pr-automation.yml` route 1). The label set at creation was removed. ### Token budget (ceiling row not moved, no re-wrap) New text is paid by deleting restatement in the same file. Each deletion and where its content still lives: | deleted | bytes | still taught at | |:--|--:|:--| | Valid-but-silently-wrong item 4 ("Conditions are bare CEL; only the stdlib … is callable bare", with its blank line) | 241 | `objectstack-formula` SKILL.md L28–30 (never wrap a field ref in `{…}`) and L219–226 (`CEL_STDLIB_FUNCTIONS`; `PRIOR()` / an unknown function fails the build); this file's opening blockquote and `description` already send condition CEL there | | "dates and money: the legacy → CEL table in **objectstack-formula**" in item 1's token list | 74 | this file's opening blockquote: "Contract, stdlib, legacy → CEL table: **objectstack-formula**" | | the inline example "(`filter: { id: { dialect: 'cel', source: 'record.id' } }`)" in the first filter-operand bullet | 60 | the same file's `update_record` example under item 3, `filter: { id: { dialect: 'cel', source: 'record.id' } }` | | "(inbox by default)" in the `notify` node row | 19 | the `notify` block below it: `channels: ['inbox'], // RAW; default inbox` | | reading | before (origin/main 4e0c5d3) | after (HEAD) | |:--|:--|:--| | `skills/objectstack-automation/SKILL.md` | 452 lines, 25815 bytes, 6449 tokens (ceiling 6449) | 452 lines, 25814 bytes, 6449 tokens (ceiling 6449, headroom 0) | | all `skills/*/SKILL.md` | 4543 lines, 226490 bytes | 4543 lines, 226489 bytes | The ceiling row in `scripts/check-skills-token-ratchet.mjs` is not moved; the ROUTES cell's 16 added bytes used the 4 tokens of headroom the first round left. The state-introspection route clause on the State Machines pointer line was a candidate and was kept: `packages/rest/src/meta-state-route-doc-spelling.test.ts` pins this file as a teaching site of that route. ## Tests Text only. No eval in `skills/objectstack-automation/evals/` covers the deleted lines (searched for `PRIOR`, `stdlib`, `bare CEL`, `legacy`, `dates and money`; the one `legacy` hit is an ERP prompt in `integrations-declared.json`). ## Acceptance notes - Contract review on 47adef2 (6606363832) was FAIL. Must-fix: the change carried `skip-changeset`, but a `skills/**` change releases `@objectstack/skills`. Fixed in 5ee2376 with a patch changeset, and the label removed. Both should-fixes (the ROUTES cell and the flows.mdx guard list) are fixed in the same commit. A delta review on 5ee2376 is owed. ## Verification <details><summary>Local gates on 5ee2376: 68 derived, 68 run, all exit 0 (`dispatch-gates --ran`)</summary> - On head 5ee2376, `node scripts/pm/dispatch-gates.mjs --commands` derived 68 commands (the changeset adds nine: `check-adr-0087-registration`, `check-changeset-no-major`, `check-empty-changeset`, `check:changeset-gate-self-tests`, `check:published-files` and four self-tests). All 68 ran with a 1200 s timeout each, in a tree with `pnpm install --frozen-lockfile` and a full `pnpm build` (118 tasks, exit 0). Each exit code was recorded before any pipe, and all 68 exited 0, `check:skill-examples`, `check:dts-closure`, `check:dual-build-cjs-loads` and `check:lean-entry-closure` included. - `--ran` printed: `68 derived famil(ies) accounted for — 68 run, 0 NOT-MEASURED`. On the first head, 47adef2, 59 derived and all 59 exited 0. - `node scripts/check-skills-token-ratchet.mjs`: exit 0, `skills/objectstack-automation/SKILL.md 6449 / 6449 (+0)`. - `node scripts/check-commit-card-trailers.mjs --range origin/main..HEAD`: exit 0. </details> 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01J8VVAmczcwpTdN2WmzM6Mo --- _Generated by [Claude Code](https://claude.ai/code/session_01J8VVAmczcwpTdN2WmzM6Mo)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent dd9add6 commit 322b6bf

3 files changed

Lines changed: 27 additions & 9 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/skills': patch
3+
---
4+
5+
docs(skills): objectstack-automation says a flow's record nodes treat a computed filter operand as a value, never as a query operator
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
- **Now.** § Filter operands states the rule from the service-automation change: a computed operand is a string, number, bigint, boolean or date, or at an `$in` / `$nin` / `$between` comparand a list of them with no empty entry. Anything else fails the node as a guard before the query runs. The fix: make the expression evaluate to the compared value (for a record, its `id`), and write conditions in the filter itself. The table of failures a `fault` edge does not route now names that refusal beside the `null` operand.
11+
- `SKILL.md` stays under its token ceiling (no ceiling moves). No schema, key, command or behaviour changes.

‎content/docs/automation/flows.mdx‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -431,6 +431,12 @@ than guarding it to `null`. To match an empty field, write the literal `null`
431431
operator map, `{ dialect: { $eq: 'x' } }`, since an object naming a string
432432
`dialect` at an operand is read as an envelope.
433433

434+
**A computed operand is a value, never a query operator**: a string, number,
435+
bigint, boolean or date, or at an `$in` / `$nin` / `$between` comparand a list
436+
of them with no empty entry. Anything else fails the node as a guard before the
437+
query runs. Make the expression evaluate to the compared value (for a record,
438+
its `id`), and write conditions in the filter itself.
439+
434440
**Where an envelope is data, the remedy names none there.** An envelope
435441
evaluates only as the top-level value of a value slot — `fields`,
436442
`assignments`, a callee's `input` / `inputs`, a screen's `defaults`,
@@ -1983,7 +1989,8 @@ succeed, **and** the fix is to edit metadata. In practice that is a missing
19831989
required config key (a data node with no `objectName`, an `http` node with no
19841990
`url`, a `subflow` or `map` with no `flowName`, a `connector_action` with no
19851991
`connectorId`/`actionId`), a filter operand that evaluated to nothing (`null`),
1986-
so the condition would have widened the query, a graph that recurses past the nesting
1992+
so the condition would have widened the query, a filter operand that evaluated to
1993+
something other than a value, a graph that recurses past the nesting
19871994
ceiling, or a run that would execute unscoped.
19881995

19891996
The split is deliberate. A dropped filter condition does not narrow a query, it

‎skills/objectstack-automation/SKILL.md‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ Flows are built from **built-in node types** (plugins register more):
9494
| Node | Purpose |
9595
|:-----|:--------|
9696
| `http` | Call an external HTTP API — always set `timeoutMs` (unset = no timeout). `url` and `headers` are served as written with the definition to every member who can read flows — only `signingSecret` and a start node's `secret` are withheld — so never a credential there: a credential means a declared connector called via `connector_action` |
97-
| `notify` | Send a notification through the messaging service (inbox by default) |
97+
| `notify` | Send a notification through the messaging service |
9898
| `connector_action` | Dispatch an action of a declared connector (§ *Integrations are declared, not coded*) or a plugin's. `connectorConfig.input` is a value-slot map like `subflow` `input`: `registerFlow` and `os validate` refuse a `{…}` token at `connectorConfig.input.<key>`. Once evaluated, it is judged against the action's `inputSchema` (`required`, `type`, `properties`, `items`) before dispatch: a breach fails the step `VALIDATION_ERROR` (`fault`-routable), nothing sent; `os validate` does not judge the `inputSchema` fit yet |
9999
| `script` | Call a **registered** function named by `config.function` (see *Valid-but-silently-wrong* #3) |
100100
| `screen` | Display a UI form to the user (screen flows only). `config.fields[]`: `{ name, label, type, required, options, defaultValue }`, `type` an open widget hint; `'file'` renders the upload widget and resumes with the `sys_file` id bound to `name` — a later `create_record` / `update_record` writes it into a `file` field, which claims the file: collect first, write after the last screen. `'record_link'` (never `'record'`, an object field type) takes no input — it shows the `reference` record whose id `defaultValue` evaluates to, by display name, as a link to its record page: the result screen of a flow that created records, `{ name: 'account_link', type: 'record_link', reference: 'crm_account', defaultValue: { dialect: 'cel', source: 'account.id' } }` over a `create_record` `outputVariable: 'account'`. Both keys required; `required` / `options` / `placeholder` / `min` / `max` refused; a resume value under its name refused (`400 INVALID_SCREEN_INPUT`). A completed `end` `message` is refused and a markdown link in a screen `description` renders literally. Next / Submit and "Step N of M" are not authored: the engine reads the graph when the screen pauses and serves `progress` (`nextScreen: 'none' | 'possible' | 'certain'`; `step` / `stepCount` only when every path agrees), which alone labels the button and draws the counter. A `decision`, `fault` edge, loop back, non-screen pause (`approval`, `wait`) or `subflow` after a screen answers `possible` — "Next", no count. `progress` in a node `config` is refused; never hand-roll a button label or step counter into `fields` |
@@ -148,7 +148,7 @@ type: 'text', isInput: true }, { name: 'approval_result', type: 'boolean', isOut
148148
149149
| ROUTES (runtime failure) | Does NOT route (fatal either way) |
150150
|:--|:--|
151-
| 404, rate-limit, rejected write, failed subflow | missing required config key (`objectName`, `url`, `flowName`, `connectorId`/`actionId`); filter operand that evaluated to `null`; graph past the nesting ceiling; unscoped run |
151+
| 404, rate-limit, rejected write, failed subflow | missing required config key (`objectName`, `url`, `flowName`, `connectorId`/`actionId`); filter operand that evaluated to `null` or a non-value; graph past the nesting ceiling; unscoped run |
152152

153153
> **Writing a `readonly` field? Set `runAs: 'system'`.** Under the default
154154
> `runAs: 'user'` the engine **strips** it from `create_record` / `update_record`
@@ -195,7 +195,7 @@ type: 'text', isInput: true }, { name: 'approval_result', type: 'boolean', isOut
195195
### Filter operands (`config.filter`)
196196

197197
- **Each operand is a value slot** — a literal or a CEL envelope evaluated
198-
before the query (`filter: { id: { dialect: 'cel', source: 'record.id' } }`).
198+
before the query.
199199
A whole-string **query placeholder** (`{current_user_id}`, `{today}`,
200200
`{current_year_start}`) passes through verbatim for the query engine; a flow
201201
variable never shadows it, and only `filter` gets this hand-off.
@@ -206,6 +206,11 @@ type: 'text', isInput: true }, { name: 'approval_result', type: 'boolean', isOut
206206
literal `null`. A relation hop (`record.owner.email`) needs the start node's
207207
`config.expand` to list the field — else the fault names the missing hop, not
208208
the remedy.
209+
- **A computed operand is a value, never a query operator**: a string, number,
210+
bigint, boolean or date, or at an `$in` / `$nin` / `$between` comparand a list
211+
of them with no empty entry. Anything else fails the node as a guard before the
212+
query runs. Make the expression evaluate to the compared value (for a record,
213+
its `id`), and write conditions in the filter itself.
209214

210215
---
211216

@@ -226,7 +231,6 @@ write them right the first time:
226231
CEL refuses an absent key where the template wrote nothing, so nothing /
227232
`null` / a default is now YOUR call; `'{record.tags.0}'` → `'record.tags[0]'`;
228233
a deep path guards each step: `has(vars.source) && has(vars.source.id) ? vars.source.id : null`
229-
- dates and money: the legacy → CEL table in **objectstack-formula**
230234
- run user: `{$User.Id}` → `current_user.id`; a flow that can run without a
231235
user guards it, `current_user != null ? current_user.id : null`.
232236
`current_user` holds `id`, `positions`, `organizationId`, `isPlatformAdmin`
@@ -280,10 +284,6 @@ write them right the first time:
280284
} });
281285
```
282286

283-
4. **Conditions are bare CEL; only the stdlib (`CEL_STDLIB_FUNCTIONS`,
284-
**objectstack-formula**) is callable bare.** An unknown function (`PRIOR()`, a
285-
typo) and a `{…}`-wrapped field ref both **fail the build** — write `record.x`.
286-
287287
---
288288

289289
→ State Machines & Approvals: [references/state-machines-and-approvals.md](./references/state-machines-and-approvals.md) — its state-introspection route is `GET /api/v1/meta/object/:name/state/:field?from=:state`.

0 commit comments

Comments
 (0)