Skip to content

GDPR Compliance Architecture

Lawful Basis for Processing

GDPR Art. 6 requires a documented lawful basis for every category of personal data processing. RestartiX processes data under three legal bases, depending on the data category:

Data CategoryLawful BasisGDPR ArticleReasoning
Patient health data (appointments, forms, reports, prescriptions)Performance of contractArt. 6(1)(b)Necessary to deliver telemedicine services the patient signed up for
Audit logs, security logsLegal obligationArt. 6(1)(c)HIPAA requires 6-year audit trail retention for healthcare providers
Patient phone number (encrypted)Performance of contractArt. 6(1)(b)Required for appointment communication
Marketing segments, analyticsConsentArt. 6(1)(a)Optional — patient must explicitly opt in
Specialist employment dataPerformance of contractArt. 6(1)(b)Employment/contractor relationship with organization
Organization integration API keysLegitimate interestArt. 6(1)(f)System operation — no personal data involved

Health Data: Special Category (Art. 9)

Form values, reports, and prescriptions contain health data (GDPR Art. 9 special category). Processing is allowed under:

  • Art. 9(2)(h): Processing necessary for medical diagnosis, provision of health care, on the basis of a contract with a health professional
  • The patient's agreement to the terms of service (contract) covers this — explicit consent per field is not required for core medical data

Explicit opt-in consent is required only for processing that goes beyond the core telemedicine service:

Processing ActivityRequires ConsentWithdrawal Impact
Core appointments + formsNo (contract)Cannot withdraw — must delete account
Reports and prescriptionsNo (contract)Cannot withdraw — must delete account
Marketing segment membershipYesRemove from all marketing segments
Analytics/aggregated data useYesExclude from analytics queries
Third-party data sharing (if any)YesStop sharing, notify third party
Email/SMS notifications beyond appointmentsYesUnsubscribe from notifications

RestartiX uses a dual approach to consent management, depending on the legal and regulatory requirements:

For consents that require legal documentation, digital signatures, or regulatory compliance (HIPAA, biometric data, video recording), the system uses forms with type='disclaimer' that auto-create user_consents entries when signed.

Examples:

  • HIPAA Privacy Notice acknowledgment
  • Video recording consent for telemedicine appointments
  • Biometric data collection (movement tracking, exercise metrics)
  • Prescription digital signature acknowledgment
  • Medical record access consent

Implementation:

  • Form templates have a consent_types TEXT[] field listing which consents are granted when the form is signed
  • When a form is signed (status = 'signed'), the system auto-creates user_consents entries for each consent type
  • The form provides a legal audit trail with signature, timestamp, and IP address
  • The user_consents entry references the form via form_id foreign key

Example:

sql
-- Form template for video recording consent
INSERT INTO form_templates (organization_id, name, type, consent_types) VALUES
(1, 'Video Recording Consent', 'disclaimer', ARRAY['video_recording', 'biometric_data']);

-- When form is signed, auto-creates user_consents entries:
INSERT INTO user_consents (organization_id, user_id, purpose, granted, granted_at, method, form_id, policy_version)
VALUES
(1, 42, 'video_recording', true, NOW(), 'form', 201, '2025-01-v1'),
(1, 42, 'biometric_data', true, NOW(), 'form', 201, '2025-01-v1');

Auto-Sync Logic:

go
// When form is signed
func (s *FormService) SignForm(ctx context.Context, formID int64) error {
    // 1. Update form status to 'signed'
    form, err := s.updateFormStatus(ctx, formID, "signed")
    if err != nil {
        return err
    }

    // 2. Get consent types from form template
    template, err := s.formTemplateStore.GetByID(ctx, form.FormTemplateID)
    if err != nil {
        return err
    }

    // 3. Auto-create user_consents entries
    for _, consentType := range template.ConsentTypes {
        err := s.consentService.GrantConsent(ctx, GrantConsentInput{
            UserID:        currentAppUserID(ctx),  // authenticated user signing the form
            OrganizationID: currentAppOrgID(ctx),
            ConsentType:   consentType,
            Granted:       true,
            Method:        "form",
            FormID:        &formID,
            PolicyVersion: getCurrentPolicyVersion(),
            IPAddress:     getIPFromContext(ctx),
            UserAgent:     getUserAgentFromContext(ctx),
        })
        if err != nil {
            return err
        }
    }

    return nil
}

Type 2: Preference Consents (user_consents only)

For opt-in preferences that don't require legal documentation or signatures, the system uses direct user_consents entries via settings UI or API.

Examples:

  • Marketing emails and newsletters
  • SMS appointment reminders (beyond required notifications)
  • Analytics and usage data collection
  • Third-party data sharing (if applicable)
  • Product announcements

Implementation:

  • No form required
  • User toggles consent in settings UI
  • Frontend calls PUT /v1/me/consents directly
  • No form_id reference — method = 'settings' or 'api'

Decision Matrix:

Consent TypeRequires Form?Signature?Why?
HIPAA notice✅ Yes✅ YesLegal requirement for medical record access
Video recording✅ Yes✅ YesBiometric data, special category under GDPR Art. 9
Exercise tracking✅ Yes✅ YesBiometric/health data collection
Prescription signing✅ Yes✅ YesMedical professional signature requirement
Marketing emails❌ No❌ NoPreference — simple opt-in/out sufficient
SMS reminders❌ No❌ NoPreference — simple opt-in/out sufficient
Analytics❌ No❌ NoPreference — simple opt-in/out sufficient

See also: Forms feature for details on how consent_types field works with disclaimer forms.

Schema

sql
-- Consent purposes tracked by the system
CREATE TYPE consent_purpose AS ENUM (
    -- Legal/Regulatory (Type 1 - require forms with signatures)
    'hipaa_notice',       -- HIPAA Privacy Notice acknowledgment
    'video_recording',    -- Video recording consent for telemedicine
    'biometric_data',     -- Biometric data collection (movement, exercise tracking)
    'prescription_signing', -- Prescription digital signature acknowledgment

    -- Preference (Type 2 - settings UI, no form required)
    'marketing',          -- Marketing emails, newsletters, campaigns
    'analytics',          -- Anonymized usage data, analytics
    'sms_reminders',      -- SMS appointment reminders (non-essential)
    'third_party_sharing' -- Sharing data with specified third parties
);

CREATE TABLE user_consents (
    id              BIGSERIAL PRIMARY KEY,
    organization_id BIGINT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
    user_id         BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,

    purpose         consent_purpose NOT NULL,

    -- Consent state
    granted         BOOLEAN NOT NULL,
    granted_at      TIMESTAMPTZ,          -- When consent was given
    withdrawn_at    TIMESTAMPTZ,          -- When consent was withdrawn (null if active)

    -- Provenance
    method          TEXT NOT NULL,         -- 'form', 'api', 'onboarding', 'settings'
    ip_address      INET,                 -- IP at time of consent action
    user_agent      TEXT,                 -- Browser/device at time of consent action

    -- Version tracking
    policy_version  TEXT NOT NULL,         -- e.g., "2025-01-v1" — which privacy policy was active
    form_id         BIGINT REFERENCES forms(id), -- If consent was captured via a form (disclaimer)

    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),

    -- Latest consent per purpose per user per org
    UNIQUE (organization_id, user_id, purpose, granted_at)
);

-- RLS
ALTER TABLE user_consents ENABLE ROW LEVEL SECURITY;

CREATE POLICY consents_select ON user_consents FOR SELECT USING (
    organization_id = current_app_org_id() AND (
        current_app_role() = 'admin'
        OR user_id = current_app_user_id()
    )
);

CREATE POLICY consents_insert ON user_consents FOR INSERT WITH CHECK (
    organization_id = current_app_org_id()
);

-- Consents are append-only. No UPDATE or DELETE policies.
-- Withdrawal is recorded as a new row with granted=false.

CREATE INDEX idx_consents_user ON user_consents(user_id, organization_id);
CREATE INDEX idx_consents_purpose ON user_consents(organization_id, purpose, granted);
CREATE INDEX idx_consents_latest ON user_consents(user_id, purpose, granted_at DESC);

Consent records are never updated or deleted. Every change creates a new row:

Row 1: user=42, purpose=marketing, granted=true,  granted_at=2025-01-15, policy_version="2025-01-v1"
Row 2: user=42, purpose=marketing, granted=false, withdrawn_at=2025-03-20, policy_version="2025-01-v1"
Row 3: user=42, purpose=marketing, granted=true,  granted_at=2025-06-01, policy_version="2025-06-v2"

The latest row per purpose determines the current state. This provides a complete audit trail of consent history.

sql
-- Get current consent state for a user
SELECT DISTINCT ON (purpose)
    purpose,
    granted,
    granted_at,
    withdrawn_at,
    policy_version
FROM user_consents
WHERE user_id = $1 AND organization_id = $2
ORDER BY purpose, granted_at DESC NULLS LAST, created_at DESC;

API Endpoints

GET /v1/me/consents
├─ Description: Get current consent state for all purposes
├─ Access: Any authenticated user (own consents)
├─ Response: 200
│  {
│    "data": [
│      {
│        "purpose": "marketing",
│        "granted": true,
│        "granted_at": "2025-06-01T10:00:00Z",
│        "policy_version": "2025-06-v2"
│      },
│      {
│        "purpose": "analytics",
│        "granted": false,
│        "withdrawn_at": "2025-03-20T14:00:00Z",
│        "policy_version": "2025-01-v1"
│      },
│      {
│        "purpose": "notifications",
│        "granted": true,
│        "granted_at": "2025-01-15T09:00:00Z",
│        "policy_version": "2025-01-v1"
│      }
│    ]
│  }
└─ Notes: Purposes not present = never consented (treated as not granted)

PUT /v1/me/consents
├─ Description: Update consent for one or more purposes
├─ Access: Any authenticated user (own consents)
├─ Request:
│  {
│    "consents": [
│      { "purpose": "marketing", "granted": true },
│      { "purpose": "analytics", "granted": false }
│    ]
│  }
├─ Response: 200 with updated consent state
├─ Business logic:
│  1. For each purpose: insert new consent row (append-only)
│  2. If granted=false: set withdrawn_at = NOW()
│  3. If granted=false for "marketing": trigger segment removal (async)
│  4. Record ip_address and user_agent from request
│  5. Set policy_version to current active policy version
│  6. Audit log: consent_changed event
└─ Errors:
   - 400 invalid_purpose: Unknown consent purpose

GET /v1/users/{id}/consents
├─ Description: View user's consent history (admin view)
├─ Access: Admin, Superadmin
├─ Response: 200 with full consent history (all rows, not just latest)
└─ Query: ?purpose=marketing (optional filter)

When a patient is onboarded (POST /v1/patients/onboard), the response includes required consent purposes. The frontend must collect consent before the patient can proceed.

Flow:

  1. Patient registers via Clerk
  2. POST /v1/patients/onboard creates patient record
  3. Frontend shows consent form with all purposes
  4. Frontend calls PUT /v1/me/consents with patient's choices
  5. Core service purposes (appointments, forms) don't require consent — they're covered by the contract

When a patient visits multiple clinics on the platform, each clinic relationship is fully independent. The technical flow:

  1. First clinic (Clinic A): POST /v1/patients/onboard creates a patient_persons record (portable, no organization_id) and a patients record linking the person to Clinic A with profile_shared = false.
  2. Consent forms fire: Automation rules create blocking disclaimer forms (privacy policy, profile sharing) scoped to organization_id = Clinic A. When signed, profile_shared is set to true on the patients record for Clinic A. Consent is recorded in user_consents with organization_id = Clinic A.
  3. Second clinic (Clinic B): POST /v1/patients/onboard detects the patient already exists (by email), reuses the same patient_persons record, but creates a new patients record for Clinic B with profile_shared = false.
  4. Independent consent flow: Clinic B's automation rules create their own blocking forms. New user_consents entries are created with organization_id = Clinic B. The patient must consent again, separately.

What crosses clinic boundaries:

  • patient_persons fields (name, DOB, blood type, allergies, insurance) — but only visible to clinic staff when profile_shared = true for that specific clinic. Name is always visible (minimum for scheduling).

What never crosses clinic boundaries:

  • Appointments, forms, reports, prescriptions, treatment plans — all scoped by organization_id and enforced by RLS.
  • user_consents entries — always scoped to a single organization.
  • Custom field values, segment memberships — organization-specific.

The platform does not facilitate automatic cross-clinic record sharing. If a patient wants Clinic B to see Clinic A's records, they export from Clinic A and provide them manually. Automatic sharing would classify the platform as a Health Information Exchange (HIE), subject to heavier regulation.

PurposeWithdrawal EffectImplementation
marketingRemove from all marketing-purpose segments. Stop marketing communications.Async job: delete segment_members rows where segment has purpose = 'marketing'
analyticsExclude user's data from future analytics queries. Existing aggregated data is not affected (Art. 17(3)(d)).Flag user in analytics queries: WHERE user_id NOT IN (withdrawn_analytics_users)
notificationsStop non-essential notifications. Essential appointment reminders continue (contract basis).Update notification preferences in user record
third_party_sharingStop sharing data. Notify third party to delete shared data.Trigger third-party deletion webhook if applicable

PII Definition

For the purposes of anonymization, erasure, and export, Personally Identifiable Information (PII) in RestartiX is:

FieldTableCategory
nameusers, patient_persons, specialistsDirect identifier
emailusersDirect identifier
phone_encryptedpatient_personsDirect identifier (encrypted)
emergency_contact_phone_encryptedpatient_personsDirect identifier (encrypted)
clerk_user_idusersPseudonymous identifier
consumer_idpatientsExternal identifier
ip_addressaudit_log, user_consentsIndirect identifier
user_agentaudit_log, user_consentsIndirect identifier
values (name-like fields)formsContext-dependent — PII if form contains patient name, address, etc.
value (name-like values)custom_field_valuesContext-dependent — PII if custom field stores name, address, etc.
signature_urlspecialistsBiometric identifier
files (signature images)formsBiometric identifier (if consent signature)

What Is NOT PII

DataReason
Appointment dates/timesNot personally identifying on their own
Form field definitions (fields JSONB)Template structure, not patient data
Segment rulesConfiguration, not patient data
Organization name/settingsBusiness data, not personal
Specialty namesReference data
Custom field definitionsConfiguration, not values

Anonymization Algorithm

When POST /v1/gdpr/users/{userID}/anonymize is called:

Step 1: Validate

go
func (s *GDPRService) Anonymize(ctx context.Context, userID int64) error {
    // 1. Verify caller is admin of user's org (or superadmin)
    // 2. Verify user exists and is not already anonymized
    // 3. Begin transaction
}

Step 2: Anonymize User Record

FieldOriginalAnonymizedMethod
name"Maria Ionescu""Anonymized User 5a3b""Anonymized User " + first 4 chars of SHA-256(user_id + salt)
email"[email protected]""[email protected]""anon-" + hash prefix + "@redacted.local"
clerk_user_id"user_abc123"NULLSet to NULL, deactivate in Clerk via API
blockedfalsetrueBlock the account

Step 3: Anonymize Patient Records

patient_persons (portable profile):

FieldOriginalAnonymizedMethod
name"Maria Ionescu""Anonymized Patient 5a3b"Same hash prefix
phone_encrypted[encrypted bytes]NULLSet to NULL
emergency_contact_phone_encrypted[encrypted bytes]NULLSet to NULL
emergency_contact_name"Ion Ionescu"NULLSet to NULL
date_of_birth"1990-05-15"NULLSet to NULL
occupation"Teacher"NULLSet to NULL
residence"Bucharest"NULLSet to NULL

patients (org-patient link):

FieldOriginalAnonymizedMethod
consumer_id"ext-12345"NULLSet to NULL

Step 4: Anonymize Form Values

sql
-- For all forms belonging to this user:
-- Replace values JSONB with anonymized version
UPDATE forms
SET values = anonymize_form_values(values, fields),
    files = '{}'::jsonb,  -- Remove all file references
    updated_at = NOW()
WHERE patient_person_id IN (SELECT id FROM patient_persons WHERE user_id = $1);

Anonymization rules per field type:

Field TypeOriginal ValueAnonymized Value
text, textarea, richtext"Maria from Bucharest""[REDACTED]"
email"[email protected]""[REDACTED]"
phone"+40712345678""[REDACTED]"
number420
date"1990-05-15""1900-01-01"
select, radio, checkbox"Big pain"Preserved (not PII — it's from a predefined list)
fileNULL (file deleted from S3)
booleantruePreserved (not PII)

Key decision: Select/radio/checkbox values from predefined options are not anonymized because they are aggregate-safe (e.g., "pain level = high" doesn't identify a person). Free-text fields are always anonymized.

Step 5: Anonymize Custom Field Values

sql
-- Delete all custom field values for this user's patient/specialist entities
DELETE FROM custom_field_values
WHERE (entity_type = 'patient' AND entity_id = $patientID)
   OR (entity_type = 'specialist' AND entity_id = $specialistID);

Custom field values are deleted, not anonymized, because they are free-form text with no predefined options.

Step 6: Anonymize Audit Log Entries

sql
-- Anonymize audit log entries (preserve structure, remove PII)
UPDATE audit_log
SET changes = anonymize_audit_changes(changes),
    ip_address = '0.0.0.0'::inet,
    user_agent = '[REDACTED]'
WHERE user_id = $1;

Audit log entries are anonymized, not deleted. The audit trail structure must be preserved for HIPAA compliance (6-year retention), but PII within the changes JSONB is redacted.

Step 7: Delete Files from S3

go
// Collect all S3 file URLs from:
// - forms.files JSONB (all field file uploads)
// - specialist.signature_url
// - appointment_document_files.file_url (for user's documents)
// Delete each from S3
for _, url := range fileURLs {
    s3Client.DeleteObject(ctx, bucket, key)
}

Step 8: Remove from Segments

sql
DELETE FROM segment_members WHERE patient_id = $patientID;

Step 9: Record Anonymization

sql
-- Mark user as anonymized
UPDATE users SET
    anonymized_at = NOW(),
    blocked = true
WHERE id = $1;

Add anonymized_at TIMESTAMPTZ column to users table:

sql
ALTER TABLE users ADD COLUMN anonymized_at TIMESTAMPTZ;

Step 10: Notify External Systems

go
// 1. Deactivate user in Clerk (revoke all sessions)
clerkClient.Users.Delete(ctx, user.ClerkUserID)

What Is Preserved After Anonymization

DataPreserved?Reason
Appointment recordsYes (with anonymized user reference)Medical record integrity (HIPAA)
Form structure (field definitions)YesTemplate structure, not PII
Form select/checkbox valuesYesAggregate-safe, not individually identifying
Report/prescription metadataYes (title, dates)Medical record integrity
Audit log structureYes (PII redacted)HIPAA 6-year requirement
Segment membershipNo — deletedNo purpose without identified patient
Custom field valuesNo — deletedFree-text PII
S3 filesNo — deletedMay contain PII (signatures, uploads)

Right to Erasure (Deletion)

DELETE /v1/gdpr/users/{userID} performs a more aggressive operation than anonymization:

Erasure vs. Anonymization

AspectAnonymizationErasure
User recordAnonymized (preserved)Soft-deleted (deleted_at set)
Patient recordAnonymized (preserved)Soft-deleted
AppointmentsPreserved with anonymized refsPreserved with anonymized refs (HIPAA)
FormsValues anonymizedForm values cleared, forms soft-deleted
Custom field valuesDeletedDeleted
Reports/prescriptionsPreserved (anonymized)Preserved (anonymized) — medical records
Audit logsPII redacted, structure preservedPII redacted, structure preserved (HIPAA)
S3 filesDeletedDeleted
Segment membershipDeletedDeleted
Consent recordsPreserved (legal proof)Preserved (legal proof of prior consent)
Clerk accountDeactivatedDeleted

HIPAA override: Even under GDPR Art. 17 erasure, medical records (appointments, reports, prescriptions) cannot be fully deleted because HIPAA requires retention. They are anonymized instead. This is permitted under Art. 17(3)(c): "for reasons of public interest in the area of public health."

Erasure Flow

go
func (s *GDPRService) Erase(ctx context.Context, userID int64) error {
    // 1. Run full anonymization (steps 1-10 above)
    if err := s.Anonymize(ctx, userID); err != nil {
        return err
    }

    // 2. Additionally: soft-delete user and patient records
    db.Exec("UPDATE users SET deleted_at = NOW() WHERE id = $1", userID)
    // Soft-delete all org-patient links for this person
    db.Exec(`
        UPDATE patients SET deleted_at = NOW()
        WHERE patient_person_id = (SELECT id FROM patient_persons WHERE user_id = $1)
    `, userID)

    // 3. Soft-delete forms (values already anonymized)
    db.Exec(`
        UPDATE forms SET deleted_at = NOW()
        WHERE patient_person_id = (SELECT id FROM patient_persons WHERE user_id = $1)
    `, userID)

    // 4. Delete Clerk account (not just deactivate)
    clerkClient.Users.Delete(ctx, user.ClerkUserID)

    // 5. Audit: record erasure event (with no PII — user already anonymized)
    auditLog.Record("gdpr_erasure", userID, orgID)

    return nil
}

Add deleted_at to forms table:

sql
ALTER TABLE forms ADD COLUMN deleted_at TIMESTAMPTZ;
CREATE INDEX idx_forms_active ON forms(organization_id) WHERE deleted_at IS NULL;

Right to Access (Data Export)

GET /v1/gdpr/users/{userID}/export returns all data associated with a user in a structured JSON format.

Export Payload Schema

json
{
  "export_metadata": {
    "exported_at": "2025-06-15T14:30:00Z",
    "exported_by": "[email protected]",
    "user_id": 42,
    "organization": "RestartiX Bucharest",
    "format_version": "1.0"
  },
  "user": {
    "id": 42,
    "name": "Maria Ionescu",
    "email": "[email protected]",
    "role": "patient",
    "created_at": "2025-01-15T09:00:00Z",
    "organizations": [
      { "id": 1, "name": "RestartiX Bucharest", "joined_at": "2025-01-15T09:00:00Z" }
    ]
  },
  "patient_person": {
    "id": 629,
    "name": "Maria Ionescu",
    "phone": "+40712345678",
    "date_of_birth": "1990-05-15",
    "blood_type": "A+",
    "allergies": [],
    "chronic_conditions": [],
    "insurance_entries": []
  },
  "patient": {
    "id": 100,
    "organization_id": 1,
    "consumer_id": "ext-12345",
    "created_at": "2025-01-15T09:00:00Z"
  },
  "consents": [
    {
      "purpose": "marketing",
      "granted": true,
      "granted_at": "2025-01-15T09:05:00Z",
      "policy_version": "2025-01-v1",
      "method": "onboarding"
    },
    {
      "purpose": "marketing",
      "granted": false,
      "withdrawn_at": "2025-03-20T14:00:00Z",
      "policy_version": "2025-01-v1",
      "method": "settings"
    }
  ],
  "appointments": [
    {
      "id": 102,
      "uid": "550e8400-e29b-41d4-a716-446655440000",
      "title": "Initial Consultation",
      "status": "done",
      "started_at": "2025-01-20T10:00:00Z",
      "ended_at": "2025-01-20T10:45:00Z",
      "specialist": "Dr. Smith",
      "specialty": "Kinesitherapy"
    }
  ],
  "forms": [
    {
      "id": 201,
      "title": "Patient Intake Survey",
      "type": "survey",
      "status": "signed",
      "signed_at": "2025-01-20T10:05:00Z",
      "appointment_id": 102,
      "values": {
        "city": "Bucharest",
        "pain_level": "Big pain",
        "medical_history": "No prior conditions"
      },
      "files": {
        "consent_signature": {
          "name": "signature.png",
          "size": 45000,
          "url": "[pre-signed URL, valid 1 hour]"
        }
      }
    }
  ],
  "custom_field_values": [
    {
      "field": "city",
      "value": "Bucharest",
      "entity_type": "patient"
    },
    {
      "field": "blood_type",
      "value": "A+",
      "entity_type": "patient"
    }
  ],
  "reports": [
    {
      "id": 50,
      "title": "Consultation Report",
      "appointment_id": 102,
      "published": true,
      "created_at": "2025-01-20T11:00:00Z",
      "files": [
        { "name": "report.pdf", "url": "[pre-signed URL, valid 1 hour]" }
      ]
    }
  ],
  "prescriptions": [
    {
      "id": 25,
      "title": "Treatment Plan",
      "appointment_id": 102,
      "published": true,
      "created_at": "2025-01-20T11:15:00Z"
    }
  ],
  "segment_memberships": [
    {
      "segment": "High Pain Patients",
      "matched_at": "2025-01-20T10:10:00Z"
    }
  ]
}

Export Implementation

go
func (s *GDPRService) Export(ctx context.Context, userID int64) (*ExportPayload, error) {
    // All queries run within the user's org context (RLS enforced)
    // Export includes data from current org only

    user, _ := s.userStore.GetByID(ctx, userID)
    person, _ := s.patientPersonStore.GetByUserID(ctx, userID)    // portable profile
    patient, _ := s.patientStore.GetByPatientPersonID(ctx, person.ID, orgID)  // org-patient link
    consents, _ := s.consentStore.GetHistory(ctx, userID)
    appointments, _ := s.appointmentStore.GetByPatientPersonID(ctx, person.ID)
    forms, _ := s.formStore.GetByPatientPersonID(ctx, person.ID)
    customFields, _ := s.customFieldValueStore.GetByEntity(ctx, "patient", patient.ID)
    reports, _ := s.documentStore.GetByPatientPersonID(ctx, person.ID, "report")
    prescriptions, _ := s.documentStore.GetByPatientPersonID(ctx, person.ID, "prescription")
    segments, _ := s.segmentStore.GetMembershipsByPatient(ctx, patient.ID)

    // Generate pre-signed URLs for all files (valid 1 hour)
    // ...

    return &ExportPayload{...}, nil
}

Export Delivery

The export is returned as a JSON response (not a file download). For large exports, the response is streamed. If the export exceeds 50MB, return a 202 Accepted with a job ID and deliver the export via S3 signed URL.


Right to Rectification

Patients can correct their personal data via standard PUT endpoints. The audit log captures all changes automatically.

Enhanced Rectification Tracking

When a user updates their own data through the standard API, the audit log already captures the before/after diff. For explicit rectification requests (e.g., patient contacts support to correct their name), the admin uses a dedicated endpoint:

POST /v1/gdpr/users/{userID}/rectify
├─ Description: Record a formal rectification request and apply corrections
├─ Access: Admin, Superadmin
├─ Request:
│  {
│    "corrections": [
│      {
│        "entity": "patient",
│        "field": "name",
│        "old_value": "Maria Ionescu",
│        "new_value": "Maria Popescu"
│      },
│      {
│        "entity": "custom_field",
│        "field": "city",
│        "old_value": "Bucharest",
│        "new_value": "Cluj-Napoca"
│      }
│    ],
│    "reason": "Patient reported name change after marriage"
│  }
├─ Response: 200
│  {
│    "rectification_id": "rect-2025-0042",
│    "applied_corrections": 2,
│    "audit_event_id": 12345
│  }
├─ Business logic:
│  1. Apply each correction to the target entity
│  2. Create audit log entry with action = "gdpr_rectification"
│  3. Include reason and rectification_id in audit metadata
│  4. Return confirmation
└─ Notes: This endpoint creates an enhanced audit trail
   that distinguishes GDPR rectification from normal updates.

Data Retention Policy

Retention Periods

Data CategoryRetention PeriodLegal BasisCleanup Action
Audit logs6 yearsHIPAA 164.312(b)Archive to cold storage after 1 year, delete after 6 years
Appointments (including soft-deleted)6 years from appointment dateHIPAA medical record retentionAnonymize after 6 years
Forms (signed)6 years from signed_atHIPAA medical record retentionAnonymize after 6 years
Reports & prescriptions6 years from created_atHIPAA medical record retentionAnonymize after 6 years
Telemetry/metrics logs90 daysOperational — no legal requirementManaged by Telemetry service (separate retention)
Consent records6 years after last interactionGDPR Art. 7 — proof of consentArchive after 6 years
User accounts (active)Duration of serviceContractN/A while active
User accounts (deleted/anonymized)6 years after anonymizationHIPAA (linked medical records)Full purge after 6 years
Segment membershipDuration of membershipContract/consentDeleted when patient leaves segment or is anonymized
Custom field valuesDuration of patient relationshipContractDeleted on anonymization/erasure
S3 files (form uploads, signatures)Same as parent entityFollows parent retentionDeleted when parent is anonymized/erased

Schema Support

sql
-- Add retention tracking to organizations
ALTER TABLE organizations ADD COLUMN retention_config JSONB NOT NULL DEFAULT '{
    "audit_log_days": 2190,
    "medical_records_days": 2190,
    "telemetry_log_days": 90,  -- informational: Telemetry service manages its own retention
    "consent_records_days": 2190
}';

Default: 2190 days = 6 years. Organizations can increase (never decrease below 6 years for HIPAA-covered data).

Retention Cleanup Job

A scheduled Go job runs daily to enforce retention:

go
// internal/jobs/retention.go

func (j *RetentionJob) Run(ctx context.Context) error {
    orgs, _ := j.orgStore.ListAll(ctx)

    for _, org := range orgs {
        config := org.RetentionConfig

        // 1. Telemetry log retention is managed by the Telemetry service (separate DB)
        // the Core API only manages its own local audit_log and medical records

        // 2. Archive old audit logs to cold storage (S3)
        // Logs older than 1 year: export to S3 as JSONL, delete from DB
        // Logs older than 6 years: delete from S3
        j.archiveOldAuditLogs(ctx, org.ID, config.AuditLogDays)

        // 3. Anonymize expired medical records
        // Appointments, forms, reports older than retention period
        j.anonymizeExpiredRecords(ctx, org.ID, config.MedicalRecordsDays)
    }

    return nil
}

Audit Log Archival Strategy

Audit logs have a two-tier retention:

  1. Hot storage (PostgreSQL): Last 12 months. Queryable via API.
  2. Cold storage (S3): 1-6 years. Exported as JSONL files per month. Retrievable on request (not queryable via API).
  3. Deletion: After 6 years, both hot and cold copies are deleted.
Year 0-1:  PostgreSQL (hot, queryable)
Year 1-6:  S3 archive (cold, retrievable on request)
Year 6+:   Deleted permanently

Breach Notification Workflow (Art. 33)

GDPR requires notification to the supervisory authority within 72 hours of becoming aware of a breach. If the breach is likely to result in high risk to individuals, affected users must also be notified (Art. 34).

Breach Detection Sources

SourceWhat It Detects
Audit log anomaliesUnusual access patterns (bulk data access, off-hours access, cross-org attempts)
Failed auth spikesBrute force attempts (Clerk reports)
RLS policy violationsQueries that hit RLS denials (logged as warnings)
Infrastructure alertsAWS GuardDuty, CloudTrail anomalies
External reportsBug bounty, security researcher reports, vendor notifications

Breach Severity Classification

SeverityCriteriaResponse TimeNotification
CriticalConfirmed PHI exposure, data exfiltrationImmediateAuthority within 72h + affected users
HighUnauthorized access to medical records, no confirmed exfiltrationWithin 4 hoursAuthority within 72h, users if risk is high
MediumUnauthorized access to non-PHI data (emails, names)Within 24 hoursAuthority within 72h
LowFailed attack, no data accessedWithin 48 hoursInternal record only, no notification required

72-Hour Response Procedure

Hour 0:     Breach detected or reported
            ├─ Create incident record in audit_log (action = "breach_detected")
            ├─ Alert on-call engineer (PagerDuty/Slack)
            └─ Begin investigation

Hour 0-4:   Initial assessment
            ├─ Identify scope: which orgs, users, data types affected
            ├─ Determine cause: vulnerability, misconfiguration, social engineering
            ├─ Classify severity (Critical/High/Medium/Low)
            └─ Contain: revoke compromised credentials, block attack vector

Hour 4-24:  Impact analysis
            ├─ Query audit_log for affected entities
            ├─ Generate list of affected users
            ├─ Determine if PHI was exposed
            └─ Prepare breach report draft

Hour 24-48: Authority notification preparation
            ├─ Complete breach report with:
            │   - Nature of the breach
            │   - Categories and number of affected individuals
            │   - Data categories compromised
            │   - Likely consequences
            │   - Measures taken to address and mitigate
            └─ Legal review of notification

Hour 48-72: Notifications sent
            ├─ Submit to supervisory authority (ANSPDCP for Romania)
            ├─ If high risk: notify affected users via email
            │   - Plain language description of what happened
            │   - What data was affected
            │   - What we've done about it
            │   - What they should do (change passwords, monitor accounts)
            │   - Contact information for questions
            └─ Record all notifications in audit_log

Hour 72+:   Follow-up
            ├─ Root cause analysis
            ├─ Remediation implementation
            ├─ Post-incident review
            └─ Update security measures

Breach Record Schema

sql
CREATE TABLE breach_records (
    id              BIGSERIAL PRIMARY KEY,
    organization_id BIGINT REFERENCES organizations(id),

    -- Classification
    severity        TEXT NOT NULL,  -- 'critical', 'high', 'medium', 'low'
    status          TEXT NOT NULL DEFAULT 'detected',
                    -- 'detected', 'investigating', 'contained',
                    -- 'notified_authority', 'notified_users', 'resolved'

    -- Timeline
    detected_at     TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    contained_at    TIMESTAMPTZ,
    authority_notified_at TIMESTAMPTZ,
    users_notified_at     TIMESTAMPTZ,
    resolved_at     TIMESTAMPTZ,

    -- Details
    description     TEXT NOT NULL,
    affected_users  INT,
    affected_data   TEXT[],         -- e.g., ['email', 'phone', 'medical_records']
    cause           TEXT,
    remediation     TEXT,

    -- Metadata
    reported_by     BIGINT REFERENCES users(id),
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- No RLS — breach records are system-level, accessible only via admin API
-- Access controlled purely at handler level (superadmin only)

Data Processor Agreements

RestartiX uses third-party services that process personal data. Each requires a Data Processing Agreement (DPA) or Business Associate Agreement (BAA).

ProcessorData ProcessedDPA/BAA StatusNotes
ClerkUser email, name, auth tokens, MFA dataBAA availableClerk signs BAA for HIPAA-covered entities. DPA included in ToS for GDPR.
AWS (RDS, S3)All database content, file uploadsBAA availableAWS BAA covers RDS, S3, and all HIPAA-eligible services. Must be enabled per account.
Daily.coAppointment room names, participant IPs during video callsBAA availableDaily.co offers BAA for telehealth. Room recordings (if enabled) are PHI.
Webhook receiversEvent notifications (org-configured URLs)None requiredWebhook payloads contain no PII — only entity IDs, event types, and timestamps. Receivers call back to the Core API's authenticated API for details. No BAA/DPA needed with automation platforms. See features/webhooks.

Webhook Payload PII Policy

Webhook payloads are delivered to org-configured external URLs (Make.com, Zapier, n8n, custom). Since we cannot control the receiver's security posture, payloads contain no PII by design — only entity IDs, event types, and 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. See features/webhooks for the full payload format and PII policy.


Children's Data

Romanian law sets the age of digital consent at 16 years (GDPR Art. 8 allows member states to set between 13-16).

Current Scope

RestartiX is a telemedicine platform for adults. The platform does not target or knowingly collect data from children under 16. If a child needs telemedicine services, the parent/guardian is the account holder.

Implementation

  • No age verification at registration (Clerk handles identity)
  • Terms of service state: "You must be at least 16 years old to use this service"
  • If a minor's data is discovered: admin can trigger anonymization via GDPR endpoints
  • Future consideration: If pediatric telemedicine is added, implement guardian consent flow with parent_user_id on patient records

Privacy by Design Checklist

Built into the Go architecture from day one:

PrincipleImplementation
Data minimizationExplicit ?fields= selection, no include=* wildcard, no unnecessary data in responses
Purpose limitationConsent model tracks purpose per processing activity
Storage limitationRetention policy with automated cleanup jobs
Integrity & confidentialityRLS, AES-256-GCM encryption, TLS, audit logging
AccountabilityAudit trail for all mutations, consent history, breach records
TransparencyGDPR export endpoint, consent management UI, clear privacy policy
Access controlFive-role RBAC with field-level filtering (see rbac-permissions.md)
PseudonymizationEncrypted phone numbers, anonymization algorithm for erasure
Right to be forgottenAnonymization + erasure endpoints with cascade logic