Skip to content

Commit ce022ba

Browse files
committed
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.<key>, 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UDhMtxPLLoFaGtdpNA7xTU
1 parent d82f8c0 commit ce022ba

21 files changed

Lines changed: 750 additions & 23 deletions
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/service-automation': patch
4+
---
5+
6+
Reconcile the flat IO nodes' declared config against what their executors read
7+
(#4045 — the notify / http / connector step of the declared-vs-read worklist).
8+
9+
**`notify` / `http` gain executor-derived Zod contracts.**
10+
`NotifyConfigSchema` and `HttpConfigSchema` (`automation/io-node-config.zod.ts`)
11+
were written by reading the executors — not by transcribing the descriptors'
12+
hand-written `configSchema` literals — and a new ledger test
13+
(`io-node-form-zod-ledger.test.ts`) compares the two key sets bidirectionally.
14+
Because the sides are independently written, agreement is evidence rather than
15+
tautology: a key survives only if the form offers it AND the executor reads it.
16+
Both nodes reconcile clean, with no deliberately-shallow ledger — their configs
17+
are flat and fully closed. Like the control-flow config Zods, these are contract
18+
exports: no engine path parses with them yet (that is #4045 step 3b, gated on
19+
the #4059 warning data).
20+
21+
**`connector_action`'s mis-rooted `configSchema` is retired — it broke
22+
schema-driven authoring.** The executor reads only the declared
23+
`FlowNodeSchema.connectorConfig` sibling block, but the descriptor published a
24+
`configSchema` declaring `connectorId`/`actionId`/`input` as `config` keys. A
25+
published `configSchema` describes `node.config` by contract, and the Studio
26+
inspector derives its property form from it — rooting every field at
27+
`config.<key>` and replacing the client's hand-written `connectorConfig` form
28+
(with its connector/action pickers). So authoring a connector node against a
29+
live backend wrote the trio where nothing reads it, and the node refused to
30+
dispatch. The descriptor now publishes no `configSchema` (joining `wait`'s
31+
deliberately-schemaless class), which drops the online designer back onto the
32+
correct sibling-block form with no client change.
33+
34+
**Stored flows that carry the mis-taught shape are healed at load.** A new
35+
ADR-0087 D2 conversion, `flow-node-connector-config-lift` (protocol 17, retires
36+
at 18), lifts `config.{connectorId,actionId,input}` onto the declared
37+
`connectorConfig` block — including the `AutomationEngine.registerFlow`
38+
rehydration seam. Declared keys win (the loose counterpart stays shadowed), and
39+
a lift that cannot complete the required `connectorId`+`actionId` pair leaves
40+
the node untouched, so a step-time refusal never becomes a load failure.
41+
42+
**`connectorConfig.input` is now optional**, matching what was always true: the
43+
executor dispatches with `input ?? {}` and the designer's keyValue editor omits
44+
an empty map entirely — so the required `input` declared in the spec turned a
45+
no-input connector action into a `registerFlow` parse failure nothing
46+
downstream asked for.

content/docs/references/automation/flow.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ const result = Flow.parse(data);
9999
| **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. |
100100
| **label** | `string` || Node label |
101101
| **config** | `Record<string, any>` | optional | Node configuration |
102-
| **connectorConfig** | `{ connectorId: string; actionId: string; input: Record<string, any> }` | optional | |
102+
| **connectorConfig** | `{ connectorId: string; actionId: string; input?: Record<string, any> }` | optional | |
103103
| **position** | `{ x: number; y: number }` | optional | |
104104
| **timeoutMs** | `integer` | optional | Maximum execution time for this node in milliseconds |
105105
| **inputSchema** | `Record<string, { type: Enum<'string' \| 'number' \| 'boolean' \| 'object' \| 'array'>; required: boolean; description?: string }>` | optional | Input parameter schema for this node |

content/docs/references/automation/index.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ This section contains all protocol schemas for the automation layer of ObjectSta
1212
<Card href="/docs/references/automation/etl" title="Etl" description="Source: packages/spec/src/automation/etl.zod.ts" />
1313
<Card href="/docs/references/automation/execution" title="Execution" description="Source: packages/spec/src/automation/execution.zod.ts" />
1414
<Card href="/docs/references/automation/flow" title="Flow" description="Source: packages/spec/src/automation/flow.zod.ts" />
15+
<Card href="/docs/references/automation/io-node-config" title="Io Node Config" description="Source: packages/spec/src/automation/io-node-config.zod.ts" />
1516
<Card href="/docs/references/automation/node-executor" title="Node Executor" description="Source: packages/spec/src/automation/node-executor.zod.ts" />
1617
<Card href="/docs/references/automation/state-machine" title="State Machine" description="Source: packages/spec/src/automation/state-machine.zod.ts" />
1718
<Card href="/docs/references/automation/sync" title="Sync" description="Source: packages/spec/src/automation/sync.zod.ts" />
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
---
2+
title: Io Node Config
3+
description: Io Node Config protocol schemas
4+
---
5+
6+
{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
7+
8+
@module automation/io-node-config
9+
10+
Config contracts for the flat IO builtins — `notify` and `http` (#4045).
11+
12+
## Provenance — written from the executors, not from the forms
13+
14+
Each schema here was derived by reading what the executor actually does with
15+
16+
`node.config` (`service-automation/builtin/notify-node.ts`, `http-nodes.ts`),
17+
18+
**not** by transcribing the hand-written `configSchema` literal on the node's
19+
20+
descriptor. That independence is the point: the two artifacts are reconciled
21+
22+
bidirectionally by `io-node-form-zod-ledger.test.ts` in `service-automation`,
23+
24+
and a Zod copied from the form would make that reconciliation a tautology —
25+
26+
it would pass by construction and prove nothing (#4045).
27+
28+
## What these schemas are (and are not) wired to
29+
30+
Like `LoopConfigSchema` / `ParallelConfigSchema` / `TryCatchConfigSchema`,
31+
32+
these are **contract exports**: no engine path `parse()`s a node config with
33+
34+
them today, so registering a flow behaves exactly as before. Wiring the
35+
36+
executors to parse — which would finally give node configs the type /
37+
38+
`required` / unknown-key enforcement the descriptor `configSchema` never
39+
40+
provided — is deliberately deferred until the #4059 undeclared-key warning
41+
42+
has measured a release's worth of real metadata (#4045 step 3b).
43+
44+
`connector_action` has no schema here on purpose: its config contract is
45+
46+
empty. The executor reads only the declared `FlowNodeSchema.connectorConfig`
47+
48+
sibling block — see the descriptor note in
49+
50+
`service-automation/builtin/connector-nodes.ts`.
51+
52+
<Callout type="info">
53+
**Source:** `packages/spec/src/automation/io-node-config.zod.ts`
54+
</Callout>
55+
56+
## TypeScript Usage
57+
58+
```typescript
59+
import { HttpConfig, NotifyConfig } from '@objectstack/spec/automation';
60+
import type { HttpConfig, NotifyConfig } from '@objectstack/spec/automation';
61+
62+
// Validate data
63+
const result = HttpConfig.parse(data);
64+
```
65+
66+
---
67+
68+
## HttpConfig
69+
70+
### Properties
71+
72+
| Property | Type | Required | Description |
73+
| :--- | :--- | :--- | :--- |
74+
| **url** | `string` || Target URL |
75+
| **method** | `string` | optional | HTTP method (default GET; POST when durable) |
76+
| **headers** | `Record<string, string>` | optional | Request headers |
77+
| **body** | `any` | optional | Request body (JSON-serialised) |
78+
| **durable** | `boolean` | optional | Fire-and-forget via the durable outbox (retry/dead-letter) instead of inline request/response |
79+
| **timeoutMs** | `number` | optional | Per-request timeout (ms) |
80+
| **signingSecret** | `string` | optional | HMAC-SHA256 secret → X-Objectstack-Signature |
81+
82+
83+
---
84+
85+
## NotifyConfig
86+
87+
### Properties
88+
89+
| Property | Type | Required | Description |
90+
| :--- | :--- | :--- | :--- |
91+
| **recipients** | `string \| string[]` || Recipient user id(s) / audience selector(s); `{token}` templates resolve per run |
92+
| **title** | `string` || Notification title |
93+
| **message** | `string` | optional | Notification body |
94+
| **channels** | `string \| string[]` | optional | Channels to fan out to (default: inbox) |
95+
| **topic** | `string` | optional | Event topic (default: "notify") |
96+
| **severity** | `string` | optional | info \| warning \| critical |
97+
| **sourceObject** | `string` | optional | Object name of the record the notification links to (writes sys_notification.source_object). Requires sourceId. |
98+
| **sourceId** | `string` | optional | Record id the notification links to (writes sys_notification.source_id). Requires sourceObject. |
99+
| **actorId** | `string` | optional | User id that caused the event (writes sys_notification.actor_id) |
100+
| **actionUrl** | `string` | optional | Explicit click-through URL; overrides the link synthesized from sourceObject/sourceId |
101+
| **payload** | `Record<string, any>` | optional | Extra template inputs merged into the notification payload |
102+
103+
104+
---
105+

content/docs/references/automation/meta.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
"webhook",
1919
"---Approvals & Jobs---",
2020
"approval",
21-
"job"
21+
"job",
22+
"---More---",
23+
"io-node-config"
2224
]
2325
}

docs/protocol-upgrade-guide.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,8 @@ Beyond those spec-surface removals, it graduates the seven flow-node config key
126126

127127
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.
128128

129+
`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.
130+
129131
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.
130132

131133
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
149151
| `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 |
150152
| `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 |
151153
| `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 |
154+
| `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 |
152155
| `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 |
153156
| `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 |
154157
| `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 |

packages/services/service-automation/src/builtin/config-schemas.test.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,10 @@
1313
* Deliberately schemaless (stay on the hardcoded designer form; a node with no
1414
* configSchema has NO online/offline divergence): `decision` (virtual Target
1515
* column derived from edges), `wait` (top-level `waitEventConfig` block),
16-
* `script` (actionType-conditional form) and `subflow` (top-level `timeoutMs`)
17-
* — a partial schema would drop those editors.
16+
* `script` (actionType-conditional form), `subflow` (top-level `timeoutMs`)
17+
* and `connector_action` (top-level `connectorConfig` block, #4045) — a
18+
* partial schema would drop those editors, and a schema rooted at `config`
19+
* would actively re-route sibling-block authoring to keys nothing reads.
1820
*/
1921

2022
import { describe, it, expect } from 'vitest';
@@ -26,6 +28,7 @@ import { registerScreenNodes } from './screen-nodes.js';
2628
import { registerLoopNode } from './loop-node.js';
2729
import { registerParallelNode } from './parallel-node.js';
2830
import { registerTryCatchNode } from './try-catch-node.js';
31+
import { registerConnectorNodes } from './connector-nodes.js';
2932

3033
function silentLogger() {
3134
return { info() {}, warn() {}, error() {}, debug() {}, child() { return silentLogger(); } } as any;
@@ -197,12 +200,23 @@ describe('builtin node configSchemas — designer parity (#3304)', () => {
197200
});
198201
});
199202

200-
it('decision / script stay deliberately schemaless (no partial forms)', () => {
203+
it('decision / script / connector_action stay deliberately schemaless (no partial forms)', () => {
201204
// A node with no configSchema renders identically online and offline (the
202205
// hardcoded fallback), so there is no divergence — and publishing a partial
203206
// schema would DROP editors the adapter cannot express (decision's virtual
204207
// Target column; script's actionType-conditional fields).
205208
expect(engine.getActionDescriptor('decision')?.configSchema).toBeUndefined();
206209
expect(engine.getActionDescriptor('script')?.configSchema).toBeUndefined();
210+
211+
// connector_action is the load-bearing member of this class (#4045): its
212+
// contract is the top-level `connectorConfig` block, and the schema it
213+
// used to publish declared that trio as `config` keys — which the
214+
// schema-driven online form then wrote to `config.*`, replacing the
215+
// hand-written connectorConfig form and producing nodes the executor
216+
// refuses. Schemaless keeps the designer on the correct sibling-block
217+
// form. Guarded here so the schema cannot quietly come back.
218+
const engine3 = new AutomationEngine(silentLogger());
219+
registerConnectorNodes(engine3, ctx());
220+
expect(engine3.getActionDescriptor('connector_action')?.configSchema).toBeUndefined();
207221
});
208222
});

packages/services/service-automation/src/builtin/connector-nodes.test.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,84 @@ describe('connector_action (baseline node)', () => {
123123
expect(result.error).toContain('ghost.noop');
124124
});
125125

126+
// #4045 — this proves the CONVERSION, not executor tolerance. The executor
127+
// reads only the declared `connectorConfig` block; the config-authored trio
128+
// reaches it because `registerFlow` applies
129+
// `flow-node-connector-config-lift`, which lifts it. This is the exact
130+
// shape the descriptor's former configSchema mis-taught the schema-driven
131+
// Studio form to write.
132+
it('accepts the mis-taught config.{connectorId,actionId,input} shape via the lift', async () => {
133+
let received: Record<string, unknown> | undefined;
134+
engine.registerConnector(fakeConnector(), {
135+
async echo(input) {
136+
received = input;
137+
return { echoed: input.message };
138+
},
139+
});
140+
141+
engine.registerFlow('lifted_flow', {
142+
name: 'lifted_flow',
143+
label: 'Lifted Flow',
144+
type: 'autolaunched',
145+
nodes: [
146+
{ id: 'start', type: 'start', label: 'Start' },
147+
{
148+
id: 'call',
149+
type: 'connector_action',
150+
label: 'Config-authored',
151+
config: { connectorId: 'fake', actionId: 'echo', input: { message: 'hi' } },
152+
},
153+
{ id: 'end', type: 'end', label: 'End' },
154+
],
155+
edges: [
156+
{ id: 'e1', source: 'start', target: 'call' },
157+
{ id: 'e2', source: 'call', target: 'end' },
158+
],
159+
});
160+
161+
const result = await engine.execute('lifted_flow');
162+
expect(result.success).toBe(true);
163+
expect(received).toEqual({ message: 'hi' });
164+
});
165+
166+
// #4045 — `input` is optional in the spec block because the executor
167+
// dispatches with `input ?? {}` and the designer's keyValue editor omits an
168+
// empty map entirely. Under the old required `input`, this exact designer
169+
// output failed FlowSchema.parse at registerFlow.
170+
it('registers and dispatches a connectorConfig with no input (empty map)', async () => {
171+
let received: Record<string, unknown> | undefined;
172+
engine.registerConnector(fakeConnector(), {
173+
async echo(input) {
174+
received = input;
175+
return { ok: true };
176+
},
177+
});
178+
179+
engine.registerFlow('no_input_flow', {
180+
name: 'no_input_flow',
181+
label: 'No Input Flow',
182+
type: 'autolaunched',
183+
nodes: [
184+
{ id: 'start', type: 'start', label: 'Start' },
185+
{
186+
id: 'call',
187+
type: 'connector_action',
188+
label: 'No Input',
189+
connectorConfig: { connectorId: 'fake', actionId: 'echo' },
190+
},
191+
{ id: 'end', type: 'end', label: 'End' },
192+
],
193+
edges: [
194+
{ id: 'e1', source: 'start', target: 'call' },
195+
{ id: 'e2', source: 'call', target: 'end' },
196+
],
197+
});
198+
199+
const result = await engine.execute('no_input_flow');
200+
expect(result.success).toBe(true);
201+
expect(received).toEqual({});
202+
});
203+
126204
it('fails the step when connectorConfig is missing required fields', async () => {
127205
engine.registerFlow('bad_config', {
128206
name: 'bad_config',

0 commit comments

Comments
 (0)