ADR-002: Patient Domain Architecture
Status: Accepted
Date: 2026-04-23
Deciders: Shantanu, Kulcare Engineering Team
Context
The Kulcare platform needs a backend API that serves the standalone patient React app (located outside this monorepo at patients/). Until now, the patient app calls legacy parts/patient endpoints that rely on Devise session auth and are tenant-agnostic in a messy way (multi-tenant data mixed into a single namespace). We need a clean, well-scoped API that:
- Lets patients log their own health data (vitals, symptoms, medications, etc.) — data that has no tenant.
- Lets patients read the clinician-authored records about them across every clinic they've visited.
- Enforces patient ownership without tenant context, because a patient's identity is global (JWT carries
user_id, nottenant_id).
This ADR captures the architectural decisions for the new Patient:: domain module.
Decision 1: Patient owns all their data — storage follows ownership
Decision: Data is physically split by authorship, not by access control:
clinical.*(per-tenant, RLS-scoped) holds clinician-authored records. Visible to the authoring facility and to the patient (cross-tenant). Clinicians write; patients read.patient.*(NEW, user-scoped, no tenant RLS) holds patient-reported records — self-logged vitals, symptoms, OTC medications, allergies, health notes, documents uploaded by the patient. Visible only to the patient unless they explicitly share.
The patient is the owner of all their data regardless of where it physically lives. "Ownership" is an access-control concept, not a storage concept.
Rationale:
- Keeps clinical data authoritative in one place (no sync drift).
patient.*can grow new resources without touching tenant-scoping machinery.- The split mirrors the real-world distinction between "my doctor wrote this" and "I wrote this."
Alternatives considered:
- Single
clinical.*schema with anis_patient_reportedflag: rejected. Would require assigning every home-logged BP reading to a tenant, which breaks the semantic model. - Copying clinical data into
patient.*via background jobs: rejected. See Decision 3.
Decision 2: patient.* is scoped by user_id, not tenant
Decision: Every patient.* table anchors to identity.users.id via user_id (uuid FK). No tenant_id column, no PostgreSQL RLS. Scoping is at the application layer: Patient::Owned#for_user scope + owner-only Pundit policy.
Rationale:
- The patient JWT is tenant-agnostic. Forcing a tenant context for patient-owned reads would add complexity with no safety benefit.
- Application-level scoping is simpler to reason about and test than RLS for user-anchored data.
- Cross-tenant queries for clinical data still use RLS (Decision 3) — the two concerns don't conflict.
Decision 3: Clinical data is read cross-tenant, not duplicated
Decision: When the patient app requests clinician-authored data (their conditions, prescriptions, visit history, etc.), Patient:: controllers invoke Clinical::Patients::LongitudinalRecordService, which:
- Queries
Clinical::Patient.unscoped.where(user_id: current_user.id)to find every tenant where the patient has a clinical record. - For each such tenant, temporarily sets the PostgreSQL RLS session variable (
set_config('app.current_tenant_id', ...)) and theShared::Current.tenantcontext. - Queries the requested clinical resource within that RLS context.
- Merges, sorts, and paginates the results across tenants.
No background sync. No cached copy. clinical.* remains the single source of truth.
Rationale:
- A typical patient visits 2–5 clinics. The fan-out cost (~10ms per tenant switch) is negligible against the complexity of a sync pipeline.
- Duplication introduces drift, conflict resolution, and retraction problems (what happens when a clinic corrects or removes a record the patient has already "copied"?).
- The RLS iterator already existed for the timeline view; extending it to per-resource reads was a small delta.
Alternatives considered:
- Periodic sync into
patient.*: rejected. Every sync scheme we sketched required solving conflict resolution and retraction, which LongitudinalRecordService sidesteps entirely. - Denormalized read model: rejected for MVP. Can be added later if fan-out becomes a problem at scale.
- Direct calls to
Clinical::controllers from the patient app: rejected. Would require every clinical controller to accept two auth modes (tenant-scoped clinician vs. user-scoped patient), mixing authz models in one place — exactly the kind of code that causes cross-tenant leak bugs.
Safety budget: MAX_TENANTS_PER_REQUEST = 10. If a user somehow ends up with more clinical patient records than that, the service caps the fan-out. Typical patients are nowhere near this; the cap exists for pathological cases.
Decision 4: Patient-reported data has provenance, not side-channels
Decision: Every patient.* row carries source + source_reference:
source | Meaning |
|---|---|
self_reported (default) | Patient typed it in manually |
clinical_imported | Patient explicitly copied a specific clinical record into their diary (e.g., "save this BP reading from last visit"). Only via a deliberate UI action — never via background sync. |
device | From a connected device or wearable (Apple Health, Fitbit, etc.) |
external | Imported from an outside health record (ABDM, pharmacy receipt, etc.) |
source_reference is a jsonb pointer ({type, id, ...}) enabling back-links without cross-schema FK coupling. A CHECK constraint ensures source_reference is present whenever source != 'self_reported'.
Idempotency: Two partial unique indexes prevent duplicates:
(user_id, source_reference->>'type', source_reference->>'id') WHERE source = 'clinical_imported'— a patient can't accidentally import the same clinical record twice.(user_id, source_reference->>'vendor', source_reference->>'sample_id') WHERE source = 'device'— device syncs can be replayed safely.
Rationale:
- Device sync is a retry-heavy operation (app backgrounded, flaky network). Idempotency must be enforced by the database, not hoped for in the app.
clinical_importedneeds provenance for clinicians to weigh reliability if the patient later shares the data.- Keeping sharing out of MVP (see Decision 6) meant we didn't need a full "share log" — but
source_referencegives us the back-link we'll need when sharing lands.
Decision 5: recorded_by_user_id = user_id for v1 (caregiver mode deferred)
Decision: Every patient.* row has recorded_by_user_id (who actually entered the row) in addition to user_id (whose record it is). For v1, the concern validates recorded_by_user_id == user_id — so only the patient themselves can author their records.
Rationale:
- Caregiver / family-member authorship is a genuine future requirement (parent logging a child's symptoms, adult child managing an elder's medications).
- But it requires a consent model (
Identity::FamilyMemberConsent) that doesn't exist yet. - The column is already in place, so enabling caregiver mode later will be a validation change, not a migration.
Decision 6: Explicit sharing is deferred
Decision: Patient-reported data is visible only to the patient. There is no mechanism for a patient to share specific patient.* rows with a facility in MVP.
Rationale:
- Sharing needs a consent UX, an audit log, and semantic decisions (does shared data get copied into
clinical.*, or referenced? Is it revocable?). None of these are answered yet. - Deferring keeps the v1 surface tight. The ownership model (Decision 1) is compatible with any sharing mechanism we land on later.
Decision 7: One abstract CRUD controller, one policy base
Decision: All 10 patient.* resource controllers inherit from Patient::OwnedResourceController, which implements the full CRUD surface (index, show, create, update, destroy, restore) parameterized by resource_class + blueprint_class. Subclasses override only permitted_attributes and apply_filters. Same pattern for Patient::OwnedPolicy — 9 of the 10 resource policies are one-line subclasses.
Rationale:
- 10 resources × identical CRUD behavior × subtle filter differences = perfect fit for a parameterized base.
- Consistency is enforceable: a smoke spec verifies no subclass accidentally overrides an action method (which would silently diverge behavior).
- Adding an 11th patient-reported resource = migration + model + blueprint + one-line policy + one-line controller + routes entry. No copy-paste.
Decision 8: Patient app talks only to Patient:: endpoints
Decision: The patients/ React app hits Patient::* controllers exclusively. Even for reads of clinician-authored data, it goes through Patient::Clinical::RecordsController (a single dispatcher) or Patient::Clinical::TimelineController. Never directly to Clinical::*.
Rationale:
- Keeps patient auth and tenant auth completely separated at the HTTP layer. No controller serves both modes.
- The façade is free to add patient-specific concerns later (field redaction, response shape changes, rate limiting) without touching clinical controllers.
- Routes for
/patient/clinical/:resource_typeare regex-constrained so unknown types 404 before hitting any service code.
Consequences
Positive:
- A patient's complete health picture is reachable through one namespace.
- Clinical data stays authoritative in
clinical.*; no sync pipeline to maintain. - The
patient.*schema is easy to grow — tenant-free, one concern + one policy base, parameterized controllers. - Device-sync idempotency is DB-enforced, not app-enforced.
Negative / tradeoffs:
- Cross-tenant reads cost 1-2ms per tenant switch. Negligible for typical patients, but worth watching if a user has >10 clinical patient records (currently capped).
recorded_by_user_idis in every table but v1 enforces it to equaluser_id. Harmless but a little wasteful — kept in place to avoid a migration when caregiver mode lands.- Every
patient.*table carries source/source_reference overhead (~40 bytes per row). Acceptable for the idempotency and traceability benefits.
References
- Plan:
docs/plans/2026-04-10-011-feat-patient-domain-schema-plan.md(design) - Plan:
docs/plans/2026-04-22-feat-patient-domain-backend-implementation-plan.md(implementation) - Origin brainstorm:
docs/brainstorms/2026-04-08-patient-portal-domain-architecture-brainstorm.md - Core service:
backend/domains/clinical/services/patients/longitudinal_record_service.rb - Concern:
backend/domains/patient/concerns/owned.rb - Policy base:
backend/domains/patient/policies/owned_policy.rb - Abstract controller:
backend/domains/patient/controllers/owned_resource_controller.rb - API reference:
docs/api-reference/patient/README.md