From abb096de4bc997dd54a8c92cacbe28459ea54ef7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 06:20:48 +0000 Subject: [PATCH 1/2] feat(automation,spec): flow executors parse() their config; undeclared config keys reject at registration (#4277) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01CW8ZP3zUuC7ovSxqnN5o77 --- .changeset/flow-executors-parse-config.md | 55 +++++ .../automation/builtin-node-config.mdx | 16 +- .../references/automation/control-flow.mdx | 2 +- .../references/automation/io-node-config.mdx | 24 +- .../references/automation/node-executor.mdx | 2 +- .../src/automation/flows/index.ts | 9 +- examples/app-todo/src/flows/task.flow.ts | 45 +++- .../src/builtin/config-parse.test.ts | 207 ++++++++++++++++++ .../src/builtin/config-unknown-keys.test.ts | 148 ++++++++----- .../src/builtin/crud-nodes.ts | 54 +++-- .../src/builtin/http-nodes.ts | 26 ++- .../src/builtin/loop-node.ts | 34 +-- .../src/builtin/map-node.ts | 31 +-- .../src/builtin/notify-node.ts | 18 +- .../src/builtin/parallel-node.ts | 25 +-- .../src/builtin/parse-config.ts | 102 +++++++++ .../src/builtin/screen-nodes.ts | 25 ++- .../src/builtin/try-catch-node.ts | 45 ++-- .../services/service-automation/src/engine.ts | 149 +++++++++---- .../src/guard-refusal-inventory.test.ts | 15 +- .../src/automation/builtin-node-config.zod.ts | 13 +- .../spec/src/automation/control-flow.test.ts | 12 +- .../spec/src/automation/control-flow.zod.ts | 21 +- .../spec/src/automation/io-node-config.zod.ts | 19 +- .../spec/src/automation/node-executor.zod.ts | 35 +-- 25 files changed, 861 insertions(+), 271 deletions(-) create mode 100644 .changeset/flow-executors-parse-config.md create mode 100644 packages/services/service-automation/src/builtin/config-parse.test.ts create mode 100644 packages/services/service-automation/src/builtin/parse-config.ts diff --git a/.changeset/flow-executors-parse-config.md b/.changeset/flow-executors-parse-config.md new file mode 100644 index 0000000000..2d53f79ee9 --- /dev/null +++ b/.changeset/flow-executors-parse-config.md @@ -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. diff --git a/content/docs/references/automation/builtin-node-config.mdx b/content/docs/references/automation/builtin-node-config.mdx index 3a599486c9..b332023987 100644 --- a/content/docs/references/automation/builtin-node-config.mdx +++ b/content/docs/references/automation/builtin-node-config.mdx @@ -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: diff --git a/content/docs/references/automation/control-flow.mdx b/content/docs/references/automation/control-flow.mdx index f28a6e9968..4b3599b19b 100644 --- a/content/docs/references/automation/control-flow.mdx +++ b/content/docs/references/automation/control-flow.mdx @@ -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) | diff --git a/content/docs/references/automation/io-node-config.mdx b/content/docs/references/automation/io-node-config.mdx index e595a6b915..2baee1d136 100644 --- a/content/docs/references/automation/io-node-config.mdx +++ b/content/docs/references/automation/io-node-config.mdx @@ -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 -provided — is deliberately deferred until the #4059 undeclared-key warning +config — its 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 diff --git a/content/docs/references/automation/node-executor.mdx b/content/docs/references/automation/node-executor.mdx index e87cc1b918..d4b5cb40d7 100644 --- a/content/docs/references/automation/node-executor.mdx +++ b/content/docs/references/automation/node-executor.mdx @@ -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 | diff --git a/examples/app-showcase/src/automation/flows/index.ts b/examples/app-showcase/src/automation/flows/index.ts index 0a277bd011..5b5d6982eb 100644 --- a/examples/app-showcase/src/automation/flows/index.ts +++ b/examples/app-showcase/src/automation/flows/index.ts @@ -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' }, ], @@ -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}', diff --git a/examples/app-todo/src/flows/task.flow.ts b/examples/app-todo/src/flows/task.flow.ts index be0abc10e4..9f8cb0e69d 100644 --- a/examples/app-todo/src/flows/task.flow.ts +++ b/examples/app-todo/src/flows/task.flow.ts @@ -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', @@ -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, }, }, { @@ -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' }, + ], + }, ], }, }, @@ -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' }, diff --git a/packages/services/service-automation/src/builtin/config-parse.test.ts b/packages/services/service-automation/src/builtin/config-parse.test.ts new file mode 100644 index 0000000000..0b7bc74316 --- /dev/null +++ b/packages/services/service-automation/src/builtin/config-parse.test.ts @@ -0,0 +1,207 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Execute-time config `parse()` for the contract-carrying builtins (#4277 — + * the enforcement half #4045 deliberately deferred). + * + * Until #4277 the Zod contracts in `@objectstack/spec/automation` were pure + * exports: nothing parsed a node config with them, so a wrong-typed or + * missing-required key sailed through to whatever the executor's loose reads + * made of it (`limit: "10"` silently ignored, `mode: "view"` silently treated + * as create). These tests pin the tightened behavior: + * + * - a config that violates its contract REFUSES the node as a **guard** — + * the failure is not routable via `fault` edges (a config is metadata; + * re-running changes nothing), and the message names every violated path; + * - `{token}` templates stay legal everywhere they were: string slots parse + * raw templates, and `http` — which reads its config interpolated — parses + * the POST-interpolation shape, where a whole-token template has already + * resolved to its value's real type; + * - the deliberate exemption: a legacy flat-graph `loop` (no `config.body`) + * predates the ADR-0031 construct and is not parsed. + */ + +import { describe, it, expect } from 'vitest'; +import { AutomationEngine } from '../engine.js'; +import { installBuiltinNodes } from './index.js'; + +function silentLogger() { + const logger: any = { + info() {}, warn() {}, error() {}, debug() {}, + child() { return logger; }, + }; + return logger; +} + +interface HttpSurface { + isHttpDeliveryReady?(): boolean; + enqueueHttp?(input: any): Promise; +} + +function engineWith(messaging?: HttpSurface) { + const engine = new AutomationEngine(silentLogger()); + const ctx: any = { + logger: silentLogger(), + getService(name: string) { + if (name === 'messaging' && messaging) return messaging; + throw new Error('none'); + }, + }; + installBuiltinNodes(engine, ctx); + return engine; +} + +/** start → n1(type, config) → end, with optional extra nodes/edges. */ +function flowWith( + type: string, + config: Record, + extra?: { nodes?: any[]; edges?: any[]; variables?: any[] }, +) { + return { + name: 'f', label: 'F', type: 'autolaunched', status: 'active', version: 1, + variables: extra?.variables ?? [], + nodes: [ + { id: 'start', type: 'start', label: 'S', config: {} }, + { id: 'n1', type, label: 'N', config }, + { id: 'end', type: 'end', label: 'E' }, + ...(extra?.nodes ?? []), + ], + edges: [ + { id: 'e1', source: 'start', target: 'n1', type: 'default' }, + { id: 'e2', source: 'n1', target: 'end', type: 'default' }, + ...(extra?.edges ?? []), + ], + }; +} + +describe('execute-time config parse (#4277)', () => { + it('refuses a wrong-typed declared key, naming the exact path', async () => { + const engine = engineWith(); + // `limit` is declared (so registration accepts it) but must be a number — + // the executor never honored a string here, so this was dead config that + // now fails loudly instead of silently doing nothing. + engine.registerFlow('f', flowWith('get_record', { objectName: 'crm_lead', limit: 'ten' })); + + const result = await engine.execute('f'); + expect(result.success).toBe(false); + expect(result.error).toContain('get_record'); + expect(result.error).toContain('config.limit'); + expect(result.error).toContain('does not satisfy the get_record contract'); + }); + + it('a parse refusal is a guard — a fault edge does NOT route it', async () => { + const engine = engineWith(); + engine.registerFlow('f', flowWith( + 'get_record', + { objectName: 'crm_lead', limit: 'ten' }, + { + nodes: [{ id: 'recover', type: 'assignment', label: 'R', config: { recovered: true } }], + edges: [{ id: 'e3', source: 'n1', target: 'recover', type: 'fault' }], + }, + )); + + const result = await engine.execute('f'); + // Routable would mean success-via-recovery; a guard stays fatal (#3863). + expect(result.success).toBe(false); + expect(result.error).toContain('does not satisfy the get_record contract'); + }); + + it('refuses a missing required key (notify without title)', async () => { + const engine = engineWith(); + engine.registerFlow('f', flowWith('notify', { recipients: 'u1' })); + + const result = await engine.execute('f'); + expect(result.success).toBe(false); + expect(result.error).toContain('notify'); + expect(result.error).toContain('config.title'); + }); + + it('string slots parse RAW templates — a `{token}` recipients/title passes', async () => { + const engine = engineWith(); + engine.registerFlow('f', flowWith('notify', { + recipients: '{who}', title: 'Hi {who}', + }, { variables: [{ name: 'who', type: 'text', isInput: true }] })); + + // No messaging service wired → the node degrades to a skipped success; + // what matters here is that the parse accepted the template strings. + const result = await engine.execute('f', { params: { who: 'u1' } } as any); + expect(result.success).toBe(true); + }); + + it('http parses the INTERPOLATED config — a whole-token template in a typed slot resolves first', async () => { + const enqueued: any[] = []; + const engine = engineWith({ + isHttpDeliveryReady: () => true, + async enqueueHttp(input) { enqueued.push(input); return 'dlv_1'; }, + }); + engine.registerFlow('f', flowWith('http', { + url: 'https://example.test/hook', durable: true, timeoutMs: '{t}', + }, { variables: [{ name: 't', type: 'number', isInput: true }] })); + + const result = await engine.execute('f', { params: { t: 1500 } } as any); + expect(result.success).toBe(true); + // Whole-token interpolation preserved the number, so the contract's + // `timeoutMs: z.number()` saw 1500, not a string. + expect(enqueued[0]?.timeoutMs).toBe(1500); + }); + + it('http still refuses a statically wrong-typed slot', async () => { + const engine = engineWith(); + engine.registerFlow('f', flowWith('http', { url: 'https://example.test', timeoutMs: 'soon' })); + + const result = await engine.execute('f'); + expect(result.success).toBe(false); + expect(result.error).toContain('config.timeoutMs'); + }); + + it('screen refuses an out-of-enum mode instead of silently treating it as create', async () => { + const engine = engineWith(); + engine.registerFlow('f', flowWith('screen', { objectName: 'crm_lead', mode: 'view' })); + + const result = await engine.execute('f'); + expect(result.success).toBe(false); + expect(result.error).toContain('config.mode'); + }); + + it('legacy flat-graph loop (no body) is exempt — an empty config still falls through', async () => { + const engine = engineWith(); + engine.registerFlow('f', flowWith('loop', {})); + + const result = await engine.execute('f'); + expect(result.success).toBe(true); + }); + + it('a structured loop (body present) IS parsed — missing collection refuses', async () => { + const engine = engineWith(); + engine.registerFlow('f', flowWith('loop', { + body: { nodes: [{ id: 'b1', type: 'assignment', label: 'B', config: { x: 1 } }], edges: [] }, + })); + + const result = await engine.execute('f'); + expect(result.success).toBe(false); + expect(result.error).toContain('loop'); + expect(result.error).toContain('config.collection'); + }); + + it('parallel refuses a non-array branches via the contract', async () => { + const engine = engineWith(); + // A string `branches` slips past registration (validateControlFlow only + // inspects arrays) — the execute-time parse is what catches it now. + engine.registerFlow('f', flowWith('parallel', { branches: 'oops' })); + + const result = await engine.execute('f'); + expect(result.success).toBe(false); + expect(result.error).toContain('parallel'); + expect(result.error).toContain('config.branches'); + }); + + it('map refuses a missing collection, naming the path', async () => { + const engine = engineWith(); + engine.registerFlow('f', flowWith('map', { flowName: 'child' })); + + const result = await engine.execute('f'); + expect(result.success).toBe(false); + expect(result.error).toContain('map'); + expect(result.error).toContain('config.collection'); + }); +}); diff --git a/packages/services/service-automation/src/builtin/config-unknown-keys.test.ts b/packages/services/service-automation/src/builtin/config-unknown-keys.test.ts index 9b1591d5d7..97f63f8ff3 100644 --- a/packages/services/service-automation/src/builtin/config-unknown-keys.test.ts +++ b/packages/services/service-automation/src/builtin/config-unknown-keys.test.ts @@ -1,17 +1,22 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * Unknown flow-node `config` keys are reported at registration (#4045). + * Unknown flow-node `config` keys are REJECTED at registration (#4277 — the + * error half of the #4045 unknown-key ladder; #4059 shipped the warn half). * - * `FlowNodeSchema.config` is `z.record(z.unknown())`, so before this a misspelled - * or invented config key was accepted in **total silence**: `visibleIf` instead of - * `visibleWhen` registered cleanly, was never read, and the only symptom was a - * feature that quietly did not happen. That is the diagnostic vacuum that made - * #3528 take three passes and two wrong diagnoses. + * `FlowNodeSchema.config` is `z.record(z.unknown())`, so before #4059 a + * misspelled or invented config key was accepted in **total silence**: + * `visibleIf` instead of `visibleWhen` registered cleanly, was never read, and + * the only symptom was a feature that quietly did not happen. That is the + * diagnostic vacuum that made #3528 take three passes and two wrong diagnoses. * - * Warn, never reject — see `validateNodeConfigKeys` for why. These tests pin both - * halves of that: the warning fires with an actionable suggestion, AND - * registration still succeeds. + * #4059 turned that into a warning while the #4045 reconciliation closed the + * read-but-undeclared population; #4277 tightens it into a rejection. These + * tests pin both halves of the tightened behavior: registration THROWS with an + * actionable prescription (path, declared set, did-you-mean, per-key + * tombstones), AND the deliberate exemptions still register — `assignment` + * (author-named keys), keyValue-map keys (author data), schemaless types + * (nothing declared ⇒ nothing undeclared). */ import { describe, it, expect } from 'vitest'; @@ -50,77 +55,90 @@ function flowWith(type: string, config: Record) { }; } -describe('unknown node config keys (#4045)', () => { - it('flags a misspelled key with a did-you-mean suggestion', () => { - const { logger, warnings } = recordingLogger(); +/** Register and return the rejection message (fails the test if it registers). */ +function rejectionOf(engine: AutomationEngine, type: string, config: Record): string { + try { + engine.registerFlow('f', flowWith(type, config)); + } catch (err) { + return (err as Error).message; + } + throw new Error(`flow with ${type} config ${JSON.stringify(config)} should have been rejected`); +} + +describe('unknown node config keys are rejected (#4277)', () => { + it('rejects a misspelled key inside the field repeater, locating the exact element', async () => { + const { logger } = recordingLogger(); const engine = engineWith(logger); // The typo lives INSIDE the field repeater — where the real #3528 one did. - engine.registerFlow('f', flowWith('screen', { + const msg = rejectionOf(engine, 'screen', { fields: [{ name: 'opportunityName', required: true, visibleIf: 'createOpportunity == true' }], - })); + }); - const hit = warnings.find((w) => w.includes('visibleIf')); - expect(hit, 'the typo should be reported').toBeDefined(); - expect(hit).toContain("node 'n1'"); - expect(hit).toContain('(screen)'); + expect(msg).toContain("node 'n1'"); + expect(msg).toContain('(screen)'); // Located to the exact element, so the author knows WHICH field. - expect(hit).toContain('config.fields[0].visibleIf'); + expect(msg).toContain('config.fields[0].visibleIf'); // The load-bearing half for an agent author: the correct key is named. Note // it comes from the DECLARED SET, not the suggestion — `visibleIf` → - // `visibleWhen` is edit-distance 4 against `nearestName`'s threshold of 3, so - // this exact typo gets no did-you-mean. Printing the declared set is what - // makes the diagnostic actionable regardless. - expect(hit).toContain( + // `visibleWhen` is edit-distance 4 against `nearestName`'s threshold of 3, + // so this exact typo gets no did-you-mean. Printing the declared set is + // what makes the diagnostic actionable regardless. + expect(msg).toContain( 'Declared here: name, label, type, required, options, defaultValue, placeholder, visibleWhen.', ); + // …and this particular key has a documented incident, so it also carries + // its tombstone (the UNKNOWN_KEY_GUIDANCE pattern). + expect(msg).toContain('visibleWhen'); + expect(msg).toContain('#3528'); + // The flow is NOT registered. + await expect(engine.getFlow('f')).resolves.toBeNull(); }); it('adds a did-you-mean when the typo IS within edit distance', () => { - const { logger, warnings } = recordingLogger(); + const { logger } = recordingLogger(); const engine = engineWith(logger); - engine.registerFlow('f', flowWith('screen', { titl: 'Details' })); - - const hit = warnings.find((w) => w.includes('titl')); - expect(hit).toContain('did you mean `title`?'); + const msg = rejectionOf(engine, 'screen', { titl: 'Details' }); + expect(msg).toContain('did you mean `title`?'); // …and still lists the declared set alongside it. - expect(hit).toContain('Declared here:'); + expect(msg).toContain('Declared here:'); }); - it('still registers the flow — warn, never reject', () => { + it('carries the fieldValues → fields tombstone on the CRUD write map', () => { const { logger } = recordingLogger(); const engine = engineWith(logger); - expect(() => engine.registerFlow('f', flowWith('screen', { visibleIf: 'a == true' }))).not.toThrow(); - // And it is really registered, not swallowed. - expect(engine.getFlow('f')).toBeDefined(); + + const msg = rejectionOf(engine, 'create_record', { + objectName: 'crm_lead', + fieldValues: { name: 'x' }, + }); + expect(msg).toContain('unknown config key `fieldValues`'); + // The tombstone names the mechanism, not just the nearest key. + expect(msg).toContain('The write map is `fields`'); }); - it('lists the declared keys when nothing is close enough to suggest', () => { - const { logger, warnings } = recordingLogger(); + it('reports every undeclared key in ONE rejection, not just the first', () => { + const { logger } = recordingLogger(); const engine = engineWith(logger); - engine.registerFlow('f', flowWith('screen', { totallyMadeUp: 42 })); - - const hit = warnings.find((w) => w.includes('totallyMadeUp')); - expect(hit).toBeDefined(); - expect(hit).not.toContain('did you mean'); - // The declared set is always present, so there is always somewhere to go. - expect(hit).toContain('Declared here:'); - expect(hit).toContain('fields'); + const msg = rejectionOf(engine, 'screen', { hideWhen: 'x', submitLabel: 'Go' }); + expect(msg).toContain('hideWhen'); + expect(msg).toContain('submitLabel'); + expect(msg).toContain('2 undeclared config key(s)'); }); - it('reports every undeclared key, not just the first', () => { - const { logger, warnings } = recordingLogger(); + it('the rejection carries the declare-it prescription for genuinely-read keys', () => { + const { logger } = recordingLogger(); const engine = engineWith(logger); - engine.registerFlow('f', flowWith('screen', { hideWhen: 'x', submitLabel: 'Go' })); - - expect(warnings.some((w) => w.includes('hideWhen'))).toBe(true); - expect(warnings.some((w) => w.includes('submitLabel'))).toBe(true); + const msg = rejectionOf(engine, 'screen', { totallyMadeUp: 42 }); + expect(msg).not.toContain('did you mean'); + // The way OUT for a custom executor whose schema lags its reads. + expect(msg).toContain("declare it on the node type's descriptor configSchema"); }); - it('stays quiet on a fully declared config', () => { + it('registers a fully declared config without complaint', async () => { const { logger, warnings } = recordingLogger(); const engine = engineWith(logger); @@ -131,31 +149,43 @@ describe('unknown node config keys (#4045)', () => { defaults: { x: 1 }, })); + await expect(engine.getFlow('f')).resolves.toBeDefined(); expect(warnings.filter((w) => w.includes('unknown config key'))).toEqual([]); }); + it('assignment is exempt WHOLESALE — its top-level keys are author variable names', async () => { + // Pinned as un-reconcilable by the form↔Zod ledger: with no `assignments` + // wrapper, the top-level config keys ARE the variables being assigned, so + // no fixed key set can describe them and the tightened check must not try. + const { logger } = recordingLogger(); + const engine = engineWith(logger); + + expect(() => engine.registerFlow('f', flowWith('assignment', { + approvalStatus: 'pending', + anyVariableNameAtAll: '{record.owner}', + }))).not.toThrow(); + await expect(engine.getFlow('f')).resolves.toBeDefined(); + }); + it('stays quiet for a node type that publishes no configSchema', () => { // `decision` / `script` are deliberately schemaless (config-schemas.test.ts): // nothing is declared, so nothing can be undeclared. - const { logger, warnings } = recordingLogger(); + const { logger } = recordingLogger(); const engine = engineWith(logger); - engine.registerFlow('f', flowWith('decision', { condition: 'a == b', whateverElse: 1 })); - - expect(warnings.filter((w) => w.includes('unknown config key'))).toEqual([]); + expect(() => engine.registerFlow('f', flowWith('decision', { condition: 'a == b', whateverElse: 1 }))) + .not.toThrow(); }); it('does not flag the keys of a keyValue map — those are author data', () => { // The walk descends where the schema declares structure and STOPS at a // free-form map: `filter: { status: 'stale' }` keys are data, not config keys. - const { logger, warnings } = recordingLogger(); + const { logger } = recordingLogger(); const engine = engineWith(logger); - engine.registerFlow('f', flowWith('get_record', { + expect(() => engine.registerFlow('f', flowWith('get_record', { objectName: 'crm_lead', filter: { status: 'stale', anythingAtAll: true }, - })); - - expect(warnings.filter((w) => w.includes('unknown config key'))).toEqual([]); + }))).not.toThrow(); }); }); diff --git a/packages/services/service-automation/src/builtin/crud-nodes.ts b/packages/services/service-automation/src/builtin/crud-nodes.ts index dda00b0184..b3cd596102 100644 --- a/packages/services/service-automation/src/builtin/crud-nodes.ts +++ b/packages/services/service-automation/src/builtin/crud-nodes.ts @@ -1,12 +1,25 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import type { PluginContext } from '@objectstack/core'; -import { defineActionDescriptor } from '@objectstack/spec/automation'; +import { + defineActionDescriptor, + GetRecordConfigSchema, + CreateRecordConfigSchema, + UpdateRecordConfigSchema, + DeleteRecordConfigSchema, +} from '@objectstack/spec/automation'; +import type { + GetRecordConfigParsed, + CreateRecordConfigParsed, + UpdateRecordConfigParsed, + DeleteRecordConfigParsed, +} from '@objectstack/spec/automation'; import type { IDataEngine } from '@objectstack/spec/contracts'; import type { DroppedFieldsEvent } from '@objectstack/spec/data'; import type { AutomationEngine } from '../engine.js'; import { interpolate, interpolateFilter, type VariableMap } from './template.js'; import { refuseNode } from '../guard-refusal.js'; +import { parseNodeConfig } from './parse-config.js'; import { resolveRunDataContext } from '../runtime-identity.js'; /** @@ -166,12 +179,17 @@ export function registerCrudNodes(engine: AutomationEngine, ctx: PluginContext): }, }), async execute(node, variables, context) { - const cfg = (node.config ?? {}) as Record; // `filters` → `filter` and `object` → `objectName` are handled at // load by the ADR-0087 D2 conversion layer ('flow-node-crud-filter-alias', - // 'flow-node-crud-object-alias'), so the executor reads the canonical - // keys directly (PD #12 fallbacks retired). - const objectName = String(cfg.objectName ?? ''); + // 'flow-node-crud-object-alias'), so the parse sees canonical keys + // (PD #12 fallbacks retired). Parsed BEFORE interpolation: every + // typed slot the contract declares beyond strings is read raw by + // this executor too (`limit` never honored a template), so a + // template in one was already dead config — now it is loud. + const parsed = parseNodeConfig('get_record', node.id, GetRecordConfigSchema, node.config); + if (!parsed.ok) return parsed.refusal; + const cfg = parsed.config; + const objectName = cfg.objectName; if (!objectName) return refuseNode('get_record: objectName required'); const filterResult = resolveNodeFilter( @@ -180,9 +198,9 @@ export function registerCrudNodes(engine: AutomationEngine, ctx: PluginContext): ); if ('error' in filterResult) return refuseNode(filterResult.error); const filter = filterResult.filter; - const fields = cfg.fields as string[] | undefined; - const limit = typeof cfg.limit === 'number' ? cfg.limit : undefined; - const outputVariable = cfg.outputVariable as string | undefined; + const fields = cfg.fields; + const limit = cfg.limit; + const outputVariable = cfg.outputVariable; const data = getData(); if (!data) { @@ -228,12 +246,14 @@ export function registerCrudNodes(engine: AutomationEngine, ctx: PluginContext): }, }), async execute(node, variables, context) { - const cfg = (node.config ?? {}) as Record; - const objectName = String(cfg.objectName ?? ''); + const parsed = parseNodeConfig('create_record', node.id, CreateRecordConfigSchema, node.config); + if (!parsed.ok) return parsed.refusal; + const cfg = parsed.config; + const objectName = cfg.objectName; if (!objectName) return refuseNode('create_record: objectName required'); const fields = interpolate(cfg.fields ?? {}, variables, context) as Record; - const outputVariable = cfg.outputVariable as string | undefined; + const outputVariable = cfg.outputVariable; const data = getData(); if (!data) { @@ -309,8 +329,10 @@ export function registerCrudNodes(engine: AutomationEngine, ctx: PluginContext): }, }), async execute(node, variables, context) { - const cfg = (node.config ?? {}) as Record; - const objectName = String(cfg.objectName ?? ''); + const parsed = parseNodeConfig('update_record', node.id, UpdateRecordConfigSchema, node.config); + if (!parsed.ok) return parsed.refusal; + const cfg = parsed.config; + const objectName = cfg.objectName; if (!objectName) return refuseNode('update_record: objectName required'); // `filters` → `filter` converted at load (ADR-0087 D2); read canonical. @@ -382,8 +404,10 @@ export function registerCrudNodes(engine: AutomationEngine, ctx: PluginContext): }, }), async execute(node, variables, context) { - const cfg = (node.config ?? {}) as Record; - const objectName = String(cfg.objectName ?? ''); + const parsed = parseNodeConfig('delete_record', node.id, DeleteRecordConfigSchema, node.config); + if (!parsed.ok) return parsed.refusal; + const cfg = parsed.config; + const objectName = cfg.objectName; if (!objectName) return refuseNode('delete_record: objectName required'); // `filters` → `filter` converted at load (ADR-0087 D2); read canonical. diff --git a/packages/services/service-automation/src/builtin/http-nodes.ts b/packages/services/service-automation/src/builtin/http-nodes.ts index b6aa7f1f40..b647ab2a4d 100644 --- a/packages/services/service-automation/src/builtin/http-nodes.ts +++ b/packages/services/service-automation/src/builtin/http-nodes.ts @@ -1,11 +1,13 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import type { PluginContext } from '@objectstack/core'; -import { defineActionDescriptor } from '@objectstack/spec/automation'; +import { defineActionDescriptor, HttpConfigSchema } from '@objectstack/spec/automation'; +import type { HttpConfigParsed } from '@objectstack/spec/automation'; import { randomUUID } from 'node:crypto'; import type { AutomationEngine } from '../engine.js'; import { refuseNode } from '../guard-refusal.js'; import { interpolate } from './template.js'; +import { parseNodeConfig } from './parse-config.js'; /** * HTTP built-in node — canonical `http` (ADR-0018 M3). @@ -93,16 +95,24 @@ export function registerHttpNodes(engine: AutomationEngine, ctx: PluginContext): }), async execute(node, variables, context) { const raw = (node.config ?? {}) as Record; - const cfg = interpolate(raw, variables, context) as Record; + // Parsed AFTER interpolation — unique among the contract-carrying + // builtins, because this executor reads the interpolated config + // wholesale, so that is the shape its contract describes: a `{token}` + // in a typed slot (`timeoutMs`, `durable`) resolves to the value's + // real type before the contract sees it (whole-token interpolation + // preserves types). + const parsed = parseNodeConfig('http', node.id, HttpConfigSchema, interpolate(raw, variables, context)); + if (!parsed.ok) return parsed.refusal; + const cfg = parsed.config; - const url = cfg.url as string | undefined; + const url = cfg.url; if (!url) return refuseNode('http: url is required'); const durable = cfg.durable === true; - const headers = cfg.headers as Record | undefined; + const headers = cfg.headers; const body = cfg.body; - const timeoutMs = typeof cfg.timeoutMs === 'number' ? cfg.timeoutMs : undefined; - const signingSecret = cfg.signingSecret as string | undefined; + const timeoutMs = cfg.timeoutMs; + const signingSecret = cfg.signingSecret; // ── Durable mode: enqueue onto the messaging HTTP outbox ────────── if (durable) { @@ -115,7 +125,7 @@ export function registerHttpNodes(engine: AutomationEngine, ctx: PluginContext): dedupKey: randomUUID(), label: `flow:${node.id}`, url, - method: (cfg.method as string) ?? 'POST', + method: cfg.method ?? 'POST', headers, signingSecret, timeoutMs, @@ -133,7 +143,7 @@ export function registerHttpNodes(engine: AutomationEngine, ctx: PluginContext): } // ── Request/response mode (default; preserves http_request) ─────── - const method = (cfg.method as string) ?? 'GET'; + const method = cfg.method ?? 'GET'; const controller = new AbortController(); const timer = timeoutMs ? setTimeout(() => controller.abort(), timeoutMs) : undefined; try { diff --git a/packages/services/service-automation/src/builtin/loop-node.ts b/packages/services/service-automation/src/builtin/loop-node.ts index ee366da473..da54f18e1e 100644 --- a/packages/services/service-automation/src/builtin/loop-node.ts +++ b/packages/services/service-automation/src/builtin/loop-node.ts @@ -1,11 +1,12 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import type { PluginContext } from '@objectstack/core'; -import { defineActionDescriptor, LOOP_MAX_ITERATIONS_CEILING } from '@objectstack/spec/automation'; -import type { FlowRegionParsed } from '@objectstack/spec/automation'; +import { defineActionDescriptor, LOOP_MAX_ITERATIONS_CEILING, LoopConfigSchema } from '@objectstack/spec/automation'; +import type { LoopConfigParsed } from '@objectstack/spec/automation'; import type { AutomationContext } from '@objectstack/spec/contracts'; import type { AutomationEngine, StepLogEntry } from '../engine.js'; import { interpolate } from './template.js'; +import { parseNodeConfig } from './parse-config.js'; /** * `loop` built-in node — a **structured iteration container** (ADR-0031). @@ -59,12 +60,15 @@ export function registerLoopNode(engine: AutomationEngine, ctx: PluginContext): }, }), async execute(node, variables, context) { - const cfg = (node.config ?? {}) as Record; - const body = cfg.body as FlowRegionParsed | undefined; + const raw = (node.config ?? {}) as Record; // ── Legacy flat-graph loop (no body) — preserve prior stub behavior. ── - if (body == null) { - const collectionName = typeof cfg.collection === 'string' ? cfg.collection : undefined; + // Deliberately NOT parsed: this shape predates the ADR-0031 construct the + // contract describes (`collection` is required there, but a legacy marker + // loop legally carries none), and the constructs are additive — the + // contract governs only the structured form it defines (#4277). + if (raw.body == null) { + const collectionName = typeof raw.collection === 'string' ? raw.collection : undefined; if (collectionName) { const legacy = variables.get(collectionName); if (Array.isArray(legacy)) { @@ -76,20 +80,20 @@ export function registerLoopNode(engine: AutomationEngine, ctx: PluginContext): } // ── Structured loop container. ── - const iteratorVariable = typeof cfg.iteratorVariable === 'string' && cfg.iteratorVariable - ? cfg.iteratorVariable - : 'item'; - const indexVariable = typeof cfg.indexVariable === 'string' && cfg.indexVariable - ? cfg.indexVariable - : undefined; + const parsed = parseNodeConfig('loop', node.id, LoopConfigSchema, raw); + if (!parsed.ok) return parsed.refusal; + const cfg = parsed.config; + const body = cfg.body!; + const iteratorVariable = cfg.iteratorVariable; + const indexVariable = cfg.indexVariable || undefined; // Resolve the collection: a `{token}` template, a bare variable name, or - // (defensively) an already-resolved array. + // an inline array (the contract is the union, same as `map`). const rawCollection = cfg.collection; let collection: unknown; if (Array.isArray(rawCollection)) { collection = rawCollection; - } else if (typeof rawCollection === 'string') { + } else { collection = interpolate(rawCollection, variables, context ?? ({} as AutomationContext)); if ((collection == null) && variables.has(rawCollection)) { collection = variables.get(rawCollection); @@ -104,7 +108,7 @@ export function registerLoopNode(engine: AutomationEngine, ctx: PluginContext): } // Hard iteration guard. - const requested = typeof cfg.maxIterations === 'number' ? cfg.maxIterations : LOOP_MAX_ITERATIONS_CEILING; + const requested = cfg.maxIterations ?? LOOP_MAX_ITERATIONS_CEILING; const maxIterations = Math.min(requested, LOOP_MAX_ITERATIONS_CEILING); if (collection.length > maxIterations) { return { diff --git a/packages/services/service-automation/src/builtin/map-node.ts b/packages/services/service-automation/src/builtin/map-node.ts index 051e4baf4f..7c02bae101 100644 --- a/packages/services/service-automation/src/builtin/map-node.ts +++ b/packages/services/service-automation/src/builtin/map-node.ts @@ -1,11 +1,13 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import type { PluginContext } from '@objectstack/core'; -import { defineActionDescriptor } from '@objectstack/spec/automation'; +import { defineActionDescriptor, MapConfigSchema } from '@objectstack/spec/automation'; +import type { MapConfigParsed } from '@objectstack/spec/automation'; import type { AutomationContext } from '@objectstack/spec/contracts'; import type { AutomationEngine } from '../engine.js'; import { refuseNode } from '../guard-refusal.js'; import { interpolate } from './template.js'; +import { parseNodeConfig } from './parse-config.js'; /** Hard cap on map fan-out — turns a runaway collection into a clean error. */ const MAX_MAP_ITEMS = 10_000; @@ -75,23 +77,26 @@ export function registerMapNode(engine: AutomationEngine, ctx: PluginContext): v }, }), async execute(node, variables, context) { - const cfg = (node.config ?? {}) as Record; // The undeclared `flow` alias this used to accept via a bare `??` has // graduated into the ADR-0087 D2 conversion layer as // 'flow-node-map-flow-alias' (#4045), rewritten at load — including the - // `registerFlow` rehydration seam — so only the canonical key is read - // here (PD #12: no consumer-side fallbacks). - const flowName = typeof cfg.flowName === 'string' ? cfg.flowName : undefined; + // `registerFlow` rehydration seam — so the parse sees only the canonical + // key (PD #12: no consumer-side fallbacks). Parsed BEFORE interpolation; + // `collection` legally arrives as a template string OR an inline array + // (the contract is the union). + const parsed = parseNodeConfig('map', node.id, MapConfigSchema, node.config); + if (!parsed.ok) return parsed.refusal; + const cfg = parsed.config; + const flowName = cfg.flowName; if (!flowName) { return refuseNode(`map '${node.id}': config.flowName (the per-item subflow) is required`); } - const iteratorVariable = - typeof cfg.iteratorVariable === 'string' && cfg.iteratorVariable ? cfg.iteratorVariable : 'item'; - const indexVariable = - typeof cfg.indexVariable === 'string' && cfg.indexVariable ? cfg.indexVariable : undefined; - const outVar = - typeof cfg.outputVariable === 'string' && cfg.outputVariable ? cfg.outputVariable : undefined; + // `|| 'item'` beyond the contract default: an authored EMPTY string still + // falls back (the default only fires on absence). + const iteratorVariable = cfg.iteratorVariable || 'item'; + const indexVariable = cfg.indexVariable || undefined; + const outVar = cfg.outputVariable || undefined; // Resolve the collection (template / bare var / already-an-array). const rawCollection = cfg.collection; @@ -134,7 +139,7 @@ export function registerMapNode(engine: AutomationEngine, ctx: PluginContext): v variables.set(iteratorVariable, item); if (indexVariable) variables.set(indexVariable, idx); - const rawInput = (cfg.input && typeof cfg.input === 'object' ? cfg.input : {}) as Record; + const rawInput = cfg.input ?? {}; const params = interpolate(rawInput, variables, context ?? ({} as AutomationContext)) as Record; // When the mapped item IS a record (has an id), expose it as the @@ -142,7 +147,7 @@ export function registerMapNode(engine: AutomationEngine, ctx: PluginContext): v // targets that item — the natural "approve each row" shape. Otherwise // the item is just data, passed via `params`. const itemIsRecord = item != null && typeof item === 'object' && typeof (item as any).id === 'string'; - const itemObject = typeof cfg.itemObject === 'string' ? cfg.itemObject : (context as any)?.object; + const itemObject = cfg.itemObject ?? (context as any)?.object; const childContext = { ...(context ?? {}), diff --git a/packages/services/service-automation/src/builtin/notify-node.ts b/packages/services/service-automation/src/builtin/notify-node.ts index 5517b37ad9..27803912dc 100644 --- a/packages/services/service-automation/src/builtin/notify-node.ts +++ b/packages/services/service-automation/src/builtin/notify-node.ts @@ -2,9 +2,11 @@ import type { PluginContext } from '@objectstack/core'; import type { AutomationContext } from '@objectstack/spec/contracts'; -import { defineActionDescriptor } from '@objectstack/spec/automation'; +import { defineActionDescriptor, NotifyConfigSchema } from '@objectstack/spec/automation'; +import type { NotifyConfigParsed } from '@objectstack/spec/automation'; import type { AutomationEngine } from '../engine.js'; import { interpolate, stringifyForTemplate, type VariableMap } from './template.js'; +import { parseNodeConfig } from './parse-config.js'; /** * Structural view of `@objectstack/service-messaging`'s service (ADR-0012), @@ -92,7 +94,7 @@ function toStr(value: unknown): string | undefined { * (Prime Directive #12). Same graduation path as `object` → `objectName` (#3796). */ function resolveSource( - cfg: Record, + cfg: Pick, variables: VariableMap, context: AutomationContext, ): { object: string; id: string } | undefined { @@ -184,11 +186,17 @@ export function registerNotifyNode(engine: AutomationEngine, ctx: PluginContext) }, }), async execute(node, variables, context) { - const cfg = (node.config ?? {}) as Record; - // The historical aliases (`to`/`subject`/`body`/`url`) are canonicalized // at load by the ADR-0087 D2 conversion 'flow-node-notify-config-aliases' - // (#3796), so only the canonical keys are read here. + // (#3796), so the parse sees only canonical keys. Parsed BEFORE + // interpolation — the contract's slots are string-typed, so `{token}` + // templates pass; the post-interpolation guards below still own + // "title/recipients resolved to nothing" (#3582), which no static + // parse can see. + const parsed = parseNodeConfig('notify', node.id, NotifyConfigSchema, node.config); + if (!parsed.ok) return parsed.refusal; + const cfg = parsed.config; + const recipientCfg = cfg.recipients ?? []; const recipients = toStringList(interpolate(recipientCfg, variables, context)); // stringifyForTemplate (not String()): a sole-token `{$error}` resolves diff --git a/packages/services/service-automation/src/builtin/parallel-node.ts b/packages/services/service-automation/src/builtin/parallel-node.ts index 84afdee0e3..68c475075c 100644 --- a/packages/services/service-automation/src/builtin/parallel-node.ts +++ b/packages/services/service-automation/src/builtin/parallel-node.ts @@ -1,15 +1,11 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import type { PluginContext } from '@objectstack/core'; -import { defineActionDescriptor } from '@objectstack/spec/automation'; -import type { FlowRegionParsed } from '@objectstack/spec/automation'; +import { defineActionDescriptor, ParallelConfigSchema } from '@objectstack/spec/automation'; +import type { ParallelConfigParsed } from '@objectstack/spec/automation'; import type { AutomationContext } from '@objectstack/spec/contracts'; import type { AutomationEngine, StepLogEntry } from '../engine.js'; - -/** One branch of a parallel block — a region plus an optional label. */ -interface ParallelBranch extends FlowRegionParsed { - name?: string; -} +import { parseNodeConfig } from './parse-config.js'; /** * `parallel` built-in node — a **structured parallel block** with an @@ -65,15 +61,12 @@ export function registerParallelNode(engine: AutomationEngine, ctx: PluginContex }, }), async execute(node, variables, context) { - const cfg = (node.config ?? {}) as Record; - const branches = cfg.branches as ParallelBranch[] | undefined; - - if (!Array.isArray(branches) || branches.length < 2) { - return { - success: false, - error: `parallel '${node.id}': config.branches must declare at least 2 branch regions`, - }; - } + // The contract owns the shape guard: `branches` is required, each branch + // a region, and `.min(2)` refuses the degenerate single-branch block the + // hand-written check here used to catch. + const parsed = parseNodeConfig('parallel', node.id, ParallelConfigSchema, node.config); + if (!parsed.ok) return parsed.refusal; + const branches = parsed.config.branches; let branchSteps: StepLogEntry[][]; try { diff --git a/packages/services/service-automation/src/builtin/parse-config.ts b/packages/services/service-automation/src/builtin/parse-config.ts new file mode 100644 index 0000000000..31cffc18b5 --- /dev/null +++ b/packages/services/service-automation/src/builtin/parse-config.ts @@ -0,0 +1,102 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Execute-time `parse()` of a builtin node's config against its Zod contract + * (#4277 — the enforcement half #4045 deliberately deferred). + * + * The contracts in `@objectstack/spec/automation` (`io-node-config.zod.ts`, + * `builtin-node-config.zod.ts`, `control-flow.zod.ts`) were written by reading + * what each executor does with `node.config`, and the form↔Zod ledger tests + * reconcile them against the descriptor forms — but until #4277 nothing + * `parse()`d them, so `configSchema` provided none of the type / `required` + * runtime enforcement it appears to promise (`FlowNodeSchema.config` is + * `z.record(z.unknown())`). This helper is the single seam every + * contract-carrying builtin runs its config through. + * + * ## Where each executor calls this + * + * At the executor's **natural read point**, which is not the same for all: + * `http` interpolates its whole config first and reads the result, so it + * parses POST-interpolation (its contract describes the interpolated shape — + * a `{token}` in a typed slot resolves to its value's real type before the + * contract sees it); everyone else reads the raw stored config key-by-key and + * parses it as stored, where `{token}` templates are still strings and the + * string-typed contract slots accept them. `loop` additionally exempts its + * legacy flat-graph path (no `config.body`) — that shape predates the + * ADR-0031 construct the contract describes. + * + * ## Why failure is a guard refusal, not a routable error + * + * A config that fails its contract is wrong METADATA: re-running the flow + * unchanged can never succeed, and the fix is to edit the node — the exact + * criteria {@link refuseNode} states ("a missing required config key" is its + * canonical example). Routing it through a `fault` edge would let one edge + * silence the contract for a node forever, the same suppression shape #3863 + * closed for the other refuse-to-execute guards. + * + * Unknown keys are NOT this seam's job: Zod's default `.strip()` drops them + * silently here, and `registerFlow()` rejects them loudly at registration + * (the tightened #4059 check). Type + `required` live here; key membership + * lives there. + */ + +import { refuseNode } from '../guard-refusal.js'; + +/** + * Structural view of a Zod schema's `safeParse` — service-automation takes no + * direct `zod` dependency (the contracts arrive already-built via + * `@objectstack/spec`), the same boundary that has the form↔Zod ledger tests + * reading key sets off `.shape` instead of importing zod. + */ +export interface NodeConfigContract { + safeParse(value: unknown): { + success: boolean; + data?: T; + error?: { issues: ReadonlyArray<{ path: ReadonlyArray; message: string }> }; + }; +} + +export type ParsedNodeConfig = + | { ok: true; config: T } + | { ok: false; refusal: { success: false; error: string; errorClass: 'guard' } }; + +/** `config.fields[0].name` — the same path spelling the registration-time + * undeclared-key diagnostic uses, so both layers report locations alike. */ +function formatIssuePath(path: ReadonlyArray): string { + let out = 'config'; + for (const seg of path) { + out += typeof seg === 'number' ? `[${seg}]` : `.${String(seg)}`; + } + return out; +} + +/** + * Parse `config` against the node type's Zod contract. On success returns the + * parsed config — typed, with the contract's defaults applied — which the + * executor reads INSTEAD of the raw record. On failure returns a ready-made + * guard refusal naming every violation with its path, so the step log carries + * the full prescription instead of the first symptom. + */ +export function parseNodeConfig( + nodeType: string, + nodeId: string, + contract: NodeConfigContract, + config: unknown, +): ParsedNodeConfig { + const result = contract.safeParse(config ?? {}); + if (result.success) { + return { ok: true, config: result.data as T }; + } + const issues = (result.error?.issues ?? []) + .map((i) => `${formatIssuePath(i.path)}: ${i.message}`) + .join('; '); + return { + ok: false, + refusal: refuseNode( + `${nodeType} '${nodeId}': config does not satisfy the ${nodeType} contract — ${issues || 'invalid config'}. ` + + `The config is metadata, so re-running changes nothing; fix the node in the flow definition. ` + + `The declared contract is the node type's configSchema (the Studio form) and the ` + + `${nodeType} config Zod in @objectstack/spec/automation (#4277).`, + ), + }; +} diff --git a/packages/services/service-automation/src/builtin/screen-nodes.ts b/packages/services/service-automation/src/builtin/screen-nodes.ts index 93f0cbec0e..ac303e0f99 100644 --- a/packages/services/service-automation/src/builtin/screen-nodes.ts +++ b/packages/services/service-automation/src/builtin/screen-nodes.ts @@ -1,9 +1,11 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import type { PluginContext } from '@objectstack/core'; -import { defineActionDescriptor } from '@objectstack/spec/automation'; +import { defineActionDescriptor, ScreenConfigSchema } from '@objectstack/spec/automation'; +import type { ScreenConfigParsed } from '@objectstack/spec/automation'; import type { AutomationEngine } from '../engine.js'; import { interpolate } from './template.js'; +import { parseNodeConfig } from './parse-config.js'; /** * Screen / Script built-in nodes — 'screen' and 'script' executors. @@ -98,7 +100,12 @@ export function registerScreenNodes(engine: AutomationEngine, ctx: PluginContext }, }), async execute(node, variables, context) { - const cfg = (node.config ?? {}) as Record; + // Parsed BEFORE interpolation: the contract's typed slots are strings + // (or `unknown`, for the interpolatable `defaultValue`/`defaults`), so + // `{var}` templates pass the parse and resolve below as always. + const parsedCfg = parseNodeConfig('screen', node.id, ScreenConfigSchema, node.config); + if (!parsedCfg.ok) return parsedCfg.refusal; + const cfg = parsedCfg.config; // `{var}` tokens in screen config resolve against the live flow // variables here (the engine does NOT pre-interpolate node config) — so // a step's title/description/field-default/object-form-default can pull @@ -143,7 +150,7 @@ export function registerScreenNodes(engine: AutomationEngine, ctx: PluginContext }; } - const rawFields = Array.isArray(cfg.fields) ? (cfg.fields as Array>) : []; + const rawFields = cfg.fields ?? []; const hasFields = rawFields.length > 0; // Suspend to collect input when the screen declares fields, or opts in // explicitly. `waitForInput === false` forces a server pass-through. @@ -152,13 +159,13 @@ export function registerScreenNodes(engine: AutomationEngine, ctx: PluginContext return { success: true }; } const fields = rawFields.map((f) => ({ - name: String(f.name ?? ''), - label: f.label != null ? String(f.label) : undefined, - type: f.type != null ? String(f.type) : undefined, + name: f.name, + label: f.label, + type: f.type, required: f.required === true, - options: Array.isArray(f.options) ? (f.options as Array<{ value: unknown; label: string }>) : undefined, + options: f.options as Array<{ value: unknown; label: string }> | undefined, defaultValue: f.defaultValue !== undefined ? interpolate(f.defaultValue, variables, context) : undefined, - placeholder: f.placeholder != null ? String(f.placeholder) : undefined, + placeholder: f.placeholder, // Forwarded RAW — deliberately not interpolated. `visibleWhen` is a // predicate the client re-evaluates on every keystroke against the // values collected SO FAR; the server has no view of those, so @@ -172,7 +179,7 @@ export function registerScreenNodes(engine: AutomationEngine, ctx: PluginContext // validating the full list blocked Submit on a field the user was // never shown — the run then sat paused with no resume request ever // issued (#3528). - visibleWhen: f.visibleWhen != null ? String(f.visibleWhen) : undefined, + visibleWhen: f.visibleWhen, })).filter((f) => f.name.length > 0); return { success: true, diff --git a/packages/services/service-automation/src/builtin/try-catch-node.ts b/packages/services/service-automation/src/builtin/try-catch-node.ts index f148d356bd..6faf3f5b09 100644 --- a/packages/services/service-automation/src/builtin/try-catch-node.ts +++ b/packages/services/service-automation/src/builtin/try-catch-node.ts @@ -1,18 +1,11 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import type { PluginContext } from '@objectstack/core'; -import { defineActionDescriptor } from '@objectstack/spec/automation'; -import type { FlowRegionParsed } from '@objectstack/spec/automation'; +import { defineActionDescriptor, TryCatchConfigSchema } from '@objectstack/spec/automation'; +import type { TryCatchConfigParsed } from '@objectstack/spec/automation'; import type { AutomationContext } from '@objectstack/spec/contracts'; import type { AutomationEngine } from '../engine.js'; - -interface RetryPolicy { - maxRetries?: number; - retryDelayMs?: number; - backoffMultiplier?: number; - maxRetryDelayMs?: number; - jitter?: boolean; -} +import { parseNodeConfig } from './parse-config.js'; /** * `try_catch` built-in node — **structured try/catch/retry** (ADR-0031 §Decision 3). @@ -77,23 +70,25 @@ export function registerTryCatchNode(engine: AutomationEngine, ctx: PluginContex }, }), async execute(node, variables, context) { - const cfg = (node.config ?? {}) as Record; - const tryRegion = cfg.try as FlowRegionParsed | undefined; - const catchRegion = cfg.catch as FlowRegionParsed | undefined; - const errorVariable = - typeof cfg.errorVariable === 'string' && cfg.errorVariable ? cfg.errorVariable : '$error'; - const retry = (cfg.retry ?? {}) as RetryPolicy; - - if (tryRegion == null) { - return { success: false, error: `try_catch '${node.id}': config.try region is required` }; - } + // Parse against the ADR-0031 contract. Note the retry defaults now come + // from the CONTRACT (RetryPolicySchema): a declared `retry` block that + // omits `retryDelayMs` gets the documented 1000ms base delay, where this + // executor historically filled in 0 — the declared default is the + // enforced one (#4277). + const parsed = parseNodeConfig('try_catch', node.id, TryCatchConfigSchema, node.config); + if (!parsed.ok) return parsed.refusal; + const cfg = parsed.config; + const tryRegion = cfg.try; + const catchRegion = cfg.catch; + const errorVariable = cfg.errorVariable || '$error'; + const retry = cfg.retry; const ctxOrEmpty = context ?? ({} as AutomationContext); - const maxRetries = typeof retry.maxRetries === 'number' ? retry.maxRetries : 0; - const baseDelay = typeof retry.retryDelayMs === 'number' ? retry.retryDelayMs : 0; - const multiplier = typeof retry.backoffMultiplier === 'number' ? retry.backoffMultiplier : 1; - const maxDelay = typeof retry.maxRetryDelayMs === 'number' ? retry.maxRetryDelayMs : 30000; - const useJitter = retry.jitter === true; + const maxRetries = retry?.maxRetries ?? 0; + const baseDelay = retry?.retryDelayMs ?? 0; + const multiplier = retry?.backoffMultiplier ?? 1; + const maxDelay = retry?.maxRetryDelayMs ?? 30000; + const useJitter = retry?.jitter === true; // Run the try region, retrying with exponential backoff up to maxRetries. let lastError = 'unknown error'; diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index 3672145b50..8923778a64 100644 --- a/packages/services/service-automation/src/engine.ts +++ b/packages/services/service-automation/src/engine.ts @@ -35,6 +35,36 @@ interface ConfigSchemaNode { items?: ConfigSchemaNode; additionalProperties?: unknown; } + +/** + * Known-confusable flow-node config keys → precise authoring guidance, + * appended to the undeclared-key rejection (#4277). The `UNKNOWN_KEY_GUIDANCE` + * pattern from `object.zod.ts`, scoped per node type so a key name that is + * wrong on one node but real on another never misfires. + * + * Entries are seeded only from documented incidents — a tombstone with no + * history is noise. The generic rejection already carries the path, the + * did-you-mean and the declared set; an entry here adds the *mechanism* the + * author was reaching for. + */ +const FLOW_NODE_UNKNOWN_KEY_GUIDANCE: Record> = { + create_record: { + fieldValues: + 'The write map is `fields` — `fieldValues` was an AI-authoring dialect that never had a ' + + 'runtime reader; the fix is the authoring source + this rejection, not a runtime alias ' + + '(#2419, rejected by design).', + }, + update_record: { + fieldValues: + 'The write map is `fields` — `fieldValues` was an AI-authoring dialect that never had a ' + + 'runtime reader (#2419, rejected by design).', + }, + screen: { + visibleIf: + 'The visibility predicate is `visibleWhen` (bare CEL, re-evaluated client-side as the ' + + 'user types — #3528, the incident this whole check descends from).', + }, +}; import { runIsUnscopedUserMode, flowTouchesData } from './runtime-identity.js'; import { isGuardRefusal } from './guard-refusal.js'; @@ -1435,10 +1465,12 @@ export class AutomationEngine implements IAutomationService { // executeNode() already throws NO_EXECUTOR at run time for unknown types. this.validateNodeTypes(name, parsed); - // #4045 — warn on config keys the node's descriptor does not declare - // (a `visibleIf` typo is silently accepted today). Warn-only: see - // validateNodeConfigKeys for why hard-failing would gamble on the nine - // builtins whose read-vs-declared drift has not been audited. + // #4277 — REJECT config keys the node's descriptor does not declare + // (the tightened #4059 warning; a `visibleIf` typo used to register in + // silence). Hard-fail with per-key prescriptions: see + // validateNodeConfigKeys for why the #4045 reconciliation made this + // safe, and for the deliberate exemptions (`assignment`, schemaless + // types, keyValue maps). this.validateNodeConfigKeys(name, parsed); // ADR-0032 §Decision 1a — parse-validate every predicate at registration, @@ -2686,48 +2718,81 @@ export class AutomationEngine implements IAutomationService { } /** - * Warn about node `config` keys the node type's descriptor does not declare - * (#4045 — the warn half of the unknown-key ladder). + * REJECT node `config` keys the node type's descriptor does not declare + * (#4277 — the error half of the #4045 unknown-key ladder; #4059 shipped + * the warn half). + * + * `FlowNodeSchema.config` is `z.record(z.unknown())`, so before #4059 a + * misspelled or invented config key was accepted in total silence: + * `visibleIf` instead of `visibleWhen` registered cleanly and then did + * nothing, which is exactly the failure shape that made #3528 take three + * passes to diagnose. The key is never read, so there is no runtime error + * to trace back — the only symptom is a feature that quietly does not + * happen. + * + * **Why an error is safe now.** An undeclared key falls into three + * populations, and when #4059 landed this seam could not tell them apart: + * an author typo (reject), a key the executor genuinely reads that its + * hand-written `configSchema` never declared (`notify.source` was exactly + * this — rejecting those breaks working apps), and dead config nobody + * reads. The #4045 reconciliation closed the second population — every + * read-but-undeclared key across the schema-carrying builtins was either + * declared on its descriptor or graduated into the ADR-0087 conversion + * layer, with ledger tests ratcheting both directions — and the #4059 + * warning measured what remains in live metadata. What survives to this + * check today is typos and dead config, and both are metadata bugs the + * platform's contract-first rule says to reject at the producer, loudly + * (Prime Directive #12; ADR-0032 no-silent-failure). * - * `FlowNodeSchema.config` is `z.record(z.unknown())`, so a misspelled or - * invented config key is accepted in total silence today: `visibleIf` instead - * of `visibleWhen` registers cleanly and then does nothing, which is exactly - * the failure shape that made #3528 take three passes to diagnose. The key is - * never read, so there is no runtime error to trace back — the only symptom - * is a feature that quietly does not happen. + * The rejection carries its prescription (the `UNKNOWN_KEY_GUIDANCE` + * pattern from `object.zod.ts`): every violation names its exact path, the + * declared key set, a did-you-mean when edit distance allows, and — for + * keys with documented history — a per-key tombstone from + * {@link FLOW_NODE_UNKNOWN_KEY_GUIDANCE}. * - * **Warn, never reject.** An undeclared key falls into three populations and - * this seam cannot yet tell them apart: an author typo (which we want to - * reject), a key the executor genuinely reads that its hand-written - * `configSchema` never declared (`notify.source` was exactly this until - * #4045 — rejecting those breaks working apps), and dead config nobody reads - * (harmless). Only 4 of the 13 schema-carrying builtins have been audited for - * the second population, so hard-failing here would gamble on the 9 that have - * not. The warning is what measures that distribution; tightening to an error - * is a later, per-key decision once the data exists, and belongs with a - * tombstone that carries the prescription (the `UNKNOWN_KEY_GUIDANCE` pattern - * in `object.zod.ts`). + * Deliberate exemptions, unchanged from the warn era: + * - **`assignment` is exempt wholesale**: with no `assignments` wrapper + * its top-level config keys ARE the author's variable names + * (logic-nodes.ts normalizes three shapes), so no fixed key set can + * describe it — pinned as un-reconcilable by the form↔Zod ledger. + * - Schemaless types (`decision`, `script`, `wait`, `subflow`, + * `connector_action`) publish no `configSchema` ⇒ nothing is declared, + * so nothing can be undeclared. `wait` / `connector_action` keep their + * contracts on FlowNode SIBLING blocks (`waitEventConfig` / + * `connectorConfig`), which this walk never touches. + * - keyValue maps (`additionalProperties: true`, no fixed `properties`) + * stop the walk: their keys are author data, not config keys. * - * Soft-fail matches {@link validateNodeTypes} directly above — same function, - * same `logger.warn` channel, same reasoning about not breaking flows over a - * diagnostic. Nothing about the published `configSchema` changes, so no - * consumer (designer form generation included) sees a different shape. + * Hard-fail matches {@link validateFlowExpressions} below — a flow whose + * metadata is wrong never registers, and every `registerFlow` call site + * already try/catches per flow, so a bad flow is skipped loudly at boot + * rather than crashing the kernel. */ private validateNodeConfigKeys(flowName: string, flow: FlowParsed): void { + const violations: string[] = []; for (const node of flow.nodes) { + // `assignment` config keys are the author's variable names — see the + // exemption note above. + if (node.type === 'assignment') continue; const schema = this.actionDescriptors.get(node.type)?.configSchema as ConfigSchemaNode | undefined; - // No descriptor, or a deliberately schemaless type (`decision`, - // `script`, `wait`, `subflow`, `connector_action` publish none — - // see config-schemas.test) ⇒ nothing is declared, so nothing can - // be undeclared. if (!schema) continue; - this.warnUndeclaredConfigKeys(flowName, node, schema, node.config, 'config'); + this.collectUndeclaredConfigKeys(node, schema, node.config, 'config', violations); + } + if (violations.length > 0) { + throw new Error( + `Flow '${flowName}' rejected: ${violations.length} undeclared config key(s) (#4277).\n` + + violations.map((v) => ` - ${v}`).join('\n') + + `\nAn undeclared key is never read, so it can only be a typo or dead config — fix the ` + + `flow's metadata (rename or remove the key). If an executor genuinely reads this key, ` + + `declare it on the node type's descriptor configSchema instead; read-but-undeclared ` + + `keys are exactly the drift the #4045 reconciliation closed.`, + ); } } /** - * Walk `value` against `schema` in lockstep, warning on keys the schema does - * not declare. + * Walk `value` against `schema` in lockstep, collecting keys the schema does + * not declare into `violations`. * * Descends **only where the schema declares structure** — an object with fixed * `properties`, or an array whose `items` do. It deliberately stops at a @@ -2741,17 +2806,17 @@ export class AutomationEngine implements IAutomationService { * comparison would miss `visibleIf` — the exact mistake this check exists to * catch — while still reporting the rarer top-level ones. A test pins it. */ - private warnUndeclaredConfigKeys( - flowName: string, + private collectUndeclaredConfigKeys( node: FlowNodeParsed, schema: ConfigSchemaNode, value: unknown, path: string, + violations: string[], ): void { if (schema.type === 'array' && schema.items) { if (!Array.isArray(value)) return; value.forEach((element, index) => { - this.warnUndeclaredConfigKeys(flowName, node, schema.items!, element, `${path}[${index}]`); + this.collectUndeclaredConfigKeys(node, schema.items!, element, `${path}[${index}]`, violations); }); return; } @@ -2764,7 +2829,7 @@ export class AutomationEngine implements IAutomationService { for (const key of Object.keys(value as Record)) { const child = schema.properties[key]; if (child) { - this.warnUndeclaredConfigKeys(flowName, node, child, (value as Record)[key], `${path}.${key}`); + this.collectUndeclaredConfigKeys(node, child, (value as Record)[key], `${path}.${key}`, violations); continue; } // The declared set is printed ALWAYS, not only as a fallback when the @@ -2778,11 +2843,13 @@ export class AutomationEngine implements IAutomationService { // listing them is both cheap and complete, and the suggestion becomes // a bonus for the cases it does catch. const suggestion = nearestName(key, declared); - this.logger.warn( - `[flow '${flowName}'] node '${node.id}' (${node.type}): unknown config key \`${key}\` at ${path}.${key}` + + const guidance = FLOW_NODE_UNKNOWN_KEY_GUIDANCE[node.type]?.[key]; + violations.push( + `node '${node.id}' (${node.type}): unknown config key \`${key}\` at ${path}.${key}` + (suggestion ? ` — did you mean \`${suggestion}\`?` : '') + ` It is not declared by this node type's configSchema, so nothing reads it.` + - ` Declared here: ${declared.join(', ')}.`, + ` Declared here: ${declared.join(', ')}.` + + (guidance ? ` ${guidance}` : ''), ); } } diff --git a/packages/services/service-automation/src/guard-refusal-inventory.test.ts b/packages/services/service-automation/src/guard-refusal-inventory.test.ts index 51089d7aea..fb8a21607c 100644 --- a/packages/services/service-automation/src/guard-refusal-inventory.test.ts +++ b/packages/services/service-automation/src/guard-refusal-inventory.test.ts @@ -79,29 +79,34 @@ function flowWithHandler(name: string, node: Record) { * that starts failing for a DIFFERENT reason does not pass vacuously. */ const GUARDS: Array<{ name: string; why: string; node: Record; expect: string }> = [ + // Since #4277 a missing REQUIRED key is refused by the executor's contract + // parse (parse-config.ts) before the hand-written guard runs, so those + // entries pin the parse refusal's fragment. The classification is the + // point of this inventory and is unchanged: a contract violation is wrong + // metadata, so it must stay a guard — un-routable. { name: 'get_record without objectName', why: 'a required config key — no run can supply it', node: { type: 'get_record', config: { filter: { id: 'x' } } }, - expect: 'objectName required', + expect: 'does not satisfy the get_record contract', }, { name: 'create_record without objectName', why: 'a required config key', node: { type: 'create_record', config: { fields: { a: 1 } } }, - expect: 'objectName required', + expect: 'does not satisfy the create_record contract', }, { name: 'update_record without objectName', why: 'a required config key', node: { type: 'update_record', config: { fields: { a: 1 } } }, - expect: 'objectName required', + expect: 'does not satisfy the update_record contract', }, { name: 'delete_record without objectName', why: 'a required config key', node: { type: 'delete_record', config: { filter: { id: 'x' } } }, - expect: 'objectName required', + expect: 'does not satisfy the delete_record contract', }, { name: 'get_record whose filter lost a condition (#3810)', @@ -128,7 +133,7 @@ const GUARDS: Array<{ name: string; why: string; node: Record; name: 'http without url', why: 'a required config key', node: { type: 'http', config: { method: 'GET' } }, - expect: 'url is required', + expect: 'does not satisfy the http contract', }, { name: 'subflow without flowName', diff --git a/packages/spec/src/automation/builtin-node-config.zod.ts b/packages/spec/src/automation/builtin-node-config.zod.ts index 86f96dc411..6e8e221964 100644 --- a/packages/spec/src/automation/builtin-node-config.zod.ts +++ b/packages/spec/src/automation/builtin-node-config.zod.ts @@ -25,12 +25,15 @@ * `map.input`), and the undeclared `map.flow` alias, which graduated into the * 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 - * today, so registering a flow behaves exactly as before. Wiring the executors - * to parse is deliberately deferred until the #4059 undeclared-key warning has - * measured a release's worth of real metadata (#4045 step 3b). + * Live execute-time contracts: each executor `parse()`s its config against + * its schema before running (`service-automation`'s `parse-config.ts`), so + * type and `required` violations refuse the node as a guard. All of these + * 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: * - `assignment` — its config cannot be described by a fixed key set: with no diff --git a/packages/spec/src/automation/control-flow.test.ts b/packages/spec/src/automation/control-flow.test.ts index 34016a64c6..5485ab671e 100644 --- a/packages/spec/src/automation/control-flow.test.ts +++ b/packages/spec/src/automation/control-flow.test.ts @@ -69,7 +69,17 @@ describe('LoopConfigSchema', () => { }) as { properties?: { collection?: { xExpression?: unknown; description?: unknown } } }; expect(schema.properties?.collection?.xExpression).toBe('template'); // description survives alongside the marker (they share one .meta()). - expect(schema.properties?.collection?.description).toBe('Template/variable resolving to the array to iterate'); + expect(schema.properties?.collection?.description).toBe( + 'Template/variable resolving to the array to iterate (an inline array is accepted)', + ); + }); + + it('accepts an inline array collection — the union map.collection declares (#4277)', () => { + // The executor has always resolved an already-an-array collection (shared + // logic with `map`); the string-only declaration under-declared what it + // reads, which the execute-time parse wiring surfaced. + const parsed = LoopConfigSchema.parse({ collection: [1, 2, 3] }); + expect(parsed.collection).toEqual([1, 2, 3]); }); }); diff --git a/packages/spec/src/automation/control-flow.zod.ts b/packages/spec/src/automation/control-flow.zod.ts index 0f7ad74d2d..9b9194bfaf 100644 --- a/packages/spec/src/automation/control-flow.zod.ts +++ b/packages/spec/src/automation/control-flow.zod.ts @@ -101,16 +101,19 @@ export type FlowRegionParsed = z.infer; export const LoopConfigSchema = lazySchema(() => z.object({ /** * The collection to iterate. A `{token}` template or bare variable name that - * resolves (at run time) to an array in the flow's variable scope. + * resolves (at run time) to an array in the flow's variable scope, or an + * inline array — the same union `map.collection` declares, because the two + * executors share the resolve logic (#4277 aligned this contract with what + * the executor has always read; the string-only declaration under-declared). */ - // `xExpression: 'template'` marks this as an `interpolate()` `{var}` template - // (not bare CEL), so the flow designer renders a `{var}` picker + mono editor - // and skips the CEL brace-trap (objectui #2670 Phase 3). Flows through - // `z.toJSONSchema` verbatim, same channel as `xRef` / `xEnumDeprecated`. The - // shipped `loop` descriptor carries the same marker on its hand-written - // configSchema literal (service-automation/builtin/loop-node.ts). - collection: z.string().min(1).meta({ - description: 'Template/variable resolving to the array to iterate', + // `xExpression: 'template'` marks the string form as an `interpolate()` + // `{var}` template (not bare CEL), so the flow designer renders a `{var}` + // picker + mono editor and skips the CEL brace-trap (objectui #2670 Phase 3). + // Flows through `z.toJSONSchema` verbatim, same channel as `xRef` / + // `xEnumDeprecated`. The shipped `loop` descriptor carries the same marker on + // its hand-written configSchema literal (service-automation/builtin/loop-node.ts). + collection: z.union([z.string().min(1), z.array(z.unknown())]).meta({ + description: 'Template/variable resolving to the array to iterate (an inline array is accepted)', xExpression: 'template', }), /** Variable name the current item is bound to inside the body. */ diff --git a/packages/spec/src/automation/io-node-config.zod.ts b/packages/spec/src/automation/io-node-config.zod.ts index 6cb1e52c3d..25465d5cd4 100644 --- a/packages/spec/src/automation/io-node-config.zod.ts +++ b/packages/spec/src/automation/io-node-config.zod.ts @@ -15,15 +15,20 @@ * 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 - * them today, so registering a flow behaves exactly as before. Wiring the - * executors to parse — which would finally give node configs the type / - * `required` / unknown-key enforcement the descriptor `configSchema` never - * provided — is deliberately deferred until the #4059 undeclared-key warning - * has measured a release's worth of real metadata (#4045 step 3b). + * these are **live execute-time contracts**: each executor `parse()`s its + * config against its schema before running (`service-automation`'s + * `parse-config.ts`), so type and `required` violations refuse the node as a + * guard (not routable via `fault` edges). `notify` parses the RAW stored + * config — its slots are string-typed, so `{token}` templates pass and the + * 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 * empty. The executor reads only the declared `FlowNodeSchema.connectorConfig` diff --git a/packages/spec/src/automation/node-executor.zod.ts b/packages/spec/src/automation/node-executor.zod.ts index 94c301cc58..49273e47c7 100644 --- a/packages/spec/src/automation/node-executor.zod.ts +++ b/packages/spec/src/automation/node-executor.zod.ts @@ -243,12 +243,18 @@ export const ActionDescriptorSchema = lazySchema(() => z.object({ * `config`. Drives Studio form generation. Optional — actions with no config * omit it. * - * **What actually validates against this, and what does not** (#4027). An - * earlier revision of this doc claimed `registerFlow()` checks that `config` - * satisfies `configSchema`. It does not, and never did — `FlowNodeSchema.config` - * is `z.record(z.unknown())`, so no layer rejects an undeclared or misspelled - * config key at author time. What *is* enforced: + * **What actually validates against this, and what does not** (#4027, + * tightened by #4277). `FlowNodeSchema.config` is `z.record(z.unknown())`, + * so the flow-level parse never types a node's config — enforcement is + * layered on top: * + * - **Unknown keys are rejected at `registerFlow()`** (#4277, tightening + * the #4059 warning): a config key this schema does not declare fails + * registration with the path, the declared key set, a did-you-mean, and + * a per-key tombstone where one exists. The walk stops at keyValue maps + * (`additionalProperties: true` — those keys are author data) and + * exempts `assignment` wholesale (its top-level keys ARE the author's + * variable names). * - **Expression slots are validated.** Every property marked * `xExpression: 'expression'` (bare CEL) is parse-checked at `registerFlow()` * and by `objectstack validate`, via the `FLOW_NODE_EXPRESSION_PATHS` ledger @@ -256,17 +262,20 @@ export const ActionDescriptorSchema = lazySchema(() => z.object({ * unvalidated the way `screen.fields[].visibleWhen` did for four months * (#3528). Slots marked `xExpression: 'template'` are recorded but not * checked: no validator implements the single-brace `{var}` dialect they use. - * - **Everything else is designer-facing only.** Types, `required`, `enum` and - * unknown keys are not enforced anywhere. Do not rely on this schema as a - * runtime guard; an executor must still validate its own config. + * - **Types and `required` are enforced at execute time for the + * contract-carrying builtins** (#4277): those executors `parse()` their + * config against the Zod contracts in `io-node-config.zod.ts` / + * `builtin-node-config.zod.ts` / `control-flow.zod.ts`, refusing the node + * (guard, not routable) on violation. For a plugin-contributed executor + * this schema remains designer-facing — the plugin must still validate + * its own config, and SHOULD follow the same parse pattern. * - * The schema is also hand-written per executor rather than derived from the - * code that reads `config`, so a property can be declared without being read - * (or read without being declared) with nothing to reconcile the two. Only the - * expression subset has a ratchet today. + * The schema is hand-written per executor rather than derived from the code + * that reads `config`; the form↔Zod ledger tests in `service-automation` + * reconcile the two for the builtins. */ configSchema: z.unknown().optional() - .describe('JSON Schema for the node config (drives the designer form; only declared expression slots are validated)'), + .describe('JSON Schema for the node config (drives the designer form; undeclared keys are rejected at registration)'), // ── capabilities ────────────────────────────────────────────────── /** Supports async pause/resume (e.g. wait, human_task). */ From 90af512d16ff19213e5d255cc0d6c652fc906d3b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 06:23:43 +0000 Subject: [PATCH 2/2] docs(automation): flows.mdx stops claiming node config is unchecked (#4277) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01CW8ZP3zUuC7ovSxqnN5o77 --- content/docs/automation/flows.mdx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/content/docs/automation/flows.mdx b/content/docs/automation/flows.mdx index c259753389..ce7c943be3 100644 --- a/content/docs/automation/flows.mdx +++ b/content/docs/automation/flows.mdx @@ -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 | @@ -148,8 +148,14 @@ Each node performs a specific action in the flow. 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. ### Node Examples