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.