Skip to content

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

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 pairRoleLegal artefact
Clinic ↔ patientClinic = controller for clinical data, marketing prefs, medical consentsClinic's privacy notice + ToS + collected consents
Patient ↔ platformPlatform = controller only for account-level dataplatform_terms + platform_privacy_notice consents
Clinic ↔ platformPlatform = processor; no consent ledger entriesMSA + 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_basisGDPR Art.Withdrawable?Purposes (foundation seed)
contract6(1)(b)No — revoke = end the contractplatform_terms, org_terms
legitimate_interest6(1)(f)No — informational acceptanceplatform_privacy_notice
legal_obligation6(1)(c)No — Art. 13/14 disclosureorg_privacy_notice (combined with Art. 9(2)(h) for medical processing)
consent6(1)(a) / 9(2)(a)Yesmarketing_email, marketing_sms, analytics, ai_processing, profile_sharing, plus all Tier B medical purposes (telemedicine, video recording, biometric capture, treatment-specific)
vital_interest6(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.


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.

sql
-- 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.

sourceCaptured byNotes
signup_checkboxPortal sign-up flow (1B.8)Atomic with patient_profiles + patients row creation
self_togglePatient settings page (1D.3)Subject and grantor are the same principal
formSigned F3 form (F3.5)source_form_id FKs the immutable form row; provenance inherited
staff_actionCS rep flips on patient's behalfGated by consents.manage; grantor ≠ subject
apiProgrammatic grant via integrationReserved; same audit shape as staff_action

Append-on-grant, UPDATE-on-withdraw

Two distinct operations on the table:

  1. Grant — INSERT a new row with granted_at set, withdrawn_at NULL.
  2. Withdraw — UPDATE the existing active row to set withdrawn_at + withdrawn_by_principal_id + optional withdrawal_reason. No new row.
  3. 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 row

Publish flow:

  1. Clinic admin (gated by organizations.manage_privacy_notice) opens the editor (Clinic Admin UI, 1D.2).
  2. UI renders the latest privacy_notice_templates body, prompts for placeholder values, surfaces toggleable sections for inclusion review.
  3. On publish, the system writes a new consent_purpose_versions row with purpose_code='org_privacy_notice', organization_id=<this org>, bumped version, and body_translations containing the assembled markdown. organization_privacy_notices.published_version updates to point at it.
  4. 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).


Two distinct middlewares, both consuming the consent ledger.

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.

sql
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
   );

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:

go
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:

go
// 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

Purposelegal_basisWithdrawal pathCascade
marketing_email, marketing_sms, analytics, ai_processing, profile_sharingconsentPOST /v1/me/consents/{id}/withdraw (self), or consents.manage staff endpointPer-org, per-purpose. profile_sharing = false triggers patients.profile_shared = FALSE at that org via DB trigger (1B.6 hook).
org_termscontract"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_noticelegal_obligationSame as org_termsSame cascade
platform_termscontractNot 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_noticelegitimate_interestInformational acceptance — same path as platform_termsSame cascade
Tier B medical (telemedicine, video_recording, biometric_capture, treatment_specific_*)consentPer-purpose; some withdraw from the portal, some require clinic-side actionPer-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:

  1. 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.
  2. No identifiable cross-tenant API surfaces. Console pages that touch identifiable cross-tenant data are classified as break_glass:{scope} and gated by RequireBreakGlass (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_log rows inside the session carry action_context = 'break_glass' + break_glass_id.
go
// 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_required if no active session covers (org, scope). Body includes the elevation form metadata so the Console can render the elevation modal.
  • 410 break_glass_expired if 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.

RightPatient pathPlatform's role
Access (Art. 15)Self-service export from portal: profile, appointments, forms, consents, treatment plansGenerates 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 staffAudit log captures all changes; staff endpoint records corrections explicitly
Erasure (Art. 17)Patient submits "delete my account" in portalTriggers GDPR erasure flow (F11.1) — anonymisation across all orgs the patient is registered at
Portability (Art. 20)Self-service export same as accessOutput 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]:

  1. 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.
  2. 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.
  3. 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.
  4. 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:

EntityStrategy
principals (the actor row)Cannot be deleted (FKs everywhere). humans.email set to anon-<hash>@redacted.local; auth-account credential link severed.
humansnameAnonymized User <hash>; phone, DOB, etc. cleared.
patient_profilesnameAnonymized Patient <hash>; phone, emergency_contact_phone, emergency_contact_name, DOB, occupation, residence cleared.
patients (per-org link)consumer_id cleared; deleted_at set.
forms.valuesFree-text fields → [REDACTED]. Select / radio / checkbox values preserved (aggregate-safe). Files removed. forms.files JSONB cleared.
custom_field_valuesDeleted (free-form text, not aggregate-safe).
audit_logPII redacted in changes JSONB; ip_address zeroed, user_agent redacted. Structure preserved — Romanian Law 95/2006 retention requires this.
consentsPreserved — legal proof of prior consent (Art. 7).
S3 files (form uploads, signature images)Deleted.
Segment membershipsDeleted (no purpose without identified patient).
Auth account (Clerk or successor)Deleted via provider API.
go
// 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

DataPreservedReason
Appointment records (with anonymised refs)YesMedical record retention (Romanian Law 95/2006; HIPAA if applicable)
Form structure (field definitions)YesTemplate, not patient data
Form select / checkbox valuesYesAggregate-safe
Report / prescription metadata (title, dates)YesMedical record retention
Audit log structureYes (PII redacted)6-year retention requirement
Consent recordsYesLegal proof of prior consent (Art. 7)
Custom field valuesNoFree-text PII
Segment membershipNoNo purpose without identified patient
S3 filesNoMay contain PII

Data Retention Policy

DataRetentionLegal basisCleanup
Audit log6 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 / prescriptions6 years from creation / signed_atRomanian Law 95/2006 medical record retentionAnonymise after 6yr
Telemetry / metrics90 daysOperationalManaged by Telemetry service (separate retention)
Consents6 years from last interactionArt. 7 — proof of consentArchive then delete
Active accountsDuration of serviceContractN/A while active
Anonymised accounts6 years post-anonymisationLinked medical recordsFull purge after 6yr
Custom field valuesDuration of patient relationshipContractDeleted on erasure
S3 filesSame as parent entityFollows parentDeleted with parent
Break-glass session recordsPermanentRegulator-defensible audit trailNever deleted

Per-org overrides live in organizations.retention_config JSONB. Orgs can increase retention but never decrease below the regulatory floor.

go
// 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:

SourceWhat it catches
Audit log anomaliesBulk data access, off-hours access, RLS denials
Failed auth spikesBrute force (auth provider reports)
RLS policy denialsLogged as warnings; trigger alerting at threshold
AWS GuardDuty / CloudTrailInfrastructure-level anomalies
External reportsBug bounty, security researcher, vendor notifications

Severity classification:

SeverityCriteriaResponse timeNotification
CriticalConfirmed PHI exposure, data exfiltrationImmediateAuthority within 72h + affected users
HighUnauthorised access to medical records, no confirmed exfilWithin 4hAuthority within 72h, users if high risk
MediumUnauthorised access to non-PHI (emails, names)Within 24hAuthority within 72h
LowFailed attack, no data accessedWithin 48hInternal 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:

sql
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-processorData processedAgreementNotes
AWS (RDS, S3)All database content, file uploadsDPA + BAA availableBAA covers RDS, S3, all HIPAA-eligible services. Enabled per account.
Auth provider (Clerk or successor)Email, name, auth tokens, MFADPA + BAA availableBAA 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 availableBAA for telehealth.
Webhook receivers (org-configured)None — payloads contain no PIINone requiredSee 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_id on patient records.

Privacy by Design Checklist

Built into the architecture from day one:

PrincipleImplementation
Data minimisationExplicit ?fields= selection on list endpoints; data-classification registry blocks egress by default (P39)
Purpose limitationConsent ledger per purpose (P17)
Storage limitationRetention policy with daily cleanup job
Integrity & confidentialityRLS on every tenant table; AES-256-GCM for high-sensitivity columns; TLS for all connections
AccountabilityAudit log on every mutation; consent history; break-glass session records
TransparencyPatient self-service "Your clinics" page; consent trail UI; per-org break-glass banner
Access controlPer-org permission codes (no global role checks); see rbac-permissions.md
PseudonymisationEncrypted phone numbers; anonymisation algorithm for erasure
Right to be forgottenAnonymisation + erasure with cascade logic (F11.1)
Controller / processor splitCross-tenant features anonymised by default; identifiable cross-tenant access via break-glass only