Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions .changeset/20418-connector-action-config-required.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
---
'@objectstack/spec': minor
---

fix(spec)!: a `connector_action` flow node its executor cannot dispatch — no `connectorConfig` block, or an empty `connectorId` / `actionId` — is refused at authoring (#20418)

Clause-②: no (narrowing)

<!-- adr-0087: registered connector-action-config-required -->

**BREAKING** — an accept-set narrowing on authored `connector_action` flow nodes, shipped
as `minor` under the launch-window convention (`check-changeset-no-major` refuses `major`
until GA; breaking-ness is carried by this banner and the ADR-0087 disposition above, not by
the level).

**What changed.** A `connector_action` node's contract is its sibling `connectorConfig`
block — the executor reads nothing else, and refuses the node when `connectorId` or
`actionId` is empty. The block was optional on the node and both ids were any string inside
it, so `FlowSchema.parse`, `AutomationEngine.registerFlow` and `objectstack validate` all
admitted a node with no block, or with an empty id, and every run that reached the node then
failed at the executor's guard. The flow parse now refuses what that read refuses, at any
depth including an ADR-0031 region body, and `registerFlow` and `objectstack validate` meet
the refusal through that parse:

- **No `connectorConfig` block** — a `custom` issue at `nodes.N.connectorConfig`, whose
message prescribes the block and says that keys left under `config` are not read.
- **`connectorId` or `actionId` empty, or only whitespace** — a `custom` issue at
`nodes.N.connectorConfig.connectorId` / `.actionId`. Whitespace is refused with the empty
string (the spec's one notion of blank): a connector `name` is a snake_case identifier, so
it names nothing a dispatch can reach.

The rule is judged in the flow walk, not by `FlowNodeSchema` alone, so a node nested in a
`loop` / `parallel` / `try_catch` body is refused at the path the author wrote
(`nodes.N.config.body.nodes.M.connectorConfig`). `FlowNodeSchema.parse` of a lone node is
unchanged.

The Studio flow designer seeds a new connector node with `connectorId: ''` and
`actionId: ''`, so a connector node added and saved before it is configured is now refused
at save. Where such a node already sits, the whole flow is refused: registered from the
metadata registry or `sys_metadata` at boot, it is skipped with a `failed to register flow`
warn naming it while the flows beside it register; a `defineStack({ flows })` source throws
`StackSchemaInvalidError` for the whole stack; an artifact file is refused whole at load.

The `node-config-key-missing` refusal (`FLOW_SLOT_REFUSAL_CODES`) now describes the old
behaviour in the past tense — "the flow used to register, and then every run that reached
this node failed there" — because the doors that message is shown at refuse the flow.

## FROM → TO

| you wrote | write instead |
|:--|:--|
| `{ type: 'connector_action', label: 'Post' }` | the connector and the action it dispatches — `connectorConfig: { connectorId: 'slack', actionId: 'chat.postMessage', input: { channel: 'C0WINS000', text: 'Done' } }` |
| `connectorConfig: { connectorId: '', actionId: '' }` | the registered connector's `name` and one of its action keys — `{ connectorId: 'rest', actionId: 'request' }` |
| `config: { connectorId: 'slack' }` (no `actionId`, no block) | the complete pair in the block — `connectorConfig: { connectorId: 'slack', actionId: 'chat.postMessage' }` |

**One-line fix:** write the `connectorConfig` block the node dispatches by, or delete a
connector node you cannot configure yet — there is no placeholder connector.

**Unchanged.** A connector node carrying a complete block parses, registers and dispatches
as before, and `input` stays optional. A complete `connectorId` / `actionId` / `input` trio
written under `config` is still lifted into the block before `registerFlow` and
`objectstack validate` judge it (the `flow-node-connector-config-lift` conversion). Other
node types are not asked for a `connectorConfig`.
6 changes: 3 additions & 3 deletions content/docs/automation/flows.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -139,12 +139,12 @@ Each node performs a specific action in the flow.
| `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 once that registry is complete — plugins contribute node types while they start, so flows registered during boot are checked in one pass when the vocabulary closes (all plugins started), and anything registered after that (Studio publish, dev reload) is checked immediately. Unknown types warn, never reject; executing one fails with `NO_EXECUTOR` |
| `label` | `string` | ✅ | Display label |
| `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 |
| `connectorConfig` | `object` | ✅ on `connector_action` | `{ connectorId, actionId, input }` — the only input a `connector_action` node's executor reads. `FlowSchema` refuses a `connector_action` node without it, and one whose `connectorId` or `actionId` is blank (empty or whitespace only), at any depth including a region body. `connectorId` is the registered connector's `name`, `actionId` one of the action keys it declares; `input` is optional |
| `position` | `{ x, y }` | optional | Visual position on canvas |
| `timeoutMs` | `number` | optional | Per-node execution timeout |
| `inputSchema` | `object` | optional | Declared input parameter types, for Studio form generation and runtime validation |
| `waitEventConfig` | `object` | optional | `wait`-node event descriptor (`eventType`, `timerDuration`, `signalName`). `timeoutMs` / `onTimeout` were removed in 17 (#4158) — `wait` has no timeout; `timerDuration` accepts a bare number as milliseconds |
| `boundaryConfig` | `object` | optional | BPMN boundary-event descriptor (interop) |
| `waitEventConfig` | `object` | ✅ on `wait` | `wait`-node event descriptor (`eventType`, `timerDuration`, `signalName`). `FlowSchema` refuses a `wait` node without it; `eventType` has no default, and `eventType: 'timer'` requires a non-blank `timerDuration`. `timeoutMs` / `onTimeout` were removed in 17 (#4158) — `wait` has no timeout; `timerDuration` is a string, and a quoted bare number (`'60000'`) is read as milliseconds |
| `boundaryConfig` | `object` | ✅ on `boundary_event` | BPMN boundary-event descriptor (interop). `FlowSchema` refuses a `boundary_event` node without it |

<Callout type="info">
The flow, node, edge, and variable **shells are `.strict()`** — a key they do not
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -201,25 +201,72 @@ describe('connector_action (baseline node)', () => {
expect(received).toEqual({});
});

it('fails the step when connectorConfig is missing required fields', async () => {
engine.registerFlow('bad_config', {
name: 'bad_config',
label: 'Bad Config',
type: 'autolaunched',
/**
* #20418 — `registerFlow`, the second of the three doors, refuses a node
* this executor cannot dispatch: it parses first (`FlowSchema`), and the
* flow parse judges the `connectorConfig` block the way this executor
* reads it. Before, each of these shapes REGISTERED and then failed every
* run at the guard below — the last test in this block is that ground.
*/
function unconfiguredFlow(name: string, call: Record<string, unknown>) {
return {
name,
label: name,
type: 'autolaunched' as const,
nodes: [
{ id: 'start', type: 'start', label: 'Start' },
{ id: 'call', type: 'connector_action', label: 'No Config' },
{ id: 'call', type: 'connector_action', label: 'Call', ...call },
{ id: 'end', type: 'end', label: 'End' },
],
edges: [
{ id: 'e1', source: 'start', target: 'call' },
{ id: 'e2', source: 'call', target: 'end' },
],
});
};
}

/** The issues `registerFlow` threw, as `[code, path]`, or `undefined` when it registered. */
function refusalOf(flow: { name: string }): Array<[string, unknown[]]> | undefined {
try {
engine.registerFlow(flow.name, flow as never);
return undefined;
} catch (e) {
return ((e as { issues?: Array<{ code: string; path: unknown[] }> }).issues ?? [])
.map((i) => [i.code, i.path] as [string, unknown[]]);
}
}

it('registerFlow refuses a node with no connectorConfig block, naming the block', async () => {
expect(refusalOf(unconfiguredFlow('no_block', {}))).toEqual([['custom', ['nodes', 1, 'connectorConfig']]]);
expect(await engine.listFlows()).not.toContain('no_block');
});

it('registerFlow refuses the designer seed — both ids blank — naming each key', () => {
expect(refusalOf(unconfiguredFlow('blank_ids', { connectorConfig: { connectorId: '', actionId: '', input: {} } })))
.toEqual([
['custom', ['nodes', 1, 'connectorConfig', 'connectorId']],
['custom', ['nodes', 1, 'connectorConfig', 'actionId']],
]);
});

it('CONTROL — the same node with its block registers', () => {
expect(refusalOf(unconfiguredFlow('configured', { connectorConfig: { connectorId: 'fake', actionId: 'echo' } })))
.toBeUndefined();
});

it('what the refused shape did at run time: the step failed at the guard, every run', async () => {
// It can no longer register, so the run registers the block whole and
// deletes it from the stored node — the shape this executor meets when
// a node reaches it past the doors.
const stored = engine.registerFlow(
'stripped',
unconfiguredFlow('stripped', { connectorConfig: { connectorId: 'fake', actionId: 'echo' } }) as never,
);
delete (stored.nodes[1] as { connectorConfig?: unknown }).connectorConfig;

const result = await engine.execute('bad_config');
const result = await engine.execute('stripped');
expect(result.success).toBe(false);
expect(result.error).toContain('connectorId');
expect(result.error).toContain("connector_action 'call': connectorConfig.connectorId and .actionId are required");
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,13 @@ function flowWithHandler(name: string, node: Record<string, unknown>) {
* inventory classifies, so those rows register the node WHOLE and remove the
* key from the stored flow before the run: the shape an executor meets when a
* config reaches it past the doors.
*
* `stripBlock` (#20418): the same move for a node whose contract is a SIBLING
* block rather than `config` — a `connector_action` with no `connectorConfig`
* is refused at the build doors too, so its row registers the block whole and
* removes it from the stored node before the run.
*/
const GUARDS: Array<{ name: string; why: string; node: Record<string, unknown>; expect: string; strip?: string }> = [
const GUARDS: Array<{ name: string; why: string; node: Record<string, unknown>; expect: string; strip?: string; stripBlock?: 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
Expand Down Expand Up @@ -168,7 +173,8 @@ const GUARDS: Array<{ name: string; why: string; node: Record<string, unknown>;
{
name: 'connector_action without connectorId/actionId',
why: 'required config keys',
node: { type: 'connector_action', config: {} },
node: { type: 'connector_action', connectorConfig: { connectorId: 'crm', actionId: 'push' } },
stripBlock: 'connectorConfig',
expect: 'are required',
},
{
Expand Down Expand Up @@ -208,7 +214,7 @@ describe('#3863 — the guard inventory stays un-routable', () => {

it.each(GUARDS.map((g, i) => ({ ...g, i })))(
'$name stays fatal with a fault edge ($why)',
async ({ node, expect: fragment, i, strip }) => {
async ({ node, expect: fragment, i, strip, stripBlock }) => {
let handlerRan = false;
engine.registerNodeExecutor({
type: 'script',
Expand All @@ -220,6 +226,7 @@ describe('#3863 — the guard inventory stays un-routable', () => {
const flowName = `guard_case_${i}`;
const stored = engine.registerFlow(flowName, flowWithHandler(flowName, node) as any);
if (strip) delete (stored.nodes.find((n) => n.id === 'op')!.config as Record<string, unknown>)[strip];
if (stripBlock) delete (stored.nodes.find((n) => n.id === 'op') as unknown as Record<string, unknown>)[stripBlock];

const result = await engine.execute(flowName, { record: { id: 'r1', owner: 'usr_7' } } as any);

Expand Down
126 changes: 126 additions & 0 deletions packages/spec/src/automation/connector-action-config-required.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #20418 — a `connector_action` node its executor cannot dispatch is refused at
* `FlowSchema.parse`, the first of the three doors.
*
* The node's contract is its SIBLING block `connectorConfig`, and the executor
* reads nothing else: `if (!cfg?.connectorId || !cfg?.actionId)` refuses the
* node. Measured before the change, all three build doors admitted what that
* read refuses — the block absent, or an id present and blank (the Studio
* designer's seed for a new node) — at the top level and inside a region body,
* and every run then failed at the node.
*
* Refused rows assert the issue `code` and the exact `path`, and that the
* message names the block or the key it refuses — never the prose around it.
* `registerFlow` meets the same refusal through this parse
* (`service-automation`'s `connector-nodes.test.ts`).
*/

import { describe, expect, it } from 'vitest';

import { FlowNodeSchema, FlowSchema } from './flow.zod';

type Node = Record<string, unknown>;

/** start → <middle nodes> → end, chained by unconditional edges. */
function flowWith(...middle: Node[]) {
const nodes: Node[] = [{ id: 'start', type: 'start', label: 'Start' }, ...middle, { id: 'end', type: 'end', label: 'End' }];
const edges = nodes.slice(1).map((n, i) => ({ id: `e${i}`, source: String(nodes[i].id), target: String(n.id) }));
return { name: 'connector_probe', label: 'Connector probe', type: 'autolaunched', nodes, edges };
}

const connector = (extra: Node = {}): Node => ({ id: 'call', type: 'connector_action', label: 'Call', ...extra });
const COMPLETE = { connectorId: 'slack', actionId: 'chat.postMessage', input: { channel: 'C1', text: 'Hi' } };

/** A `loop` whose body holds `inner` — the ADR-0031 region the walk must reach. */
const loopAround = (inner: Node): Node => ({
id: 'each', type: 'loop', label: 'Each',
config: { collection: [1], iteratorVariable: 'item', body: { nodes: [inner], edges: [] } },
});

function issuesOf(flow: unknown) {
const result = FlowSchema.safeParse(flow);
return result.success ? [] : result.error.issues.map((i) => ({ code: i.code, path: i.path, message: i.message }));
}

describe('FlowSchema refuses a connector_action node with no connectorConfig block', () => {
it('top level: one `custom` issue at the block, prescribing it', () => {
const issues = issuesOf(flowWith(connector()));
expect(issues.map((i) => [i.code, i.path])).toEqual([['custom', ['nodes', 1, 'connectorConfig']]]);
expect(issues[0].message).toContain('requires a `connectorConfig` block');
});

it('inside a region body: refused at the path the author wrote', () => {
const issues = issuesOf(flowWith(loopAround(connector())));
expect(issues.map((i) => [i.code, i.path])).toEqual([
['custom', ['nodes', 1, 'config', 'body', 'nodes', 0, 'connectorConfig']],
]);
});

it('the keys written under `config` instead: refused at the block, and told to move them', () => {
// A direct parse meets the pre-conversion spelling, like every other
// tombstone; `registerFlow` and `objectstack validate` convert a complete
// pair into the block first (the `flow-node-connector-config-lift` D2 entry).
const issues = issuesOf(flowWith(connector({ config: { connectorId: 'slack', actionId: 'chat.postMessage' } })));
expect(issues.map((i) => [i.code, i.path])).toEqual([['custom', ['nodes', 1, 'connectorConfig']]]);
expect(issues[0].message).toContain('from `config` into the block');
});
});

describe('FlowSchema refuses a blank connectorId / actionId', () => {
it('the designer seed (both ids empty): one issue per key', () => {
const issues = issuesOf(flowWith(connector({ connectorConfig: { connectorId: '', actionId: '', input: {} } })));
expect(issues.map((i) => [i.code, i.path])).toEqual([
['custom', ['nodes', 1, 'connectorConfig', 'connectorId']],
['custom', ['nodes', 1, 'connectorConfig', 'actionId']],
]);
expect(issues[0].message).toContain('`connectorConfig.connectorId` holds a string that is blank');
expect(issues[1].message).toContain('`connectorConfig.actionId` holds a string that is blank');
});

it.each([
['a whitespace-only connectorId', { connectorId: ' \t', actionId: 'chat.postMessage' }, 'connectorId'],
['an empty actionId', { connectorId: 'slack', actionId: '' }, 'actionId'],
])('%s: refused at that key only', (_name, block, key) => {
const issues = issuesOf(flowWith(connector({ connectorConfig: block })));
expect(issues.map((i) => [i.code, i.path])).toEqual([['custom', ['nodes', 1, 'connectorConfig', key]]]);
});

it('inside a region body: refused at the path the author wrote', () => {
const issues = issuesOf(flowWith(loopAround(connector({ connectorConfig: { connectorId: 'slack', actionId: '' } }))));
expect(issues.map((i) => [i.code, i.path])).toEqual([
['custom', ['nodes', 1, 'config', 'body', 'nodes', 0, 'connectorConfig', 'actionId']],
]);
});
});

describe('what is NOT refused by this rule', () => {
it('CONTROL — a complete block parses, at the top level and in a region body, with and without `input`', () => {
expect(issuesOf(flowWith(connector({ connectorConfig: COMPLETE })))).toEqual([]);
expect(issuesOf(flowWith(connector({ connectorConfig: { connectorId: 'rest', actionId: 'request' } })))).toEqual([]);
expect(issuesOf(flowWith(loopAround(connector({ connectorConfig: COMPLETE }))))).toEqual([]);
});

it('CONTROL — another node type carries no connectorConfig and is not asked for one', () => {
expect(issuesOf(flowWith({ id: 'n', type: 'assignment', label: 'N' }))).toEqual([]);
});

it('a block the node shape already refuses is not reported a second time', () => {
// `{}` and a non-string id fail the block's own shape; this rule judges
// strings only, so the author sees one issue per key, not two.
expect(issuesOf(flowWith(connector({ connectorConfig: {} }))).map((i) => [i.code, i.path])).toEqual([
['invalid_type', ['nodes', 1, 'connectorConfig', 'connectorId']],
['invalid_type', ['nodes', 1, 'connectorConfig', 'actionId']],
]);
expect(issuesOf(flowWith(connector({ connectorConfig: { connectorId: 5, actionId: 'a' } }))).map((i) => i.code))
.toEqual(['invalid_type']);
});

it('FlowNodeSchema alone still parses the designer seed — the refusal is the FLOW\'s', () => {
// The node contract a designer seed is held to on its own stays structural;
// the flow it is saved into is what gets refused (see the second describe).
const seed = connector({ connectorConfig: { connectorId: '', actionId: '', input: {} } });
expect(FlowNodeSchema.safeParse(seed).success).toBe(true);
});
});
Loading
Loading