Effects
The only declaration that reaches the world, and everything that makes doing so replayable.
An effect reacts to appended events by doing something outside the process: sending mail, calling an API, invoking another command. It is the only declaration with a network, and every call it makes on the world is journaled.
effect NotifyCustomer {
on @order.placed as e { order_id, @key customer_id, email } {
// Inclusive of the triggering event, so a customer's first order leaves this at 1.
fold orders: Int = 0
on @order.placed(customer_id) => orders + 1
if email.is_none() {
log("order {order_id} has no address to confirm to")
return
}
let response = http.post(
"https://mail.example/confirm",
{
"to": reveal(email),
"order_id": order_id,
"first_order": orders == 1,
},
)
if response.status >= 400 {
log("confirmation rejected with status {response.status}")
}
}
}
Arms
An arm names the event types it triggers on, destructures the fields it wants, and runs a body:
on @booking.made as e { booking_id, @key guest_id, email } {
as e binds the whole event, so e.at (a Timestamp), e.id (a Uuid), e.position
(an Int) and the fields are all reachable. One arm handles one event type; two arms
cannot claim the same type in the same effect.
An effect may not trigger itself. Emitting an event that this effect handles, from a command this effect invoked, is rejected statically rather than discovered as a loop at three in the morning.
Lanes, and why @key is mandatory
Every arm names a lane with @key. Work partitions by that key: one lane processes in log
order, and different lanes never wait on each other.
on @order.placed as e { order_id, @key customer_id, email } {
One customer’s orders are confirmed in order. An address that will not accept mail holds up that customer and nobody else.
A composite key is a sequence, and the order is the written order: { @key a, @key b } and
{ @key b, @key a } are different lanes.
@key is mandatory because the alternative is a single global lane, and a single global
lane means one undeliverable address stops every effect in the system.
Delivery
| Form | Runs |
|---|---|
on |
once per matching event |
on latest |
once per key per batch, at the newest matching position in it |
on live |
only for events appended after this effect first activated |
on latest is for work that supersedes itself: recomputing a summary, pushing a current
state somewhere. Catching up, a key’s whole backlog collapses into one invocation, which is
why lag can fall by far more than the invocation count rises.
on latest may not invoke, because collapsing a backlog into one run means the commands
the skipped events would have invoked are not going to happen, and that is a decision the
runtime should not make silently.
on live resolves its boundary once, at first activation against a data directory, and
persists it. It is not re-resolved on a later boot, so it means “not the history that
existed when I was deployed” rather than “not the history that exists now”.
Arm-local state
An arm may fold, and its fold stops at the triggering event’s own position, inclusive:
fold orders: Int = 0
on @order.placed(customer_id) => orders + 1
A customer’s first order leaves that at 1, not 0. Because the fold is bounded at the trigger’s position, it is a function of the log prefix and that position, so every retry and every replay reproduces it exactly. That is why an effect folds the log rather than reading a projector: a projector’s contents depend on when you looked.
Reaching the world
| Call | Returns | Journaled |
|---|---|---|
http.get(url) |
Response |
yes |
http.post(url, body) |
Response |
yes |
http.put(url, body) |
Response |
yes |
http.patch(url, body) |
Response |
yes |
http.delete(url) |
Response |
yes |
invoke Name { .. } |
Outcome |
yes |
now() |
Timestamp |
yes, pinned per invocation |
reveal(value) |
the plaintext | no |
erase(value) |
nothing | yes |
log(message) |
nothing | no |
fail(message) |
nothing, terminal | n/a |
Every verb takes an optional trailing headers = { ... }. There is no timeout clause and
no timeout key: one attempt is capped at 10 s to connect and 30 s overall, both fixed in the
runtime.
response.status and response.body are the only parenless field accesses on a builtin
type in the language. The body is Json, read with .string(k), .int(k), .bool(k), .json(k),
.array(k) and .number(k), each returning an optional.
Recording what happened
An effect never appends. When it needs to record something, it invokes a command:
let result = invoke RecordConfirmation { booking_id, confirmed_at: now() }
if !result.ok() {
fail("could not record the confirmation: {result.message()}")
}
That command goes through the same append condition as any other, which is what keeps the log’s invariants true regardless of who is writing.
Commands under commands/internal/ have no HTTP route and exist for exactly this.
Failing
fail(message) is the only author-terminal outcome. It completes the position and moves
on, recording a terminal skip rather than retrying forever.
Use it when a retry cannot help: the data is gone, the request was structurally wrong, the remote said no permanently.
There is no way to ask for a retry, and the things that would want one have already been
re-sent: transport errors and the statuses that clear on their own never reach the arm. A
bare return completes the position just as fail does, without the terminal-skip record,
so reach for fail when you want the skip to be countable.
Effect-local functions
An fn declared inside an effect may call out, and may return nothing:
effect ConfirmBooking {
fn notify(url: String, body: Json) {
let response = http.post(url, body)
if response.status >= 400 {
log("notify failed with {response.status}")
}
}
on @booking.made as e { @key guest_id } {
notify(CONFIRM_URL, { "guest": guest_id })
}
}
It may not reveal, erase or read the clock with now(), it declares no fold of
its own, and it cannot be called from a fold arm. Keeping reveal and erase in the arm
itself is what lets the checker prove erasure happens last, and now() is pinned once per
invocation, so read it in the arm and pass the moment in.
Credentials
A secret is a declaration, not a constant:
secret DISCORD_WEBHOOK
It has no type and no value in the source. The value is supplied at deployment, the type is
Secret, and a Secret is a taint: it can be passed to an outbound call and cannot be
interpolated into a log line or a message. See
Keys and secrets.
What runs it
Everything above is the language. What the runtime does with a failing arm, a wedged lane and a journal that will not drain is Effects in production, and it is the page to read before you deploy one.