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
40 changes: 40 additions & 0 deletions .changeset/22502-flow-binding-variable-dollar-name-refused.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
---
'@objectstack/spec': major
---

A flow node's `outputVariable` (`get_record`, `create_record`, `map`, `script`, `subflow`) refuses a variable name that starts with `$`, and a `try_catch` node's `errorVariable` refuses every `$` name except the engine's own `$error`, its default. The refusal names the remedy: the same name without the `$`, read as `{{ name }}`.

Clause-②: no (narrowing)

<!-- adr-0087: registered flow-binding-variable-dollar-name-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.** The `$` names are the flow engine's own variables: it binds `$record`, `$runId`, `$flowName`, `$flowLabel` and `$error`, a flat-graph `loop` binds `$loopItems` and `$loopIndex`, and a resume signal may not write any `$` name. A flow text slot already refuses a `{{ }}` hole whose root is a `$` name the engine does not bind, and tells the author to drop the `$`. But the binding keys took any string, so a flow could bind `errorVariable: '$caught'` and then be refused `'Failed: {{ $caught.message }}'`. The two doors of one contract gave two answers. Now both give the text slot's answer.

**What is refused.**

- `outputVariable` on `GetRecordConfigSchema`, `CreateRecordConfigSchema`, `MapConfigSchema`, `ScriptConfigSchema` and `SubflowConfigSchema`: a name that starts with `$`. That includes the engine's own names, since a binding over `$record` would overwrite the trigger record for the rest of the run.
- `errorVariable` on `TryCatchConfigSchema`: a name that starts with `$`, other than `$error`.
- Each key states the rule as a JSON Schema `pattern`, so the published `json-schema/**` refuses what the parse refuses. The parse issue is an `invalid_format` (regex) issue at the key, and its message names the remedy.
- `FlowSchema.parse`, `registerFlow` and `objectstack validate` refuse the flow at `nodes.N.config.outputVariable` / `nodes.N.config.errorVariable`, inside a region body too (`nodes.N.config.try.nodes.M.config.outputVariable`). A stored flow carrying one is skipped at boot with a warn naming it, and each executor's own contract parse refuses the node at run time.

**Unchanged.** `errorVariable` absent (it defaults to `$error`) or an explicit `errorVariable: '$error'`; any name that does not start with `$`, a `$` later in the name (`a$b`) included; an empty string, which every executor reads as no binding; and every hole the text-slot judge already admits. Non-string values keep the type refusal they had.

## FROM → TO

| you wrote | write instead |
|:--|:--|
| `errorVariable: '$caught'` with `message: 'Failed: {{ $caught.message }}'` | `errorVariable: 'caught'` with `'Failed: {{ caught.message }}'`, or drop `errorVariable` and write `{{ $error.message }}` |
| `outputVariable: '$lead'` with `title: '{{ $lead.name }}'` | `outputVariable: 'lead'` with `title: '{{ lead.name }}'` |

**The one-line fix: name the variable without the `$`, and rename every read of it with it.**

No D2 conversion rewrites the name: the bare name may already be bound in the flow, and the reads of the old name sit in every dialect a flow string speaks (a text-slot hole, a CEL expression, a single-brace token in a value position), so the rename is the author's.

**Who is affected, measured.** The last published spec, `@objectstack/spec@17.7.0` (npm `latest`), types all six keys as plain strings and accepts any `$` name. This repository was measured with `git grep` over `packages`, `examples`, `skills`, `apps`, `content` and `docs`: the only `$`-named `errorVariable` / `outputVariable` other than `$error` were three test fixtures, all renamed here (`errorVariable: '$err'` in two `packages/spec` automation tests, `errorVariable: '$caught'` in a `service-automation` test). Every authored `errorVariable` in examples and docs is `$error`, which stays legal, and no example or doc binds a `$`-named `outputVariable`. The pinned `objectui` checkout uses `errorVariable: '$error'` only. Deployed metadata and other repositories were not measured.

### The kit

- **The rule.** `flowBoundVariableNameSchema` in `automation/flow-bound-variable-name.ts`, package-internal (no new public export), composed into the six keys. It reads the `$` prefix the engine's closed list implies, not a copy of the list.
- **The ledger.** The D3 semantic entry `flow-binding-variable-dollar-name-refused` (protocol 18), with no D2 conversion.
6 changes: 6 additions & 0 deletions content/docs/automation/flows.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -855,6 +855,12 @@ a key it does not declare, such as `maxRetry` for `maxRetries`, is refused at
`objectstack validate` with a did-you-mean, rather than dropped to a default
that never retries.

`errorVariable` defaults to `$error`, the engine's own variable, read as
`{{ $error.message }}`. A name of your own is written without a `$` —
`errorVariable: 'caught'`, read as `{{ caught.message }}` — because the `$`
names belong to the engine: any other `$` name is refused at
`objectstack validate`, and so is a `$`-named `outputVariable` on any node.

`catch` is optional in the schema, and omitting it is the trap: with no `catch`
the container **fails** when the `try` region fails, so the failure propagates
exactly as if nothing had been wrapped (measured — the no-`catch` run and the
Expand Down
6 changes: 3 additions & 3 deletions content/docs/references/automation/builtin-node-config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ Value the variable takes: a CEL value envelope `{ dialect: 'cel', source }` eval
| :--- | :--- | :--- | :--- |
| **objectName** | `string` | ✅ | Object to insert into |
| **fields** | `Record<string, any>` | optional | Field values to write on the new record: each key is a field name, each value a CEL value envelope or a literal |
| **outputVariable** | `string` | optional | Flow variable bound to the created record |
| **outputVariable** | `string` | optional | Flow variable bound to the created record — a name without a leading `$` (the `$` names are the flow engine's own), read as `{{ name }}` |


---
Expand Down Expand Up @@ -195,7 +195,7 @@ A value: a CEL value envelope `{ dialect: 'cel', source }` evaluated by the expr
| **filter** | `Record<string, any>` | optional | Field/value pairs to match (operator values like `{"$ne": null}` are preserved) |
| **fields** | `string[]` | optional | Field projection — only these fields are read (default: all) |
| **limit** | `number` | optional | Max records to return; >1 switches to a multi-record query |
| **outputVariable** | `string` | optional | Flow variable the result is bound to |
| **outputVariable** | `string` | optional | Flow variable the result is bound to — a name without a leading `$` (the `$` names are the flow engine's own), read as `{{ name }}` |


---
Expand All @@ -212,7 +212,7 @@ A value: a CEL value envelope `{ dialect: 'cel', source }` evaluated by the expr
| **indexVariable** | `string` | optional | Optional variable holding the current index |
| **itemObject** | `string` | optional | When items are records, the object they belong to (exposes each item as the child's record) |
| **input** | `Record<string, any>` | optional | Params passed to each item's subflow (interpolated per item) |
| **outputVariable** | `string` | optional | Each item's subflow output, collected in order |
| **outputVariable** | `string` | optional | Each item's subflow output, collected in order — bound to a name without a leading `$` (the `$` names are the flow engine's own), read as `{{ name }}` |


---
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/automation/control-flow.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@ const result = FlowRegionSchema.parse(data);
| :--- | :--- | :--- | :--- |
| **try** | `{ nodes: object[]; edges?: object[] }` | ✅ | Protected region |
| **catch** | `{ nodes: object[]; edges?: object[] }` | optional | Handler region run when the try region fails |
| **errorVariable** | `string` | optional (default: `"$error"`) | Variable holding the caught error in the catch region — a `TryCatchErrorValue`: `nodeId`, `message`, `code` when the failing node carried a platform-classified error code (ADR-0112 — branch on `$error.code` to tell "the row is already there" from "the store is down"), and `iteration` / `item` when the failure happened inside a loop body |
| **errorVariable** | `string` | optional (default: `"$error"`) | Variable holding the caught error in the catch region — `$error` (the default), or a name without a leading `$` read as `{{ name.message }}`; any other `$` name is the flow engine's own and refused. The value is a `TryCatchErrorValue`: `nodeId`, `message`, `code` when the failing node carried a platform-classified error code (ADR-0112 — branch on `$error.code` to tell "the row is already there" from "the store is down"), and `iteration` / `item` when the failure happened inside a loop body |
| **retry** | `{ maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; maxRetryDelayMs?: integer; … }` | optional | Optional retry policy for the try region |

### Nested Shape: `TryCatchConfig.try`
Expand Down
4 changes: 2 additions & 2 deletions content/docs/references/automation/schemaless-node-config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ const result = DecisionConditionSchema.parse(data);
| :--- | :--- | :--- | :--- |
| **function** | `string` | ✅ | Registered function to call (defineStack(`{ functions }`)). Contractually pure — it returns a value a later declarative node persists |
| **inputs** | `Record<string, any>` | optional | Inputs passed to the function (values interpolate `{token}` templates) |
| **outputVariable** | `string` | optional | Flow variable the function's return value is bound to |
| **outputVariable** | `string` | optional | Flow variable the function's return value is bound to — a name without a leading `$` (the `$` names are the flow engine's own), read as `{{ name }}` |
| **actionType** | `never` | optional | [REMOVED] `script.config.actionType` was removed in @objectstack/spec 17 — none of its values did what it said. The two built-ins were logger-backed stubs that recorded the intent and delivered nothing under any configuration, and every other value was a second spelling of `config.function`. Replace it per branch: for `email` use a `notify` node (it delivers through the messaging service — the in-app inbox by default, real email once `@objectstack/plugin-email` is installed); for `slack` use a `connector_action` node with the Slack connector, or an `http` node posting to a webhook; for anything else, move the name into `config.function`. Run `os migrate meta --from 16` to list the mechanical edits for the shorthand case into `config.function`; `--write` applies the ones it can prove, and the stub and marker values are removed. |
| **template** | `never` | optional | [REMOVED] `script.config.template` was removed in @objectstack/spec 17 — it fed only the logger-backed `email`/`slack` stubs, which never rendered or sent a message, so no template id was ever resolved. Delete the key. A `notify` node carries its own `title`/`message`, and stored templates live in the messaging service (`sys_notification_template`), not on the node. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. |
| **recipients** | `never` | optional | [REMOVED] `script.config.recipients` was removed in @objectstack/spec 17 — the addresses were logged, never messaged: the `email`/`slack` branches it fed delivered nothing. Use a `notify` node, whose `recipients` (user ids, field refs or addresses) reach the messaging service for real. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. |
Expand All @@ -182,7 +182,7 @@ const result = DecisionConditionSchema.parse(data);
| :--- | :--- | :--- | :--- |
| **flowName** | `string` | ✅ | Flow invoked as this step (it may pause — approval / screen / wait) |
| **input** | `Record<string, any>` | optional | Values passed to the subflow's input variables (interpolate `{token}` templates) |
| **outputVariable** | `string` | optional | Parent flow variable the subflow's output is bound to |
| **outputVariable** | `string` | optional | Parent flow variable the subflow's output is bound to — a name without a leading `$` (the `$` names are the flow engine's own), read as `{{ name }}` |


---
Expand Down
5 changes: 4 additions & 1 deletion docs/protocol-upgrade-guide.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,10 @@ const ctxWith = (data: any): any => ({

describe('#14955 — the throw arm refreshes `$error` like the returned-failure arm', () => {
it('a thrown failure with no fault edge of its own names ITSELF on the run-wide `$error`', async () => {
// `tc` binds its caught error to `$caught`, deliberately NOT to `$error`,
// `tc` binds its caught error to `caught`, deliberately NOT to `$error`,
// so the catch region reads the ENGINE's run-wide variable rather than
// `try_catch`'s own rebuilt binding.
// `try_catch`'s own rebuilt binding. (Not `$caught`: a `$` name other
// than `$error` is the engine's, and the contract refuses it — #22502.)
const engine = new AutomationEngine(makeLogger());
let seenError: any;
let seenNodeScoped: any;
Expand Down Expand Up @@ -74,7 +75,7 @@ describe('#14955 — the throw arm refreshes `$error` like the returned-failure
{
id: 'tc', type: 'try_catch', label: 'Guarded',
config: {
errorVariable: '$caught',
errorVariable: 'caught',
try: { nodes: [{ id: 'boom', type: 'store_down', label: 'Boom' }], edges: [] },
catch: { nodes: [{ id: 'look', type: 'probe', label: 'Look' }], edges: [] },
},
Expand Down
Loading
Loading