Skip to content

Workflow Constructs

Workflow constructs are operations in the reserved wf. namespace. This section defines the base constructs for values, branching, iteration, concurrency, waiting, repetition, composition, and termination.

Constructs defined by optional profiles are specified in those profiles. Each construct is a step body and follows the common rules for step ids, bindings, modifiers, outcomes, and evaluation.

Unknown wf. operations are rejected under Duplicate and Unknown Keys; a construct key not permitted by the selected format version is rejected.

wf.value evaluates an expression-bearing value structure and binds the result.

Key Role Required Type or domain Default
wf.value Subject Yes Expression-bearing scalar, list, or map. —

Expressions at leaves are evaluated recursively; keys are data and MUST NOT contain interpolations.

- lead:
wf.value:
email: "{{ message.from | lower }}"
priority: "{{ classify.priority }}"

A scalar or list used directly as a step body uses the compact wf.value form defined under Step Structure. The corresponding compact-map rule under Step Structure applies only when no immediate key is an operation key or standard step-modifier name.

In either case, the entire step body is the value structure. Compact and explicit forms have identical evaluation and binding semantics.

A validator SHOULD warn when a compact wf.value map has an immediate key equal to an unprefixed construct name listed in the Operation-Key Grammar. A validator SHOULD NOT issue the compact-value warning for explicit wf.value.

Consequently, a data map with an operation-shaped or standard step-modifier top-level key uses explicit wf.value.

steps:
- status: ready
- recipients:
- alice@example.com
- bob@example.com
- settings:
retries: 3
urgent: true

These compact forms are equivalent to:

steps:
- status: { wf.value: ready }
- recipients:
wf.value:
- alice@example.com
- bob@example.com
- settings:
wf.value:
retries: 3
urgent: true

The explicit form disambiguates a map containing an operation-shaped data key:

- metadata:
wf.value:
example.key: value

Evaluation is pure and uses the step’s enclosing scope. The step succeeds and binds the fully evaluated value, or fails with the evaluation fault from any leaf.

The result is created atomically; a fault creates no partial binding. The prohibition on modifiers for wf.value is defined under Step Modifiers.

A projection is a map from names to wf.value-shaped structures. Every projection is evaluated in a source scope defined by the containing operation.

A binding projection creates immutable bindings or named result members in a destination scope or result defined by the containing operation. Projection names MUST satisfy the Workfile name syntax and be unique.

Names in binding projections are binding positions and are therefore subject to the reserved-name rule under Name Syntax and Reserved Names. Every expression is evaluated against the source scope, not against another entry’s projected value; projection entries MUST NOT depend on one another.

If any entry faults or fails its required type, the projection produces no map or bindings. outputs, trigger and transition into, and the with map of wf.render are binding projections.

The with map of wf.call is an argument projection: it produces a map for the child workflow and does not create bindings in the caller or child expression scope.

The with map of wf.agent is a context projection: it produces a map for the agent and does not create bindings in the enclosing or agent expression scope. The containing section defines each projection’s source scope, destination scope when it has one, and use of the resulting map.

A correlation match map is not a projection and creates no binding.

The when modifier is a boolean expression evaluated before the guarded step begins. If true, the step evaluates normally. If false, the step has outcome skipped, performs no operation, and its readable binding resolves to null.

- notify:
messaging.send:
to: "{{ customer.email }}"
text: Welcome!
when: "{{ customer.opted_in }}"

If customer.opted_in is false, messaging.send is not invoked, the step outcome is skipped, and notify resolves to null.

A statically non-boolean guard is a validation error. A guard that cannot be typed statically is checked at runtime; a non-boolean result or other evaluation fault fails the step and MUST NOT be treated as false or recorded as a skip.

Only the body kinds listed under Step Modifiers accept when. Compact values use a conditional expression when they require conditional evaluation; explicit wf.value can instead use when.

wf.route dispatches on one expression by equality.

Key Role Required Type or domain Default
wf.route Subject Yes Expression of non-nullable static type string or enum[...]; every other static type is invalid. —
cases Configuration Yes Nonempty map from distinct literal string labels to step lists. —
else Configuration No; prohibited for a statically established closed enum domain (document.control). Step list for a string subject or enum whose closed domain cannot be established. [].

A validator SHOULD warn when a wf.route subject has static type string and no explicit else, explaining that an unmatched value succeeds with an empty branch.

- triage:
wf.route: "{{ classify.category }}"
cases:
sales: [{ create_lead: { crm.create: { email: "{{ message.from }}" } } }]
support: [{ create_ticket: { helpdesk.create: { body: "{{ message.body }}" } } }]
else: []

The routing expression is evaluated once. Exactly one matching case runs; else, including the implicit empty else, runs if no case matches.

Cases are unordered, and their document order MUST NOT affect selection. Label distinctness is required by the cases rule above.

Each case and the explicit or implicit else creates a nested scope. The construct succeeds when the selected branch succeeds and binds that branch’s scope.

A path through the construct is statically permitted when it resolves in at least one branch; if it names a binding absent from the selected branch, it resolves to null. A path through wf.route or wf.choose has the join of its static types in every branch where it resolves.

If no join exists, a reference to the path is a validation error. Absence from one or more selectable branches makes the joined type nullable.

When the routing expression has a statically established closed enum domain, validation MUST reject labels outside that enum and require exactly one case for every member.

A missing member is document.control. Exhaustiveness proof uses exactly the sources and propagation rules under Closed Enum Domains.

wf.choose selects the first arm whose guard is true.

Key Role Required Type or domain Default
wf.choose Subject Yes Ordered, nonempty list of arms. —
else Configuration No Step list. [].

Each arm has exactly the two members below.

Key Role Required Type or domain Default
when Member Yes Boolean expression. —
then Member Yes Step list. —
- triage:
wf.choose:
- when: "{{ order.total >= 1000 }}"
then: [{ review: { sales.request_review: { order: "{{ order.id }}" } } }]
- when: "{{ order.total >= 100 }}"
then: [{ tag: { sales.tag: { order: "{{ order.id }}" } } }]
else: []

Arm guards are evaluated in declaration order. The first true arm runs, and later guards MUST NOT be evaluated.

else, including the implicit empty else, runs if every guard is false. A guard fault fails the construct rather than selecting else.

Each then and the explicit or implicit else creates a nested scope. The construct binds the selected scope and follows the same cross-branch path rule as wf.route.

Overlapping guards are permitted and resolved by order. A single arm with omitted or empty else is the defined representation of a guarded step group.

wf.for_each evaluates a list source and runs steps once per element.

Key Role Required Type or domain Default
wf.for_each Subject Yes Expression yielding a list or action-call source (defined below). —
as Configuration Yes Name of the immutable element binding in each iteration. —
steps Configuration Yes. Step list. —
max Configuration No Positive integer literal or literal unbounded; expressions are invalid with document.type. unbounded.
concurrency Configuration No Positive integer literal; expressions are invalid with document.type. 1.
retain Configuration No Literal all, failures, or none. all.
on_iteration_fail Configuration No Literal fail_fast or continue. fail_fast.

The as name MUST NOT collide with another visible binding. Iterations correspond to source positions and create isolated nested scopes; no binding crosses between iterations.

- attachments:
wf.for_each: "{{ message.attachments }}"
as: attachment
max: 25
concurrency: 4
on_iteration_fail: continue
retain: failures
steps:
- scan: { malware.scan: { file: "{{ attachment }}" } }

concurrency limits simultaneously executing iterations but does not change iteration positions or result aggregation. Scheduling order and the order of independent external effects are otherwise unspecified; an author who requires source-order effects MUST use concurrency: 1.

For an expression source, a list longer than an integer max fails the construct with flow.max_exceeded before any iteration begins and MUST NOT be truncated. An omitted max or explicit unbounded uses the finite source list’s length as the bound.

For example, given 21 items, wf.for_each: "{{ inputs.items }}" with max: 20 fails before any iteration begins. Using wf.for_each: "{{ inputs.items | slice(0, 20) }}" selects at most the first 20 items for iteration. The action-source rules below describe a different failure timing: some iterations may already have completed their external effects when an additional element exceeds max.

On success, every mode binds an object containing count (total iterations), succeeded (successful iterations), and failed (failed iterations), with count = succeeded + failed. Retained scopes are additional members of that object:

Value Retained scopes
all items: list of every iteration scope in source order; no failures member.
failures failures: list of failed iteration scopes in source order; no items member.
none No items or failures member.

These summary members exist in the construct binding, not in an iteration scope. An iteration step id such as count is therefore unambiguous. For example, attachments.count reads the total and attachments.items[0].scan reads a retained result with retain: all; the execution step path remains attachments[0].scan. Changing retention preserves summary access, but references to discarded scopes become invalid.

The count members are int values. A binding path to a member absent under the declared retention mode is a validation error with document.reference. Indexing follows the ordinary reference rules: attachments[0] is invalid because an object index must be a string; attachments['count'] reads the summary member.

Every iteration remains identifiable in logical execution state by its indexed step path and outcome even when its values are not retained. This is recorded execution information, not an indexed binding; persistent history is required only by an applicable profile. Every scope in items or failures has the static shape of the iteration body.

A path through a nested wf.route or wf.choose is typed by the same cross-branch rule, independent of which iteration is indexed.

The source can instead be an action-call map containing exactly one connector action whose value is an argument map or null; null denotes the empty argument map.

This action-source form structurally requires the wf.action-source-iteration capability. The resolved action MUST declare read-only effects. The source action’s output MUST declare exactly one field, and that field’s type MUST be a list. The list elements become iteration values; the action result creates no separate binding.

The source invocation uses the connector’s unambiguous deployment default connection and the action’s manifest retry policy. It otherwise uses ordinary action validation and failure semantics and has step path <construct-path>.(source). No cursor, continuation token, page reference, or pagination lifecycle enters workflow scope through an action source.

An action remains one Workfile invocation even when its connector internally paginates or batches to produce the declared list. An implementation can materialize that list before iteration or consume its elements incrementally.

When an integer max is exceeded, the construct fails with flow.max_exceeded as soon as an additional element is received. Whether any iteration begins or completes before that failure is unspecified behavior; a materializing implementation can fail before iteration, while an incremental implementation can already have completed iterations and their external effects.

A validator SHOULD warn when an action-source iteration has an integer max and its iteration body can dispatch an action that declares write effects. The warning SHOULD explain that an action-source max failure can occur after some iterations have completed their external effects.

An omitted max or explicit unbounded permits the finite action result itself to establish the bound.

Implementation note. Incremental consumption can keep an action source within a deployment’s documented value-size limits when materializing the same result would make that valid workflow unsupported. The rules under Resource Limits permit this support difference between implementations.

Iteration failures, on_iteration_fail, and ambiguity follow Failure Policy Precedence. Every retained iteration scope contains the reserved error binding, with its relative step path defined there.

wf.parallel runs named branches and joins their results.

Key Role Required Type or domain Default
wf.parallel Subject Yes Nonempty map from distinct branch names to step lists. —
on_branch_fail Configuration No Literal fail_fast or continue. fail_fast.

Every branch creates an isolated nested scope and can read enclosing bindings, but MUST NOT reference a sibling branch.

- enrich:
wf.parallel:
crm: [{ contact: { crm.lookup: { email: "{{ message.from }}" } } }]
billing: [{ account: { billing.lookup: { email: "{{ message.from }}" } } }]
on_branch_fail: continue

The executor can schedule branches concurrently or sequentially. Scheduling does not change branch identity or result aggregation; action responses and independent external effects can occur in any order expressly permitted by Execution Semantics.

After all applicable branches finish, the construct binds a scope whose members are the named branch scopes.

Branch failures, on_branch_fail, and ambiguity follow Failure Policy Precedence. Each branch scope contains the reserved error binding, with its relative step path defined there.

wf.wait delays continuation by a duration or until a timestamp. The explicit mode distinguishes an interval from a deadline without requiring the reader to infer an expression’s type.

Key Role Required Type or domain Default
wf.wait Subject Yes Map containing exactly one of for and until. —

The mode map uses the following members; either value can be a whole-value interpolation of the required type.

Key Role Required Type or domain Default
for Member One of for, until Nonnegative duration. —
until Member One of for, until Timestamp. —
- cooling_off: { wf.wait: { for: 2d } }
- day_before: { wf.wait: { until: "{{ hire.start_date | minus(1d) }}" } }

If the target instant is not later than the instant at which the step starts, the wait completes immediately. Otherwise the step succeeds no earlier than the target instant, subject to implementation scheduling delay.

wf.wait produces no readable binding. Its permitted modifiers are defined exclusively by Step Modifiers.

A Core Executor MUST support it within its documented resource limits; an implementation requiring durable suspension for a particular wait validates support under the applicable deployment or Durable Execution Profile requirements.

wf.repeat performs bounded polling. The table defines its step-list subject and three required configuration keys.

Key Role Required Type or domain Default
wf.repeat Subject Yes Iteration step list. —
every Configuration Yes Nonnegative duration, including a whole-value interpolation. —
max Configuration Yes Positive integer literal; unbounded is prohibited. —
until Configuration Yes Boolean expression. —
- indexed:
wf.repeat:
- check: { search.status: { id: "{{ ticket.id }}" } }
every: 30s
max: 20
until: "{{ check.status == 'indexed' }}"

every is evaluated once, in the enclosing scope, after when succeeds and before the first iteration.

The first iteration starts immediately; before each later iteration the executor waits for every after completion of the preceding iteration.

Each iteration creates an isolated nested scope. After an iteration completes successfully, until is evaluated in that iteration’s scope.

If true, the construct succeeds and binds an object with count, an int giving the number of iterations, and items, the list of iteration scopes in execution order. If false and fewer than max iterations have run, execution waits and repeats.

If false after iteration max, the construct fails with flow.max_exhausted. A fault in until fails the construct. An affected until follows dry-run control suppression.

No binding crosses between iterations. A failed iteration fails the construct under ordinary step and recovery policy; wf.repeat has no on_iteration_fail or retain key.

This section defines the wf.composition capability. A Workfile uses this capability when it contains wf.call, whether or not that step is reachable during execution.

wf.call syntax, callee resolution, call-graph validation, and callee output inference are Workfile Core validation requirements. Executing wf.call requires wf.composition. Every Core Executor claims this capability under Core Executor Conformance.

wf.call invokes a statically identified Workfile as a child run.

Key Role Required Type or domain Default
wf.call Subject Yes Literal project-relative path without interpolation, satisfying the project-content path rules. —
with Configuration No Argument projection for child inputs. {}
- refund:
wf.call: workflows/refunds/process.workfile
with:
account: "{{ account.id }}"

with is an argument projection evaluated in the caller’s scope and supplied as the child’s inputs; its names do not create child bindings but become members of the child’s inputs map. Validation MUST resolve the callee, reject unknown input names, ensure required inputs are supplied or defaulted, and validate each supplied value.

Unknown input names and missing required inputs are rejected statically under these rules. Supplied values still undergo the runtime checks under Static Typing and Assignability and Inputs, including json compatibility, string-to-enum membership, and input constraints.

The callee receives a new run.id, sets run.parent to the caller’s run.id, and otherwise has its own root scope. The child inherits the caller’s run.dry_run value. Partial child results follow dry-run propagation.

A Workfile MUST NOT override dry-run status through with or any other workflow value. The step succeeds when the child run completes successfully or stops successfully, and binds the child’s successful workflow result. A failed child supplies its terminal code to the wf.call step’s failure handler and ordinary failure policy.

A child terminal result recording the abort ambiguity disposition propagates directly to the calling run, preserves its ambiguity information, and bypasses the wf.call step’s modifiers and enclosing failure policies. A child result recording fail instead follows the ordinary call-failure rule above, as required under on_unknown.

A child cancelled independently while its calling step remains active supplies a flow.child_cancelled failure to that step’s failure policy. Cancellation of the calling step requests cancellation of the child under Internal Work Cancellation; a child that is suspended or held keeps an active calling step pending until it reaches a terminal outcome or is cancelled.

A callee binds an empty map when its selected result projection is empty. A path not declared by the callee’s joined successful result type is a validation error. Keys declared only by a stop result are therefore valid paths, with nullability determined under Outputs.

A wf.call whose resolved callee declares on is invalid with document.control; a called Workfile cannot also be an initiation target.

Every callee and its dependencies MUST be resolved and pinned before execution. The complete call graph MUST be acyclic. An implementation MUST support a call graph at least eight documents deep, counting the initially invoked Workfile.

For an initial lookup against supplied project content, a missing callee is invalid with dependency.unresolved; a call-graph cycle is invalid with document.control. Missing resolution input or unavailable previously pinned content follows Resolution and Pinning. A graph deeper than the target’s documented limit is unsupported with deployment.limit.

wf.stop ends the current run early and successfully.

Key Role Required Type or domain Default
wf.stop Subject Yes Expression-bearing string reason. —
result Configuration No Binding projection, evaluated in the stop step’s lexical scope. {}.

The stop step’s lexical scope includes visible enclosing bindings and, in a state-form Workfile, the current state-entry bindings; ordinary reference and scope restrictions still apply.

- no_change:
when: "{{ current_value == previous_value }}"
wf.stop: No change detected.
result: { changed: false, count: 0 }

The executor MUST evaluate the reason first, then the result, before successful termination begins. A fault in either evaluation while the stop remains active fails the step and run with that fault code, without producing a successful result. A skipped or suppressed stop evaluates neither its reason nor its result and does not initiate termination. Dry-run result substitution is defined under Dry Runs.

After both evaluations succeed and the stop is accepted, the step records success with the reason, and the run reaches stopped with that result. Subsequent steps are not_run. The executor MUST NOT evaluate root outputs for an explicit stop. wf.stop is not a failure and does not activate retry or recovery policy.

Termination applies to the run, not merely the nested scope containing the step. Internal Work Cancellation applies to sibling work and every enclosing construct that cannot complete.

When concurrent stops compete, the executor MUST accept at most one stop, preserving its reason and complete result together; other stop evaluations cannot merge with or replace that result. Scheduling determines which stop is accepted; declaration order gives no priority. Acceptance requires the stop still to be active and the run not to have accepted another termination or cancellation. Interrupted competitors follow Internal Work Cancellation.

In a child run, wf.stop stops only that child; its wf.call step succeeds and binds the child’s successful result.

A validator SHOULD warn, explaining the nullability change, when an omitted stop result makes a normal-completion key nullable compared with its non-null type in the join of all other result shapes.

wf.stop produces no readable step binding. Its permitted modifiers are defined exclusively by Step Modifiers.

wf.fail ends the current run early and unsuccessfully.

Key Role Required Type or domain Default
wf.fail Subject Yes Expression-bearing string reason. —
code Configuration No Literal matching author-failure-code in the Syntax Reference; no interpolation. failed.
- threshold_guard:
when: "{{ orphan_ratio > inputs.maximum }}"
wf.fail: "Refusing to archive {{ orphans | length }} records."
code: threshold_exceeded

The step and run have outcome failed with the selected code and evaluated reason. Internal Work Cancellation applies to subsequent and sibling work and every enclosing construct that cannot complete. Workflow outputs are not evaluated for the failed run, as defined under Outputs.

wf.fail expresses an author decision, so retry, on_fail, on_iteration_fail, on_branch_fail, recovery policy, and compensation do not intercept it. It produces no readable binding. Its permitted modifiers are defined exclusively by Step Modifiers.

A fault while evaluating its reason fails the step and run with the fault code instead.

In a child run, wf.fail fails that child. The calling wf.call step receives the same failure code, then applies its own failure policy.