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.