Skip to main content

Expressions, let, Loops & Retry

This page covers the WSL expression language and the state-level constructs that build on it: let bindings, foreach and while loops, and retry.

All of these are evaluated engine-side. The transition wire protocol is unchanged — transitions still receive plain resolved argument maps.

The expression language​

Expressions appear in when, if, let, a foreach collection, a while condition, and retry's on: guard.

Operators​

GroupOperators
Arithmetic+ - * / %
Comparison== != < <= > >=
Boolean&& || !
Null-coalesce??

Precedence, lowest to highest: ?? → \|\| → && → equality → relational → additive → multiplicative → unary → postfix [index] → primary.

  • int op int → int; a / that does not divide evenly promotes to float; any float operand makes the result float.
  • + on two strings concatenates. "a" + 1 is an error — use interpolation.
  • Comparisons coerce int ↔ float. "1" == 1 is false (not an error); ordering a string against a number is an error.
  • Division / modulo by zero is an error (routes to on fail).

Literals​

1 3.14 "text" 'text' true false null
[1, 2, 3] {code: "US", rate: 3}

Paths​

A bare dotted path resolves against the workflow context:

result.ok constants.version err.message
line.amount items[0] cfg.limits.maxAmount

The <<path>> token form is equivalent to a bare path and remains supported.

String interpolation​

let label = "posting ${line.amount} to ${account.code}"
let msg = "retry ${attempt + 1} of ${constants.maxRetries}"

${ ... } holds a full expression. Escape a literal dollar as \$.

Builtins​

Pure, side-effect-free, no transition calls:

len · lower · upper · contains · startsWith · endsWith · int · float · string · bool · default(x, fallback) · has(path)

Truthiness​

&&, ||, !, and every when / if / while guard use truthiness:

Falsy: null, false, 0, 0.0, "", [], {}. Truthy: everything else.

So when user.name means when truthy(user.name). && and || return an operand (JS-style): a && b is a when a is falsy, otherwise b.


let bindings​

state PriceLine {
let base = 10
let total = base * $constants.rate
let label = "line ${total}"
action ledger/ledger.Post(amount: total, note: label) as posted
on success -> Done
on fail -> Failed
}
  • Evaluated in order when the state is entered — before if, the action arguments, and the on success when guards, all of which can reference them.
  • Single-assignment: re-assigning a name in the same state is a validation error.
  • Each binding may reference earlier bindings and anything in the context.

SWSL​

In .swsl, let lines sit at module level alongside const and run once when the workflow starts:

module pricing

const { rate: 3 }

let base = 10
let total = base * $constants.rate

ledger.Post(amount: total) -> common.Response(message: "done") -> .

Multi-guard on success when​

A state may have several on success when <expr> -> <state> transitions plus one plain on success -> <state>:

state CheckStatus {
action orders/orders.Status(id: $request.id) as st
on success when st.state == "shipped" -> Shipped
on success when st.state == "cancelled" -> Cancelled
on success -> Pending
on fail -> Failed
}

Guards are evaluated top to bottom; the first truthy one wins. The plain on success is the fallback when no guard matches. With no fallback and no match, the state fails closed (on fail).


foreach — iterate a collection​

state PostLines {
foreach line in <<invoice.lines>> {
action ledger/ledger.Post(amount: line.amount, account: line.account) as posted
}
on success -> Done
on fail -> Rollback
}
  • The body is exactly one action (with an optional as alias). A foreach state has no top-level action.
  • line and line_index (0-based) are bound in scope for each iteration.
  • An empty or null collection → zero iterations → on success.
  • A non-list collection is an error.
  • Sequential by default; the first iteration failure stops the loop and takes on fail.
  • On full success the alias (posted above) is bound to the ordered list of per-iteration responses, then the on success path (guards first).

Bounded concurrency​

foreach line in <<invoice.lines>> parallel[limit: 4] {
action ledger/ledger.Post(amount: line.amount) as posted
}

parallel[limit: K] runs iterations concurrently, at most K in flight. Bare parallel (no [limit]) is unbounded. Every iteration runs to completion even if some fail (no cancellation); results stay ordered; any failure → on fail with an aggregated error.


while — loop on a condition​

state Drain {
while[max: 500] <<queue.size>> > 0 {
action queue/queue.Pop() as item
}
on success -> Done
on fail -> Failed
}
  • max is required (>= 1) — there is no unbounded while.
  • The condition is re-evaluated before each iteration, with while_index (0-based) bound.
  • Condition falsy → on success. Action failure → on fail.
  • Reaching max with the condition still truthy → on fail with a "reached max iterations" error.

Because v1.0.0 of the engine cannot re-evaluate ordinary when on state revisits, while is the supported way to express a bounded loop whose exit depends on changing state.


retry — re-run a failed action​

state ChargeCard {
retry[max: 3, delay: "200ms", on: "err.retryable == true"]
action payments/payments.Charge(amount: $total) as charge
on success -> Confirm
on fail -> Failed
}
Attribute
maxrequired — number of retries (extra attempts)
delayoptional Go duration string ("200ms", "1s") — pause between attempts
onoptional expression; after a failed attempt err is bound to {message, ...} (plus a map response's fields). A falsy result stops retrying.

retry re-runs only the action, never downstream states. A transition under a retry policy must be idempotent — it may be called more than once.

retry is not yet supported on a foreach / while state.


Execution guards​

Independent of any loop construct, the engine aborts a run that:

  • exceeds ~10 000 total state transitions, or
  • enters any single state more than ~1 000 times.

These catch a runaway manual loop (a transition that keeps routing back to an earlier state). foreach / while are additionally bounded by their collection length / max.