Kulcare Docs
DeveloperPatient

Patient cross-tenant appointments

Reference for the three workstreams that turn Kulcare from a fragmented per-clinic experience into a unified patient surface, while keeping clinic-side data tenant-isolated under RLS.

Status: Backend complete. Frontend wiring (clinic-app PatientPicker + patient-side booking switch in patient/) deferred to follow-ups.

Patient-side codebase: this doc refers to the in-monorepo patient/ Vite app. The legacy patient-app (React 17, decommissioned) is no longer relevant.

Brainstorm: docs/brainstorms/2026-05-06-patient-cross-tenant-appointments-brainstorm.md

Plans: Workstream B · Workstream C


What this enables

Three coupled capabilities:

  1. Patient sees all appointments across all facilities — GET /patient/appointments aggregates practitioner appointments (per-tenant via RLS) with vendor appointments (no tenant) into one chronological list with enriched payload.

  2. Clinic finds any patient at booking time — GET /clinical/patients/federated_search returns three buckets (in_clinic, in_kulcare, no_match) so a clinic can pick up a patient who's already in the Kulcare network without a manual intake step. Privacy-guarded — never leaks which other clinics a patient has visited.

  3. Patient books at any clinic from the patient app — POST /patient/appointment_orders carries the Identity::User JWT, sets RLS to the target tenant, auto-provisions a Clinical::Patient if needed, and creates the appointment. First-time-at-tenant requires explicit consent.


Architecture

The three patient identity layers

Already in the schema, exposed as APIs by this work:

LayerScopeKeyed onPurpose
Identity::UserGlobalemail, phone+country_codeAuth identity. No patient-specific data.
Identity::PatientGlobal, no tenant_idphone_number (unique when present)Cross-tenant "this person is a Kulcare patient" identity. has_many :clinical_patients. Optional user_id. Since #1864 phone_number is nullable — a guardian's dependent has no number of their own — so uniqueness is enforced by a partial index (idx_patients_phone_unique_not_null WHERE phone_number IS NOT NULL). Callers that key off a phoneless identity must dedup on identity_patient_id, not phone.
Clinical::PatientPer-tenant (TenantScoped + RLS)tenant_id + mrnPer-facility medical record. Links: user_id → Identity::User; identity_patient_id → Identity::Patient.

Same human at three facilities = three Clinical::Patient rows, all linked to one Identity::Patient, all linked to one Identity::User (when signed up).

Cross-tenant fan-out pattern

Implemented in Patient::Appointments::ListService and Clinical::Patients::LongitudinalRecordService. Sets app.current_tenant_id per-tenant, queries within RLS, restores in ensure:

def with_rls_context(tenant_id)
  conn = ActiveRecord::Base.connection
  previous_tenant = Shared::Current.tenant

  conn.execute(
    ActiveRecord::Base.sanitize_sql(
      ["SELECT set_config('app.current_tenant_id', ?, true)", tenant_id.to_s]
    )
  )
  Shared::Current.tenant = Identity::Tenant.find(tenant_id)

  yield
ensure
  Shared::Current.tenant = previous_tenant
  conn.execute("SELECT set_config('app.current_tenant_id', '', true)")
end

Vendor appointments use a single global query (no RLS context needed) and union with the practitioner results. Both are sorted by start_time DESC and paginated.

Pending refactor: this pattern is duplicated across three call sites. Tracked for extraction into Shared::Tenancy::RlsContext in #1344 item 12.


Workstream A — GET /patient/appointments

Backed by: Patient::Appointments::ListService Blueprint: Patient::AppointmentBlueprint :patient_card Auth: Identity::User JWT (skips tenant context)

Response shape

{
  "data": [{
    "id": "...",
    "appointment_time": "2026-05-08T10:00:00Z",
    "appointment_status": "booked",
    "booked_at": "2026-05-06T07:00:00Z",
    "appointment_type": "fixed",
    "couple_session": false,
    "doctor_details": {
      "id": "...",
      "kind": "practitioner",
      "name": "Dr. Asha Sharma",
      "photo": "https://cdn.kulcare/avatars/asha.png"
    },
    "clinic_details": {
      "id": "...",
      "name": "Mumbai Heart Clinic",
      "location": {
        "address_line_1": "12 Marine Dr",
        "city": "Mumbai",
        "state": "MH",
        "postal_code": "400001"
      }
    },
    "chief_complaint": "fever, 3 days",
    "reason_codes": ["fever, 3 days"],
    "tenant_id": "..."
  }],
  "meta": { "total": 5, "page": 1, "limit": 20, "fan_out": 3 }
}

fan_out is the number of tenants iterated (capped at 10 per request via MAX_TENANTS_PER_REQUEST).

Vendor vs practitioner appointments

Both surface here — doctor_details.kind distinguishes:

  • Practitioner — tenant-scoped, appointable_type = 'Identity::Practitioner', anchored on patient_id. Has clinic_details.
  • Vendor — no tenant (tenant_id IS NULL), appointable_type = 'Identity::Vendor', anchored on attendee_user_id. clinic_details is null; vendor's service_category exposed instead of photo.

What this is NOT

  • Not the LHR. Appointments are forward-looking scheduling. LHR (Clinical::Patients::LongitudinalRecordService) is for retrospective clinical history (encounters, conditions, observations, prescriptions, allergies, documents).
  • Not authoritative for editing. POST /patient/appointment_orders creates; PUT /patient/appointments/:id/cancel and /confirm mutate state. This endpoint is read-only.

Backed by: Clinical::Patients::FederatedSearchService Auth: Tenant-scoped clinic JWT, Pundit :search? policy

Request

GET /clinical/patients/federated_search?phone=9876543210&country_code=91&name=Asha

Required: at least one of phone, email, name. When phone is given, country_code is required — phone alone could match the same national number across countries.

Response

{
  "data": {
    "in_clinic": [
      {
        "id": "...",
        "name": "Asha Sharma",
        "phone": "9876543210",
        "mrn": "MRN-0042",
        "email": "...",
        "age": 34,
        "gender": "female"
      }
    ],
    "in_kulcare": [
      {
        "identity_patient_id": "...",
        "identity_user_id": "...",
        "name": "Asha Sharma",
        "phone": "9876543210",
        "phone_country_code": "91",
        "age": 34,
        "gender": "female",
        "registered_via": "patient_app"
      }
    ],
    "no_match": []
  }
}

Privacy guarantees

  • in_kulcare results never leak which other clinics a patient has visited. Only global Identity::Patient / Identity::User fields are exposed.
  • registered_via exposes only the kind of registration source (patient_app if Identity::User exists, another_clinic otherwise). No clinic names, no clinic count.
  • name search uses ActiveRecord::Base.sanitize_sql_like on the input plus a 3-character minimum so % / _ cannot enumerate the global identity table.

Auto-provision at booking

When the clinic picks an in_kulcare patient and confirms the booking, Orders::CreateAppointmentOrderService accepts these alternatives to patient_id:

# Existing path (unchanged):
Orders::CreateAppointmentOrderService.call(patient_id: '...', ...)

# New paths (Workstream B):
Orders::CreateAppointmentOrderService.call(identity_patient_id: '...', ...)
Orders::CreateAppointmentOrderService.call(identity_user_id: '...', ...)

The service:

  1. Looks for an existing Clinical::Patient in the current tenant linked to the given Identity::Patient / Identity::User. If found, uses it.
  2. If absent, calls Clinical::Patients::FindOrCreateService to provision a new Clinical::Patient linked to the existing identity layer.
  3. Race-safe: rescues ActiveRecord::RecordNotUnique and re-reads. Backed by partial unique indexes on both (tenant_id, user_id) and (tenant_id, identity_patient_id).

Audit logging — events B1 and B2

All emissions route through Audit::Dispatch (contract). Don't write Audit::PhiAccessLog.create! directly.

EventTypeResourceWhen
B1PhiAccessLog (access_type: :view, access_reason: :treatment)Identity::PatientOne row per in_kulcare result returned. Records cross-clinic identity exposure
B2PhiAccessLog (access_type: :amend, access_reason: :treatment)Clinical::PatientAuto-provision. Includes internal pre_existing_in_other_tenants count

reason_detail stores structured metadata as JSON (no metadata jsonb column on PhiAccessLog yet — see #1344 item 8).


Workstream C — POST /patient/appointment_orders

Backed by: Patient::AppointmentOrders::CreateService Auth: Identity::User JWT Returns: Same shape as Workstream A's :patient_card view

Request

{
  "tenant_id": "...",
  "facility_id": "...",
  "practitioner_id": "...",
  "appointment_type": "fixed",
  "start_time": "2026-06-01T10:00:00Z",
  "end_time": "2026-06-01T10:30:00Z",
  "reason_codes": ["fever, 3 days"],
  "patient_instruction": "Bring previous reports",
  "consent_acknowledged": true
}

status is not accepted — patient cannot pre-set the appointment status. Service hard-defaults to booked for non-walk-in.

Service flow

  1. Validate tenant + facility (must exist, tenant must be active, facility must be operational? and belong to tenant).
  2. Open ActiveRecord::Base.transaction.
  3. Inside the transaction, set RLS context to the target tenant via with_rls_context.
  4. Look for existing Clinical::Patient for current_user in target tenant. If none:
    • Validate consent_acknowledged: true (else fail with :consent_required).
    • Auto-provision via Clinical::Patients::FindOrCreateService wrapped in a savepoint (requires_new: true) so a RecordNotUnique on concurrent insert doesn't poison the outer transaction.
    • Fire C1 (Audit::ConsentAuditLog) and C2 (Audit::PhiAccessLog).
  5. Delegate to Clinical::Appointments::CreateService (existing) for the actual appointment creation. Reuses validation, slot lookup, SMS notifications, reminder scheduling.
  6. Return enriched payload via Patient::AppointmentBlueprint :patient_card.

Server-enforced: consent_acknowledged: true is required only on first-time bookings at this tenant. Returning patients (already have a Clinical::Patient) need no consent — consent was given the first time.

Frontend should detect is_first_time_at_this_facility: true from the practitioner search and render the consent prompt ("Your name and phone will be shared with [Facility Name]. Continue?") before Confirm.

subject_user vs acting_user

Both are passed through:

  • subject_user — the user whose Clinical::Patient is created (the dependent in a guardian-on-behalf-of flow).
  • acting_user — the human who clicked Confirm (the guardian in a guardian-on-behalf-of flow).

Audit logs record both for HIPAA forensics. ConsentAuditLog.scope and PhiAccessLog.reason_detail include acting_user_id. Defaults to subject_user when no X-On-Behalf-Of header.

Audit logging — events C1, C2

EventTypeWhen
C1Audit::ConsentAuditLog (consent_type: :data_sharing, action: :granted, collection_method: :electronic)Patient confirms first-time-at-facility booking. granted_to jsonb encodes facility identity, scope jsonb encodes data categories + via + user_id + acting_user_id + UA
C2Audit::PhiAccessLog (access_type: :amend, access_reason: :patient_request)Clinical::Patient auto-provisioned. via: 'patient_app_authenticated_booking'

IP address is captured natively in Audit::ConsentAuditLog#ip_address. User-agent goes in scope.user_agent.

Error contract

Error codeHTTPWhen
validation_failed422Missing required fields
tenant_not_found404Tenant id invalid or status != active
facility_not_found404Facility id invalid, wrong tenant, or not operational
consent_required422First-time-at-tenant booking without consent_acknowledged: true
provisioning_failed422FindOrCreateService could not create the patient row
appointment_create_failed422Downstream Clinical::Appointments::CreateService rejected (slot conflict, practitioner unavailable, etc.)
internal_error422Anything unexpected. Real exception logged to Rails.logger only — never returned in the response body

What's deferred

ItemIssue
Audit::Dispatch centralized serviceShipped in PR #1441 (#1338 item 1). Contract: developer/patients/audit/README.md.
Audit log retention policy#1338 item 2
Patient-facing "who's seen my record" UI#1338 item 3
13 follow-up items from review (audit fail-loud, RlsContext extraction, metadata jsonb, etc.)Items 1, 2, 3, 5, 6, 7, 8, 9, 10, 11, 12, 4, 13 shipped. Only product-decision items remain. See #1344.
Workstream C Phase 3 (public booking phone-match) + Phase 4 (patient-app frontend wiring)Shipped — see #1346 (QA).
Clinical::Practitioners::AvailabilityService references nonexistent encounters.appointment_type columnFixed #1347
Clinic-app frontend (PatientPicker 3-bucket rendering)Building blocks shipped in PR #1439. Intake-flow integration deferred.

File map

ConcernPath
Patient list servicebackend/domains/patient/services/appointments/list_service.rb
Patient list blueprintbackend/domains/patient/blueprints/appointment_blueprint.rb
Patient list controllerbackend/domains/patient/controllers/appointments_controller.rb
Federated search servicebackend/domains/clinical/services/patients/federated_search_service.rb
Federated search controllerbackend/domains/clinical/controllers/patients_controller.rb#federated_search
Auto-provision (clinic-side)backend/domains/clinical/services/orders/create_appointment_order_service.rb
Auth booking servicebackend/domains/patient/services/appointment_orders/create_service.rb
Auth booking controllerbackend/domains/patient/controllers/appointment_orders_controller.rb
Find-or-create patient (reused)backend/domains/clinical/services/patients/find_or_create_service.rb

References

On this page