← 10. Security · Contents · Next → 12. Operating
11. Personal data¶
wardn records who passed which door, when, and with what credential. That is processing of personal data, and it is designed in from the start rather than bolted on.
make compliance # trace a request, export a person, erase them, purge what expired
Three journals, three questions¶
They are kept apart on purpose, because they answer different questions and are read by different people.
| Journal | Question | Example |
|---|---|---|
activity_logs | What happened at a door? | "North entry opened at 08:14 for badge BDG-0001" |
control_plane_audit_logs | Who did what in the system? | "sophie@acme.example opened North entry from the dashboard" |
system_audit_logs | What did the system do to itself? | "edge-01 stopped reporting", "retention dropped three partitions" |
flowchart LR
A["A badge at a reader"] --> AL["activity_logs"]
B["An operator clicks Open"] --> CP["control_plane_audit_logs"]
B --> AL
C["A cabinet goes silent"] --> SA["system_audit_logs"]
D["An alert is raised"] --> SA A remote opening produces two entries. Putting the operator's name in the activity log would blur the line between a credential presented at a reader and a person clicking a button; losing the opening from that log entirely would leave a car through a door with nothing to explain it. Both entries exist, and neither pretends to be the other.
Append-only, by construction¶
The three journals are declared PARTITION BY RANGE on their time column, and the application role has UPDATE and DELETE revoked on all of them.
flowchart TD
APP["Backend: wardn_app"] -->|"SELECT, INSERT"| L["The three journals"]
APP -.->|"UPDATE / DELETE refused<br/>by PostgreSQL"| L
MNT["Retention & erasure: wardn_maintenance"] -->|"drop partitions,<br/>anonymise on erasure"| L Why at the database level? A rule enforced by code is a rule a future developer will work around without meaning to. A permission refused by PostgreSQL produces an immediate, visible error.
make verify asserts the grants are exactly this.
There are no foreign keys on the journal tables, and that is deliberate: an unknown credential has no user row to point at, deleting a decommissioned reader must never fail a log write nor cascade into history, and monthly partitioning stays simple. Referential integrity is resolved at read time by explicit joins, all of them outer except the zone.
Retention¶
| What | Kept for | Variable |
|---|---|---|
| Passages | 30 days | ACTIVITY_LOG_RETENTION_DAYS |
| The technical journal | 30 days | SYSTEM_LOG_RETENTION_DAYS |
| The control plane audit | 365 days | AUDIT_LOG_RETENTION_DAYS |
| A revoked access right | 30 days | ACCESS_RIGHT_RETENTION_DAYS |
| An acknowledged sync task | 7 days | SYNC_TASK_RETENTION_DAYS |
The control plane audit is kept longest because it is the one that answers questions after the fact: who changed what, and when.
A revoked right is kept a while so a passage from last week can still be explained by the right that allowed it. Past that, it goes.
Acknowledged sync tasks have the shortest window because each row carries a whole rights payload: a queue nobody empties outweighs every log table within days. Only acknowledged tasks are purged. One still queued or failed is a right that never reached its door, and has to stay visible.
How the purge works¶
Nothing is deleted row by row from a journal. A whole monthly partition is dropped, which is what makes retention cheap enough to actually run and why the application role can keep DELETE revoked.
flowchart TD
S["Retention sweep, every 6 hours<br/>one instance at a time, under an advisory lock"]
S --> D["Drop every partition entirely older<br/>than its window"]
S --> E["Ensure the next two months of partitions exist"]
S --> R["Delete revoked rights past their window"]
S --> T["Delete acknowledged sync tasks past theirs"]
D --> J["Record what was destroyed<br/>in the technical journal"]
E --> J
R --> J
T --> J Dropping and creating belong to the same sweep: a deployment left alone for months would otherwise purge its way into a table with no partition for today, and the next passage would fail to record.
A deliberate consequence. A partition is dropped only once all of it is past the window, so a 30-day rule keeps between 30 and 60 days. Erring towards keeping is the right way to be wrong about a log, but it is a fact that belongs in a register of processing activities, not a rounding error.
Running it now:
curl -X POST -H "Authorization: Bearer $TOKEN" \
http://localhost:3000/api/v1/maintenance/retention
It reports what it dropped, is idempotent (nothing left to purge is a successful, empty run), and is not available to a tenant API key, because the sweep is fleet-wide.
The purge records itself in the journal it just purged, written through the application role: the record of what was destroyed is itself append-only.
Right of access¶
curl -H "Authorization: Bearer $TOKEN" \
http://localhost:3000/api/v1/users/$USER/personal-data
Returns, in one document: the person, every access right they have held with the credentials each carried, and every passage attributed to them.
{
"user": { "id": "0f00…", "email": "sophie@acme.example", "fullName": "Sophie Martin" },
"accessRights": [
{ "id": "1a00…", "zoneId": "0b00…", "type": "permanent", "granted": true,
"validFrom": "2026-01-01T00:00:00Z", "validTo": "2027-01-01T00:00:00Z",
"isActive": true, "credentials": [{ "type": "badgeNumber", "value": "BDG-0001" }] }
],
"passages": [
{ "occurredAt": "2026-08-10T07:14:22.104Z", "doorId": "0d00…",
"credentialType": "badgeNumber", "accessGranted": true, "reason": "SUCCESS" }
]
}
The read records itself. The control plane audit only covers mutations, and handing over somebody's whole file is not an ordinary read. So this one writes its own entry into the technical journal.
Right to erasure¶
curl -X DELETE -H "Authorization: Bearer $TOKEN" \
http://localhost:3000/api/v1/users/$USER/personal-data
flowchart TD
E["DELETE /users/{id}/personal-data"] --> C["Note which cabinets hold<br/>a credential of this person"]
C --> T["In one transaction, as wardn_maintenance:"]
T --> A["activity_logs: user_id → NULL,<br/>credential_id → NULL,<br/>scannedValue → '(erased)',<br/>payload → NULL"]
T --> R["access_rights: deleted<br/>(credentials cascade)"]
T --> U["users: deleted"]
A --> S["Full resync of every cabinet noted above"]
R --> S
U --> S
S --> J["Recorded in the technical journal:<br/>who asked, what was erased"] The passages stay. A zone has to know that a lane opened at 08:14, and a log full of holes is no longer a log. What goes is everything that ties those rows to a person: the identity, the credential, and the payload the cabinet sent.
The badge stops working at the door, not only in the database. Every cabinet that held one of these rights is sent its whole set again rather than a delta. This is the one operation where being sure is worth more than being cheap.
This is the only write in wardn that reaches into an append-only journal. It therefore runs through the maintenance role, which the ordinary pool never uses, and it leaves a record of itself.
Both trails record who asked: the control plane audit records the DELETE, and the technical journal records what it destroyed.
Data minimisation on the cabinet¶
The cabinet holds the least it can and still decide:
| It holds | It does not hold |
|---|---|
sha256(normalise(type, value)) | The credential value |
| A user id, when there is one | Names, emails, descriptions |
| The current validity window | Any history |
| Undelivered passages | Delivered ones |
Lapsed rights are dropped past EXPIRED_RIGHT_GRACE_DAYS, and delivered passages are deleted from the queue the moment the broker confirms them.
edge/wardn-edge/src/store.rs carries a test that reads the raw database file back and asserts a plate never appears in readable form on disk.
Following a request¶
Every response carries X-Trace-Id, and every error carries the same id in its body, including the RFC 9457 problem document a client sees.
curl -i -H "Authorization: Bearer $TOKEN" http://localhost:3000/api/v1/zones
# X-Trace-Id: 4bf92f3577b34da6a3ce929d0e0e4736
The backend joins a caller's traceparent rather than starting its own, so an id minted by a gateway survives the whole way through. Spans exist whether or not anything collects them, which is what keeps the id on every response.
That is what turns "a data subject says their badge was refused on Tuesday" into a request, its queries and its broker calls.
Restoring is a compliance event¶
Restoring an older backup rolls back later erasures and the record that they happened. Somebody who exercised their right to be forgotten comes back, and the database itself can no longer say who they were.
The erasure ledger is therefore written beside each backup rather than inside it, and make restore prints the replay procedure with the real paths filled in. The full procedure is in 12. Operating.
The obligations are met. Now: keeping the thing alive.
Next → 12. Operating