Event-sourcing runtimeone languageone binarypre-1.0, and moving
The restrictions
are the point.
A command cannot reach the network. A projector cannot decrypt. A fold cannot read a clock. Each holds because of what kind of declaration it is, not because something checks at run time.
That is what lets hekla rebuild a read model from position zero, replay an effect without re-sending it, and tell you before a deploy whether your new code would still do the same thing.
event @order.placed {
order_id: Uuid,
customer_id: Int,
email: String @subject(customer_id) @max(200),
total: Money(2),
}
event @order.cancelled {
order_id: Uuid,
customer_id: Int,
}
refusal TooManyOpen "this customer has too many open orders"
command PlaceOrder(order_id: Uuid, customer_id: Int, email: String, total: Money(2)) {
// What this folds is what it conflicts on: if another writer lands in the same
// slice first, the append is rejected and the whole command retries.
fold open_orders: Int = 0
on @order.placed(customer_id) => open_orders + 1
on @order.cancelled(customer_id) => open_orders - 1
if open_orders >= 10 {
return reject TooManyOpen
}
emit @order.placed { order_id, customer_id, email, total }
}$ curl -fsSL https://hekla.tqwewe.com/install.sh | shlinux x86_64 / aarch64Declare it once.
The runtime serves it.
There is no build step, because there is nothing to compile. Every route, every read model, the OpenAPI document and the admin console are derived from the declarations below, and the name in the source is the name on the wire. Deploy is restart.
// `@order.placed` carries data for two different subjects: the customer and the shop.
// Per-field subjects (rather than scoping the whole event to one) let each be erased
// independently: shredding the customer leaves the shop's order record intact, and
// shredding the shop never touches the customer's personal fields.
//
// The customer's two fields are optional, and that is load-bearing rather than
// incidental. An erased subject's column reads back absent, so the type has to admit
// absence for erasure to be observable through a read model at all.
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),
}tags derived from the fields
Every field is indexed and becomes a store tag unless it opts out with @no_index, so there is no second list to keep in step with the declaration.
email: Sealed(String, customer_id)
Encrypted under a key scoped to that customer, in the payload, the tag index and every read-model column, before it reaches storage. order_total is scoped to the shop instead, so the two erase independently.
components/schemas/event.order.placed
Each event gets a schema in the generated OpenAPI document, describing the log's vocabulary rather than a wire shape.
refusal SoldOut "this shop's launch allocation is gone"
command PlaceOrder(
order_id: Uuid,
customer_id: Int,
shop_id: Int,
email: String?,
shipping_address: String?,
order_total: Money(2),
notes: String,
) {
// Narrow: this one order. A caller retrying the same `order_id` is a no-op rather
// than a second order.
fold placed: Bool = false
on @order.placed(order_id) => true
// Wide on purpose: an allocation is a rule about every order in the shop, so every
// order in a shop conflicts with every other. That is what a hard cap costs, and the
// retry loop is what absorbs it.
fold sold: Int = 0
on @order.placed(shop_id) => sold + 1
if placed {
return
}
if sold >= LAUNCH_ALLOCATION {
return reject SoldOut
}
emit @order.placed {
order_id,
customer_id,
shop_id,
email,
shipping_address,
order_total,
notes,
}
}POST/commands/PlaceOrder
The route is the declared name, not the file. The body is the parameters as JSON, and a key the command does not declare is a 400 naming it.
422sold_out
A refusal reaches the caller as its name in snake_case, with the message settled at the declaration rather than at each site that answers with it.
409after five re-decides
Another order in the same shop lands inside the wide slice, so the command re-folds and retries. Only an unresolved conflict answers 409.
// Orders by id. The customer's personal columns receive sealed content, so the seal
// propagates onto them and the projector stores ciphertext without ever handling
// plaintext; the read API decrypts on the way out.
//
// `GET /read/CustomerOrders/Order/{order_id}` returns one order with the personal
// fields decrypted. After `hekla erase customer_id <id>` those two columns read back
// absent while the row and its plaintext `customer_id` remain, which is what a
// per-field subject buys: the shop's `order_total` is scoped to a different key and is
// untouched, though this read model does not carry it.
projector CustomerOrders {
entity Order {
order_id: Uuid @key,
customer_id: Int @index,
email: String? @max(200),
shipping_address: String? @max(200),
}
on @order.placed { order_id, customer_id, email, shipping_address } {
put Order { order_id, customer_id, email, shipping_address }
}
}GET/read/CustomerOrders/Order/{order_id}
One row by its key, typed from the column that declared @key, with the sealed columns decrypted on the way out.
GET/read/CustomerOrders/Order?customer_id=7
Only the key and declared indexes are filterable. Anything else is a 400, and pagination is a cursor rather than an offset.
POST/projectors/CustomerOrders/replay
Rebuild from position 0 into a fresh database, seal it, and rename it in. A reader never sees a torn one.
effect NotifyCustomer {
on @order.placed as e { order_id, @key customer_id, email } {
// Inclusive of the triggering event, so a customer's first order leaves this at 1.
fold orders: Int = 0
on @order.placed(customer_id) => orders + 1
if email.is_none() {
log("order {order_id} has no address to confirm to")
return
}
let response = http.post(
"https://mail.example/confirm",
{
"to": reveal(email),
"order_id": order_id,
"first_order": orders == 1,
},
)
if response.status >= 400 {
log("confirmation rejected with status {response.status}")
}
}
}a journal entry per call
Every impure call looks itself up in the journal first. A crash after the POST but before the journal write replays the POST rather than sending a second one.
one lane per @key customer_id
One customer's orders are confirmed in log order, and an address that will not accept mail holds up nobody else.
GET/admin/effects/NotifyCustomer/invocations
Each invocation and the calls it journaled, with what every one of them returned. This is how a wedge gets diagnosed.
// The narrow slice. A caller retrying the same order writes nothing rather than a
// second order, and says so by succeeding.
test "a repeat of the same order writes nothing" {
given @order.placed {
order_id: "11111111-1111-1111-1111-111111111111",
customer_id: 7,
shop_id: 1,
email: "ada@example.com",
shipping_address: "1 High St",
order_total: 25.99,
notes: "",
}
run PlaceOrder {
order_id: "11111111-1111-1111-1111-111111111111",
customer_id: 7,
shop_id: 1,
email: "ada@example.com",
shipping_address: "1 High St",
order_total: 25.99,
notes: "",
}
expect nothing
}a real log, not a mock
Each case runs against a real tephra log in a temporary directory, real SQLite read models and a real key store, so the slice and the append condition are genuinely exercised.
respond stubs the network
The only stubbed thing. An effect test scripts what a call comes back with, then asserts on what was sent.
no framework to adopt
A test is a declaration like any other, run by the same binary that checks the program.
$ hekla check
Static analysis. Runs nothing, opens no data directory, reads no environment, so it stays a CI gate.
$ hekla test
Every test declaration in the program, against a real log, real read models and a real key store.
$ hekla serve
The API on 127.0.0.1:8080, the console, and the reference. One binary, no server beside it.
What each kind of code is allowed to do.
Most systems make this a convention and ask a reviewer to police it. heklang moves it into the grammar: a command cannot call out because invoke does not parse in one, and a projector cannot decrypt because reveal does not parse in one. That is why a rebuild and a replay reproduce what they did the first time, rather than usually reproducing it.
| Call | command | projector | effect arm | effect fn | module fn | fold arm |
|---|---|---|---|---|---|---|
| now()read a clock | command: allowed | projector: not allowed | effect arm: allowed | effect fn: not allowed | module fn: not allowed | fold arm: not allowed |
| http.*reach the network | command: not allowed | projector: not allowed | effect arm: allowed | effect fn: allowed | module fn: not allowed | fold arm: not allowed |
| invokerun a command | command: not allowed | projector: not allowed | effect arm: allowed | effect fn: allowed | module fn: not allowed | fold arm: not allowed |
| log, failreport and stop | command: not allowed | projector: not allowed | effect arm: allowed | effect fn: allowed | module fn: not allowed | fold arm: not allowed |
| reveal, erasedecrypt and shred | command: not allowed | projector: not allowed | effect arm: allowed | effect fn: not allowed | module fn: not allowed | fold arm: not allowed |
| emitappend an event | command: allowed | projector: not allowed | effect arm: not allowed | effect fn: not allowed | module fn: not allowed | fold arm: not allowed |
Every program terminates.
There is no while. Recursion is rejected statically and the error prints the cycle as a path. A for runs once per element of a finite container, and every path must return. A smart contract language buys that guarantee at run time with gas metering. Here it is not expressible in the first place.
Nothing is ever minted.
There is no random() and no uuid4() anywhere in the language. Identity comes from Uuid.derive(seed, name), a pure function of both arguments, so a retry and a replay produce the same id. "Never mint a random id" is not a rule anyone has to remember, because it cannot be written.
What you read is what you conflict on.
A fold is a read declaration, not a variable. It names a slice of the log, and the slices a command folded are the condition its append is checked against. Optimistic concurrency falls out of the code instead of being configured beside it, which is why the keyword is not let.
// Narrow: this one order. A caller retrying the same `order_id` is a no-op rather
// than a second order.
fold placed: Bool = false
on @order.placed(order_id) => true
// Wide on purpose: an allocation is a rule about every order in the shop, so every
// order in a shop conflicts with every other. That is what a hard cap costs, and the
// retry loop is what absorbs it.
fold sold: Int = 0
on @order.placed(shop_id) => sold + 1
if placed {
return
}
if sold >= LAUNCH_ALLOCATION {
return reject SoldOut
}@order.placed(order_id)narrow
This one order. A caller retrying the same id writes nothing rather than a second order.
@order.placed(shop_id)wide, on purpose
An allocation is a rule about every order in the shop, so every order in a shop conflicts with every other. That is what a hard cap costs.
Three small propositions, not one aggregate. If another writer lands in any of these slices after the read, the append is rejected and the whole command re-decides against the new log. There is no way for the declared boundary to drift from the actual reads, because they are the same object.
Would this deploy still do the same thing?
An event log is append-only, so a bad deploy is not something you undo. hekla plan compares what you are about to ship against what is running, and separates a change behind a contract that did not move from a change something outside the program can see.
$ hekla plan . --data-dir /srv/hekla/datacompared 6 declaration(s) against what is deployed behaviour command DoA (commands/a.hk) behaviour command DoB (commands/b.hk) contract projector UserStats (projectors/user-stats.hk) projector UserStats rebuilds from zero, redoing 12481 position(s) because `guard ShopIsConnected` changed: DoA, DoB0 added, 0 removed, 3 changed; 1 projector(s) would rebuild$ hekla plan . --data-dir /srv/hekla/data --replay effect NotifyCustomer @ 4812: it reached a call the recorded run never made (http.post #0)replayed 312 invocation(s) across 2 affected effect(s); 310 reproduce,2 diverge0 added, 0 removed, 1 changed; 0 projector(s) would rebuild,2 recorded invocation(s) would divergeNothing is mocked. The journal holds the responses that run really received, so a candidate that branches differently on a response reaches a call the journal has no entry for. That miss is the finding.
Nothing is sent. Not an HTTP request, not an append, not an erasure. It reads the log through a follower that takes no lock, so it runs against a directory a live server has open.
What it cannot see is counted. An erased subject, an operator skip, a journal retention already reclaimed. Each is named and subtracted rather than quietly assumed to have passed.
Crypto-shredding is a type.
One annotation makes a field Sealed(String, attendee_id). The seal rides on the value, so it survives a binding, a fold, a helper, and being written into a read-model column. A projector can store personal data it is structurally incapable of reading.
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),
}http.post(url, { "email": email })
It cannot be sent in a request body.
log("the address is {email}")
It cannot be interpolated into a string.
if email == "ada@example.com" { }
It cannot be compared: equality over two ciphertexts leaks whether they hold the same value.
email.trim()
A method that reads the content is reading the content.
email.unwrap_or("")
A plaintext default and sealed content cannot share one slot.
$ hekla erase attendee_id 7
One key delete. Every value scoped to that subject becomes unmatchable and unreadable across the log and every read model at once, with no rewrite, no compaction and no index rebuild. It takes no lock, so it works against a running server.
Money never rounds silently.
Money(2) is a scaled integer and a distinct type from Decimal(2), purely so it can carry an operator table the checker enforces before the program runs. Where a result is not exactly representable, the bare operator is an error and you name mul and a rounding mode instead.
| Money(2) + Money(2) | Money(2) | two amounts |
| Money(2) * Int | Money(2) | an amount, repeated |
| Money(2) / Money(2) | Decimal(6) | an amount over an amount is a rate |
| Money(2) * Decimal(4) | Money(2) | exact only, or name a rounding mode |
| Money(2) * Money(2) | type error | two amounts multiplied is not an amount |
| Money(2) + Decimal(2) | type error | this is adding a tax rate to a total |
Currency is not in the type. Declare a currency field beside the amount.
A test is a declaration.
given seeds the log, run or deliver acts, and expect asserts on the events, rows and calls that resulted. An expectation is spelled like the thing it asserts, so expect reject SkuTaken sits beside return reject SkuTaken in the source.
test "a sale on a deleted plan does not resurrect it" {
given @plan.created { plan_id: 1, title: "Two-year cover", price: 19.99 }
given @plan.deleted { plan_id: 1 }
given @plan.sold { plan_id: 1, price: 19.99 }
project Plans
expect no Plan[1]
expect Sales[1] { revenue: 19.99 }
}A fixture built by the thing under test is refused on purpose: one broken command would otherwise fail every test that used it as scenery.
Effects that survive the crash.
Every impure call looks itself up in the journal before it runs, and journal rows commit call by call rather than once per invocation. A crash after the POST but before the journal write replays the POST. The invoke after it lands once.
@key names the lane
An arm names the trigger field that identifies its lane. One lane processes in log order, and lanes never wait on each other, so an event that cannot be processed holds up its own key and nobody else's.
retryable never arrives
Transport failures and every status that clears on its own (408, 425, 429 and any 5xx) are absorbed with backoff before the arm sees them, so a 4xx that reaches your code is a real rejection to decide on.
a wedge is diagnosable
An effect that cannot make progress stops its own lane and reports its consecutive-failure count and last error, so a wedge is distinguishable from ordinary lag. Advancing past one is a manual operator action, never automatic.
The key is the call itself, hashed, plus an ordinal separating identical repeated calls. It is not a sequence number, so editing or reordering an arm does not corrupt replay: the failure mode of editing during a deploy is "a different path was taken", never "a side effect fired twice". Note the honest edge: invoke is exactly-once only when the target is idempotent under replay, and a raw http.* call is at-least-once.
The console is the same URL as the API.
A request naming text/html in its Accept header gets the console. Everything else gets the JSON, byte for byte unchanged. curl sends */* and so does a bare fetch(), so neither is affected, and every deep link works in both directions.
It is compiled into the binary: plain ES modules over one vendored 13KB runtime, served from the same port, working with no network at all.
$ curl localhost:8080/admin/effects/NotifyCustomer{"name":"NotifyCustomer","position":4812,"lag":0,"failures":0, "last_error":null,"quarantine":null}$ open http://localhost:8080/admin/effects/NotifyCustomerthe same route, rendered as the console, on that effectGET /admin/events
Page the log newest first, filtered by type or by tag.
GET /admin/traces/{correlation_id}
One causal chain, drawn as the tree it is, transitively.
GET /admin/effects/{name}/invocations
Every call an invocation journaled, and what each returned.
GET /admin/schema
The project this process loaded, with a hash per declaration.
The mistakes are unrepresentable.
The usual failure modes of generated backend code are a stray clock read, an id minted on a retry, a call out from a handler that has to replay. None of them parse here, so the part left to review is the domain rule rather than the machinery around it.
A skill ships with each repository, the checker names every reason it rejected a program in one pass, and every one of those reasons maps to a documented fix.
One binary. One directory.
tephra is the event log, embedded as a library rather than run as a server, so there is no network hop and no serialisation on the write path. SQLite is bundled for the read models. There is nothing to stand up beside the runtime and nothing to point it at.
data/
events/ tephra segments, the source of truth
projectors/{Name}.db one SQLite database per projector
hekla.db effect journals, subject keys, declarationsNothing is ever mutated or deleted in place, so backup is copying the directory. Embedding does not buy a second writer: it is one logical writer per store either way, reached directly instead of over a socket.
The constraints, stated plainly.
An engineer who hits one of these after adopting hekla is a worse outcome than one who reads them and walks away. None of it is softened below.
Nothing is authenticated.
Not the command API, not the read API, not /admin, not /metrics. A caller who can reach the port can append events and skip an effect's work. The bind address is the boundary, and it defaults to 127.0.0.1.
One node, one writer, one process.
A runtime takes an exclusive lock on its data directory. There is no replication and no sharding: the value of a dynamic boundary is queries that span entities, so partitioning by tag would break the conditions the store exists to check.
Deploy is restart.
The project loads at startup and there is no hot reload. Reload raises the same checkpoint and in-flight-invocation questions as deployment, and answering them under a file watcher is how the mechanism everything depends on gets subtly wrong.
heklang is the only way in.
There is no Rust, TypeScript or WASM SDK, now or later. A single pure sandboxed authoring language is what makes determinism structural and the effect journal sound, so multi-language authoring is permanently out of scope.
Erasure has edges.
A field appended without a subject can never be erased, and nothing warns about it. Subject encryption is deterministic, so it leaks equality and frequency: do not give a status enum a subject. Losing the master key is total, unrecoverable loss.
It is early.
hekla is 0.4 and heklang is 0.5. Both are weeks old, both carry breaking changes between minor versions, and neither has run anything of yours in production yet.
Three commands to a running service.
A project is a directory of .hk files. There is nothing to scaffold, nothing to compile, and no server to stand up beside it.
Prebuilt binaries are Linux, x86_64 and aarch64. Everywhere else, and anywhere you would rather not pipe a script into a shell, cargo install hekla builds the same thing from source.
$ curl -fsSL https://hekla.tqwewe.com/install.sh | shinstall: installed hekla v0.4.0 (x86_64-unknown-linux-musl) to /usr/local/bin/hekla$ hekla check ./orderschecked 3 module(s): 1 command(s), 1 projector(s), 1 effect(s), 1 event(s)ok: no errors, 0 warning(s)$ hekla test ./orders11 passed, 0 failed$ HEKLA_MASTER_KEY=$(head -c 32 /dev/urandom | base64) \ hekla serve ./ordershekla listening on http://127.0.0.1:8080 admin console http://127.0.0.1:8080/admin api reference http://127.0.0.1:8080/docs