Skip to content

    Guides12

    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.

    An event’s payload receives personal and person next to z:

    app/domain/customer/customer-registered.tsTypeScript
    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:

    app/domain/order/order-placed.tsTypeScript
    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.

    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:

    app/personal-data.tsTypeScript
    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:

    TypeScript
    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 as null, 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.

    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.

    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:

    app/domain/customer/commands/remind-customer.tsTypeScript
    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.

    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:

    app/domain/signup/processes/onboarding/index.tsTypeScript
    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.

    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.

    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.

    TypeScript
    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 }:

    • reason is "requested" for forget and "retention" when the retention forgot them. A forgetting a forget ledger commits again keeps its id, its time and its reason.
    • whole is true when the person was forgotten as a whole, and false for forget(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 among categories either.
    • categories are the categories forgotten, every one but those retained when whole; retained, those a legal obligation kept; expired, those kept at an earlier forgetting whose retention ended now.
    • fingerprints names 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.

    TypeScript
    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:

    TypeScript
    {
    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.

    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:

    app/read/customer-directory/view.tsTypeScript
    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:

    app/read/customer-directory/projections/bounda/personal-data-forgotten.tsTypeScript
    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:

    app/read/newsletter/projections/bounda/personal-data-forgotten.tsTypeScript
    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.

    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:

    app/audit.tsTypeScript
    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.

    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:

    app/domain/customer/policies/bounda/erase-on-personal-data-forgotten.tsTypeScript
    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.

    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:

    TypeScript
    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:

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

    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.

    bounda.config.tsTypeScript
    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.

    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.

    app/domain/customer/processes/erasure/index.tsTypeScript
    import type { Process } from "./+types/index";
    export const config = ({ events }: Process.ConfigArgs) => ({
    startedBy: [events.customer.AccountClosed],
    completedBy: [events.customer.AccountReopened],
    timeout: "30d",
    });
    app/domain/customer/processes/erasure/at-timeout.tsTypeScript
    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.

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

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

    TypeScript
    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:

    Terminal windowShell
    openssl rand -base64 32

    A 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:

    Terminal windowShell
    npx wrangler secret put BOUNDA_MASTER_KEY

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

    Generate a new key, give it the variable the configuration reads, and move the old one to previous:

    TypeScript
    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 them

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

    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:

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

    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 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:

    TypeScript
    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:

    TypeScript
    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" }) },
    },
    },
    });
    wrangler.jsoncJSON
    "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.

    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 hold

    An 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:

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

    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.

    TypeScript
    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:

    Terminal windowShell
    bounda forget-ledger expire --binding FORGET_LEDGER --keep-for 35d

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

    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:

    Terminal windowShell
    bounda forgotten --since 2026-11-01T03:00:00Z > forgotten.jsonl

    Then, once the copy is back:

    Terminal windowShell
    bounda forget --from forgotten.jsonl

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

    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 reconcileEvery or 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 over

    and 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:

    Terminal windowShell
    bounda restored

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

    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.

    createTestApp forgets like any app, and expectForgotten checks that nothing of the person is left readable:

    TypeScript
    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, greeting
    What 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:

    TypeScript
    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: what recordTelemetry() recorded, so every span the runtime started, its attributes, status, exceptions and events, is searched too. It registers a global OpenTelemetry tracer provider until stop(), 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, after forget(personId, { categories }): what was sealed or kept of the person in them, and the fields of before in 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:

    TypeScript
    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 });