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
| Group | Operators |
|---|---|
| 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 tofloat; anyfloatoperand makes the resultfloat.+on two strings concatenates."a" + 1is an error — use interpolation.- Comparisons coerce
int↔float."1" == 1isfalse(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 theon success whenguards, 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 optionalasalias). Aforeachstate has no top-level action. lineandline_index(0-based) are bound in scope for each iteration.- An empty or
nullcollection → 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 (
postedabove) is bound to the ordered list of per-iteration responses, then theon successpath (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
}
maxis required (>= 1) — there is no unboundedwhile.- The condition is re-evaluated before each iteration, with
while_index(0-based) bound. - Condition falsy →
on success. Action failure →on fail. - Reaching
maxwith the condition still truthy →on failwith 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 | |
|---|---|
max | required — number of retries (extra attempts) |
delay | optional Go duration string ("200ms", "1s") — pause between attempts |
on | optional 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.