Skip to content
hekla

Personal data

A subject-scoped field, what the seal does to it, and how erasure becomes one key delete.

An event log is append-only, which is exactly the wrong shape for a deletion request. hekla’s answer is crypto-shredding, and it is a type rather than a procedure.

Declaring a subject

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),
}

@subject(customer_id) says: encrypt this value under a key scoped to that customer before it reaches storage.

Three rules, and each of them is load-bearing:

The subject id stays plaintext. customer_id is how the runtime finds the key, so sealing it would make the key unfindable.

The subject id may not be optional. @subject(x) names a field of the same event, and that field must always have a value: a missing id is not a missing key, it is no question at all. The sealed field itself may be optional or not, as the domain requires.

Subjects are per field, not per event. The order above scopes two fields to the customer and one to the shop, so erasing the customer leaves the shop’s commercial record intact and erasing the shop never touches the customer’s address.

The seal is a type

@subject(x) produces Sealed(T, x), a type nobody writes. Opt stays outermost, so String? @subject(customer_id) is Opt(Sealed(String, customer_id)).

Sealed content is not comparable to plaintext and not comparable to content sealed under a different subject. That is not a lint: an equality over two ciphertexts would leak whether they hold the same value, so the type system does not offer one.

The seal rides along. A projector receiving a sealed column stores ciphertext and never handles plaintext, and the read API decrypts on the way out. A fold carrying one carries the seal too.

reveal

Only an effect arm can decrypt:

let response = http.post(
  "https://mail.example/confirm",
  { "to": reveal(email), "order_id": order_id },
)

Not a command, not a projector, not an effect-local fn. The narrower that boundary is, the shorter the list of places plaintext can exist, and one arm is as narrow as it gets while still being able to send the mail.

reveal is not journaled. It re-decrypts on every attempt, deliberately: a journaled plaintext would outlive the erasure it belonged to. log is not journaled either.

erase

erase(customer_id, 7)

That deletes one key row. It is O(1), irreversible, needs no master key, takes no lock, and takes effect across the whole log and every read model at once, with no rewrite, no compaction and no index rebuild.

From the CLI:

hekla erase customer_id 7

The expression form takes the subject field’s own type, so the Int above is written bare. The CLI and a test’s erased line take its textual form instead, which is why "7" is quoted in both of those.

erase is a statement rather than a value, because there is nothing an author could do differently on either answer. No reveal may be reachable after an erase in the same arm, and the checker enforces it as a reachability analysis over the arm’s control flow rather than a lexical ordering check. erase is journaled and reveal is not, so a replay would skip the erase and re-run the reveal against a key that is gone. Ordinary statements after an erase are fine, and an erase inside a branch that does not fall through leaves a later reveal legal.

What changes afterwards

Surface After an erasure
GET /read/... the column is omitted, exactly as an absent value is
GET /admin/events/... the ciphertext stays; the subject’s state reads erased
a projector rebuild writes the column NULL, because no read path ever mints a key
an effect’s reveal fails the invocation terminally, so the position completes
GET /admin/subjects/{f}/{v} absent, indistinguishable from never having existed
an external system unaffected, because it never held a key

That fourth row is the one to internalise. A reveal of an erased subject does not wedge the lane forever; the data is gone and no retry can recover it, so the runtime records a terminal skip and moves on.

It is a point-in-time shred

Erasure destroys the key that existed. It is not a tombstone, so a later event writing the same subject’s field mints a fresh key and is readable.

Values written under the superseded key report stale rather than erased. Five states show up on /admin:

State Means
decrypted read back in plaintext
encrypted present, not decrypted for this request
erased the key is gone
stale written under a key that has since been superseded
unreadable the wrapping master key is not configured

The cipher is deterministic

It has to be, because an encrypted value is also a tag and a tag has to be matchable. The consequence is that it leaks equality and frequency.

What no checker can tell you

Which fields are personal is your judgement. A field appended without a @subject can never be erased, and nothing warns about it. That is a decision about meaning, and neither the compiler nor a model can make it from a name.

Getting it wrong is not recoverable by editing the declaration later, because the events already in the log were written in plaintext. This is the modelling decision to slow down on.

Testing it

A test cannot call erase, so it declares the state instead:

const ORDER: Uuid = "11111111-1111-1111-1111-111111111111"

test "an erased customer's address is gone" {
  given @order.placed {
    order_id: ORDER,
    customer_id: 7,
    shop_id: 1,
    email: "ada@example.com",
    shipping_address: "1 High St",
    order_total: 25.99,
    notes: "",
  }
  erased customer_id "7"

  project CustomerOrders

  expect Order[ORDER] { customer_id: 7, email: none, shipping_address: none }
}

These run against a real key store, so the column is unreadable because its key is gone rather than because a flag says so.

Operating it

The master key that wraps every subject key, the rotation procedure, and what breaks if you lose one are in Keys and secrets.