Skip to content
hekla

Commands

The only declaration that appends, why a fold is a read declaration rather than a variable, and what the three outcomes mean.

A command is the only declaration that writes to the log. It replays the history its decision depends on, decides, and appends.

command JoinWaitlist(entry_id: Uuid, email: String) {
  fold joined: Bool = false
    on @waitlist.joined(email) => true

  if email.trim() == "" {
    return invalid("an entry needs an email")
  }
  if joined {
    return reject AlreadyJoined
  }

  emit @waitlist.joined { entry_id, email }
}

The declared name is the route: this is POST /commands/JoinWaitlist, and the request body is the parameters as JSON.

A fold is a read declaration

fold is the idea the rest of the runtime rests on, and the keyword is deliberately not let.

fold joined: Bool = false
  on @waitlist.joined(email) => true

That does two things at once. It names a slice of the log, every @waitlist.joined carrying this address, and it folds that slice into a value.

The second half is the part people expect. The first half is the one that matters: the slices a command folded are the condition its append is checked against. If another writer lands in one of them between the read and the append, the append is refused and the command runs again against the new log.

So optimistic concurrency falls out of the code rather than being configured beside it, and the declared boundary cannot drift from the actual reads, because they are the same object. That is a Dynamic Consistency Boundary, and Why DCB is the longer argument.

The width of a slice is the cost. A fold on one order id conflicts with almost nothing. A fold on every order in a shop makes every order in that shop conflict with every other, which is what a hard cap actually costs:

// Narrow: this one order. A caller retrying the same `order_id` is a no-op rather
// than a second order.
fold placed: Bool = false
  on @order.placed(order_id) => true

// Wide on purpose: an allocation is a rule about every order in the shop, so every
// order in a shop conflicts with every other. That is what a hard cap costs, and the
// retry loop is what absorbs it.
fold sold: Int = 0
  on @order.placed(shop_id) => sold + 1

Inside a fold arm you are folding, and nothing else: no clock, no network, no emit. The arm is a function of the events in the slice, which is what lets the runtime resolve it against any prefix of the log and get the same answer.

Stages

A run of adjacent fold and guard declarations is one pass over the log. Any other statement closes that run, and a fold after it opens a new pass.

command Example(id: Uuid) {
  fold a: Int = 0                 // stage one
    on @thing.happened(id) => a + 1
  guard ThingExists { id }        // still stage one

  if a > 10 {                     // this closes it
    return invalid("too many")
  }

  fold b: Bool = false            // stage two, a second pass
    on @other.thing(id) => true
}

Two stages is not wrong, it is just two reads. Grouping folds together is usually free and occasionally the difference between one pass and four.

The three outcomes

Outcome Status Carries Means
falls off the end 200 positions and tags written committed, possibly with no events
return invalid(msg) 400 a message the request is wrong
return reject Name 422 a code and a message the world says no

The distinction between the last two is worth holding onto. invalid is about the request: a blank email is wrong whoever sent it, and the caller learns nothing about the log. reject is about the world: this address is already taken, which is a fact the caller could not have known, and it carries a code a client can switch on.

The condition comes back with all three, because a refusal still read the log and a trace needs to know what it depended on. Only a committing command has an append to check against it, so only a committing command is re-decided on a conflict.

emit

emit writes an event whole:

emit @waitlist.joined { entry_id, email }

Every declared field must be given. There is no partial event and no default fill, because an event is a fact and a fact with a hole in it is not one.

A command may emit more than once, and everything it emits commits in one atomic append under one condition.

What a command may not do

It cannot reach the network, decrypt sealed content, or call an effect. http.post does not parse inside a command; neither does reveal.

It can read a clock, and now() is pinned once per invocation, so two calls see the same instant and a conflict retry does not drift.

Conflicts

When the append condition fails, the runtime re-runs the command against the new log. The budget is HEKLA_MAX_ATTEMPTS, default 5, capped at 15.

Exhausting it answers 409 with code concurrency_conflict. That is not a failure of your code; it means the boundary you chose is hotter than the retry budget can absorb, and the fix is usually a narrower slice rather than a bigger number.