# Personal data: how S17 is built

What S17 built (plan CORE §3.23; Ghana's Data Protection Act 2012 and the GDPR; TuteLoop's consents and export,
TuteDesk's retention and append-only trails, 25 October 2026). The rules: **every kind of personal data is a part**;
**a copy holds only this workspace's**; **the audit log is never rewritten: a person's words in it are sealed**
(CORE-D17); **other logs empty their personal columns through one narrow door**; **only the person gives a consent**.

## Requests (`privacy_request`, `Requests`)

A workspace's record: whose (`membership_id`, and `person_code`, their key's code, so it still names them after
erasure), the kind (`copy`, `fix`, `erase`, `stop`: code), how it came (`privacy_channel`, Input Fields), how they were
known (`account`, `document`, `not_yet`: code), what they said, received and due (`privacy.due_days`, 30), the state
(`open`, `answered`, `refused`), the answer. Each step is a row of `privacy_request_event` (append-only; the actor's
name sealed). `privacy.manage` (the Owner's; elevated). Logged by staff, or asked from My data (`Requests::ask`: in the
app, from their own account). `privacy.requested` tells whoever may answer; `privacy:due` (daily, 07:30) reminds them
once of each due within a week. A refusal tells the person why (`privacy.refused`) and that they may complain to the
Data Protection Commission.

## The parts (`PersonalDataPart`, `PersonalData::PARTS`)

Each says what it covers (tables or `table.column`s), its fate before anyone presses (`erased`, `ended`, `emptied`,
`anonymised`, `unreadable`, `kept`), how much there is, its rows for a copy, and how it is erased in this workspace
and, when no workspace holds the person any more, on the platform.

| Part | Covers | Fate |
|---|---|---|
| `consent` | `consent` | erased (through the purge door) |
| `request` | `privacy_request`, its steps' actor | kept, by code; what they said emptied |
| `email` | `email_message` recipients, delivery, subject, body, actor | emptied (to, about or by them) |
| (none) `send_block`, `send_block_line` (NU6) | addresses and numbers not to be written to | kept after an erasure: a suppression list is how someone is never written to again |
| `product_email` (NU5) | `product_email` recipients, subject, body | emptied, as the e-mail log is: the row keeps that an e-mail went and when |
| `notification` | `notification` | erased (theirs, and others' about or by them) |
| `file` | files they added or that are theirs (a copy too); `file_access` | erased from the vault; their downloads anonymised |
| `import` | `import_row` of the row that added them; `import.user_id`, `undone_by` | emptied |
| `identifier` | `person_identifier` (this workspace's; all on the platform) | erased |
| `membership` | this workspace's record of them (names, birth date, e-mail, phone: plan SP), the place, roles, grants, invitations, tokens, saved views, a mute, import columns, exports, a lens's member, `setting_value.set_by_name` | ended, then erased |
| `sign_in` | `auth_event` | anonymised with the platform record: the count stays |
| `profile` | `person` (the account's name, a confirmed phone), `user` (with the address they sign in with) and what hangs on it, `person_merge.snapshot` | erased with the platform record |
| `audit` | `audit` names, `person_key` | kept, unreadable: this workspace's key destroyed |

A product adds a part for its own records, before `membership`. `Guards/PersonalDataGuardTest` scans the schema: every
foreign key to `person`, `user` or `membership`, and every column copying a name, contact or device, is a part's or
listed as not personal with why.

## A copy (`CopyFile`, `MakeCopy`)

Every part's `copy()`, only this workspace's (and the person's own account and sign-ins), as a zip of `data.json` and
`copy.pdf` (dompdf; the view `privacy.copy`, in the person's words), kept in the vault on the request and named by
code, never by the person. Made in the background as whoever pressed. Sent as a link (`privacy.link_days`, 7) that
asks for a code e-mailed to the same address (`privacy.code`, 10 minutes, the `privacy-code` limiter), so a forwarded
link opens nothing; each download is in the file access log and the request's steps.

## Erasure (`PersonalData::erase`, `EraseData`)

Reviewed first (each part's fate), then the password again (step-up) and the name typed exactly. In the background, as
whoever pressed, under the request's audit tag (`privacy:<public id>`), in one transaction with the purge and erasure
doors open: every part's `erase()`, then, when no membership anywhere holds them (the Architect's decision, 25 October
2026: no manual step by the Service), every part's `erasePlatform()`. The request is answered and says which.

## The keys (CORE-D17: `person_key`, `PersonKeys`, `PersonalFields`, `Sealed`)

One key per person per workspace, and one for the platform's rows; each its own AES-256-GCM key, wrapped by the app
key, with a 6-letter code. The audit writers (`Audited`, `AuditTrail`) seal, with the row's workspace key: the actor's
name (a person's, never the Service's), and, when the subject is personal (`PersonalFields::FIELDS`: a person, an
account, an ID, a place, an invitation, a request), its name and personal values. A sealed value reads
`sealed:<code>:<base64>`; the `Sealed` cast on `Audit` (and on the request's steps and consents' recorder) opens it, or
reads "Former member 7K2QXM" once the key is destroyed. A destroyed key stays with its person, so anything sealed for
them afterwards is unreadable from the start. Searching the audit list by a name finds the people it belongs to (the
sealed text can't be searched).

Rows written before S17 aren't sealed (none in production). Each new person's first audit row in a workspace makes a key,
so a large import is slower (1,000 people: about 66 seconds in the test, against 36).

## The erasure door

The append-only logs' trigger takes its personal columns as arguments (`auth_event`: user, e-mail tried, address,
device, context; `file_access`: user, name, address, device; `person_merge`: snapshot; `lens_session`: the member;
`email_message`: recipients, delivery, subject, body, actor). Under `app.log_erase` (`AppendOnly::erasing`) an update may
only empty those (to null, "", [] or {}); anything else is still refused. The audit log names none.

**The merge's door** (plan MC, 3 November 2026): while `app.log_repoint` names a column (`AppendOnly::repointing`,
opened only by `MergePeople::repoint()`), a log's row may change that column and nothing else, so a consent the
duplicate gave follows its membership to the kept one with its id, time and words.

## Consents (`consent`, `Consents`)

Append-only: the purpose (`consent_purpose`), given or withdrawn, the wording and version as they read then, how (`app`,
`told`), a note, IP and device (the person's own only), the recorder sealed. The latest row for a purpose is where it
stands (`Consents::given()` for a product's use). Only the person gives one (My data); staff record a withdrawal they
were told about (`privacy.consent.withdraw`, with how). A merge moves the duplicate's consents to the kept membership
as they were (the merge's door, above), so where each stands is unchanged.

## My data (`me.data`)

The person's own page in the workspace they're in: their consents (the live toggle), a copy, a correction, an erasure,
their requests. In the user menu (`Nav::MINE`). The Service has none.

## Adding a part

1. A class extending `PersonalDataPart`: `covers()`, `fate()`, `count()`, `copy()` (this workspace's only), `erase()`,
   `erasePlatform()` if the platform holds any; register it in `PersonalData::PARTS` before `membership`.
2. Its words: `privacy.part.<key>.title`, `.yours`, `.how`; its copy's field labels under `privacy.copy.field.*`.
3. A model whose audit rows name a person: add it to `PersonalFields::FIELDS` (and `personOf()`).
4. A new log: its personal columns as its trigger's arguments.

## The guards

| Guard | Test |
|---|---|
| After an erasure no table or audit row reads the name, contact or ID (every table read) | `Privacy/ErasureTest` |
| Kept elsewhere: only this workspace erased, that one still readable | `Privacy/ErasureTest` |
| A copy holds only this workspace's data | `Privacy/RequestTest` |
| The link opens nothing without its code; it runs out | `Privacy/RequestTest` |
| Every personal column is a part's or listed; parts whole; the audit log sealed | `Guards/PersonalDataGuardTest` |
| The new logs purged or kept for good | `Guards/LogGuardTest` |
