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 Category | Lawful Basis | GDPR Article | Reasoning |
|---|---|---|---|
| Patient health data (appointments, forms, reports, prescriptions) | Performance of contract | Art. 6(1)(b) | Necessary to deliver telemedicine services the patient signed up for |
| Audit logs, security logs | Legal obligation | Art. 6(1)(c) | HIPAA requires 6-year audit trail retention for healthcare providers |
| Patient phone number (encrypted) | Performance of contract | Art. 6(1)(b) | Required for appointment communication |
| Marketing segments, analytics | Consent | Art. 6(1)(a) | Optional — patient must explicitly opt in |
| Specialist employment data | Performance of contract | Art. 6(1)(b) | Employment/contractor relationship with organization |
| Organization integration API keys | Legitimate interest | Art. 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
When Consent IS Required
Explicit opt-in consent is required only for processing that goes beyond the core telemedicine service:
| Processing Activity | Requires Consent | Withdrawal Impact |
|---|---|---|
| Core appointments + forms | No (contract) | Cannot withdraw — must delete account |
| Reports and prescriptions | No (contract) | Cannot withdraw — must delete account |
| Marketing segment membership | Yes | Remove from all marketing segments |
| Analytics/aggregated data use | Yes | Exclude from analytics queries |
| Third-party data sharing (if any) | Yes | Stop sharing, notify third party |
| Email/SMS notifications beyond appointments | Yes | Unsubscribe from notifications |
Consent Model
Two-Layer Consent Architecture
RestartiX uses a dual approach to consent management, depending on the legal and regulatory requirements:
Type 1: Legal/Regulatory Consents (Form + user_consents)
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-createsuser_consentsentries for each consent type - The form provides a legal audit trail with signature, timestamp, and IP address
- The
user_consentsentry references the form viaform_idforeign key
Example:
-- 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:
// 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/consentsdirectly - No form_id reference —
method = 'settings'or'api'
Decision Matrix:
| Consent Type | Requires Form? | Signature? | Why? |
|---|---|---|---|
| HIPAA notice | ✅ Yes | ✅ Yes | Legal requirement for medical record access |
| Video recording | ✅ Yes | ✅ Yes | Biometric data, special category under GDPR Art. 9 |
| Exercise tracking | ✅ Yes | ✅ Yes | Biometric/health data collection |
| Prescription signing | ✅ Yes | ✅ Yes | Medical professional signature requirement |
| Marketing emails | ❌ No | ❌ No | Preference — simple opt-in/out sufficient |
| SMS reminders | ❌ No | ❌ No | Preference — simple opt-in/out sufficient |
| Analytics | ❌ No | ❌ No | Preference — simple opt-in/out sufficient |
See also: Forms feature for details on how consent_types field works with disclaimer forms.
Schema
-- 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 Is Append-Only
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.
Querying Current Consent
-- 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)Consent During Onboarding
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:
- Patient registers via Clerk
POST /v1/patients/onboardcreates patient record- Frontend shows consent form with all purposes
- Frontend calls
PUT /v1/me/consentswith patient's choices - Core service purposes (appointments, forms) don't require consent — they're covered by the contract
Consent Across Multiple Organizations
When a patient visits multiple clinics on the platform, each clinic relationship is fully independent. The technical flow:
- First clinic (Clinic A):
POST /v1/patients/onboardcreates apatient_personsrecord (portable, noorganization_id) and apatientsrecord linking the person to Clinic A withprofile_shared = false. - Consent forms fire: Automation rules create blocking disclaimer forms (privacy policy, profile sharing) scoped to
organization_id = Clinic A. When signed,profile_sharedis set totrueon thepatientsrecord for Clinic A. Consent is recorded inuser_consentswithorganization_id = Clinic A. - Second clinic (Clinic B):
POST /v1/patients/onboarddetects the patient already exists (by email), reuses the samepatient_personsrecord, but creates a newpatientsrecord for Clinic B withprofile_shared = false. - Independent consent flow: Clinic B's automation rules create their own blocking forms. New
user_consentsentries are created withorganization_id = Clinic B. The patient must consent again, separately.
What crosses clinic boundaries:
patient_personsfields (name, DOB, blood type, allergies, insurance) — but only visible to clinic staff whenprofile_shared = truefor 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_idand enforced by RLS. user_consentsentries — 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.
Consent Withdrawal Effects
| Purpose | Withdrawal Effect | Implementation |
|---|---|---|
marketing | Remove from all marketing-purpose segments. Stop marketing communications. | Async job: delete segment_members rows where segment has purpose = 'marketing' |
analytics | Exclude 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) |
notifications | Stop non-essential notifications. Essential appointment reminders continue (contract basis). | Update notification preferences in user record |
third_party_sharing | Stop 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:
| Field | Table | Category |
|---|---|---|
name | users, patient_persons, specialists | Direct identifier |
email | users | Direct identifier |
phone_encrypted | patient_persons | Direct identifier (encrypted) |
emergency_contact_phone_encrypted | patient_persons | Direct identifier (encrypted) |
clerk_user_id | users | Pseudonymous identifier |
consumer_id | patients | External identifier |
ip_address | audit_log, user_consents | Indirect identifier |
user_agent | audit_log, user_consents | Indirect identifier |
values (name-like fields) | forms | Context-dependent — PII if form contains patient name, address, etc. |
value (name-like values) | custom_field_values | Context-dependent — PII if custom field stores name, address, etc. |
signature_url | specialists | Biometric identifier |
files (signature images) | forms | Biometric identifier (if consent signature) |
What Is NOT PII
| Data | Reason |
|---|---|
| Appointment dates/times | Not personally identifying on their own |
Form field definitions (fields JSONB) | Template structure, not patient data |
| Segment rules | Configuration, not patient data |
| Organization name/settings | Business data, not personal |
| Specialty names | Reference data |
| Custom field definitions | Configuration, not values |
Anonymization Algorithm
When POST /v1/gdpr/users/{userID}/anonymize is called:
Step 1: Validate
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
| Field | Original | Anonymized | Method |
|---|---|---|---|
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" | NULL | Set to NULL, deactivate in Clerk via API |
blocked | false | true | Block the account |
Step 3: Anonymize Patient Records
patient_persons (portable profile):
| Field | Original | Anonymized | Method |
|---|---|---|---|
name | "Maria Ionescu" | "Anonymized Patient 5a3b" | Same hash prefix |
phone_encrypted | [encrypted bytes] | NULL | Set to NULL |
emergency_contact_phone_encrypted | [encrypted bytes] | NULL | Set to NULL |
emergency_contact_name | "Ion Ionescu" | NULL | Set to NULL |
date_of_birth | "1990-05-15" | NULL | Set to NULL |
occupation | "Teacher" | NULL | Set to NULL |
residence | "Bucharest" | NULL | Set to NULL |
patients (org-patient link):
| Field | Original | Anonymized | Method |
|---|---|---|---|
consumer_id | "ext-12345" | NULL | Set to NULL |
Step 4: Anonymize Form Values
-- 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 Type | Original Value | Anonymized Value |
|---|---|---|
text, textarea, richtext | "Maria from Bucharest" | "[REDACTED]" |
email | "[email protected]" | "[REDACTED]" |
phone | "+40712345678" | "[REDACTED]" |
number | 42 | 0 |
date | "1990-05-15" | "1900-01-01" |
select, radio, checkbox | "Big pain" | Preserved (not PII — it's from a predefined list) |
file | NULL (file deleted from S3) | |
boolean | true | Preserved (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
-- 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
-- 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
// 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
DELETE FROM segment_members WHERE patient_id = $patientID;Step 9: Record Anonymization
-- Mark user as anonymized
UPDATE users SET
anonymized_at = NOW(),
blocked = true
WHERE id = $1;Add anonymized_at TIMESTAMPTZ column to users table:
ALTER TABLE users ADD COLUMN anonymized_at TIMESTAMPTZ;Step 10: Notify External Systems
// 1. Deactivate user in Clerk (revoke all sessions)
clerkClient.Users.Delete(ctx, user.ClerkUserID)What Is Preserved After Anonymization
| Data | Preserved? | Reason |
|---|---|---|
| Appointment records | Yes (with anonymized user reference) | Medical record integrity (HIPAA) |
| Form structure (field definitions) | Yes | Template structure, not PII |
| Form select/checkbox values | Yes | Aggregate-safe, not individually identifying |
| Report/prescription metadata | Yes (title, dates) | Medical record integrity |
| Audit log structure | Yes (PII redacted) | HIPAA 6-year requirement |
| Segment membership | No — deleted | No purpose without identified patient |
| Custom field values | No — deleted | Free-text PII |
| S3 files | No — deleted | May contain PII (signatures, uploads) |
Right to Erasure (Deletion)
DELETE /v1/gdpr/users/{userID} performs a more aggressive operation than anonymization:
Erasure vs. Anonymization
| Aspect | Anonymization | Erasure |
|---|---|---|
| User record | Anonymized (preserved) | Soft-deleted (deleted_at set) |
| Patient record | Anonymized (preserved) | Soft-deleted |
| Appointments | Preserved with anonymized refs | Preserved with anonymized refs (HIPAA) |
| Forms | Values anonymized | Form values cleared, forms soft-deleted |
| Custom field values | Deleted | Deleted |
| Reports/prescriptions | Preserved (anonymized) | Preserved (anonymized) — medical records |
| Audit logs | PII redacted, structure preserved | PII redacted, structure preserved (HIPAA) |
| S3 files | Deleted | Deleted |
| Segment membership | Deleted | Deleted |
| Consent records | Preserved (legal proof) | Preserved (legal proof of prior consent) |
| Clerk account | Deactivated | Deleted |
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
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:
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
{
"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
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 Category | Retention Period | Legal Basis | Cleanup Action |
|---|---|---|---|
| Audit logs | 6 years | HIPAA 164.312(b) | Archive to cold storage after 1 year, delete after 6 years |
| Appointments (including soft-deleted) | 6 years from appointment date | HIPAA medical record retention | Anonymize after 6 years |
| Forms (signed) | 6 years from signed_at | HIPAA medical record retention | Anonymize after 6 years |
| Reports & prescriptions | 6 years from created_at | HIPAA medical record retention | Anonymize after 6 years |
| Telemetry/metrics logs | 90 days | Operational — no legal requirement | Managed by Telemetry service (separate retention) |
| Consent records | 6 years after last interaction | GDPR Art. 7 — proof of consent | Archive after 6 years |
| User accounts (active) | Duration of service | Contract | N/A while active |
| User accounts (deleted/anonymized) | 6 years after anonymization | HIPAA (linked medical records) | Full purge after 6 years |
| Segment membership | Duration of membership | Contract/consent | Deleted when patient leaves segment or is anonymized |
| Custom field values | Duration of patient relationship | Contract | Deleted on anonymization/erasure |
| S3 files (form uploads, signatures) | Same as parent entity | Follows parent retention | Deleted when parent is anonymized/erased |
Schema Support
-- 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:
// 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:
- Hot storage (PostgreSQL): Last 12 months. Queryable via API.
- Cold storage (S3): 1-6 years. Exported as JSONL files per month. Retrievable on request (not queryable via API).
- 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 permanentlyBreach 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
| Source | What It Detects |
|---|---|
| Audit log anomalies | Unusual access patterns (bulk data access, off-hours access, cross-org attempts) |
| Failed auth spikes | Brute force attempts (Clerk reports) |
| RLS policy violations | Queries that hit RLS denials (logged as warnings) |
| Infrastructure alerts | AWS GuardDuty, CloudTrail anomalies |
| External reports | Bug bounty, security researcher reports, vendor notifications |
Breach Severity Classification
| Severity | Criteria | Response Time | Notification |
|---|---|---|---|
| Critical | Confirmed PHI exposure, data exfiltration | Immediate | Authority within 72h + affected users |
| High | Unauthorized access to medical records, no confirmed exfiltration | Within 4 hours | Authority within 72h, users if risk is high |
| Medium | Unauthorized access to non-PHI data (emails, names) | Within 24 hours | Authority within 72h |
| Low | Failed attack, no data accessed | Within 48 hours | Internal 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 measuresBreach Record Schema
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).
| Processor | Data Processed | DPA/BAA Status | Notes |
|---|---|---|---|
| Clerk | User email, name, auth tokens, MFA data | BAA available | Clerk signs BAA for HIPAA-covered entities. DPA included in ToS for GDPR. |
| AWS (RDS, S3) | All database content, file uploads | BAA available | AWS BAA covers RDS, S3, and all HIPAA-eligible services. Must be enabled per account. |
| Daily.co | Appointment room names, participant IPs during video calls | BAA available | Daily.co offers BAA for telehealth. Room recordings (if enabled) are PHI. |
| Webhook receivers | Event notifications (org-configured URLs) | None required | Webhook 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_idon patient records
Privacy by Design Checklist
Built into the Go architecture from day one:
| Principle | Implementation |
|---|---|
| Data minimization | Explicit ?fields= selection, no include=* wildcard, no unnecessary data in responses |
| Purpose limitation | Consent model tracks purpose per processing activity |
| Storage limitation | Retention policy with automated cleanup jobs |
| Integrity & confidentiality | RLS, AES-256-GCM encryption, TLS, audit logging |
| Accountability | Audit trail for all mutations, consent history, breach records |
| Transparency | GDPR export endpoint, consent management UI, clear privacy policy |
| Access control | Five-role RBAC with field-level filtering (see rbac-permissions.md) |
| Pseudonymization | Encrypted phone numbers, anonymization algorithm for erasure |
| Right to be forgotten | Anonymization + erasure endpoints with cascade logic |