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

Every remaining place a flow binds a variable by name refuses a name that starts with `$`: a `loop` or `map` node's `iteratorVariable` and `indexVariable`, an object-form `screen` node's `idVariable`, a `screen` field's `name`, a declared flow variable's `name`, and an `assignment` node's targets. The refusal names the remedy: the same name without the `$`, read as `{{ name }}`.

Clause-②: no (narrowing)

<!-- adr-0087: registered flow-binding-name-dollar-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 refuses a `{{ }}` hole over a `$` name the engine does not bind, and `outputVariable` / `errorVariable` already refuse one. Every other binding took any string, and on the run a `$` binding was worse than unreadable (measured with the engine on this release's `main`):

- `iteratorVariable: '$record'` on a `loop` or `map` left `$record` holding the last item for the rest of the run, and `indexVariable: '$runId'` left `$runId` holding an index;
- `assignments: { $record: … }` overwrote the trigger record;
- a declared variable named `$record` was overwritten by the engine at run start, so its `defaultValue` never reached the run;
- a `screen` whose `idVariable` or field `name` was a `$` name paused, and then could never be submitted: the resume carrying the value was refused with `INVALID_SIGNAL`;
- a leading blank hid the `$` from a first-character rule, while the `screen` and `script` executors trim a name before they bind it: under that rule `idVariable: ' $id'` registered, the paused screen named `$id`, and its resume was refused the same way; a `script`'s `outputVariable: ' $record'` passes the protocol-18 rule too and is trimmed to `$record` by its executor (read from the executor, not run).

**What is refused.**

- `iteratorVariable` and `indexVariable` on `LoopConfigSchema` and `MapConfigSchema`, `idVariable` on `ScreenConfigSchema`, `name` on `ScreenFieldConfigSchema` and on `FlowVariableSchema`: a name that starts with `$`. 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.
- Every binding key — `outputVariable` and `errorVariable` included — now judges the first NON-BLANK character, so `' $x'` is refused like `'$x'`. The pattern's `\s` is exactly the set `String.prototype.trim` removes.
- An `assignment` node's targets, in each shape its executor binds: a key of the `assignments` map (`AssignmentConfigSchema` states it as `propertyNames.pattern`), a top-level key of the bare legacy config, and the `variable` (or `name`, `key`) of a legacy `assignments: [{ variable, value }]` item.
- `FlowSchema.parse`, `registerFlow` and `objectstack validate` refuse the flow where the name was written — `nodes.N.config.iteratorVariable`, `nodes.N.config.fields.M.name`, `nodes.N.config.assignments.NAME`, `variables.N.name` — inside a region body too. A stored flow carrying one is skipped at boot with a warn naming it, and the `loop`, `map` and `screen` executors' own contract parse refuses the node at run time.

**Unchanged.** Any name whose first non-blank character is not `$`, a `$` later in the name (`a$b`) included; the defaults (`iteratorVariable` is still `item`); an empty string where the key took one; a body-less legacy `loop`, whose `iteratorVariable` nothing reads; and every hole the text-slot judge already admits. Non-string values keep the type refusal they had.

## FROM → TO

| you wrote | write instead |
|:--|:--|
| `iteratorVariable: '$row'` with `title: '{{ $row.name }}'` | `iteratorVariable: 'row'` with `title: '{{ row.name }}'` |
| `idVariable: '$account'` | `idVariable: 'account_id'`, read as `{{ account_id }}` |
| `variables: [{ name: '$total', type: 'number' }]` | `variables: [{ name: 'total', type: 'number' }]`, read as `{{ total }}` |
| `assignments: { $total: … }` | `assignments: { total: … }` |

**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 every one of these keys as a plain string. Measured on `main` at this change's base: the 35 flows `examples/app-crm`, `examples/app-todo` and `examples/app-showcase` ship all parse clean under the new rule, and a TypeScript-AST scan of `examples` (234 files), `packages/platform-objects` (167, which ships no flow), `packages/qa/dogfood` (280), the rest of `packages` and the `hotcrm` app found no `$`-led binding on any of these positions. The pinned `objectui` checkout binds none either. 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), now composed into every binding position. A `$` binding is refused with one sentence whichever door judges it.
- **The ledger.** The D3 semantic entry `flow-binding-name-dollar-refused` (protocol 18), with no D2 conversion.
2 changes: 1 addition & 1 deletion content/docs/references/api/automation-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ const result = AutomationApiErrorCode.parse(data);

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **name** | `string` | ✅ | Variable name |
| **name** | `string` | ✅ | Variable name — a name without a leading `$` (the `$` names are the flow engine's own), read as `{{ name }}` |
| **type** | `string` | ✅ | Data type (text, number, boolean, object, list) |
| **isInput** | `boolean` | optional (default: `false`) | Is input parameter |
| **isOutput** | `boolean` | optional (default: `false`) | Is output parameter |
Expand Down
10 changes: 5 additions & 5 deletions content/docs/references/automation/builtin-node-config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -208,8 +208,8 @@ A value: a CEL value envelope `{ dialect: 'cel', source }` evaluated by the expr
| :--- | :--- | :--- | :--- |
| **collection** | `string \| any[]` | ✅ | Template/variable resolving to the array to process (an inline array is accepted) |
| **flowName** | `string` | ✅ | Subflow run for each item — it may pause (e.g. an approval) |
| **iteratorVariable** | `string` | optional (default: `"item"`) | Variable holding the current item |
| **indexVariable** | `string` | optional | Optional variable holding the current index |
| **iteratorVariable** | `string` | optional (default: `"item"`) | Variable holding the current item — a name without a leading `$` (the `$` names are the flow engine's own), read as `{{ name }}` |
| **indexVariable** | `string` | optional | Optional variable holding the current index — a name without a leading `$` (the `$` names are the flow engine's own) |
| **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, keyed by its input variables: each value a CEL value envelope `{ dialect: 'cel', source }` evaluated per item (the item variable is in scope), or a literal written as it is — a `{…}` template token is refused |
| **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 All @@ -228,7 +228,7 @@ A value: a CEL value envelope `{ dialect: 'cel', source }` evaluated by the expr
| **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 |
| **idVariable** | `string` | optional | Object form only: variable bound to the saved record's id |
| **idVariable** | `string` | optional | Object form only: variable bound to the saved record's id — a name without a leading `$` (the `$` names are the flow engine's own), read as `{{ name }}` |
| **mode** | `Enum<'create' \| 'edit'>` | optional (default: `"create"`) | Object form only: create (default) or edit; 'edit' needs a recordId to name its target |
| **recordId** | `string` | optional | Object form only: id of the record to edit (required for mode: 'edit' to be useful) |
| **defaults** | `Record<string, any>` | optional | Object form only: prefilled values |
Expand All @@ -237,7 +237,7 @@ A value: a CEL value envelope `{ dialect: 'cel', source }` evaluated by the expr

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **name** | `string` | ✅ | Field name (the flow variable the value binds to) |
| **name** | `string` | ✅ | Field name (the flow variable the value binds to) — a name without a leading `$` (the `$` names are the flow engine's own), read as `{{ name }}` |
| **label** | `string` | optional | Display label |
| **type** | `string` | optional | Input type |
| **required** | `boolean` | optional | Whether a value is required to submit |
Expand All @@ -259,7 +259,7 @@ A value: a CEL value envelope `{ dialect: 'cel', source }` evaluated by the expr

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **name** | `string` | ✅ | Field name (the flow variable the value binds to) |
| **name** | `string` | ✅ | Field name (the flow variable the value binds to) — a name without a leading `$` (the `$` names are the flow engine's own), read as `{{ name }}` |
| **label** | `string` | optional | Display label |
| **type** | `string` | optional | Input type |
| **required** | `boolean` | optional | Whether a value is required to submit |
Expand Down
4 changes: 2 additions & 2 deletions content/docs/references/automation/control-flow.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -145,8 +145,8 @@ const result = FlowRegionSchema.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **collection** | `string \| any[]` | ✅ | Template/variable resolving to the array to iterate (an inline array is accepted) |
| **iteratorVariable** | `string` | optional (default: `"item"`) | Loop variable holding the current item |
| **indexVariable** | `string` | optional | Optional loop variable holding the current index |
| **iteratorVariable** | `string` | optional (default: `"item"`) | Loop variable holding the current item — a name without a leading `$` (the `$` names are the flow engine's own), read as `{{ name }}` |
| **indexVariable** | `string` | optional | Optional loop variable holding the current index — a name without a leading `$` (the `$` names are the flow engine's own) |
| **maxIterations** | `integer` | optional | Hard cap on iterations (clamped to the engine ceiling) |
| **body** | `{ nodes: object[]; edges?: object[] }` | optional | Loop body region (omit for legacy flat-graph loops) |

Expand Down
4 changes: 2 additions & 2 deletions content/docs/references/automation/flow.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ const result = FlowSchema.parse(data);

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **name** | `string` | ✅ | Variable name |
| **name** | `string` | ✅ | Variable name — a name without a leading `$` (the `$` names are the flow engine's own), read as `{{ name }}` |
| **type** | `string` | ✅ | Data type (text, number, boolean, object, list) |
| **isInput** | `boolean` | optional (default: `false`) | Is input parameter |
| **isOutput** | `boolean` | optional (default: `false`) | Is output parameter |
Expand Down Expand Up @@ -223,7 +223,7 @@ const result = FlowSchema.parse(data);

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **name** | `string` | ✅ | Variable name |
| **name** | `string` | ✅ | Variable name — a name without a leading `$` (the `$` names are the flow engine's own), read as `{{ name }}` |
| **type** | `string` | ✅ | Data type (text, number, boolean, object, list) |
| **isInput** | `boolean` | optional (default: `false`) | Is input parameter |
| **isOutput** | `boolean` | optional (default: `false`) | Is output parameter |
Expand Down
3 changes: 2 additions & 1 deletion packages/spec/dropped-refinements.baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"measured": {
"zod": "4.4.3",
"publishedSchemasWithDroppedRefinements": 226,
"droppedRefinementSites": 704,
"droppedRefinementSites": 705,
"refinementSitesThatDidProject": 369,
"refinementSitesWithNoJsonFormToCompare": 0
},
Expand Down Expand Up @@ -462,6 +462,7 @@
},
"automation/AssignmentConfig": {
"sites": [
"out",
"out.assignments.out.valueType",
"out.catchall"
]
Expand Down
Loading
Loading