Skip to content

Commit 667b83e

Browse files
authored
feat(spec,automation): publish executor-derived config contracts for the schemaless flow nodes (#4278) (#4325)
The descriptor-schemaless builtins (decision/script/subflow/wait/ connector_action) deliberately publish no configSchema, so their Studio forms live only in objectui's hand-written FLOW_NODE_CONFIG table — and nothing reconciled that table against the executors. `script` had drifted: the form offered an `outputVariables` key nothing reads, two actionType options that fail every run, a no-op default, and could not author the function path that works. - New automation/schemaless-node-config.zod.ts: ScriptConfigSchema / SubflowConfigSchema / DecisionConfigSchema (+ DecisionConditionSchema), written from the executors. Contract exports only — unlike their builtin-node-config siblings (wired into execute-time parsing by #4277), a flat parse cannot describe them: script's legal key set depends on actionType and decision may branch purely on edge predicates. Their enforcement is the objectui reconciliation test (objectui#3099). - SCRIPT_BUILTIN_ACTION_TYPES + SCRIPT_INVOKE_FUNCTION_ACTION_TYPE published; the script executor now builds its dispatch set from the constant, so the designer's options, the dispatch set, and the "not a built-in action" failure message cannot disagree. - subflow's undeclared `flow` alias graduates into the ADR-0087 D2 conversion flow-node-subflow-flow-alias (protocol 17 live window); the bare `cfg.flowName ?? cfg.flow` executor fallback is deleted (PD #12). - decision audited: the form matches the executor; the Target column is a virtual edge-derived column and the legacy singular `condition` is unread. Closes #4278
1 parent ade7be4 commit 667b83e

21 files changed

Lines changed: 642 additions & 14 deletions
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/service-automation": patch
4+
---
5+
6+
feat(spec,automation): publish executor-derived config contracts for the schemaless flow nodes (#4278)
7+
8+
The five descriptor-schemaless builtins (`decision` / `script` / `subflow` /
9+
`wait` / `connector_action`) deliberately publish no `configSchema`, so their
10+
Studio form lives only in objectui's hand-written `FLOW_NODE_CONFIG` table —
11+
and nothing reconciled that table against the executors. `script` had drifted:
12+
the form offered an `outputVariables` key nothing reads, two `actionType`
13+
options (`sms` / `notification`) that fail every run, a no-op default (`code`),
14+
and could not author the `function` / `inputs` / `outputVariable` path that
15+
works.
16+
17+
New in `@objectstack/spec/automation` — contract exports only. Unlike their
18+
`builtin-node-config.zod.ts` siblings, which #4277 wired into execute-time
19+
parsing, no engine path `parse()`s node config with these: `script`'s legal key
20+
set depends on `actionType` and `decision` may branch purely on edge
21+
predicates, so a flat parse would either reject valid shapes or check nothing.
22+
Their enforcement is the objectui reconciliation test.
23+
24+
- `ScriptConfigSchema` / `SubflowConfigSchema` / `DecisionConfigSchema` (+
25+
`DecisionConditionSchema`) — written from the executors in
26+
`service-automation`, the machine-readable half of the cross-repo
27+
reconciliation objectui's `flow-node-config` test now performs. `wait` and
28+
`connector_action` need no new schema — their contracts are the existing
29+
`FlowNodeSchema` sibling blocks (`waitEventConfig` / `connectorConfig`).
30+
- `SCRIPT_BUILTIN_ACTION_TYPES` (`['email', 'slack']`) and
31+
`SCRIPT_INVOKE_FUNCTION_ACTION_TYPE` (`'invoke_function'`) — the `script`
32+
executor now builds its dispatch set from the published constant, so the
33+
designer's options, the dispatch set, and the "not a built-in action"
34+
failure message can no longer disagree.
35+
36+
Undeclared-alias graduation in the same change (Prime Directive #12, the
37+
`map.flow` path): the `subflow` executor's bare `cfg.flowName ?? cfg.flow`
38+
fallback is deleted, replaced by the ADR-0087 D2 conversion
39+
`flow-node-subflow-flow-alias` — a stored `subflow` node authored with
40+
`config.flow` is rewritten to the canonical `config.flowName` at load
41+
(including the `AutomationEngine.registerFlow` rehydration seam). FROM
42+
`config.flow` TO `config.flowName`; one-line fix for hand-maintained sources:
43+
rename the key.

content/docs/references/automation/builtin-node-config.mdx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,9 +71,19 @@ reason instead of pretending a shape.
7171

7272
- `decision` / `script` / `subflow` / `wait` / `connector_action` — the
7373

74-
deliberately-schemaless class (config-schemas.test.ts); their contracts
74+
descriptor-schemaless class (config-schemas.test.ts). `wait` and
7575

76-
live elsewhere (edges, sibling blocks, conditional forms).
76+
`connector_action` keep their contracts in FlowNodeSchema's sibling
77+
78+
blocks (`waitEventConfig` / `connectorConfig`); the other three publish
79+
80+
executor-derived config contracts in `schemaless-node-config.zod.ts`
81+
82+
(#4278) — separate from this module because they must NOT grow into
83+
84+
descriptor `configSchema`s (the forms they describe stay hand-written in
85+
86+
objectui, reconciled by a test there).
7787

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

content/docs/references/automation/index.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ This section contains all protocol schemas for the automation layer of ObjectSta
1515
<Card href="/docs/references/automation/flow" title="Flow" description="Source: packages/spec/src/automation/flow.zod.ts" />
1616
<Card href="/docs/references/automation/io-node-config" title="Io Node Config" description="Source: packages/spec/src/automation/io-node-config.zod.ts" />
1717
<Card href="/docs/references/automation/node-executor" title="Node Executor" description="Source: packages/spec/src/automation/node-executor.zod.ts" />
18+
<Card href="/docs/references/automation/schemaless-node-config" title="Schemaless Node Config" description="Source: packages/spec/src/automation/schemaless-node-config.zod.ts" />
1819
<Card href="/docs/references/automation/state-machine" title="State Machine" description="Source: packages/spec/src/automation/state-machine.zod.ts" />
1920
<Card href="/docs/references/automation/sync" title="Sync" description="Source: packages/spec/src/automation/sync.zod.ts" />
2021
<Card href="/docs/references/automation/time-relative-trigger" title="Time Relative Trigger" description="Source: packages/spec/src/automation/time-relative-trigger.zod.ts" />

content/docs/references/automation/meta.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"job",
2222
"---More---",
2323
"builtin-node-config",
24-
"io-node-config"
24+
"io-node-config",
25+
"schemaless-node-config"
2526
]
2627
}
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
---
2+
title: Schemaless Node Config
3+
description: Schemaless 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/schemaless-node-config
9+
10+
Config contracts for the **descriptor-schemaless** builtins whose designer
11+
12+
form lives ONLY in objectui's hand-written `FLOW_NODE_CONFIG` table —
13+
14+
`script`, `subflow` and `decision` (#4278).
15+
16+
## Why these nodes publish no descriptor `configSchema` — and still need this
17+
18+
`config-schemas.test.ts` in `service-automation` pins the schemaless class
19+
20+
with each member's reason: `decision`'s virtual Target column is derived from
21+
22+
the out-edges, `script`'s form switches on `actionType`, `subflow` carries a
23+
24+
top-level `timeoutMs` — a published partial schema would DROP those editors
25+
26+
(the #4210 `connector_action` incident). So the Studio form for these types
27+
28+
is objectui's hand-written group, and until #4278 **nothing reconciled that
29+
30+
hand-written table against the executors**: `script`'s form offered an
31+
32+
`outputVariables` key nothing reads, two `actionType` options that fail every
33+
34+
run, a no-op default — and could not author the `function`/`inputs`/
35+
36+
`outputVariable` path that works.
37+
38+
These schemas are the machine-readable half of that reconciliation. They are
39+
40+
**written from the executors** (`service-automation/builtin/screen-nodes.ts`
41+
42+
for `script`, `subflow-node.ts`, `logic-nodes.ts` for `decision`), not from
43+
44+
any form, and objectui's `flow-node-config` reconciliation test compares its
45+
46+
hand-written key sets against them — the same bidirectional ledger the
47+
48+
descriptor-schema'd builtins get from `builtin-node-form-zod-ledger.test.ts`,
49+
50+
carried across the repo seam by the `@objectstack/spec` dependency objectui
51+
52+
already has.
53+
54+
`wait` and `connector_action` — the other two schemaless members — need no
55+
56+
entry here: their contracts are the spec-structured sibling blocks on
57+
58+
`FlowNodeSchema` (`waitEventConfig` / `connectorConfig`), which the
59+
60+
same objectui test reconciles directly.
61+
62+
## What these schemas are (and are not) wired to
63+
64+
Contract exports only — no engine path `parse()`s a node config with them,
65+
66+
so registering a flow behaves exactly as before. This is where they differ
67+
68+
from their `builtin-node-config.zod.ts` siblings, which #4277 wired into
69+
70+
execute-time parsing (`service-automation`'s `parse-config.ts`) and into the
71+
72+
`registerFlow()` unknown-key rejection.
73+
74+
That difference is deliberate, and it is the same reason these three publish
75+
76+
no descriptor `configSchema`: **their key set is not the whole contract.**
77+
78+
`script`'s legal keys depend on `actionType` (a built-in side effect reads
79+
80+
`template`/`recipients`/`variables`; the function path reads
81+
82+
`function`/`inputs`/`outputVariable`), and `decision` may carry no
83+
84+
`conditions` at all when it branches purely on edge predicates. A flat parse
85+
86+
would either reject those shapes or wave everything through — neither is the
87+
88+
contract. Wiring them in needs a discriminated form first; until then the
89+
90+
enforcement they DO get is the objectui reconciliation test, which is what
91+
92+
#4278 was actually about (a form authoring keys nothing reads).
93+
94+
Undeclared aliases are NOT part of these contracts: `subflow`'s historical
95+
96+
`flow` spelling graduated into the ADR-0087 D2 conversion
97+
98+
`flow-node-subflow-flow-alias` (the `map.flow` path), so the executor only
99+
100+
ever sees `flowName`.
101+
102+
<Callout type="info">
103+
**Source:** `packages/spec/src/automation/schemaless-node-config.zod.ts`
104+
</Callout>
105+
106+
## TypeScript Usage
107+
108+
```typescript
109+
import { DecisionCondition, DecisionConfig, ScriptConfig, SubflowConfig } from '@objectstack/spec/automation';
110+
import type { DecisionCondition, DecisionConfig, ScriptConfig, SubflowConfig } from '@objectstack/spec/automation';
111+
112+
// Validate data
113+
const result = DecisionCondition.parse(data);
114+
```
115+
116+
---
117+
118+
## DecisionCondition
119+
120+
### Properties
121+
122+
| Property | Type | Required | Description |
123+
| :--- | :--- | :--- | :--- |
124+
| **label** | `string` || Branch label; the winning branch resumes down the out-edge with this label ('true' expression = default/else path) |
125+
| **expression** | `string` || Bare CEL predicate deciding this branch |
126+
127+
128+
---
129+
130+
## DecisionConfig
131+
132+
### Properties
133+
134+
| Property | Type | Required | Description |
135+
| :--- | :--- | :--- | :--- |
136+
| **conditions** | `{ label: string; expression: string }[]` | optional | Ordered decision branches (first true expression wins; omit to branch purely on edge conditions) |
137+
138+
139+
---
140+
141+
## ScriptConfig
142+
143+
### Properties
144+
145+
| Property | Type | Required | Description |
146+
| :--- | :--- | :--- | :--- |
147+
| **actionType** | `string` | optional | How this step runs: a built-in side effect ('email' \| 'slack'), the 'invoke_function' marker, or shorthand for a registered-function name |
148+
| **function** | `string` | optional | Registered function to call (defineStack(`{ functions }`)); takes precedence over actionType |
149+
| **inputs** | `Record<string, any>` | optional | Inputs passed to the function (values interpolate `{token}` templates) |
150+
| **outputVariable** | `string` | optional | Flow variable the function's return value is bound to |
151+
| **template** | `string` | optional | Built-in side effects only: message template id |
152+
| **recipients** | `string[]` | optional | Built-in side effects only: recipients (user ids, field refs, or addresses) |
153+
| **variables** | `Record<string, any>` | optional | Built-in side effects only: values injected into the template |
154+
| **script** | `string` | optional | Inline JS source — recognized but not executed by the built-in runtime; use a registered function via `function` instead |
155+
156+
157+
---
158+
159+
## SubflowConfig
160+
161+
### Properties
162+
163+
| Property | Type | Required | Description |
164+
| :--- | :--- | :--- | :--- |
165+
| **flowName** | `string` || Flow invoked as this step (it may pause — approval / screen / wait) |
166+
| **input** | `Record<string, any>` | optional | Values passed to the subflow's input variables (interpolate `{token}` templates) |
167+
| **outputVariable** | `string` | optional | Parent flow variable the subflow's output is bound to |
168+
169+
170+
---
171+

docs/audits/2026-07-unknown-key-strictness-ledger.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ tightening (the #4001 "sharing-rule lesson": candidates, not verdicts).
165165
| `external-catalog.zod.ts` | 4 | wire (p) | |
166166
| `field-value.zod.ts` / `seed.zod.ts` / `validation.zod.ts` | 1 ea | mixed (p) | |
167167

168-
### `automation/`89 sites
168+
### `automation/`93 sites
169169

170170
| File | Sites | Class | Note |
171171
|---|---|---|---|
@@ -180,6 +180,7 @@ tightening (the #4001 "sharing-rule lesson": candidates, not verdicts).
180180
| `node-executor.zod.ts` | 4 | wire | executor contract |
181181
| `io-node-config.zod.ts` | 2 | authorable | `NotifyConfigSchema` / `HttpConfigSchema` (#4045) — the sibling contracts that validate the **open** `config` slot on flow `notify` / `http` nodes. Authored per-node, so the open-slot exemption above does not extend to them; candidate once the executors' own drift is verified |
182182
| `builtin-node-config.zod.ts` | 8 | authorable | Same family (#4045): the CRUD quartet, `screen`, `map`. Written from what the executors read rather than from the descriptors' `configSchema` literals, and reconciled bidirectionally by `builtin-node-form-zod-ledger.test.ts` — so unlike most rows here, this one already has a drift check of its own. Same candidacy note as `io-node-config` |
183+
| `schemaless-node-config.zod.ts` | 4 | authorable | Same family, third panel (#4278): `script` / `subflow` / `decision` (+ the decision branch item) — the descriptor-schemaless nodes whose form lives in objectui's hand-written table. Written from the executors; the drift check is objectui's `flow-node-config.spec-reconciliation` test (cross-repo, via the published exports). Contract exports only — nothing parses node config with them yet, so strictness candidacy follows `io-node-config` |
183184
| `webhook.zod.ts` | 1 | authorable (p) | spec-only (#3461) |
184185

185186
### `security/` — 20 sites

docs/protocol-upgrade-guide.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ One flow key changes WITHOUT a lossless target: `errorHandling.maxRetries` (#424
157157
| `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 |
158158
| `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 |
159159
| `flow-node-map-flow-alias` | `flow.node.map.config.flowName` | map flow-node config key 'flow' → 'flowName' (#4045 — undeclared executor fallback graduation) | live — protocol 17 loader accepts the old shape |
160+
| `flow-node-subflow-flow-alias` | `flow.node.subflow.config.flowName` | subflow flow-node config key 'flow' → 'flowName' (#4278 — undeclared executor fallback graduation) | live — protocol 17 loader accepts the old shape |
160161
| `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 |
161162
| `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 |
162163
| `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: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@
1717
* and `connector_action` (top-level `connectorConfig` block, #4045) — a
1818
* partial schema would drop those editors, and a schema rooted at `config`
1919
* would actively re-route sibling-block authoring to keys nothing reads.
20+
*
21+
* Schemaless ≠ contractless (#4278): `script` / `subflow` / `decision` publish
22+
* executor-derived config Zods in spec `automation/schemaless-node-config.zod.ts`
23+
* (wait / connector_action keep theirs as FlowNodeSchema sibling blocks), and
24+
* objectui reconciles its hand-written form groups against those exports. The
25+
* descriptors below still publish NO configSchema — that is what this file pins.
2026
*/
2127

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

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

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { describe, it, expect, beforeEach } from 'vitest';
4+
import {
5+
SCRIPT_BUILTIN_ACTION_TYPES,
6+
SCRIPT_INVOKE_FUNCTION_ACTION_TYPE,
7+
ScriptConfigSchema,
8+
} from '@objectstack/spec/automation';
49
import { AutomationEngine, type FlowFunctionHandler } from '../engine.js';
510
import { registerScreenNodes } from './screen-nodes.js';
611

@@ -155,6 +160,65 @@ it('canonicalizes a stored `functionName` key to `function` at load (#1870 DX, #
155160
});
156161
});
157162

163+
/**
164+
* #4278 — the script node's contract is the spec-published one. The designer
165+
* form for `script` is objectui's hand-written group (this node deliberately
166+
* publishes no descriptor configSchema — config-schemas.test.ts), so the only
167+
* machine-readable statement of what it accepts is
168+
* `SCRIPT_BUILTIN_ACTION_TYPES` / `ScriptConfigSchema` in
169+
* `@objectstack/spec/automation`. These pins are the objectstack half of the
170+
* cross-repo reconciliation: the executor dispatches exactly the published
171+
* built-in set (it now builds its dispatch set FROM the constant), and its
172+
* failure message names that same set — objectui's side reconciles its form
173+
* options and key set against the same exports.
174+
*/
175+
describe('script contract ↔ spec-published constants (#4278)', () => {
176+
let engine: AutomationEngine;
177+
178+
beforeEach(() => {
179+
engine = new AutomationEngine(createTestLogger());
180+
registerScreenNodes(engine, createCtx());
181+
});
182+
183+
it.each([...SCRIPT_BUILTIN_ACTION_TYPES])(
184+
"every published built-in actionType runs the built-in branch: '%s'",
185+
async (actionType) => {
186+
engine.registerFlow('script_flow', scriptFlow({ actionType, template: 't', recipients: ['a'] }));
187+
const result = await engine.execute('script_flow', {} as any);
188+
expect(result.success).toBe(true);
189+
},
190+
);
191+
192+
it('an actionType outside the published set fails naming exactly that set (the #4278 sms repro)', async () => {
193+
// The old objectui form offered 'sms' / 'notification'; neither is in
194+
// the published set, so they resolve as function names and fail. The
195+
// error must name the published members — it is the message the #4278
196+
// report quoted, and the form's options now come from the same constant.
197+
engine.registerFlow('script_flow', scriptFlow({ actionType: 'sms' }));
198+
const result = await engine.execute('script_flow', {} as any);
199+
expect(result.success).toBe(false);
200+
for (const builtin of SCRIPT_BUILTIN_ACTION_TYPES) {
201+
expect(result.error).toContain(builtin);
202+
}
203+
expect(result.error).toMatch(/'sms' is not a built-in action/);
204+
});
205+
206+
it('the published Zod accepts the canonical authoring shapes (contract sanity)', () => {
207+
// Function path — the only shape that does real work.
208+
expect(ScriptConfigSchema.parse({
209+
actionType: SCRIPT_INVOKE_FUNCTION_ACTION_TYPE,
210+
function: 'score_lead',
211+
inputs: { leadId: '{record.id}' },
212+
outputVariable: 'score',
213+
})).toMatchObject({ function: 'score_lead' });
214+
// Built-in side effect.
215+
expect(ScriptConfigSchema.parse({ actionType: 'email', template: 't', recipients: ['a'], variables: { x: 1 } }))
216+
.toMatchObject({ actionType: 'email' });
217+
// Inline script — recognized (and documented as not executed).
218+
expect(ScriptConfigSchema.parse({ script: 'return 1;' })).toMatchObject({ script: 'return 1;' });
219+
});
220+
});
221+
158222
/** A one-`screen`-node flow whose screen node carries `config`. */
159223
function screenFlow(config: Record<string, unknown>) {
160224
return {

0 commit comments

Comments
 (0)