GDPR Compliance Architecture
Developer reference for the schema, middleware, and code paths that implement RestartiX's GDPR compliance posture. Code-flavoured and assumes you're shipping a feature that touches consent, privacy notices, cross-tenant patient data, or DSAR fulfilment.
For the why of the controller/processor split, read decisions.md → Why clinic is controller, platform is processor — that's the substantive ADR. For the patient-facing narrative in plain language, read security/consent-and-privacy.md. The schema rationale lives in data-model.md → Area 15. This file is the developer-eye view of the same machinery.
SQL is illustrative
SQL fragments in this document are examples meant to convey shape and intent — they're not authoritative reproductions of the production schema. The real migrations live in services/api/migrations/core/.
Table of Contents
- Controller / Processor Split
- Lawful Basis
- Consent Ledger Schema
- Privacy Notice Templates
- Re-Consent Middleware + RequireConsent
- Withdrawal Cascade
- API Endpoints
- Cross-Tenant Anonymisation Rule
- Platform Break-Glass Access
- DSAR Routing
- Anonymisation Algorithm (Erasure)
- Data Retention Policy
- Breach Notification
- Sub-Processors
- Children's Data
- Privacy by Design Checklist
Controller / Processor Split
The clinic is the GDPR data controller for patient data; RestartiX is a data processor under an Art. 28 DPA annexed to the MSA. The platform is controller only for the thin slice of account-level data (login credentials, security telemetry on the account itself, fraud prevention).
| Actor pair | Role | Legal artefact |
|---|---|---|
| Clinic ↔ patient | Clinic = controller for clinical data, marketing prefs, medical consents | Clinic's privacy notice + ToS + collected consents |
| Patient ↔ platform | Platform = controller only for account-level data | platform_terms + platform_privacy_notice consents |
| Clinic ↔ platform | Platform = processor; no consent ledger entries | MSA + DPA + sub-processor list |
Cross-tenant features (platform analytics, benchmarks, AI training corpora) operate on anonymised data only — see Cross-Tenant Anonymisation Rule. Joint controllership is avoided by construction.
When designing a feature that touches more than one org's patient data, ask: "is this anonymised, break-glass-gated, or wrong?" There is no fourth option.
Lawful Basis
Every consent purpose carries a legal_basis that determines whether the patient can withdraw it from the toggle path or only by terminating the underlying relationship.
legal_basis | GDPR Art. | Withdrawable? | Purposes (foundation seed) |
|---|---|---|---|
contract | 6(1)(b) | No — revoke = end the contract | platform_terms, org_terms |
legitimate_interest | 6(1)(f) | No — informational acceptance | platform_privacy_notice |
legal_obligation | 6(1)(c) | No — Art. 13/14 disclosure | org_privacy_notice (combined with Art. 9(2)(h) for medical processing) |
consent | 6(1)(a) / 9(2)(a) | Yes | marketing_email, marketing_sms, analytics, ai_processing, profile_sharing, plus all Tier B medical purposes (telemedicine, video recording, biometric capture, treatment-specific) |
vital_interest | 6(1)(d) | No — emergency carve-out | (reserved; not currently seeded) |
Health data (forms, reports, prescriptions) is special category under Art. 9. The lawful basis is Art. 9(2)(h) (provision of healthcare on the basis of a contract with a health professional) — not patient consent. The clinic's published org_privacy_notice discloses this; per-field explicit consent is not required for core medical processing.
The withdrawable boolean on consent_purposes is derived in code (TRUE iff legal_basis = 'consent') and stored as a column for query speed.
Consent Ledger Schema
Single append-on-grant table spanning platform-scope and org-scope purposes. Detail in data-model.md → Area 15; the canonical ledger pattern is P17. Foundation status: ships in 1B.9.
-- Catalog: every purpose the system tracks
CREATE TABLE consent_purposes (
code TEXT PRIMARY KEY,
scope TEXT NOT NULL CHECK (scope IN ('platform', 'org')),
name TEXT NOT NULL,
description TEXT,
legal_basis TEXT NOT NULL CHECK (legal_basis IN (
'contract', 'legitimate_interest', 'consent',
'legal_obligation', 'vital_interest'
)),
withdrawable BOOLEAN NOT NULL, -- derived: TRUE iff legal_basis = 'consent'
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Versioned policy text. organization_id NULL = platform-default;
-- non-NULL = clinic override (only valid for org-scope purposes).
CREATE TABLE consent_purpose_versions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
purpose_code TEXT NOT NULL REFERENCES consent_purposes(code),
organization_id UUID REFERENCES organizations(id),
version INT NOT NULL,
body_translations JSONB NOT NULL, -- {"en": "...", "ro": "..."}
published_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
published_by_principal_id UUID NOT NULL REFERENCES principals(id),
UNIQUE (purpose_code, organization_id, version)
);
-- The ledger. One row per grant; UPDATE on withdraw is the only mutation.
CREATE TABLE consents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID REFERENCES organizations(id), -- NULL = platform-scope
patient_profile_id UUID NOT NULL REFERENCES patient_profiles(id),
purpose_code TEXT NOT NULL REFERENCES consent_purposes(code),
purpose_version INT NOT NULL,
source TEXT NOT NULL CHECK (source IN (
'signup_checkbox', 'self_toggle', 'form', 'staff_action', 'api'
)),
source_form_id UUID REFERENCES forms(id), -- NULL except when source='form'
granted_at TIMESTAMPTZ NOT NULL,
granted_by_principal_id UUID NOT NULL REFERENCES principals(id),
granted_via_ip INET,
withdrawn_at TIMESTAMPTZ,
withdrawn_by_principal_id UUID REFERENCES principals(id),
withdrawal_reason TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX consents_subject_history
ON consents (patient_profile_id, organization_id, purpose_code, granted_at DESC);
CREATE INDEX consents_active_per_org
ON consents (organization_id, purpose_code) WHERE withdrawn_at IS NULL;
ALTER TABLE consents ENABLE ROW LEVEL SECURITY;
-- Patient sees their own (across all orgs, including platform-scope rows).
-- Org staff with consents.view_org sees consents for patients registered at their org.
-- Platform-scope rows visible to break-glass-elevated platform staff via 1B.11.
CREATE POLICY consents_select ON consents FOR SELECT USING (
patient_profile_id = ANY(current_human_patient_profile_ids())
OR (
organization_id = current_app_org_id()
AND current_app_has_permission('consents', 'view_org')
)
OR (
organization_id IS NULL
AND current_app_has_break_glass('platform_consents')
)
);
-- INSERT on grant; UPDATE only to set withdrawn_at + withdrawn_by_principal_id.
-- DELETE never permitted.
CREATE POLICY consents_insert ON consents FOR INSERT WITH CHECK (
granted_by_principal_id = current_app_principal_id()
OR current_app_has_permission('consents', 'manage')
);Source discriminator
Every ledger row records how the grant or withdrawal happened. The discriminator is load-bearing for audit reviews (regulators ask "how did the patient consent?") and for routing follow-up effects.
source | Captured by | Notes |
|---|---|---|
signup_checkbox | Portal sign-up flow (1B.8) | Atomic with patient_profiles + patients row creation |
self_toggle | Patient settings page (1D.3) | Subject and grantor are the same principal |
form | Signed F3 form (F3.5) | source_form_id FKs the immutable form row; provenance inherited |
staff_action | CS rep flips on patient's behalf | Gated by consents.manage; grantor ≠ subject |
api | Programmatic grant via integration | Reserved; same audit shape as staff_action |
Append-on-grant, UPDATE-on-withdraw
Two distinct operations on the table:
- Grant — INSERT a new row with
granted_atset,withdrawn_atNULL. - Withdraw — UPDATE the existing active row to set
withdrawn_at+withdrawn_by_principal_id+ optionalwithdrawal_reason. No new row. - Re-grant after withdrawal — INSERT a new row. The old withdrawn row stays; the table is the full history.
Reading current state is a query against the latest active row per (patient_profile_id, organization_id, purpose_code). The consents_active_per_org partial index is sized for the active set, not the history, which keeps it cheap at scale.
Privacy Notice Templates
The org_privacy_notice text the patient sees and accepts is assembled by the clinic, not provided as a fixed platform notice. This preserves the controllership boundary: the clinic owns the legal artefact; the platform provides the scaffolding.
Schema in data-model.md → Area 15a; foundation detail in 1B.10.
The shape:
privacy_notice_templates (platform catalog, versioned)
body_with_placeholders TEXT -- {{clinic_name}}, {{dpo_email}}, ...
toggleable_sections JSONB -- [{key, default, body}, ...]
-- e.g. video_recording, biometric_capture,
-- cross_border_transfer
organization_privacy_notices (per-org, one row)
source_template_version INT -- snapshot of template at last publish
placeholder_values JSONB
included_sections JSONB
assembled_body TEXT -- final markdown the patient accepts
published_version INT -- → consent_purpose_versions rowPublish flow:
- Clinic admin (gated by
organizations.manage_privacy_notice) opens the editor (Clinic Admin UI, 1D.2). - UI renders the latest
privacy_notice_templatesbody, prompts for placeholder values, surfaces toggleable sections for inclusion review. - On publish, the system writes a new
consent_purpose_versionsrow withpurpose_code='org_privacy_notice',organization_id=<this org>, bumpedversion, andbody_translationscontaining the assembled markdown.organization_privacy_notices.published_versionupdates to point at it. - Re-consent middleware (next section) detects the bump and prompts existing patients on next request.
Template version bumps from the platform side (in Console, 1D.1) trigger a "review template update" prompt for every clinic whose source_template_version is older. Until the clinic re-publishes, their previously-assembled body keeps serving — no break in legality.
A clinic that has not published their org_privacy_notice cannot onboard patients (the consent grant fails, which fails sign-up).
Re-Consent Middleware + RequireConsent
Two distinct middlewares, both consuming the consent ledger.
current_required_consent_versions(principal_id, organization_id)
SQL helper returning the set of (purpose_code, version) pairs the principal has not yet accepted at the latest version. Always includes platform-scope purposes; includes the org's purposes only when organization_id is non-NULL.
SELECT cp.code, cpv.version
FROM consent_purposes cp
JOIN consent_purpose_versions cpv
ON cpv.purpose_code = cp.code
AND (cpv.organization_id = $org_id OR cpv.organization_id IS NULL)
WHERE cp.scope = 'platform'
OR (cp.scope = 'org' AND $org_id IS NOT NULL)
AND cpv.version = (
SELECT MAX(version) FROM consent_purpose_versions
WHERE purpose_code = cp.code
AND (organization_id = $org_id OR organization_id IS NULL)
)
AND NOT EXISTS (
SELECT 1 FROM consents c
WHERE c.patient_profile_id IN (
SELECT patient_profile_id FROM patient_profiles_for_principal($principal_id)
)
AND c.purpose_code = cp.code
AND c.purpose_version >= cpv.version
AND c.withdrawn_at IS NULL
);Re-consent middleware
Runs before any non-public route. If current_required_consent_versions returns a non-empty set, the response is 412 consent_required with the list of missing purposes:
func RequireCurrentConsents(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
principal := PrincipalFromContext(ctx)
orgID := OrgIDFromContext(ctx) // may be nil for platform-only routes
missing, err := consentRepo.RequiredVersions(ctx, principal.ID, orgID)
if err != nil {
httputil.RenderError(w, r, err)
return
}
if len(missing) > 0 {
httputil.RenderError(w, r, NewConsentRequiredError(missing))
return
}
next.ServeHTTP(w, r)
})
}The portal's API client treats 412 consent_required as a global interrupt: it raises a blocking re-consent modal listing the missing purposes (with the latest version's body), and posts back to POST /v1/me/consents to accept.
RequireConsent(purpose) — feature-level gate
Analogous to RequireTierEntitlement / RequireOrgEntitlement (P-tier middleware composition). Used by features that require a specific consent before they can run, on top of the universal re-consent gate:
// services/api/internal/core/server/routes.go
r.Route("/v1/appointments/{id}/start-video", func(r chi.Router) {
r.Use(middleware.RequirePermission("appointments", "join"))
r.Use(middleware.RequireConsent("telemedicine")) // F3.5
r.Use(middleware.RequireConsent("video_recording")) // if recording is on
r.Post("/", appointmentHandler.StartVideoCall)
})RequireConsent returns 403 consent_required with a missing_purpose body field (distinct from the 412 used by the universal middleware). Real consumers light up in F3 (telemedicine), F5 (appointments), F9 (telerehab biometric capture).
Withdrawal Cascade
| Purpose | legal_basis | Withdrawal path | Cascade |
|---|---|---|---|
marketing_email, marketing_sms, analytics, ai_processing, profile_sharing | consent | POST /v1/me/consents/{id}/withdraw (self), or consents.manage staff endpoint | Per-org, per-purpose. profile_sharing = false triggers patients.profile_shared = FALSE at that org via DB trigger (1B.6 hook). |
org_terms | contract | "Leave clinic" flow in portal (1C.3) | Sets patients.deleted_at at that clinic. Cascades withdrawal of every org-scope consent at that org. Other clinics unaffected. |
org_privacy_notice | legal_obligation | Same as org_terms | Same cascade |
platform_terms | contract | Not a self-toggle path. UI reads "to revoke these, delete your account" and triggers GDPR erasure (F11.1). | Account anonymisation across all orgs the patient is registered at; cascades withdrawal of every consent row. |
platform_privacy_notice | legitimate_interest | Informational acceptance — same path as platform_terms | Same cascade |
Tier B medical (telemedicine, video_recording, biometric_capture, treatment_specific_*) | consent | Per-purpose; some withdraw from the portal, some require clinic-side action | Per-feature effect: stops new recordings, disables camera-based measurements, etc. Withdrawal cascade across Tier B purposes is an open decision — see F3.5. |
The cascading pieces are wired as DB triggers + domain events, not as ad-hoc handler logic. Look for consents_after_update and consents_after_insert triggers in the 1B.9 migration.
API Endpoints
GET /v1/me/consents
├─ Description: Patient's full consent history grouped by (organization_id, purpose_code).
│ Includes platform-scope rows (organization_id NULL) and per-org rows for every
│ org the patient is a patient at.
├─ Access: Any authenticated patient (own consents only via RLS)
├─ Response: 200
│ {
│ "data": [
│ {
│ "organization_id": null,
│ "purpose_code": "platform_terms",
│ "scope": "platform",
│ "legal_basis": "contract",
│ "withdrawable": false,
│ "current": {
│ "purpose_version": 3,
│ "granted_at": "2026-01-15T09:00:00Z",
│ "source": "signup_checkbox"
│ },
│ "history": [...]
│ },
│ {
│ "organization_id": "01HX...",
│ "purpose_code": "marketing_email",
│ "scope": "org",
│ "legal_basis": "consent",
│ "withdrawable": true,
│ "current": null,
│ "history": [
│ { "purpose_version": 1, "granted_at": "...", "withdrawn_at": "...", "source": "signup_checkbox" }
│ ]
│ }
│ ]
│ }
POST /v1/me/consents
├─ Description: Grant one or more consents. Used by the sign-up consent block,
│ the re-consent modal, and self-toggle "on" switches in patient settings.
├─ Request:
│ {
│ "grants": [
│ { "organization_id": "01HX...", "purpose_code": "marketing_email", "purpose_version": 1, "source": "self_toggle" }
│ ]
│ }
├─ Response: 201 with the inserted ledger rows
└─ Notes: The principal grants for themselves. Platform-scope grants pass
organization_id = null. Atomic batch — partial failure rolls back the whole set.
POST /v1/me/consents/{id}/withdraw
├─ Description: Patient self-withdraws a single consent.
├─ Request: { "withdrawal_reason": "..." } // optional
├─ Response: 200 with the updated row
└─ Errors:
- 409 not_withdrawable: purpose's legal_basis != 'consent'
(UI should not have offered the toggle; double-guard here)
- 404 not_found: no active consent row matches this id for the caller
POST /v1/organizations/{org_id}/patients/{patient_id}/consents/grant
POST /v1/organizations/{org_id}/patients/{patient_id}/consents/{id}/withdraw
├─ Description: Staff-action grant or withdraw on a patient's behalf
│ (CS rep on the phone, or admin marking opt-out).
├─ Access: consents.manage permission
├─ Records: granted_by_principal_id / withdrawn_by_principal_id = staff principal,
│ source = 'staff_action'.
└─ Audit-logged with full diff.
GET /v1/organizations/{org_id}/patients/{patient_id}/consents
├─ Description: Per-patient consent trail at this clinic (Clinic Admin UI 1C.2).
├─ Access: consents.view_org
└─ Same response shape as /v1/me/consents but filtered to this clinic.DSAR-specific endpoints (export, erasure, rectification) are routed through the clinic as controller — see DSAR Routing.
Cross-Tenant Anonymisation Rule
Any feature that needs to compute over multiple clinics' patient data — platform analytics, benchmarks, AI training corpora, cross-clinic search — must operate on anonymised data only. Once data is irreversibly stripped of identifiers, it stops being personal data, and the controllership question dissolves.
Enforcement happens in two places:
- Data classification at egress. Every column carries a class + list of allowed egress targets in data-classification.md. Egress paths (telemetry, AI inference, marketing, analytics) call
classification.AllowedFor(table, target)/classification.Filter(record, target)— never hand-build the field list. See P39. - No identifiable cross-tenant API surfaces. Console pages that touch identifiable cross-tenant data are classified as
break_glass:{scope}and gated byRequireBreakGlass(1B.11). Aggregate / processor-scope surfaces (org list, patient counters, audit metadata without diffs) are always-on.
If a feature genuinely cannot work without identifiable cross-tenant data, that requires its own ADR with the joint-controllership trade-off explicit. Default answer is no.
Platform Break-Glass Access
The processor boundary is the default; break-glass is the documented exception path for any platform-staff cross-tenant access. Full design in Foundation 1B.11. What you need to know as a developer:
- Per-org scope. A session covers one org and one resource scope (
patient_list,patient_detail,audit_full,cross_org_lookup,org_management). Cross-org elevation is a separate, higher-friction path. - Time-bound. Default 1 hour, max 4. No "open all day" mode.
- Justification required.
reason_category(support_ticket|security_incident|dsar_routing|fraud_investigation|platform_engineering) plus free-text + optional ticket reference. - Always-on clinic notification. When a session opens, the clinic admin gets an email and an in-app banner naming who, when, scope, reason. Non-negotiable — this is what makes break-glass defensible.
- Every read is logged.
audit_logrows inside the session carryaction_context = 'break_glass'+break_glass_id.
// In a Console route handler that needs to read a patient detail
r.Route("/v1/console/organizations/{org_id}/patients/{patient_id}", func(r chi.Router) {
r.Use(middleware.RequireSuperadmin) // or future support_engineer role
r.Use(middleware.RequireBreakGlass("patient_detail"))
r.Get("/", consoleHandler.GetPatientDetail)
})RequireBreakGlass(scope) returns:
- 403
break_glass_requiredif no active session covers(org, scope). Body includes the elevation form metadata so the Console can render the elevation modal. - 410
break_glass_expiredif a session existed but expired.
Elevation endpoint: POST /v1/break-glass/sessions with {organization_id, scope, reason_category, reason_text, reason_ref?, expires_in_minutes}. Audit-logged and rate-limited (1A.13).
DSAR Routing
Patients exercise GDPR rights against the controller — the clinic. The platform's role is to assist, not to act as primary controller for patient data.
| Right | Patient path | Platform's role |
|---|---|---|
| Access (Art. 15) | Self-service export from portal: profile, appointments, forms, consents, treatment plans | Generates the export from data the platform holds; per-org slices are routed to the corresponding clinic for fulfilment |
| Rectification (Art. 16) | Patient updates own profile in portal; clinical corrections requested through clinic staff | Audit log captures all changes; staff endpoint records corrections explicitly |
| Erasure (Art. 17) | Patient submits "delete my account" in portal | Triggers GDPR erasure flow (F11.1) — anonymisation across all orgs the patient is registered at |
| Portability (Art. 20) | Self-service export same as access | Output is structured JSON / CSV — machine-readable |
| Restrict processing (Art. 18) | Self-toggle withdrawable consents (marketing_*, analytics, ai_processing, profile_sharing) | Effect is immediate — recorded in ledger; downstream effects via cascade triggers |
When a request comes to the platform by mistake
The platform never acts as primary controller for patient DSARs. If a patient sends a DSAR to [email protected]:
- Auto-responder acknowledges receipt and lists the clinics the patient is registered at, pulled from
/v1/me.patient_org_ids. Patient is asked to confirm which clinic the request is for. - Self-service — the portal "Your clinics" page surfaces each clinic's data-controller contact (DPO email, registered address) with a "Make a data subject request" button that pre-fills the contact details + patient identity.
- Forwarding — if the patient confirms the clinic, platform support forwards the request to the clinic's DPO with the patient cc'd. No platform-side lookup of patient data.
- Orphaned cases — ex-patient with no active account, where the platform must look up which clinics they were registered at. Goes through break-glass with
reason_category = 'dsar_routing'.
The Console's "Cross-org patient lookup" surface is exactly the orphaned-DSAR path and is gated by break_glass:cross_org_lookup.
Platform-controller DSARs (account-level data only)
For account-level data the platform is controller of (login credentials, security telemetry on the account itself), the export and erasure flows are platform-fulfilled. This is a thin slice — most DSAR content lives in clinic data, not platform data.
Anonymisation Algorithm (Erasure)
GDPR Art. 17(3)(c) permits retention of medical records when required by law (Romanian Law 95/2006 mandates 6-year retention). Erasure on this platform is implemented as anonymisation: PII is irreversibly stripped, the record structure is preserved.
Implementation lives in F11.1 GDPR Implementation. What's anonymised, by entity:
| Entity | Strategy |
|---|---|
principals (the actor row) | Cannot be deleted (FKs everywhere). humans.email set to anon-<hash>@redacted.local; auth-account credential link severed. |
humans | name → Anonymized User <hash>; phone, DOB, etc. cleared. |
patient_profiles | name → Anonymized Patient <hash>; phone, emergency_contact_phone, emergency_contact_name, DOB, occupation, residence cleared. |
patients (per-org link) | consumer_id cleared; deleted_at set. |
forms.values | Free-text fields → [REDACTED]. Select / radio / checkbox values preserved (aggregate-safe). Files removed. forms.files JSONB cleared. |
custom_field_values | Deleted (free-form text, not aggregate-safe). |
audit_log | PII redacted in changes JSONB; ip_address zeroed, user_agent redacted. Structure preserved — Romanian Law 95/2006 retention requires this. |
consents | Preserved — legal proof of prior consent (Art. 7). |
| S3 files (form uploads, signature images) | Deleted. |
| Segment memberships | Deleted (no purpose without identified patient). |
| Auth account (Clerk or successor) | Deleted via provider API. |
// services/api/internal/core/domain/gdpr/service.go
func (s *Service) Erase(ctx context.Context, principalID uuid.UUID) error {
return s.tx.Run(ctx, func(tx *sql.Tx) error {
// 1. Lock the principal row + assert not already anonymized
// 2. Anonymize humans + patient_profiles
// 3. For each (org, patient) the principal is a patient at:
// - Anonymize forms.values + forms.files
// - Delete custom_field_values
// - Delete segment_members
// - Set patients.deleted_at
// - Cascade-withdraw all org-scope consents
// 4. Anonymize audit_log entries (PII fields only; structure preserved)
// 5. Collect S3 keys, delete asynchronously via outbox
// 6. Mark principals.anonymized_at = NOW()
// 7. Delete auth account in provider via outbox
// 8. Append audit_log entry: action = 'gdpr_erasure'
return nil
})
}The flow is transactional in the database (steps 1–6, 8) and uses the outbox pattern (1A.9) for the irreversible side effects (S3 deletes, auth-provider account deletion) so partial failure doesn't leave the system inconsistent.
What is preserved after anonymisation
| Data | Preserved | Reason |
|---|---|---|
| Appointment records (with anonymised refs) | Yes | Medical record retention (Romanian Law 95/2006; HIPAA if applicable) |
| Form structure (field definitions) | Yes | Template, not patient data |
| Form select / checkbox values | Yes | Aggregate-safe |
| Report / prescription metadata (title, dates) | Yes | Medical record retention |
| Audit log structure | Yes (PII redacted) | 6-year retention requirement |
| Consent records | Yes | Legal proof of prior consent (Art. 7) |
| Custom field values | No | Free-text PII |
| Segment membership | No | No purpose without identified patient |
| S3 files | No | May contain PII |
Data Retention Policy
| Data | Retention | Legal basis | Cleanup |
|---|---|---|---|
| Audit log | 6 years (hot 12 months in PostgreSQL → cold 1–6yr in S3 → delete) | Romanian Law 95/2006 + GDPR Art. 5(1)(e) | Daily job archives, deletes after 6yr |
| Appointments / forms / reports / prescriptions | 6 years from creation / signed_at | Romanian Law 95/2006 medical record retention | Anonymise after 6yr |
| Telemetry / metrics | 90 days | Operational | Managed by Telemetry service (separate retention) |
| Consents | 6 years from last interaction | Art. 7 — proof of consent | Archive then delete |
| Active accounts | Duration of service | Contract | N/A while active |
| Anonymised accounts | 6 years post-anonymisation | Linked medical records | Full purge after 6yr |
| Custom field values | Duration of patient relationship | Contract | Deleted on erasure |
| S3 files | Same as parent entity | Follows parent | Deleted with parent |
| Break-glass session records | Permanent | Regulator-defensible audit trail | Never deleted |
Per-org overrides live in organizations.retention_config JSONB. Orgs can increase retention but never decrease below the regulatory floor.
// internal/jobs/retention.go — daily job
func (j *RetentionJob) Run(ctx context.Context) error {
for _, org := range orgs {
cfg := org.RetentionConfig
// 1. Telemetry retention is owned by the Telemetry service; skip here.
// 2. Archive audit_log rows older than 12 months to S3 (JSONL per month).
// 3. Delete S3 archive rows older than cfg.AuditLogDays.
// 4. Anonymise appointments/forms/reports older than cfg.MedicalRecordsDays.
}
return nil
}Audit log archival is two-tier: hot (PostgreSQL, 0–12 months, queryable via API) → cold (S3 JSONL per month, 1–6 years, retrievable on request) → deletion at 6 years.
Breach Notification
GDPR Art. 33 requires notification to the supervisory authority within 72 hours of becoming aware of a breach. If high risk to individuals, affected users must also be notified (Art. 34).
Detection sources:
| Source | What it catches |
|---|---|
| Audit log anomalies | Bulk data access, off-hours access, RLS denials |
| Failed auth spikes | Brute force (auth provider reports) |
| RLS policy denials | Logged as warnings; trigger alerting at threshold |
| AWS GuardDuty / CloudTrail | Infrastructure-level anomalies |
| External reports | Bug bounty, security researcher, vendor notifications |
Severity classification:
| Severity | Criteria | Response time | Notification |
|---|---|---|---|
| Critical | Confirmed PHI exposure, data exfiltration | Immediate | Authority within 72h + affected users |
| High | Unauthorised access to medical records, no confirmed exfil | Within 4h | Authority within 72h, users if high risk |
| Medium | Unauthorised access to non-PHI (emails, names) | Within 24h | Authority within 72h |
| Low | Failed attack, no data accessed | Within 48h | Internal record only |
72-hour response procedure in security/audit-trail.md and security/compliance-checklist.md. The platform is not the supervisory-authority-facing party for clinic patient data — that is the clinic, as controller. The platform notifies the clinic per the DPA; the clinic notifies ANSPDCP.
The breach_records table tracks platform-side incidents:
CREATE TABLE breach_records (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID REFERENCES organizations(id), -- NULL = platform-wide
severity TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'detected',
detected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
contained_at TIMESTAMPTZ,
authority_notified_at TIMESTAMPTZ,
users_notified_at TIMESTAMPTZ,
resolved_at TIMESTAMPTZ,
description TEXT NOT NULL,
affected_users INT,
affected_data TEXT[],
cause TEXT,
remediation TEXT,
reported_by_principal_id UUID REFERENCES principals(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Platform-level table: no RLS. Access via superadmin API only.Sub-Processors
Sub-processors are listed in the DPA each clinic signs and disclosed in the clinic's published privacy notice (the org_privacy_notice template's sub-processor section is mandatory).
| Sub-processor | Data processed | Agreement | Notes |
|---|---|---|---|
| AWS (RDS, S3) | All database content, file uploads | DPA + BAA available | BAA covers RDS, S3, all HIPAA-eligible services. Enabled per account. |
| Auth provider (Clerk or successor) | Email, name, auth tokens, MFA | DPA + BAA available | BAA for HIPAA-covered entities. DPA in ToS for GDPR. |
| Daily.co (telemedicine video) | Room names, participant IPs during calls. Recordings (if enabled) are PHI. | BAA available | BAA for telehealth. |
| Webhook receivers (org-configured) | None — payloads contain no PII | None required | See below. |
Webhook payload PII policy
Webhook payloads delivered to org-configured external URLs (Make.com, Zapier, n8n, custom) contain no PII by design — only entity IDs, event types, timestamps. Receivers call back to the Core API's authenticated API for details if needed. This eliminates the need for BAAs/DPAs with automation platforms. Full payload format in features/webhooks.
Children's Data
Romanian law sets the age of digital consent at 16 years (GDPR Art. 8 allows member states 13–16).
- The platform is for adults. Terms of service state: "You must be at least 16 years old to use this service."
- No age verification at registration (auth provider handles identity).
- If a minor's data is discovered, admin triggers anonymisation via the GDPR erasure flow.
- Future: if pediatric telemedicine is added, implement guardian consent flow with
parent_principal_idon patient records.
Privacy by Design Checklist
Built into the architecture from day one:
| Principle | Implementation |
|---|---|
| Data minimisation | Explicit ?fields= selection on list endpoints; data-classification registry blocks egress by default (P39) |
| Purpose limitation | Consent ledger per purpose (P17) |
| Storage limitation | Retention policy with daily cleanup job |
| Integrity & confidentiality | RLS on every tenant table; AES-256-GCM for high-sensitivity columns; TLS for all connections |
| Accountability | Audit log on every mutation; consent history; break-glass session records |
| Transparency | Patient self-service "Your clinics" page; consent trail UI; per-org break-glass banner |
| Access control | Per-org permission codes (no global role checks); see rbac-permissions.md |
| Pseudonymisation | Encrypted phone numbers; anonymisation algorithm for erasure |
| Right to be forgotten | Anonymisation + erasure with cascade logic (F11.1) |
| Controller / processor split | Cross-tenant features anonymised by default; identifiable cross-tenant access via break-glass only |