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: Int,
  shop_id: Int,
  // 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) encrypts the value under a key scoped to that subject

@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: the subject 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.

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.

There is no event versioning

An event type carries no version, and there is no upcast hook. Today the ways to evolve an event are to add an optional field, or to declare a new event type and have projectors and effects handle both.

This is the honest gap in the model rather than a design position, and retrofitting it is exactly the migration it would exist to avoid. Plan for it: the shape of an event is the part of a hekla project that is hardest to change later.