Skip to content
hekla

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.