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
43 changes: 43 additions & 0 deletions .changeset/schemaless-node-config-contracts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
---
"@objectstack/spec": minor
"@objectstack/service-automation": patch
---

feat(spec,automation): publish executor-derived config contracts for the schemaless flow nodes (#4278)

The five descriptor-schemaless builtins (`decision` / `script` / `subflow` /
`wait` / `connector_action`) deliberately publish no `configSchema`, so their
Studio form lives 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 (`sms` / `notification`) that fail every run, a no-op default (`code`),
and could not author the `function` / `inputs` / `outputVariable` path that
works.

New in `@objectstack/spec/automation` — contract exports only. Unlike their
`builtin-node-config.zod.ts` siblings, which #4277 wired into execute-time
parsing, no engine path `parse()`s node config with these: `script`'s legal key
set depends on `actionType` and `decision` may branch purely on edge
predicates, so a flat parse would either reject valid shapes or check nothing.
Their enforcement is the objectui reconciliation test.

- `ScriptConfigSchema` / `SubflowConfigSchema` / `DecisionConfigSchema` (+
`DecisionConditionSchema`) — written from the executors in
`service-automation`, the machine-readable half of the cross-repo
reconciliation objectui's `flow-node-config` test now performs. `wait` and
`connector_action` need no new schema — their contracts are the existing
`FlowNodeSchema` sibling blocks (`waitEventConfig` / `connectorConfig`).
- `SCRIPT_BUILTIN_ACTION_TYPES` (`['email', 'slack']`) and
`SCRIPT_INVOKE_FUNCTION_ACTION_TYPE` (`'invoke_function'`) — the `script`
executor now builds its dispatch set from the published constant, so the
designer's options, the dispatch set, and the "not a built-in action"
failure message can no longer disagree.

Undeclared-alias graduation in the same change (Prime Directive #12, the
`map.flow` path): the `subflow` executor's bare `cfg.flowName ?? cfg.flow`
fallback is deleted, replaced by the ADR-0087 D2 conversion
`flow-node-subflow-flow-alias` — a stored `subflow` node authored with
`config.flow` is rewritten to the canonical `config.flowName` at load
(including the `AutomationEngine.registerFlow` rehydration seam). FROM
`config.flow` TO `config.flowName`; one-line fix for hand-maintained sources:
rename the key.
14 changes: 12 additions & 2 deletions content/docs/references/automation/builtin-node-config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,19 @@ reason instead of pretending a shape.

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

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

live elsewhere (edges, sibling blocks, conditional forms).
`connector_action` keep their contracts in FlowNodeSchema's sibling

blocks (`waitEventConfig` / `connectorConfig`); the other three publish

executor-derived config contracts in `schemaless-node-config.zod.ts`

(#4278) — separate from this module because they must NOT grow into

descriptor `configSchema`s (the forms they describe stay hand-written in

objectui, reconciled by a test there).

<Callout type="info">
**Source:** `packages/spec/src/automation/builtin-node-config.zod.ts`
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 @@ -15,6 +15,7 @@ This section contains all protocol schemas for the automation layer of ObjectSta
<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/schemaless-node-config" title="Schemaless Node Config" description="Source: packages/spec/src/automation/schemaless-node-config.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" />
<Card href="/docs/references/automation/time-relative-trigger" title="Time Relative Trigger" description="Source: packages/spec/src/automation/time-relative-trigger.zod.ts" />
Expand Down
3 changes: 2 additions & 1 deletion content/docs/references/automation/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"job",
"---More---",
"builtin-node-config",
"io-node-config"
"io-node-config",
"schemaless-node-config"
]
}
171 changes: 171 additions & 0 deletions content/docs/references/automation/schemaless-node-config.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
---
title: Schemaless Node Config
description: Schemaless 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/schemaless-node-config

Config contracts for the **descriptor-schemaless** builtins whose designer

form lives ONLY in objectui's hand-written `FLOW_NODE_CONFIG` table —

`script`, `subflow` and `decision` (#4278).

## Why these nodes publish no descriptor `configSchema` — and still need this

`config-schemas.test.ts` in `service-automation` pins the schemaless class

with each member's reason: `decision`'s virtual Target column is derived from

the out-edges, `script`'s form switches on `actionType`, `subflow` carries a

top-level `timeoutMs` — a published partial schema would DROP those editors

(the #4210 `connector_action` incident). So the Studio form for these types

is objectui's hand-written group, and until #4278 **nothing reconciled that

hand-written table against the executors**: `script`'s form offered an

`outputVariables` key nothing reads, two `actionType` options that fail every

run, a no-op default — and could not author the `function`/`inputs`/

`outputVariable` path that works.

These schemas are the machine-readable half of that reconciliation. They are

**written from the executors** (`service-automation/builtin/screen-nodes.ts`

for `script`, `subflow-node.ts`, `logic-nodes.ts` for `decision`), not from

any form, and objectui's `flow-node-config` reconciliation test compares its

hand-written key sets against them — the same bidirectional ledger the

descriptor-schema'd builtins get from `builtin-node-form-zod-ledger.test.ts`,

carried across the repo seam by the `@objectstack/spec` dependency objectui

already has.

`wait` and `connector_action` — the other two schemaless members — need no

entry here: their contracts are the spec-structured sibling blocks on

`FlowNodeSchema` (`waitEventConfig` / `connectorConfig`), which the

same objectui test reconciles directly.

## What these schemas are (and are not) wired to

Contract exports only — no engine path `parse()`s a node config with them,

so registering a flow behaves exactly as before. This is where they differ

from their `builtin-node-config.zod.ts` siblings, which #4277 wired into

execute-time parsing (`service-automation`'s `parse-config.ts`) and into the

`registerFlow()` unknown-key rejection.

That difference is deliberate, and it is the same reason these three publish

no descriptor `configSchema`: **their key set is not the whole contract.**

`script`'s legal keys depend on `actionType` (a built-in side effect reads

`template`/`recipients`/`variables`; the function path reads

`function`/`inputs`/`outputVariable`), and `decision` may carry no

`conditions` at all when it branches purely on edge predicates. A flat parse

would either reject those shapes or wave everything through — neither is the

contract. Wiring them in needs a discriminated form first; until then the

enforcement they DO get is the objectui reconciliation test, which is what

#4278 was actually about (a form authoring keys nothing reads).

Undeclared aliases are NOT part of these contracts: `subflow`'s historical

`flow` spelling graduated into the ADR-0087 D2 conversion

`flow-node-subflow-flow-alias` (the `map.flow` path), so the executor only

ever sees `flowName`.

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

## TypeScript Usage

```typescript
import { DecisionCondition, DecisionConfig, ScriptConfig, SubflowConfig } from '@objectstack/spec/automation';
import type { DecisionCondition, DecisionConfig, ScriptConfig, SubflowConfig } from '@objectstack/spec/automation';

// Validate data
const result = DecisionCondition.parse(data);
```

---

## DecisionCondition

### Properties

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **label** | `string` | ✅ | Branch label; the winning branch resumes down the out-edge with this label ('true' expression = default/else path) |
| **expression** | `string` | ✅ | Bare CEL predicate deciding this branch |


---

## DecisionConfig

### Properties

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **conditions** | `{ label: string; expression: string }[]` | optional | Ordered decision branches (first true expression wins; omit to branch purely on edge conditions) |


---

## ScriptConfig

### Properties

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **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 |
| **function** | `string` | optional | Registered function to call (defineStack(`{ functions }`)); takes precedence over actionType |
| **inputs** | `Record<string, any>` | optional | Inputs passed to the function (values interpolate `{token}` templates) |
| **outputVariable** | `string` | optional | Flow variable the function's return value is bound to |
| **template** | `string` | optional | Built-in side effects only: message template id |
| **recipients** | `string[]` | optional | Built-in side effects only: recipients (user ids, field refs, or addresses) |
| **variables** | `Record<string, any>` | optional | Built-in side effects only: values injected into the template |
| **script** | `string` | optional | Inline JS source — recognized but not executed by the built-in runtime; use a registered function via `function` instead |


---

## SubflowConfig

### Properties

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **flowName** | `string` | ✅ | Flow invoked as this step (it may pause — approval / screen / wait) |
| **input** | `Record<string, any>` | optional | Values passed to the subflow's input variables (interpolate `{token}` templates) |
| **outputVariable** | `string` | optional | Parent flow variable the subflow's output is bound to |


---

3 changes: 2 additions & 1 deletion docs/audits/2026-07-unknown-key-strictness-ledger.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ tightening (the #4001 "sharing-rule lesson": candidates, not verdicts).
| `external-catalog.zod.ts` | 4 | wire (p) | |
| `field-value.zod.ts` / `seed.zod.ts` / `validation.zod.ts` | 1 ea | mixed (p) | |

### `automation/` — 89 sites
### `automation/` — 93 sites

| File | Sites | Class | Note |
|---|---|---|---|
Expand All @@ -180,6 +180,7 @@ tightening (the #4001 "sharing-rule lesson": candidates, not verdicts).
| `node-executor.zod.ts` | 4 | wire | executor contract |
| `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 |
| `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` |
| `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` |
| `webhook.zod.ts` | 1 | authorable (p) | spec-only (#3461) |

### `security/` — 20 sites
Expand Down
1 change: 1 addition & 0 deletions docs/protocol-upgrade-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ One flow key changes WITHOUT a lossless target: `errorHandling.maxRetries` (#424
| `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-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 |
| `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 |
| `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 @@ -17,6 +17,12 @@
* 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.
*
* Schemaless ≠ contractless (#4278): `script` / `subflow` / `decision` publish
* executor-derived config Zods in spec `automation/schemaless-node-config.zod.ts`
* (wait / connector_action keep theirs as FlowNodeSchema sibling blocks), and
* objectui reconciles its hand-written form groups against those exports. The
* descriptors below still publish NO configSchema — that is what this file pins.
*/

import { describe, it, expect } from 'vitest';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect, beforeEach } from 'vitest';
import {
SCRIPT_BUILTIN_ACTION_TYPES,
SCRIPT_INVOKE_FUNCTION_ACTION_TYPE,
ScriptConfigSchema,
} from '@objectstack/spec/automation';
import { AutomationEngine, type FlowFunctionHandler } from '../engine.js';
import { registerScreenNodes } from './screen-nodes.js';

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

/**
* #4278 — the script node's contract is the spec-published one. The designer
* form for `script` is objectui's hand-written group (this node deliberately
* publishes no descriptor configSchema — config-schemas.test.ts), so the only
* machine-readable statement of what it accepts is
* `SCRIPT_BUILTIN_ACTION_TYPES` / `ScriptConfigSchema` in
* `@objectstack/spec/automation`. These pins are the objectstack half of the
* cross-repo reconciliation: the executor dispatches exactly the published
* built-in set (it now builds its dispatch set FROM the constant), and its
* failure message names that same set — objectui's side reconciles its form
* options and key set against the same exports.
*/
describe('script contract ↔ spec-published constants (#4278)', () => {
let engine: AutomationEngine;

beforeEach(() => {
engine = new AutomationEngine(createTestLogger());
registerScreenNodes(engine, createCtx());
});

it.each([...SCRIPT_BUILTIN_ACTION_TYPES])(
"every published built-in actionType runs the built-in branch: '%s'",
async (actionType) => {
engine.registerFlow('script_flow', scriptFlow({ actionType, template: 't', recipients: ['a'] }));
const result = await engine.execute('script_flow', {} as any);
expect(result.success).toBe(true);
},
);

it('an actionType outside the published set fails naming exactly that set (the #4278 sms repro)', async () => {
// The old objectui form offered 'sms' / 'notification'; neither is in
// the published set, so they resolve as function names and fail. The
// error must name the published members — it is the message the #4278
// report quoted, and the form's options now come from the same constant.
engine.registerFlow('script_flow', scriptFlow({ actionType: 'sms' }));
const result = await engine.execute('script_flow', {} as any);
expect(result.success).toBe(false);
for (const builtin of SCRIPT_BUILTIN_ACTION_TYPES) {
expect(result.error).toContain(builtin);
}
expect(result.error).toMatch(/'sms' is not a built-in action/);
});

it('the published Zod accepts the canonical authoring shapes (contract sanity)', () => {
// Function path — the only shape that does real work.
expect(ScriptConfigSchema.parse({
actionType: SCRIPT_INVOKE_FUNCTION_ACTION_TYPE,
function: 'score_lead',
inputs: { leadId: '{record.id}' },
outputVariable: 'score',
})).toMatchObject({ function: 'score_lead' });
// Built-in side effect.
expect(ScriptConfigSchema.parse({ actionType: 'email', template: 't', recipients: ['a'], variables: { x: 1 } }))
.toMatchObject({ actionType: 'email' });
// Inline script — recognized (and documented as not executed).
expect(ScriptConfigSchema.parse({ script: 'return 1;' })).toMatchObject({ script: 'return 1;' });
});
});

/** A one-`screen`-node flow whose screen node carries `config`. */
function screenFlow(config: Record<string, unknown>) {
return {
Expand Down
Loading
Loading