Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
87a3bc4
wip(spec): value-slot template-dialect judge (#19939)
claude Oct 8, 2026
dad0062
wip: registerFlow, executors and os validate refuse the value-slot te…
claude Oct 8, 2026
133a147
wip: D3 entry, in-repo site migration and docs for the value-slot tem…
claude Oct 8, 2026
0540049
wip(spec): judge legacy assignment shapes as literals; test updates (…
claude Oct 8, 2026
5a8c5d7
wip(spec): ledger the bare legacy assignment catchall refinement (#19…
claude Oct 8, 2026
637ec01
wip(lint): the value-slot template refusal pins; any call is an expre…
claude Oct 8, 2026
a96cc41
wip: changeset for the value-slot template retirement (#19939)
claude Oct 8, 2026
03d403a
wip(service-automation): tests for the value-slot template refusal (#…
claude Oct 8, 2026
2983595
wip(service-automation): envelope test expectations (#19939)
claude Oct 8, 2026
ceb5821
wip: consumer tests use CEL value envelopes in value slots (#19939)
claude Oct 8, 2026
f074c9f
Merge remote-tracking branch 'origin/main' into claude/issue-19939-fl…
claude Oct 8, 2026
c03d771
chore(spec): regenerate api-surface, export-origins and reference doc…
claude Oct 8, 2026
0d7eb27
wip(example-todo): the pre-fix recurrence shape is refused at registr…
claude Oct 8, 2026
31c52e5
wip(service-automation): type the kept-spellings test output (#19939)
claude Oct 8, 2026
04346ce
Merge remote-tracking branch 'origin/main' into claude/issue-19939-fl…
claude Oct 8, 2026
b073d92
chore(changeset): grade the value-slot template retirement major — th…
claude Oct 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions .changeset/19939-flow-value-slot-template-dialect-refused.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
---
'@objectstack/spec': major
'@objectstack/service-automation': major
'@objectstack/lint': major
---

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.

Clause-②: yes (narrowing)

<!-- adr-0087: registered flow-value-slot-template-dialect-refused -->

**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.*`).

**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).

**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.

**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.

**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:

- 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;
- the run user — `{$User.<path>}`. The flow CEL scope binds no user.

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.

## FROM → TO

| you wrote | write instead | what changes |
|:--|:--|:--|
| `'{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`) |
| `'{list.0}'` | `{ dialect: 'cel', source: 'list[0]' }` | an empty list fails the run |
| `'{$error.message}'` | `{ dialect: 'cel', source: 'vars["$error"].message' }` | a `$`-named variable is read through `vars` |
| `'{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` |
| `'Renewal — {contract.number}'` | `{ dialect: 'cel', source: "'Renewal — ' + contract.number" }` | wrap a non-string hole in `string(…)`, one that may be null in `coalesce(…, '')` |
| braces meant literally, `'{"a": 1}'` | `{ dialect: 'cel', source: "'{\"a\": 1}'" }` | a CEL string literal |

**The one-line fix: write the CEL spelling the refusal names, and guard a value that may be absent.**

**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.

### The kit

- **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).
- **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.
- **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.
121 changes: 79 additions & 42 deletions content/docs/automation/flows.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -193,31 +193,33 @@ node type has registered, after the boot pull had registered it.
label: 'Build digest',
config: {
assignments: {
// `{token}` flow interpolation — a sole token keeps the token's type,
// text with holes stays text
owner_name: '{manager.name}',
// CEL value envelope — evaluated by the expression engine to a value, so
// CEL value envelope — evaluated by the expression engine to a value,
// type kept; a path copies the value it names
owner_name: { dialect: 'cel', source: 'manager.name' },
// the declared CEL stdlib is reachable from metadata: one line per task
digest: { dialect: 'cel', source: 'joinNonEmpty(overdue_tasks.map(t, t.subject), "\\n")' },
// anything else is a literal, written as it is
status: 'pending',
},
},
}
```

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

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

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

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

</Callout>

<Callout type="warn" title="The `{…}` template dialect is retired from value slots">

A value slot no longer reads `{token}` interpolation ([#19939]): a string there
is the literal text it spells, so one carrying a `{…}` token — `'{record.owner}'`,
`'Hello {name}'`, `'{round(x * 100) / 100}'`, at any depth of an array or object
value, in all three `assignment` shapes — is **refused** at `objectstack
validate`, at `registerFlow` and by the executor, with the CEL spelling of each
token. Nothing converts it for you: every spelling answers differently under CEL
for some input, so the rewrite is yours to judge.

| you wrote | write instead | what changes |
|:---|:---|:---|
| `'{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`) |
| `'{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'` | CEL divides two integers as integers: keep a decimal operand on every division |
| `'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(…, '')` |
| braces meant literally, `'{"a": 1}'` | `source: "'{\"a\": 1}'"` | a CEL string literal |

Two spellings **keep** their meaning for now, because CEL cannot write them yet:
the date macros (`'{NOW()}'`, `'{TODAY() + 7}'` — CEL's `now()` / `today()` are
timestamps, not the ISO text the macros write, and there is no string form for
one) and the run user (`'{$User.Id}'` — the flow's CEL scope binds no user).

[#19939]: https://github.com/objectstack-ai/objectstack/issues/19939

</Callout>

**Create Record:**

```typescript
Expand All @@ -251,12 +281,14 @@ nothing is assigned or written in its place.
config: {
objectName: 'task',
fields: {
title: 'Follow up on {record.name}',
assignee: '{record.owner}',
due_date: '{TODAY() + 7}', // braces required — without them this writes the literal text
// CEL value envelope — evaluated to the value written, same rules as an
// CEL value envelopes — evaluated to the value written, same rules as an
// assignment value. `100.0`, not `100`: CEL divides two integers as integers.
title: { dialect: 'cel', source: "'Follow up on ' + record.name" },
assignee: { dialect: 'cel', source: 'has(record.owner) ? record.owner : null' },
estimate: { dialect: 'cel', source: 'round(record.amount * 0.15 * 100.0) / 100.0' },
// a date macro — one of the two `{…}` spellings a value slot still reads
due_date: '{TODAY() + 7}',
status: 'open', // a literal
},
},
}
Expand Down Expand Up @@ -328,7 +360,7 @@ compile error carrying the same prescription.
config: {
function: 'scoreLead', // registered via defineStack({ functions })
inputs: { rating: '{record.rating}' }, // {var} templates resolve against flow variables
outputVariable: 'leadScore', // later: fields: { score: '{leadScore}' }
outputVariable: 'leadScore', // later: fields: { score: { dialect: 'cel', source: 'leadScore' } }
},
}
```
Expand Down Expand Up @@ -1882,42 +1914,47 @@ failures so one broken flow does not abort startup.

## Expressions in flows

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

| Where | Dialect | Write it like | Bindings |
|:---|:---|:---|:---|
| Start-node `condition` | **CEL** (bare, no braces) | `record.amount > 500` | `record.*`, `previous.*`, bare field names, `vars.*` |
| Edge `condition` | **CEL** (bare, no braces) | `record.status == 'open'` | same as above |
| Decision-node `conditions[].expression` | **CEL** (bare, no braces) | `order_amount > 10000` | flow variables by name, and `vars.*` |
| 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 |
| 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) |
| 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`, …) |

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

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

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

<Callout type="info">
Expand Down Expand Up @@ -2145,7 +2182,7 @@ export const hotLeadFollowUp: Flow = {
objectName: 'task',
fields: {
subject: 'Follow up on hot lead',
related_to: '{record.id}',
related_to: { dialect: 'cel', source: 'record.id' },
priority: 'high',
},
},
Expand Down
7 changes: 6 additions & 1 deletion content/docs/kernel/runtime-services/examples.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,12 @@ export const RollUpOrderTotals = defineFlow({
config: {
objectName: 'sales_order',
filter: { id: '{record.id}' },
fields: { line_count: '{totals.line_count}', amount_total: '{totals.total}' },
// A value slot computes with a CEL value envelope; the script's
// output `totals` is bound by the node before this one.
fields: {
line_count: { dialect: 'cel', source: 'totals.line_count' },
amount_total: { dialect: 'cel', source: 'totals.total' },
},
},
},
{ id: 'end', type: 'end', label: 'End' },
Expand Down
Loading
Loading