Personal data
Mark the fields of an event, a process state or a read model that hold personal data, say whose they are, sort them in categories with their legal basis and retention, seal what was written before a field was marked, forget a person or export their data with one call, record who opened whose data, forget them after a retention or when a purpose ends, wrap their keys under a master key kept out of the database, handle forgetting in every read model that showed them, and check in a test that nothing of a forgotten person is left readable.
Events are never deleted, so personal data in one is forgotten by making it unreadable: each
person has a key for each category of their data, their fields are stored encrypted
with the key of the field’s category, and forget throws the keys away.
Forgetting in an immutable log says when to do this
and when to keep the data behind a port instead. This page is the how.
Marking a field
Section titled “Marking a field”An event’s payload receives personal and person next to z:
import type { Event } from "./+types/customer-registered";
export const payload = ({ z, personal }: Event.PayloadArgs) => z.object({ email: personal(z.email()), plan: z.enum(["free", "pro"]) });
export const begin = ({ event }: Event.BeginArgs) => ({ email: event.payload.email, plan: event.payload.plan,});personal(schema) takes any schema: a string, a number, a whole object or an array, which is
encrypted as one value. It marks a top-level field of the payload; a mark deeper down is not seen,
so mark the field that holds the nested object. Here the person is the aggregate itself: the key
is the customer’s id. When the event belongs to another aggregate, person names the field that
identifies them:
import type { Event } from "./+types/order-placed";
export const payload = ({ z, personal, person }: Event.PayloadArgs) => z.object({ customerId: person(z.string()), lines: z.array(z.object({ sku: z.string(), quantity: z.int().positive() })), deliveryNote: personal(z.string()).optional(), });An event names at most one person.
Categories
Section titled “Categories”Not all of a person’s data goes at the same time. A profile is kept while they are a customer; an
invoice, for the years the law asks, even if they ask to be forgotten; a newsletter address, until
they withdraw their consent. Each kind is a category, declared in app/personal-data.ts with its
legal basis, as article 6 of the GDPR names it, and its retention:
import type { PersonalData } from "./+types/personal-data";
export const categories = { profile: { basis: "contract", retention: "30d" }, marketing: { basis: "consent", retention: "730d" }, invoices: { basis: "legal-obligation", retention: "6y" },} satisfies PersonalData.Categories;The basis is one of consent, contract, legal-obligation, vital-interests, public-task and
legitimate-interests. The retention is how long the category is kept after the person’s last
activity: a duration as the configuration writes them, "30d", or whole calendar months or
years, "18mo", "6y", which a legal period is given in. A calendar period ends on the same day of
the month and at the same time, leap years and months of unequal length counted as they fall; a day
the end’s month lacks, as the 29th of February six years on, ends at the very end of that month’s
last day, so a period is never cut short. runtime.personalData.retention takes the same. A field takes a category with personal’s second argument, and a read
model’s field with .personal’s:
export const payload = ({ z, personal, person }: Event.PayloadArgs) => z.object({ customerId: person(z.string()), total: z.number(), billingAddress: personal(z.string(), { category: "invoices" }), deliveryNote: personal(z.string(), { category: "profile" }).optional(), });The generator registers the names, so a category the file does not declare, in personal,
.personal or forget, does not compile, and .bounda/types.ts fails to compile while the file
declares a category default or one without a basis Bounda knows or a retention; the app refuses
to start on the same, for a project that skipped the type check. A field marked without a
category is in the default category, default, whose retention is
runtime.personalData.retention and which declares no basis: an app without
app/personal-data.ts has that category alone, and behaves as if categories did not exist.
Each person has a key per category, created when a field of that category is first sealed for them, so an event or a command that holds fields of several categories seals each under its own key, and each category is forgotten on its own:
forget(person)forgets every category, as below.forget(person, { categories: ["marketing"] })forgets those alone, as when a consent is withdrawn: their fields read asnull, the rest stays readable, and the person is not forgotten as a whole.- Each category is forgotten after its own retention.
A category whose basis is legal-obligation outlives a forget while its retention, counted from
the person’s last activity, has not run out: the law that requires the data outweighs the request
to erase it (article 17(3)(b)). Its key is kept, restricted, and no longer in force: what it sealed
still opens, for the export and for the reads the law needs, but nothing new is sealed under it,
no snapshot or remembered rejection holds its data, and the scheduled commands and dead letters
that held it go with the rest. The key’s last activity is fixed at the forgetting, so a person who
comes back extends nothing kept from before: what they write afterwards gets a new key in force,
and the kept one goes on its own once its retention has passed, with a PersonalDataForgotten
that names the category in expired. Whether a key is kept that way, and since when, is recorded
on its row. A category whose retention had already run out at the forgetting is forgotten with the
rest.
Changing a category
Section titled “Changing a category”A category’s retention can change at any time: the sweep reads it as it runs, so a longer or a
shorter one applies at once to every person, what a forgetting kept included. So does a basis, and
a category removed from the file: a key a forgetting kept under a legal obligation is destroyed by
the next pass of the worker once its category is no longer a legal-obligation one, with a
PersonalDataForgotten that names it in expired, since nothing allows keeping it any more.
A field moved to another category, or out of a category into the default one or back, is sealed anew under the person’s key of its new category by the backfill, in every event, process state, scheduled command and dead letter that holds it: the marks changed, so the worker walks the stream again, opens each value sealed under a category its field is no longer in and seals it under the right one, as the forgettings since it was written say, a category kept or forgotten included. From then on forgetting the old category no longer reaches the value, and forgetting the new one does. A remembered rejection that may quote it under the old key is dropped, as is a snapshot of its stream, and decided or folded again. A value no key opens any more, its person forgotten in its old category, stays as it is: it was forgotten already. Going back to earlier marks walks the stream again too.
In a command’s payload
Section titled “In a command’s payload”A command is stored too when it waits: dispatched with delay, retried by the scheduler after a
failure, or dead-lettered once it gives up. A command that carries personal data marks its payload
the same way, with personal and person from its own PayloadArgs:
import type { Command } from "./+types/remind-customer";
export const payload = ({ z, personal }: Command.PayloadArgs) => z.object({ customerId: z.string(), email: personal(z.email()) });Wherever the command is stored, the marked fields are encrypted with the key of the person it
names, the aggregate’s id or the field marked person, and forget
cancels it. A command dispatched at once is never stored, so the marks change nothing for what it
stores; person still says whom it touches, which restricting their
processing goes by, so a command may mark a person field alone. So
may an event: it then names its person as one with personal fields does, for the
export, and is refused with ValidationError when that field is
empty. The
handler reads command.payload.email as a string, never null: a command whose person is
forgotten never runs. A query’s PayloadArgs has no personal, since a query is never stored.
A command scheduled before its fields were marked is sealed in place by the
backfill, and forget finds it from then on.
In a process’s state
Section titled “In a process’s state”A process keeps its state in the lifecycle events of its own
stream, so a state that holds personal data marks it the same way, with personal and person
from its StateArgs:
import type { Process } from "./+types/index";
export const config = ({ events }: Process.ConfigArgs) => ({ startedBy: [events.signup.SignupStarted], completedBy: [events.signup.SignupConfirmed],});
export const state = ({ z, deadline, personal, person }: Process.StateArgs) => z.object({ customerId: person(z.string()).nullable().default(null), email: personal(z.email()).nullable().default(null), reminder: deadline(), });Every lifecycle event that carries the state stores its marked fields encrypted with the key of
the person the state names: the field marked person, or, when none is marked, the instance’s
id while a personal field holds a value, as an event’s aggregate id is. A state learns whose it is
in a later step, so it starts naming nobody: its personal and person fields default to null,
and the app does not start otherwise. While its person field is null it names nobody, and a
step that sets a personal field with nobody named fails with ValidationError, as a terminal
error. A personal field holding null holds nothing of theirs and is stored as it is.
Once the person is forgotten, those fields read as null and the instance carries on: its
handlers and deadlines run on the rest of the state, and the types say so, string | null for
email whatever its schema. The schema takes null for a personal field too, so a handler may
clear one itself. Only the marked fields are encrypted, never the whole state, which would leave
an instance unable to go on once its person is forgotten.
The rest follows from the stream: the export lists the lifecycle events that name the person, with the fields of the state they carry; while their processing is restricted, a step of the instance is refused before its handler runs; and a step that fails has its error text encrypted with the key of the person its event names, or else of the one the state names, which covers a deadline. A lifecycle event written before its state marked its fields is sealed in place by the backfill.
What the types say
Section titled “What the types say”Whoever writes the event, the command handler through events.customerRegistered({ email }),
passes what the schema takes: a string. Whoever reads it, begin, evolve, a policy, a
projection, a process, sees string | null: the value, or null once the person is forgotten. So does a process handler that
reads a personal field of its own state.
The null reaches the state too, so the begin above gives the customer an email of
string | null, and a command handler that reads state.email handles the case. A field
marked .optional() reads as string | null | undefined: absent, or forgotten.
An upcaster runs on the payload after it is decrypted, so one that
reads a personal field of an old shape must take null there too.
Marking a field later
Section titled “Marking a field later”A field marked after events were written finds those events holding it in the clear, and naming
nobody. The runtime fixes that in the background: the worker walks the whole stream from its first
event and seals, in place, each value a field marked now holds in the clear, with the key of the
person the marks name, who becomes the event’s person. Then it does the same for the payloads of
scheduled commands and dead letters, and for the error text of a policy’s or a process’s dead
letter that failed before the marks, whose work names someone now. From then on forget, the
export and restricting reach that data as
if it had been marked from the start. Nothing to configure, nothing to run: it starts when an app
whose schemas mark anything starts, and the lifecycle events of a process, whose state marks its
fields the same way, are part of the stream it walks.
It walks once per set of marks. Its position is kept under a fingerprint of every mark of the
registry, so adding, removing or moving one walks the stream again from the start, and a restart
with the same marks walks nothing twice. Once it reaches the head it goes through the scheduled
commands and dead letters, and from then on reads what comes after once a minute: an instance still
running the old marks, as in a rolling deploy, writes in the clear for a while, and what it writes
is sealed that much later, the stores included. One instance walks at a time, under a lease kept in
bounda_checkpoints; a batch that fails, because the key wrapper does not
answer, say, holds the walk for every instance, 30 seconds and twice as long after each failure in
a row, up to an hour, and the worker logs it.
A person forgotten before the marks were added has no key to seal with, and creating one would make
what their forgetting should have covered readable again. What an event held of them before their
last forgetting is sealed under a key no store keeps, and reads as null, as if it had been marked
then; what came after, once they were registered again, is sealed under the key they hold now. A
scheduled command or a dead letter of a person who holds no key and was forgotten gets the same, and
the text of an error never gets a new key, as when a handler fails. The worker logs personal data of persons forgotten before it was marked is sealed for good; rebuild the read models that project it
with the event types: a read model that showed those fields keeps what it projected before the
forgetting reached them, and a rebuild replays the events with
them forgotten. No other read model needs one: sealing changes how an event is stored, never what it
reads as. A snapshot folded from fields in the clear is dropped with the batch that seals its stream,
since it names nobody a forget would remove it for, and the next command folds the stream again.
What it cannot seal stays as it was, and the worker says so: a value in the clear in an event whose
person field is empty, personal data written before its marks names no person and stays in the clear, with the event types, and a scheduled or dead-lettered command whose person field is
empty, commands stored before their marks name no person and stay in the clear. The
marks are read on the payload as stored, below the upcasters, so a field
an upcaster renamed or computed is sealed under the name it was stored with, or not at all when it
was never stored; an event whose person field changed since it was sealed keeps the person it was
sealed for. A rejection remembered under an idempotencyKey before
the marks names nobody and lasts until its key expires, a day by default. A person whose processing
is restricted has their data sealed all the same: sealing protects it, and processes nothing.
app.getLag() reports where the walk stands, personalDataBackfill: { position, caughtUp }, null
for an app that marks nothing: caughtUp once the walk has sealed the stream up to its head and
every scheduled command and dead letter as the marks of now say, which is when an export or a
forget reaches everything; what is written after is sealed as it is written, and an instance
still on older marks makes it false again until the walk has sealed what that instance wrote. The worker logs personal data written before its marks is sealed with what it
sealed once the walk catches up, and each batch it seals is a bounda.backfill
span. When retention is configured, a person an event names only once
it is resealed has that event recorded as their last activity, so the retention forgets
them in their time.
Forgetting a person
Section titled “Forgetting a person”const forgotten = await app.forget("c-42");await app.catchUpReadModels({ through: forgotten });forget is on every BoundaClient: the app, the Durable Object through connect, the bounda
context of React Router. A client bound with as records who asked on PersonalDataForgotten, in
metadata.actor, and so on ProcessingRestricted and ProcessingRestrictionLifted. In one transaction it deletes the person’s keys, but those a
legal obligation keeps, cancels the scheduled commands that hold their data, removes
the dead letters of those that gave up, and appends PersonalDataForgotten to the stream
bounda:<personId>: the runtime’s own aggregate, bounda, has one stream per person it forgot.
Its payload is { personId, reason, whole, categories, retained, expired, fingerprints, cancelledCommands, removedDeadLetters }:
reasonis"requested"forforgetand"retention"when the retention forgot them. A forgetting a forget ledger commits again keeps its id, its time and its reason.wholeistruewhen the person was forgotten as a whole, andfalseforforget(person, { categories })or a category’s retention, and for a forgetting committed again from the ledger that kept a key given to the person after it: that key’s category is then not amongcategorieseither.categoriesare the categories forgotten, every one but thoseretainedwhenwhole;retained, those a legal obligation kept;expired, those kept at an earlier forgetting whose retention ended now.fingerprintsnames the keys it covered, destroyed or retained, by their check value, which opens nothing; none for a person who held none.- The rest are the ids and types of what went, the only record of commands that never became events.
forget(person, { categories }) forgets those categories alone: their keys go, and so do the
scheduled commands, dead letters, snapshots and remembered rejections that hold data sealed under
them; the rest of the person’s data, and what holds it, stays. Naming a category the app does not
have, or none, is refused with ValidationError. It resolves like a command, with the event’s
position, so catchUpReadModels({ through }) waits for the read models that handle it, and the Durable Object’s forget and the one in React
Router’s bounda context wait for them themselves unless consistency is "eventual". A person
without a key is forgotten all the same, since a read model may hold what their events carried in
the clear, and forgetting twice appends twice. An empty id is refused with ValidationError.
From then on every personal field of theirs reads as null, in every event, however old, but
those of a category kept under a legal obligation. A person registered again gets fresh keys;
their old events stay unreadable. A command running
when forget commits stores nothing it decided on their data, nor anything sealed under the key
forget removed: the state it loaded remembers the key each person’s data was read under, its
events name the key they seal each person’s fields under, and the store appends them only while
every one of those people still holds that key, checked in the append’s own transaction. A
forget that commits first makes the append fail with
ConcurrencyError, as on a stream that moved, even when another command
gave the person a fresh key meanwhile; the retry decides on the state without their data, and
seals what it writes of them under their fresh key, as for someone registered again.
A scheduled command already running when forget comes writes nothing: its claim is gone, so
neither its events nor a dead letter are stored, though its handler may have run. One that a
forget misses, stored in the instant between reading the key and the key going, is sealed under
a key no one holds: it is dropped when it comes due, unrun, and the worker logs scheduled command dropped: its person was forgotten. The dead letters list shows a
scheduled command’s payload with its fields opened.
A snapshot holds the state in the clear, so the same
transaction removes every snapshot whose state carried a field of theirs under a key that goes or is
kept; each one names the people it holds and the key it read each one under, and none is written
for a person who no longer holds that key in force. The next command on such an
aggregate folds it from its first event, with the fields forgotten, and the snapshot after it
holds them as null.
A command dispatched with an idempotencyKey remembers
what it answered, and a rejection’s message may quote the state it was decided on. Such a
rejection names the people whose fields were in that state, with their keys as a snapshot does,
and forget removes it too: the same request sent again is decided again, on the state without
them. What it cannot see is personal
data that came in the command’s payload, marked or not: keep that out of a rejection’s
message and say what went wrong with its code and the ids involved.
The message of an error a handler throws may quote a personal field it read, of any category, so a
dead letter keeps the message and the stack encrypted
under every key the person its work names holds, one inside the other, and forgetting any category
of theirs leaves the letter in place with them reading as null. The keys are those the work read their data under: a person forgotten while the handler
was failing, and given a fresh key before it gave up, has the text encrypted under a key nobody
keeps, so it reads as null too instead of opening under the fresh key; the stream’s ScheduledCommandFailed and ProcessFailed name the error’s class and code
and never its text, and so does the last error the inbox and the scheduler keep of a claim.
The runtime’s own log lines and spans carry no error’s text: an error is logged and traced by its
class, its code and the ids of what failed, and the line that files a dead letter names the letter,
whose text app.deadLetters opens for as long as the person is not forgotten. Only
runtime.telemetry.errorText: "include", or
BOUNDA_ERROR_TEXT=include, puts the message and the stack there, in the clear, out of forget’s
reach: keep it to development. A React Router app logs what its requests throw by the same rule
once app/entry.server.tsx exports Bounda’s handleError
(logging errors); without it, React Router logs every
message and stack.
What forget does not reach is text your code writes elsewhere and data left unmarked: your logs
keep whatever you wrote to them, an event’s payload written in the clear, with the data in a field
you did not mark, stays readable, a command’s payload left unmarked is stored as it came, and the
error of work that names nobody, such as a deadline of a process whose state names nobody or a
scheduled command whose payload marks nothing, stays in its dead letter in the clear. Log and throw with ids, not the data itself,
and mark every field that holds it. A letter retried or discarded keeps its text until
runtime.deadLetters.retention removes it, sealed
or not; set it to bound how long that is.
Nor does it reach the actor in the metadata of events, who acted on them (see
Who acts): it is an id, as a person’s id is,
so bind clients with the id of a user or a service, never their name or email.
Exporting a person’s data
Section titled “Exporting a person’s data”const exported = await app.exportPersonalData("c-42");exportPersonalData is on every BoundaClient too: the app, the Durable Object through
connect, the bounda context of React Router. It answers what the store holds that names the
person, for them to take away, as the right of access and the right to portability ask:
{ personId: "c-42", exportedAt: "2026-03-01T10:00:00.000Z", categories: { default: { basis: null, retention: "730d", restrictedUntil: null }, profile: { basis: "contract", retention: "30d", restrictedUntil: null }, invoices: { basis: "legal-obligation", retention: "6y", restrictedUntil: null }, }, events: [ { id, type: "CustomerRegistered", aggregateType: "customer", aggregateId: "c-42", version: 1, position: 17, timestamp, data: { email: "ana@example.com" }, categories: { email: "default" } }, { id, type: "OrderPlaced", aggregateType: "order", aggregateId: "o-9", version: 1, position: 40, timestamp, data: { billingAddress: "1 Main St", deliveryNote: "ring twice" }, categories: { billingAddress: "invoices", deliveryNote: "profile" } }, ], scheduledCommands: [ { commandId, type: "RemindCustomer", executeAt, data: { email }, categories: { email: "default" } }, ], deadLetters: [ { id, kind: "policy", handler: "customer.welcomeOnCustomerRegistered", errorMessage: "no mailbox for ana@example.com", errorStack, data: {}, categories: {} }, ], subscriberFailures: [ { subscriber: "projection:customerDirectory", eventId, eventType: "CustomerRegistered", errorMessage: "cannot index ana@example.com", errorStack }, ], readModels: { customerDirectory: [ { key: "c-42", data: { email: "ana@example.com" }, categories: { email: "default" } }, ], orderSummary: [{ key: "o-9", data: {}, categories: {} }], },}categories describes every category of the app, as article 15(1) of the GDPR asks: why it is
processed, its basis, null for the default category, which declares none; how long it is kept
after their last activity, its retention, null when nothing forgets it for inactivity; and,
for a category a legal obligation kept past a forgetting, when what was kept goes,
restrictedUntil. Every record lists the category of each field of its data in categories.
events are every event that names the person, by its field marked person or its aggregate’s
id, in position order, the events of their own stream bounda:<personId> included; data holds
the fields marked personal of that event, opened, and nothing else of its payload, so an event
without such fields has an empty data. A process’s lifecycle event names the person its state
names, and its data holds the personal fields of that state. scheduledCommands are the commands waiting to run
whose payload holds personal fields of theirs, with commandId the id the dispatch answered or,
for one the runtime scheduled itself, the scheduler’s key, each with the same data.
deadLetters are the handler runs that gave up and name the person: work about them, whose
errorMessage and errorStack are encrypted with their keys and come out opened, and the
scheduled commands whose payload holds their fields, with its data. subscriberFailures are the
last failures of the subscribers on an event that names them, kept with their error text
encrypted with their keys (see observability), opened.
After forget, the person’s events and letters are still theirs, and every field and error text
reads as null, as a handler reads it. readModels has an entry for every read model with a field marked
.person(): the rows whose person field holds the id, each
with its primary key as key, so the person can tell them apart, and its fields marked
.personal() as data, read through the read model’s own storage, which may be another
adapter’s, in pages by primary key, so a projection that writes a row meanwhile cannot make
another skip or repeat. An empty id is refused with ValidationError.
Only what is marked comes out, and that is the limit to know: a field left unmarked is not
personal data to Bounda, so the rest of OrderPlaced is the order’s and stays out; a read model
without a person field is a view Bounda cannot tell is about anyone, and whatever a policy sent
out is at the mailing provider or the search index. An app that owes a person more than their
marked fields takes this export and adds what it knows from its ports. An event stored before its
type marked its fields is found once the backfill has sealed it.
Each store keeps the person an event names in a person column, indexed with the position, and
the adapters read by it with readByPerson; a scheduled command is found by the persons it
carries, and a dead letter by its person or its persons. forget and the export find those
rows, and the snapshots and remembered rejections forget removes, through an index, never by
reading every row: PostgreSQL indexes the persons column itself, and SQLite, the Durable Object
included, keeps a table of which row names which person, written and removed by the same statement
as the row.
Personal data in read models
Section titled “Personal data in read models”A read model that projects an event with personal fields has copies of them in its rows. Its view marks them, as an event’s payload does, with two methods of the field builder:
import type { View } from "./+types/view";
export const fields = ({ f }: View.FieldsArgs) => ({ customerId: f.string().primaryKey().person(), email: f.string().optional().personal(), plan: f.string(),});.personal() marks a column that holds personal data; .person() marks the one that holds the
id of the person the row is about, a string column, at most one per read model. They are
methods of the field, next to .index(), because a read model’s columns are declared there; a
person who appears in many rows, such as a customer in an order summary, is marked on that
foreign column (customerId: f.string().index().person()), with its own index if the export of
a person with many rows must be fast. .personal({ category: "invoices" }) puts the column in a
category, which the export names; without it the column is in the default one. The
marks change nothing about how the adapter stores the column. They do three things: the person’s export includes the rows
that name them, a query of a read model with personal fields is recorded in
the access log, and the generator checks forgetting by them.
Every read model that keeps personal data must project PersonalDataForgotten from
projections/bounda/personal-data-forgotten.ts:
import type { Projection } from "./+types/personal-data-forgotten";
export const project = async ({ event, table }: Projection.Args) => { await table.delete({ customerId: event.payload.personId });};What it does is the read model’s business: delete the row, as here, keep it without the personal
columns by upserting it again without them, or keep a count. Its payload says which
categories went: categories, every one but those a legal obligation kept when the
person was forgotten as a whole, and only those named when a consent was withdrawn (whole: false). A read model clears what it holds of those categories alone, so a newsletter’s subscribers,
all of them marketing, go when that category does, and stay when only an invoice is at stake:
import type { Projection } from "./+types/personal-data-forgotten";
export const project = async ({ event, table }: Projection.Args) => { const { personId, categories } = event.payload; if (categories.includes("marketing")) await table.delete({ subscriberId: personId });};A projection of a personal event handles null as well, since a rebuild replays every event, the
forgotten ones included: a string column that is NOT NULL takes a null email badly, so make
the column optional and leave it out, as the directory above does.
Who opened what
Section titled “Who opened what”Accountability asks to know who opened whose personal data. Every operation that hands out a person’s data in the clear records it, once per call:
| Operation | persons |
detail |
|---|---|---|
exportPersonalData |
the person | { events, scheduledCommands, deadLetters, subscriberFailures, readModelRows }, how many of each; null when it failed |
deadLetters.list |
those the letters name | { letters }, how many |
deadLetters.get, retry, discard |
those the letter names | { letter }, its id |
subscriberFailures.list |
those the failures’ events name | { failures }, how many |
subscriberFailures.get |
the one the failure’s event names | { subscriber }, its name |
a query of a read model with fields marked .personal() |
those the person field names in the rows the query read |
{ query, readModel } |
Each record is { operation, actor, persons, personCount, at, outcome, detail }, and a query’s
adds unattributed: actor is the client’s, from
as, and null for a client that names
nobody; persons names each person once, in the order read, and at most MAX_ACCESS_PERSONS of
them, with personCount saying how many there were in all, so a query that lists every customer
gives a bounded record; at is the app’s clock; outcome is ok when the call handed its result
out and failed when it rejected after reading, since its error may quote what it read, as a
handler’s cannot reach ana@example.com does.
A query is matched to its read model by the module’s place, and to its persons by the rows it
read through its table or client, in repository and handler alike, not by its result: a
handler that reshapes the rows into another object still handed their data out. So a query that
reads rows and answers only a count is recorded too, with those persons, and one that only calls
table.count() is recorded with none. A query whose repository or handler throws is
recorded as failed, with the persons of the rows it read until then. A query that takes
client.raw, the driver itself, reads rows Bounda cannot see: it is recorded with
unattributed: true, which says that persons names only those of the rows read through table
and client, and whose rows it read through the driver is unknown. A query that composes another
records nothing of its own unless its read model has personal fields; the one it calls records
itself, as the same actor. A query receives that actor too, in its repository and its handler,
so it can read only what whoever asks may see: see Queries. What
the log does not see is in Limitations.
Without anything more, each record goes to the app’s logger at info, with the message
personal data accessed and the record as its fields. Where it should go instead, a log pipeline
kept apart, a SIEM, a table in another database, is app/audit.ts:
import type { OnPersonalDataAccess } from "@bounda-dev/core";
export const onPersonalDataAccess: OnPersonalDataAccess = async ({ access, env }) => { await fetch(env.AUDIT_URL, { method: "POST", body: JSON.stringify(access) });};The generator puts it in the registry, which checks it against AuditModule, and the app refuses
to start when it does not export the function, so every host gets it: boot(), createApp, the
Durable Object, React Router. It receives the record with the host’s env, the tenant and the
app’s logger, as a port’s create does. A test app never calls it: createTestApp sends the
records to its logger, or to the onPersonalDataAccess the test passes. It runs once the call
has read and before it settles, whether it then hands its result out or rejects, and the call
waits for it. When it throws, the call rejects with that error, handing out neither its result
nor its own error: data handed out without its record is what an access log exists to rule out,
so a sink that is down stops the reads it would have recorded. A retry or a discard of a dead
letter acts first, so that its record can say how it went: with the sink down, the retry or the
discard is done, and the call still rejects without the letter. A hook that would rather let the
reads through catches its own failure and keeps the record somewhere else.
The records are never sampled, unlike a trace, and nothing is written to the store by default:
it would be a write on every read, and a record that names a person is personal data that
forget would then have to reach. A logger that drops info drops them too.
Telling the outside world
Section titled “Telling the outside world”What a policy sent out stays out: the mailing provider has the contact, the search index has the
profile. A policy in policies/bounda/ reacts to the event:
import type { Policy } from "./+types/erase-on-personal-data-forgotten";
export const handler = async ({ event, mailer, idempotencyKey }: Policy.HandlerArgs) => { await mailer.eraseContact(event.payload.personId, idempotencyKey);};It runs with the aggregate’s ports, retries and dead-letters as any policy, and its handler is
typed with PersonalDataForgottenEvent.
Restricting processing
Section titled “Restricting processing”A person may ask that their data be kept but not processed for a while, the GDPR’s restriction
of processing, while a dispute about it is settled. restrict is on every BoundaClient, next
to forget:
const restricted = await app.restrict("c-42");await app.catchUpReadModels({ through: restricted });In one transaction it writes the person’s row in bounda_persons and appends
ProcessingRestricted to the stream bounda:<personId>, and it resolves like a command, so
catchUpReadModels({ through }) waits for the read models that handle it, and the Durable Object
and React Router wait for them themselves unless consistency is "eventual". Restricting a
person restricted already appends nothing and resolves without events. An empty id is refused
with ValidationError.
From then on every command that touches the person is refused with
ProcessingRestrictedError (PROCESSING_RESTRICTED, with the id in
personId) before it runs, and before it is scheduled when it has a delay: a command whose
payload names them with person, or whose aggregate is the person, that is when the command’s
payload or any event of the aggregate marks a field without person. A command that names them
nowhere Bounda cannot tell is theirs, so mark the person field of every command that acts for
someone. app.commands throws the refusal; nothing is recorded under an idempotencyKey, so the
same request sent once the restriction is lifted runs. A command already running when restrict
commits is refused all the same: the store checks, in the transaction that would write its
events, that nobody they touch is restricted, so the two are ordered one before the other and its
events are never stored after the restriction. Its events touch the person the command names and
every person whose fields they seal, so a command for someone else whose events copy a restricted
person’s marked fields is refused too.
A policy, process or scheduled command whose command is refused this way becomes a dead letter at
once, as for any terminal error, with errorCode: "PROCESSING_RESTRICTED" and the person in
person, even when the event it handled names nobody: a policy on Invited, an event of the
newsletter, dispatching registerCustomer for the restricted customer. So does a step of a
process instance whose state names the person, before its handler runs,
and the store refuses a lifecycle event that would encrypt their fields as it refuses a command’s.
A failed process parks what follows, as it always does. Reading the person’s
data, forget and the export of their data stay allowed: a restriction is not an erasure.
unrestrict lifts it:
const lifted = await app.unrestrict("c-42");It clears the row, appends ProcessingRestrictionLifted the same way and schedules a run of the
worker that retries, in order, every failed dead letter the restriction caused, each as
app.deadLetters.retry would, so the policies
run, the processes resume and the scheduled commands are dispatched, without an operator. A letter
that fails again for good stays failed, as that failure’s letter, for the operator; one that
fails in a way worth retrying, a timeout or a store that does not answer, stays as it was and the
run is retried with the retry settings of policies; one refused
because the person is restricted again waits for the next lift. Lifting for a person
not restricted appends nothing and resolves without events.
Handling the two events is optional. A read model may project them from
projections/bounda/processing-restricted.ts and
projections/bounda/processing-restriction-lifted.ts, to show the person as restricted where
customer service looks, and a policy may react from
policies/bounda/<action>-on-processing-restricted.ts, to tell the outside world. Both carry
{ personId } and are typed ProcessingRestrictedEvent and ProcessingRestrictionLiftedEvent.
Retention
Section titled “Retention”The GDPR’s storage limitation asks that personal data be kept no longer than its purpose needs. Bounda covers the two ways a purpose ends: a person who stops coming back, and something the domain decides.
Inactivity
Section titled “Inactivity”export default defineConfig({ storage: sqlite({ path: "./data/app.db" }), runtime: { personalData: { retention: "730d" } },});With runtime.personalData.retention set, the runtime forgets a person no event has named for that
long, as forget does, with reason: "retention" in PersonalDataForgotten. There is no default:
without it nobody is forgotten for inactivity. The setting is app-wide, with no override per
aggregate, because a person’s events span aggregates: a customer who places an order is active, as
a customer, whatever aggregate the order is.
Activity is an event of the app’s aggregates that names the person, by its field marked person
or its aggregate’s id, as the export finds them. The runtime’s own
events on bounda:<personId> are not activity: restricting or lifting a restriction does not
start the clock again, and neither does forgetting, which the runtime records instead. A person
active again after being forgotten starts a new clock from that activity; their old events stay
unreadable. A person whose processing is restricted is left alone
while the restriction lasts, since keeping their data is what they asked for, and is forgotten
once it is lifted if they are due by then.
No timer is armed per person. A subscriber of the dispatcher, retention, follows the stream from
its first event and records in bounda_persons when each person was last active and last
forgotten, with its own checkpoint, in batches, like any subscriber; it shows in the lag, and
started over from the first event it records the same rows. Every pass of the scheduled-command
worker then sweeps: an indexed read of the persons whose last activity is older than the
retention, not restricted and not forgotten since, a bounded batch of them each forgotten in a
transaction of its own. One instance sweeps at a time, under a lease kept in bounda_checkpoints
that the others leave alone until it lapses, two minutes after the holder last renewed it; an
instance that stalled past its lease stops at the next person, and the one that took it over goes
on. The deadline is worked out at sweep time from the setting, so changing it
applies at once, to everyone, and turning it on applies it to the history: whoever was already
inactive for that long is forgotten in the first passes. A person named by an event the subscriber has not recorded yet is
left for a later pass, read in the transaction that forgets them, so nobody is forgotten for an
activity the row does not show yet, nor twice when a lease changes hands mid-way; an event stored in
a transaction running beside it may still be forgotten with them, as one a command stores while
forget runs may be. A person the sweep could not forget, because their forget failed or their
row is behind, waits on their own, half a minute at first and twice as long after each failure up
to an hour, while the pass goes on with the next ones. The wait is kept on their row in
bounda_persons, written only when an attempt does not succeed, so every instance skips them
until it ends and a restart backs off from where it was; their next activity, or their
forgetting, clears it. nextDueAt() names the end of that wait, so a host never wakes for them
again at once. A database that cannot keep the wait, read-only after a failover say, pauses the
sweep in that instance instead, for as long and growing the same way.
A host without a polling worker learns of it from app.nextDueAt(), which names the earliest
retention deadline with the scheduled commands, so a Durable Object
arms its alarm for it.
End of purpose
Section titled “End of purpose”The domain knows when a purpose ends, so a process decides: “forget the customer
30 days after their account closed, unless it reopens” is a process started by AccountClosed,
completed by AccountReopened, with a timeout of 30 days whose handler forgets them.
import type { Process } from "./+types/index";
export const config = ({ events }: Process.ConfigArgs) => ({ startedBy: [events.customer.AccountClosed], completedBy: [events.customer.AccountReopened], timeout: "30d",});import type { Process } from "./+types/at-timeout";
export const handler = async ({ aggregateId, forget }: Process.DeadlineArgs) => { await forget(aggregateId);};Policies and processes receive forget(personId, options) in their arguments, next to commands,
with the same categories a client’s forget takes. It
forgets as forget on the app does, with reason: "requested", inside the run’s own unit of work:
the forgetting commits with the run’s commands and its step, in the same transaction, or not at
all, so a run that fails and is retried forgets once. It resolves at once, before the commit, and
rejects an empty id with ValidationError. The PersonalDataForgotten it appends is caused by the
event the handler reacted to, one hop deeper, as its commands are.
Each category for its own time
Section titled “Each category for its own time”“Keep the invoices six years, the profile thirty days” is two categories, each with
its retention. A declared category is forgotten on its own once the person has been inactive for
its retention, as forget(person, { categories }) does, with reason: "retention" and whole: false: the profile goes thirty days after their last activity, the invoices six years after it,
whatever the other categories do. A category whose basis is legal-obligation is forgotten that way
too when its retention ends, even when the app’s retention forgot the person as a whole earlier:
that forgetting kept it, and its time runs from the activity it kept.
Activity is the person’s, as for the app’s retention: any event of the app’s aggregates that names
them, whatever category it carries, so a customer who keeps ordering keeps their profile. The sweep
reads each category’s keys by their last activity, recorded on every key the person holds in force
by the same subscriber, retention, which runs whenever the app has a retention or declares a
category; a person whose processing is restricted is left alone, and a key the sweep could not
forget waits on its own row, as a person does. runtime.personalData.retention is the default
category’s retention, and forgets the person as a whole: every category but those a legal
obligation keeps, after which each declared category has nothing left to forget but what was
kept.
Where the keys live
Section titled “Where the keys live”Each adapter keeps the keys in a table next to the events, bounda_personal_keys, one row per
key with the person, the category, the key, the id of the key it is wrapped under, its
fingerprint, the person’s last activity and, for one a legal obligation kept past a forgetting,
since when: at most one key in force per person and category. Each key is named by the person and
its fingerprint: the memory adapter
in memory, SQLite and the Durable Object in their database, PostgreSQL in its schema. The
fingerprint is the key’s AES-GCM tag of nothing under a nonce no value is ever sealed under, a key
check value: it names the key without revealing anything of it, and the runtime computes it from
the key itself, so it checks every key it reads against it, even one it can never export. Whether a person’s processing is restricted, and since when, lives apart, in
bounda_persons, since a person without a key is restricted all the same, and so does, with a
retention, when they were last active and last forgotten as a whole. A key is created when the
first event with a personal field of that person in its category is stored, by the transaction
that stores it, and deleted by forget. Encryption is AES-256-GCM through WebCrypto, with a nonce per value, on Node and in
workerd alike; what the store holds for a personal field is an envelope with the ciphertext, the
person’s id, the category unless it is the default one, and the key’s fingerprint, which the global stream
shows as it is; a value sealed under a key the person no longer has, because they were forgotten
and registered again, reads as null next to what their new key opens. What is read is opened by
that shape, whatever the schema marks, so an event or a delayed command whose handler or caller
writes an envelope itself, in any field, is refused with a ValidationError: it could only read
back as null, or not at all.
A master key
Section titled “A master key”A key kept next to the data opens it for whoever reads the database: a dump, a backup, a replica,
a support session. So the runtime wraps every person’s key before the store keeps it, under a
master key that never enters the database: envelope encryption, what a cloud KMS does. The
fingerprint stays that of the key, whatever it is wrapped under, so forget is still one
transaction and nothing else changes; a copy of the database without the master key opens
nothing.
import { defineConfig, masterKey } from "@bounda-dev/core/config";import { sqlite } from "@bounda-dev/sqlite";
export default defineConfig({ storage: sqlite({ path: "./data/app.db" }), runtime: { personalData: { keyWrapper: masterKey({ from: "BOUNDA_MASTER_KEY" }) }, },});from names the environment variable, never the key, and the app reads it from the host’s
environment when it starts: process.env in Node, after boot() has loaded .env, and the
Worker’s secrets in a Durable Object. It holds 32 random bytes in base64:
openssl rand -base64 32A variable that is missing, or holds anything else, fails the start with a ConfigurationError
that names the variable, never its value. The keys are wrapped with AES-KW; each master key’s id is
derived from it, so a wrong master key is told apart from the right one: reading a key it did not
wrap fails with a ConfigurationError saying under which id that key is.
A project from create-bounda comes with one: a key generated for it in .env on Node, or in
.dev.vars on Cloudflare, both left out of Git, and the configuration above. In production, set
the variable the way the host keeps secrets. On Cloudflare that is a secret of the Worker:
npx wrangler secret put BOUNDA_MASTER_KEYKeep the master key as you keep the database’s backups, for as long as they are kept: lost, it
takes every person’s data with it, as if each had been forgotten. bounda rebuild reads it from
.env like boot(); rebuildReadModel takes the environment as env.
Under a master key, a person’s key exists in the app only as a WebCrypto key that can never be
exported. WebCrypto generates it, wraps it under the master key and unwraps it straight into a key
it seals and opens with, so its raw bytes never reach JavaScript memory, where nothing could erase
them: no string, no buffer, on Node, in workerd or in a test app given the master key. The bytes read from the master key’s variable
are zeroed once imported; the variable itself is the one copy the host keeps.
Unwrapped keys are kept in memory, by fingerprint, up to ten thousand per app and for five minutes from the moment each was unwrapped, so a command does not pay for unwrapping on every read, and the key of someone no longer active does not stay usable in the process: keys past their time go at the next read of any key, and a timer drops them when no key is read, without keeping a Node process alive. A Durable Object arms no such timer, which would keep it from sleeping: there they go at the next read, or with the object when the platform evicts it. A forgotten person’s key goes once the forget commits, and the keys the rotation unwraps are not kept. The person’s row is still read first every time: a person forgotten on another instance is forgotten here at once.
Without keyWrapper the keys are kept as they are, next to the data, and the app logs a warning
when it starts if the registry marks any field personal; an app from createTestApp warns
nothing. Then the key’s text is what the store holds, read back as a string: the runtime exports a
new key once, to store it, imports what it reads at once, and zeroes the bytes either way. Setting one later wraps the keys kept so far in the background, as a rotation does.
Rotating the master key
Section titled “Rotating the master key”Generate a new key, give it the variable the configuration reads, and move the old one to
previous:
keyWrapper: masterKey({ from: "BOUNDA_MASTER_KEY", previous: ["BOUNDA_MASTER_KEY_OLD"] }),A key wrapped under a previous master key still opens; the worker rewraps them under the current
one in batches, on every pass, as the retention sweep runs, one instance at a time under a lease
of its own for each set of master keys, as the retention’s. A key that fails to rewrap waits on its
row, as a person the retention could not forget does, while the others go on. Once none is left
the worker marks the rotation complete for this set of master keys, in bounda_checkpoints, and
logs it once:
no personal key is wrapped under a previous key any more; remove themThen remove the old variable and previous. Rewrapping changes no event and no fingerprint, so it
runs while the app serves. A Durable Object wakes for it as for a deadline. While the mark stands,
nextDueAt() never reads the key table, and the passes read it once a minute across every
instance, dropping the mark if they find a key to rewrap: an instance still on the old
configuration, in a rolling deploy, may wrap a new key under the old master key, and the next
pass after that minute finds it. Changing the master keys, previous included, reads the table
again; the old configuration’s mark stays while instances on it may still run, and goes once none
has renewed it for ten minutes.
Destroying the old master key once the backups taken before the rotation have expired is what makes those backups unreadable as a whole.
A KMS or a vault
Section titled “A KMS or a vault”masterKey keeps the master key in the app’s environment. A KMS, Vault’s transit engine or an HSM
keeps it out of the app altogether: give keyWrapper an implementation of KeyWrapper through
defineKeyWrapper, which receives the host’s environment when the app starts. With AWS KMS:
import { DecryptCommand, EncryptCommand, KMSClient } from "@aws-sdk/client-kms";import { defineConfig, defineKeyWrapper } from "@bounda-dev/core/config";import { sqlite } from "@bounda-dev/sqlite";
const kms = defineKeyWrapper(({ env }) => { const keyId = env.BOUNDA_KMS_KEY_ID; if (keyId === undefined) throw new Error("Set BOUNDA_KMS_KEY_ID to the KMS key's ARN"); const client = new KMSClient({}); return { kid: keyId, wrap: async (key) => { const { CiphertextBlob } = await client.send( new EncryptCommand({ KeyId: keyId, Plaintext: key }), ); if (CiphertextBlob === undefined) throw new Error("KMS returned no ciphertext"); return CiphertextBlob; }, unwrap: async ({ kid, wrapped }) => { const { Plaintext } = await client.send( new DecryptCommand({ KeyId: kid, CiphertextBlob: wrapped }), ); if (Plaintext === undefined) throw new Error("KMS returned no plaintext"); return Plaintext; }, };});
export default defineConfig({ storage: sqlite({ path: "./data/app.db" }), runtime: { personalData: { keyWrapper: kms } },});kid is stored next to every key the wrapper wraps; unwrap receives it back, with what wrap
returned. A KMS needs the key’s raw bytes, so they exist while wrap and unwrap run, and only
then: the runtime zeroes the array it gives wrap once it settles, and imports what unwrap
resolves as a key that can never be exported and zeroes that array at once. So resolve a
Uint8Array of your own, as the SDK hands it, and keep no copy of either. The runtime checks the
key unwrap answers against its fingerprint, so a wrapper that answers with another key fails
loudly instead of sealing data under it. Creating a key takes one call to wrap and none to
unwrap. KMS rotates a key’s material itself and keeps decrypting under the old one with the
same ARN, so kid does not change; moving to another KMS key is a rotation as above, with the old
ARN in previous. Each instance asks KMS once per person it creates, and once per person it reads
in every five minutes they are active, thanks to the cache.
A wrapper that fails, because KMS is unreachable, fails the command that needed the key, which the caller or the reaction’s retry sees; the person’s data is not touched.
Backups
Section titled “Backups”Restoring a copy of the database taken before someone was forgotten brings their key back, and with it their data: the only record that they were forgotten was in the database the copy replaced. The ICO’s guidance for backups is to put them beyond use while they exist and, when one is restored, to apply the erasures again. A forget ledger is how Bounda does the second part.
The forget ledger
Section titled “The forget ledger”The ledger is a record of every forgetting kept outside the database: for each PersonalDataForgotten,
the person’s id, the event’s id, its time, its reason, the categories it asked for, null for
the person as a whole, and the fingerprints of the keys in force it covers, and nothing else. It is
a suppression list, the one thing about a forgotten person that has to outlive their data, so it
holds no field of theirs; a fingerprint is a check value of a key, which opens nothing and names
nothing once the key is gone. Forgetting someone again forgets what that forgetting asked for: a
withdrawn consent again, never the person as a whole, nor the invoices a legal obligation keeps,
and those kept again while their retention runs; a forgetting that only ended what an earlier one
kept asks for nothing, since the retention ends it again on its own. Give it a store a restore of
the database does not touch:
import { defineConfig, masterKey } from "@bounda-dev/core/config";import { sqlite } from "@bounda-dev/sqlite";
export default defineConfig({ storage: sqlite({ path: "./data/app.db" }), runtime: { personalData: { keyWrapper: masterKey({ from: "BOUNDA_MASTER_KEY" }), forgetLedger: { store: sqlite({ path: "./ledger/forget-ledger.db" }) }, }, },});Its store takes any adapter pointed at another database, where it creates one table,
bounda_forget_ledger, with the adapter’s tablePrefix, the first time it is used: a SQLite file
on another disk or volume for an app on one machine, postgresql({ url }) on another server, or a
schema of the same server backed up apart. A plain file of the app’s own making would be the same
thing with less: the SQLite file already is one, written atomically. An adapter that points at the
storage’s own database, which a restore would roll back with it, fails the start with a
ConfigurationError. The table keeps each tenant’s entries apart, so tenants can share one
ledger.
On Cloudflare a restore of a Durable Object’s storage goes back to a point in time, the ledger with it if it lived there, so the ledger is a KV namespace:
import { cloudflare, kvForgetLedger } from "@bounda-dev/cloudflare";import { defineConfig, masterKey } from "@bounda-dev/core/config";
export default defineConfig({ storage: cloudflare(), runtime: { personalData: { keyWrapper: masterKey({ from: "BOUNDA_MASTER_KEY" }), forgetLedger: { store: kvForgetLedger({ binding: "FORGET_LEDGER" }) }, }, },});"kv_namespaces": [{ "binding": "FORGET_LEDGER", "id": "<the namespace's id>" }]Every object keeps its entries under its own name, so an object reached through an id without a
name fails to start; createWorker names each by its tenant. KV may take up to a minute to list a key it
just wrote, so an object that starts reads the ledger once more a minute later, and wakes for it.
Anything else is a ledger of your own through defineForgetLedger, which receives the host’s
environment, the tenant and keepForMs when the app starts: it keeps every field of an entry, the
reason and the fingerprints included, which the reconciliation decides by; its read returns
entries in the order of their time, then of their event’s id, and byForgetLedgerOrder from
@bounda-dev/core/adapter sorts them so; its optional prune removes the entries older than a
time; its readMark and raiseMark keep numbers that only go up, which the runtime uses to tell
a copy restored while the app was stopped; forgetLedgerContract from
@bounda-dev/core/adapter/testing is the test it has to pass.
What the runtime does with it
Section titled “What the runtime does with it”Every forgetting writes its intention to the ledger first, and commits after: forget, a policy
or process, the retention, forgetAgain. The intention is the entry the forgetting will be: its
event’s id and time are set before the transaction, and the transaction appends the event with
them, so the ledger and the database name one forgetting by one id. A policy’s or process’s
forget writes it just before its run’s transaction opens, once the run has decided.
What happens to an intention whose commit does not happen, because the process stopped in between
or the database refused the transaction, is the same in every case: the next reconciliation
commits it, since forgetting is what was asked. A forget that rejects after its intention was
written still forgets the person then; calling it again forgets them at once, and is never an
error. The retention is the one exception, as it decides on the person’s activity: a forgetting
the retention made is committed again only while the retention would still forget the person,
read on the database as it is then, so a person who came back, or whose processing was
restricted, keeps their data.
An intention names the key the person held when it was written, and a reconciliation commits it
only while the person still holds that key: a key they hold that it does not name was given to
them after, as when they come back and register again, and is kept. That also settles the order
between several intentions of one person, which never changes the result: each commits only while
the key it names is there, so the first to commit destroys it and the others find it gone. A
reconciliation leaves an entry younger than a minute, FORGET_LEDGER_GRACE_MS from
@bounda-dev/core, to the next one, due once it is old enough: it may be the intention of a
forgetting still committing, which then commits with its own actor. After a restore the instances find, it
commits them at once: the copy cannot be committing any of them.
The event store is also the ledger’s outbox: a subscriber of the dispatcher, forget-ledger,
copies every PersonalDataForgotten to the ledger again in batches, with a policy’s guarantee,
and the ledger keeps one entry per event however often it receives it. So a forgetting whose
intention the ledger refused, or did not take within its timeout, which an app that is not
strict logs as a warning and commits all the same, still reaches it: a batch the ledger refuses is
delivered again, and the subscriber’s lag in getLag and bounda.dispatcher.lag shows how far
behind it is. While an intention the ledger did not take in time is still pending, the next
forgettings do not wait for it, and leave their copy to the subscriber. Configuring a ledger on an
app that already forgot people copies those forgettings first.
When the app starts, before it serves anything, it reads the ledger past a cursor the database keeps, the time of the last entry it applied, and commits again, under its own id, time and reason, each forgetting the database does not hold whose person still holds a key it names. After a normal start that read returns next to nothing. After a restore the cursor went back with the copy, and the read returns exactly the forgettings made since the copy was taken, the intentions whose commit never happened included; the read models that show those persons hear of it before the start resolves, and the app logs how many it committed again:
committed again forgettings the database did not holdAn entry whose forgetting the database holds is skipped even when the person holds a key, since that key was given to them after they were forgotten. The check and the forgetting are one transaction, so instances starting together forget a person once. A Durable Object does it on every cold start; restoring its storage restarts it.
While the app runs, it reads the ledger again every reconcileEvery, five minutes unless you set
it. One instance takes each round, whichever finds it due first, and the time of the last round is
kept in the database, so a restore takes it back too and cannot hold the next round off. The
round runs on the worker’s passes, in every instance whose role runs the worker; a Durable Object
runs it on the passes it wakes for, never waking for it alone, since restoring its storage
restarts it anyway. A database restored under a running app is reconciled at once, on the next
pass that finds the restore.
Each call to the ledger is given up after its timeout, ten seconds unless you set it, with
FORGET_LEDGER_TIMEOUT; the call may still land after it. A ledger
that cannot be read when the app starts does not stop it: the app logs an error, starts, and its
worker tries again after 30 seconds, then twice as long after each failure, up to an hour.
The wait is kept in bounda_checkpoints, beside the cursor, so every instance waits for the
longest and a restart backs off from where it was, though a start always tries once; the first
reconciliation that succeeds clears it.
A reconciliation that takes longer than the timeout as a whole, each call within it, is left to
finish in the background, with an error logged, and the app starts.
Until then getLag() says forgetLedger: { reconciled: false }, and a person the restore
brought back can be read; alert on it. An app without a ledger reports forgetLedger: null.
To never forget anyone the ledger has not recorded, nor serve before the ledger was read, make it strict:
forgetLedger: { store: sqlite({ path: "./ledger/forget-ledger.db" }), strict: true },A strict app forgets nobody whose intention the ledger did not take: forget, forgetAgain and
a policy’s forget fail with FORGET_LEDGER_UNAVAILABLE, the
ledger’s error as its cause, and nothing is forgotten, so trying again is safe; a policy’s run
fails and is retried as any other, and the retention holds the person and tries again later. It
waits at start for the reconciliation however long it takes, each call within the timeout, and a
ledger it cannot read fails the start with
FORGET_LEDGER_UNRECONCILED, the ledger’s error as its cause;
starting again tries again. The CLI’s commands that start the app need the ledger then;
bounda forgotten does not, since it reads the event store alone. Availability now depends on the
ledger’s: give it a store at least as available as the database.
A strict app loses no forgetting to a restore: each is in the ledger before it commits. One that is
not strict loses only a forgetting whose intention the ledger did not take, while it was down or
slow, and that the subscriber had not copied yet when the database was lost. Keep the
forget-ledger subscriber’s lag near zero, as for any other.
Keeping the ledger short
Section titled “Keeping the ledger short”The ledger only matters for backups you can still restore. With keepFor, an entry older than
that is removed: set it a little longer than your oldest restorable backup.
forgetLedger: { store: sqlite({ path: "./ledger/forget-ledger.db" }), keepFor: "35d" },The instance that takes a periodic round prunes after it, through the ledger’s prune, and a
forgetting older than keepFor is not appended at all. KV expires each key on its own instead,
keepFor after its forgetting, since it cannot list its oldest keys first. A backup older than
keepFor brings back whom the removed entries named, so when you keep backups longer, raise it
first. Without keepFor entries are kept for good: ids, times and fingerprints only, but still the
ids of people who asked to be forgotten.
A KV key gets its expiration when it is written, so the keys written before keepFor was set, or
while it was longer, keep theirs. Give them the one keepFor sets now, from the project’s root,
with the binding and the keepFor of the configuration:
bounda forget-ledger expire --binding FORGET_LEDGER --keep-for 35dIt goes through the project’s wrangler, logged in to the account, to the deployed namespace,
--local to the state of wrangler dev, with --env and --wrangler-config as wrangler takes
them; it writes every object’s keys again with their expiration and removes those already past
it. expireKvForgetLedger({ namespace, keepForMs }) from @bounda-dev/cloudflare does the same
from a Worker, within the KV operations one invocation may make.
Without a ledger
Section titled “Without a ledger”The CLI does the same by hand. Before restoring, while the old database can still be read, or regularly, as part of the backups, save who was forgotten since the copy you will restore was taken:
bounda forgotten --since 2026-11-01T03:00:00Z > forgotten.jsonlThen, once the copy is back:
bounda forget --from forgotten.jsonlbounda forgotten prints one line of JSON per forgetting, the entry a ledger would hold, reading
the event store without starting the app; bounda forget --from commits again each forgetting
listed, of the categories it asked for or of the person as a whole, under its own id, time and
reason, as a reconciliation would: unless the database holds it, and only while the person holds a
key in force it covers and names. With a ledger configured, it records each in the ledger first. A
line may also be a bare person’s id, for a list kept some other way, which is forgotten as a whole,
as a request, whenever the person holds a key: list only those forgotten after the copy was taken.
--actor <id> names who does it on each PersonalDataForgotten it appends.
app.forgetAgain(entries, { actor }) is the same in code, each entry with its categories. Then
run bounda restored, so the jobs start over from the copy (restores). Without a
backup in play, bounda forget <person> forgets a person from the command line, --category
naming the categories alone, and bounda export <person> prints their data: see the
CLI.
Without either, a restored backup brings back the data of everyone forgotten after it was taken, until they are forgotten again.
Restores
Section titled “Restores”A copy of the database brings back more than forgotten persons: the waits of the jobs that had failed, which count failures against data the copy holds as it was. So the runtime finds a restore, wherever it happens, and then:
- reconciles the forget ledger at once, without waiting for
reconcileEveryor for a wait the copy brought back; - has every instance forget what it knew of its background jobs, the backfill caught up, the rotation complete, the pauses after a failure, the dispatcher’s backoff, and read the copy again;
- and, on the instance that found it first, clears the waits the copy brought back, on persons, keys, the backfill and the reconciliation.
The attempts of handlers, a policy’s, a process’s or a scheduled command’s, stay as the copy has them: those runs happened, and what they did through a port is not undone. Each find logs
the database went back to an earlier copy; background jobs start overand bounda status counts the restores found. How it is found depends on when the copy came back:
| The copy was restored | Found by | What is guaranteed |
|---|---|---|
| under the running app | every instance, on its next pass, before it delivers an event or claims a scheduled command, or within five seconds in the web role |
Any copy taken before the last write the instances saw, or before the newest event an instance read or committed. A copy taken after both differs from the database in nothing but waits, which the jobs read again anyway. |
| while the app was stopped, with a forget ledger | the first instance to start, before it serves anything | Any copy taken before the last mark the ledger holds, which follows every move of the counter and a stop after any write, or before a forgetting the ledger holds and the copy does not. Only after a crash can a copy of the last five seconds of writes, with no forgetting among them, go unseen: it differs from the database in waits alone. |
| while the app was stopped, without a ledger | bounda restored, which you run after restoring |
What you declare: the database alone cannot tell a copy from itself. |
Under the running app, every instance moves a counter in bounda_checkpoints one forward after
it wrote, at most every five seconds, and remembers what it read: a copy holds less, and the
first instance to see it counts the restore in another row, which every other instance then sees
changed. An idle instance writes nothing, so neither does its watch. Every instance also watches
the newest event it read, or committed as a forgetting, at its position: events are only ever
appended, so a copy that lost it is found at once, even one taken in the five seconds before the
counter moved. A pass that finds a restore reconciles the ledger before the dispatcher delivers
anything or the worker claims anything, so a scheduled command of someone the copy brought back
never runs. Backups and restores has the steps for each engine. With a forget ledger the
counter is also raised in the ledger, as a mark outside the database, which is how a start tells
a copy restored while the app was stopped; and a forgetting the ledger holds that the database
does not is a restore too, whatever the counter says, so the forgettings a copy undid are
applied again and found by the same reconciliation. A forgetting never depends on the mark: what
a restore that goes unseen leaves is waits, which end on their own within an hour.
On Workers KV, kvForgetLedger, a read may lag a write by up to a minute in another location, so
a start there may read an older mark and miss a copy of the last minute, unless a forgetting
tells it; with only one object per tenant writing it, the mark itself never goes down. A ledger
in a SQL database reads what was written. Without a ledger, run, after restoring:
bounda restoredapp.restored() is the same in code. It counts the restore, so running instances learn of it
too, clears the waits, and reconciles the ledger at once when there is one.
Old backups
Section titled “Old backups”The ledger handles a backup once it is restored. Rotating the master key handles the others: destroying the old key once every backup it covers has expired leaves those backups unreadable as a whole, the forgotten persons with everyone else.
In tests
Section titled “In tests”createTestApp forgets like any app, and expectForgotten checks that nothing of the person is
left readable:
import { createTestApp, expectForgotten } from "@bounda-dev/core/testing";
const { app } = await createTestApp({ registry });await app.commands.registerCustomer({ customerId: "c-1", email: "ana@example.com", plan: "pro" });await app.forget("c-1");await app.runUntilIdle();expect(await app.queries.getCustomer({ customerId: "c-1" })).toEqual({ customerId: "c-1" });await expectForgotten(app, "c-1");It looks for every value the person had: each value of a field marked personal() that the test
app sealed under a key they no longer hold, in an event, a process’s state, a command it stored or
a dead letter, a cancelled command’s included, and each value a read model kept in a field marked
.personal() for them before its category was forgotten. It searches every place the runtime keeps
something, read as stored, never through the APIs that open fields: every table of the storage
and of each read model’s adapter, unmarked columns and JSON documents included, so a projection
that copied the email into a column it did not mark is found; the forget ledger; and every entry
the app logged through the test app’s logger. A test app logs the text of errors so a failing
test says what failed, which an app outside a test does not do by default (see
runtime.telemetry.errorText): that text, in logs and
spans, is searched only when the test’s config sets errorText itself. A person given a key again after the forgetting
keeps what they gave since: only what was sealed under the keys they lost is looked for.
When it finds a value, it rejects with NotForgottenError, whose message lists each place and
says what to do about each kind:
Person "c-1" was not forgotten everywhere: 1 place still holds a value of theirs in the clear. - "ana@example.com" in bounda_rm_customer_directory, customer_id=c-1, greetingWhat to do: - A read model kept the value: project PersonalDataForgotten in it and delete the row or clear the field, and mark the field .personal() so its export and the access log see it.error.found holds the same as data: { value, store, row, field } for each. It is a plain
async function, so it works under any test runner.
A value is matched whatever its case, inside any longer text: an error message, a log line, a
JSON column. One shorter than MIN_SUBSTRING_LENGTH characters is matched only as a whole word,
since Ana turns up inside Banana; key material and ciphertext are compared whole, never
searched inside, since a short value turns up in random text by chance. Numbers count as their
digits, booleans and null not at all, and a value equal to the person’s id is never looked for,
since ids stay by design. Give test data values that appear nowhere else, as ana@example.com
does.
The options say what else to look for and where:
import { expectForgotten, recordTelemetry } from "@bounda-dev/core/testing";
const telemetry = recordTelemetry();try { // ... await expectForgotten(app, "c-1", { values: ["Ana G.", "+34 600 123 456"], telemetry, places: { mailer: mailer.sent }, });} finally { telemetry.stop();}values: what a handler or a projection derived from the person’s fields, a lowercased email or a formatted phone number, which no seal holds.before: the person’s export, taken before they were forgotten; every value it opened is looked for.telemetry: whatrecordTelemetry()recorded, so every span the runtime started, its attributes, status, exceptions and events, is searched too. It registers a global OpenTelemetry tracer provider untilstop(), and throws when one is registered already, since OpenTelemetry keeps the first.places: anything else, under the name the report gives it: what a test double of a port was sent, so a test proves the policy that tells the outside world erased it there too.categories: the categories to check alone, afterforget(personId, { categories }): what was sealed or kept of the person in them, and the fields ofbeforein them, are looked for, and the person must hold no key in force of them.
Without categories it looks for what was sealed under every key the person no longer holds, so a
category a legal obligation kept past the forgetting, whose key they still hold, is left out: that
data stays lawfully, and its values may turn up anywhere it was kept. It refuses to pass for want
of something to look for: a person who still holds their keys, or one with no value to look for
and no values, rejects with an Error saying so, and so does one who still holds a key in force
of a category categories names.
expectForgotten also takes an adapter instead of the app, and then searches that adapter’s
tables for values and before alone: for an app built with createApp or boot(), or a
Durable Object’s storage, read from inside the object with durableObjectAdapter from
@bounda-dev/cloudflare (see Testing on Cloudflare). It reads
the whole database, so point it only at a test’s, and call it before app.stop(): an
in-memory database is gone once the app stops. It works on every adapter: the in-memory one,
SQLite, the Durable Object and PostgreSQL alike, each through its inspect().
app.runUntilIdle() never moves a test’s clock to a retention deadline, or every person the test
created would be forgotten; a test reaches it by moving the clock itself:
const { app, clock } = await createTestApp({ registry, config: { runtime: { personalData: { retention: "30d" } } },});await app.commands.registerCustomer({ customerId: "c-1", email: "ana@example.com", plan: "pro" });await app.runUntilIdle();clock.advance(31 * 86_400_000);await app.runUntilIdle();expect((await app.exportPersonalData("c-1")).events[0]?.data).toEqual({ email: null });