Skip to content

Commit b07d829

Browse files
os-zhuangclaude
andauthored
feat(automation,spec): 流程执行器 parse() 自己的 config,未声明键在注册时报错 (#4277) (#4332)
* feat(automation,spec): flow executors parse() their config; undeclared config keys reject at registration (#4277) Half (a): the 12 contract-carrying builtins (CRUD quartet, screen, map, notify, http, loop/parallel/try_catch) now parse node.config against their Zod contracts before executing (builtin/parse-config.ts). A type or missing-required violation refuses the node as a guard — not routable via fault edges. Templates stay legal: string slots parse the raw config; http parses post-interpolation, the shape its executor reads. A legacy flat-graph loop (no config.body) stays exempt. LoopConfigSchema.collection widened to string|array — the executor always accepted inline arrays (map.collection already declared the union), so string-only under-declared. Half (b): registerFlow now REJECTS config keys the node type's descriptor configSchema does not declare (tightening the #4059 warning), naming the path, the declared key set, a did-you-mean, and per-key tombstones (the UNKNOWN_KEY_GUIDANCE pattern). assignment stays exempt wholesale (its top-level keys are author variable names); schemaless types and keyValue maps are unchanged. Example metadata fixed at the producer (PD #12): two showcase notify nodes missing the required recipients/title (both already failed at run time), and app-todo's dead getAll/message/buttons keys plus bare-string select options. Closes #4277 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CW8ZP3zUuC7ovSxqnN5o77 * docs(automation): flows.mdx stops claiming node config is unchecked (#4277) The docs-drift advisory on #4332 flagged flows.mdx, and it was right: the node-property table and the strict-shells callout both described config as an open record with no enforcement, which #4277 changed — undeclared keys reject at registerFlow() and the contract-carrying builtins parse their config at execute time. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CW8ZP3zUuC7ovSxqnN5o77 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8dcc0f5 commit b07d829

26 files changed

Lines changed: 870 additions & 274 deletions
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
---
2+
"@objectstack/service-automation": minor
3+
"@objectstack/spec": minor
4+
---
5+
6+
feat(automation,spec): flow executors `parse()` their config, and undeclared config keys reject at registration (#4277)
7+
8+
The #4045 reconciliation left every flat builtin with a Zod config contract that
9+
nothing enforced, and #4059 left `registerFlow` warning about undeclared keys it
10+
could not yet safely reject. #4277 installs both halves of the enforcement:
11+
12+
**1. Executors parse their config (execute time).** The 12 contract-carrying
13+
builtins — `get_record` / `create_record` / `update_record` / `delete_record`,
14+
`screen`, `map`, `notify`, `http`, `loop` / `parallel` / `try_catch` — now run
15+
`node.config` through their Zod contract before executing
16+
(`service-automation/builtin/parse-config.ts`). A type or missing-`required`
17+
violation refuses the node as a **guard** (`errorClass: 'guard'`, not routable
18+
via `fault` edges — config is metadata; re-running changes nothing), naming
19+
every violated path. `{token}` templates stay legal: string-typed slots parse
20+
the raw template, and `http` — whose executor reads the interpolated config —
21+
parses POST-interpolation, where a whole-token template has already resolved to
22+
its value's real type. Exemption: a legacy flat-graph `loop` (no `config.body`)
23+
predates the ADR-0031 construct and is not parsed.
24+
25+
**2. Undeclared config keys are rejected at `registerFlow` (registration
26+
time).** The #4059 warning is now an error: a config key the node type's
27+
descriptor `configSchema` does not declare fails registration, with the exact
28+
path, the declared key set, a did-you-mean, and — for keys with documented
29+
history (`screen.visibleIf`, `create_record`/`update_record.fieldValues`) — a
30+
per-key tombstone (the `UNKNOWN_KEY_GUIDANCE` pattern). Unchanged exemptions:
31+
`assignment` is exempt wholesale (its top-level keys ARE the author's variable
32+
names), schemaless types (`decision`/`script`/`wait`/`subflow`/
33+
`connector_action`) declare nothing so nothing can be undeclared, and keyValue
34+
maps stop the walk (their keys are author data). Every `registerFlow` call site
35+
already try/catches per flow, so a bad stored flow is skipped loudly at boot,
36+
never a crashed kernel.
37+
38+
**Contract fix folded in:** `LoopConfigSchema.collection` is now
39+
`z.union([z.string().min(1), z.array(z.unknown())])` — the executor has always
40+
accepted an inline array (shared resolve logic with `map.collection`, which
41+
already declared the union), so the string-only declaration under-declared what
42+
it reads.
43+
44+
**Migration.** If a flow stops registering: the error names the undeclared key
45+
and its path — rename it to the declared key it meant (`visibleIf`
46+
`visibleWhen`, `fieldValues``fields`), or delete it (an undeclared key was
47+
never read, so removing it changes no behavior). If an executor of yours
48+
genuinely reads the key, declare it on the node type's descriptor
49+
`configSchema`. If a node starts refusing at run time: the refusal names each
50+
violated path against the contract — fix the value's type or supply the missing
51+
required key (e.g. `get_record` `limit` must be a number; `screen`
52+
`fields[].options` entries are `{ value, label }` objects; `notify` requires
53+
`recipients` + `title`). Retry-policy defaults now come from the contract: a
54+
`try_catch` `retry` block that omits `retryDelayMs` gets the documented 1000ms
55+
base delay where the executor historically used 0.

content/docs/automation/flows.mdx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ Each node performs a specific action in the flow.
138138
| `id` | `string` || Unique node identifier |
139139
| `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()` |
140140
| `label` | `string` || Display label |
141-
| `config` | `object` | optional | Type-specific configuration (an open record — the registered executor's `configSchema` owns its shape) |
141+
| `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) |
142142
| `connectorConfig` | `object` | optional | `{ connectorId, actionId, input }` for a `connector_action` node |
143143
| `position` | `{ x, y }` | optional | Visual position on canvas |
144144
| `timeoutMs` | `number` | optional | Per-node execution timeout |
@@ -148,8 +148,14 @@ Each node performs a specific action in the flow.
148148

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

155161
### Node Examples

content/docs/references/automation/builtin-node-config.mdx

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,15 +43,21 @@ field-item keys `options`/`defaultValue`/`placeholder`, `map.indexVariable`,
4343

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

46-
## What these schemas are (and are not) wired to
46+
## What these schemas are wired to (#4277)
4747

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

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

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

54-
measured a release's worth of real metadata (#4045 step 3b).
54+
parse the RAW stored config — their typed slots are strings (or `unknown`
55+
56+
where values interpolate), so `\{token\}` templates pass and resolve at the
57+
58+
executor's existing interpolation points. Unknown keys are rejected earlier,
59+
60+
at `registerFlow()` (the tightened #4059 check); the parse here strips them.
5561

5662
Deliberately absent:
5763

content/docs/references/automation/control-flow.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ const result = FlowRegion.parse(data);
107107

108108
| Property | Type | Required | Description |
109109
| :--- | :--- | :--- | :--- |
110-
| **collection** | `string` || Template/variable resolving to the array to iterate |
110+
| **collection** | `string \| any[]` || Template/variable resolving to the array to iterate (an inline array is accepted) |
111111
| **iteratorVariable** | `string` | optional | Loop variable holding the current item |
112112
| **indexVariable** | `string` | optional | Optional loop variable holding the current index |
113113
| **maxIterations** | `integer` | optional | Hard cap on iterations (clamped to the engine ceiling) |

content/docs/references/automation/io-node-config.mdx

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,21 +25,31 @@ and a Zod copied from the form would make that reconciliation a tautology —
2525

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

28-
## What these schemas are (and are not) wired to
28+
## What these schemas are wired to (#4277)
2929

3030
Like `LoopConfigSchema` / `ParallelConfigSchema` / `TryCatchConfigSchema`,
3131

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

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

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

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

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

42-
has measured a release's worth of real metadata (#4045 step 3b).
42+
post-interpolation guards still own "resolved to nothing". `http` parses
43+
44+
the INTERPOLATED config, because that is the shape its executor reads —
45+
46+
a `\{token\}` in a typed slot (`timeoutMs`, `durable`) resolves to its real
47+
48+
type first. Unknown keys are the registration layer's job: `registerFlow()`
49+
50+
rejects keys the descriptor `configSchema` does not declare (the tightened
51+
52+
#4059 check), while the parse here strips them.
4353

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

content/docs/references/automation/node-executor.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ Canonical cross-paradigm action/node descriptor (ADR-0018)
7070
| **icon** | `string` | optional | Icon id resolved by the designer |
7171
| **category** | `Enum<'logic' \| 'data' \| 'io' \| 'human' \| 'control' \| 'custom'>` || Palette category |
7272
| **paradigms** | `Enum<'flow' \| 'approval'>[]` || Authoring surfaces that may offer this action |
73-
| **configSchema** | `any` | optional | JSON Schema for the node config (drives the designer form; only declared expression slots are validated) |
73+
| **configSchema** | `any` | optional | JSON Schema for the node config (drives the designer form; undeclared keys are rejected at registration) |
7474
| **supportsPause** | `boolean` || Supports async pause/resume |
7575
| **supportsCancellation** | `boolean` || Supports cancellation |
7676
| **supportsRetry** | `boolean` || Supports retry on failure |

examples/app-showcase/src/automation/flows/index.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1384,7 +1384,10 @@ export const InquiryPurgeFlow = defineFlow({
13841384
id: 'report',
13851385
type: 'notify',
13861386
label: 'Report cleanup',
1387-
config: { topic: 'inquiry_purge', message: 'Closed inquiries purged.' },
1387+
// `recipients` + `title` are the notify contract's required pair — this
1388+
// node used to omit both, so every purge run FAILED here at execute time
1389+
// (found when the executors started parsing their config, #4277).
1390+
config: { topic: 'inquiry_purge', recipients: ['admin@objectos.ai'], title: 'Closed inquiries purged', message: 'Closed inquiries purged.' },
13881391
},
13891392
{ id: 'end', type: 'end', label: 'End' },
13901393
],
@@ -1538,6 +1541,10 @@ export const TaskDueReminderFlow = defineFlow({
15381541
label: 'Remind Owner',
15391542
config: {
15401543
topic: 'task.due_soon',
1544+
// The contract's required audience — this node used to omit it, so
1545+
// every sweep run FAILED here ("at least one recipient is required")
1546+
// and the #1874 demo never delivered (surfaced by #4277's parse).
1547+
recipients: ['{record.assignee}'],
15411548
channels: ['inbox'],
15421549
severity: 'warning',
15431550
title: 'Task due soon: {record.title}',

examples/app-todo/src/flows/task.flow.ts

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@ export const TaskReminderFlow: Flow = {
2020
{ id: 'start', type: 'start', label: 'Start (Daily 8 AM)', config: { schedule: '0 8 * * *', objectName: 'todo_task' } },
2121
{
2222
id: 'get_upcoming_tasks', type: 'get_record', label: 'Get Tasks Due Tomorrow',
23-
config: { objectName: 'todo_task', filter: { due_date: '{tomorrow}', is_completed: false }, outputVariable: 'tasksToRemind', getAll: true },
23+
// `limit > 1` is the declared way to make this a LIST read (`find`, not
24+
// `findOne`) — the undeclared `getAll` that sat here was never read, so
25+
// this sweep silently fetched a single task (#4277 rejects the key now).
26+
config: { objectName: 'todo_task', filter: { due_date: '{tomorrow}', is_completed: false }, outputVariable: 'tasksToRemind', limit: 200 },
2427
},
2528
{
2629
id: 'loop_tasks', type: 'loop', label: 'Loop Through Tasks',
@@ -67,10 +70,11 @@ export const OverdueEscalationFlow: Flow = {
6770
{ id: 'start', type: 'start', label: 'Start (Daily 9 AM)', config: { schedule: '0 9 * * *', objectName: 'todo_task' } },
6871
{
6972
id: 'get_overdue_tasks', type: 'get_record', label: 'Get Severely Overdue Tasks',
73+
// `limit > 1` = LIST read; the undeclared `getAll` was never read (#4277).
7074
config: {
7175
objectName: 'todo_task',
7276
filter: { due_date: { $lt: '{3_days_ago}' }, is_completed: false, is_overdue: true },
73-
outputVariable: 'overdueTasks', getAll: true,
77+
outputVariable: 'overdueTasks', limit: 200,
7478
},
7579
},
7680
{
@@ -178,11 +182,31 @@ export const QuickAddTaskFlow: Flow = {
178182
{
179183
id: 'screen_1', type: 'screen', label: 'Task Details',
180184
config: {
185+
// Select options are `{ value, label }` pairs (the screen contract's
186+
// shape — bare strings fail the executor's config parse, #4277).
181187
fields: [
182188
{ name: 'subject', label: 'Task Subject', type: 'text', required: true },
183-
{ name: 'priority', label: 'Priority', type: 'select', options: ['low', 'normal', 'high', 'urgent'], defaultValue: 'normal' },
189+
{
190+
name: 'priority', label: 'Priority', type: 'select', defaultValue: 'normal',
191+
options: [
192+
{ value: 'low', label: 'Low' },
193+
{ value: 'normal', label: 'Normal' },
194+
{ value: 'high', label: 'High' },
195+
{ value: 'urgent', label: 'Urgent' },
196+
],
197+
},
184198
{ name: 'dueDate', label: 'Due Date', type: 'date', required: false },
185-
{ name: 'category', label: 'Category', type: 'select', options: ['personal', 'work', 'shopping', 'health', 'finance', 'other'] },
199+
{
200+
name: 'category', label: 'Category', type: 'select',
201+
options: [
202+
{ value: 'personal', label: 'Personal' },
203+
{ value: 'work', label: 'Work' },
204+
{ value: 'shopping', label: 'Shopping' },
205+
{ value: 'health', label: 'Health' },
206+
{ value: 'finance', label: 'Finance' },
207+
{ value: 'other', label: 'Other' },
208+
],
209+
},
186210
],
187211
},
188212
},
@@ -196,13 +220,14 @@ export const QuickAddTaskFlow: Flow = {
196220
},
197221
{
198222
id: 'success_screen', type: 'screen', label: 'Success',
223+
// The screen contract has no `message`/`buttons` keys — nothing ever
224+
// read them, so this "success screen" was an invisible pass-through
225+
// (#4277 rejects the keys now). `description` + `waitForInput: true`
226+
// is the declared way to pause on a message-only confirmation screen.
199227
config: {
200-
message: 'Task "{subject}" created successfully!',
201-
buttons: [
202-
{ label: 'Create Another', action: 'restart' },
203-
{ label: 'View Task', action: 'navigate', target: '/task/{newTaskId.id}' },
204-
{ label: 'Done', action: 'finish' },
205-
],
228+
title: 'Task Created',
229+
description: 'Task "{subject}" created successfully!',
230+
waitForInput: true,
206231
},
207232
},
208233
{ id: 'end', type: 'end', label: 'End' },

0 commit comments

Comments
 (0)