Skip to content
hekla

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.

orders.hk
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 | sh
01A project is a directory of declarations

Declare 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.

events/order.hk
// `@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),
}
The vocabulary
  • 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.

$ 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.

02Determinism is structural, not policed

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.

Which calls each kind of heklang declaration is allowed to make
Callcommandprojectoreffect armeffect fnmodule fnfold arm
now()read a clockcommand: allowedprojector: not allowedeffect arm: allowedeffect fn: not allowedmodule fn: not allowedfold arm: not allowed
http.*reach the networkcommand: not allowedprojector: not allowedeffect arm: allowedeffect fn: allowedmodule fn: not allowedfold arm: not allowed
invokerun a commandcommand: not allowedprojector: not allowedeffect arm: allowedeffect fn: allowedmodule fn: not allowedfold arm: not allowed
log, failreport and stopcommand: not allowedprojector: not allowedeffect arm: allowedeffect fn: allowedmodule fn: not allowedfold arm: not allowed
reveal, erasedecrypt and shredcommand: not allowedprojector: not allowedeffect arm: allowedeffect fn: not allowedmodule fn: not allowedfold arm: not allowed
emitappend an eventcommand: allowedprojector: not allowedeffect arm: not allowedeffect fn: not allowedmodule fn: not allowedfold 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.

03The Dynamic Consistency Boundary

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.

commands/place-order.hk
// 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
}
The append condition it produces
  • @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.

04A deploy you can ask questions of

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.

what would change
$ 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
and would it still behave the same
$ 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 diverge

Nothing 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.

05Subject-scoped encryption

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.

events/order.hkdeclare
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),
}
What the checker refuses
  • 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.

06Two smaller guarantees

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) * IntMoney(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 errortwo amounts multiplied is not an amount
Money(2) + Decimal(2)type errorthis 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.

tests.hk
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.

07Durable execution

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.

08Operating it

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.

one route, two representations
$ 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 effect

GET /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.

09Writing it with an agent

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.

10What it runs on

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, declarations

Nothing 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.

11Before you adopt it

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.

12Start

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