Repository navigation
Commit ee6aa01
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
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
204 | 204 | | |
205 | 205 | | |
206 | 206 | | |
207 | | - | |
208 | | - | |
| 207 | + | |
| 208 | + | |
209 | 209 | | |
210 | 210 | | |
211 | 211 | | |
212 | 212 | | |
213 | | - | |
214 | | - | |
| 213 | + | |
| 214 | + | |
215 | 215 | | |
216 | 216 | | |
217 | 217 | | |
| |||
221 | 221 | | |
222 | 222 | | |
223 | 223 | | |
224 | | - | |
225 | | - | |
226 | | - | |
227 | | - | |
228 | | - | |
229 | | - | |
230 | | - | |
231 | | - | |
232 | | - | |
233 | | - | |
234 | | - | |
235 | | - | |
236 | | - | |
237 | | - | |
238 | | - | |
239 | | - | |
240 | | - | |
241 | | - | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
242 | 245 | | |
243 | 246 | | |
244 | 247 | | |
245 | | - | |
246 | | - | |
| 248 | + | |
| 249 | + | |
247 | 250 | | |
248 | 251 | | |
249 | 252 | | |
| |||
260 | 263 | | |
261 | 264 | | |
262 | 265 | | |
263 | | - | |
264 | | - | |
| 266 | + | |
265 | 267 | | |
266 | 268 | | |
267 | 269 | | |
| |||
270 | 272 | | |
271 | 273 | | |
272 | 274 | | |
273 | | - | |
| 275 | + | |
274 | 276 | | |
275 | 277 | | |
276 | 278 | | |
277 | | - | |
278 | 279 | | |
279 | 280 | | |
280 | 281 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
4 | 4 | | |
5 | 5 | | |
6 | 6 | | |
7 | | - | |
| 7 | + | |
8 | 8 | | |
9 | 9 | | |
10 | 10 | | |
| |||
44 | 44 | | |
45 | 45 | | |
46 | 46 | | |
47 | | - | |
| 47 | + | |
48 | 48 | | |
49 | 49 | | |
50 | | - | |
51 | | - | |
| 50 | + | |
| 51 | + | |
52 | 52 | | |
53 | 53 | | |
54 | 54 | | |
| |||
0 commit comments