Files
0pt/docs/compliance/PII_GUARDRAIL.md
T
admin 17bd1b40a0 feat(0pt): record legitimate-interest stance — B2B contacts, company-linked only
Recorded decision (Simon 2026-08-31): personal data may be stored under
legitimate interest ONLY as business contacts bound to a company/org entity.
Gate enforces it: legitimate_interest requires purpose=business_contact and
entity_linked_only=true; recordStored() requires entity_id (orgnr/rgs/LEI,
hashed) and refuses person-only rows. +1 test; suite 167 pass | 1 skip;
typecheck clean.
2026-09-15 02:40:52 +02:00

77 lines
4.4 KiB
Markdown

# PII Guardrail — GDPR requirement for personal-data sources (0pt)
Status: **enforced, fail-closed.** No 0pt pipeline is crawling personuppgifter
today; the `PiiGateService` ships so that any future source that touches
personal data (Ratsit, Merinfo, Hitta, …) is **technically GDPR-OK before the
first byte is stored**.
## The rule
1. **Classify.** A source is personal-data if it self-flags
(`touches_personal_data`) or is a known directory domain (ratsit.se,
merinfo.se, hitta.se, mrkoll.se, eniro.se, birthday.se, upplysning.se).
2. **No policy → no processing.** `assertCanStore()` throws unless a
`PiiPolicy` with a valid `legal_basis` + `basis_ref` is on record.
3. **Everything is evidenced.** Each decision is emitted to AUD0 at risk L3
(`0pt.pii_policy_created | pii_data_retained | pii_erased |
pii_retention_purged`).
## Recorded legitimate-interest stance (Simon, 2026-08-31)
> "Vi får lagra dem om dem är kopplade till företagen i affärssyfte."
Personal data may be stored under **legitimate interest (art. 6(1)(f))** ONLY
as **business contacts bound to a company/org entity** (`entity_linked_only`).
The gate enforces this mechanically:
- `legal_basis: legitimate_interest` ⇒ `purpose` MUST be `business_contact` and
`entity_linked_only` MUST be `true`. A lazily-written policy is rejected.
- `recordStored()` refuses person-only rows for such policies: an `entity_id`
(orgnr/rgs-nummer/LEI) is REQUIRED; only its hash is ever stored.
- Stand-alone personal records (no backing company) are not permitted under LI.
This is the recorded business-policy decision — a written LIA should be kept
and its id referenced in `basis_ref` (`lia-YYYY-MM-b2b-NN` naming is fine).
## GDPR article mapping
| Requirement | Implementation |
|---|---|
| Legal basis — art. 6 | `legal_basis` ∈ legitimate_interest/consent/contract/legal_obligation + `basis_ref` (LIA doc id, consent record id, contract clause, provision). Cannot be empty. |
| Data minimisation — art. 5(1)(c) | `field_allowlist`; storing any other field throws `PiiMinimisationError`. Empty allowlist = nothing stored. |
| Storage limitation — art. 5(1)(e) | `retention_days`; `enforceRetention()` purges expired rows. `0` = fetch-and-drop, nothing persisted. |
| Integrity/confidentiality — art. 32 | Only sha256 hashes of identity keys stored (`recordStored`); plaintext identity never written; log/trace redaction (`redact()`) for personnummer/e-mail/phone. |
| Right of access — art. 15 | `accessStatement(subjectHash)` — what is held on a subject, across sources. |
| Right to erasure — art. 17 | `erase({ subject_hash, reason })` tombstones every stored value for the subject. |
| Data protection by design — art. 25 | Gate defaults ON in `SourceQueryService`; no bypass knobs. |
## When someone requests a personal-data source (e.g. Ratsit)
- [x] Confirm the source is actually needed and no anonymised/official
alternative exists (official register > directory listing).
- [x] Write the **Legitimate Interest Assessment** (or record consent /
contract / legal obligation) and store its id in `basis_ref`.
LIA minimum: purpose, necessity, individuals' rights impact, mitigations.
For LI: purpose `business_contact` + `entity_linked_only` (see recorded
stance above — an LIA referencing `lia-YYYY-MM-b2b-NN` is good practice).
- [x] **VERIFY current terms + robots.txt + availability** on the live site.
Do not hardcode "catsit.com allows" — re-check each enable.
- [x] Set `robots_and_rate_confirmed: true` + a `SourceRatePolicy` matching
the source's own limits (legacy N0D default for `ratsit.se` was `1/min` —
treat that as a ceiling, not a target).
- [x] Define the minimum `field_allowlist` (e.g. only `street_address`, not
`phone`).
- [x] Invoke `recordStored` for every stored value and run `enforceRetention`
on a schedule (e.g. daily cron).
- [x] Record a DPIA note if processing is "large scale" (art. 35) — bulk
directory scraping of living persons is large-scale by default.
## Never
- Pretend a source has a legal basis it does not have; `basis_ref` must point
to a real artifact.
- Store identity keys in plaintext (`pseudonymize_keys` defaults to true).
- Use stealth/bot-bypass/rate-evasion — excluded by CLAUDE.md §1 regardless of
GDPR.
- Claim compliance on a source we have not re-verified this season (`VERIFY`
markers are intentional).