Skip to content
hekla

Guards and refusals

A named proposition about the log, and a named reason for saying no.

Two small declarations that exist so a rule stated in several commands is written once.

Guards

A guard is a named proposition about the log. It folds, it decides, and it hands nothing back:

guard SeatIsFree(workshop_id: Uuid, seat: Int) {
  fold taken: Bool = false
    on @seat.booked(workshop_id, seat) => true

  if taken {
    return reject SeatTaken
  }
}

A command uses it by name:

command BookSeat(workshop_id: Uuid, seat: Int, attendee: String) {
  guard WorkshopIsOpen { workshop_id }
  guard SeatIsFree { workshop_id, seat }

  emit @seat.booked { workshop_id, seat, attendee }
}

Three things follow from that.

Order on the page is the precedence. The first guard that refuses is the answer, so writing WorkshopIsOpen above SeatIsFree means a closed workshop reports as closed rather than as a taken seat.

A guard’s slices join the caller’s append boundary. The guard read the log, so what it read is part of what the command’s append is conditioned on. Sharing a rule shares its concurrency cost too, which is the honest trade and the reason a guard is not free.

Guards compose. One guard may use another, and the boundary accumulates transitively. hek check --boundaries prints what each command ends up guarding, which is the way to see what a deep guard chain actually costs.

What a guard may not do

A guard’s only outcomes are return reject Name and return invalid(...). It cannot emit, it cannot hand a value back, and it reads the log once.

That last restriction is what keeps it a proposition rather than a subroutine. If you find yourself wanting a value out of a guard, what you want is a fold in the command, or a fn.

Naming

Name a guard as a proposition, not as a thing: CourseIsDefined, not Course. It reads correctly at the call site, where the line says guard CourseIsDefined { course_id }, and it makes an unhelpful name obvious when the proposition it states is vague.

Refusals

A refusal is a named reason a command said no.

refusal AlreadyJoined "that email is already on the list"

Used as return reject AlreadyJoined, it produces 422 with:

{
  "correlation_id": "d50eb468-55fb-4411-983f-8400e250dc92",
  "causation_id": "6a4bd410-c971-488a-8243-771847cbe260",
  "error": {
    "code": "already_joined",
    "message": "that email is already on the list"
  }
}

The wire code is derived from the name, in snake_case. That is why the name must be capitalised and carry no underscore: AlreadyJoined becomes already_joined, and there has to be exactly one way back and forth.

Declaring the reason once is what keeps one code from acquiring two messages depending on which command rejected.

Refusals with fields

A refusal can interpolate:

refusal InsufficientFunds(needed: Money(2), available: Money(2))
  "needs {needed} but only {available} is available"

Rejected as return reject InsufficientFunds { needed: total, available: balance }.

Reading one back

Inside an effect, invoke returns an Outcome, and a refusal can be recognised by name:

let result = invoke RecordConfirmation { booking_id }
if result.refused(AlreadyConfirmed) {
  return
}

refused takes a code, and a bare refusal name resolves to its own, so a renamed or misspelled refusal is a compile error here rather than a branch that silently stops matching. A string literal is still accepted, and still checked by nobody.

invalid is not a refusal

invalid(...) carries no code and is not declared anywhere. It means the request itself is malformed, which is a different question from the world saying no. See Commands for where the line sits.