ObjectStackObjectStack

Flow schema — Automation Protocol reference

Flow schemas of the ObjectStack Automation Protocol: Flow, FlowEdge, FlowNode and 3 more — each property with its type, default and a TypeScript example.

Source: packages/spec/src/automation/flow.zod.ts

TypeScript Usage

import { FlowSchema, FlowEdgeSchema, FlowNodeSchema, FlowNodeAction, FlowVariableSchema, FlowVersionHistorySchema } from '@objectstack/spec/automation';
import type { Flow, FlowEdge, FlowNode, FlowNodeAction, FlowVersionHistory } from '@objectstack/spec/automation';

// Validate data
const result = FlowSchema.parse(data);

Flow

Properties

PropertyTypeRequiredDescription
namestring✅Machine name
labelstring✅Flow label
descriptionstringoptional
successMessagestringoptionalMessage carried on AutomationResult for every terminal run (not only screen flows); the screen-flow UI shows it as a toast instead of a generic "Done".
errorMessagestringoptionalMessage carried on AutomationResult for every terminal run (not only screen flows); the screen-flow UI shows it as a toast instead of the raw error.
versionintegeroptional (default: 1)Version number
statusEnum<'draft' | 'active' | 'obsolete' | 'invalid'>optional (default: "draft")Deployment status
templateneveroptional[REMOVED] flow.template was removed in @objectstack/spec 17.0.0 (audit close-out) — no designer or engine path ever read it, so flagging a flow as a template/subflow did nothing. Delete the key. Shared logic is invoked via a subflow NODE referencing the flow by name. Run os migrate meta --from 16 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand.
typeEnum<'autolaunched' | 'record_change' | 'schedule' | 'screen' | 'api'>✅Flow type
variables{ name: string; type: string; isInput?: boolean; isOutput?: boolean; … }[]optionalFlow variables
nodes{ id: string; type: string; label: string; config?: Record<string, any>; … }[]✅Flow nodes
edges{ id: string; source: string; target: string; condition?: string | object; … }[]✅Flow connections
activeneveroptional[REMOVED] flow.active was removed in @objectstack/spec 17.0.0 (audit close-out) — it never had an effect: the engine arms flows from status, and active: false did NOT stop a flow (worse, the default read as disabled while the engine treated unset as enabled). Delete the key. Use status: 'obsolete' (or 'invalid') to unbind and disable a flow, status: 'active' to arm it. Run os migrate meta --from 16 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand.
runAsEnum<'system' | 'user'>optional (default: "user")Execution identity for the run: system = elevated (bypasses RLS), user = the triggering user (RLS-respecting). A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form.
errorHandling{ strategy?: Enum<'fail' | 'retry' | 'continue'>; maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; … }optionalFlow-level error handling configuration. A durable pause ends the retry-governed segment: strategy: 'retry' describes one synchronous dispatch, so a run that parks on an approval/screen/wait node and later resumes gets one attempt for anything that fails after the pause. Protect the post-pause half with its own failure handling in the flow — a try_catch node's retry around the post-resume work, or fault edges to a handler node.
protection{ lock: Enum<'none' | 'no-overlay' | 'no-delete' | 'full'>; reason: string; docsUrl?: string }optionalPackage author protection block — lock policy for this flow.
_lockEnum<'none' | 'no-overlay' | 'no-delete' | 'full'>optionalItem-level lock — controls overlay & delete (ADR-0010).
_lockReasonstringoptionalHuman-readable reason shown when a write is refused by _lock.
_lockSourceEnum<'artifact' | 'package' | 'env-forced'>optionalLayer that set _lock (artifact | package | env-forced).
_provenanceEnum<'package' | 'org' | 'env-forced'>optionalOrigin of the item (package | org | env-forced).
_packageIdstringoptionalOwning package machine id.
_packageVersionstringoptionalOwning package version.
_lockDocsUrlstringoptionalOptional documentation link surfaced next to _lockReason.

Nested Shape: Flow.variables[number]

PropertyTypeRequiredDescription
namestring✅Variable name — a name without a leading $ (the $ names are the flow engine's own), read as {{ name }}
typestring✅Data type (text, number, boolean, object, list)
isInputbooleanoptional (default: false)Is input parameter
isOutputbooleanoptional (default: false)Is output parameter
defaultValueanyoptionalValue bound at run start when no parameter supplies one — this is what makes a declared variable always bound. An explicitly supplied param wins, including false and null; the boundary is params[name] !== undefined.

Nested Shape: Flow.nodes[number]

PropertyTypeRequiredDescription
idstring✅Node unique ID
typestring✅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.
labelstring✅Node label
configRecord<string, any>optionalNode configuration
connectorConfig{ connectorId: string; actionId: string; input?: Record<string, any> }optional
position{ x: number; y: number }optional
timeoutMsintegeroptionalMaximum execution time for this node in milliseconds
inputSchemaRecord<string, { type: Enum<'string' | 'number' | 'boolean' | 'object' | 'array'>; required?: boolean; description?: string }>optionalInput parameter schema for this node
outputSchemaneveroptional[REMOVED] flow.nodes[].outputSchema was removed in @objectstack/spec 17.0.0 (audit close-out) — it was never validated: the engine does not check node outputs against it, so it documented a contract nothing enforced. Delete the key. Downstream nodes read prior outputs via expressions ({{nodeId.field}}) regardless of any declaration. Run os migrate meta --from 16 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand.
waitEventConfig{ eventType: Enum<'timer' | 'signal' | 'webhook' | 'manual' | 'condition'>; timerDuration?: string; signalName?: string }optionalConfiguration for wait node event resumption. REQUIRED on a type: 'wait' node — the block is the whole contract, and a wait node without one parses into a run that parks forever reporting success. Under eventType: 'timer' a timerDuration is required too.
boundaryConfig{ attachedToNodeId: string; eventType: Enum<'error' | 'timer' | 'signal' | 'cancel'>; interrupting?: boolean; errorCode?: string; … }optionalConfiguration for boundary events attached to host nodes. REQUIRED on a type: 'boundary_event' node — without it the node names neither the activity it watches nor what fires it.

Nested Shape: Flow.edges[number]

PropertyTypeRequiredDescription
idstring✅Edge unique ID
sourcestring✅Source Node ID
targetstring✅Target Node ID
conditionstring | { dialect: Enum<'cel' | 'cron' | 'template'>; source: string; ast?: any; meta?: object }optionalPredicate (CEL) returning boolean used for branching. An evaluated slot: a bare non-blank CEL string, or an envelope carrying a non-blank source — an ast-only envelope, and a source that is blank after trimming, are refused at authoring because the engine evaluates source alone and would otherwise answer a silent false.
typeEnum<'default' | 'fault' | 'conditional' | 'back'>optional (default: "default")Connection type: default (normal flow), fault (error path), conditional (expression-guarded), or back (ADR-0044 declared back-edge — traversed normally at run time, but excluded from DAG cycle validation so a revise/rework loop can re-enter an earlier node)
labelstringoptionalLabel on the connector
isDefaultbooleanoptional (default: false)BPMN default flow: traverse this edge only when no sibling conditional edge of the same source node matched. Mutually exclusive with condition; at most one per source node.

Nested Shape: Flow.errorHandling

PropertyTypeRequiredDescription
strategyEnum<'fail' | 'retry' | 'continue'>optional (default: "fail")How to handle node execution errors. 'retry' governs ONE synchronous dispatch: a durable pause (approval/screen/wait) ends the retry-governed segment, so a failure after the run resumes is not retried.
maxRetriesintegeroptional (default: 0)Retry attempts after the initial one. Read only under strategy: 'retry', which requires >= 1; 0 (the default) means no retry.
backoffMsintegeroptional (default: 1000)Base delay before the first retry (ms); subsequent delays multiply by backoffMultiplier
backoffMultipliernumberoptional (default: 1)Exponential backoff multiplier; 1 (the default) keeps the delay flat
maxRetryDelayMsintegeroptional (default: 30000)Ceiling for a single backoff delay (ms)
jitterbooleanoptional (default: false)Randomize each delay within [50%, 100%] of its computed value — spreads a thundering herd of simultaneous retries
retryDelayMsneveroptional[REMOVED] retryDelayMs was removed in @objectstack/spec 17.0.0 — the retry policy now has ONE spelling for its base delay across every surface that carries it: job.retryPolicy, a try_catch node's retry and flow.errorHandling. Rename the key to backoffMs; the value (milliseconds before the first retry) is unchanged. Run os migrate meta --from 16 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand.
fallbackNodeIdneveroptional[REMOVED] flow.errorHandling.fallbackNodeId was removed in @objectstack/spec 17.0.0 (audit close-out) — the engine routes unrecoverable node errors via per-node fault edges (an edge with type: 'fault'), and never read this key: a fallback configured here silently did not exist. Delete the key and draw a fault edge from the failing node to the handler node instead. Run os migrate meta --from 16 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand.

Nested Shape: Flow.protection

PropertyTypeRequiredDescription
lockEnum<'none' | 'no-overlay' | 'no-delete' | 'full'>✅Lock policy — none | no-overlay | no-delete | full.
reasonstring✅User-visible reason shown when the lock blocks an action.
docsUrlstringoptionalOptional URL the Studio banner links to for more context.

FlowEdge

Properties

PropertyTypeRequiredDescription
idstring✅Edge unique ID
sourcestring✅Source Node ID
targetstring✅Target Node ID
conditionstring | { dialect: Enum<'cel' | 'cron' | 'template'>; source: string; ast?: any; meta?: object }optionalPredicate (CEL) returning boolean used for branching. An evaluated slot: a bare non-blank CEL string, or an envelope carrying a non-blank source — an ast-only envelope, and a source that is blank after trimming, are refused at authoring because the engine evaluates source alone and would otherwise answer a silent false.
typeEnum<'default' | 'fault' | 'conditional' | 'back'>optional (default: "default")Connection type: default (normal flow), fault (error path), conditional (expression-guarded), or back (ADR-0044 declared back-edge — traversed normally at run time, but excluded from DAG cycle validation so a revise/rework loop can re-enter an earlier node)
labelstringoptionalLabel on the connector
isDefaultbooleanoptional (default: false)BPMN default flow: traverse this edge only when no sibling conditional edge of the same source node matched. Mutually exclusive with condition; at most one per source node.

FlowNode

Properties

PropertyTypeRequiredDescription
idstring✅Node unique ID
typestring✅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.
labelstring✅Node label
configRecord<string, any>optionalNode configuration
connectorConfig{ connectorId: string; actionId: string; input?: Record<string, any> }optional
position{ x: number; y: number }optional
timeoutMsintegeroptionalMaximum execution time for this node in milliseconds
inputSchemaRecord<string, { type: Enum<'string' | 'number' | 'boolean' | 'object' | 'array'>; required?: boolean; description?: string }>optionalInput parameter schema for this node
outputSchemaneveroptional[REMOVED] flow.nodes[].outputSchema was removed in @objectstack/spec 17.0.0 (audit close-out) — it was never validated: the engine does not check node outputs against it, so it documented a contract nothing enforced. Delete the key. Downstream nodes read prior outputs via expressions ({{nodeId.field}}) regardless of any declaration. Run os migrate meta --from 16 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand.
waitEventConfig{ eventType: Enum<'timer' | 'signal' | 'webhook' | 'manual' | 'condition'>; timerDuration?: string; signalName?: string }optionalConfiguration for wait node event resumption. REQUIRED on a type: 'wait' node — the block is the whole contract, and a wait node without one parses into a run that parks forever reporting success. Under eventType: 'timer' a timerDuration is required too.
boundaryConfig{ attachedToNodeId: string; eventType: Enum<'error' | 'timer' | 'signal' | 'cancel'>; interrupting?: boolean; errorCode?: string; … }optionalConfiguration for boundary events attached to host nodes. REQUIRED on a type: 'boundary_event' node — without it the node names neither the activity it watches nor what fires it.

Nested Shape: FlowNode.connectorConfig

PropertyTypeRequiredDescription
connectorIdstring✅Registered connector name
actionIdstring✅Action key declared by the connector
inputRecord<string, any>optionalMapped inputs for the action. The flow definition, this map included, is served to every member who can read flows, so never put a credential here: the connector authenticates through its own auth.credentialRef.

Nested Shape: FlowNode.inputSchema[string]

PropertyTypeRequiredDescription
typeEnum<'string' | 'number' | 'boolean' | 'object' | 'array'>✅Parameter type
requiredbooleanoptional (default: false)Whether the parameter is required
descriptionstringoptionalParameter description

Nested Shape: FlowNode.waitEventConfig

PropertyTypeRequiredDescription
eventTypeEnum<'timer' | 'signal' | 'webhook' | 'manual' | 'condition'>✅What kind of event resumes the execution
timerDurationstringoptionalISO 8601 duration (e.g., "PT1H") or wait time for timer events
signalNamestringoptionalNamed signal or webhook event to wait for
timeoutMsneveroptional[REMOVED] waitEventConfig.timeoutMs was removed in @objectstack/spec 17. It documented a timeout guard that never existed: nothing ever failed or resumed a wait on a deadline. Its only reader treated it as the timer DURATION when timerDuration was absent, so use timerDuration — but QUOTE the number: the key is a string, and a bare numeric string is read as milliseconds, making timeoutMs: 60000 and timerDuration: '60000' the same wait (timerDuration: 'PT1M' is the ISO 8601 spelling of that same 60s). Stored flows are converted automatically — the conversion does the quoting for you. Run os migrate meta --from 16 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand.
onTimeoutneveroptional[REMOVED] waitEventConfig.onTimeout was removed in @objectstack/spec 17. It had no readers at all — no code path ever inspected it, so neither fail nor continue ever happened. Delete the key. There is no replacement: wait has no timeout, and a wait node resumes only when its timer elapses or its signal arrives. Run os migrate meta --from 16 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand.

Nested Shape: FlowNode.boundaryConfig

PropertyTypeRequiredDescription
attachedToNodeIdstring✅Host node ID this boundary event monitors
eventTypeEnum<'error' | 'timer' | 'signal' | 'cancel'>✅Boundary event trigger type
interruptingbooleanoptional (default: true)If true, the host activity is cancelled when this event fires
errorCodestringoptionalSpecific error code to catch (empty = catch all errors)
timerDurationstringoptionalISO 8601 duration for timer boundary events
signalNamestringoptionalNamed signal to catch

FlowNodeAction

Allowed Values

  • start
  • end
  • decision
  • assignment
  • loop
  • create_record
  • update_record
  • delete_record
  • get_record
  • http
  • notify
  • script
  • screen
  • wait
  • subflow
  • map
  • connector_action
  • parallel_gateway
  • join_gateway
  • boundary_event

FlowVariable

Properties

PropertyTypeRequiredDescription
namestring✅Variable name — a name without a leading $ (the $ names are the flow engine's own), read as {{ name }}
typestring✅Data type (text, number, boolean, object, list)
isInputbooleanoptional (default: false)Is input parameter
isOutputbooleanoptional (default: false)Is output parameter
defaultValueanyoptionalValue bound at run start when no parameter supplies one — this is what makes a declared variable always bound. An explicitly supplied param wins, including false and null; the boundary is params[name] !== undefined.

FlowVersionHistory

Properties

PropertyTypeRequiredDescription
flowNamestring✅Flow machine name
versioninteger✅Version number
definition{ name: string; label: string; description?: string; successMessage?: string; … }✅Complete flow definition snapshot
createdAtstring✅When this version was created
createdBystringoptionalUser who created this version
changeNotestringoptionalDescription of what changed in this version

Nested Shape: FlowVersionHistory.definition

PropertyTypeRequiredDescription
namestring✅Machine name
labelstring✅Flow label
descriptionstringoptional
successMessagestringoptionalMessage carried on AutomationResult for every terminal run (not only screen flows); the screen-flow UI shows it as a toast instead of a generic "Done".
errorMessagestringoptionalMessage carried on AutomationResult for every terminal run (not only screen flows); the screen-flow UI shows it as a toast instead of the raw error.
versionintegeroptional (default: 1)Version number
statusEnum<'draft' | 'active' | 'obsolete' | 'invalid'>optional (default: "draft")Deployment status
templateneveroptional[REMOVED] flow.template was removed in @objectstack/spec 17.0.0 (audit close-out) — no designer or engine path ever read it, so flagging a flow as a template/subflow did nothing. Delete the key. Shared logic is invoked via a subflow NODE referencing the flow by name. Run os migrate meta --from 16 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand.
typeEnum<'autolaunched' | 'record_change' | 'schedule' | 'screen' | 'api'>✅Flow type
variables{ name: string; type: string; isInput?: boolean; isOutput?: boolean; … }[]optionalFlow variables
nodes{ id: string; type: string; label: string; config?: Record<string, any>; … }[]✅Flow nodes
edges{ id: string; source: string; target: string; condition?: string | object; … }[]✅Flow connections
activeneveroptional[REMOVED] flow.active was removed in @objectstack/spec 17.0.0 (audit close-out) — it never had an effect: the engine arms flows from status, and active: false did NOT stop a flow (worse, the default read as disabled while the engine treated unset as enabled). Delete the key. Use status: 'obsolete' (or 'invalid') to unbind and disable a flow, status: 'active' to arm it. Run os migrate meta --from 16 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand.
runAsEnum<'system' | 'user'>optional (default: "user")Execution identity for the run: system = elevated (bypasses RLS), user = the triggering user (RLS-respecting). A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form.
errorHandling{ strategy?: Enum<'fail' | 'retry' | 'continue'>; maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; … }optionalFlow-level error handling configuration. A durable pause ends the retry-governed segment: strategy: 'retry' describes one synchronous dispatch, so a run that parks on an approval/screen/wait node and later resumes gets one attempt for anything that fails after the pause. Protect the post-pause half with its own failure handling in the flow — a try_catch node's retry around the post-resume work, or fault edges to a handler node.
protection{ lock: Enum<'none' | 'no-overlay' | 'no-delete' | 'full'>; reason: string; docsUrl?: string }optionalPackage author protection block — lock policy for this flow.
_lockEnum<'none' | 'no-overlay' | 'no-delete' | 'full'>optionalItem-level lock — controls overlay & delete (ADR-0010).
_lockReasonstringoptionalHuman-readable reason shown when a write is refused by _lock.
_lockSourceEnum<'artifact' | 'package' | 'env-forced'>optionalLayer that set _lock (artifact | package | env-forced).
_provenanceEnum<'package' | 'org' | 'env-forced'>optionalOrigin of the item (package | org | env-forced).
_packageIdstringoptionalOwning package machine id.
_packageVersionstringoptionalOwning package version.
_lockDocsUrlstringoptionalOptional documentation link surfaced next to _lockReason.

On this page