Lawful basis, consent and retention — Examples

Example 1 — One row per data category

A team is drafting its privacy record and has written a single line: “user data — purpose: to run the service — basis: consent — kept: indefinitely”. That sentence cannot be audited and cannot be enforced. Rewrite it as the table the team actually needs, with a basis and a lifetime per category.

Draft record
  system: customer portal
  data:   user data
  why:    to run the service
  basis:  consent
  kept:   indefinitely

Solution

Data            Purpose           Basis        Location      Retention
email, name     account + login   contract     users         account life
IP address      abuse prevention  legitimate   request logs  30 days
marketing prefs promotions        consent      users         until withdrawn
order history   fulfilment        contract     orders        legal minimum

The table is the artefact; everything else — the consent text, the retention jobs, the access decisions — is derived from it. The same person now appears in four rows with different bases and different lifetimes, which is the point: consent is the right answer only for the marketing row, while the data the service cannot run without belongs under contract. The usual mistake is one row per system, which hides the fields that are not justified and never gets a lifetime attached.

The signup form stores marketing_opt_in = true and nothing else. When a user asks what they agreed to and when, support has no answer, and a withdrawal cannot be scoped to a purpose. Replace the boolean with a record the team can defend.

-- users table, as built
marketing_opt_in boolean not null default false

Solution

-- consent table, one row per purpose
create table consent (
  user_id      bigint not null,
  purpose      text   not null,
  granted_at   timestamptz not null,
  text_version text   not null,
  withdrawn_at timestamptz
);
-- a withdrawal is an update, not a new email to support

Storing the date, the purpose and the version of the text shown turns “did they agree?” into a fact, and consent for promotions no longer stands in for consent to something else. Withdrawal updates the row, so the toggle that set consent unsets it. The usual mistake is to keep the boolean and add a policy statement around it; a policy cannot answer a question the schema was never able to store.