Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
22d4d06
wip(spec,service-automation,lint,formula): flow text slots read {{ }}…
claude Oct 8, 2026
105a1ca
wip: convert in-tree text-slot sites, docs, D3 entry (#22110)
claude Oct 8, 2026
f7787bd
wip: spec judge tests, regenerated artifacts (#22110)
claude Oct 8, 2026
4499863
wip: regenerate api-surface/export-origins (#22110)
claude Oct 8, 2026
b31e69b
wip: service-automation text-slot renderer pins and test fixtures (#2…
claude Oct 8, 2026
e2fcb56
wip: lint door/test pins, dogfood fixtures, descriptor help (#22110)
claude Oct 8, 2026
cf93a63
wip: lint type fix (#22110)
claude Oct 8, 2026
a6a9309
wip: todo example test reads {{ }} holes (#22110)
claude Oct 8, 2026
c3297ca
wip: changeset (#22110)
claude Oct 8, 2026
1a6ca14
Merge remote-tracking branch 'origin/main' into claude/issue-22110-fl…
claude Oct 8, 2026
f94f922
chore(spec): regenerate the reference docs after merging main (#22110)
claude Oct 8, 2026
d54cc71
fix(dogfood): the conformance ledger's notify prose names no tracker …
claude Oct 8, 2026
11bdb8c
Merge remote-tracking branch 'origin/main' into claude/issue-22110-fl…
claude Oct 8, 2026
73b6828
chore(changeset): declare the text-slot change as a narrowing (#22110)
claude Oct 8, 2026
e899a2c
Merge remote-tracking branch 'origin/main' into claude/issue-22110-fl…
claude Oct 8, 2026
0b09402
fix(spec,lint): patch round 2 — notify canon, one-stray-brace holes, …
claude Oct 8, 2026
d2a9c95
Merge remote-tracking branch 'origin/main' into claude/issue-22110-fl…
claude Oct 8, 2026
171cea0
fix(spec,lint): patch round 3 — the tmpl and typed-refusal canon, the…
claude Oct 8, 2026
c673c86
fix(lint): the bracket-path pin spreads a typed record, so the test t…
claude Oct 8, 2026
b5d3cd5
test(spec): the tmpl docblock pin holds the protocol-18 canon — notif…
claude Oct 8, 2026
0788b58
Merge remote-tracking branch 'origin/main' into claude/issue-22110-fl…
claude Oct 8, 2026
5bfa9ae
chore(spec): regenerate api-surface and export-origins on the merged …
claude Oct 8, 2026
bff973d
Merge remote-tracking branch 'origin/main' into claude/issue-22110-fl…
claude Oct 8, 2026
f84cd82
Merge remote-tracking branch 'origin/main' into claude/issue-22110-fl…
claude Oct 9, 2026
4e275f1
Merge remote-tracking branch 'origin/main' into claude/issue-22110-fl…
claude Oct 9, 2026
ce4f651
Merge remote-tracking branch 'origin/main' into claude/issue-22110-fl…
claude Oct 9, 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
39 changes: 39 additions & 0 deletions .changeset/22110-flow-text-slot-double-brace.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
---
'@objectstack/spec': major
'@objectstack/service-automation': major
'@objectstack/lint': major
'@objectstack/formula': minor
---

A flow TEXT slot — a `notify` node's `title` and `message`, a `screen` node's `title` and `description`, a refusing `end` node's `message` — reads ADR-0032 §3's `{{ }}` template holes, rendered by the formula template engine over the flow's variables. A single-brace `{…}` token in one is refused at `objectstack validate`, at `registerFlow` and by the node's contract, with the `{{ }}` spelling of each token.

Clause-②: yes (narrowing)

<!-- adr-0087: registered flow-text-slot-single-brace-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.** ADR-0032 Decision 3 fixes one template delimiter: "One delimiter, `{{ }}`; single `{ }` deleted." The notify pair was already typed as `template` slots, while the executor read the single brace, so a flow carried two template dialects and an author who met both mixed them.

**What changes.**

- **The renderer.** The five text slots render through one renderer inside `@objectstack/service-automation` (package-internal; the package's public exports do not change): `{{ path }}` and `{{ path | formatter[:arg] }}` holes, every other character literal. A hole reads a flow variable by name (`{{ record.name }}`, a node output `{{ lookup.result }}`, an index `{{ rows.0.subject }}` or `{{ rows[0].subject }}`, and a `$`-named one: `{{ $error.message }}`). `null` and an absent path render nothing, an object or array renders as JSON, a `Date` as its ISO text; the ADR-0032 formatters make the rest explicit (`{{ amount | currency }}`, `{{ due | date:long }}`). A hole holds no logic.
- **The refusal.** One judge in `@objectstack/spec/automation` (`textSlotTemplateRefusal`, `flowNodeTextSlotSources`, `FLOW_NODE_TEXT_SLOTS`, `TEXT_SLOT_TEMPLATE_REFUSAL`): `NotifyConfigSchema`, `ScreenConfigSchema` and `EndConfigSchema` refuse a single-brace token at the slot's key; `registerFlow` and `objectstack validate` (`expression-invalid`, `error`) refuse it with the same words and also compile every slot's holes, so `{{ a + b }}` or an unknown formatter is refused before a run. A stored flow carrying one is skipped at boot with a warn naming it. Past the doors, a hole that does not compile fails the node with a guard refusal, which a `fault` edge does not route.
- **`@objectstack/formula`.** A template hole's path may contain `$` (`{{ $error.message }}`): a widening of the hole grammar, so a host scope's `$`-named variable has a template spelling. An unbound one renders nothing, like any unknown path.
- **`flow-double-brace-interpolation`** no longer flags `{{ }}` on the text slots, and its hint names them as the only `{{ }}` positions; a bare `$ref.x` outside the holes of a text slot is flagged with the hole spelling.
- **Unchanged:** every other flow string keeps the single-brace dialect — `recipients`, `actionUrl`, `sourceId`, `payload`, `templateData` values, a screen's `recordId` / `defaults` / field `defaultValue`, `subflow.input`, `script.inputs`, `map.input`, `http`, `loop` / `map` `collection`, `filter` — and the value slots are CEL's.

**Not converted.** No ADR-0087 D2 conversion rewrites `{x}` to `{{ x }}`. The two renderers were run over the same variables: a path renders the same text for a string, a number, a boolean, `null`, an absent key or variable, an ISO date string, an object or an array, but a `Date` value rendered JSON-quoted under the 17.x interpolator (`"2026-10-08T09:30:00.000Z"`, quotes included) and as its ISO text now, and a screen or `end` text that was one token holding an object, an array or a `Date` rendered `String(value)` (`[object Object]`). So the rewrite is the author's to check; the D3 record is the semantic entry `flow-text-slot-single-brace-refused`.

## FROM → TO

| you wrote | write instead |
|:--|:--|
| `title: 'Deal won: {record.name}'` | `title: 'Deal won: {{ record.name }}'` |
| `message: 'Failed: {$error.message}'` | `message: 'Failed: {{ $error.message }}'` |
| `message: 'Total {amount * 2}'`, `'{round(x)}'` | compute it first — `assignments: { v: { dialect: 'cel', source: 'amount * 2' } }` — then `'Total {{ v }}'` (a number format is a formatter: `{{ v \| number:2 }}`) |
| `message: 'Due {TODAY() + 7}'`, `'By {$User.Id}'` | compute it first with an `assignment` node, whose value slot still reads that spelling — `assignments: { due: '{TODAY() + 7}' }` — then `'Due {{ due }}'` |

**The one-line fix: double the braces of every path token in a text slot (`{x}` → `{{ x }}`), and compute anything else into a variable first.**

**Who is affected, measured.** This repository's in-tree text-slot sites — `examples/app-showcase` (24 strings, 30 tokens) and `examples/app-todo` (5 strings, 7 tokens), all of them paths — are rewritten in this change, with the docs pages that taught the single brace (`content/docs/automation/flows.mdx`, `content/docs/getting-started/common-patterns.mdx`). Other repositories and deployed metadata were not measured here.
86 changes: 72 additions & 14 deletions content/docs/automation/flows.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ missing, empty, whitespace-only or non-string `source`, an `ast` with no
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
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`
Expand Down Expand Up @@ -281,6 +281,54 @@ one) and the run user (`'{$User.Id}'` — the flow's CEL scope binds no user).

</Callout>

### Text slots read double-brace holes

A flow's **text** slots — a `notify` node's `title` and `message`, a `screen`
node's `title` and `description`, and a refusing `end` node's `message` —
render through the formula template engine ([#22110], ADR-0032 §3): each
`{{ }}` hole is a **variable path** with an optional **formatter**, and every
other character is literal.

```typescript
{
id: 'notify_won', type: 'notify', label: 'Deal won',
config: {
recipients: '{record.owner}', // not a text slot: single brace
title: 'Deal won: {{ record.name }}',
message: '{{ record.amount | currency }} closed on {{ record.close_date | date:long }}.',
},
}
```

- A hole reads the flow's variables by name — `{{ record.name }}`, a node
output `{{ lookup.result }}`, an index `{{ rows.0.subject }}` or
`{{ rows[0].subject }}`, and the engine-set `$`-named ones:
`{{ $error.message }}` in a fault handler.
- Value → text is defined per value: `null` and an absent path render nothing,
an object or array renders as JSON, a date object as its ISO text. A
formatter makes the rest explicit: `upper`, `lower`, `trim`, `number[:digits]`,
`currency[:CODE]`, `percent[:digits]`, `date[:short|medium|long|iso]`,
`datetime[:…]`, `truncate[:n]`, `default:'fallback'`, `json`.
- A hole holds **no logic** — `{{ a + b }}` or `{{ round(x) }}` is refused at
`objectstack validate` and `registerFlow`. Compute the value first, in an
`assignment` node's CEL value envelope, and write the variable as a hole.

A **single-brace** `{…}` token in a text slot is refused — at `objectstack
validate`, at `registerFlow` and by the node's own contract — with its `{{ }}`
spelling. Nothing converts it for you: the two renderers answer differently for
some values (a date object rendered JSON-quoted under the old dialect; a screen
title holding one object rendered `[object Object]`), so the rewrite is yours to
check.

| you wrote | write instead |
|:---|:---|
| `'Deal won: {record.name}'` | `'Deal won: {{ record.name }}'` |
| `'Failed: {$error.message}'` | `'Failed: {{ $error.message }}'` |
| `'Total {amount * 2}'`, `'{round(x)}'` | compute it into a variable (`assignments: { v: { dialect: 'cel', source: 'amount * 2' } }`), then `'Total {{ v }}'` |
| `'Due {TODAY() + 7}'`, `'By {$User.Id}'` | compute it into a variable with an `assignment` node, whose value slot still reads that spelling (`assignments: { due: '{TODAY() + 7}' }`), then `'Due {{ due }}'` |

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

**Create Record:**

```typescript
Expand Down Expand Up @@ -591,7 +639,7 @@ second terminal node type).
label: 'Refused — duplicate',
config: {
outcome: 'refused', // 'completed' (default) | 'refused'
message: 'Refused: {record.name} is a confirmed duplicate of {duplicate.name}',
message: 'Refused: {{ record.name }} is a confirmed duplicate of {{ duplicate.name }}',
},
}
```
Expand All @@ -602,8 +650,9 @@ second terminal node type).
text as `refusalMessage`, and the trigger / resume response carries the same
(`success: true`, `status: 'refused'`, `refusalMessage` — and **no**
`successMessage`, so there is nothing to toast).
- `message` is a `{token}` template interpolated at run time **exactly like a
`screen` node's `description`**, so the text names the record. It is
- `message` is a `{{ }}` template rendered at run time **exactly like a
`screen` node's `description`** (see [text slots](#text-slots-read-double-brace-holes)),
so the text names the record. It is
**required** when `outcome` is `refused` (a refusal without text is the shape
this replaces) and **refused** on a completed end (nothing would ever render
it — the key would be a silent no-op). The config is strict: an undeclared key
Expand Down Expand Up @@ -1932,8 +1981,12 @@ failures so one broken flow does not abort startup.

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.
value envelope. **Text** slots — a `notify` title or message, a screen title or
description, a refusing `end` node's message — are `{{ }}` templates (see
[text slots](#text-slots-read-double-brace-holes)). Single braces remain only on the
value-like positions that hand a resolved value over (`recipients`,
`sourceId`, a `subflow` input, an `http` body, …) and in the date macros a value
slot still reads.

| Where | Dialect | Write it like | Bindings |
|:---|:---|:---|:---|
Expand All @@ -1942,6 +1995,7 @@ screen description — and the date macros a value slot still reads.
| Decision-node `conditions[].expression` | **CEL** (bare, no braces) | `order_amount > 10000` | flow variables by name, and `vars.*` |
| 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`, …) |
| Text slots — `notify` `title` / `message`, `screen` `title` / `description`, `end` `message` | **template** (`{{ }}` holes) | `'Deal won: {{ record.name }} ({{ record.amount \| currency }})'` | flow variables by name (`$`-named ones too: `{{ $error.message }}`) — a path and an optional formatter, never logic |

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
Expand All @@ -1964,13 +2018,17 @@ user.
braces.
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.
instead. In a single-brace position (an `http` body, a `subflow` input), 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.
4. **A single-brace token in a text slot** — `title: 'Deal won: {record.name}'`
— is refused with its `{{ }}` spelling ([text slots](#text-slots-read-double-brace-holes)): it is no placeholder there,
and would go out as literal text.
</Callout>

<Callout type="info">
Expand Down Expand Up @@ -2212,7 +2270,7 @@ export const hotLeadFollowUp: Flow = {
config: {
recipients: '{record.owner}',
title: 'New hot lead',
message: 'Hot lead created: {record.name}',
message: 'Hot lead created: {{ record.name }}',
channels: ['inbox'],
},
},
Expand Down
13 changes: 7 additions & 6 deletions content/docs/getting-started/common-patterns.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -276,11 +276,12 @@ export const assignmentNotification = defineFlow({
type: 'notify',
label: 'Notify Assignee',
config: {
// String fields use single-brace {…} templates, not {{…}}.
// `recipients` takes recipient USER IDs — a lookup value interpolates to the id.
// `recipients` takes recipient USER IDs — a single-brace {…} token hands
// the lookup value (the id) over. `title` / `message` are TEXT slots:
// `{{ }}` template holes, a path with an optional formatter.
recipients: '{record.assigned_to}',
title: 'Task Assigned: {record.title}',
message: 'You have been assigned to task "{record.title}".'
title: 'Task Assigned: {{ record.title }}',
message: 'You have been assigned to task "{{ record.title }}".'
}
},
{ id: 'end', type: 'end', label: 'End' }
Expand Down Expand Up @@ -356,8 +357,8 @@ Define a multi-step approval flow for records.
label: 'Request Approval',
config: {
recipients: '{record.submitted_by}',
title: 'Expense approval needed: {record.title}',
message: 'Expense "{record.title}" needs manual approval.'
title: 'Expense approval needed: {{ record.title }}',
message: 'Expense "{{ record.title }}" needs manual approval.'
}
},
{ id: 'end', type: 'end', label: 'End' }
Expand Down
10 changes: 6 additions & 4 deletions content/docs/references/automation/builtin-node-config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@ parse the RAW stored config — their typed slots are strings (or `unknown`
where values interpolate), so `{token}` templates pass and resolve at the
executor's existing interpolation points. The value slots are the exception
since #19939: the `{token}` dialect is retired there (the "value slots"
section below).
section below). So are the text slots since #22110: a screen `title` /
`description` and an `end` `message` render `{{ }}` holes, and a `{token}`
there is refused (`flow-text-slot-template.ts`).

## Unknown keys — closed here too, as of #4001 批 9

Expand Down Expand Up @@ -171,7 +173,7 @@ Value the variable takes: a CEL value envelope `{ dialect: 'cel', source }` eval
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **outcome** | `Enum<'completed' \| 'refused'>` | optional (default: `"completed"`) | How the run ends when it reaches this node. `completed` (the default) is the ordinary terminal. `refused` is a first-class refusal: the run records `refused` — distinct from `failed`, a refusal is a successful evaluation that says no — carries the rendered `message`, is never resumed, and a runner shows the message with Close only: no Submit, no completion toast. |
| **message** | `string` | optional | Why the run was refused, as a `{token}` template interpolated at run time exactly like a screen `description` (`{record.name}` etc.), so the text names the record. Required when `outcome` is `refused`; refused when it is `completed` — a completion renders nothing, so the key would be a silent no-op. |
| **message** | `string` | optional | Why the run was refused, as a template rendered at run time exactly like a screen `description` — `{{ }}` holes over the flow's variables (`{{ record.name }}`), so the text names the record. Required when `outcome` is `refused`; refused when it is `completed` — a completion renders nothing, so the key would be a silent no-op. |


---
Expand Down Expand Up @@ -221,8 +223,8 @@ A value: a CEL value envelope `{ dialect: 'cel', source }` evaluated by the expr

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **title** | `string` | optional | Heading shown above the screen |
| **description** | `string` | optional | Body text shown under the heading |
| **title** | `string` | optional | Heading shown above the screen — a template rendered per run with `{{ }}` holes over the flow's variables (`{{ record.name }}`); falls back to the node label when it renders nothing |
| **description** | `string` | optional | Body text shown under the heading — a template rendered per run with `{{ }}` holes (`{{ record.name }}`) |
| **fields** | `{ name: string; label?: string; type?: string; required?: boolean; … }[]` | optional | Input fields collected on this screen |
| **waitForInput** | `boolean` | optional | Pause to show the screen even with no fields; false forces a server pass-through |
| **objectName** | `string` | optional | Render this object's full create/edit form instead of a flat field list |
Expand Down
Loading
Loading