Skip to content
hekla

Events

The declaration that defines a fact, how fields become tags, and what the runtime wraps around one.

An event is a fact that already happened. It is written whole, it never changes, and it is the only thing in a hekla project that is genuinely durable.

event @order.placed {
  order_id: Uuid,
  // Subject ids stay plaintext: they are how the runtime finds the key.
  customer_id: Customer,
  shop_id: Shop,
  // Personal, scoped to the customer.
  email: String? @subject(customer_id) @max(200),
  shipping_address: String? @subject(customer_id) @max(200),
  // The shop's commercial figure, scoped to the shop.
  order_total: Money(2) @subject(shop_id),
  // Free text nobody queries: opt out of tagging, and of being a huge tag.
  notes: String @no_index @max(500),
}

The name is a path, @domain.thing, and it is global: paths do not live in the six name spaces the other declarations use.

Events are conventionally declared under events/, but nothing enforces that, because where an event is declared changes nothing about what the runtime does with it.

Fields become tags

Every field is indexed and becomes a store tag unless it opts out. That is what makes a field available to a fold slice, which is the whole point: a command can only condition its append on something the store can match.

event @waitlist.joined {
  entry_id: Uuid,
  email: String @max(200),
}

Both fields are tagged, so both @waitlist.joined(entry_id) and @waitlist.joined(email) are slices a command can fold.

Because the tags are derived from the declaration, there is no second list to keep in step with it. Adding a field makes it foldable; nothing else has to be told.

The annotations an event field takes

Annotation Effect
@max(n) bounds a String or String?. Legal on nothing else
@no_index keeps the field out of the tag set, so it is not foldable
@subject(field) seals the value under a key scoped to the subject that field names
@absent(value) what the field reads as on a stored event written before it existed

@no_index is for free text nobody queries. A 500-character note as a tag is a large tag that no slice will ever match on, so opting out is both a modelling statement and a storage one.

@subject is covered in Personal data. The short version: it names a sibling field whose type is a declared subject, and that type is the namespace the key is filed under. The id itself stays plaintext, because that is how the runtime finds the key, and it may not be optional, because a missing id is no question at all. A projector column receiving sealed content is the thing that must be optional, because an erased value reads back absent.

Any declared type can be sealed, a record and a container included, so one address is one field rather than nine.

A subject declared under a parent adds one rule to the event: it carries an id field for every ancestor of that subject, in plaintext, once and not optional. Minting the child’s key wraps it inside the parent’s, and the event in front of the runtime is the only place the parent’s id can come from. None is refused, and so is two.

The envelope

The runtime wraps every appended event in metadata you do not declare:

position its place in the total order across the whole log
correlation_id shared by everything caused by one original request
causation_id the immediate cause of this particular append
timestamp when the append committed

A command’s response carries the correlation and causation ids and the positions it wrote, which is what makes ?after= work on the read side.

Correlation flows across an effect boundary too, so a command, the effect it triggered and the command that effect invoked share one correlation id. GET /admin/traces/{id} is that chain.

Idempotency

Two mechanisms, and they answer different questions.

A caller’s idempotency-key header makes a retried request return the original response instead of acting twice. That is transport-level: it protects against a dropped response.

A fold on the identity you were given makes the command itself idempotent:

fold placed: Bool = false
  on @order.placed(order_id) => true

if placed {
  return
}

That protects against anything, including a caller who lost their key, because it is a statement about the log rather than about the request. A committed command that decided to do nothing still answers 200 with an empty events array.

Identity itself is derived rather than minted. There is no uuid4(); Uuid.derive(seed, name) is a pure function of both arguments, so the same inputs produce the same id on a retry and on a replay.

Adding a field to an event that already happened

An event type carries no version and there is no upcast hook. What a field carries instead is @absent, which says what it reads as on a payload written before the field existed:

event @booking.confirmed {
  booking_id: Uuid,
  channel: String @max(20) @absent("email"),
}

The field stays required. emit must supply it on every new append, and the literal is consulted only on the way back out of the log, where a stored payload has no key for it. That is the difference from String?: an optional says absence is a real domain state a handler must consider forever, and @absent says the gap is an artefact of when the field was declared.

A required field with neither is the shape that does not work. Every stored event of that type becomes undecodable, and hekla refuses to serve a program that cannot read its own log, naming the type and the field: @booking.confirmed (no stored event can carry channel). See Running a project.

Four shapes are refused, all at parse time. @absent on a field that is already optional, because the type has answered the question. @absent on a @subject field, because sealed content has no plaintext literal to give, and an erased subject needs the field optional anyway. It does reach sealed content one level down: a seal is read as stored history rather than as a request body, so a field added to a subject-bound record today reads as its @absent literal on every seal already written. A literal longer than the field’s own @max, because it could not be stored if it were written. And a literal that is not of the field’s type, resolved by the same rule as an entity column’s default.

The annotation is part of an event’s shape, so an effect arm matching several event types needs the same @absent on a shared field as it needs the same type. Naming the field in the destructure is unknown-member against the first type that does not match; reading it off the binding is event-shape.

What still has no path

Renaming a field, or changing its type, is not evolution the declaration can absorb: a field’s type is part of the fact. The move is to declare the new shape under a new name carrying @absent, and drop the old field, which stops being decoded once nothing declares it. Declaring a new event type and handling both is the larger version of the same answer.

Plan for it: the shape of an event is the part of a hekla project that is hardest to change later.