GDPR Data Export Procedure (UK GDPR Articles 15 + 20)
GDPR Data Export Procedure (UK GDPR Articles 15 + 20)
Section titled “GDPR Data Export Procedure (UK GDPR Articles 15 + 20)”Status: v1 (kh-prod-readiness-S10 W6, 28/04/2026). Audience: Liam + any future operator handling Subject Access Requests (SARs) or data portability requests. Owner: prod-readiness track. Pair with:
docs/audits/kh-production-readiness-phase-1/specs/wp-g6.7-gdpr-data-export-spec.md(the spec — PII inventory, Article 15/20 scope distinction, decisions register) andscripts/export-user-data.ts(the executable script).
§1. Purpose
Section titled “§1. Purpose”Knowledge Hub processes personal data of authenticated users (auth records, content authorship, audit trails, behavioural preferences, etc.). Under the UK General Data Protection Regulation (UK GDPR), data subjects have:
- Article 15 — Right of access: the right to obtain a copy of the personal data we hold about them, plus information about how it is processed.
- Article 20 — Right to data portability: the right to receive personal data they have provided to us in a structured, commonly used and machine-readable format, and the right to transmit it to another controller.
This runbook operationalises both rights with a manual operator-triggered procedure backed by an executable export script.
§1.1 Legal context
Section titled “§1.1 Legal context”- Statutory deadline: ONE CALENDAR MONTH from receipt of a verified request, extendable once by two months for complex requests with prior written notice to the subject (UK GDPR Article 12 §3).
- Cost: FREE OF CHARGE. A “reasonable fee” may be charged only for manifestly unfounded or excessive requests, which v1 of this procedure does not handle (escalate to legal).
- Format requirement (Article 20): structured, commonly used, machine-readable. JSON satisfies all three.
- Format requirement (Article 15): “in a commonly used electronic form” if requested electronically. JSON + CSV summary + Markdown index satisfies this.
- Identity verification: “proportionate to the data” (ICO RoA guidance). For Knowledge Hub today this means email-on-file plus a second factor (recent invoice number, transaction reference, or sign-in log entry).
- Scope: v1 covers manual SAR triage only. There is no self-service portal; subjects request via email to Liam (or whatever operator role succeeds him).
ICO right-of-access guidance: https://ico.org.uk/for-organisations/uk-gdpr-guidance-and-resources/individual-rights/right-of-access/ (verbatim quotation paraphrased from prior knowledge of UK GDPR Articles 12-15 statutory text + Data Protection Act 2018).
§1.2 Escalation path if statutory deadline is at risk
Section titled “§1.2 Escalation path if statutory deadline is at risk”If the deadline is within 7 days and the export bundle is not yet ready:
- Notify the subject in writing of an Article 12 §3 two-month extension, citing complexity (e.g. “the volume of audit-trail data referencing your account requires additional time to assemble”). Send before the one-month deadline expires; document in the DSAR register.
- Escalate to Liam if the operator is not Liam.
- If the deadline has already lapsed without notification, the breach is serious — escalate immediately to Liam and document in DSAR register §10. ICO can be notified separately by Liam if the subject complains.
§2. Pre-flight: confirm subject identity
Section titled “§2. Pre-flight: confirm subject identity”Before invoking the script, the operator MUST verify the requester is the data subject (not someone else attempting to harvest the subject’s data).
§2.1 Minimum identity verification
Section titled “§2.1 Minimum identity verification”The requester must demonstrate at least TWO of the following:
- Email control. The request originates from the email address on
file in
auth.users.email(request comes FROM that address; or a confirmation reply to that address before the export is sent). - Account knowledge. A recent sign-in date, last invoice reference, or last interaction with the system that an outsider could not plausibly know.
- Photo ID. For high-sensitivity bundles (e.g. former employees with bid-response history), request a scanned copy of UK photo ID (passport / driving licence). Delete the ID immediately after verification — the ID itself is high-PII and should not enter long-term storage.
§2.2 What “verification” looks like in practice
Section titled “§2.2 What “verification” looks like in practice”A typical Knowledge Hub SAR flow today:
- Subject emails Liam: “Please send me all data you hold about me under UK GDPR Article 15.”
- Liam confirms the request was sent FROM the email address on file (Item 1 of §2.1). If yes, proceed; if not, reply requesting verification.
- Liam replies: “To confirm your identity, please reply with the date of your most recent sign-in or the reference number of your last bid response.”
- Subject confirms; Liam logs the verification method in the DSAR register (§10).
- Liam invokes the export script (per §3 below).
Document the verification method in the DSAR register row. If you cannot verify the requester’s identity, REFUSE THE REQUEST in writing, citing UK GDPR Article 12 §6, and log the refusal in the DSAR register.
§2.3 Refusal and exemption grounds
Section titled “§2.3 Refusal and exemption grounds”The operator MAY refuse a request if:
- Identity cannot be verified (per §2.2 above).
- The request is “manifestly unfounded or excessive” (UK GDPR Article 12 §5 (b)) — e.g. repetitive identical requests within 30 days, or requests for clearly unrelated data.
- The data is exempt under Schedule 2 to the Data Protection Act 2018 (e.g. legal-professional-privilege material, journalistic exemption).
Refusal must be in writing within ONE MONTH and must inform the subject of their right to complain to the ICO (https://ico.org.uk).
§3. Step-by-step procedure
Section titled “§3. Step-by-step procedure”§3.1 Pre-export checklist
Section titled “§3.1 Pre-export checklist”- Identity verified per §2 above.
- DSAR register row started with received-date and verification method.
- Statutory deadline calculated (received-date + 1 month) and noted.
- Operator has access to
~/.config/kb-prod-creds.env(or equivalent) withSUPABASE_SERVICE_ROLE_KEYfor the target environment. - Operator confirms which environment (
prodfor the canonical KB;stagingonly for test runs against TEST_USER_*).
§3.2 Invocation
Section titled “§3.2 Invocation”Production target — by UUID (preferred):
# Source prod credentials (per docs/runbooks/local-development.md §3.2)source ~/.config/kb-prod-creds.env
# Invoke exportSUPABASE_URL="$SUPABASE_URL" \SUPABASE_SERVICE_ROLE_KEY="$SUPABASE_SERVICE_ROLE_KEY" \bun run scripts/export-user-data.ts \ --env=prod \ --user-id <subject-uuid> \ --output ./exports/ \ --article=15 \ --format=both
# Always unset prod creds after to avoid leaking into later commandsunset SUPABASE_URL SUPABASE_SERVICE_ROLE_KEYProduction target — by email:
source ~/.config/kb-prod-creds.env
bun run scripts/export-user-data.ts \ --env=prod \ --email <subject-email> \ --output ./exports/ \ --article=15 \ --format=both
unset SUPABASE_URL SUPABASE_SERVICE_ROLE_KEYArticle-20-only (portability subset):
bun run scripts/export-user-data.ts \ --env=prod \ --user-id <subject-uuid> \ --article=20Staging dry-run (against TEST_USER_1 — safe, no real PII):
# .env.local already points at staging by default (post-WP-S5.2)bun run scripts/export-user-data.ts \ --env=staging \ --email=test.user1@test-kb-aish.co.uk \ --output=/tmp/dsar-test \ --article=15§3.3 Expected output
Section titled “§3.3 Expected output”A directory named <subject-uuid>-<ISO8601-timestamp>/ containing:
| File | Description |
|---|---|
subject.json | The subject’s auth.users + user_profiles + user_roles + user_notification_prefs rows (machine-readable JSON). |
activity.json | The subject’s read_marks + notifications. |
audit-trail.json | All audit-trail rows where subject is the actor (Article 15 only — omitted with --article=20). |
attributed-content.json | All content rows attributed to subject (Article 15 only). |
manifest.json | Bundle metadata: invocation flags, file inventory, SHA-256 checksums. |
README.md | Plain-English index for the data subject (UK English). |
subject-summary.csv | Single-row CSV of subject identifier columns (with --format=both). |
activity-summary.csv | Per-event CSV of activity (with --format=both). |
Exit codes:
0— success.1— subject not found (noauth.usersrow matching--user-id/--email).2— export error (DB unreachable, write permission denied, etc.).
§3.4 Review the output before transmission
Section titled “§3.4 Review the output before transmission”Before sending the bundle:
- Open
manifest.json— confirm the file inventory and that all SHA-256 hashes are present. - Open
subject.json— confirm the email matches the requester. - Skim
subject.json’sroles_granted_by_subjectrows (if present) — confirm only the grantee’s pseudonymoususer_idandroleare shown, withdisplay_nameredacted (per {138.20}); spot-check before sending. - Open
subject-summary.csvin Excel — confirm there are no obvious data corruption issues. - Skim
audit-trail.json— confirm no third-party PII has leaked in (the script redacts third-party email/name from attribution rows per spec §3.1 D-G6.7-9, but spot-check before sending). - Open
README.md— confirm the plain-English index is sensible for the subject to read.
If any of these checks fail, FIX before transmission. Do not send a malformed bundle.
§3.5 Package and transmit
Section titled “§3.5 Package and transmit”- Create a ZIP archive of the bundle directory:
Terminal window cd ./exports/zip -r <uuid>-<timestamp>.zip <uuid>-<timestamp>/ - Transmit via an encrypted channel:
- 1Password share (preferred — auto-expires after 7 days).
- Encrypted email (PGP / S/MIME) if the subject has a public key.
- Vendor-provided secure-file-transfer if available.
- NEVER send via plain email or unencrypted file-share.
- Transmit a separate notification (separate channel) to the subject
informing them the bundle has been sent and providing the SHA-256
checksum from
manifest.jsonfor integrity verification. - Log the transmission method in the DSAR register row.
§3.6 Post-export retention (of the export bundle itself)
Section titled “§3.6 Post-export retention (of the export bundle itself)”The export bundle is itself sensitive PII. Retention discipline:
- Local copy: delete from operator’s local filesystem within 30 days of transmission. The subject has the canonical copy.
- DSAR register: record the bundle’s deletion date.
- Backup copies: if the operator’s machine is auto-backed-up (Time Machine, etc.), the backup may retain the bundle longer than 30 days. Document the retention discipline in the DSAR register; if the subject objects, manually purge the backup.
- Cloud storage: NEVER place the bundle in cloud storage that is not end-to-end encrypted with operator-only access (e.g. iCloud Drive, Google Drive, OneDrive). Use only 1Password share or equivalent.
§4. Re-running the export
Section titled “§4. Re-running the export”If a subject sends a follow-up request within 30 days asking for the same data, the operator MAY re-run the script (the data may have changed) and transmit the updated bundle. The first request was the substantive one; subsequent requests within 30 days are at the operator’s discretion to fulfil free of charge or escalate as “manifestly unfounded” per §2.3.
If the data has not materially changed (e.g. the subject’s content authorship is identical), the operator MAY also re-transmit the original bundle with a brief note explaining no material change. Log the re-transmission in the DSAR register.
§5. Article 17 (right to erasure) interaction
Section titled “§5. Article 17 (right to erasure) interaction”Article 17 erasure is OUT OF SCOPE for this WP. If a subject requests erasure (not just access):
- Acknowledge receipt within ONE MONTH (statutory deadline).
- Escalate to Liam — erasure has audit-trail tension (deleting
content_history.created_byrewrites history that we are required to retain for trust + governance reasons). - Liam decides per-table what to delete vs pseudonymise.
- v1 of this WP does NOT provide an erasure script. Manual SQL is required, performed by Liam against the prod DB with extreme care.
A future WP-G6.X will spec the erasure procedure, including pseudonymisation of audit-trail actor columns.
§6. Common failure modes
Section titled “§6. Common failure modes”§6.1 “subject not found” (exit code 1)
Section titled “§6.1 “subject not found” (exit code 1)”The --user-id UUID or --email does not match any auth.users row.
- Re-check the request for transcription errors.
- Try the alternate identifier (if you used
--email, try--user-idwith the UUID from a recent sign-in log). - If still no match, the subject may never have registered (in which case there is no PII to export — reply to that effect) or the account may have been deleted (in which case escalate to Liam to check whether the deletion was an erasure response that produced a tombstone log).
§6.2 “export error” (exit code 2)
Section titled “§6.2 “export error” (exit code 2)”DB unreachable, write permission denied, etc.
- Confirm
SUPABASE_URLandSUPABASE_SERVICE_ROLE_KEYare set correctly (perdocs/runbooks/local-development.md§3.2). - Confirm the output directory is writable.
- Check the script’s stderr for the underlying error.
- If the error is “Bun fetch hangs on HTTP 204” (per CLAUDE.md
Supabase gotchas), re-invoke with
dangerouslyDisableSandbox: trueif running from the Claude Code sandbox.
§6.3 Bundle is empty / mostly empty
Section titled “§6.3 Bundle is empty / mostly empty”A new user with no activity / authorship will produce a small bundle
(subject.json + manifest.json + README.md only; activity and
audit files empty). This is correct — there is nothing else to export.
Send the small bundle and explain in the cover note.
§6.4 Multiple auth.users rows match the email
Section titled “§6.4 Multiple auth.users rows match the email”Should be impossible (UNIQUE constraint), but if the script reports multiple matches:
- Do NOT proceed with the export.
- Escalate to Liam for data-corruption investigation.
- The subject’s request is paused until the duplicates are resolved.
- Notify the subject of the delay (within statutory deadline).
§6.5 Bundle fails SHA-256 verification on the recipient end
Section titled “§6.5 Bundle fails SHA-256 verification on the recipient end”The recipient computes a SHA-256 of a file in the bundle and gets a
different value than manifest.json.
- Confirm the recipient is computing on the file inside the ZIP, not the ZIP itself.
- Re-transmit the bundle and compare the manifest checksum the second time.
- If still mismatching, the bundle was corrupted in transit — re-export from scratch and re-transmit.
§7. Audit trail
Section titled “§7. Audit trail”Every invocation of the export script automatically writes a row to
pipeline_runs with pipeline_name = 'dsar_export' (per spec §9
D-G6.7-7). This is the operator-side audit trail — the row records
the timestamp, target environment, subject UUID (if logged), and
status (completed / completed_with_errors / failed).
A failed run additionally fires a Sentry alert (per
recordPipelineRun() contract in lib/pipeline/record-run.ts).
To audit DSAR runs from prod:
# Source prod creds (per local-development.md §3.2)source ~/.config/kb-prod-creds.env
# Query recent DSAR runsbun run scripts/kb-search.ts \ --env=prod \ --query "select * from pipeline_runs where pipeline_name = 'dsar_export' order by created_at desc limit 50"(Or via the Supabase MCP execute_sql tool with a similar query.)
§8. Verification recipe (operator self-test)
Section titled “§8. Verification recipe (operator self-test)”Before relying on the script for a real SAR, the operator should run this against staging to confirm setup:
# 1. Confirm .env.local points at staginggrep SUPABASE_URL .env.local | head -1# expected: SUPABASE_URL=https://turayklvaunphgbgscat.supabase.co
# 2. Confirm test user exists on stagingbun run scripts/seed-e2e-users.ts --check
# 3. Run the exportbun run scripts/export-user-data.ts \ --env=staging \ --email=test.user1@test-kb-aish.co.uk \ --output=/tmp/dsar-test \ --article=15 \ --format=both
# 4. Inspect outputls /tmp/dsar-test/# expected: <uuid>-<timestamp>/ls /tmp/dsar-test/*/# expected: 8 files (subject.json, activity.json, audit-trail.json,# attributed-content.json, manifest.json, README.md,# subject-summary.csv, activity-summary.csv)
# 5. Check manifest integritycat /tmp/dsar-test/*/manifest.json | head -40# expected: invocation metadata + file inventory with SHA-256 hashes
# 6. Clean uprm -rf /tmp/dsar-test/If any step fails, troubleshoot per §6 above before relying on the procedure for a real SAR.
§9. Cross-references
Section titled “§9. Cross-references”- Spec:
docs/audits/kh-production-readiness-phase-1/specs/wp-g6.7-gdpr-data-export-spec.md— PII inventory, Article 15/20 distinction, decisions register. - Script:
scripts/export-user-data.ts. - WP-G3.4 user_profiles spec:
docs/audits/kh-production-readiness-phase-1/specs/wp-g3.4-user-profiles-spec-v1.md— mirror table that the script reads. - Local-dev runbook:
docs/runbooks/local-development.md§3.2 — Pattern B explicit-env-override invocation pattern this script uses. - CLAUDE.md gotchas: Supabase + General + Testing sections.
- ICO right-of-access guidance: https://ico.org.uk/for-organisations/uk-gdpr-guidance-and-resources/individual-rights/right-of-access/ (access date 28/04/2026).
recordPipelineRun()reference:lib/pipeline/record-run.ts.
§10. DSAR register
Section titled “§10. DSAR register”Append a new row to the table below for every Subject Access Request, whether fulfilled, refused, or extended. Rows are immutable; corrections add a new row referencing the original row’s date.
| # | Date received (DD/MM/YYYY) | Subject identifier (UUID OR email) | Identity verification method | Statutory deadline | Article | Bundle invocation timestamp (ISO 8601) | Bundle SHA-256 (manifest.json) | Transmission method | Bundle deletion date | Status | Notes |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 28/04/2026 | (procedure ratification) | (procedure ratification) | n/a | n/a | n/a | n/a | n/a | n/a | n/a | Procedure ratified kh-prod-readiness-S10 W6 — no SARs received yet. |
| 1 | DD/MM/YYYY | <e.g. email-on-file + last-sign-in date> | DD/MM/YYYY | 15 OR 20 | YYYY-MM-DDTHH:MM:SS.SSSZ | <e.g. 1Password> | DD/MM/YYYY | Fulfilled OR Refused OR Extended |
When the table grows beyond 20 rows, archive older rows to
docs/handover/dsar-register-archive-YYYY.md and reset the table to
the current year only.
§11. Changelog
Section titled “§11. Changelog”- 28/04/2026 (kh-prod-readiness-S10 W6) — v1: Initial runbook drafted
alongside
wp-g6.7-gdpr-data-export-spec.md+scripts/export-user-data.ts. DSAR register stub seeded with row 0 (procedure ratification). Article 17 erasure deferred to follow-up WP-G6.X. Local staging dry-run is the v1 verification gate.