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.