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 legacypatient-app(React 17, decommissioned) is no longer relevant.Brainstorm:
docs/brainstorms/2026-05-06-patient-cross-tenant-appointments-brainstorm.mdPlans: Workstream B · Workstream C
What this enables
Three coupled capabilities:
-
Patient sees all appointments across all facilities —
GET /patient/appointmentsaggregates practitioner appointments (per-tenant via RLS) with vendor appointments (no tenant) into one chronological list with enriched payload. -
Clinic finds any patient at booking time —
GET /clinical/patients/federated_searchreturns 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. -
Patient books at any clinic from the patient app —
POST /patient/appointment_orderscarries theIdentity::UserJWT, sets RLS to the target tenant, auto-provisions aClinical::Patientif 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:
| Layer | Scope | Keyed on | Purpose |
|---|---|---|---|
Identity::User | Global | email, phone+country_code | Auth identity. No patient-specific data. |
Identity::Patient | Global, no tenant_id | phone_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::Patient | Per-tenant (TenantScoped + RLS) | tenant_id + mrn | Per-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)")
endVendor 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::RlsContextin #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 onpatient_id. Hasclinic_details. - Vendor — no tenant (
tenant_id IS NULL),appointable_type = 'Identity::Vendor', anchored onattendee_user_id.clinic_detailsis null; vendor'sservice_categoryexposed 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_orderscreates;PUT /patient/appointments/:id/canceland/confirmmutate state. This endpoint is read-only.
Workstream B — GET /clinical/patients/federated_search
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=AshaRequired: 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_kulcareresults never leak which other clinics a patient has visited. Only globalIdentity::Patient/Identity::Userfields are exposed.registered_viaexposes only the kind of registration source (patient_appif Identity::User exists,another_clinicotherwise). No clinic names, no clinic count.namesearch usesActiveRecord::Base.sanitize_sql_likeon 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:
- Looks for an existing
Clinical::Patientin the current tenant linked to the givenIdentity::Patient/Identity::User. If found, uses it. - If absent, calls
Clinical::Patients::FindOrCreateServiceto provision a newClinical::Patientlinked to the existing identity layer. - Race-safe: rescues
ActiveRecord::RecordNotUniqueand 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.
| Event | Type | Resource | When |
|---|---|---|---|
| B1 | PhiAccessLog (access_type: :view, access_reason: :treatment) | Identity::Patient | One row per in_kulcare result returned. Records cross-clinic identity exposure |
| B2 | PhiAccessLog (access_type: :amend, access_reason: :treatment) | Clinical::Patient | Auto-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
- Validate tenant + facility (must exist, tenant must be
active, facility must beoperational?and belong to tenant). - Open
ActiveRecord::Base.transaction. - Inside the transaction, set RLS context to the target tenant via
with_rls_context. - Look for existing
Clinical::Patientforcurrent_userin target tenant. If none:- Validate
consent_acknowledged: true(else fail with:consent_required). - Auto-provision via
Clinical::Patients::FindOrCreateServicewrapped in a savepoint (requires_new: true) so aRecordNotUniqueon concurrent insert doesn't poison the outer transaction. - Fire C1 (
Audit::ConsentAuditLog) and C2 (Audit::PhiAccessLog).
- Validate
- Delegate to
Clinical::Appointments::CreateService(existing) for the actual appointment creation. Reuses validation, slot lookup, SMS notifications, reminder scheduling. - Return enriched payload via
Patient::AppointmentBlueprint :patient_card.
Consent
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 whoseClinical::Patientis 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
| Event | Type | When |
|---|---|---|
| C1 | Audit::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 |
| C2 | Audit::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 code | HTTP | When |
|---|---|---|
validation_failed | 422 | Missing required fields |
tenant_not_found | 404 | Tenant id invalid or status != active |
facility_not_found | 404 | Facility id invalid, wrong tenant, or not operational |
consent_required | 422 | First-time-at-tenant booking without consent_acknowledged: true |
provisioning_failed | 422 | FindOrCreateService could not create the patient row |
appointment_create_failed | 422 | Downstream Clinical::Appointments::CreateService rejected (slot conflict, practitioner unavailable, etc.) |
internal_error | 422 | Anything unexpected. Real exception logged to Rails.logger only — never returned in the response body |
What's deferred
| Item | Issue |
|---|---|
Audit::Dispatch centralized service | Shipped 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 |
| Items 1, 2, 3, 5, 6, 7, 8, 9, 10, 11, 12, 4, 13 shipped. Only product-decision items remain. See #1344. | |
| Shipped — see #1346 (QA). | |
Clinical::Practitioners::AvailabilityService references nonexistent encounters.appointment_type column | Fixed #1347 |
PatientPicker 3-bucket rendering) | Building blocks shipped in PR #1439. Intake-flow integration deferred. |
File map
| Concern | Path |
|---|---|
| Patient list service | backend/domains/patient/services/appointments/list_service.rb |
| Patient list blueprint | backend/domains/patient/blueprints/appointment_blueprint.rb |
| Patient list controller | backend/domains/patient/controllers/appointments_controller.rb |
| Federated search service | backend/domains/clinical/services/patients/federated_search_service.rb |
| Federated search controller | backend/domains/clinical/controllers/patients_controller.rb#federated_search |
| Auto-provision (clinic-side) | backend/domains/clinical/services/orders/create_appointment_order_service.rb |
| Auth booking service | backend/domains/patient/services/appointment_orders/create_service.rb |
| Auth booking controller | backend/domains/patient/controllers/appointment_orders_controller.rb |
| Find-or-create patient (reused) | backend/domains/clinical/services/patients/find_or_create_service.rb |
References
- Brainstorm:
docs/brainstorms/2026-05-06-patient-cross-tenant-appointments-brainstorm.md - Plans:
docs/plans/2026-05-06-feat-clinic-federated-patient-search-plan.md,docs/plans/2026-05-06-feat-patient-app-authenticated-booking-plan.md - Polymorphic Appointment doc:
docs/guides/developer/clinical/appointments/polymorphic-appointable.md - Patient domain README:
docs/guides/developer/patient-domain/README.md - PRs: #1335 (A), #1342 (A follow-up), #1341 (B), #1343 (B follow-up), #1345 (C), #1348 (C follow-up)