Repository navigation
docs(skills): objectstack-automation teaches the CEL value envelope in fields / assignment values and / 100.0 for money - #22284
Conversation
…n fields / assignment values and / 100.0 for money
A flow value slot (create_record / update_record fields.*, an assignment
value) no longer reads the single-brace template dialect: a string is the
literal text it spells and a {…} token is refused at objectstack validate,
registerFlow and the executor, naming the CEL spelling. The skill's item 1,
item 2 and the script → update_record example now teach the
{ dialect: 'cel', source: '…' } envelope, the has() guard for a key that
may be absent, and / 100.0 for money (CEL divides two integers as
integers), and keep the two spellings a value slot still accepts
({NOW()} / {TODAY() ± N}, {$User.PATH}) and the text slots and filter that
keep the template. The eval that pinned {recalc.discount} in fields now
expects the envelope. Token ratchet paid inside each file.
Claude-Session: https://claude.ai/code/session_01CXydFDyiQwNbGFkmwrcRQq
Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Reviewed in-seat by the skills seat 1 (dispatching seat; this session is served at the contract-review tier, read off ① Derived judgments
② Semver levelNone. Published skill text and one eval; no package content, API or schema. ③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
维护者速读(终稿)席位:skills 席 1, 改了什么:发布版自动化技能( 为什么改:这份技能随 风险与代价(含回滚):只改技能文本和一条 eval,不碰 席位意见:同意合入,席内契约复核 PASS。席位自己在 你要做的:用授权账号( Generated by Claude Code |
Fixes #22260
Clause-②: no
skills/objectstack-automation/SKILL.mdtaught{token}values increate_record/update_recordfieldsand 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.mdis the FROM → TO authority for every spelling below) the tree refuses those values atobjectstack validate,registerFlowand the executors, and/ 100is 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/onlySKILL.md(+33 / −32 lines onorigin/mainc8bb3c8d9c)::224-241) is rewritten around the value slot: increate_record/update_recordfields.*and anassignmentvalue (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 workedhas()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/ 100turns123.46into123), 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 (notifytitle/message,scriptinputs,httpurl/body, …) andfilterkeep 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).:245-246):fields: { ref: '{newRec}' }/'{newRec.id}'→fields: { ref: { dialect: 'cel', source: 'newRec' } }(writes the whole record object) /source: 'newRec.id'.: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).:207-208,:213-214):fieldsdropped from "intitle,message,fieldsandurla bare{current_year_start}is a nonsense reference" (afieldsstring is now literal text, not a reference at all) and "write payload" dropped from "(message body,httpurl, write payload) only renders an empty string — a warning" (afields{record.x}is now an error at validate, not a warning).evals/flows-triggers-approvals.json(eval 5): "persists{recalc.discount}viafields" → "persists it withfields: { discount: { dialect: 'cel', source: 'recalc.discount' } }";must_containgainsdialect: 'cel',must_not_containgains{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:lineonc8bb3c8d9cSKILL.md:224-226config(fields,inputs, notifymessage/title, …) interpolate{token}"fieldsin the template setfieldsinputsand notify text keys stay in the template listSKILL.md:232{round(x * 100) / 100}source: 'round(x * 100) / 100.0'SKILL.md:235/:237body: '{{ai_reply}}'/body: '{ai_reply}'{ai_reply}is refused (path); in a text slot still the template{{x}}is the template-field dialect)SKILL.md:236-237ticket: '$source.id'/ticket: '{source.id}'fields-shaped key{source.id}refused (path)SKILL.md:238-241'{ROUND(x, 2)}'/'{Math.round(x)}'/'{(x).toFixed(2)}'{…}is refused at validate first; in a text slot still fails the node at run timeSKILL.md:245fields: { ref: '{newRec}' }update_recordfield valuefields: { ref: { dialect: 'cel', source: 'newRec' } }SKILL.md:246fields: { ref: '{newRec.id}' }update_recordfield valuesource: 'newRec.id'SKILL.md:267inputs: { ticketId: '{record.id}' }script.inputsscreen-nodes.ts:343still interpolatesSKILL.md:272filter: { id: '{record.id}' }filterSKILL.md:273fields: { ai_category: '{ai.ai_category}', ai_sentiment: '{ai.ai_sentiment}' }update_recordfield valueSKILL.md:107-114recipients/title/message/sourceIdSKILL.md:154{NODEID.error}(or run-wide{$error})"vars["$error"].messageSKILL.md:208/:213fields" / "write payload" in the filter-tokens prosereferences/examples-flows.md:41,:119-121{TODAY()},{TODAY() + N}filterreferences/examples-flows.md:44fields: { status: 'escalated' }references/state-machines-and-approvals.md:120filter: { id: '{record.id}' }, fields: { stage: 'closed_won' }filter/ literal fieldevals/flows-triggers-approvals.json:7renewal_date: { $lt: '{TODAY()}' },fields: { status: 'lapsed' }filter/ literalevals/flows-triggers-approvals.json:17recipients: '{record.owner_id}', "titlewith single-brace interpolation"evals/flows-triggers-approvals.json:47{recalc.discount}viafields"update_recordfield valuereferences/_index.mdis generator-owned and unchanged. Noos:check-marked block changed (the two in this package are inreferences/*.md, untouched).skills/**readings — the token ratchet, paid inside each filec8bb3c8d9c)5ac59ff0da)SKILL.mdevals/flows-triggers-approvals.jsonSKILL.mdToken =
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:{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. amultiple: truelookup, stored as an array)" → "(array index)".roundis integer-only (noround(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.body: '{{ai_reply}}'→ "{{x}}is the template-field dialect" in the text-slot paragraph.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).'{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, notfault-routable"), scoped to text slots — in a value slot the build refuses the{…}first.:277the one-linedefineStack({ functions: { 'helpdesk.aiTriageStub': … } })registration — spelled a third time (item 3's head showsdefineStack({ functions: { my_fn: … } })and the second fence registers'helpdesk.aiTriageStub') → deleted.: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-levelschedule; no cron tag)"; "nodes wired withedges(nevernext), ending in anendnode" → "edges(nevernext), anendnode"; "Failure routing is an edge{ source, target, type: 'fault' }" → "atype: 'fault'edge" (the shape stays pinned bymust_contain).Measured at the tree, not recalled
Refusal control — built
packages/spec/distat5ac59ff0da,flowNodeValueTemplateRefusalsandUpdateRecordConfigSchemaimported from@objectstack/spec/automation::273→ 2 refusals,path=fields.ai_category/fields.ai_sentiment,label=update_record field value; each message leads with theVALUE_SLOT_TEMPLATE_REFUSALsentence ("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 withhas():has(ai.ai_category) ? ai.ai_category : null(the guarded form writesnull).":246→ 1 refusal atfields.ref: "Write{newRec.id}as { dialect: 'cel', source: 'newRec.id' } …":232→ 1 refusal atfields.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, soround(x * 100) / 100drops the decimals whereround(x * 100) / 100.0keeps them."ai.ai_category,newRec.id,round(x * 100) / 100.0,has(record.owner) ? record.owner : null) → 0 refusals;UpdateRecordConfigSchema.safeParseon the old sample →success: falseat path["fields","ai_category"], on the new sample →success: true.fields('{NOW()}','{TODAY() + 30}','{$User.Id}') → 0 refusals; notifytitle/messageandscriptinputscarrying{record.id}→ 0 (not value slots); legacyassignments: [{ variable, value: '{record.amount}' }]→ 1 refusal atassignments[0].value.CEL evaluation — built
@objectstack/formula,ExpressionEngine.evaluate({ dialect: 'cel', source }, scope)with the engine's owncelScopeshape ({ 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)⇒12346has(record.missing) ? record.missing : null⇒null· barerecord.missing⇒ error "No such key: missing" ·record.tags[0]⇒"x"·has(vars.x) ? vars.x : null⇒123.456list[0]for a variable literally namedlist⇒ error "Cannot index type 'type' with type 'int'" (listis a CEL type name) — see Acceptance notes.Gates — local, at
5ac59ff0danode scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackon this tree derives 24 families from the two changed paths. All 24 ran, each exit code captured before any pipe;--ranreconciliation: "24 derived, 24 run, 0 NOT-MEASURED, 0 UNRUN (a DERIVED zero — all 24 recorded an exit code and none of them is 3)".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"node scripts/check-skills-token-ratchet.mjs --self-testpnpm 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)"pnpm check:corpus-claim-drift— "Scanned: 254 .md/.mdx file(s) … skills 53"pnpm check:doc-authoringpnpm check:nul-bytes— "scanned 10237 text file(s) … no raw ASCII control bytes"pnpm check:skill-compatibility— "11 pinned major(s) all match the workspace (@objectstack/spec is 17.x)"pnpm check:skill-frame-syncpnpm check:role-wordpnpm --filter @objectstack/spec run check:skill-docs— "Skill docs in sync"pnpm check:watch-hint-literalpnpm --filter @objectstack/lint run check:doc-formula-expressions— first answered exit 3, "PREREQUISITE NOT MET —@objectstack/lintis not built" (not a measurement); afterpnpm exec turbo run build --filter=@objectstack/lint --concurrency=2under the verify lock (VERDICT command-exit 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)pnpm check:agent-test-spelling·check:cross-package-test-inputs·check:driver-memory-census·check:gitlink-declared·check:pm-governed-merges·check:refd-timer-probecheck:skill-examplesnot 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 packagetest/typecheckis owed: the diff touches nopackages/**.Changeset: none —
skills/**publishes nothing from a released package;skip-changesetis applied with the PR assignee in onelabel-writecall (recorded in the report comment on #22260).Acceptance notes
'{list.0}'→{ dialect: 'cel', source: 'list[0]' }, andcelPathinpackages/spec/src/automation/flow-value-slot-template.tswhich every refusal message is built from, emitlist[0]for a variable literally namedlist; under CELlistis 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 throughobjectstack validate(whether validate acceptslist[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 teachesrecord.tags[0]instead.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 namesvars["$error"].message. Left as is (22 tokens of headroom); carrier: [v18] retire the{var}template dialect in flow assignment slots: refuse at registration with per-spelling remedies (the C half of #11182 ruling D, on the v18 train) #19939's second half, which will touch this item again.descriptionstill routes "CEL expressions in flow conditions / edge guards" to objectstack-formula; value envelopes now also are CEL. Untouched because the frontmatter regeneratesskills/README.mdandcontent/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