Writing heklang with an agent
A language an agent can finish.
Most of what makes generated backend code untrustworthy is not the logic. It is the quiet nondeterminism: a clock read in a fold, an id minted on a retry, a call out from a handler that has to replay. heklang does not have syntax for any of that, so the review that matters is the domain rule rather than the machinery around it.
Ask for a rule. Get a program.
Pick a request. Each one is a real project in this repository, and the hekla check line under it is what running the checker against that project actually prints.
This is a recording, not a live model. Nothing here calls an API, and the code below was written once, checked, and committed rather than generated on demand.
A shop should never be able to reserve more stock than it has on hand.
- The rule spans every movement of one sku, so the command folds both event types filtered on it.
- Folding that slice is what conditions the append, so two concurrent reservations cannot both pass.
- Running out is a fact about the world, so it is a refusal with a code rather than an invalid.
event @stock.received {
sku: String @max(64),
quantity: Int,
}
event @stock.reserved {
reservation_id: Uuid,
sku: String @max(64),
quantity: Int,
}refusal NotEnoughStock(available: Int) "only {available} left for this sku"
command ReserveStock(reservation_id: Uuid, sku: String, wanted: Int) {
// Every movement of this sku, which is what the reservation has to be
// consistent with. Folding it is what conditions the append on it.
fold on_hand: Int = 0
on @stock.received(sku) { quantity } => on_hand + quantity
on @stock.reserved(sku) { quantity } => on_hand - quantity
if wanted <= 0 {
return invalid("a reservation needs a positive quantity")
}
if wanted > on_hand {
return reject NotEnoughStock { available: on_hand }
}
emit @stock.reserved { reservation_id, sku, quantity: wanted }
}When a payment is recorded, email the customer a receipt and record that we sent it.
- Sending mail reaches the network, so it has to be an effect, and only an effect may reveal the address.
- @key payment_id gives each payment its own lane, so one undeliverable address holds up nothing else.
- Recording that it sent goes back through a command, because an effect never appends directly.
event @payment.recorded {
payment_id: Uuid,
customer_id: Int,
email: String? @subject(customer_id) @max(200),
amount: Money(2),
}
event @receipt.sent {
payment_id: Uuid,
}effect SendReceipt {
on @payment.recorded { @key payment_id, email, amount } {
if email.is_none() {
log("payment {payment_id} has no address to send a receipt to")
return
}
let response = http.post("https://mail.example/receipt", {
"to": reveal(email),
"amount": amount,
})
if response.status >= 400 {
log("receipt rejected with status {response.status}")
return
}
invoke RecordReceipt { payment_id }
}
}// Internal: an effect writes back to the log by invoking a command, never by
// appending, so the fact lands under this command's own boundary.
command RecordReceipt(payment_id: Uuid) {
fold sent: Bool = false
on @receipt.sent(payment_id) => true
if sent {
return
}
emit @receipt.sent { payment_id }
}When a customer asks to be forgotten, make their personal data permanently unreadable.
- email is scoped to the customer with @subject, which is what makes it erasable at all.
- erase may only be called from an effect arm, and the subject is on the trigger, so it infers.
- One key delete makes every value under that customer unreadable across the log and every read model.
event @customer.registered {
customer_id: Int,
email: String? @subject(customer_id) @max(200),
}
event @customer.erasure.requested {
customer_id: Int,
}// Only an effect may erase, and the subject is on the trigger, so this one infers.
effect EraseCustomer {
on @customer.erasure.requested { @key customer_id } {
erase(customer_id)
log("erased every value scoped to customer {customer_id}")
}
}I need a read model of open tickets that I can filter by assignee.
- A projector is the read side: a pure fold over the log into rows.
- Filtering by assignee needs @index on that column, because only the key and declared indexes are filterable.
- Closing deletes the row rather than setting a status, because a closed ticket is not an open one.
event @ticket.opened {
ticket_id: Uuid,
assignee_id: Int,
subject: String @max(200),
}
event @ticket.closed {
ticket_id: Uuid,
}projector OpenTickets {
entity Ticket {
ticket_id: Uuid @key,
assignee_id: Int @index,
subject: String @max(200),
}
on @ticket.opened { ticket_id, assignee_id, subject } {
put Ticket { ticket_id, assignee_id, subject }
}
// `delete` rather than a status column: a closed ticket is not an open one.
on @ticket.closed { ticket_id } {
delete Ticket[ticket_id]
}
}Four properties that happen to suit a model.
None of these were designed for agents. They fall out of a language built so a projector rebuild and an effect replay reproduce exactly what they did the first time, and they happen to be the same properties that make a program mechanically checkable by anything.
The mistakes are unrepresentable
The usual failure modes of generated backend code are a stray clock read, a minted random id, a call out from somewhere that has to be reproducible. None of them parse here. A model cannot introduce nondeterminism into a projector by accident, because the syntax for it does not exist in one.
The checker is a complete oracle
One pass either accepts the program or names every reason it does not, with an extent and a hint per finding. There is no separate type-check to reach and no partial state to reason about, so the loop is write, check, fix, rather than write and hope.
Every rejection has a documented fix
The diagnostic set is closed at forty-four codes, and the skill's reference maps each one to what to do about it. Recovering from a rejection is a lookup rather than an invention, which is exactly the step models are worst at.
It can check its own work
A test is a declaration in the same language, so the agent writes cases beside the code and hekla test runs them against a real log, real read models and a real key store. There is no harness to stand up and nothing to mock.
Two skills ship with the source.
Both repositories carry a Claude skill: the rules distilled out of the specification, one reference file per declaration kind, a table from every diagnostic code to its fix, and a worked example program. Run this in a project that writes .hk files and the agent working there picks them up.
It takes one path out of each repository's tarball, so nothing is cloned and nothing else lands. Drop hekla from the list if you only want the language.
mkdir -p .claude/skills
for repo in heklang hekla; do
curl -sL "https://git.tqwewe.com/tephra/$repo/archive/main.tar.gz" |
tar -xz -C .claude/skills --strip-components=3 "$repo/.claude/skills/$repo"
done.claude/skills/heklang/
SKILL.md, seven reference files, a seven-file example
The language: declarations, folds, sealed content, refusals and tests, plus a table from every diagnostic code to its fix.
.claude/skills/hekla/
SKILL.md, six reference files, a twelve-file example project
Everything the runtime adds around it: the directory convention, the generated HTTP surface, the CLI, and how subject encryption works underneath.
Still your judgement.
A checker that accepts a program is saying it is well formed, not that it is right. These are the parts no static pass and no model can decide for you.
A green check is necessary, not sufficient.
What it cannot see is data-dependent. A Money operation that cannot be answered exactly type-checks and then fails at run time naming mul or div, and no static pass can know which values will arrive.
The boundary is a modelling decision.
Which slices a command folds is a judgement about the domain, and a model will pick a plausible one rather than the right one. hek check --boundaries prints what each command guards, transitively, so the choice is reviewable rather than buried.
Which fields are personal is your call.
A field appended without a @subject can never be erased, and nothing warns about it. That is a judgement about meaning, and neither the checker nor a model can make it from a name.
There is no language server yet.
An agent works from hek check output rather than in-editor diagnostics. The tree-sitter grammar is the start of one, and hek fmt - formats a module from stdin, which is what format-on-save wants.