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.