Data map and RoPA
A data map says where every piece of personal data lives and where it travels. A RoPA (record of processing activities) says why we process it. Keep both in one register that engineers update in the same pull request as the code. Without it, a deletion request depends on someone's memory.
How it works
The key idea: the diagram is generated from the register, it is not drawn. If a claim is not a row, it is not on the diagram. A hand-drawn diagram is right for three weeks and then it lies.
A DFD (data flow diagram) shows systems as boxes and data movements as arrows. A RoPA is the record GDPR Article 30 asks controllers and processors to keep: purposes, categories of people and data, recipients, transfers, retention and security measures.
Why field-level
"The users table holds user data" cannot answer the questions that matter:
- Which fields are special category data (religion, health, sexual orientation, biometrics)?
- On an erasure request, what exactly do we delete, anonymise, or lawfully keep?
- When a new column appears in a migration, is it registered, or is it drift?
So the smallest unit is a data element (one logical field), and every place it exists is a separate location row.
data_element "Email address"
location regional_db.users.email plaintext, primary copy
location analytics.user_properties.email_hash hashed
location auth_provider.user_record plaintext, third party
location cache session:{id}.email plaintext, TTL 24h
location error_tracker event.user.email should be scrubbed: verify
Classification lives on the element. How to delete it lives on the location.
How to build it
Fields per entry
system (a box on the diagram)
| Field | Notes |
|---|---|
key, name, kind | app, service, datastore, queue, job, third_party |
environment, region | Where it runs. Region matters for transfers |
owner_person | Required. Someone answers for it |
vendor_id | Set for third parties |
data_element (one logical field)
| Field | Notes |
|---|---|
key, display_name | user.email, profile.religion |
category | identity, contact, device, location, behavioural, content, financial, special category |
is_special_category | GDPR Article 9 data needs an Article 9 condition, usually explicit consent |
is_child_data | Drives children's rules |
is_free_text | Free text can contain anything, including special category data |
source | From the person, derived, inferred or from a third party. Changes the notice duty |
hash_before_egress | Matches the analytics schema's PII flag |
dsr_access, dsr_erasure, dsr_portability | What a request does to this field |
erasure_exemption_reason | Required when we keep it after an erasure request |
data_element_location (one physical copy)
| Field | Notes |
|---|---|
system_id, locator | regional_db.users.email, s3://example-bucket/avatars/{user_id} |
form | plaintext, hashed, encrypted, tokenised, derived |
is_primary_copy | The system of record |
erasure_mechanism | Concrete: the SQL, API call, job name or runbook link |
retention_rule_id | Overrides the element default |
evidence_ref, last_verified_at | The file or migration that proves it, and when someone checked |
processing_activity (one RoPA row)
| Field | Notes |
|---|---|
name, purpose | Plain words, one purpose per row |
controller_entity, our_role | Which legal entity, and controller or processor |
gdpr_lawful_basis | consent, contract, legal obligation, vital interests, public task, legitimate interests |
lia_ref | Required when the basis is legitimate interests |
dpdp_basis | consent or a listed legitimate use. DPDP has no legitimate interests |
consent_purpose_code | Link to the consent registry |
elements, recipients, retention, security_measures | Article 30 content |
profiling, automated_decision, large_scale_special_category | Any true means a DPIA is likely needed |
dpia_status, review_due_at, owner_person |
flow (an arrow on the diagram)
| Field | Notes |
|---|---|
source, target, mechanism | HTTPS, websocket, queue, SDK, batch job, manual export |
auth_requirement | public is the value to watch |
crosses_region, leaves_country | Then transfer_basis is required |
elements | Which fields travel on this edge, and in what form |
vendor (a processor)
| Field | Notes |
|---|---|
role | processor, sub-processor, joint controller, independent controller |
dpa_status, dpa_url | Signed data processing agreement |
transfer_mechanism | adequacy, SCCs, other, or missing |
trains_on_our_data | Critical for AI vendors |
review_due_at |
retention_rule
| Field | Notes |
|---|---|
trigger | from collection, last activity, account deletion, consent withdrawal |
duration_days | Null means kept forever and needs a written reason |
action | hard delete, anonymise, archive, review |
enforcement | automated, manual, or none |
enforcement_ref | The job that actually does it |
enforcement = none is the most useful field in the whole register. It is the gap list.
The gap dashboard
Rows that are wrong by definition:
- A special category element with no Article 9 condition on its activity.
- Legitimate interests with no assessment on file.
- DPIA required and not complete.
- A retention rule with
enforcement = none, or kept forever without a reason. - A flow that leaves the country with no transfer basis.
- A vendor with no signed DPA.
- Data kept after erasure with no exemption reason.
- Anything not verified within its review cycle.
- Columns, routes or SDKs found by the scan that are not registered.
Keep it alive
Run a scheduled scan that compares the code with the register:
| Source | Compared with |
|---|---|
| Database schema and migrations | locations of kind column |
| Route and GraphQL inventory | flows |
| Analytics event schema (PII flags) | analytics elements |
| Infrastructure code (buckets, tables, queues) | datastores |
| Package manifests and SDK imports | vendors |
A new column not in the register shows as unregistered. A registered column that vanished
shows as drifted. Both land on the gap dashboard as work.
The register describes where all personal data lives, so it is sensitive itself. Give it its own role in the admin panel and an audit log. It holds no subject data: requests store a hash of the identifier, never the raw value.
Rules
A pull request that adds a personal-data field, a new store, a new vendor or a new flow updates the register in the same pull request. Otherwise the next deletion request misses it.
erasure_mechanism must be concrete. "Handled by the user module" is not a mechanism. Name
the job, the SQL or the API call, or write none so it shows as a gap.
Mistakes we made
- "Regional personal data, global content" was not true. The data map said user-owned data lived in the home region and the global database held only app content. An audit found community posts, private group chats, moderation reports with free-text allegations and staff accounts on the global tier, by design. A raw device hardware ID was stored globally with no erasure path. A code comment listing "three schemas" was why nobody noticed. Map from the schema, not from the architecture slide.
- A comment promised a backstop that did not exist. Two files said an S3 lifecycle rule
deleted uploaded lead files after a few days. No such rule existed in any environment, so
every file was kept forever. Register the rule with its
enforcement_ref, and verify it against live infrastructure. - Undisclosed processors. The policy named one AI provider. The code used a different one, plus two moderation providers nobody had listed. A vendor list built from package manifests would have caught it.
Checklist
- Every new field has a data element with category, special-category flag and DSR stance.
- Every copy (DB, cache, logs, analytics, vendor) has a location row with a concrete erasure mechanism.
- Every new vendor has a DPA status and a transfer mechanism.
- Every flow that leaves the country has a transfer basis.
- Every retention rule names the job that enforces it.
- The drift scan is green, or the new gap is ticketed.