GDPR Article 30 asks a controller to keep a record of processing activities: each purpose, the data it uses, its legal basis, who receives the data, how long it is kept, and the security measures around it. Written by hand, it drifts from the system the day it is written. Your Worker derives it, from what it composes and declares, and names every gap it finds rather than filling one in.
Capabilities declare what they do; you complete it
Each kit capability that stores personal data declares its activities, with a default legal basis where it can state one:
| Activity | Purpose | Legal basis |
|---|---|---|
auth.account | Accounts and sign-in | contract |
consent.record | The consent record itself | legal-obligation |
email.delivery | Sending the mail your app sends | contract |
email.marketing | Marketing mail | consent, citing its consent purpose |
email.tracking | Open and click tracking | consent, citing its consent purpose |
payments.purchases | Purchases and subscriptions, naming each rail you turned on | contract |
A legal basis is one of the six in Article 6(1): consent, contract, legal-obligation, vital-interests, public-task or legitimate-interests.
Add your own activities, and replace a capability’s basis, in a processing block in the Worker’s pithy.config.ts:
const config = {
capabilities: [auth({ baseURL }), consent(), email({ fromAddress, baseUrl })],
app,
processing: {
activities: [
{ id: "clinic.intake", purpose: "Take a patient's history.", classes: ["phi"], legalBasis: "consent", consentPurpose: "clinic.records" },
],
overrides: { "auth.account": { legalBasis: "legitimate-interests" } },
},
};An activity of yours under consent names the purpose in consent({ purposes }) it rests on. The block is parsed when the Worker assembles, and four mistakes each refuse to deploy: an activity under a composed capability’s name, one declared twice, an override naming an activity nothing composes, and a consent purpose under a basis that is not consent.
What it derives for you
- Recipients. Cloudflare always, with what it receives. Workers AI for a capability whose
aibinding is bound. Cloudflare Email Service for email, and each payment rail you turned on for payments. - Retention. The class ages for the classes an activity uses, and every purge window, from the Worker’s resolved
retentionblock. Retention and legal holds is that block. - Measures. The evidence pack’s controls that protect stored personal data, with each control’s current state when the connection may also read the pack.
- Consent counts. For a consent-based activity, its purpose and notice version, whether it is explicit, and how many subjects hold a grant, have withdrawn, and hold a grant that stands now: current notice, at the minimum method, unexpired. They come from the consent record.
The classes each activity claims are cross-checked against the privacy inventory, the same walk GET /control-plane/privacy reports. That is why a field left unmarked is one the record cannot cross-check.
Every gap is named
An activity with no legal basis is a gap, never a guess. The record fills in none of these:
| Gap | Where | Means |
|---|---|---|
no-legal-basis | An activity | It states no basis |
no-consent-purpose | An activity | A consent basis names no purpose, or one no catalog declares |
class-not-stored | An activity | It claims a class nothing stores |
class-unclaimed | The record | A capability stores a class no activity of its own, and none of yours, claims |
phi-not-covered | The record | A stored phi field no consent purpose covers |
no-standing-grant | The record | A consent purpose covering a class has no grant standing now, or no consent capability is composed to record one |
What you still have to declare. Every activity of your own, with its classes and its basis. A legal basis for any activity the kit could not state one for. The privacy class of every field you store. A recipient your own code sends data to, on your activity.
Reading it
GET /control-plane/processingThe record sits behind its own scope, processing:read. It is a read, so pithy dashboard connect preselects it. A connection made before it existed does not hold it, and adds it with --update, naming every scope it already holds beside the new one. No CLI command reads it, and the hosted dashboard does not render it yet, so until it does, read the route directly with a client of your own.
The response carries environment, generatedAt, the build’s version, activities[], recipients[] across the record, measures[], gaps[] and notDerivable[]. Each activity carries id, purpose, declaredBy (the capability, or config), classes, legalBasis or null, basisSource (capability or config), consent, recipients, retention, measures and gaps. The reference has the rest of the shape.
What the Worker could not read on a call is named, not hidden. The privacy inventory, the consent record and the consent catalog each land in notDerivable when they cannot be read, and a gap one of them would have found is not reported as absent. A consent record it could not read still checks each consent basis against the catalog.
It never reaches further than the connection may
Holding processing:read widens no other scope. The measures carry their current states only for a connection that also holds evidence:read. Without privacy:read, a gap names the capability and the class, never a table or a field: phi-not-covered is reported field by field with it, and once per capability without it.
It describes the processing, and changes none of it.