Skip to content

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