Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
30 changes: 30 additions & 0 deletions .changeset/28054-flow-message-template.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
'@objectstack/spec': minor
'@objectstack/service-automation': minor
'@objectstack/lint': minor
'@object-ui/app-shell': minor
---

feat(automation): a flow's `successMessage` / `errorMessage` are `{{ }}` text slots over the run's variables

Clause-②: no (narrowing) — `registerFlow` and `objectstack validate` (compile, the publish gate) now refuse a flow whose `successMessage` / `errorMessage` carries a single-brace `{token}` or a `{{ }}` hole that does not compile, where they accepted any string before. A `{{ }}` hole in either key now renders instead of being copied, which changes what a stored message means and widens no accept set.

Metadata-protocol: narrowed (`FlowSchema.successMessage` / `errorMessage`, and the `flows.FLOW.successMessage` / `.errorMessage` translation leaves, are refused at the flow doors and the translation parse when they carry a single-brace token or an uncompilable hole; the meaning shift of a `{{ }}` hole, from literal text to a rendered slot, is the semantic half and ranks below it)

<!-- adr-0087: not-required (no-migration-prescription) No key is added, removed or renamed, and a message no first-party producer writes is the only one refused: measured, 0 first-party flow messages (examples, package sources, dogfood fixtures, skills) carry a brace at all. A refused message is one that would otherwise reach the user with its braces raw, and the refusal names the key and spells the `{{ var }}` form to write. A conversion cannot tell whether a literal brace was meant as a hole or as text, so the author decides: write the hole, or rephrase the brace. -->

A screen flow can now finish on "3 contacts enrolled": `successMessage: '{{ enrolledCount }} contacts enrolled'` renders the count the run bound. Until now the engine copied both messages onto the run result as written, so a `{{ }}` hole reached the user raw.

- **Spec (`@objectstack/spec`).** `FlowSchema.successMessage` and `errorMessage` join the flow text slots (`FLOW_TEXT_SLOTS`, `flowTextSlotSources` in `automation/flow-text-slot-template.ts`): `{{ path }}` and `{{ path | formatter }}` holes over the run's variables, the grammar a refusing `end` node's `message` reads. The `flows.<flow>.successMessage` / `.errorMessage` translations are the same text slot as their source, so a single-brace `{token}` in one is refused, and `flowTerminalMessageKey(flow, key)` spells the key the engine reads.
- **Engine (`@objectstack/service-automation`).** Every terminal exit renders the message instead of copying it: `successMessage` over the variables the run finished with (first run, a retried attempt, a resume), `errorMessage` over the variables at the failure point (`{{ $error.message }}` included; the retry-exhausted exit reports the last attempt's rendering). The translated template is picked in the run's locale before the holes are filled, exactly as for a screen's heading. A hole over a variable the run never bound renders nothing, as in every text slot, and a message with no hole is byte-identical. A message that cannot render (a `{{ x | currency }}` hole with no tenant default currency) is left off the result with a warning; the run's verdict stands.
- **Doors (`registerFlow`, `objectstack validate`, the publish gate).** Both keys are judged as a node's text slot is: a single-brace `{var}` token is refused with its `{{ var }}` spelling, and a hole that does not compile (`{{ a + b }}`) is refused with the compile step's words.
- **Console (`@object-ui/app-shell`).** The flow's toasts are drawn as served. The client overlay of `flows.<flow>.successMessage` / `.errorMessage` is removed, because the engine now serves them translated and filled, and an overlay would put the bundle's template, holes raw, over the filled sentence. `launchConsoleFlow` takes the host's `language` in place of `translation: { i18n, language }`, and `interpretFlowResponse` takes the locale string in place of `FlowCopyLocale`.

**BREAKING** accept-set narrowing at the flow doors (`@objectstack/spec`, `@objectstack/service-automation`, `@objectstack/lint`), shipped as `minor`.

- **Before.** `successMessage: 'Enrolled {count} contacts'` registered, `objectstack validate` exited 0, and the run's result carried the string as written, braces and all. A `{{ enrolledCount }}` hole was copied the same way.
- **Now.** `registerFlow` refuses that flow at `successMessage` with the text-slot refusal an `end` node's `message` gets, spelling `{{ count }}`; `objectstack validate` / compile / the publish gate report the issue at `flows[i].successMessage` and exit 1. A hole that does not compile (`{{ a + b }}`) is refused with the compile step's words. A translated `flows.FLOW.successMessage` / `.errorMessage` with a single-brace token is refused by the translation parse. A well-formed `{{ enrolledCount }}` renders over the run's variables at the terminal exit.
- **Unchanged.** A message with no brace renders byte-identical. An absent message stays absent. A hole over a variable the run never bound renders blank, as in every text slot. The run's own verdict, status and history row do not depend on the message.
- **Who sees it.** An author whose flow message carries a closed single-brace token, a hole that does not compile, or a hole over a `$` root the engine does not bind: 0 first-party flows do.

**The remedy.** Write `{{ var }}` holes over the run's variables, spelled as the refusal prints them. For a `{word}` meant as literal text, rephrase it so no closed single-brace token remains (drop the braces, or use other brackets); a lone `{` that closes nothing is not judged.
4 changes: 2 additions & 2 deletions content/docs/automation/flows.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,8 @@ const approvalFlow = {
| `nodes` | `FlowNode[]` | ✅ | Flow nodes |
| `edges` | `FlowEdge[]` | ✅ | Connections between nodes |
| `errorHandling` | `object` | optional | Error handling strategy |
| `successMessage` | `string` | optional | Toast a screen-flow runner shows on terminal success (plain string — `{var}` is **not** interpolated) |
| `errorMessage` | `string` | optional | Toast a screen-flow runner shows on failure, instead of the raw error |
| `successMessage` | `string` | optional | Toast a screen-flow runner shows on terminal success — a `{{ }}` template over the run's variables (`'{{ enrolledCount }} contacts enrolled'`), rendered when the run completes |
| `errorMessage` | `string` | optional | Toast a screen-flow runner shows on failure, instead of the raw error — a `{{ }}` template rendered over the variables at the failure point (`{{ $error.message }}` included) |

### Flow Types

Expand Down
26 changes: 13 additions & 13 deletions content/docs/references/api/automation-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -169,8 +169,8 @@ const result = AutomationApiErrorCode.parse(data);
| **name** | `string` | ✅ | Machine name |
| **label** | `string` | ✅ | Flow label |
| **description** | `string` | optional | |
| **successMessage** | `string` | optional | Message carried on AutomationResult for every terminal run (not only screen flows); the screen-flow UI shows it as a toast instead of a generic "Done". |
| **errorMessage** | `string` | optional | Message carried on AutomationResult for every terminal run (not only screen flows); the screen-flow UI shows it as a toast instead of the raw error. |
| **successMessage** | `string` | optional | Text for a terminal success, carried on AutomationResult for every terminal run (not only screen flows); the screen-flow UI shows it as a toast instead of a generic "Done". A `{{ }}` template over the run's variables (`{{ enrolledCount }} contacts enrolled`), rendered when the run completes. |
| **errorMessage** | `string` | optional | Text for a terminal failure, carried on AutomationResult for every terminal run (not only screen flows); the screen-flow UI shows it as a toast instead of the raw error. A `{{ }}` template over the run's variables, rendered over the variables at the failure point. |
| **version** | `integer` | optional (default: `1`) | Version number |
| **status** | `Enum<'draft' \| 'active' \| 'obsolete' \| 'invalid'>` | optional (default: `"draft"`) | Deployment status |
| **type** | `Enum<'autolaunched' \| 'record_change' \| 'schedule' \| 'screen' \| 'api'>` | ✅ | Flow type |
Expand Down Expand Up @@ -297,8 +297,8 @@ const result = AutomationApiErrorCode.parse(data);
| **name** | `string` | ✅ | Machine name |
| **label** | `string` | ✅ | Flow label |
| **description** | `string` | optional | |
| **successMessage** | `string` | optional | Message carried on AutomationResult for every terminal run (not only screen flows); the screen-flow UI shows it as a toast instead of a generic "Done". |
| **errorMessage** | `string` | optional | Message carried on AutomationResult for every terminal run (not only screen flows); the screen-flow UI shows it as a toast instead of the raw error. |
| **successMessage** | `string` | optional | Text for a terminal success, carried on AutomationResult for every terminal run (not only screen flows); the screen-flow UI shows it as a toast instead of a generic "Done". A `{{ }}` template over the run's variables (`{{ enrolledCount }} contacts enrolled`), rendered when the run completes. |
| **errorMessage** | `string` | optional | Text for a terminal failure, carried on AutomationResult for every terminal run (not only screen flows); the screen-flow UI shows it as a toast instead of the raw error. A `{{ }}` template over the run's variables, rendered over the variables at the failure point. |
| **version** | `integer` | optional (default: `1`) | Version number |
| **status** | `Enum<'draft' \| 'active' \| 'obsolete' \| 'invalid'>` | optional (default: `"draft"`) | Deployment status |
| **type** | `Enum<'autolaunched' \| 'record_change' \| 'schedule' \| 'screen' \| 'api'>` | ✅ | Flow type |
Expand Down Expand Up @@ -382,7 +382,7 @@ const result = AutomationApiErrorCode.parse(data);

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **errorMessage** | `string` | optional | The flow author's own failure text (`flow.errorMessage`), copied verbatim from `AutomationResult.errorMessage`; absent when the author wrote none |
| **errorMessage** | `string` | optional | The flow author's own failure text (`flow.errorMessage`, rendered by the engine), copied verbatim from `AutomationResult.errorMessage`; absent when the author wrote none |
| **summary** | `{ selected: integer; acted: integer; skipped: integer; unmeasured?: integer; … }` | optional | The run's per-node accounting (`AutomationResult.summary`) - which node failed; absent when the engine measured none |
| **innerRefusal** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; message: string }` | optional | The refusal a node's write met inside the run (`AutomationResult.innerRefusal`): `{ code, message }`, `code` in its wire spelling (e.g. `UNIQUE_VIOLATION`). Absent when the failure met no refusal |

Expand Down Expand Up @@ -839,8 +839,8 @@ const result = AutomationApiErrorCode.parse(data);
| **name** | `string` | ✅ | Machine name |
| **label** | `string` | ✅ | Flow label |
| **description** | `string` | optional | |
| **successMessage** | `string` | optional | Message carried on AutomationResult for every terminal run (not only screen flows); the screen-flow UI shows it as a toast instead of a generic "Done". |
| **errorMessage** | `string` | optional | Message carried on AutomationResult for every terminal run (not only screen flows); the screen-flow UI shows it as a toast instead of the raw error. |
| **successMessage** | `string` | optional | Text for a terminal success, carried on AutomationResult for every terminal run (not only screen flows); the screen-flow UI shows it as a toast instead of a generic "Done". A `{{ }}` template over the run's variables (`{{ enrolledCount }} contacts enrolled`), rendered when the run completes. |
| **errorMessage** | `string` | optional | Text for a terminal failure, carried on AutomationResult for every terminal run (not only screen flows); the screen-flow UI shows it as a toast instead of the raw error. A `{{ }}` template over the run's variables, rendered over the variables at the failure point. |
| **version** | `integer` | optional (default: `1`) | Version number |
| **status** | `Enum<'draft' \| 'active' \| 'obsolete' \| 'invalid'>` | optional (default: `"draft"`) | Deployment status |
| **type** | `Enum<'autolaunched' \| 'record_change' \| 'schedule' \| 'screen' \| 'api'>` | ✅ | Flow type |
Expand Down Expand Up @@ -1122,8 +1122,8 @@ const result = AutomationApiErrorCode.parse(data);
| **status** | `Enum<'completed' \| 'paused' \| 'failed' \| 'stranded' \| 'refused'>` | optional | Lifecycle status. `paused` means the run suspended at a node and can be continued with the resume route. Absent or `completed`/`failed`/`stranded`/`refused` means the run reached a terminal state. `refused` is a first-class refusal: the flow reached an `end` node declaring `outcome: 'refused'` — a successful evaluation that said no, so `success` is true, `successMessage` is absent and the per-record reason is on `refusalMessage`; a runner shows it with Close only. `stranded` is the terminally-failed-but-repairable run: a resume consumed the suspension and a downstream node threw, so the run is recorded as failed and can be re-armed only by an explicit operator verb - never by the resume route, which answers RUN_NOT_FOUND for it. |
| **runId** | `string` | optional | Run id - set when `status` is `paused`, so callers can resume it |
| **screen** | `{ nodeId: string; title?: string; description?: string; fields: object[]; … }` | optional | The screen to render - set when the run paused at a `screen` node awaiting user input. The client collects values for `screen.fields` and resumes the run with them. |
| **successMessage** | `string` | optional | Friendly terminal message copied from the flow definition on terminal success, so a screen-flow runner can show a meaningful toast |
| **errorMessage** | `string` | optional | Friendly terminal message copied from the flow definition on failure |
| **successMessage** | `string` | optional | Friendly terminal message rendered from the flow definition on terminal success (its `{{ }}` holes filled over the run's variables, in the run's locale), so a screen-flow runner can show a meaningful toast |
| **errorMessage** | `string` | optional | Friendly terminal message rendered from the flow definition on failure (its `{{ }}` holes filled over the variables at the failure point, in the run's locale) |
| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim, of the refusal a node met inside the run - present exactly when that refusal's producer opted in at throw time (`ApiErrorSchema.userMessage` semantics). Never replaces `error` |
| **innerRefusal** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; message: string }` | optional | The refusal a node's write met inside the run, structured: `{ code, message }`, `code` in its wire spelling. Set on a failed run only when a refusal was met; `code` above and `error` are unchanged by it |
| **flowLabel** | `string` | optional | The flow definition's authored `label`, copied verbatim so a runner can name the flow (header, completion toast) and translate it against `flows.<flow>.label`. Set on every result of an evaluation of a registered flow (paused and terminal alike); absent on a refusal carrying `code`. For a subflow chain it is the addressed (parent) run's flow. Never defaulted to the API name |
Expand All @@ -1149,8 +1149,8 @@ const result = AutomationApiErrorCode.parse(data);
| **name** | `string` | ✅ | Machine name |
| **label** | `string` | ✅ | Flow label |
| **description** | `string` | optional | |
| **successMessage** | `string` | optional | Message carried on AutomationResult for every terminal run (not only screen flows); the screen-flow UI shows it as a toast instead of a generic "Done". |
| **errorMessage** | `string` | optional | Message carried on AutomationResult for every terminal run (not only screen flows); the screen-flow UI shows it as a toast instead of the raw error. |
| **successMessage** | `string` | optional | Text for a terminal success, carried on AutomationResult for every terminal run (not only screen flows); the screen-flow UI shows it as a toast instead of a generic "Done". A `{{ }}` template over the run's variables (`{{ enrolledCount }} contacts enrolled`), rendered when the run completes. |
| **errorMessage** | `string` | optional | Text for a terminal failure, carried on AutomationResult for every terminal run (not only screen flows); the screen-flow UI shows it as a toast instead of the raw error. A `{{ }}` template over the run's variables, rendered over the variables at the failure point. |
| **version** | `integer` | optional (default: `1`) | Version number |
| **status** | `Enum<'draft' \| 'active' \| 'obsolete' \| 'invalid'>` | optional (default: `"draft"`) | Deployment status |
| **type** | `Enum<'autolaunched' \| 'record_change' \| 'schedule' \| 'screen' \| 'api'>` | ✅ | Flow type |
Expand Down Expand Up @@ -1214,8 +1214,8 @@ const result = AutomationApiErrorCode.parse(data);
| **name** | `string` | ✅ | Machine name |
| **label** | `string` | ✅ | Flow label |
| **description** | `string` | optional | |
| **successMessage** | `string` | optional | Message carried on AutomationResult for every terminal run (not only screen flows); the screen-flow UI shows it as a toast instead of a generic "Done". |
| **errorMessage** | `string` | optional | Message carried on AutomationResult for every terminal run (not only screen flows); the screen-flow UI shows it as a toast instead of the raw error. |
| **successMessage** | `string` | optional | Text for a terminal success, carried on AutomationResult for every terminal run (not only screen flows); the screen-flow UI shows it as a toast instead of a generic "Done". A `{{ }}` template over the run's variables (`{{ enrolledCount }} contacts enrolled`), rendered when the run completes. |
| **errorMessage** | `string` | optional | Text for a terminal failure, carried on AutomationResult for every terminal run (not only screen flows); the screen-flow UI shows it as a toast instead of the raw error. A `{{ }}` template over the run's variables, rendered over the variables at the failure point. |
| **version** | `integer` | optional (default: `1`) | Version number |
| **status** | `Enum<'draft' \| 'active' \| 'obsolete' \| 'invalid'>` | optional (default: `"draft"`) | Deployment status |
| **type** | `Enum<'autolaunched' \| 'record_change' \| 'schedule' \| 'screen' \| 'api'>` | ✅ | Flow type |
Expand Down
Loading
Loading