Flow schemas of the ObjectStack Automation Protocol: Flow, FlowEdge, FlowNode and 3 more — each property with its type, default and a TypeScript example.
Message 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".
errorMessage
string
optional
Message 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.
version
integer
optional (default: 1)
Version number
status
Enum<'draft' | 'active' | 'obsolete' | 'invalid'>
optional (default: "draft")
Deployment status
template
never
optional
[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.
[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.
runAs
Enum<'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.
Flow-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.
Variable name — a name without a leading $ (the $ names are the flow engine's own), read as {{ name }}
type
string
✅
Data type (text, number, boolean, object, list)
isInput
boolean
optional (default: false)
Is input parameter
isOutput
boolean
optional (default: false)
Is output parameter
defaultValue
any
optional
Value 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.
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.
[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.
Configuration 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.
Configuration 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.
Predicate (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.
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)
label
string
optional
Label on the connector
isDefault
boolean
optional (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.
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.
maxRetries
integer
optional (default: 0)
Retry attempts after the initial one. Read only under strategy: 'retry', which requires >= 1; 0 (the default) means no retry.
backoffMs
integer
optional (default: 1000)
Base delay before the first retry (ms); subsequent delays multiply by backoffMultiplier
backoffMultiplier
number
optional (default: 1)
Exponential backoff multiplier; 1 (the default) keeps the delay flat
maxRetryDelayMs
integer
optional (default: 30000)
Ceiling for a single backoff delay (ms)
jitter
boolean
optional (default: false)
Randomize each delay within [50%, 100%] of its computed value — spreads a thundering herd of simultaneous retries
retryDelayMs
never
optional
[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.
fallbackNodeId
never
optional
[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.
Predicate (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.
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)
label
string
optional
Label on the connector
isDefault
boolean
optional (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.
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.
[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.
Configuration 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.
Configuration 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.
Mapped 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.
ISO 8601 duration (e.g., "PT1H") or wait time for timer events
signalName
string
optional
Named signal or webhook event to wait for
timeoutMs
never
optional
[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.
onTimeout
never
optional
[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.
Variable name — a name without a leading $ (the $ names are the flow engine's own), read as {{ name }}
type
string
✅
Data type (text, number, boolean, object, list)
isInput
boolean
optional (default: false)
Is input parameter
isOutput
boolean
optional (default: false)
Is output parameter
defaultValue
any
optional
Value 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.
Message 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".
errorMessage
string
optional
Message 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.
version
integer
optional (default: 1)
Version number
status
Enum<'draft' | 'active' | 'obsolete' | 'invalid'>
optional (default: "draft")
Deployment status
template
never
optional
[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.
[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.
runAs
Enum<'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.
Flow-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.