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

feat(automation,spec): flow executors `parse()` their config, and undeclared config keys reject at registration (#4277)

The #4045 reconciliation left every flat builtin with a Zod config contract that
nothing enforced, and #4059 left `registerFlow` warning about undeclared keys it
could not yet safely reject. #4277 installs both halves of the enforcement:

**1. Executors parse their config (execute time).** The 12 contract-carrying
builtins — `get_record` / `create_record` / `update_record` / `delete_record`,
`screen`, `map`, `notify`, `http`, `loop` / `parallel` / `try_catch` — now run
`node.config` through their Zod contract before executing
(`service-automation/builtin/parse-config.ts`). A type or missing-`required`
violation refuses the node as a **guard** (`errorClass: 'guard'`, not routable
via `fault` edges — config is metadata; re-running changes nothing), naming
every violated path. `{token}` templates stay legal: string-typed slots parse
the raw template, and `http` — whose executor reads the interpolated config —
parses POST-interpolation, where a whole-token template has already resolved to
its value's real type. Exemption: a legacy flat-graph `loop` (no `config.body`)
predates the ADR-0031 construct and is not parsed.

**2. Undeclared config keys are rejected at `registerFlow` (registration
time).** The #4059 warning is now an error: a config key the node type's
descriptor `configSchema` does not declare fails registration, with the exact
path, the declared key set, a did-you-mean, and — for keys with documented
history (`screen.visibleIf`, `create_record`/`update_record.fieldValues`) — a
per-key tombstone (the `UNKNOWN_KEY_GUIDANCE` pattern). Unchanged exemptions:
`assignment` is exempt wholesale (its top-level keys ARE the author's variable
names), schemaless types (`decision`/`script`/`wait`/`subflow`/
`connector_action`) declare nothing so nothing can be undeclared, and keyValue
maps stop the walk (their keys are author data). Every `registerFlow` call site
already try/catches per flow, so a bad stored flow is skipped loudly at boot,
never a crashed kernel.

**Contract fix folded in:** `LoopConfigSchema.collection` is now
`z.union([z.string().min(1), z.array(z.unknown())])` — the executor has always
accepted an inline array (shared resolve logic with `map.collection`, which
already declared the union), so the string-only declaration under-declared what
it reads.

**Migration.** If a flow stops registering: the error names the undeclared key
and its path — rename it to the declared key it meant (`visibleIf` →
`visibleWhen`, `fieldValues` → `fields`), or delete it (an undeclared key was
never read, so removing it changes no behavior). If an executor of yours
genuinely reads the key, declare it on the node type's descriptor
`configSchema`. If a node starts refusing at run time: the refusal names each
violated path against the contract — fix the value's type or supply the missing
required key (e.g. `get_record` `limit` must be a number; `screen`
`fields[].options` entries are `{ value, label }` objects; `notify` requires
`recipients` + `title`). Retry-policy defaults now come from the contract: a
`try_catch` `retry` block that omits `retryDelayMs` gets the documented 1000ms
base delay where the executor historically used 0.
12 changes: 9 additions & 3 deletions content/docs/automation/flows.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ Each node performs a specific action in the flow.
| `id` | `string` | ✅ | Unique node identifier |
| `type` | `string` | ✅ | Node type — a built-in id from the table above **or** a plugin-registered one. Per ADR-0018 the spec does not gate this with a closed enum; it is checked against the live action registry at `registerFlow()` |
| `label` | `string` | ✅ | Display label |
| `config` | `object` | optional | Type-specific configuration (an open record — the registered executor's `configSchema` owns its shape) |
| `config` | `object` | optional | Type-specific configuration — the registered executor's `configSchema` owns its shape. Keys that schema does not declare are rejected at `registerFlow()`, and the built-in executors `parse()` the value against their Zod contract before running (#4277) |
| `connectorConfig` | `object` | optional | `{ connectorId, actionId, input }` for a `connector_action` node |
| `position` | `{ x, y }` | optional | Visual position on canvas |
| `timeoutMs` | `number` | optional | Per-node execution timeout |
Expand All @@ -148,8 +148,14 @@ Each node performs a specific action in the flow.

<Callout type="info">
The flow, node, edge, and variable **shells are `.strict()`** — a key they do not
declare is a parse error naming the likely intended key, not a silent strip. (A
node's `config` stays open, so plugin node types keep their own vocabulary.)
declare is a parse error naming the likely intended key, not a silent strip. A
node's `config` is open at the flow-parse level (plugin node types keep their own
vocabulary), but it is not unchecked: `registerFlow()` rejects config keys the
node type's published `configSchema` does not declare — naming the path, the
declared key set, and a did-you-mean — and the contract-carrying builtins
additionally `parse()` their config at execute time, refusing the node on a type
or missing-`required` violation (#4277). A node type that publishes no
`configSchema` declares nothing, so nothing can be undeclared.
</Callout>

### Node Examples
Expand Down
16 changes: 11 additions & 5 deletions content/docs/references/automation/builtin-node-config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -43,15 +43,21 @@ field-item keys `options`/`defaultValue`/`placeholder`, `map.indexVariable`,

ADR-0087 D2 conversion layer like `notify.source` before it.

## What these schemas are (and are not) wired to
## What these schemas are wired to (#4277)

Contract exports only — no engine path `parse()`s a node config with them
Live execute-time contracts: each executor `parse()`s its config against

today, so registering a flow behaves exactly as before. Wiring the executors
its schema before running (`service-automation`'s `parse-config.ts`), so

to parse is deliberately deferred until the #4059 undeclared-key warning has
type and `required` violations refuse the node as a guard. All of these

measured a release's worth of real metadata (#4045 step 3b).
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. Unknown keys are rejected earlier,

at `registerFlow()` (the tightened #4059 check); the parse here strips them.

Deliberately absent:

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 @@ -107,7 +107,7 @@ const result = FlowRegion.parse(data);

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **collection** | `string` | ✅ | Template/variable resolving to the array to iterate |
| **collection** | `string \| any[]` | ✅ | Template/variable resolving to the array to iterate (an inline array is accepted) |
| **iteratorVariable** | `string` | optional | Loop variable holding the current item |
| **indexVariable** | `string` | optional | Optional loop variable holding the current index |
| **maxIterations** | `integer` | optional | Hard cap on iterations (clamped to the engine ceiling) |
Expand Down
24 changes: 17 additions & 7 deletions content/docs/references/automation/io-node-config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -25,21 +25,31 @@ and a Zod copied from the form would make that reconciliation a tautology —

it would pass by construction and prove nothing (#4045).

## What these schemas are (and are not) wired to
## What these schemas are wired to (#4277)

Like `LoopConfigSchema` / `ParallelConfigSchema` / `TryCatchConfigSchema`,

these are **contract exports**: no engine path `parse()`s a node config with
these are **live execute-time contracts**: each executor `parse()`s its

them today, so registering a flow behaves exactly as before. Wiring the
config against its schema before running (`service-automation`'s

executors to parse — which would finally give node configs the type /
`parse-config.ts`), so type and `required` violations refuse the node as a

`required` / unknown-key enforcement the descriptor `configSchema` never
guard (not routable via `fault` edges). `notify` parses the RAW stored

providedis deliberately deferred until the #4059 undeclared-key warning
configits slots are string-typed, so `\{token\}` templates pass and the

has measured a release's worth of real metadata (#4045 step 3b).
post-interpolation guards still own "resolved to nothing". `http` parses

the INTERPOLATED config, because that is the shape its executor reads —

a `\{token\}` in a typed slot (`timeoutMs`, `durable`) resolves to its real

type first. Unknown keys are the registration layer's job: `registerFlow()`

rejects keys the descriptor `configSchema` does not declare (the tightened

#4059 check), while the parse here strips them.

`connector_action` has no schema here on purpose: its config contract is

Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/automation/node-executor.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ Canonical cross-paradigm action/node descriptor (ADR-0018)
| **icon** | `string` | optional | Icon id resolved by the designer |
| **category** | `Enum<'logic' \| 'data' \| 'io' \| 'human' \| 'control' \| 'custom'>` | ✅ | Palette category |
| **paradigms** | `Enum<'flow' \| 'approval'>[]` | ✅ | Authoring surfaces that may offer this action |
| **configSchema** | `any` | optional | JSON Schema for the node config (drives the designer form; only declared expression slots are validated) |
| **configSchema** | `any` | optional | JSON Schema for the node config (drives the designer form; undeclared keys are rejected at registration) |
| **supportsPause** | `boolean` | ✅ | Supports async pause/resume |
| **supportsCancellation** | `boolean` | ✅ | Supports cancellation |
| **supportsRetry** | `boolean` | ✅ | Supports retry on failure |
Expand Down
9 changes: 8 additions & 1 deletion examples/app-showcase/src/automation/flows/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1384,7 +1384,10 @@ export const InquiryPurgeFlow = defineFlow({
id: 'report',
type: 'notify',
label: 'Report cleanup',
config: { topic: 'inquiry_purge', message: 'Closed inquiries purged.' },
// `recipients` + `title` are the notify contract's required pair — this
// node used to omit both, so every purge run FAILED here at execute time
// (found when the executors started parsing their config, #4277).
config: { topic: 'inquiry_purge', recipients: ['admin@objectos.ai'], title: 'Closed inquiries purged', message: 'Closed inquiries purged.' },
},
{ id: 'end', type: 'end', label: 'End' },
],
Expand Down Expand Up @@ -1538,6 +1541,10 @@ export const TaskDueReminderFlow = defineFlow({
label: 'Remind Owner',
config: {
topic: 'task.due_soon',
// The contract's required audience — this node used to omit it, so
// every sweep run FAILED here ("at least one recipient is required")
// and the #1874 demo never delivered (surfaced by #4277's parse).
recipients: ['{record.assignee}'],
channels: ['inbox'],
severity: 'warning',
title: 'Task due soon: {record.title}',
Expand Down
45 changes: 35 additions & 10 deletions examples/app-todo/src/flows/task.flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ export const TaskReminderFlow: Flow = {
{ id: 'start', type: 'start', label: 'Start (Daily 8 AM)', config: { schedule: '0 8 * * *', objectName: 'todo_task' } },
{
id: 'get_upcoming_tasks', type: 'get_record', label: 'Get Tasks Due Tomorrow',
config: { objectName: 'todo_task', filter: { due_date: '{tomorrow}', is_completed: false }, outputVariable: 'tasksToRemind', getAll: true },
// `limit > 1` is the declared way to make this a LIST read (`find`, not
// `findOne`) — the undeclared `getAll` that sat here was never read, so
// this sweep silently fetched a single task (#4277 rejects the key now).
config: { objectName: 'todo_task', filter: { due_date: '{tomorrow}', is_completed: false }, outputVariable: 'tasksToRemind', limit: 200 },
},
{
id: 'loop_tasks', type: 'loop', label: 'Loop Through Tasks',
Expand Down Expand Up @@ -67,10 +70,11 @@ export const OverdueEscalationFlow: Flow = {
{ id: 'start', type: 'start', label: 'Start (Daily 9 AM)', config: { schedule: '0 9 * * *', objectName: 'todo_task' } },
{
id: 'get_overdue_tasks', type: 'get_record', label: 'Get Severely Overdue Tasks',
// `limit > 1` = LIST read; the undeclared `getAll` was never read (#4277).
config: {
objectName: 'todo_task',
filter: { due_date: { $lt: '{3_days_ago}' }, is_completed: false, is_overdue: true },
outputVariable: 'overdueTasks', getAll: true,
outputVariable: 'overdueTasks', limit: 200,
},
},
{
Expand Down Expand Up @@ -178,11 +182,31 @@ export const QuickAddTaskFlow: Flow = {
{
id: 'screen_1', type: 'screen', label: 'Task Details',
config: {
// Select options are `{ value, label }` pairs (the screen contract's
// shape — bare strings fail the executor's config parse, #4277).
fields: [
{ name: 'subject', label: 'Task Subject', type: 'text', required: true },
{ name: 'priority', label: 'Priority', type: 'select', options: ['low', 'normal', 'high', 'urgent'], defaultValue: 'normal' },
{
name: 'priority', label: 'Priority', type: 'select', defaultValue: 'normal',
options: [
{ value: 'low', label: 'Low' },
{ value: 'normal', label: 'Normal' },
{ value: 'high', label: 'High' },
{ value: 'urgent', label: 'Urgent' },
],
},
{ name: 'dueDate', label: 'Due Date', type: 'date', required: false },
{ name: 'category', label: 'Category', type: 'select', options: ['personal', 'work', 'shopping', 'health', 'finance', 'other'] },
{
name: 'category', label: 'Category', type: 'select',
options: [
{ value: 'personal', label: 'Personal' },
{ value: 'work', label: 'Work' },
{ value: 'shopping', label: 'Shopping' },
{ value: 'health', label: 'Health' },
{ value: 'finance', label: 'Finance' },
{ value: 'other', label: 'Other' },
],
},
],
},
},
Expand All @@ -196,13 +220,14 @@ export const QuickAddTaskFlow: Flow = {
},
{
id: 'success_screen', type: 'screen', label: 'Success',
// The screen contract has no `message`/`buttons` keys — nothing ever
// read them, so this "success screen" was an invisible pass-through
// (#4277 rejects the keys now). `description` + `waitForInput: true`
// is the declared way to pause on a message-only confirmation screen.
config: {
message: 'Task "{subject}" created successfully!',
buttons: [
{ label: 'Create Another', action: 'restart' },
{ label: 'View Task', action: 'navigate', target: '/task/{newTaskId.id}' },
{ label: 'Done', action: 'finish' },
],
title: 'Task Created',
description: 'Task "{subject}" created successfully!',
waitForInput: true,
},
},
{ id: 'end', type: 'end', label: 'End' },
Expand Down
Loading
Loading