Skip to content

Commit 0e9e7b7

Browse files
feat(spec)!: flow value slots refuse the {…} template dialect, naming the CEL spelling of each token (#19939, C half) (#22259)
Part of #19939 — this lands the C half for every spelling CEL can write today, and measures the B half empty. What stays open on the card: refusing the two spellings this PR deliberately keeps (`{NOW()}` / `{TODAY() ± N}` and `{$User.*}`), each once CEL can spell it — see "Kept, and why" and the report's open question. Clause-②: yes (narrowing) ## The rulings this executes (quoted, not paraphrased) - #11182 ruling D (record `5805777944`, maintainer 「11182 D 其他同意」), item 3: "**v18 carrier.** C (refuse the template dialect at registration, per-spelling remedies: `/ 100.0`, `has()` guards, a string form for `NOW()` / `TODAY()`) and whatever of B is lossless ride the v18 train"; governing text: "ADR-0087 D2 (only lossless mappings ride an automatic conversion — the 12 DIFF spellings the round measured are not lossless)". - The card body: "B, only where lossless. An ADR-0087 D2 conversion for the spellings the #11182 round measured as SAME under both engines (13 of 25); the 12 DIFF spellings are ⛔ never auto-converted (a semantic rewrite is not a conversion)." and "this repo's 21 sites migrate in the same wave". - Triage `6051196407`: the card is `domain:spec` whole; the `service-automation` and `lint` files are declared cross-lane on the claim `6052247536`. The text-slot interpolation in `builtin/template.ts` is #22110's and is untouched (one docblock paragraph names the value-slot retirement; no code there changed). - Release state, re-read at push on `origin/main` `f4bed5834` (merged into this branch): `.changeset/pre.json` is **present** (`"mode": "pre"`, `"tag": "next"` — #22084 entered pre mode at 07:03Z today), so per triage `6038868940` and the dispatch this is graded `major`, with a BREAKING section and an ADR-0087 `registered` disposition. It was absent at the claim (`959c209d5`) and at this branch's first merge of `main` (`7d7943dd0`); the changeset moved from `minor` to `major` in the commit after the second merge. ## What changes A flow VALUE slot no longer reads the single-brace `{…}` template dialect. In every value slot — `create_record.fields.*`, `update_record.fields.*`, the `assignment` node's `assignments` map, and the two legacy `assignment` shapes the executor still reads (the `assignments: [{ variable, value }]` array and the bare config) — a string, or a string at any depth of an array or object value, that carries a `{…}` token the interpolator would resolve is refused, with the CEL spelling of each token. A string with no token is the literal text it spells; a computed value is a CEL value envelope. **One judge, every door** — `packages/spec/src/automation/flow-value-slot-template.ts`: - `valueSlotTemplateRefusals(value)` judges one value; `flowNodeValueTemplateRefusals(nodeType, config)` locates every refusal in a node's config (the ledger's `value` slots through `resolveFlowNodeValueSlots`, plus the two legacy `assignment` shapes normalised exactly as the executor normalises them); `VALUE_SLOT_TEMPLATE_REFUSAL` is the sentence every refusal leads with. - The contract says it: `celValueSlotSchema` composes the judge, so `FlowValueSlotSchema`, `AssignmentValueSchema`, `CreateRecordConfigSchema` and `UpdateRecordConfigSchema` refuse it at the value's path, and `AssignmentConfigSchema`'s catchall (the bare legacy shape) does too — one new dropped-refinement site, ledgered (`automation/AssignmentConfig` `out.catchall`, totals 678 → 679). - **Registration** — `AutomationEngine.registerFlow` (`validateFlowExpressions`) pushes one located failure per refused string: `node 'w' (create_record) create_record field value at config.fields.total: …`. - **Build door** — `@objectstack/lint` `validateStackExpressions` reports the same refusal as `expression-invalid` at `error` (the existing rule family; it gates `os validate`, `os compile` and the metadata save door). This replaces the `warning` hint ruling D point 1 put there for 17.x. - **Run time** — the `create_record` / `update_record` executors refuse it at their own `parseNodeConfig` (through `FlowValueSlotSchema`), and the `assignment` executor calls `flowNodeValueTemplateRefusals` before it assigns anything; both return a guard refusal, so a fault edge cannot route it. **The remedies, per spelling** (the refusal names them for the authored token): | you wrote | the refusal prescribes | what changes | |:--|:--|:--| | `'{record.owner}'`, `'{x}'` | `{ dialect: 'cel', source: 'record.owner' }` | CEL refuses an absent variable or key where the template wrote nothing — the `has()` guard: `has(record.owner) ? record.owner : null`, `has(vars.x) ? vars.x : null` (writes `null`) | | `'{list.0}'` | `source: 'list[0]'` | an empty list fails the run | | `'{$error.message}'` | `source: 'vars["$error"].message'` | a `$`-named variable is read through `vars` | | `'{round(x * 100) / 100}'` | `source: 'round(x * 100) / 100.0'` | `/ 100.0`: CEL divides two integers as integers (`123.46` becomes `123`) — every integer divisor is rewritten in the prescription | | `'Renewal — {contract.number}'` | `source: "'Renewal — ' + contract.number"` | wrap a non-string hole in `string(…)`, one that may be null in `coalesce(…, '')` | | `'{"a": 1}'` (braces meant literally) | `source: "'{\"a\": 1}'"` | a CEL string literal | ## B — measured empty: no spelling is lossless (ADR-0087 D2) Re-measured on this branch, not copied: every spelling was evaluated through the shipped interpolator (`interpolateString`) and through the shipped CEL value path (`AutomationEngine.evaluateValueEnvelope`, the real `celScope`) over the same variables. The 25-row grid reproduces the #11182 round exactly — **13 SAME / 12 DIFF**: | # | spelling and input | template | CEL | verdict | |:--|:--|:--|:--|:--| | S1–S4 | `{name}`, `{amount}`, `{oppRecord.name}`, `{oppRecord.amount}` — key present | the value | the value | SAME | | S5 | `{userList.0}` → `userList[0]`, list non-empty | `"u1"` | `"u1"` | SAME | | S6 | `{$error.message}` → `vars["$error"].message`, present | `"boom"` | `"boom"` | SAME | | S7, S8 | `Hello {o.name}`, `Total: {amount}` → concatenation, holes present | the text | the text | SAME | | S9 | `{o.owner}`, key present with `null` | `null` | `null` | SAME | | S10 | token-free `converted` → `'converted'` | the text | the text | SAME | | S11 | `{round(… / 100 * 100) / 100}`, integer-valued amount | `54000` | `54000` | SAME | | S12, S13 | `{rows}` (a list), `{flag}` (a boolean) | the value | the value | SAME | | D1, D2 | the money spelling, `amount` `1234.56` | `123.46`, `1111.1` | `123`, `1111` | DIFF | | D3 | `{10 / 4}` | `2.5` | `2` | DIFF | | D4, D5 | `{NOW()}` → `now()`, `{TODAY()}` → `today()` | ISO text | a `Date` (Timestamp) | DIFF | | D6 | `string(now())` | ISO text | refused: no `string(Timestamp)` overload | DIFF | | D7 | `{missing}` — variable absent | `undefined` | fault: unknown variable | DIFF | | D8 | `{oppRecord.owner}` — key absent | `undefined` | fault: no such key | DIFF | | D9 | `{userList.0}` — empty list | `undefined` | fault: index out of bounds | DIFF | | D10 | `Hello {o.owner}` — hole `null` | `"Hello "` | fault: no overload for string plus null | DIFF | | D11 | `{$User.Id}` → `current_user.id` | the run user id | fault: unknown variable `current_user` | DIFF | | D12 | token-free `converted` read as CEL source | the text | fault: unknown variable | DIFF | The "13 of 25" in the card counted **probes**, not spellings: S1–S13 are the present-key / integer-valued scenarios of the same spellings whose absent-key, null-hole, decimal and Timestamp scenarios are D1–D12. Read per spelling — the unit a conversion rewrites — every authored spelling has a DIFF input: a path faults where the template wrote nothing (D7–D9), text with holes faults on a null hole (D10), arithmetic truncates (D1–D3), the date macros change type (D4–D6), `$User` has no binding (D11). Only a token with no variable in it (`{1.5}`, `{100}`) maps losslessly, and none is authored in either repository. Controls beyond the 25: a `has()` guard writes `null` where the template wrote nothing (X2–X4), so it is a semantic rewrite, not a conversion. So, by D2's letter and the card's own "a semantic rewrite is not a conversion", **no D2 conversion is registered**; the retirement is the D3 semantic entry `flow-value-slot-template-dialect-refused` (step 18, rationale fragment order 88). A pin replays the whole conversion chain, retired entries included, over every measured spelling and asserts each value comes out as authored. ## Kept, and why — two spellings CEL cannot write yet A refusal must name what to write instead. For two spellings there is nothing to name, measured: - **The date macros** — `{NOW()}`, `{TODAY()}`, `± N` days. CEL's `now()` / `today()` / `daysFromNow()` / `addDays()` yield a Timestamp, which reaches the data engine as a `Date` object (measured through ObjectQL with a recording driver: `today()` into a `date` field arrives as `Date(2026-10-08T00:00:00.000Z)` where the macro wrote `"2026-10-08"`), and `string(today())` is refused for want of an overload. This is the card's own contingency ("a `packages/formula` sub-card if v18 needs it") — it does. - **The run user** — `{$User.*}`. The flow CEL scope binds no user (`current_user.id` faults, D11). Binding ADR-0068's canonical `current_user` there is a contract decision this PR does not take (open question in the report). A string whose tokens include one of these keeps its 17.x meaning; everything else in it would be refused if moved alone, so the whole string is kept. Their refusal is the remaining half of #19939, after the two prerequisites. ## This repository's sites (census at `959c209d5`) A TypeScript-AST walk over `git ls-files` (every `create_record` / `update_record` `fields` value and `assignment` value, all three shapes, same-file spreads): **21 authored sites**, 20 migrated here, 1 kept. | file:line (base) | before | after | |:--|:--|:--| | `examples/app-crm/src/flows/convert-lead.flow.ts:148` | `'{account_id}'` | `source: 'account_id'` | | `examples/app-crm/src/flows/convert-lead.flow.ts:149` | `'{opportunity_id}'` | `source: 'opportunity_id'` | | `examples/app-showcase/src/automation/flows/index.ts:115` | `'{new_assignee}'` | `source: 'new_assignee'` | | `examples/app-showcase/src/automation/flows/index.ts:1235` | `'{$error.message}'` | `source: 'vars["$error"].message'` | | `examples/app-showcase/src/automation/flows/index.ts:1602` | `'{record.title}'` | `source: 'record.title'` | | `examples/app-showcase/src/automation/flows/index.ts:1603` | `'{record.assignee}'` | `source: 'has(record.assignee) ? record.assignee : null'` | | `examples/app-showcase/src/automation/flows/index.ts:1604` | `'{record.project}'` | `source: 'has(record.project) ? record.project : null'` | | `examples/app-todo/src/flows/task.flow.ts:377` | `'{completedTask.subject}'` | `source: 'completedTask.subject'` | | `examples/app-todo/src/flows/task.flow.ts:377` | `'{completedTask.description}'` | guarded `has(completedTask.description) ? … : null` | | `examples/app-todo/src/flows/task.flow.ts:378` | `'{completedTask.priority}'` | guarded | | `examples/app-todo/src/flows/task.flow.ts:378` | `'{completedTask.category}'` | guarded | | `examples/app-todo/src/flows/task.flow.ts:379` | `'{completedTask.owner}'` | guarded | | `examples/app-todo/src/flows/task.flow.ts:380` | `'{completedTask.recurrence_type}'` | guarded | | `examples/app-todo/src/flows/task.flow.ts:381` | `'{completedTask.recurrence_interval}'` | guarded | | `examples/app-todo/src/flows/task.flow.ts:384` | `'{nextDueDate}'` | `source: 'nextDueDate'` | | `examples/app-todo/src/flows/task.flow.ts:457` | `'{subject}'` | `source: 'subject'` | | `examples/app-todo/src/flows/task.flow.ts:457` | `'{priority}'` | `source: 'has(vars.priority) ? vars.priority : null'` | | `examples/app-todo/src/flows/task.flow.ts:457` | `'{dueDate}'` | `source: 'has(vars.dueDate) ? vars.dueDate : null'` | | `examples/app-todo/src/flows/task.flow.ts:457` | `'{category}'` | `source: 'has(vars.category) ? vars.category : null'` | | `examples/app-todo/src/flows/task.flow.ts:457` | `'{$User.Id}'` | **kept** (the run user — see above) | | `packages/verify/src/handle.fixture.ts:191` | `'{resolution}'` | `source: 'resolution'` | Each guard is a judgment the template made silently: a field a screen may leave empty, or a key a row may not carry, writes `null` (on insert a field default still applies). Docs code samples migrated too: `content/docs/automation/flows.mdx` (the assignment and create-record examples, an inline comment, the hot-lead example), `content/docs/kernel/runtime-services/examples.mdx` (two fields), `packages/services/service-automation/README.md` (one field), and the test fixture `packages/qa/dogfood/test/fixtures/flow-durable-suspend-fixture.ts:81`. **hotcrm** (public, read-only clone at `c529de2`, not touched): 91 authored sites (2 of them through the same-file `MEMBERSHIP_FIELDS` spread) — **71 refused** (31 bare references, 36 dotted paths, 4 text with holes) and **20 kept** (15 date macros — 8 `{NOW()}`, 3 `{TODAY()}`, 3 `{TODAY() + N}`, 1 `{TODAY() + var}` — and 5 `{$User.Id}`, all `owner_id` on `create_record`); plus 5 in tests (4 refused, 1 kept). Its two money sites already use the CEL envelope with `/ 100.0`. ## H4 — where `{var}` is still read after this change - **In the three value slots:** the interpolator call stays (`crud-nodes.ts` `resolveFieldValues`, `logic-nodes.ts`), reached only by the two kept spellings; on every other literal it is the identity. It is removed when the kept spellings are refused. Pinned: the kept spellings resolve through the executors (`logic-nodes.test.ts`, `crud-fields-value-envelope.test.ts`), and `value-slot-template-grammar.test.ts` drives the interpolator over every kept and refused spelling so the spec's copy of the token grammar cannot drift from `resolveToken`. - **Outside them, unchanged by this card:** text slots (#22110's: `notify` title / message, screen text, `end` message), `filter` values (`interpolateFilter`, the filter-placeholder hand-off), `loop.collection` / `map.collection` (the ledger's `flow-template` role), and the value-like positions `subflow.input`, `map.input`, `script.inputs`, `screen.defaults`, a screen field's `defaultValue`, and `http` (interpolated whole before its parse). ## Wrong guidance (ruling D item 2) `builtin/template.ts` and `content/docs/automation/flows.mdx` already carried `/ 100.0` on `main` (#20205). This PR removes the last `round(x * 100) / 100` claim in its surface: the comment in `flow-field-expression-scale.integration.test.ts` that called it "the CEL-identical authoring pattern" (the oracle now runs as a CEL envelope with `/ 100.0`, and the docblock states integer division). `skills/objectstack-automation/SKILL.md:232` still reads `{round(x * 100) / 100}` and teaches `{token}` in `fields`; it is Tier H and not in this PR. ## BREAKING An accept-set narrowing on a published authoring surface (`Clause-②: yes (narrowing)`, copied from the claim), graded `major` on the v18 `next` pre line; the changeset carries the BREAKING banner, the FROM → TO table above and the ADR-0087 disposition `registered flow-value-slot-template-dialect-refused`. A stored flow carrying a refused value is refused at registration (skipped at boot with a warn naming it); `os validate` names each one with its CEL spelling. ## Tests and gates This PR opens at `b073d92de`. The package suites, typechecks and consumer runs below were read at `31c52e59c` (or the commit named); the second merge of `main` after it changed nothing under `packages/spec/src/automation`, `packages/lint`, `packages/services/service-automation`, `packages/triggers`, `packages/qa`, `packages/verify` or `examples` (in `packages/cli`, only its secret-rewrap files), and the spec build, generated-artifact check, spec migration / conversion / automation suites and every gate were re-run at `b073d92de`. The box is shared, so durations are not quoted. Builds, tests and typechecks ran through `scripts/pm/os-verify-lock.sh`, each read off its `VERDICT command-exit` line. - **Package suites** (vitest, `--maxWorkers=2`): `@objectstack/spec` 679 files, 19605 passed + 1 todo; `@objectstack/lint` 125 files, 5730 passed; `@objectstack/service-automation` 176 files, 2157 passed. - **Typecheck** (`pnpm --filter … run typecheck`, exit 0 each): spec, lint, service-automation, trigger-record-change, dogfood, cli, example-todo, example-showcase, example-crm, verify. - **Consumers** (after a full `turbo run build --concurrency=2`, exit 0): `trigger-record-change` 11 files / 114; `trigger-schedule` 8 / 174; `plugin-approvals` 61 / 899; `verify` 18 / 133; `example-todo` 7 / 238 (at `0d7eb274c`); `example-showcase` 33 / 408; `example-crm` 5 / 45; `mcp` 7 / 74, `metadata-protocol` 6 / 127 and `runtime` 20 / 548 — each the files of that package that author these node types or load an example (a narrowing, declared: the rest of those suites is CI's); `cli` unit 2 / 14 and integration 2 / 14 (the integration project run because this diff edits `package-install-local-boot-steps.integration.test.ts`; four more cli files I named are integration-tier and declared to CI); `dogfood` 3 / 25 (`flow-trigger-record-credential-mask`, `flow-durable-suspend`, `expression-conformance`). - **The new pins**: `packages/spec/src/automation/flow-value-slot-template.test.ts` (every refused class with its remedy, the kept spellings, the controls, every value-slot contract, every value position of a node, and the no-conversion replay); `packages/lint/src/validate-expressions.fields-value-slot.test.ts` (the build door: `expression-invalid` at `error`, located, in all three value slots and both legacy shapes; kept spellings and `filter` clean); `packages/services/service-automation/src/builtin/crud-fields-value-envelope.test.ts`, `logic-nodes.test.ts` and `assignment-value-envelope.test.ts` (registration and the run-time twin, nothing written, kept spellings resolve); `value-slot-template-grammar.test.ts` (the spec judge against the interpolator, kept and refused spellings and the dispatch-order edges). - **Generated artifacts**: `pnpm --filter @objectstack/spec check:generated` — 15 of 15 current after `--fix` regenerated `api-surface/`, `export-origins/` and `content/docs/references/**` on the merged tree, re-read exit 0 at `b073d92de` after `pnpm --filter @objectstack/spec build` (exit 0); `dropped-refinements.baseline.json` hand-edited (one site, totals 678 → 679). At `b073d92de` the spec's `src/migrations`, `src/conversions`, `src/automation` and `scripts` suites: 115 files, 3251 passed. - **Gates**: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `b073d92de` (47 paths vs merge base `f4bed5834`) derived 120 commands; all 120 ran with their exit codes captured before any pipe. 119 answered exit 0 on the first pass; `check:skill-examples` answered exit 3 PREREQUISITE NOT MET (`packages/client/dist` older than `src` after the merge — nothing measured), and after `pnpm --filter @objectstack/client build` (exit 0) it answered exit 0, "262 prose examples type-check across 3 surface(s)". `--ran` reconciliation: "120 derived famil(ies) accounted for — 120 run, 0 NOT-MEASURED". The printed artifact-roster block (35 roster, 14 checker-health self-tests) and the 11 declared wide-population families ran too: 57 exit 0; `check-closing-target-claim`, `check-partof-closing-keyword` and `check-single-claim-paths` answered exit 2 "NOT WIRED" (they need a PR number / body) — NOT MEASURED there; `check-partof-closing-keyword` with this body as `PR_BODY` is in the report. - **Merged `main`** twice through `scripts/pm/os-regen-merge.sh` (each merge committed first, regeneration as its own commit): at `7d7943dd0` (`pnpm install --frozen-lockfile` after it), and at `f4bed5834`, which brought `.changeset/pre.json` and #22166's step-18 entry (`sys-setting-global-rung-moved`, rationale order 87 — this PR's fragment keeps 88, the next free one; `gen:migration-registry` re-run on the merged tree changed nothing). Seven later `main` commits (to `73a0a6bf1`) are not merged: `git merge-tree` answers clean, their three overlapping files (`packages/lint/src/validate-expressions.ts` / `.test.ts`, `packages/spec/src/migrations/registry.ts`) change other regions, and none adds a `{…}` value-slot string. ## Acceptance notes - **`current_user` in a flow's CEL** — the build doors and the run disagree today, independently of this PR: `validateExpression('predicate', "current_user.id == 'u1'", { scope: 'flattened', … })` (the check `registerFlow` and `os validate` run on a flow condition) answers `ok`, and `ExpressionEngine.evaluate` over the flow's scope shape answers "Unknown variable: current_user" (measured at those two primitives; a door-level run is not part of this PR). Binding ADR-0068's `current_user` in the flow CEL scope would close that and give `{$User.*}` its remedy — the open question in the report. - **Value-like `{var}` positions outside this card's three slots** still read the dialect: `subflow.input`, `map.input`, `script.inputs`, `screen.defaults`, a screen field's `defaultValue`, and `http`. Text slots are #22110's; these have no carrier. - **Two findings on one value**: `validate-flow-template-paths` still checks a `{record.…}` path inside a value-slot string that is now refused anyway, so such a value draws both its path finding and the refusal. Harmless; no carrier. - **The save door**: the refusal is a `validateStackExpressions` finding, the rule the runtime publish gate runs on a flow write, so a Studio / REST / MCP save of such a flow answers `422 INVALID_METADATA` by construction — not separately measured in this PR (#19938 measured that door for the envelope arm of the same rule). - **Kept-spelling fixtures**: spec and lint test fixtures that only go through `FlowSchema.parse` or a non-expression rule (`flow.test.ts`, `validate-field-consumers.test.ts`, `validate-flow-template-paths.test.ts`, `validate-readonly-flow-writes.test.ts`) still spell a value-slot template; they pass, because `FlowSchema` judges no value slot and those rules filter by their own id. --- _Generated by [Claude Code](https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8cbe255 commit 0e9e7b7

47 files changed

Lines changed: 1589 additions & 451 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
---
2+
'@objectstack/spec': major
3+
'@objectstack/service-automation': major
4+
'@objectstack/lint': major
5+
---
6+
7+
A flow VALUE slot no longer reads the single-brace `{…}` template dialect: a `create_record` / `update_record` `fields` value or an `assignment` value that carries a `{…}` token is refused at `objectstack validate`, at `registerFlow` and by the executor, with the CEL spelling of each token. A computed value is a CEL value envelope, `{ dialect: 'cel', source: '…' }`; a string is the literal text it spells.
8+
9+
Clause-②: yes (narrowing)
10+
11+
<!-- adr-0087: registered flow-value-slot-template-dialect-refused -->
12+
13+
**BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`).
14+
15+
**Why.** Since #19938 the value slots also evaluate a CEL value envelope, so a flow had two dialects for one job — two function sets, two meanings of `/`, and a template that wrote nothing where CEL refuses. The maintainer's ruling D on the flow expression dialects put the template's retirement from these slots on the v18 train, with a remedy for every spelling and an automatic conversion only where one is lossless (ADR-0087 D2).
16+
17+
**What is refused.** In every value slot — `create_record.fields.*`, `update_record.fields.*`, the `assignment` node's `assignments` map, and the two legacy `assignment` shapes the executor still reads (the `assignments: [{ variable, value }]` array and the bare `{ <variable>: <value> }` config) — a string, or a string at any depth of an array or object value, that carries a `{…}` token the interpolator would resolve. One judge says it everywhere (`flowNodeValueTemplateRefusals` / `valueSlotTemplateRefusals` in `@objectstack/spec/automation`): `FlowValueSlotSchema`, `AssignmentValueSchema`, `CreateRecordConfigSchema`, `UpdateRecordConfigSchema` and `AssignmentConfigSchema` refuse it at the value's path; `objectstack validate` reports it as `expression-invalid` at `error`, located at the node and `config.<path>`, which also refuses it at the metadata save door; `registerFlow` refuses the flow (a stored flow carrying one is skipped at boot with a warn naming it); and the `create_record` / `update_record` / `assignment` executors refuse the node before writing anything. Each refusal leads with `VALUE_SLOT_TEMPLATE_REFUSAL` and then names the CEL spelling of the string's tokens.
18+
19+
**Not converted.** No D2 conversion rewrites any spelling: every token spelling authored in flows answers differently under CEL for some input (measured through both engines over the same variables, 13 of 25 probes the same, 12 different), so a rewrite would change what some flow writes. Where a value may be absent, whether the field should take nothing, `null` or a default was decided silently by the template; it is now the author's decision.
20+
21+
**What is still accepted, unchanged.** A CEL value envelope, every literal (a token-free string, numbers, booleans, `null`, arrays, objects), and two spellings CEL cannot write yet, which keep their meaning until it can:
22+
23+
- the date macros — `{NOW()}`, `{TODAY()}`, with an optional `± N` day offset. CEL's `now()` / `today()` / `daysFromNow()` / `addDays()` yield a Timestamp, which reaches the data engine as a `Date` object rather than the ISO text the macro wrote, and CEL has no string form for one;
24+
- the run user — `{$User.<path>}`. The flow CEL scope binds no user.
25+
26+
A string whose tokens include one of these is not refused. Text slots (`notify` `title` / `message`, a screen `description`, …) and `filter` values keep the template dialect.
27+
28+
## FROM → TO
29+
30+
| you wrote | write instead | what changes |
31+
|:--|:--|:--|
32+
| `'{record.owner}'`, `'{x}'` | `{ dialect: 'cel', source: 'record.owner' }` | CEL refuses an absent variable or key where the template wrote nothing — guard one that may be absent: `has(record.owner) ? record.owner : null`, `has(vars.x) ? vars.x : null` (writes `null`) |
33+
| `'{list.0}'` | `{ dialect: 'cel', source: 'list[0]' }` | an empty list fails the run |
34+
| `'{$error.message}'` | `{ dialect: 'cel', source: 'vars["$error"].message' }` | a `$`-named variable is read through `vars` |
35+
| `'{round(x * 100) / 100}'` | `{ dialect: 'cel', source: 'round(x * 100) / 100.0' }` | CEL divides two integers as integers: keep a decimal operand on every division, or `123.46` becomes `123` |
36+
| `'Renewal — {contract.number}'` | `{ dialect: 'cel', source: "'Renewal — ' + contract.number" }` | wrap a non-string hole in `string(…)`, one that may be null in `coalesce(…, '')` |
37+
| braces meant literally, `'{"a": 1}'` | `{ dialect: 'cel', source: "'{\"a\": 1}'" }` | a CEL string literal |
38+
39+
**The one-line fix: write the CEL spelling the refusal names, and guard a value that may be absent.**
40+
41+
**Who is affected, measured.** A TypeScript-AST census of every `create_record` / `update_record` `fields` value and `assignment` value (all three shapes, same-file spreads included): this repository at `959c209d5` carried 21 authored sites (`examples/**` and `packages/verify/src`) — 20 refused (9 bare references, 10 dotted paths, 1 `$error` path, all migrated in this change) and 1 kept (`{$User.Id}`, `examples/app-todo`); hotcrm at `c529de2` carries 91 (2 of them through a same-file spread) — 71 refused (31 bare references, 36 dotted paths, 4 text with holes) and 20 kept (15 date macros, 5 `{$User.Id}`). Deployed metadata and other repositories were not measured.
42+
43+
### The kit
44+
45+
- **The refusal.** `automation/flow-value-slot-template.ts` (`VALUE_SLOT_TEMPLATE_REFUSAL`, `valueSlotTemplateRefusals`, `flowNodeValueTemplateRefusals`), composed into the value-slot contracts in `automation/builtin-node-config.zod.ts`; one new dropped-refinement site (`automation/AssignmentConfig` `out.catchall`, the bare legacy shape's values).
46+
- **The doors.** `AutomationEngine.registerFlow` and the `create_record` / `update_record` / `assignment` executors (`@objectstack/service-automation`); `validateStackExpressions` (`@objectstack/lint`), whose `warning` hint pointing a template expression at the envelope this refusal replaces.
47+
- **The ledger.** The D3 semantic entry `flow-value-slot-template-dialect-refused` (protocol 18). No key is removed, so there is no tombstone, and there is no D2 conversion: no authored spelling maps losslessly.

‎content/docs/automation/flows.mdx‎

Lines changed: 79 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -193,31 +193,33 @@ node type has registered, after the boot pull had registered it.
193193
label: 'Build digest',
194194
config: {
195195
assignments: {
196-
// `{token}` flow interpolation — a sole token keeps the token's type,
197-
// text with holes stays text
198-
owner_name: '{manager.name}',
199-
// CEL value envelope — evaluated by the expression engine to a value, so
196+
// CEL value envelope — evaluated by the expression engine to a value,
197+
// type kept; a path copies the value it names
198+
owner_name: { dialect: 'cel', source: 'manager.name' },
200199
// the declared CEL stdlib is reachable from metadata: one line per task
201200
digest: { dialect: 'cel', source: 'joinNonEmpty(overdue_tasks.map(t, t.subject), "\\n")' },
201+
// anything else is a literal, written as it is
202+
status: 'pending',
202203
},
203204
},
204205
}
205206
```
206207

207-
A value's **shape** selects its form — there is no mode key. A plain string is
208-
always `{token}` interpolation (a bare `a + b` is the literal text `a + b`, not
209-
CEL); an object that names a `dialect` is an expression envelope and must be a
210-
valid `cel` one — a missing, empty, whitespace-only or non-string `source`, an
211-
`ast` with no `source`, or a `template` / `cron` dialect, is refused at the
212-
variable's path. Numbers, booleans, arrays and plain objects are assigned as
213-
literals. A later `notify` node renders the variable as any other:
214-
`message: '{digest}'`, and it renders the **evaluated** value.
208+
A value's **shape** selects its form — there is no mode key. An object that
209+
names a `dialect` is an expression envelope and must be a valid `cel` one — a
210+
missing, empty, whitespace-only or non-string `source`, an `ast` with no
211+
`source`, or a `template` / `cron` dialect, is refused at the variable's path.
212+
Everything else is a **literal**, assigned as it is: a string is the text it
213+
spells (a bare `a + b` is the literal text `a + b`, not CEL), and numbers,
214+
booleans, arrays and plain objects are what they look like. A later `notify`
215+
node renders the variable as any other: `message: '{digest}'`, and it renders
216+
the **evaluated** value.
215217

216218
The same rules hold for the field values of `create_record` / `update_record`
217219
(below): the `assignments` map and the `fields` map are the flow's **value
218-
slots**, and each accepts a CEL value envelope beside `{token}` templates and
219-
literals. Only a slot's top-level value is judged — an object nested inside a
220-
JSON value or an array is data, whatever keys it carries.
220+
slots**, and each takes a CEL value envelope or a literal. Only a slot's
221+
top-level value is an expression — an object nested inside a JSON value or an
222+
array is data, whatever keys it carries.
221223

222224
<Callout type="info" title="Where a malformed envelope is refused">
223225

@@ -241,6 +243,34 @@ nothing is assigned or written in its place.
241243

242244
</Callout>
243245

246+
<Callout type="warn" title="The `{…}` template dialect is retired from value slots">
247+
248+
A value slot no longer reads `{token}` interpolation ([#19939]): a string there
249+
is the literal text it spells, so one carrying a `{…}` token — `'{record.owner}'`,
250+
`'Hello {name}'`, `'{round(x * 100) / 100}'`, at any depth of an array or object
251+
value, in all three `assignment` shapes — is **refused** at `objectstack
252+
validate`, at `registerFlow` and by the executor, with the CEL spelling of each
253+
token. Nothing converts it for you: every spelling answers differently under CEL
254+
for some input, so the rewrite is yours to judge.
255+
256+
| you wrote | write instead | what changes |
257+
|:---|:---|:---|
258+
| `'{record.owner}'`, `'{x}'` | `{ dialect: 'cel', source: 'record.owner' }` | CEL refuses an **absent** variable or key where the template wrote nothing — guard one that may be absent: `has(record.owner) ? record.owner : null`, `has(vars.x) ? vars.x : null` (writes `null`) |
259+
| `'{list.0}'` | `source: 'list[0]'` | an empty list fails the run |
260+
| `'{$error.message}'` | `source: 'vars["$error"].message'` | a `$`-named variable is read through `vars` |
261+
| `'{round(x * 100) / 100}'` | `source: 'round(x * 100) / 100.0'` | CEL divides two integers as integers: keep a decimal operand on every division |
262+
| `'Follow up on {record.name}'` | `source: "'Follow up on ' + record.name"` | wrap a non-string hole in `string(…)`, one that may be null in `coalesce(…, '')` |
263+
| braces meant literally, `'{"a": 1}'` | `source: "'{\"a\": 1}'"` | a CEL string literal |
264+
265+
Two spellings **keep** their meaning for now, because CEL cannot write them yet:
266+
the date macros (`'{NOW()}'`, `'{TODAY() + 7}'` — CEL's `now()` / `today()` are
267+
timestamps, not the ISO text the macros write, and there is no string form for
268+
one) and the run user (`'{$User.Id}'` — the flow's CEL scope binds no user).
269+
270+
[#19939]: https://github.com/objectstack-ai/objectstack/issues/19939
271+
272+
</Callout>
273+
244274
**Create Record:**
245275

246276
```typescript
@@ -251,12 +281,14 @@ nothing is assigned or written in its place.
251281
config: {
252282
objectName: 'task',
253283
fields: {
254-
title: 'Follow up on {record.name}',
255-
assignee: '{record.owner}',
256-
due_date: '{TODAY() + 7}', // braces required — without them this writes the literal text
257-
// CEL value envelope — evaluated to the value written, same rules as an
284+
// CEL value envelopes — evaluated to the value written, same rules as an
258285
// assignment value. `100.0`, not `100`: CEL divides two integers as integers.
286+
title: { dialect: 'cel', source: "'Follow up on ' + record.name" },
287+
assignee: { dialect: 'cel', source: 'has(record.owner) ? record.owner : null' },
259288
estimate: { dialect: 'cel', source: 'round(record.amount * 0.15 * 100.0) / 100.0' },
289+
// a date macro — one of the two `{…}` spellings a value slot still reads
290+
due_date: '{TODAY() + 7}',
291+
status: 'open', // a literal
260292
},
261293
},
262294
}
@@ -328,7 +360,7 @@ compile error carrying the same prescription.
328360
config: {
329361
function: 'scoreLead', // registered via defineStack({ functions })
330362
inputs: { rating: '{record.rating}' }, // {var} templates resolve against flow variables
331-
outputVariable: 'leadScore', // later: fields: { score: '{leadScore}' }
363+
outputVariable: 'leadScore', // later: fields: { score: { dialect: 'cel', source: 'leadScore' } }
332364
},
333365
}
334366
```
@@ -1882,42 +1914,47 @@ failures so one broken flow does not abort startup.
18821914

18831915
## Expressions in flows
18841916

1885-
A flow mixes **two expression dialects**, and the rule is short: **every
1886-
condition is CEL; braces are for values** — and the numeric functions the value
1887-
dialect accepts are mirrored from CEL's, so a name you learn in conditions
1888-
means the same thing inside braces.
1917+
A flow computes with **one expression dialect, CEL**: every condition is bare
1918+
CEL, and every computed value (a field value, an assignment value) is a CEL
1919+
value envelope. Braces are for **text** slots — a `notify` title or message, a
1920+
screen description — and the date macros a value slot still reads.
18891921

18901922
| Where | Dialect | Write it like | Bindings |
18911923
|:---|:---|:---|:---|
18921924
| Start-node `condition` | **CEL** (bare, no braces) | `record.amount > 500` | `record.*`, `previous.*`, bare field names, `vars.*` |
18931925
| Edge `condition` | **CEL** (bare, no braces) | `record.status == 'open'` | same as above |
18941926
| Decision-node `conditions[].expression` | **CEL** (bare, no braces) | `order_amount > 10000` | flow variables by name, and `vars.*` |
1895-
| Field values in `create_record` / `update_record` | **Interpolation** (braces required) | `'Follow up on {record.name}'`, `'{TODAY() + 7}'` | `{var}`, `{var.path}`, `{$User.Id}`, `{$User.Email}`, `{NOW()}`, `{TODAY()}`, `{TODAY() + 90}` (whole days), and the CEL-mirrored numeric functions `round`, `floor`, `ceil`, `abs`, `min`, `max` (#11060) — `round` is **integer-only**, exactly like CEL's (there is no `round(x, 2)`); for N decimals write `{round(x * 100) / 100.0}` (scale 2). Keep the decimal point: in CEL `round()` returns an int and `int / int` is integer division, so `round(x * 100) / 100` drops the decimals there — `/ 100.0` is right in both dialects |
1927+
| Field values and assignment values, as a **literal** | none — written as it is | `'open'`, `42`, `true`, `['a', 'b']` | — (a `{…}` template token is refused here since [#19939](https://github.com/objectstack-ai/objectstack/issues/19939), except the date macros `{NOW()}` / `{TODAY() ± N}` and `{$User.*}`, which still resolve until CEL can write them) |
18961928
| Field values and assignment values, as a **CEL value envelope** | **CEL** (in an envelope) | `{ dialect: 'cel', source: 'round(price * 100.0) / 100.0' }` | flow variables by name, and `vars.*` — the whole CEL stdlib (`joinNonEmpty`, …) |
18971929

1898-
A value slot takes either form, chosen by shape: a string is interpolation, an
1899-
object naming a `dialect` is a CEL envelope. The template form keeps working
1900-
unchanged; `objectstack validate` points a template **expression** — arithmetic
1901-
or one of the six functions inside braces — at the envelope with a warning,
1902-
never an error. Plain references (`{record.name}`), the date macros and
1903-
`{$User.*}` are left alone: CEL's `now()` / `today()` are timestamps, not the
1904-
strings the macros write, and the flow's CEL scope binds no user.
1930+
A value slot takes either form, chosen by shape: an object naming a `dialect`
1931+
is a CEL envelope, everything else is a literal. The `{token}` template dialect
1932+
it used to read is retired: `objectstack validate`, `registerFlow` and the
1933+
executor refuse a `{…}` token in a value slot with its CEL spelling (see *The
1934+
`{…}` template dialect is retired from value slots* above). The date macros and
1935+
`{$User.*}` are kept until CEL can write them: CEL's `now()` / `today()` are
1936+
timestamps, not the strings the macros write, and the flow's CEL scope binds no
1937+
user.
19051938

19061939
<Callout type="warn">
19071940
**The failure modes to memorize:**
19081941

1909-
1. **Braces missing in a field value** — `due_date: 'TODAY() + 7'` writes the
1910-
literal text `TODAY() + 7` into the field. Write `'{TODAY() + 7}'`.
1942+
1. **A computed value written as a string** — `due_date: 'TODAY() + 7'` or
1943+
`amount: 'price * 2'` writes the literal text into the field. Compute it with
1944+
a CEL value envelope (`{ dialect: 'cel', source: 'price * 2' }`); the date
1945+
macros keep their braces for now: `'{TODAY() + 7}'`.
19111946
2. **Braces put *into* a condition** — `'{record.amount} > 500'`. Conditions
19121947
fail loudly rather than silently, with an error that tells you to drop the
19131948
braces.
1914-
3. **An unsupported function in a field value** — `total: '{ROUND(x, 2)}'`,
1915-
`'{Math.round(x)}'`, `'{(x).toFixed(2)}'` — fails the node with a **named
1916-
error** listing the supported set and, where one is close, the spelling you
1917-
meant. Before #11060 this was a *silent* failure: the unknown name was
1918-
rewritten to `null` and the field was simply written `undefined`. A `fault`
1919-
edge does not catch this error — the expression itself is wrong, so
1920-
re-running can never succeed; fix the spelling.
1949+
3. **A `{…}` template in a value slot** — `total: '{round(x * 100) / 100}'`,
1950+
`owner: '{record.owner}'` — is refused with the CEL spelling to write
1951+
instead. In a text slot, an unsupported function in braces —
1952+
`'{ROUND(x, 2)}'`, `'{Math.round(x)}'`, `'{(x).toFixed(2)}'` — fails the node
1953+
with a **named error** listing the supported set and, where one is close,
1954+
the spelling you meant. Before #11060 this was a *silent* failure: the
1955+
unknown name was rewritten to `null` and the field was simply written
1956+
`undefined`. A `fault` edge does not catch this error — the expression
1957+
itself is wrong, so re-running can never succeed; fix the spelling.
19211958
</Callout>
19221959

19231960
<Callout type="info">
@@ -2145,7 +2182,7 @@ export const hotLeadFollowUp: Flow = {
21452182
objectName: 'task',
21462183
fields: {
21472184
subject: 'Follow up on hot lead',
2148-
related_to: '{record.id}',
2185+
related_to: { dialect: 'cel', source: 'record.id' },
21492186
priority: 'high',
21502187
},
21512188
},

‎content/docs/kernel/runtime-services/examples.mdx‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,12 @@ export const RollUpOrderTotals = defineFlow({
9393
config: {
9494
objectName: 'sales_order',
9595
filter: { id: '{record.id}' },
96-
fields: { line_count: '{totals.line_count}', amount_total: '{totals.total}' },
96+
// A value slot computes with a CEL value envelope; the script's
97+
// output `totals` is bound by the node before this one.
98+
fields: {
99+
line_count: { dialect: 'cel', source: 'totals.line_count' },
100+
amount_total: { dialect: 'cel', source: 'totals.total' },
101+
},
97102
},
98103
},
99104
{ id: 'end', type: 'end', label: 'End' },

0 commit comments

Comments
 (0)