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.

Three journals, three questions

They are kept apart on purpose, because they answer different questions and are read by different people.

Journal Question Example
The activity log What happened at a door? "North entry opened at 08:14 for badge BDG-0001"
The control plane audit Who did what in the system? "sophie@acme.example opened North entry from the dashboard"
The technical journal 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 log"]
    B["An operator clicks Open"] --> CP["Control plane audit"]
    B --> AL
    C["A controller goes silent"] --> SA["Technical journal"]
    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 cannot be altered or removed row by row, not because the application code happens to avoid doing so, but because the role it connects as simply does not hold the permission to.

flowchart TD
    APP["Backend"] -->|"can write new rows"| L["The three journals"]
    APP -.->|"changing or deleting one<br/>is refused by the database itself"| L
    MNT["Retention & erasure<br/>(a separate role, used only for this)"] -->|"drop whole time periods,<br/>anonymise on request"| L

Why enforce this at the database level? A rule enforced only by application code is a rule some future change quietly works around without meaning to. A permission the database itself refuses produces an immediate, impossible-to-miss error instead.

There are deliberately no strict links tying journal rows to the tables they mention:

  • An unrecognised credential has no person to point at.
  • Deleting a decommissioned reader must never fail a log write or ripple into history.
  • Splitting the journal by month stays simple.

Making sense of a row again later is done by looking things up at read time instead.

Retention

What Kept for Configured by
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
A telemetry sample 5 days TELEMETRY_RETENTION_DAYS

Telemetry — a controller's own CPU load, memory, disk, and similar system metrics (chapter 7) — is not personal data: nothing in it identifies a person, only a machine. It is listed here for completeness, and because its purge is a plain DELETE rather than a partition drop like the four rows above it — small enough at its own volume that it does not need one.

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 around a while so a passage from last week can still be explained by the right that allowed it at the time. Past that window, it goes.

An acknowledged sync task has the shortest window of all, because each one carries a whole rights payload — a queue nobody ever empties would outweigh every other log put together within days. Only acknowledged tasks are cleared out this way; one still pending or failed is a right that never actually reached its door, and has to stay visible until it does. Visible is not the same as untouched: erasure reaches a queued delta whatever its status, so no window here is a way for someone's name to outlive them.

How the purge works

Nothing is ever deleted from a journal one row at a time. A whole time period is dropped in one go instead, which is what makes this cheap enough to actually run on schedule, and what lets the application role keep no delete permission on these tables at all.

flowchart TD
    S["Retention sweep, every few hours,<br/>one instance at a time"]
    S --> D["Drop every period entirely<br/>past its own window"]
    S --> E["Make sure the next couple of months<br/>already have somewhere to write to"]
    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 old periods and preparing new ones happen in the same pass — a deployment left untouched for months would otherwise purge its way right up to a day with nowhere left to write, and the next passage would simply fail to be recorded at all.

A deliberate consequence. A time period is only ever dropped once all of it has passed its window, so a rule that says "30 days" genuinely keeps somewhere between 30 and 60. Erring toward keeping a little longer is the right way to be wrong about a log — but it is a real fact worth knowing, not a rounding error to gloss over.

This same purge can also be triggered by hand at any moment — reachable only to an administrator, since it reaches across the whole fleet rather than one customer's own slice of it. It reports back exactly what it dropped, and running it when there is genuinely nothing left to purge is simply a normal, successful, empty run.

Even the purge itself leaves a record behind, written the ordinary way: the record of what was destroyed is itself something nobody can go back and quietly edit.

Right of access

Asked for a specific person, wardn hands back one document: that person, every access right they have ever held along with whatever credentials each one carried, and every passage ever attributed to them.

{
  "user": { "id": "0f00…", "name": "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 ordinarily only covers changes, and handing over somebody's entire file is not an ordinary read — so this one specific request writes its own entry into the technical journal, every time.

Right to erasure

flowchart TD
    E["A request to erase this person"] --> C["Note which controllers hold<br/>a credential of theirs"]
    C --> T["In one single step:"]
    T --> A["Their passages stay, but stripped of<br/>anything that names them"]
    T --> D["Deliveries still queued keep<br/>every part but theirs"]
    T --> R["Their access rights are removed entirely"]
    T --> U["Their own record is removed entirely"]
    A --> S["Every controller noted above is sent<br/>its whole rights set again"]
    D --> S
    R --> S
    U --> S
    S --> J["Recorded in the technical journal:<br/>who asked, what was erased"]

The passages themselves stay. A site still has to be able to say that a lane opened at 08:14 — a log full of holes stops being a log at all. What actually goes is everything that ties those rows back to a specific person: their identity, their credential, the raw information the controller originally sent about that read.

A delivery still waiting in the queue is redacted the same way. What is queued for a controller carries the person's identifier and the digest of their badge, and only an acknowledged delivery is ever cleared out — so one that never reached its controller would hold both for good. What names them goes; the rest of that delivery stays, because it is other people's rights and those are still owed their door.

The badge stops working right at the door, not only in a database somewhere. Every controller that held one of this person's rights is sent its entire set again, in full, rather than just the one change — this is the one situation in the whole system where being certain matters more than being economical about it.

This is the only kind of write in wardn that ever reaches into an otherwise append-only journal. It runs through a role kept specifically for this and nothing else, entirely separate from the one the application normally uses — and even this leaves a trail of its own behind.

Both records exist: who asked, in the control plane audit, and exactly what was destroyed, in the technical journal.

There is no shorter way round. Plainly deleting a person is refused for as long as a right of theirs exists, revoked ones included: that path would take the same rows away without anonymising a passage, without telling a controller anything, and without leaving either record behind.

Data minimisation on the controller

The controller holds the least amount of information it possibly can and still make its own decisions:

It holds It does not hold
A scrambled, one-way form of each credential The credential's actual value
A person's internal id, when there is one attached Their name, email, or any description
The current validity window Any history of past ones
Passages not yet delivered Ones already confirmed delivered

A lapsed right is dropped after a short grace period, and a passage is deleted from the local queue the moment the server confirms it actually arrived — nothing sits around on the box longer than it has to.

Following a request

Every response, and every error, carries a short trace id along with it — the same id a person could quote back, and the same one that would show up if that specific request needed to be found and looked into afterwards.

That is what turns "a person says their badge was refused on Tuesday" into something concrete and traceable: the exact request, its exact queries, and everything that happened as a result of it.

Restoring is a compliance event

Restoring an older backup also rolls back any erasure that happened since — including the record that it ever happened at all. Someone who exercised their right to be forgotten would effectively come back, and the database itself would no longer even be able to say who they had been.

Because of that, the record of what was erased is kept alongside each backup rather than folded inside it, so restoring a backup and correctly re-applying every erasure that came after it are always two separate, deliberate steps — never one that silently undoes the other. The full procedure is in 12. Operating.


The obligations are met. Now: keeping the thing alive.

Next → 12. Operating