Skip to content

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

Open
benatespina wants to merge 2 commits into
mainfrom
feat/personal-data
Open

benatespina wants to merge 2 commits into
mainfrom
feat/personal-data

Conversation

@benatespina

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

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.

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. 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.
…orget

Under read-your-writes a forget only waits for the read models that handle
it; the others catch up in the alarm, which may or may not have fired by
the time the test looked, so the assertion raced the runtime.

This branch has not been deployed

No deployments
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