Skip to content

Commit ee6aa01

Browse files
docs(skills): objectstack-automation teaches the CEL value envelope in fields / assignment values and / 100.0 for money (#22284)
Fixes #22260 Clause-②: no `skills/objectstack-automation/SKILL.md` taught `{token}` values in `create_record` / `update_record` `fields` and the money sample `{round(x * 100) / 100}`. Since #19939 pass 1 landed (PR #22259 is merged; its changeset `.changeset/19939-flow-value-slot-template-dialect-refused.md` is the FROM → TO authority for every spelling below) the tree refuses those values at `objectstack validate`, `registerFlow` and the executors, and `/ 100` is integer division in CEL (#11182 ruling D item 2 prescribed `/ 100.0`). #19939's second half (the two kept spellings) remains open. Tier H (`skills/**`): this PR stays draft and lands on an authorized APPROVED review or by the maintainer's hand — no seat readies, queues or arms it. ## What changed — two files, `skills/objectstack-automation/` only **`SKILL.md` (+33 / −32 lines on `origin/main` `c8bb3c8d9c`):** - **Item 1 of the authoring-traps list (`:224-241`)** is rewritten around the value slot: in `create_record` / `update_record` `fields.*` and an `assignment` value (its two legacy shapes too) a string is the literal text it spells, a `{…}` token is refused at the three doors naming its CEL spelling, and a computed value is `{ dialect: 'cel', source: '…' }` — one worked `has()` form (`'{record.owner}'` → `source: 'has(record.owner) ? record.owner : null'`, with "nothing / `null` / a default is now YOUR call"), the array-index form (`'{record.tags.0}'` → `'record.tags[0]'`), the money form (`source: 'round(x * 100) / 100.0'` — CEL divides two integers as integers, so `/ 100` turns `123.46` into `123`), and the two spellings a value slot still accepts (`{NOW()}` / `{TODAY() ± N}`, `{$User.PATH}`). A second paragraph names the slots pass 1 does not touch — text slots (notify `title` / `message`, `script` `inputs`, `http` `url` / `body`, …) and `filter` keep the single-brace template — and keeps the text-slot grammar and its two traps (`{{x}}` is the template-field dialect; an unknown call-position name fails the node at run time). - **Item 2 (`:245-246`)**: `fields: { ref: '{newRec}' }` / `'{newRec.id}'` → `fields: { ref: { dialect: 'cel', source: 'newRec' } }` (writes the whole record object) / `source: 'newRec.id'`. - **Item 3's example (`:273`)**: `fields: { ai_category: '{ai.ai_category}', ai_sentiment: '{ai.ai_sentiment}' }` → `fields: { ai_category: { dialect: 'cel', source: 'ai.ai_category' } }` (one field; the second was the same spelling twice). - **Filter-tokens section (`:207-208`, `:213-214`)**: `fields` dropped from "in `title`, `message`, `fields` and `url` a bare `{current_year_start}` is a nonsense reference" (a `fields` string is now literal text, not a reference at all) and "write payload" dropped from "(message body, `http` url, write payload) only renders an empty string — a warning" (a `fields` `{record.x}` is now an error at validate, not a warning). **`evals/flows-triggers-approvals.json`** (eval 5): "persists `{recalc.discount}` via `fields`" → "persists it with `fields: { discount: { dialect: 'cel', source: 'recalc.discount' } }`"; `must_contain` gains `dialect: 'cel'`, `must_not_contain` gains `{recalc`, so the eval now pins the new spelling and refuses the old. ### Every `{…}` value spelling in the package, judged against the changeset's refused list | File `:line` on `c8bb3c8d9c` | Spelling | Slot | Verdict | Now | |:--|:--|:--|:--|:--| | `SKILL.md:224-226` | "Value fields on a node's `config` (`fields`, `inputs`, notify `message`/`title`, …) interpolate `{token}`" | puts `fields` in the template set | false for `fields` | item 1 rewritten; `inputs` and notify text keys stay in the template list | | `SKILL.md:232` | `{round(x * 100) / 100}` | value-slot teaching | refused (expression), and integer division | `source: 'round(x * 100) / 100.0'` | | `SKILL.md:235` / `:237` | `body: '{{ai_reply}}'` / `body: '{ai_reply}'` | slot unnamed | in a value slot `{ai_reply}` is refused (path); in a text slot still the template | folded into the text-slot paragraph (`{{x}}` is the template-field dialect) | | `SKILL.md:236-237` | `ticket: '$source.id'` / `ticket: '{source.id}'` | `fields`-shaped key | `{source.id}` refused (path) | retired; the rule survives as "a string is the literal text it spells" | | `SKILL.md:238-241` | `'{ROUND(x, 2)}'` / `'{Math.round(x)}'` / `'{(x).toFixed(2)}'` | slot unnamed | in a value slot the `{…}` is refused at validate first; in a text slot still fails the node at run time | kept for text slots, one spelling | | `SKILL.md:245` | `fields: { ref: '{newRec}' }` | `update_record` field value | refused (path) | `fields: { ref: { dialect: 'cel', source: 'newRec' } }` | | `SKILL.md:246` | `fields: { ref: '{newRec.id}' }` | `update_record` field value | refused (path) | `source: 'newRec.id'` | | `SKILL.md:267` | `inputs: { ticketId: '{record.id}' }` | `script.inputs` | not a value slot — `screen-nodes.ts:343` still interpolates | unchanged | | `SKILL.md:272` | `filter: { id: '{record.id}' }` | `filter` | keeps the template | unchanged | | `SKILL.md:273` | `fields: { ai_category: '{ai.ai_category}', ai_sentiment: '{ai.ai_sentiment}' }` | `update_record` field value | refused (2 paths) | one CEL envelope | | `SKILL.md:107-114` | notify `recipients` / `title` / `message` / `sourceId` | text slots | keep the template | unchanged | | `SKILL.md:154` | "The handler reads `{NODEID.error}` (or run-wide `{$error}`)" | no slot named | true in a text slot; in a value slot the refusal names `vars["$error"].message` | unchanged (see Acceptance notes) | | `SKILL.md:208` / `:213` | "`fields`" / "write payload" in the filter-tokens prose | — | false since pass 1 | dropped | | `references/examples-flows.md:41`, `:119-121` | `{TODAY()}`, `{TODAY() + N}` | `filter` | keeps the template | unchanged | | `references/examples-flows.md:44` | `fields: { status: 'escalated' }` | field value | literal | unchanged | | `references/state-machines-and-approvals.md:120` | `filter: { id: '{record.id}' }, fields: { stage: 'closed_won' }` | `filter` / literal field | keep | unchanged | | `evals/flows-triggers-approvals.json:7` | `renewal_date: { $lt: '{TODAY()}' }`, `fields: { status: 'lapsed' }` | `filter` / literal | keep | unchanged | | `evals/flows-triggers-approvals.json:17` | notify `recipients: '{record.owner_id}'`, "`title` with single-brace interpolation" | text slots | keep | unchanged | | `evals/flows-triggers-approvals.json:47` | "persists `{recalc.discount}` via `fields`" | `update_record` field value | refused (path) | CEL envelope, pinned | `references/_index.md` is generator-owned and unchanged. No `os:check`-marked block changed (the two in this package are in `references/*.md`, untouched). ## `skills/**` readings — the token ratchet, paid inside each file | File | Before (`c8bb3c8d9c`) | After (`5ac59ff0da`) | Ceiling | |:--|:--|:--|:--| | `SKILL.md` | 438 lines · 23125 bytes · **5782 tokens** | 439 lines · 23052 bytes · **5763 tokens** | 5785 (headroom 3 → 22) | | `evals/flows-triggers-approvals.json` | 55 lines · 5019 bytes · **1255 tokens** | 55 lines · 5018 bytes · **1255 tokens** | 1255 (headroom 0 → 2 bytes) | | whole package, all 10 `SKILL.md` | 4408 lines · **52572 tokens** | 4409 lines · **52553 tokens** | −19 tokens | Token = `ceil(utf8 bytes / 4)`, the gate's own convention. No ceiling moved; nothing was re-wrapped to buy a line. Every deleted line and where its content survives: 1. Item 1's grammar bullets (`{var}` / `{record.title}`, `{record.tags.0}` array index, `{$User.Id}` / `{NOW()}` / `{TODAY() + 30}`, the six functions "mirror the CEL stdlib 1:1", "anything without `{…}` is a literal") → compressed into the text-slot paragraph (same facts, now scoped to the slots that still read the template); "(e.g. a `multiple: true` lookup, stored as an array)" → "(array index)". 2. "`round` is integer-only (no `round(x, 2)`); for N decimals write `{round(x * 100) / 100}` (scale 2)" → the money bullet (`/ 100.0`); `round(x)` → int is in objectstack-formula's stdlib table, which item 4 and the opening blockquote already point at. 3. ❌ `body: '{{ai_reply}}'` → "`{{x}}` is the template-field dialect" in the text-slot paragraph. 4. ❌ `ticket: '$source.id'` and ✅ `body: '{ai_reply}'`, `ticket: '{source.id}'` → retired: in a value slot the ✅ spelling is now refused outright and the refusal names its CEL form; the literal rule survives as "a string is the literal text it spells" (value slots) and "no `{…}` ⇒ literal" (text slots). 5. ❌ `'{ROUND(x, 2)}'` / `'{Math.round(x)}'` / `'{(x).toFixed(2)}'` "… with a named error naming the supported set. The build does not catch these (conditions are checked, call-position names are not)" → one spelling, the same rule ("fails the node at run time, unchecked at build, not `fault`-routable"), scoped to text slots — in a value slot the build refuses the `{…}` first. 6. `:277` the one-line `defineStack({ functions: { 'helpdesk.aiTriageStub': … } })` registration — spelled a third time (item 3's head shows `defineStack({ functions: { my_fn: … } })` and the second fence registers `'helpdesk.aiTriageStub'`) → deleted. 7. `:263` `// ❌ DON'T: expect the function to update the record itself (it has no data API)` → restated the prose two lines above the fence ("it does NOT read/write the database") → deleted; the ✅ line stays. Eval file, paid by: "(FlowSchema has no top-level `schedule`; no cron tagged template)" → "(no top-level `schedule`; no cron tag)"; "nodes wired with `edges` (never `next`), ending in an `end` node" → "`edges` (never `next`), an `end` node"; "Failure routing is an edge `{ source, target, type: 'fault' }`" → "a `type: 'fault'` edge" (the shape stays pinned by `must_contain`). ## Measured at the tree, not recalled **Refusal control** — built `packages/spec/dist` at `5ac59ff0da`, `flowNodeValueTemplateRefusals` and `UpdateRecordConfigSchema` imported from `@objectstack/spec/automation`: - old `:273` → 2 refusals, `path=fields.ai_category` / `fields.ai_sentiment`, `label=update_record field value`; each message leads with the `VALUE_SLOT_TEMPLATE_REFUSAL` sentence ("A value slot no longer reads the `{…}` template dialect: a string here is the literal text it spells …") then "Write `{ai.ai_category}` as { dialect: 'cel', source: 'ai.ai_category' }. CEL refuses an absent variable or key where the template wrote nothing, so guard one that may be absent with `has()`: `has(ai.ai_category) ? ai.ai_category : null` (the guarded form writes `null`)." - old `:246` → 1 refusal at `fields.ref`: "Write `{newRec.id}` as { dialect: 'cel', source: 'newRec.id' } …" - old `:232` → 1 refusal at `fields.total`: "Write `{round(x * 100) / 100}` as { dialect: 'cel', source: 'round(x * 100) / 100.0' }. Every division keeps a decimal operand: CEL divides two integers as integers, so `round(x * 100) / 100` drops the decimals where `round(x * 100) / 100.0` keeps them." - the new envelopes (`ai.ai_category`, `newRec.id`, `round(x * 100) / 100.0`, `has(record.owner) ? record.owner : null`) → 0 refusals; `UpdateRecordConfigSchema.safeParse` on the old sample → `success: false` at path `["fields","ai_category"]`, on the new sample → `success: true`. - the two survivors in `fields` (`'{NOW()}'`, `'{TODAY() + 30}'`, `'{$User.Id}'`) → 0 refusals; notify `title` / `message` and `script` `inputs` carrying `{record.id}` → 0 (not value slots); legacy `assignments: [{ variable, value: '{record.amount}' }]` → 1 refusal at `assignments[0].value`. **CEL evaluation** — built `@objectstack/formula`, `ExpressionEngine.evaluate({ dialect: 'cel', source }, scope)` with the engine's own `celScope` shape (`{ extra: { ...vars, vars }, record: vars }`, `engine.ts:11791-11806`), `x = 123.456`: - `round(x * 100) / 100` ⇒ `123` · `round(x * 100) / 100.0` ⇒ `123.46` · `round(x * 100)` ⇒ `12346` - `has(record.missing) ? record.missing : null` ⇒ `null` · bare `record.missing` ⇒ error "No such key: missing" · `record.tags[0]` ⇒ `"x"` · `has(vars.x) ? vars.x : null` ⇒ `123.456` - `list[0]` for a variable literally named `list` ⇒ error "Cannot index type 'type' with type 'int'" (`list` is a CEL type name) — see Acceptance notes. ## Gates — local, at `5ac59ff0da` `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on this tree derives 24 families from the two changed paths. All 24 ran, each exit code captured before any pipe; `--ran` reconciliation: "24 derived, 24 run, 0 NOT-MEASURED, 0 UNRUN (a DERIVED zero — all 24 recorded an exit code and none of them is 3)". | Command | Exit | |:--|:--| | `node scripts/check-skills-token-ratchet.mjs` — "skills/objectstack-automation/SKILL.md is 5763 tokens (ceiling 5785; headroom 22)" · "54 authored bundle file(s) within their ceilings" | 0 | | `node scripts/check-skills-token-ratchet.mjs --self-test` | 0 | | `pnpm check:skill-identifier-liveness` — "Leg 1: 457 citation(s) over 53 published file(s) … Leg 2: 8 registered exhaustive section(s), 0 ledgered gap(s)" | 0 | | `pnpm check:corpus-claim-drift` — "Scanned: 254 .md/.mdx file(s) … skills 53" | 0 | | `pnpm check:doc-authoring` | 0 | | `pnpm check:nul-bytes` — "scanned 10237 text file(s) … no raw ASCII control bytes" | 0 | | `pnpm check:skill-compatibility` — "11 pinned major(s) all match the workspace (@objectstack/spec is 17.x)" | 0 | | `pnpm check:skill-frame-sync` | 0 | | `pnpm check:role-word` | 0 | | `pnpm --filter @objectstack/spec run check:skill-docs` — "Skill docs in sync" | 0 | | `pnpm check:watch-hint-literal` | 0 | | `pnpm --filter @objectstack/lint run check:doc-formula-expressions` — first answered exit 3, "PREREQUISITE NOT MET — `@objectstack/lint` is not built" (not a measurement); after `pnpm exec turbo run build --filter=@objectstack/lint --concurrency=2` under the verify lock (VERDICT command-exit 0) | 0 | | `node scripts/check-ci-filter-parity.mjs` · `check-closing-keyword-parity.mjs` (+ `--self-test`) · `check-comment-mask-corpus.mjs` · `check-doc-route-spelling.mjs --advisory` (+ `--self-test`) | 0 each | | `pnpm check:agent-test-spelling` · `check:cross-package-test-inputs` · `check:driver-memory-census` · `check:gitlink-declared` · `check:pm-governed-merges` · `check:refd-timer-probe` | 0 each | `check:skill-examples` not run as a control: no marked block changed. Not run locally (CI-owned): `pnpm lint`, the type-check lanes, Test Core — no package source changed. No package `test` / `typecheck` is owed: the diff touches no `packages/**`. Changeset: none — `skills/**` publishes nothing from a released package; `skip-changeset` is applied with the PR assignee in one `label-write` call (recorded in the report comment on #22260). ## Acceptance notes - **Finding, for the seat to file (class c)**: the changeset's FROM → TO row `'{list.0}'` → `{ dialect: 'cel', source: 'list[0]' }`, and `celPath` in `packages/spec/src/automation/flow-value-slot-template.ts` which every refusal message is built from, emit `list[0]` for a variable literally named `list`; under CEL `list` is a type name, so the remedy an author copies evaluates to "Cannot index type 'type' with type 'int'" (probe above). Affected: variables named after a CEL type (`list`, `map`, `int`, `string`, `bool`, `double`, `uint`, `bytes`, `type`, `timestamp`, `duration`, `null_type`). Reach: measured through the built formula engine, not through `objectstack validate` (whether validate accepts `list[0]` was not probed). Dedupe words: `list[0] CEL type name`, `celPath value slot remedy`, `Cannot index type 'type'`, `flow-value-slot-template list variable`. The skill now teaches `record.tags[0]` instead. - Noted, not filed: `SKILL.md:154` "The handler reads `{NODEID.error}` (or run-wide `{$error}`)" names no slot; it holds in a text slot, and in a value slot the refusal names `vars["$error"].message`. Left as is (22 tokens of headroom); carrier: #19939's second half, which will touch this item again. - Noted, not filed: the frontmatter `description` still routes "CEL expressions in flow conditions / edge guards" to objectstack-formula; value envelopes now also are CEL. Untouched because the frontmatter regenerates `skills/README.md` and `content/docs/ai/skills-reference.mdx` (`check:skill-docs`), outside this card's file surface; carrier: none. ## 维护者速读(草稿) ### 改了什么 发布版自动化技能(`skills/objectstack-automation/SKILL.md`)里教 AI 怎么给流程节点的 `fields` 赋值的那一条,改成了现在运行时真正接受的写法:`create_record` / `update_record` 的 `fields` 和 `assignment` 的值,要么是字面量,要么是 CEL 信封 `{ dialect: 'cel', source: '…' }`;旧的 `{token}` 单花括号写法在这些位置会被 `objectstack validate`、`registerFlow` 和执行器拒绝。同时把金额取两位小数的样例从 `/ 100` 改成 `/ 100.0`,补了「键可能不存在时用 `has()` 守一下」的写法,并明确写出仍旧接受的两种旧写法(`{NOW()}` / `{TODAY() ± N}`、`{$User.Id}`)和没有变化的位置(通知文案、`inputs`、`http`、`filter` 仍用单花括号模板)。配套的一条 eval 也改成期望新写法。两个文件各自在自己的 token 上限内付清,没有抬上限。 ### 为什么改 这份技能随 `npx skills add` 发到每个客户项目,是 AI 写流程之前读的第一份材料。PR #22259 合并后,它教的 `fields` 写法会在校验时被拒;更糟的是金额样例 `{round(x * 100) / 100}` 没有任何门拦着——在 CEL 里是整数除法,123.46 会悄悄变成 123。实测:旧样例在构建后的 spec 上全部被拒并给出 CEL 写法;新样例全部通过;CEL 引擎里 `/ 100` 得 123、`/ 100.0` 得 123.46。 ### 风险与代价(含回滚) 只改了技能文本和一条 eval,不碰 `packages/**`,不发包、不需要 changeset。风险在措辞:每句话都对照了合并后的 changeset 和源码,24 个派生门禁本地全绿(含 token 棘轮、标识符存活、兼容版本)。回滚就是 revert 这一个 commit,没有数据或运行时影响。一个顺带发现(变量恰好叫 `list` 时,拒绝文案给出的 `list[0]` 在 CEL 里跑不通)不在本 PR 范围,留给席位立单。 ### 席位意见 (留空) ### 你要做的 审阅后在本 PR 上给一个 APPROVED(Tier H,`skills/**`),或自行合并;不需要其他操作。 --- _Generated by [Claude Code](https://claude.ai/code/session_01CXydFDyiQwNbGFkmwrcRQq)_ Co-authored-by: objectstack-fleet[bot] <332303061+objectstack-fleet[bot]@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent ea4aa5c commit ee6aa01

2 files changed

Lines changed: 33 additions & 32 deletions

File tree

‎skills/objectstack-automation/SKILL.md‎

Lines changed: 29 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -204,14 +204,14 @@ a query instead of narrowing it.
204204
one it does **not** resolve that IS a recognised filter placeholder
205205
(`{current_user_id}`, `{current_year_start}`) passes through **verbatim** for
206206
the query engine to expand. So a flow variable named after a placeholder
207-
**shadows** it. Only `filter` gets this hand-off — in `title`, `message`,
208-
`fields` and `url` a bare `{current_year_start}` is a nonsense reference.
207+
**shadows** it. Only `filter` gets this hand-off — in `title`, `message`
208+
and `url` a bare `{current_year_start}` is a nonsense reference.
209209
- **Static checkability splits by position.** A `{record.…}` token **inside a
210210
filter** naming an unknown field, or hopping a relation the start node does not
211211
list in `config.expand`, is an **ERROR** at `objectstack validate`: it resolves
212212
to nothing, the condition is DROPPED, and the node refuses to execute. The
213-
*same* reference **outside** a filter (message body, `http` url, write payload)
214-
only renders an empty string — a **warning**. A `{var}` naming a flow variable
213+
*same* reference **outside** a filter (message body, `http` url) only
214+
renders an empty string — a **warning**. A `{var}` naming a flow variable
215215
or node output is **not statically checkable at all**.
216216

217217
---
@@ -221,29 +221,32 @@ a query instead of narrowing it.
221221
Legal metadata that authors — AI especially — get wrong; most are caught by
222222
`objectstack build`, but write them right the first time:
223223

224-
1. **Flow node VALUE interpolation uses SINGLE braces.** Value fields on a node's
225-
`config` (`fields`, `inputs`, notify `message`/`title`, …) interpolate
226-
`{token}`:
227-
- `{var}` / `{record.title}` — variable / record field
228-
- `{record.tags.0}` — **array index** (e.g. a `multiple: true` lookup, stored as an array)
229-
- `{$User.Id}` / `{NOW()}` / `{TODAY() + 30}` — current user / date macros
230-
- `{round(x)}` `{floor(x)}` `{ceil(x)}` `{abs(x)}` `{min(a,b)}` `{max(a,b)}` —
231-
mirror the CEL stdlib 1:1. `round` is **integer-only** (no `round(x, 2)`);
232-
for N decimals write `{round(x * 100) / 100}` (scale 2)
233-
- anything without `{…}` is a **literal**
234-
235-
❌ `body: '{{ai_reply}}'` — double-brace is the *formula / template-field* dialect, **not** flow values
236-
❌ `ticket: '$source.id'` — a bare `$ref` is a literal string, not interpolated
237-
✅ `body: '{ai_reply}'`, `ticket: '{source.id}'`
238-
❌ `'{ROUND(x, 2)}'` / `'{Math.round(x)}'` / `'{(x).toFixed(2)}'` — any other
239-
name in call position **fails the node** with a named error naming the
240-
supported set. The build does **not** catch these (conditions are checked,
241-
call-position names are not) and a `fault` edge cannot route it.
224+
1. **A `fields` / `assignment` VALUE is a literal or a CEL envelope.** In
225+
`create_record` / `update_record` `fields.*` and an `assignment` value (its
226+
two legacy shapes too) a string is the literal text it spells, and a `{…}`
227+
token is **refused** — at `objectstack validate` (`expression-invalid`),
228+
`registerFlow` and the executor — naming its CEL spelling. Compute with
229+
`{ dialect: 'cel', source: '…' }`:
230+
- `'{record.owner}'` → `source: 'has(record.owner) ? record.owner : null'` —
231+
CEL refuses an absent key where the template wrote nothing, so nothing /
232+
`null` / a default is now YOUR call; `'{record.tags.0}'` → `'record.tags[0]'`
233+
- money: `source: 'round(x * 100) / 100.0'` — CEL divides two integers as
234+
integers, so `/ 100` turns `123.46` into `123`
235+
- still accepted there until CEL can write them: `{NOW()}` / `{TODAY() ± N}`
236+
and `{$User.<path>}`
237+
238+
Text slots (notify `title` / `message`, `inputs`, `http` `url` / `body`, …)
239+
and `filter` keep the SINGLE-brace template — `{var}` / `{record.title}`,
240+
`{record.tags.0}` (array index), `{$User.Id}`, `{NOW()}`, `{TODAY() + 30}`,
241+
`{round(x)}`-style arithmetic; no `{…}` ⇒ literal; `{{x}}` is the
242+
template-field dialect. ❌ `'{ROUND(x, 2)}'` — an unknown name in call
243+
position **fails the node** at run time, unchecked at build, not
244+
`fault`-routable.
242245

243246
2. **`create_record`'s `outputVariable` holds the created RECORD, not its id.**
244247
Reference a field explicitly.
245-
❌ `update_record … fields: { ref: '{newRec}' }` → yields the whole record object
246-
✅ `fields: { ref: '{newRec.id}' }`
248+
❌ `fields: { ref: { dialect: 'cel', source: 'newRec' } }` → writes the whole record object
249+
✅ `source: 'newRec.id'`
247250

248251
3. **`script` nodes call a registered function — that is all they do.** Set
249252
`config.function` to a function registered via
@@ -260,8 +263,7 @@ Legal metadata that authors — AI especially — get wrong; most are caught by
260263
it. Keep data effects on the flow graph (visible, governed, build-checkable):
261264

262265
```ts
263-
// ❌ DON'T: expect the function to update the record itself (it has no data API)
264-
// ✅ DO: function returns values → outputVariable → update_record persists
266+
// ✅ function returns values → outputVariable → update_record persists
265267
{ id: 'ai', type: 'script', config: {
266268
function: 'helpdesk.aiTriageStub', // returns { ai_category, ai_sentiment, … }
267269
inputs: { ticketId: '{record.id}' }, // inputs are interpolated
@@ -270,11 +272,10 @@ Legal metadata that authors — AI especially — get wrong; most are caught by
270272
{ id: 'apply', type: 'update_record', config: {
271273
objectName: 'helpdesk_ticket',
272274
filter: { id: '{record.id}' },
273-
fields: { ai_category: '{ai.ai_category}', ai_sentiment: '{ai.ai_sentiment}' },
275+
fields: { ai_category: { dialect: 'cel', source: 'ai.ai_category' } },
274276
} },
275277
```
276278

277-
`defineStack({ functions: { 'helpdesk.aiTriageStub': (ctx) => ({ ai_category: 'other', … }) } })`.
278279
If you genuinely need data-lifecycle **side effects** (read/write other records),
279280
that's an L2 **hook** (objectstack-data) — hooks get `ctx.api`; flow functions don't.
280281

‎skills/objectstack-automation/evals/flows-triggers-approvals.json‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
{
55
"id": 1,
66
"prompt": "Every night at 02:00, mark `subscription` records whose `renewal_date` is in the past as `lapsed`. `status` is a readonly field that only automation may change.",
7-
"expected_output": "Emits a `defineFlow` with `type: 'schedule'`, the cadence on the START NODE's `config.schedule: { type: 'cron', expression: '0 2 * * *' }` (FlowSchema has no top-level `schedule`; no cron tagged template), `runAs: 'system'` because a scheduled run has no trigger user and a readonly field is stripped under `runAs: 'user'`. One `update_record` node with `filter` as an ObjectQL where-map (`renewal_date: { $lt: '{TODAY()}' }`) and `fields: { status: 'lapsed' }` (not `values`), `label` on every node, nodes wired with `edges` (never `next`), ending in an `end` node.",
7+
"expected_output": "Emits a `defineFlow` with `type: 'schedule'`, the cadence on the START NODE's `config.schedule: { type: 'cron', expression: '0 2 * * *' }` (no top-level `schedule`; no cron tag), `runAs: 'system'` because a scheduled run has no trigger user and a readonly field is stripped under `runAs: 'user'`. One `update_record` node with `filter` as an ObjectQL where-map (`renewal_date: { $lt: '{TODAY()}' }`) and `fields: { status: 'lapsed' }` (not `values`), `label` on every node, `edges` (never `next`), an `end` node.",
88
"files": [],
99
"assertions": {
1010
"must_contain": ["defineFlow", "type: 'schedule'", "runAs: 'system'", "update_record", "edges"],
@@ -44,11 +44,11 @@
4444
{
4545
"id": 5,
4646
"prompt": "Add a step that calls our `pricing.recalc` function to compute the discount and saves it on the quote, then posts the quote to our tax service over HTTP; if the tax call fails, route to a handler node instead of failing the run.",
47-
"expected_output": "Emits a `script` node with `config.function: 'pricing.recalc'` (registered via `defineStack({ functions })`), `inputs`, and `outputVariable: 'recalc'`; the function is a pure compute step, so a following `update_record` persists `{recalc.discount}` via `fields`. The `http` node sets `timeoutMs`. Failure routing is an edge `{ source, target, type: 'fault' }` — a `label: 'error'` alone routes nothing.",
47+
"expected_output": "Emits a `script` node with `config.function: 'pricing.recalc'` (registered via `defineStack({ functions })`), `inputs`, and `outputVariable: 'recalc'`; the function is a pure compute step, so a following `update_record` persists it with `fields: { discount: { dialect: 'cel', source: 'recalc.discount' } }`. The `http` node sets `timeoutMs`. Failure routing is a `type: 'fault'` edge — a `label: 'error'` alone routes nothing.",
4848
"files": [],
4949
"assertions": {
50-
"must_contain": ["function:", "outputVariable", "type: 'fault'", "timeoutMs", "update_record"],
51-
"must_not_contain": ["label: 'error'", "query_record"]
50+
"must_contain": ["function:", "outputVariable", "type: 'fault'", "timeoutMs", "update_record", "dialect: 'cel'"],
51+
"must_not_contain": ["label: 'error'", "query_record", "{recalc"]
5252
}
5353
}
5454
]

0 commit comments

Comments
 (0)