Schemaless Node Config
Schemaless Node Config protocol schemas
@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, 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.
script's own reason for staying schemaless was that its form switched on
actionType. #4343 retired that switch, so the node is now three flat keys
and could graduate to a published descriptor configSchema the way map
did — a follow-up, deliberately not folded into the retirement.
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 wired to
script and subflow are parsed at execute time since #4343, through
the same parseNodeConfig() seam #4277 gave the flat builtins
(service-automation's parse-config.ts): a config that fails its contract
refuses the node as a GUARD — wrong metadata, so a rerun cannot help and no
fault edge may route it (#3863).
script could not be parsed while its legal key set depended on
actionType; #4343 removed that dependence instead of modelling it.
Converging the node to its one real path — call a registered function — left
a flat three-key contract a flat parse fits exactly, and the five keys the
other branches read became retiredKey tombstones.
The two halves reach different audiences, which is why they shipped together:
- the tombstones teach whoever authors the key —
tsctypes itnever, and a direct parse raises the prescription. They do NOT reach a stored flow:FlowNodeSchema.configisz.record(z.unknown()), so no load-path parse ever descends into a node's config; - the execute-time parse is what a stored flow meets.
registerFlowcanonicalizes data at rest through the retired conversion too (#3903), so a storedactionType: 'email'node arrives here stripped of the keys nothing read — and then refuses, naming thefunctionit does not have, instead of logging a line and reporting success as it used to.
decision stays export-only, deliberately: it may carry no conditions at
all when it branches purely on edge predicates (a plain BPMN exclusive
gateway), and conditions is its only key — so a parse would have nothing
left to check. Its enforcement remains 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.
Unknown keys — closed as of #4001 批 9, and this class had NO other door
The descriptor-schema'd builtins have a registration-time key gate:
registerFlow() walks each node's config against the descriptor's
configSchema and hard-rejects what it does not declare (#4277). These
three node types are exempt from that walk — by construction, since it
derives the declared set from a configSchema they publish none of
(validateNodeConfigKeys' schemaless exemption). So until now the entire
script / subflow / decision config surface had exactly zero unknown-key
enforcement at any layer: the execute-time parse #4343 added checks types and
requiredness, and Zod's default .strip deleted everything else in silence.
That is the #4001 asymmetry in its purest form — a guard was written for the
door in front of its author, and the class it structurally could not cover is
precisely the class with no second door. Closing these shapes is therefore
not a duplicate check for script and subflow; it is their first one.
decision is still export-only, so its strictness binds at authoring
(tsc), in the published JSON Schema, and in objectui's reconciliation —
not at run time. It is closed anyway, because the campaign's whole finding
is that a shape left open accretes a test, a form and a fixture that assert
the openness, and then closing it is a migration instead of an edit.
Source: packages/spec/src/automation/schemaless-node-config.zod.ts
TypeScript Usage
import { DecisionConditionSchema, DecisionConfigSchema, ScriptConfigSchema, SubflowConfigSchema } from '@objectstack/spec/automation';
import type { DecisionCondition, DecisionConfig, ScriptConfig, SubflowConfig } from '@objectstack/spec/automation';
// Validate data
const result = DecisionConditionSchema.parse(data);DecisionCondition
Properties
| Property | Type | Required | Description |
|---|---|---|---|
| label | string | ✅ | Branch label; the winning branch resumes down the out-edge with this label (no match → the out-edge marked isDefault, or one labelled 'default') |
| 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 |
|---|---|---|---|
| function | string | ✅ | Registered function to call (defineStack({ functions })). Contractually pure — it returns a value a later declarative node persists |
| 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 |
| actionType | never | optional | [REMOVED] script.config.actionType was removed in @objectstack/spec 17 (#4343) — none of its values did what it said. The two built-ins were logger-backed stubs that recorded the intent and delivered nothing under any configuration, and every other value was a second spelling of config.function. Replace it per branch: for email use a notify node (it delivers through the messaging service — the in-app inbox by default, real email once @objectstack/plugin-email is installed); for slack use a connector_action node with the Slack connector, or an http node posting to a webhook; for anything else, move the name into config.function. Run os migrate meta --from 16 to list the mechanical edits for the shorthand case into config.function; the stub and marker values are removed. |
| template | never | optional | [REMOVED] script.config.template was removed in @objectstack/spec 17 (#4343) — it fed only the logger-backed email/slack stubs, which never rendered or sent a message, so no template id was ever resolved. Delete the key. A notify node carries its own title/message, and stored templates live in the messaging service (sys_notification_template), not on the node. Run os migrate meta --from 16 to list the mechanical edits for existing sources; apply them by hand. |
| recipients | never | optional | [REMOVED] script.config.recipients was removed in @objectstack/spec 17 (#4343) — the addresses were logged, never messaged: the email/slack branches it fed delivered nothing. Use a notify node, whose recipients (user ids, field refs or addresses) reach the messaging service for real. Run os migrate meta --from 16 to list the mechanical edits for existing sources; apply them by hand. |
| variables | never | optional | [REMOVED] script.config.variables was removed in @objectstack/spec 17 (#4343) — it injected values into a template no side effect ever rendered. Delete the key. A notify node carries structured data in payload; a registered function takes it in config.inputs. Run os migrate meta --from 16 to list the mechanical edits for existing sources; apply them by hand. |
| script | never | optional | [REMOVED] script.config.script was removed in @objectstack/spec 17 (#4343) — the built-in runtime has no server-side JS sandbox, so an inline body was recognized and never executed: the node warned and completed as a no-op. Move the logic into a registered function (defineStack({ functions })) and name it in config.function. Run os migrate meta --from 16 to list the mechanical edits for existing sources; apply them by hand. |
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 |