From ce022ba1af5aa0ded354f2e9fbc9157399182ad9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 15:32:27 +0000 Subject: [PATCH] fix(spec,service-automation): reconcile notify/http/connector declared config with what the executors read (#4045) The notify/http/connector step of the declared-vs-read worklist: - notify/http gain executor-derived Zod contracts (NotifyConfigSchema, HttpConfigSchema) written from the executors, not the form literals, plus a bidirectional form <-> Zod key-set ledger test. Both reconcile clean, with no deliberately-shallow entries. - connector_action's configSchema is retired: the executor reads only the declared FlowNodeSchema.connectorConfig sibling, but the published schema declared the trio as config keys - and the schema-driven Studio form roots every field at config., replacing the hand-written connectorConfig form, so online authoring produced nodes that refused to dispatch. Schemaless drops the designer back onto the correct form. - flow-node-connector-config-lift (ADR-0087 D2, protocol 17) heals stored flows carrying the mis-taught config.{connectorId,actionId, input} shape, with a completeness guard so a step-time refusal never becomes a load failure. - connectorConfig.input is optional, matching the executor (input ?? {}) and the designer's keyValue editor, which omits an empty map. - check:variant-docs (new in #4177) is classified in the check:generated ledger (NO_GENERATOR) - unclassified, it failed every gate run. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UDhMtxPLLoFaGtdpNA7xTU --- .changeset/io-node-config-reconciliation.md | 46 +++++ content/docs/references/automation/flow.mdx | 2 +- content/docs/references/automation/index.mdx | 1 + .../references/automation/io-node-config.mdx | 105 ++++++++++++ content/docs/references/automation/meta.json | 4 +- docs/protocol-upgrade-guide.md | 3 + .../src/builtin/config-schemas.test.ts | 20 ++- .../src/builtin/connector-nodes.test.ts | 78 +++++++++ .../src/builtin/connector-nodes.ts | 23 +-- .../builtin/io-node-form-zod-ledger.test.ts | 122 +++++++++++++ .../services/service-automation/src/engine.ts | 5 +- packages/spec/api-surface.json | 6 + packages/spec/authorable-surface.json | 20 ++- packages/spec/json-schema.manifest.json | 4 +- packages/spec/scripts/check-generated.ts | 1 + packages/spec/spec-changes.json | 12 ++ packages/spec/src/automation/flow.zod.ts | 16 +- packages/spec/src/automation/index.ts | 1 + .../spec/src/automation/io-node-config.zod.ts | 131 ++++++++++++++ packages/spec/src/conversions/registry.ts | 162 ++++++++++++++++++ packages/spec/src/migrations/registry.ts | 11 ++ 21 files changed, 750 insertions(+), 23 deletions(-) create mode 100644 .changeset/io-node-config-reconciliation.md create mode 100644 content/docs/references/automation/io-node-config.mdx create mode 100644 packages/services/service-automation/src/builtin/io-node-form-zod-ledger.test.ts create mode 100644 packages/spec/src/automation/io-node-config.zod.ts diff --git a/.changeset/io-node-config-reconciliation.md b/.changeset/io-node-config-reconciliation.md new file mode 100644 index 0000000000..7512380ce3 --- /dev/null +++ b/.changeset/io-node-config-reconciliation.md @@ -0,0 +1,46 @@ +--- +'@objectstack/spec': minor +'@objectstack/service-automation': patch +--- + +Reconcile the flat IO nodes' declared config against what their executors read +(#4045 — the notify / http / connector step of the declared-vs-read worklist). + +**`notify` / `http` gain executor-derived Zod contracts.** +`NotifyConfigSchema` and `HttpConfigSchema` (`automation/io-node-config.zod.ts`) +were written by reading the executors — not by transcribing the descriptors' +hand-written `configSchema` literals — and a new ledger test +(`io-node-form-zod-ledger.test.ts`) compares the two key sets bidirectionally. +Because the sides are independently written, agreement is evidence rather than +tautology: a key survives only if the form offers it AND the executor reads it. +Both nodes reconcile clean, with no deliberately-shallow ledger — their configs +are flat and fully closed. Like the control-flow config Zods, these are contract +exports: no engine path parses with them yet (that is #4045 step 3b, gated on +the #4059 warning data). + +**`connector_action`'s mis-rooted `configSchema` is retired — it broke +schema-driven authoring.** The executor reads only the declared +`FlowNodeSchema.connectorConfig` sibling block, but the descriptor published a +`configSchema` declaring `connectorId`/`actionId`/`input` as `config` keys. A +published `configSchema` describes `node.config` by contract, and the Studio +inspector derives its property form from it — rooting every field at +`config.` and replacing the client's hand-written `connectorConfig` form +(with its connector/action pickers). So authoring a connector node against a +live backend wrote the trio where nothing reads it, and the node refused to +dispatch. The descriptor now publishes no `configSchema` (joining `wait`'s +deliberately-schemaless class), which drops the online designer back onto the +correct sibling-block form with no client change. + +**Stored flows that carry the mis-taught shape are healed at load.** A new +ADR-0087 D2 conversion, `flow-node-connector-config-lift` (protocol 17, retires +at 18), lifts `config.{connectorId,actionId,input}` onto the declared +`connectorConfig` block — including the `AutomationEngine.registerFlow` +rehydration seam. Declared keys win (the loose counterpart stays shadowed), and +a lift that cannot complete the required `connectorId`+`actionId` pair leaves +the node untouched, so a step-time refusal never becomes a load failure. + +**`connectorConfig.input` is now optional**, matching what was always true: the +executor dispatches with `input ?? {}` and the designer's keyValue editor omits +an empty map entirely — so the required `input` declared in the spec turned a +no-input connector action into a `registerFlow` parse failure nothing +downstream asked for. diff --git a/content/docs/references/automation/flow.mdx b/content/docs/references/automation/flow.mdx index c37afe5ed3..7f8c3eb908 100644 --- a/content/docs/references/automation/flow.mdx +++ b/content/docs/references/automation/flow.mdx @@ -99,7 +99,7 @@ const result = Flow.parse(data); | **type** | `string` | ✅ | Action type — a built-in FlowNodeAction id or a plugin-registered node type. Validated against the live action registry at registerFlow() (ADR-0018), not by a closed enum. | | **label** | `string` | ✅ | Node label | | **config** | `Record` | optional | Node configuration | -| **connectorConfig** | `{ connectorId: string; actionId: string; input: Record }` | optional | | +| **connectorConfig** | `{ connectorId: string; actionId: string; input?: Record }` | optional | | | **position** | `{ x: number; y: number }` | optional | | | **timeoutMs** | `integer` | optional | Maximum execution time for this node in milliseconds | | **inputSchema** | `Record; required: boolean; description?: string }>` | optional | Input parameter schema for this node | diff --git a/content/docs/references/automation/index.mdx b/content/docs/references/automation/index.mdx index 74dce5c365..fed34f0c16 100644 --- a/content/docs/references/automation/index.mdx +++ b/content/docs/references/automation/index.mdx @@ -12,6 +12,7 @@ This section contains all protocol schemas for the automation layer of ObjectSta + diff --git a/content/docs/references/automation/io-node-config.mdx b/content/docs/references/automation/io-node-config.mdx new file mode 100644 index 0000000000..e595a6b915 --- /dev/null +++ b/content/docs/references/automation/io-node-config.mdx @@ -0,0 +1,105 @@ +--- +title: Io Node Config +description: Io Node Config protocol schemas +--- + +{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} + +@module automation/io-node-config + +Config contracts for the flat IO builtins — `notify` and `http` (#4045). + +## Provenance — written from the executors, not from the forms + +Each schema here was derived by reading what the executor actually does with + +`node.config` (`service-automation/builtin/notify-node.ts`, `http-nodes.ts`), + +**not** by transcribing the hand-written `configSchema` literal on the node's + +descriptor. That independence is the point: the two artifacts are reconciled + +bidirectionally by `io-node-form-zod-ledger.test.ts` in `service-automation`, + +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 + +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). + +`connector_action` has no schema here on purpose: its config contract is + +empty. The executor reads only the declared `FlowNodeSchema.connectorConfig` + +sibling block — see the descriptor note in + +`service-automation/builtin/connector-nodes.ts`. + + +**Source:** `packages/spec/src/automation/io-node-config.zod.ts` + + +## TypeScript Usage + +```typescript +import { HttpConfig, NotifyConfig } from '@objectstack/spec/automation'; +import type { HttpConfig, NotifyConfig } from '@objectstack/spec/automation'; + +// Validate data +const result = HttpConfig.parse(data); +``` + +--- + +## HttpConfig + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **url** | `string` | ✅ | Target URL | +| **method** | `string` | optional | HTTP method (default GET; POST when durable) | +| **headers** | `Record` | optional | Request headers | +| **body** | `any` | optional | Request body (JSON-serialised) | +| **durable** | `boolean` | optional | Fire-and-forget via the durable outbox (retry/dead-letter) instead of inline request/response | +| **timeoutMs** | `number` | optional | Per-request timeout (ms) | +| **signingSecret** | `string` | optional | HMAC-SHA256 secret → X-Objectstack-Signature | + + +--- + +## NotifyConfig + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **recipients** | `string \| string[]` | ✅ | Recipient user id(s) / audience selector(s); `{token}` templates resolve per run | +| **title** | `string` | ✅ | Notification title | +| **message** | `string` | optional | Notification body | +| **channels** | `string \| string[]` | optional | Channels to fan out to (default: inbox) | +| **topic** | `string` | optional | Event topic (default: "notify") | +| **severity** | `string` | optional | info \| warning \| critical | +| **sourceObject** | `string` | optional | Object name of the record the notification links to (writes sys_notification.source_object). Requires sourceId. | +| **sourceId** | `string` | optional | Record id the notification links to (writes sys_notification.source_id). Requires sourceObject. | +| **actorId** | `string` | optional | User id that caused the event (writes sys_notification.actor_id) | +| **actionUrl** | `string` | optional | Explicit click-through URL; overrides the link synthesized from sourceObject/sourceId | +| **payload** | `Record` | optional | Extra template inputs merged into the notification payload | + + +--- + diff --git a/content/docs/references/automation/meta.json b/content/docs/references/automation/meta.json index ff757fa5aa..edfc92eb72 100644 --- a/content/docs/references/automation/meta.json +++ b/content/docs/references/automation/meta.json @@ -18,6 +18,8 @@ "webhook", "---Approvals & Jobs---", "approval", - "job" + "job", + "---More---", + "io-node-config" ] } \ No newline at end of file diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index f514c7b278..7c448774a6 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -126,6 +126,8 @@ Beyond those spec-surface removals, it graduates the seven flow-node config key The same graduation covers `wait`, whose fallback was not a config-to-config rename (#4045). `wait` keeps its contract in the declared `waitEventConfig` block, not in `config` at all — yet the executor also read six loose `config` keys, two of them (`duration`, `signal`) spellings the spec never declared. The conversion lifts them onto the declared block in the executor's own `??` precedence, so a value already declared wins and its loose counterpart is left shadowed. One wrinkle makes this a rewrite rather than a delete: `waitEventConfig.eventType` is required once the block exists, and the loader parses the CONVERTED flow — so a source carrying only `config: { duration }` is stamped with `eventType: 'timer'`, the exact default the executor applied to that shape. Behaviour-preserving in both directions. +`connector_action` gets the same lift for the opposite reason (#4045). Its contract also lives in a declared sibling block (`connectorConfig`), and the executor never read `config` at all — but the node's descriptor published a `configSchema` declaring `connectorId`/`actionId`/`input` as `config` keys, and the Studio inspector derives its form from a published schema, so schema-driven authoring wrote the trio to the wrong place and produced nodes that refused to dispatch. The conversion lifts the trio onto the declared block (declared keys win; a lift that cannot complete the required connectorId+actionId pair leaves the node untouched rather than turning a step-time refusal into a load failure), and the descriptor stops publishing the mis-rooted schema. + And it removes the RLS-policy key `priority` (#3896 security audit): promised "conflict resolution" that cannot exist, because applicable policies OR-combine (most permissive wins) — there is never a conflict to order, and nothing ever read the key (call graph closed across the collection site, the projection round-trip and the compiler). A pure lossless delete: outcomes are identical with or without it; the schema tombstones the key with the same prescription. The same close-out retires the four inert tool authoring keys (`category`, `permissions`, `active`, `builtIn`): none is part of AIToolDefinition and no execution path read them. Two were misleading in the dangerous direction — `permissions` promised an invocation gate nothing enforced, and `active: false` read as "withdrawn" while the tool kept reaching the LLM tool set. Lossless deletes; the strict ToolSchema rejects each with its prescription. @@ -149,6 +151,7 @@ The close-out sweep finishes the enforce-or-remove worklist across the remaining | `flow-node-crud-object-alias` | `flow.node.config.objectName` | CRUD flow-node config key 'object' → 'objectName' (#3796 — `readAliasedConfig` shim graduation) | live — protocol 17 loader accepts the old shape | | `flow-node-notify-config-aliases` | `flow.node.notify.config` | notify flow-node config keys 'to' → 'recipients', 'subject' → 'title', 'body' → 'message', 'url' → 'actionUrl' (#3796), and nested 'source: {object, id}' → 'sourceObject' / 'sourceId' (#4045) | live — protocol 17 loader accepts the old shape | | `flow-node-wait-event-config-lift` | `flow.node.wait.waitEventConfig` | wait flow-node loose config keys → the declared `waitEventConfig` block: 'eventType', 'timerDuration'/'duration' → 'timerDuration', 'signalName'/'signal' → 'signalName', 'timeoutMs' (#4045) | live — protocol 17 loader accepts the old shape | +| `flow-node-connector-config-lift` | `flow.node.connector_action.connectorConfig` | connector_action flow-node loose config keys 'connectorId' / 'actionId' / 'input' → the declared `connectorConfig` block (#4045) | live — protocol 17 loader accepts the old shape | | `flow-node-script-config-aliases` | `flow.node.script.config` | script flow-node config keys 'functionName' → 'function', 'input' → 'inputs' (#3796) | live — protocol 17 loader accepts the old shape | | `permission-rls-priority-removed` | `permission.rowLevelSecurity.priority` | RLS-policy key 'priority' removed (#3896 audit — policies OR-combine, so the promised conflict-resolution semantics cannot exist; dropping it changes no outcome) | retired — `migrate meta` only | | `tool-inert-authoring-keys-removed` | `tool.category / tool.permissions / tool.active / tool.builtIn` | tool keys 'category'/'permissions'/'active'/'builtIn' removed (#3896 close-out — authorable and inert; permissions gated nothing, active:false withdrew nothing) | retired — `migrate meta` only | diff --git a/packages/services/service-automation/src/builtin/config-schemas.test.ts b/packages/services/service-automation/src/builtin/config-schemas.test.ts index d15ea2b88e..71d631c053 100644 --- a/packages/services/service-automation/src/builtin/config-schemas.test.ts +++ b/packages/services/service-automation/src/builtin/config-schemas.test.ts @@ -13,8 +13,10 @@ * Deliberately schemaless (stay on the hardcoded designer form; a node with no * configSchema has NO online/offline divergence): `decision` (virtual Target * column derived from edges), `wait` (top-level `waitEventConfig` block), - * `script` (actionType-conditional form) and `subflow` (top-level `timeoutMs`) - * — a partial schema would drop those editors. + * `script` (actionType-conditional form), `subflow` (top-level `timeoutMs`) + * and `connector_action` (top-level `connectorConfig` block, #4045) — a + * partial schema would drop those editors, and a schema rooted at `config` + * would actively re-route sibling-block authoring to keys nothing reads. */ import { describe, it, expect } from 'vitest'; @@ -26,6 +28,7 @@ import { registerScreenNodes } from './screen-nodes.js'; import { registerLoopNode } from './loop-node.js'; import { registerParallelNode } from './parallel-node.js'; import { registerTryCatchNode } from './try-catch-node.js'; +import { registerConnectorNodes } from './connector-nodes.js'; function silentLogger() { return { info() {}, warn() {}, error() {}, debug() {}, child() { return silentLogger(); } } as any; @@ -197,12 +200,23 @@ describe('builtin node configSchemas — designer parity (#3304)', () => { }); }); - it('decision / script stay deliberately schemaless (no partial forms)', () => { + it('decision / script / connector_action stay deliberately schemaless (no partial forms)', () => { // A node with no configSchema renders identically online and offline (the // hardcoded fallback), so there is no divergence — and publishing a partial // schema would DROP editors the adapter cannot express (decision's virtual // Target column; script's actionType-conditional fields). expect(engine.getActionDescriptor('decision')?.configSchema).toBeUndefined(); expect(engine.getActionDescriptor('script')?.configSchema).toBeUndefined(); + + // connector_action is the load-bearing member of this class (#4045): its + // contract is the top-level `connectorConfig` block, and the schema it + // used to publish declared that trio as `config` keys — which the + // schema-driven online form then wrote to `config.*`, replacing the + // hand-written connectorConfig form and producing nodes the executor + // refuses. Schemaless keeps the designer on the correct sibling-block + // form. Guarded here so the schema cannot quietly come back. + const engine3 = new AutomationEngine(silentLogger()); + registerConnectorNodes(engine3, ctx()); + expect(engine3.getActionDescriptor('connector_action')?.configSchema).toBeUndefined(); }); }); diff --git a/packages/services/service-automation/src/builtin/connector-nodes.test.ts b/packages/services/service-automation/src/builtin/connector-nodes.test.ts index f9f7fa9695..5fbc665be6 100644 --- a/packages/services/service-automation/src/builtin/connector-nodes.test.ts +++ b/packages/services/service-automation/src/builtin/connector-nodes.test.ts @@ -123,6 +123,84 @@ describe('connector_action (baseline node)', () => { expect(result.error).toContain('ghost.noop'); }); + // #4045 — this proves the CONVERSION, not executor tolerance. The executor + // reads only the declared `connectorConfig` block; the config-authored trio + // reaches it because `registerFlow` applies + // `flow-node-connector-config-lift`, which lifts it. This is the exact + // shape the descriptor's former configSchema mis-taught the schema-driven + // Studio form to write. + it('accepts the mis-taught config.{connectorId,actionId,input} shape via the lift', async () => { + let received: Record | undefined; + engine.registerConnector(fakeConnector(), { + async echo(input) { + received = input; + return { echoed: input.message }; + }, + }); + + engine.registerFlow('lifted_flow', { + name: 'lifted_flow', + label: 'Lifted Flow', + type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { + id: 'call', + type: 'connector_action', + label: 'Config-authored', + config: { connectorId: 'fake', actionId: 'echo', input: { message: 'hi' } }, + }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'call' }, + { id: 'e2', source: 'call', target: 'end' }, + ], + }); + + const result = await engine.execute('lifted_flow'); + expect(result.success).toBe(true); + expect(received).toEqual({ message: 'hi' }); + }); + + // #4045 — `input` is optional in the spec block because the executor + // dispatches with `input ?? {}` and the designer's keyValue editor omits an + // empty map entirely. Under the old required `input`, this exact designer + // output failed FlowSchema.parse at registerFlow. + it('registers and dispatches a connectorConfig with no input (empty map)', async () => { + let received: Record | undefined; + engine.registerConnector(fakeConnector(), { + async echo(input) { + received = input; + return { ok: true }; + }, + }); + + engine.registerFlow('no_input_flow', { + name: 'no_input_flow', + label: 'No Input Flow', + type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { + id: 'call', + type: 'connector_action', + label: 'No Input', + connectorConfig: { connectorId: 'fake', actionId: 'echo' }, + }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'call' }, + { id: 'e2', source: 'call', target: 'end' }, + ], + }); + + const result = await engine.execute('no_input_flow'); + expect(result.success).toBe(true); + expect(received).toEqual({}); + }); + it('fails the step when connectorConfig is missing required fields', async () => { engine.registerFlow('bad_config', { name: 'bad_config', diff --git a/packages/services/service-automation/src/builtin/connector-nodes.ts b/packages/services/service-automation/src/builtin/connector-nodes.ts index 57664c0ad4..1fd890b210 100644 --- a/packages/services/service-automation/src/builtin/connector-nodes.ts +++ b/packages/services/service-automation/src/builtin/connector-nodes.ts @@ -36,16 +36,19 @@ export function registerConnectorNodes(engine: AutomationEngine, ctx: PluginCont // Present in both authoring paradigms (ADR-0018 §registry table; // workflow_rule retired per ADR-0019). paradigms: ['flow', 'approval'], - // Config contract — drives the Studio property form and flow validation. - configSchema: { - type: 'object', - required: ['connectorId', 'actionId'], - properties: { - connectorId: { type: 'string', description: 'Registered connector name' }, - actionId: { type: 'string', description: 'Action key declared by the connector' }, - input: { type: 'object', description: 'Mapped inputs for the action' }, - }, - }, + // NO configSchema — deliberate, same class as `wait` (#4045). The + // node's contract is not `config` at all: the executor reads only + // the declared `FlowNodeSchema.connectorConfig` sibling block. + // This descriptor used to publish a configSchema declaring + // `connectorId`/`actionId`/`input` — but a published configSchema + // by contract describes `node.config` and the Studio inspector + // derives its property form from it, rooting every field at + // `config.` and REPLACING the client's hand-written + // connectorConfig form (with its connector/action pickers). So the + // schema steered online authoring to keys the executor never + // reads, and the node refused to dispatch. Stored flows that + // carry the mis-taught shape are healed at load by the ADR-0087 D2 + // conversion 'flow-node-connector-config-lift'. }), async execute(node, variables, context) { const cfg = node.connectorConfig; diff --git a/packages/services/service-automation/src/builtin/io-node-form-zod-ledger.test.ts b/packages/services/service-automation/src/builtin/io-node-form-zod-ledger.test.ts new file mode 100644 index 0000000000..48a410f52b --- /dev/null +++ b/packages/services/service-automation/src/builtin/io-node-form-zod-ledger.test.ts @@ -0,0 +1,122 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * **Designer form ↔ Zod reconciliation for the IO nodes** (#4045) — the flat + * sibling of `control-flow-form-zod-ledger.test.ts`. + * + * `notify` and `http` each carry TWO descriptions of the same config: the + * hand-written `configSchema` on the descriptor (drives the Studio form) and an + * executor-derived Zod in `io-node-config.zod.ts` (the validation contract). + * The Zod was written by reading the executor — NOT by transcribing the form — + * so comparing the two key sets is a real reconciliation, not a tautology: + * a key can only be on both sides if the form offers it AND the executor reads + * it. `notify.source` (read, never declared — #4050) and a `visibleIf` typo + * (declared-looking, never read — #3528) are the two failure shapes this pins + * shut for these nodes. + * + * Unlike the control-flow trio there is NO deliberately-shallow ledger here: + * these configs are flat and fully closed, so the form and the Zod must agree + * exactly. If an asymmetry ever needs to become deliberate, add a ledger with + * a reason (the #4078 pattern) rather than widening an expectation. + * + * `connector_action` is the third IO node and reconciles differently: its + * config contract is EMPTY. The executor reads only the declared + * `FlowNodeSchema.connectorConfig` sibling block, so the descriptor must + * publish no `configSchema` at all — the one it used to publish declared the + * block's keys as `config` keys, and the schema-driven Studio form then wrote + * them where nothing reads (#4045). Pinned below from both directions: + * schemaless descriptor, and the sibling block's key set. + * + * Key sets are read off `.shape` (no `zod` import) for the same dependency + * reason as the control-flow ledger. + */ + +import { describe, it, expect } from 'vitest'; +import { NotifyConfigSchema, HttpConfigSchema, FlowNodeSchema } from '@objectstack/spec/automation'; +import { AutomationEngine } from '../engine.js'; +import { registerNotifyNode } from './notify-node.js'; +import { registerHttpNodes } from './http-nodes.js'; +import { registerConnectorNodes } from './connector-nodes.js'; + +const NODES: ReadonlyArray<{ nodeType: string; zod: unknown }> = [ + { nodeType: 'notify', zod: NotifyConfigSchema }, + { nodeType: 'http', zod: HttpConfigSchema }, +]; + +function engineWithIoNodes() { + const logger: any = { + info() {}, warn() {}, error() {}, debug() {}, + child() { return logger; }, + }; + const ctx: any = { logger, getService() { throw new Error('none'); } }; + const engine = new AutomationEngine(logger); + registerNotifyNode(engine, ctx); + registerHttpNodes(engine, ctx); + registerConnectorNodes(engine, ctx); + return engine; +} + +const engine = engineWithIoNodes(); + +/** Top-level keys the designer form offers for a node type. */ +function formKeys(nodeType: string): string[] { + const schema = engine.getActionDescriptor(nodeType)?.configSchema as + | { properties?: Record } + | undefined; + expect(schema, `${nodeType} should publish a configSchema`).toBeDefined(); + return Object.keys(schema!.properties ?? {}).sort(); +} + +/** Top-level keys the Zod object accepts, read straight off `.shape`. */ +function zodKeys(schema: unknown): string[] { + const shape = (schema as { shape?: Record }).shape; + expect(shape, 'the Zod schema should expose a .shape').toBeDefined(); + return Object.keys(shape ?? {}).sort(); +} + +describe('IO-node form ↔ Zod reconciliation (#4045)', () => { + it.each(NODES)('$nodeType: the form offers exactly the keys the executor reads', ({ nodeType, zod }) => { + const form = formKeys(nodeType); + const zodded = zodKeys(zod); + + // Read by the executor (per the Zod), absent from the form ⇒ reachable only + // by hand-authored metadata — the `notify.source` shape (#4050). + expect( + zodded.filter((k) => !form.includes(k)), + `${nodeType}: read by the executor but absent from the designer form`, + ).toEqual([]); + + // Offered by the form, not read by the executor ⇒ an author fills in a + // Studio field that does nothing — the #3528 shape. + expect( + form.filter((k) => !zodded.includes(k)), + `${nodeType}: offered by the designer form but never read by the executor`, + ).toEqual([]); + }); + + describe('connector_action: the contract is the connectorConfig sibling, not config', () => { + it('publishes no configSchema (deliberately schemaless)', () => { + // A published configSchema roots the schema-driven Studio form at + // `config.`, replacing the hand-written connectorConfig form — the + // exact drift that made online-authored connector nodes refuse to + // dispatch (#4045). Also asserted in config-schemas.test.ts alongside + // the other schemaless types; repeated here so THIS file tells the whole + // connector reconciliation story. + expect(engine.getActionDescriptor('connector_action')?.configSchema).toBeUndefined(); + }); + + it('the declared sibling block carries exactly the keys the executor reads', () => { + // The executor reads node.connectorConfig.{connectorId,actionId,input} + // and nothing else (connector-nodes.ts). The spec side of that contract + // is FlowNodeSchema.connectorConfig — unwrap the optional wrapper + // structurally to stay off a direct `zod` dependency. + const prop = (FlowNodeSchema as unknown as { shape: Record }) + .shape.connectorConfig as { unwrap?: () => { shape?: Record } }; + expect(prop, 'FlowNodeSchema should declare connectorConfig').toBeDefined(); + expect(prop.unwrap, 'connectorConfig should be an optional-wrapped object').toBeTypeOf('function'); + const shape = prop.unwrap!().shape; + expect(shape, 'the unwrapped connectorConfig should expose a .shape').toBeDefined(); + expect(Object.keys(shape ?? {}).sort()).toEqual(['actionId', 'connectorId', 'input']); + }); + }); +}); diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index 7f80bd2004..15bd386b66 100644 --- a/packages/services/service-automation/src/engine.ts +++ b/packages/services/service-automation/src/engine.ts @@ -2717,8 +2717,9 @@ export class AutomationEngine implements IAutomationService { for (const node of flow.nodes) { const schema = this.actionDescriptors.get(node.type)?.configSchema as ConfigSchemaNode | undefined; // No descriptor, or a deliberately schemaless type (`decision`, - // `script`, `wait`, `subflow` publish none — see config-schemas.test) - // ⇒ nothing is declared, so nothing can be undeclared. + // `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'); } diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 46a786dc84..83a378e067 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -2147,6 +2147,9 @@ "FlowVersionHistoryParsed (type)", "FlowVersionHistorySchema (const)", "GuardRefSchema (const)", + "HttpConfig (type)", + "HttpConfigParsed (type)", + "HttpConfigSchema (const)", "LOOP_MAX_ITERATIONS_CEILING (const)", "LOOP_NODE_TYPE (const)", "LoopConfig (type)", @@ -2156,6 +2159,9 @@ "NON_AUTHORABLE_APPROVER_TYPES (const)", "NodeExecutorDescriptor (type)", "NodeExecutorDescriptorSchema (const)", + "NotifyConfig (type)", + "NotifyConfigParsed (type)", + "NotifyConfigSchema (const)", "OAuth2Config (type)", "OAuth2ConfigSchema (const)", "ORG_MEMBERSHIP_LEVELS (const)", diff --git a/packages/spec/authorable-surface.json b/packages/spec/authorable-surface.json index e5d2d73b3c..cb2ebf9981 100644 --- a/packages/spec/authorable-surface.json +++ b/packages/spec/authorable-surface.json @@ -1,5 +1,5 @@ { - "description": "Ratchet of every AUTHORABLE key in the spec \u2014 what a metadata author may write, which for this platform IS the third-party API. Auto-updated on additions (commit the change). A key that disappears without a tombstone fails gen:schema, because these schemas are not .strict() and Zod would silently strip it. \"[RETIRED]\" marks a tombstoned key that still rejects with an upgrade prescription. See #3855, ADR-0059 \u00a75.", + "description": "Ratchet of every AUTHORABLE key in the spec — what a metadata author may write, which for this platform IS the third-party API. Auto-updated on additions (commit the change). A key that disappears without a tombstone fails gen:schema, because these schemas are not .strict() and Zod would silently strip it. \"[RETIRED]\" marks a tombstoned key that still rejects with an upgrade prescription. See #3855, ADR-0059 §5.", "keys": [ "ai/AIModelConfig:maxTokens", "ai/AIModelConfig:model", @@ -2415,6 +2415,13 @@ "automation/FlowVersionHistory:definition", "automation/FlowVersionHistory:flowName", "automation/FlowVersionHistory:version", + "automation/HttpConfig:body", + "automation/HttpConfig:durable", + "automation/HttpConfig:headers", + "automation/HttpConfig:method", + "automation/HttpConfig:signingSecret", + "automation/HttpConfig:timeoutMs", + "automation/HttpConfig:url", "automation/LoopConfig:body", "automation/LoopConfig:collection", "automation/LoopConfig:indexVariable", @@ -2429,6 +2436,17 @@ "automation/NodeExecutorDescriptor:supportsPause", "automation/NodeExecutorDescriptor:supportsRetry", "automation/NodeExecutorDescriptor:version", + "automation/NotifyConfig:actionUrl", + "automation/NotifyConfig:actorId", + "automation/NotifyConfig:channels", + "automation/NotifyConfig:message", + "automation/NotifyConfig:payload", + "automation/NotifyConfig:recipients", + "automation/NotifyConfig:severity", + "automation/NotifyConfig:sourceId", + "automation/NotifyConfig:sourceObject", + "automation/NotifyConfig:title", + "automation/NotifyConfig:topic", "automation/OAuth2Config:authorizationUrl", "automation/OAuth2Config:clientIdField", "automation/OAuth2Config:clientSecretField", diff --git a/packages/spec/json-schema.manifest.json b/packages/spec/json-schema.manifest.json index ede9396721..1bbbc4427e 100644 --- a/packages/spec/json-schema.manifest.json +++ b/packages/spec/json-schema.manifest.json @@ -1,5 +1,5 @@ { - "description": "Ratchet manifest of every JSON Schema emitted by scripts/build-schemas.ts. Auto-appended when new schemas are added (commit the change). A listed schema that a build no longer emits fails gen:schema \u2014 remove a key ONLY for a deliberate retirement. See #2978.", + "description": "Ratchet manifest of every JSON Schema emitted by scripts/build-schemas.ts. Auto-appended when new schemas are added (commit the change). A listed schema that a build no longer emits fails gen:schema — remove a key ONLY for a deliberate retirement. See #2978.", "schemas": [ "ai/AIModelConfig", "ai/AIUsageRecord", @@ -554,8 +554,10 @@ "automation/FlowVariable", "automation/FlowVersionHistory", "automation/GuardRef", + "automation/HttpConfig", "automation/LoopConfig", "automation/NodeExecutorDescriptor", + "automation/NotifyConfig", "automation/OAuth2Config", "automation/OperationParameter", "automation/OperationType", diff --git a/packages/spec/scripts/check-generated.ts b/packages/spec/scripts/check-generated.ts index ff944e0d22..fedfcaabe0 100644 --- a/packages/spec/scripts/check-generated.ts +++ b/packages/spec/scripts/check-generated.ts @@ -62,6 +62,7 @@ const NO_GENERATOR: ReadonlyArray<{ check: string; why: string }> = [ { check: 'check:empty-state', why: 'audits empty-state coverage — no artifact' }, { check: 'check:react-conformance', why: 'audits react blocks against their contract — no artifact' }, { check: 'check:skill-examples', why: 'validates skill examples parse — no artifact' }, + { check: 'check:variant-docs', why: 'audits hand-written docs cover every declared union variant — the fix is a doc/ledger edit, not a gen: command' }, ]; /** diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index 7c6c49b736..f4a0776604 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -110,6 +110,12 @@ "conversionId": "flow-node-wait-event-config-lift", "toMajor": 17 }, + { + "surface": "flow.node.connector_action.connectorConfig", + "to": "connector_action flow-node loose config keys 'connectorId' / 'actionId' / 'input' → the declared `connectorConfig` block (#4045)", + "conversionId": "flow-node-connector-config-lift", + "toMajor": 17 + }, { "surface": "flow.node.script.config", "to": "script flow-node config keys 'functionName' → 'function', 'input' → 'inputs' (#3796)", @@ -507,6 +513,12 @@ "conversionId": "flow-node-wait-event-config-lift", "toMajor": 17 }, + { + "surface": "flow.node.connector_action.connectorConfig", + "to": "connector_action flow-node loose config keys 'connectorId' / 'actionId' / 'input' → the declared `connectorConfig` block (#4045)", + "conversionId": "flow-node-connector-config-lift", + "toMajor": 17 + }, { "surface": "flow.node.script.config", "to": "script flow-node config keys 'functionName' → 'function', 'input' → 'inputs' (#3796)", diff --git a/packages/spec/src/automation/flow.zod.ts b/packages/spec/src/automation/flow.zod.ts index dc8cea4a10..5ce568cc3f 100644 --- a/packages/spec/src/automation/flow.zod.ts +++ b/packages/spec/src/automation/flow.zod.ts @@ -159,14 +159,22 @@ export const FlowNodeSchema = lazySchema(() => z.object({ /** Node Configuration Options (Specific to type) */ config: z.record(z.string(), z.unknown()).optional().describe('Node configuration'), - /** + /** * Connector Action Configuration * Used when type is 'connector_action' + * + * This block — not `config` — IS the `connector_action` contract: the + * executor reads nothing else (connector-nodes.ts, #4045). `input` is + * optional to match what the executor and the designer actually do: the + * executor dispatches with `input ?? {}`, and the Studio inspector's + * keyValue editor omits an empty map entirely — so a required `input` + * declared here turned a no-input action into a load failure nothing + * downstream asked for. */ connectorConfig: z.object({ - connectorId: z.string(), - actionId: z.string(), - input: z.record(z.string(), z.unknown()).describe('Mapped inputs for the action'), + connectorId: z.string().describe('Registered connector name'), + actionId: z.string().describe('Action key declared by the connector'), + input: z.record(z.string(), z.unknown()).optional().describe('Mapped inputs for the action'), }).optional(), /** UI Position (for the canvas) */ diff --git a/packages/spec/src/automation/index.ts b/packages/spec/src/automation/index.ts index 518eae4844..2fa96245db 100644 --- a/packages/spec/src/automation/index.ts +++ b/packages/spec/src/automation/index.ts @@ -3,6 +3,7 @@ export * from './flow.zod'; export * from './control-flow.zod'; +export * from './io-node-config.zod'; export { flowForm } from './flow.form'; export * from './execution.zod'; export * from './webhook.zod'; diff --git a/packages/spec/src/automation/io-node-config.zod.ts b/packages/spec/src/automation/io-node-config.zod.ts new file mode 100644 index 0000000000..6cb1e52c3d --- /dev/null +++ b/packages/spec/src/automation/io-node-config.zod.ts @@ -0,0 +1,131 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * @module automation/io-node-config + * + * Config contracts for the flat IO builtins — `notify` and `http` (#4045). + * + * ## Provenance — written from the executors, not from the forms + * + * Each schema here was derived by reading what the executor actually does with + * `node.config` (`service-automation/builtin/notify-node.ts`, `http-nodes.ts`), + * **not** by transcribing the hand-written `configSchema` literal on the node's + * descriptor. That independence is the point: the two artifacts are reconciled + * bidirectionally by `io-node-form-zod-ledger.test.ts` in `service-automation`, + * 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 + * + * 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). + * + * `connector_action` has no schema here on purpose: its config contract is + * empty. The executor reads only the declared `FlowNodeSchema.connectorConfig` + * sibling block — see the descriptor note in + * `service-automation/builtin/connector-nodes.ts`. + */ + +import { z } from 'zod'; +import { lazySchema } from '../shared/lazy-schema'; + +// ─── notify ────────────────────────────────────────────────────────── + +/** + * `notify` node config — what the executor reads (ADR-0012 outbound + * notification via the `messaging` service). + * + * Executor semantics worth knowing beyond the key set: + * + * - `recipients` and `title` are **required at execute time** (the step fails + * without them). The descriptor's form deliberately publishes no `required` + * array — see the comment on the `configSchema` literal — so requiredness + * lives here and in the execute-time guard, not in the form. + * - Every string-ish value except `channels` passes through `interpolate()`, + * so `{record.x}` templates are legal anywhere they are; `channels` is read + * raw (channel ids are static routing, not per-record data). + * - `sourceObject`/`sourceId` only take effect as a PAIR — a half-specified + * click-through target is dropped so the inbox never renders a dead link. + * The schema keeps both optional rather than refining, because the executor + * tolerates (drops) the half-specified shape rather than rejecting it. + * - The historical aliases (`to`/`subject`/`body`/`url`, nested + * `source: { object, id }`) are NOT part of this contract: the ADR-0087 D2 + * conversion `flow-node-notify-config-aliases` rewrites them at load, so the + * executor only ever sees the canonical keys below (#3796, #4045). + */ +export const NotifyConfigSchema = lazySchema(() => z.object({ + /** Who gets the notification — user id(s) / audience selector(s). */ + recipients: z.union([z.string(), z.array(z.string())]) + .describe('Recipient user id(s) / audience selector(s); `{token}` templates resolve per run'), + /** Notification title (execute-time required). */ + title: z.string().describe('Notification title'), + /** Notification body. */ + message: z.string().optional().describe('Notification body'), + /** Channels to fan out to (default: inbox). Read raw — no template interpolation. */ + channels: z.union([z.string(), z.array(z.string())]).optional() + .describe('Channels to fan out to (default: inbox)'), + /** Event topic handed to the messaging service (default: "notify"). */ + topic: z.string().optional().describe('Event topic (default: "notify")'), + /** Severity forwarded to the messaging service. */ + severity: z.string().optional().describe('info | warning | critical'), + /** Click-through target object — only effective together with `sourceId` (#2675). */ + sourceObject: z.string().optional() + .describe('Object name of the record the notification links to (writes sys_notification.source_object). Requires sourceId.'), + /** Click-through target record id — only effective together with `sourceObject`. */ + sourceId: z.string().optional() + .describe('Record id the notification links to (writes sys_notification.source_id). Requires sourceObject.'), + /** User id that caused the event. */ + actorId: z.string().optional().describe('User id that caused the event (writes sys_notification.actor_id)'), + /** Explicit click-through URL; overrides the sourceObject/sourceId link. */ + actionUrl: z.string().optional() + .describe('Explicit click-through URL; overrides the link synthesized from sourceObject/sourceId'), + /** Extra template inputs merged into the notification payload. */ + payload: z.record(z.string(), z.unknown()).optional() + .describe('Extra template inputs merged into the notification payload'), +})); + +export type NotifyConfig = z.input; +export type NotifyConfigParsed = z.infer; + +// ─── http ──────────────────────────────────────────────────────────── + +/** + * `http` node config — what the executor reads (ADR-0018 M3 outbound callout). + * + * Executor semantics worth knowing beyond the key set: + * + * - `url` is **required at execute time** (the step is refused without it) — + * the one key the descriptor's form also marks `required`. + * - The whole config is `interpolate()`d before reading, so every value may + * carry `{token}` templates. + * - `method` defaults per mode — GET inline, POST durable — which is why the + * schema declares no static `.default()`. + * - `durable: true` routes through the messaging HTTP outbox + * (retry / dead-letter) and returns `{ deliveryId }` instead of the + * response; without an outbox it degrades to the inline call. + */ +export const HttpConfigSchema = lazySchema(() => z.object({ + /** Target URL (execute-time required). */ + url: z.string().describe('Target URL'), + /** HTTP method — default GET inline, POST when durable. */ + method: z.string().optional().describe('HTTP method (default GET; POST when durable)'), + /** Request headers. */ + headers: z.record(z.string(), z.string()).optional().describe('Request headers'), + /** Request body — JSON-serialised before sending. */ + body: z.unknown().optional().describe('Request body (JSON-serialised)'), + /** Fire-and-forget via the durable outbox instead of inline request/response. */ + durable: z.boolean().optional() + .describe('Fire-and-forget via the durable outbox (retry/dead-letter) instead of inline request/response'), + /** Per-request timeout in milliseconds (both modes). */ + timeoutMs: z.number().optional().describe('Per-request timeout (ms)'), + /** HMAC-SHA256 signing secret → X-Objectstack-Signature. */ + signingSecret: z.string().optional().describe('HMAC-SHA256 secret → X-Objectstack-Signature'), +})); + +export type HttpConfig = z.input; +export type HttpConfigParsed = z.infer; diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 59c0771a2f..f331f74de5 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -1233,6 +1233,167 @@ const flowNodeWaitEventConfigLift: MetadataConversion = { }, }; +/** The `config` keys a mis-taught `connector_action` node carries, in declared-block order. */ +const CONNECTOR_CONFIG_LIFTS = ['connectorId', 'actionId', 'input'] as const; + +/** + * Lift `connector_action`'s loose `config.*` keys onto the declared + * `connectorConfig` sibling (#4045). + * + * Like `wait`, `connector_action`'s contract does not live in `config` at all — + * it is `FlowNodeSchema.connectorConfig` (`flow.zod.ts`), and the executor reads + * nothing else. Unlike `wait`, the executor never carried a loose-config + * fallback; the wrong spelling was taught by the node's own **descriptor**, + * whose `configSchema` declared `connectorId`/`actionId`/`input` as `config` + * keys — and the Studio inspector derives its property form from a published + * `configSchema` (rooting every field at `config.`), so an author who + * configured a connector node against a live backend produced exactly this + * shape, and a node that then refused to dispatch. The descriptor stops + * publishing that schema in the same change (see connector-nodes.ts); this + * conversion is what makes the flows it mis-taught start working. + * + * Precedence matches every other lift here: a key already on the declared + * block WINS and its loose counterpart is left shadowed in place. + * + * One completeness guard is load-bearing, mirroring `wait`'s `eventType` + * default: the loader parses the CONVERTED flow, and `connectorConfig` requires + * `connectorId` + `actionId` once the block exists. Unlike `eventType` there is + * no defensible default for either, so when lifting cannot complete that pair + * the node is left **untouched** — it keeps failing at run time with the same + * clear refusal it produces today, rather than going from "registers, fails + * the step" to "fails to load". + */ +function liftConnectorConfigShape(stack: Dict, emit: Emit): Dict { + return mapFlowNodes(stack, (node, path) => { + if (node.type !== 'connector_action') return node; + const config = node.config; + if (!isDict(config)) return node; + + const cc: Dict = isDict(node.connectorConfig) ? { ...node.connectorConfig } : {}; + const nextConfig: Dict = { ...config }; + const lifts: Array<(typeof CONNECTOR_CONFIG_LIFTS)[number]> = []; + for (const key of CONNECTOR_CONFIG_LIFTS) { + if (nextConfig[key] == null) continue; + if (cc[key] != null) continue; // declared block wins; the loose key stays shadowed + cc[key] = nextConfig[key]; + delete nextConfig[key]; + lifts.push(key); + } + if (lifts.length === 0) return node; + // Completeness guard — never materialize a block the loader would reject. + if (cc.connectorId == null || cc.actionId == null) return node; + + for (const key of lifts) { + emit({ from: `config.${key}`, to: `connectorConfig.${key}`, path: `${path}.connectorConfig.${key}` }); + } + return { ...node, config: nextConfig, connectorConfig: cc }; + }); +} + +/** + * Connector flow-node loose `config` keys → the declared `connectorConfig` + * sibling (protocol 17, #4045). See {@link liftConnectorConfigShape} for the + * precedence rules and the completeness guard. **Live window**; retires at 18. + */ +const flowNodeConnectorConfigLift: MetadataConversion = { + id: 'flow-node-connector-config-lift', + toMajor: 17, + surface: 'flow.node.connector_action.connectorConfig', + summary: + "connector_action flow-node loose config keys 'connectorId' / 'actionId' / 'input' → " + + 'the declared `connectorConfig` block (#4045)', + apply(stack, emit) { + return liftConnectorConfigShape(stack, emit); + }, + fixture: { + before: { + flows: [ + { + name: 'task_completed_slack', + nodes: [ + { id: 'n1', type: 'start' }, + // The shape the descriptor's configSchema (via the schema-driven + // Studio form) mis-taught: the whole trio under `config`. + { + id: 'n2', + type: 'connector_action', + config: { + connectorId: 'slack', + actionId: 'chat.postMessage', + input: { channel: 'C0WINS000', text: 'Task done: {record.title}' }, + }, + }, + // A declared block WINS: `config.connectorId` stays shadowed in + // place, and since nothing else lifts, the node is untouched. + { + id: 'n3', + type: 'connector_action', + connectorConfig: { connectorId: 'rest', actionId: 'get', input: {} }, + config: { connectorId: 'ignored' }, + }, + // Completeness guard: no actionId anywhere, so lifting would + // create a block the loader rejects — left untouched instead + // (same run-time refusal as today). + { + id: 'n4', + type: 'connector_action', + config: { connectorId: 'slack' }, + }, + // A partial lift may complete an existing block: only `input` + // lifts into the already-complete pair. + { + id: 'n5', + type: 'connector_action', + connectorConfig: { connectorId: 'rest', actionId: 'get' }, + config: { input: { path: '/api/v1/health' } }, + }, + ], + }, + ], + }, + after: { + flows: [ + { + name: 'task_completed_slack', + nodes: [ + { id: 'n1', type: 'start' }, + { + id: 'n2', + type: 'connector_action', + config: {}, + connectorConfig: { + connectorId: 'slack', + actionId: 'chat.postMessage', + input: { channel: 'C0WINS000', text: 'Task done: {record.title}' }, + }, + }, + { + id: 'n3', + type: 'connector_action', + connectorConfig: { connectorId: 'rest', actionId: 'get', input: {} }, + config: { connectorId: 'ignored' }, + }, + { + id: 'n4', + type: 'connector_action', + config: { connectorId: 'slack' }, + }, + { + id: 'n5', + type: 'connector_action', + connectorConfig: { connectorId: 'rest', actionId: 'get', input: { path: '/api/v1/health' } }, + config: {}, + }, + ], + }, + ], + }, + // n2: the full trio lifts. n3: shadowed → no lift, no notice. n4: guard — + // nothing materializes. n5: the single unshadowed `input`. + expectedNotices: 4, + }, +}; + /** * Script flow-node config key aliases → canonical (protocol 17, #3796). * @@ -1867,6 +2028,7 @@ export const CONVERSIONS_BY_MAJOR: Readonly