Skip to content

feat(core): personal data in events, forgotten by crypto-shredding - #213

Merged
benatespina merged 3 commits into
mainfrom
feat/personal-data
Oct 31, 2026
Merged

benatespina merged 3 commits into
mainfrom
feat/personal-data

Conversation

@benatespina

@benatespina benatespina commented Oct 24, 2026 •

Copy link
Copy Markdown
Member

Summary

  • Personal data in an immutable log: a field marked personal in an event's payload is stored encrypted with a key of the person the event names, and forget(personId) deletes the key and appends PersonalDataForgotten, in one transaction. The field reads as null from then on, in the types too.
  • A read model that projects a personal field must handle PersonalDataForgotten; the generator checks it through a type in .bounda/types.ts. A policy on the same event tells the outside world.
  • Snapshots (feat(core): snapshot an aggregate's state, valid only for a fingerprint of the code that folds it #211) hold the state in the clear: each snapshot names the people whose fields it holds, forget removes them in the same transaction as the key, and put writes only while every one of them still has a key, checked in the same statement, so a snapshot written beside a forget never outlives it.
  • Rejections remembered under an idempotency key (feat(core): remember what a command dispatched with an idempotency key answered #212) keep their message, which may quote the state: each record names the people whose fields were in that state, put records it only while they have a key, and forget removes it, so a retry is decided again. The guide asks to keep personal data from a command's payload out of rejection messages.
  • A command that loaded a person's data before forget and appends after it would have given them a new key and stored the data readable again: the append now carries the people its state held, and sealing a field of one without a key throws ConcurrencyError, so the command is decided again without their data.
  • A Concepts page says when a port is enough and when crypto-shredding is the answer, with what Marten, Axon and KurrentDB do; a guide has the how.

Changes

  • core: EventPayloadArgs with personal and person; PersonalSchema/PersonSchema typed T | null on read, T on write; HasPersonalFields and ForgetsPersonalData; PersonalKeyStore on Storage and StorageTransaction, in memory and SQLite (core/adapter/sqlite); the event store wrapper that seals and opens fields (AES-256-GCM through WebCrypto, envelope with the person's id and the key's fingerprint); forget on BoundaClient; the bounda aggregate and PersonalDataForgotten; storage contracts for the key store and the transaction.

  • postgresql: personal_keys table and store.

  • cloudflare: forget on the object, the stub and connect; POST /forget/<personId> on the Worker; the contracts run in workerd.

  • react-router: forget on the lazy stub.

  • cli: projections/bounda/ and policies/bounda/, core.EventPayloadArgs for events, <ReadModel>Forgetting in .bounda/types.ts, bounda refused as an aggregate name; fixtures regenerated, with a customer-directory read model and personal fields in customer-registered and order-placed.

  • Docs: concepts/forgetting-in-an-immutable-log, guides/personal-data, and conventions, limitations, reading the stream, Cloudflare, project layout, read models and adapters updated. Changeset minor.

  • Snapshots: Snapshot.persons, SnapshotStore.forget, snapshotStore on StorageTransaction; a persons column in SQLite (JSON) and PostgreSQL (text[], keys locked FOR SHARE while a snapshot is written); contract cases for the conditional put, forget and the transaction; an end-to-end test that loads a snapshotted aggregate after forget.

Decisions

  • The person is the aggregate's id unless a field is marked person; an event names at most one. Marks sit on top-level fields; a nested object or array is sealed as one value.
  • A forgotten field is null, not a sentinel or a default: explicit, JSON-safe, and the type forces every reader to handle it.
  • The runtime's events belong to the reserved aggregate bounda, one stream per person, so no domain name is taken.
  • Keys live next to the events, in the adapter's database: backup and restore keep both, a test needs no second store, the Durable Object has none. Documented as a choice, with what it implies.
  • Out of scope and documented: payloads of delayed commands and dead letters stay in the clear, and so does a dead letter's error message that quotes a decrypted field.

Quality

  • pnpm check green, three full runs. Stryker on the files touched: core 97 %, postgresql 97.6 %, react-router 100 %, cli and cloudflare over their thresholds.
  • /code-review at high: one real bug fixed (a person forgotten and registered again had old envelopes that failed to decrypt with the new key; envelopes now carry the key's fingerprint and read as null), plus a worker route hardened against malformed escapes, a doc example that was a no-op, and .transform() after personal() keeping the mark.

Events are never deleted, so a person's right to erasure needs another
answer than deleting rows. An event's payload now takes personal and
person next to z: a field marked personal is stored encrypted with a key
of the person the event names, one per person in the adapter's database,
and forget(personId) throws that key away and appends
PersonalDataForgotten to the person's stream, in one transaction. The
field then reads as null everywhere, and the types say so: T | null for
whoever reads it, T for whoever writes it, so the compiler finds every
place that assumed the data would always be there.

Forgetting is an event because what the log held has leaked into read
models and the outside world: a read model that projects a personal
field must project PersonalDataForgotten too, which the generator checks
through a type in .bounda/types.ts, and a policy on it tells the
providers. Snapshots hold the state in the clear, so each one names the
people whose fields it holds; forget removes them with the key, and a
snapshot is only written while all of its people still have one. The
envelope carries a fingerprint of the key, so a person forgotten and
registered again reads their old events as null next to the new ones.
The global stream shows the fields as stored.
A rejection remembered under an idempotency key keeps its message, which
may quote the state it was decided on, personal fields included, so a
retry after forget would hand them back. Like a snapshot, the record now
names the people whose fields were in that state, put writes it only
while each of them has a key, and forget removes it in the same
transaction as the key: the retry is decided again, on the state without
them. Personal data that came in a command's payload is never marked, so
the guide says to keep it out of a rejection's message.
…ad and its append

A command that loaded a person's data before forget and appended after it
gave them a new key and stored their data readable again. The append now
carries the people the state held, and sealing a field of one of them who
no longer has a key throws ConcurrencyError, so the command is decided
again on the state without their data. Dead letters keeping a decrypted
field in an error message join the documented limits.
@benatespina
benatespina merged commit 2a09ee0 into main Oct 31, 2026
10 checks passed
@benatespina
benatespina deleted the feat/personal-data branch October 31, 2026 09:57
benatespina added a commit that referenced this pull request Oct 31, 2026
)

* feat(core): personal data in events, forgotten by crypto-shredding

Events are never deleted, so a person's right to erasure needs another
answer than deleting rows. An event's payload now takes personal and
person next to z: a field marked personal is stored encrypted with a key
of the person the event names, one per person in the adapter's database,
and forget(personId) throws that key away and appends
PersonalDataForgotten to the person's stream, in one transaction. The
field then reads as null everywhere, and the types say so: T | null for
whoever reads it, T for whoever writes it, so the compiler finds every
place that assumed the data would always be there.

Forgetting is an event because what the log held has leaked into read
models and the outside world: a read model that projects a personal
field must project PersonalDataForgotten too, which the generator checks
through a type in .bounda/types.ts, and a policy on it tells the
providers. Snapshots hold the state in the clear, so each one names the
people whose fields it holds; forget removes them with the key, and a
snapshot is only written while all of its people still have one. The
envelope carries a fingerprint of the key, so a person forgotten and
registered again reads their old events as null next to the new ones.
The global stream shows the fields as stored.

* feat(core): forget the rejections remembered for a person

A rejection remembered under an idempotency key keeps its message, which
may quote the state it was decided on, personal fields included, so a
retry after forget would hand them back. Like a snapshot, the record now
names the people whose fields were in that state, put writes it only
while each of them has a key, and forget removes it in the same
transaction as the key: the retry is decided again, on the state without
them. Personal data that came in a command's payload is never marked, so
the guide says to keep it out of a rejection's message.

* fix(core): never write back a person forgotten between a command's load and its append

A command that loaded a person's data before forget and appended after it
gave them a new key and stored their data readable again. The append now
carries the people the state held, and sealing a field of one of them who
no longer has a key throws ConcurrencyError, so the command is decided
again on the state without their data. Dead letters keeping a decrypted
field in an error message join the documented limits.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant