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
46 changes: 46 additions & 0 deletions .changeset/io-node-config-reconciliation.md
Original file line number Diff line number Diff line change
@@ -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.<key>` 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.
2 changes: 1 addition & 1 deletion content/docs/references/automation/flow.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>` | optional | Node configuration |
| **connectorConfig** | `{ connectorId: string; actionId: string; input: Record<string, any> }` | optional | |
| **connectorConfig** | `{ connectorId: string; actionId: string; input?: Record<string, any> }` | optional | |
| **position** | `{ x: number; y: number }` | optional | |
| **timeoutMs** | `integer` | optional | Maximum execution time for this node in milliseconds |
| **inputSchema** | `Record<string, { type: Enum<'string' \| 'number' \| 'boolean' \| 'object' \| 'array'>; required: boolean; description?: string }>` | optional | Input parameter schema for this node |
Expand Down
1 change: 1 addition & 0 deletions content/docs/references/automation/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ This section contains all protocol schemas for the automation layer of ObjectSta
<Card href="/docs/references/automation/etl" title="Etl" description="Source: packages/spec/src/automation/etl.zod.ts" />
<Card href="/docs/references/automation/execution" title="Execution" description="Source: packages/spec/src/automation/execution.zod.ts" />
<Card href="/docs/references/automation/flow" title="Flow" description="Source: packages/spec/src/automation/flow.zod.ts" />
<Card href="/docs/references/automation/io-node-config" title="Io Node Config" description="Source: packages/spec/src/automation/io-node-config.zod.ts" />
<Card href="/docs/references/automation/node-executor" title="Node Executor" description="Source: packages/spec/src/automation/node-executor.zod.ts" />
<Card href="/docs/references/automation/state-machine" title="State Machine" description="Source: packages/spec/src/automation/state-machine.zod.ts" />
<Card href="/docs/references/automation/sync" title="Sync" description="Source: packages/spec/src/automation/sync.zod.ts" />
Expand Down
105 changes: 105 additions & 0 deletions content/docs/references/automation/io-node-config.mdx
Original file line number Diff line number Diff line change
@@ -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`.

<Callout type="info">
**Source:** `packages/spec/src/automation/io-node-config.zod.ts`
</Callout>

## 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<string, string>` | 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<string, any>` | optional | Extra template inputs merged into the notification payload |


---

4 changes: 3 additions & 1 deletion content/docs/references/automation/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
"webhook",
"---Approvals & Jobs---",
"approval",
"job"
"job",
"---More---",
"io-node-config"
]
}
3 changes: 3 additions & 0 deletions docs/protocol-upgrade-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> | 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<string, unknown> | 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',
Expand Down
Loading
Loading