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
60 changes: 60 additions & 0 deletions .changeset/flow-variable-default-value.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
---
"@objectstack/spec": minor
"@objectstack/service-automation": minor
---

feat(spec,service-automation): a flow variable can declare a `defaultValue`, so "declared" means "bound" (#4697)

Declaring a flow variable used to guarantee nothing at run time. The engine bound
an `isInput` variable **only** when the caller actually supplied it
(`params[name] !== undefined`), so every path that omitted the parameter left the
name unbound — and a flow condition is strict CEL, where an unbound name does not
read as `false`, it **aborts the predicate and stops the run**. The declaration was
documentation, not a guarantee, and there was no metadata form that said "this
variable always has a value".

`FlowVariableSchema` now takes an optional `defaultValue`, and the engine binds it
whenever no parameter supplies one:

```typescript
variables: [
{ name: 'createOpportunity', type: 'boolean', isInput: true, defaultValue: false },
]
```

The rules:

- **A supplied parameter always wins**, including a falsy one — the boundary is
`!== undefined`, so `false`, `null`, `0` and `''` are answers rather than
absences, and only a genuinely missing parameter falls through to the default.
- **A non-input declaration takes its default too.** `isInput: false` means no
parameter can reach the name, so the default is the only thing that can bind it.
- **A declared variable shadows a trigger-record field of the same name**, whether
it was bound from a parameter or from its default — the rule a parameter already
followed. A name cannot resolve out of a different source depending on whether
the caller passed it.

Both run entry points seed from one shared site, so the retry path behaves
identically to the first attempt.

**Additive and opt-in.** A declaration without `defaultValue` behaves exactly as
before, so existing flows parse and run unchanged. The value is not cross-checked
against the declared `type` — `type` is an open string with no vocabulary to check
against, the same posture as every other `defaultValue` on the authoring surface.

The case this closes came from a screen flow (hotcrm#643): a screen collects an
optional checkbox, the client returns only the fields the user actually touched,
so on the untouched path the variable was never bound, the outgoing edge aborted,
and a lead conversion persisted nothing. The workaround was an `assignment` node
before every screen mirroring the screen field's own `defaultValue`; a declared
default replaces that ceremony.

The docs half of the same gap is now written down too
(`content/docs/automation/flows.mdx`): under strict CEL the guard an author
reaches for first — `has(X.f)` — **aborts** on an unbound `X`, the very case it is
written for. Only the `vars.`-scoped `has(vars.X)` tests bindedness. That truth
table is measured against the live evaluator in
`service-automation/src/flow-variable-default.test.ts` rather than asserted, so a
prescription nothing executes cannot quietly stop being true. Prefer
`defaultValue` over either guard: a guard encodes "unanswered means no" into the
predicate and leaves the graph defect in place.
101 changes: 99 additions & 2 deletions content/docs/automation/flows.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -336,7 +336,10 @@ Two things worth knowing:

- **Give a `required` boolean a `defaultValue`.** An untouched checkbox holds no
value at all, which counts as unanswered — without `defaultValue: false` the
user cannot express "no" by leaving it clear.
user cannot express "no" by leaving it clear. Mirror it on the **flow
variable** as well ([`defaultValue`](#defaultvalue--declaring-a-variable-is-not-the-same-as-binding-it)):
the client returns only the fields the user touched, so on the untouched path
the name is never bound and a downstream condition reading it aborts the run.
- **A broken predicate fails open** (the field stays visible) rather than hiding
an input the flow may be waiting on. `registerFlow()` does check the predicate
as bare CEL — a `{var}` template or a syntax error is a loud registration
Expand Down Expand Up @@ -951,6 +954,7 @@ variables: [
{ name: 'input_id', type: 'text', isInput: true, isOutput: false },
{ name: 'result', type: 'object', isInput: false, isOutput: true },
{ name: 'counter', type: 'number', isInput: false, isOutput: false },
{ name: 'createOpportunity', type: 'boolean', isInput: true, defaultValue: false },
]
```

Expand All @@ -960,6 +964,96 @@ variables: [
| `type` | `string` | `'text'`, `'number'`, `'boolean'`, `'object'`, `'list'` |
| `isInput` | `boolean` | Available as input parameter |
| `isOutput` | `boolean` | Available as output parameter |
| `defaultValue` | `unknown` | Bound at run start when no parameter supplies one — this is what makes the declaration a guarantee. Optional |

### `defaultValue` — declaring a variable is not the same as binding it

Without `defaultValue`, a declaration is documentation. The engine binds an
`isInput` variable **only** when the caller actually passed it
(`params[name] !== undefined`), so every path that omits the parameter leaves
the name unbound — and an unbound name in a condition does not read as `false`,
it **aborts the predicate and stops the run**.

Declare the default and the name is bound on every path:

{/* os:check */}
```typescript
import { defineFlow } from '@objectstack/spec/automation';

export const LeadConversion = defineFlow({
name: 'lead_conversion',
label: 'Lead Conversion',
type: 'screen',
variables: [
// Bound to `false` on every path the caller leaves it out of.
{ name: 'createOpportunity', type: 'boolean', isInput: true, defaultValue: false },
],
nodes: [
{ id: 'start', type: 'start', label: 'Start' },
{ id: 'end', type: 'end', label: 'End' },
],
edges: [{ id: 'e1', source: 'start', target: 'end' }],
});
```

The rules, all three worth knowing:

- **A supplied parameter always wins** — including a falsy one. The boundary is
`!== undefined`, so `false`, `null`, `0` and `''` are answers, not absences,
and only a genuinely missing parameter falls through to the default.
- **A non-input variable takes its default too.** `isInput: false` means no
parameter can reach the name, so the default is the only thing that can bind
it.
- **A declared variable shadows a trigger-record field of the same name** —
whether it was bound from a parameter or from its default. That is the rule a
parameter already followed; extending it to defaults is what keeps a name from
resolving out of a different source depending on whether the caller passed it.

The case this exists for is a screen that collects something optional. The
client returns only the fields the user actually touched, so an untouched
checkbox arrives as nothing at all — and the edge reading it aborts. Give the
variable a `defaultValue` mirroring the screen field's own, and the untouched
path routes:

```typescript
variables: [
{ name: 'createOpportunity', type: 'boolean', isInput: true, defaultValue: false },
],
nodes: [
{ id: 'collect', type: 'screen', label: 'Conversion Details', config: {
fields: [{ name: 'createOpportunity', label: 'Create Opportunity?',
type: 'boolean', defaultValue: false }],
} },
],
edges: [
{ id: 'e_no', source: 'collect', target: 'skip', condition: 'createOpportunity == false' },
]
```

The value is **not** checked against the declared `type` — `type` is an open
string, so there is no vocabulary to check against. It behaves like every other
`defaultValue` on the authoring surface.

<Callout type="warn">
**Reading a variable that might be unbound: only the `vars.`-scoped guard
works.** Conditions are strict CEL, and `has()` is not the escape hatch it looks
like — measured on the live evaluator (and pinned as a test):

| expression | `X` unbound | `X = null` | `X = {}` | `X = {f: 1}` |
| :--- | :--- | :--- | :--- | :--- |
| `X.f == 1` | **aborts** `Unknown variable: X` | **aborts** `No such key: f` | **aborts** | `true` |
| `has(X.f)` | **aborts** `Unknown variable: X` | `false` | `false` | `true` |
| `has(vars.X)` | `false` | `true` | `true` | `true` |

So the natural spelling `has(X.f)` fails on exactly the case it was written for,
and `has(vars.X) && X.f == 1` is the guard that holds (CEL short-circuits, so
the right-hand read never runs on the unbound leg).

Reach for `defaultValue` first anyway. A guard says "unanswered means no" inside
the predicate and leaves the run's shape depending on what the client happened
to send; a default removes the unbound state, and every later reader of the flow
can see it in the declaration.
</Callout>

## Error Handling

Expand Down Expand Up @@ -1152,7 +1246,10 @@ curl -b cookies.txt -X POST \
**Passing inputs.** Declare variables with `isInput: true`, then send them in
`params` under the same names — only declared inputs are bound. `recordId` and
`objectName` are lifted into `params` for you (plus an `<objectName>Id` alias),
and `event` defaults to `'manual'`.
and `event` defaults to `'manual'`. A param you *omit* leaves the variable
unbound unless its declaration carries a
[`defaultValue`](#defaultvalue--declaring-a-variable-is-not-the-same-as-binding-it);
a param you send explicitly always wins, `false` and `null` included.

**Identity.** The caller's identity (user, positions, permissions, tenant) is
forwarded into the run, so a `runAs: 'user'` flow executes under that caller's
Expand Down
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 @@ -94,7 +94,7 @@ const result = AutomationApiErrorCode.parse(data);
| **status** | `Enum<'draft' \| 'active' \| 'obsolete' \| 'invalid'>` | optional | Deployment status |
| **template** | `never` | optional | [REMOVED] `flow.template` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — no designer or engine path ever read it, so flagging a flow as a template/subflow did nothing. Delete the key. Shared logic is invoked via a subflow NODE referencing the flow by name. |
| **type** | `Enum<'autolaunched' \| 'record_change' \| 'schedule' \| 'screen' \| 'api'>` | ✅ | Flow type |
| **variables** | `{ name: string; type: string; isInput?: boolean; isOutput?: boolean }[]` | optional | Flow variables |
| **variables** | `{ name: string; type: string; isInput?: boolean; isOutput?: boolean; … }[]` | optional | Flow variables |
| **nodes** | `{ id: string; type: string; label: string; config?: Record<string, any>; … }[]` | ✅ | Flow nodes |
| **edges** | `{ id: string; source: string; target: string; condition?: string \| object; … }[]` | ✅ | Flow connections |
| **active** | `never` | optional | [REMOVED] `flow.active` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — it never had an effect: the engine arms flows from `status`, and `active: false` did NOT stop a flow (worse, the default read as disabled while the engine treated unset as enabled). Delete the key. Use `status: 'obsolete'` (or 'invalid') to unbind and disable a flow, `status: 'active'` to arm it. |
Expand Down
3 changes: 2 additions & 1 deletion content/docs/references/automation/flow.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ const result = FlowSchema.parse(data);
| **status** | `Enum<'draft' \| 'active' \| 'obsolete' \| 'invalid'>` | optional | Deployment status |
| **template** | `never` | optional | [REMOVED] `flow.template` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — no designer or engine path ever read it, so flagging a flow as a template/subflow did nothing. Delete the key. Shared logic is invoked via a subflow NODE referencing the flow by name. |
| **type** | `Enum<'autolaunched' \| 'record_change' \| 'schedule' \| 'screen' \| 'api'>` | ✅ | Flow type |
| **variables** | `{ name: string; type: string; isInput?: boolean; isOutput?: boolean }[]` | optional | Flow variables |
| **variables** | `{ name: string; type: string; isInput?: boolean; isOutput?: boolean; … }[]` | optional | Flow variables |
| **nodes** | `{ id: string; type: string; label: string; config?: Record<string, any>; … }[]` | ✅ | Flow nodes |
| **edges** | `{ id: string; source: string; target: string; condition?: string \| object; … }[]` | ✅ | Flow connections |
| **active** | `never` | optional | [REMOVED] `flow.active` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — it never had an effect: the engine arms flows from `status`, and `active: false` did NOT stop a flow (worse, the default read as disabled while the engine treated unset as enabled). Delete the key. Use `status: 'obsolete'` (or 'invalid') to unbind and disable a flow, `status: 'active'` to arm it. |
Expand Down Expand Up @@ -142,6 +142,7 @@ const result = FlowSchema.parse(data);
| **type** | `string` | ✅ | Data type (text, number, boolean, object, list) |
| **isInput** | `boolean` | ✅ | Is input parameter |
| **isOutput** | `boolean` | ✅ | Is output parameter |
| **defaultValue** | `any` | optional | Value bound at run start when no parameter supplies one — this is what makes a declared variable always bound. An explicitly supplied param wins, including `false` and `null`; the boundary is `params[name] !== undefined`. |


---
Expand Down
68 changes: 52 additions & 16 deletions packages/services/service-automation/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2762,14 +2762,7 @@ export class AutomationEngine implements IAutomationService {
if (reentryKey) this.activeRecordFlows.add(reentryKey);

// Initialize variable context
const variables = new Map<string, unknown>();
if (flow.variables) {
for (const v of flow.variables) {
if (v.isInput && context?.params?.[v.name] !== undefined) {
variables.set(v.name, context.params[v.name]);
}
}
}
const variables = this.seedDeclaredVariables(flow, context);
// Inject trigger record. `$record` is the canonical handle; `record` is a
// friendlier alias so templates/conditions can write `{record.title}` and
// `record.status`. We also flatten the record's own fields to top-level
Expand Down Expand Up @@ -5707,6 +5700,56 @@ export class AutomationEngine implements IAutomationService {
return { success: false, error: lastError, durationMs: Date.now() - startTime };
}

/**
* Seed a run's variable map from the flow's DECLARED variables — the one
* place `declared` is turned into `bound` (#4697).
*
* Two sources, in this precedence:
*
* 1. `context.params[name]`, for an `isInput` variable, when the caller
* supplied it. The boundary is `!== undefined`, so an explicit `false`,
* `null`, `0` or `''` is a supplied value and wins over the default —
* only *absence* falls through.
* 2. `defaultValue`, when the declaration carries one. This is the half
* that did not exist before #4697: a declared variable the caller left
* out stayed **unbound**, and conditions are strict CEL, where reading
* an unbound name ABORTS the predicate (`Unknown variable: X`) instead
* of yielding `false`. A screen flow collecting an optional checkbox hit
* exactly that — the runner returns only the fields the user touched, so
* the untouched path aborted the outgoing edge and the run stopped
* (hotcrm#643). The workaround was an `assignment` node per screen,
* mirroring the screen field's own `defaultValue`.
*
* `defaultValue` is honoured for a NON-input variable too: params are not
* readable there by definition, so the default is the only thing that can
* bind it, and "declared means bound" would otherwise hold for half the
* declarations. A declaration with no `defaultValue` behaves exactly as
* before — existing flows are untouched.
*
* Seeding happens BEFORE the trigger record is flattened to top-level
* names, and that flattening skips names already present. So a declared
* variable — bound from a param or from its default — shadows a record
* field of the same name, which is the rule params already followed; a
* default cannot make the same name resolve from a different source
* depending on whether the caller passed it.
*/
private seedDeclaredVariables(
flow: FlowParsed,
context?: AutomationContext,
): Map<string, unknown> {
const variables = new Map<string, unknown>();
if (!flow.variables) return variables;
for (const v of flow.variables) {
const supplied = v.isInput ? context?.params?.[v.name] : undefined;
if (supplied !== undefined) {
variables.set(v.name, supplied);
} else if (v.defaultValue !== undefined) {
variables.set(v.name, v.defaultValue);
}
}
return variables;
}

/**
* Execute a flow without triggering retry logic (used by retryExecution to prevent recursion).
*/
Expand All @@ -5724,14 +5767,7 @@ export class AutomationEngine implements IAutomationService {
return { success: false, error: `Flow '${flowName}' is disabled` };
}

const variables = new Map<string, unknown>();
if (flow.variables) {
for (const v of flow.variables) {
if (v.isInput && context?.params?.[v.name] !== undefined) {
variables.set(v.name, context.params[v.name]);
}
}
}
const variables = this.seedDeclaredVariables(flow, context);
if (context?.record) {
variables.set('$record', context.record);
}
Expand Down
Loading
Loading