Architectural Decisions
Purpose: Document key architectural decisions in ADR (Architecture Decision Record) format
ADR-001: Multi-Tenancy Strategy
Context
The platform serves multiple healthcare organizations that must have complete data isolation. The current system uses implicit multi-tenancy via clinic_id foreign keys without database-level enforcement.
Decision
Use shared database with Row-Level Security (RLS) strategy.
Options Considered
| Option | Pros | Cons |
|---|---|---|
| Separate databases per tenant | Highest isolation, easy backup/restore | Complex operations, connection pooling issues |
| Separate schemas per tenant | Good isolation, PostgreSQL native | Schema proliferation, migration complexity |
| Shared schema with tenant_id | Simple, single migration path | Requires RLS for isolation |
| Hybrid (our choice) | Best of both: domain schemas + shared tenant_id | Requires careful design |
Approach
- Single PostgreSQL database
- Multiple schemas for domain separation (not tenant separation)
tenant_idcolumn on all tenant-scoped tables- Row-Level Security policies for automatic tenant filtering
- Session variable for tenant context
Consequences
- Positive: Simpler operations, single backup, shared connection pool
- Positive: Database-level isolation (not just application-level)
- Negative: Must set tenant context on every request
- Negative: Complex cross-tenant queries (by design - these should be rare)
ADR-002: PostgreSQL Schema-per-Domain
Context
The current database has ~501 tables in a single schema with naming conventions as the only organization method.
Decision
Use PostgreSQL schemas for domain separation.
Schema Organization
| Schema | Purpose | Tenant Scoped |
|---|---|---|
reference | Global lookup/catalog data | No |
identity | Who provides what, where: providers, organizations, facilities, services | Partial |
clinical | Patient care, encounters, medications | Yes |
billing | Invoices, payments, subscriptions | Yes |
growth | Campaigns, leads, communications | Yes |
connect | Professional networking, jobs | Cross-tenant |
integration | External services, ABDM, webhooks | Yes |
audit | Activity logs, PHI access, LLM logs | Yes |
Consequences
- Positive: Clear domain boundaries, easier reasoning
- Positive: Schema-level permissions possible
- Positive: Can grant different access per schema
- Negative: Cross-schema joins require schema prefixes
- Negative: Slightly more complex migrations
ADR-003: Facility vs Clinic Naming
Context
The current model uses "Clinic" for all healthcare facilities, including hospitals, surgery centers, therapy centers, and virtual practices. This causes semantic confusion and limits proper classification.
Decision
Rename the core entity from Clinic to Facility.
Terminology Change
| Current | New | FHIR Mapping |
|---|---|---|
| Clinic | Facility | Organization + Location |
| ClinicGroup | Organization | Organization (parent) |
| ClinicLocation | FacilityLocation | Location |
| ClinicDoctor | PractitionerRole | PractitionerRole |
| ClinicService | HealthcareService | HealthcareService |
Consequences
- Positive: Generic term that fits all facility types
- Positive: Better FHIR alignment
- Positive: Clearer mental model for users and developers
- Negative: Breaking change requiring code updates
- Negative: User education needed
ADR-004: Multi-Dimensional Facility Classification
Context
The current is_hospital boolean cannot capture the complexity of healthcare facilities that may have:
- OPD (outpatient department)
- IPD (inpatient department)
- Operating theaters
- Various ownership models
- Different specialty scopes
Decision
Use multi-dimensional classification instead of a single type field.
Classification Dimensions
Dimension 1: Service Level
opd_only - Outpatient services only
opd_daycare - OPD + Day care/observation (< 24 hours)
opd_ipd - OPD + Inpatient (no surgery)
opd_ipd_surgery - OPD + IPD + Surgical services
ipd_only - Inpatient only (rehab, nursing home)
surgery_center - Ambulatory surgery (no overnight stay)Dimension 2: Facility Scale
solo_practice - Single provider, no beds
group_practice - Multiple providers, no beds
micro_facility - 1-5 beds, 0-1 OTs
small_facility - 6-25 beds, 1-2 OTs
medium_facility - 26-100 beds, 2-5 OTs
large_facility - 100+ beds, 5+ OTsDimension 3: Ownership Model
doctor_owned - Individual practitioner owned
partnership - Doctor partnership/group
corporate - Corporate/chain owned
trust - Trust/charitable organization
government - Government operatedDimension 4: Specialty Scope
single_specialty - One specialty only
limited_specialty - Few related specialties
multi_specialty - Multiple unrelated specialties
super_specialty - Advanced subspecialty care
general - General/primary careConsequences
- Positive: Accurately captures all facility types
- Positive: Enables proper feature exposure per facility type
- Positive: Better reporting and analytics
- Negative: More fields to populate during onboarding
- Negative: Requires validation logic for valid combinations
ADR-005: FHIR R4 Resource Mapping
Context
Healthcare data interoperability requires FHIR R4 compliance. The current system has partial FHIR alignment for ABDM (India) but lacks complete resource mapping.
Decision
Map core entities to FHIR R4 resources for the clinical schema.
Resource Mapping
| Internal Entity | FHIR Resource | Notes |
|---|---|---|
| Tenant | N/A | Platform concept, not FHIR |
| Organization | Organization | With parent hierarchy (partOf) |
| Facility | Organization + Location | Dual mapping |
| FacilityLocation | Location | Physical address |
| Practitioner | Practitioner | Global identity |
| PractitionerRole | PractitionerRole | Facility-specific role |
| HealthcareService | HealthcareService | Services offered |
| Patient | Patient | Core identity |
| Encounter | Encounter | Clinical visit |
| EpisodeOfCare | EpisodeOfCare | Treatment program |
| CarePlan | CarePlan | Treatment planning |
| Goal | Goal | Desired outcomes |
| Condition | Condition | Diagnoses/problems |
| Observation | Observation | Vitals, measurements |
| MedicationRequest | MedicationRequest | Prescriptions |
| ServiceRequest | ServiceRequest | Lab/imaging orders |
| DiagnosticReport | DiagnosticReport | Results |
Key Patterns
- LHR as Computed View - Not a FHIR resource, aggregated on demand
- Identifier Arrays - JSONB for multiple identifiers (ABHA, NPI, etc.)
- Coding Systems - ICD-10, SNOMED, LOINC support
- Resource Status - Align with FHIR status lifecycles
Consequences
- Positive: Interoperability with ABDM, NHS, CMS
- Positive: Industry-standard data model
- Positive: Clear semantics for clinical data
- Negative: Learning curve for team
- Negative: Some FHIR complexity may not be needed
ADR-006: Cross-Tenant Connect Product
Context
Kulcare Connect is a professional networking product where practitioners can:
- Create public profiles
- Network with other practitioners
- Apply for jobs across organizations
This requires data that is NOT tenant-isolated.
Decision
Create a separate connect schema that operates cross-tenant.
Design Principles
-
Practitioner vs Profile
identity.practitioners- Core identity (can belong to multiple tenants)connect.professional_profiles- Public-facing profile (1:1 with practitioner)
-
No tenant_id on Connect tables
- Profiles are personal to the practitioner
- Connections cross organizational boundaries
- Jobs can be from any tenant (facility)
-
Linking Pattern
- Professional profiles link to practitioners
- Practitioners have roles at facilities (tenant-scoped)
- Jobs link to facilities (tenant-scoped posting)
Consequences
- Positive: Practitioners maintain single profile across employers
- Positive: Natural networking across organizations
- Positive: Job board works across the platform
- Negative: Different access patterns than other schemas
- Negative: Profile visibility requires careful permission design
ADR-007: AI-Assisted Care Data Organization
Context
The platform includes AI-assisted features:
- AI Scribe for transcription
- AI suggestions for clinical decision support
- LLM call logging for audit
Decision
Split AI data between clinical and audit schemas.
Data Placement
| Table | Schema | Rationale |
|---|---|---|
| transcription_sessions | clinical | Linked to encounters, clinical workflow |
| ai_suggestions | clinical | Linked to encounters, clinical content |
| llm_call_logs | audit | Audit trail, not clinical content |
| clinic_ai_settings | identity | Configuration per facility |
AI Suggestion Linkage
Encounter
|
+-- TranscriptionSession
| |
| +-- TranscriptionSegments
|
+-- AiSuggestions (linked via encounter_id)
|
+-- Accepted/Rejected status
+-- Modifications trackedConsequences
- Positive: Clinical AI data near clinical workflow
- Positive: Audit data properly isolated
- Positive: Clear separation of concerns
- Negative: Some cross-schema queries needed
ADR-008: Reference Data Organization
Context
Reference data (lookup tables, catalogs, coding systems) needs to be shared globally without tenant scope.
Decision
Create a dedicated reference schema without tenant_id.
Categories
| Category | Examples | Governance |
|---|---|---|
| Medical Coding | ICD codes, SNOMED concepts, LOINC codes | External standards |
| Catalog | Drugs, procedures, lab tests | Platform-managed |
| Geographic | Countries, states, cities | Platform-managed |
| Facility Types | Service levels, ownership models | Platform-defined |
| Specialties | Medical specialties hierarchy | Platform-managed |
Consequences
- Positive: Single source of truth for reference data
- Positive: No tenant duplication
- Positive: Easy to update globally
- Negative: No tenant customization of reference data
- Negative: Must be careful with data that might need customization
ADR-009: Audit and Compliance Data
Context
Healthcare platforms have stringent audit requirements:
- HIPAA (US) - PHI access logging
- DPDP (India) - Data protection
- General audit trails
Decision
Create a dedicated audit schema with comprehensive logging.
Audit Categories
| Table | Purpose | Retention |
|---|---|---|
| activity_logs | General user actions | 2 years |
| phi_access_logs | Patient data access | 6 years (HIPAA) |
| llm_call_logs | AI/LLM audit trail | 2 years |
| document_audit_logs | Clinical document changes | Permanent |
Log Structure
activity_logs:
- tenant_id, user_id, user_email
- action (create, read, update, delete)
- resource_type, resource_id
- old_values, new_values (JSONB)
- ip_address, user_agent
- created_at
phi_access_logs:
- tenant_id, user_id
- patient_id, resource_type, resource_id
- access_type (view, download, print, share)
- access_reason
- created_atConsequences
- Positive: Comprehensive audit trail
- Positive: Meets HIPAA/DPDP requirements
- Positive: Enables compliance reporting
- Negative: Storage growth for high-volume systems
- Negative: Query performance for large audit tables
ADR-010: Enterprise Benefits as Cross-Tenant Schema
Context
Enterprises (employers/companies) provide healthcare benefits to their employees who are patients at various healthcare facilities. The question is whether enterprises should be:
- A new tenant type (like facilities)
- A cross-tenant entity (like Connect)
- A separate database (like Pharma)
Decision
Implement enterprises as a cross-tenant schema within the main Kulcare database.
Rationale
| Option | Pros | Cons |
|---|---|---|
| New tenant type | RLS isolation | Employees span multiple facility tenants |
| Cross-tenant (chosen) | Natural employee-patient linkage | Different access patterns |
| Separate database | Full isolation | Requires cross-DB joins for patient data |
Why cross-tenant:
- Employees are patients at facilities across multiple tenants
- Insurance claims reference clinical encounters (same DB)
- Empaneled providers are practitioners (same DB)
- Benefits plans span multiple contracted facilities
Design
enterprise schema (NO tenant_id)
├── employers # Company entities
├── employer_facilities # Which facilities accept this employer
├── employer_employees # Links to clinical.patients (cross-tenant)
├── benefit_plans # ICHRA-style plans
├── employee_enrollments # Enrollment records
├── empaneled_providers # Negotiated rates with practitioners
└── wellness_programs # Health camps, initiativesConsequences
- Positive: Natural integration with clinical and billing
- Positive: Single database for all patient-related data
- Positive: Employee-patient linkage is straightforward
- Negative: Different access pattern than tenant-scoped schemas
- Negative: Requires employer-based permission system
ADR-011: Pharma Relations in Separate Database
Context
Pharmaceutical companies need to manage doctor engagement, marketing campaigns, and clinical trials. This data has different characteristics than clinical healthcare data.
Decision
Create a separate pharma_db database for pharmaceutical relations.
Rationale
| Factor | Kulcare DB | Pharma DB |
|---|---|---|
| Tenant model | Facilities | Pharma companies |
| Data type | PHI (patient data) | B2B marketing |
| Compliance | HIPAA/DPDP | Sunshine Act, FDA |
| Scale | Moderate | High-volume campaigns |
| Integration | Direct joins | API sync |
Why separate:
- Pharma companies are not healthcare facilities
- Clinical trials require strict isolation (FDA/DCGI)
- High-volume campaign data could impact clinical DB performance
- Sunshine Act compliance requires dedicated audit trail
- Marketing data should not mix with PHI
Integration Pattern
Kulcare DB Pharma DB
----------- ---------
identity.practitioners -------> practitioners_sync (read-only, anonymized)
reference.drugs --------------> drugs_sync (read-only)
Integration via:
- Scheduled API sync (hourly for practitioners)
- Event-driven sync (immediate for drug catalog updates)
- No direct database linksConsequences
- Positive: Clear data isolation
- Positive: Independent scaling
- Positive: Clinical trial compliance
- Negative: Cross-database queries not possible
- Negative: Data duplication for reference data
- Negative: More complex deployment
ADR-012: Vendor Model for Unlicensed Service Providers
Context
Healthcare facilities work with two types of service providers:
- Licensed practitioners: Doctors, nurses, physiotherapists, dietitians
- Unlicensed providers: Yoga instructors, lamaze instructors, wellness coaches
The current Doctor model uses is_doctor = false for non-physicians, but this conflates licensed allied health with unlicensed providers.
Decision
Create a separate Vendor entity for unlicensed service providers.
Classification
| Provider Type | Entity | License Required | Examples |
|---|---|---|---|
| Physician | Practitioner | Yes (Medical Council) | MD, MBBS |
| Allied Health | Practitioner | Yes (Allied Council) | PT, RD, OT |
| Unlicensed | Vendor | No (certifications optional) | Yoga, Lamaze, Wellness |
Design
identity.vendors
├── id, tenant_id
├── name, business_name
├── vendor_type: individual | business
├── service_category: wellness | fitness | education | support
├── services: JSONB ["Prenatal Yoga", "Lamaze Classes"]
├── credentials: JSONB (non-medical certifications)
└── status: active | inactive
identity.facility_vendors
├── facility_id
├── vendor_id
├── contract_type: contracted | preferred | occasional
├── rate_card: JSONBConsequences
- Positive: Clean separation between licensed and unlicensed
- Positive: Vendors can be included in service packages
- Positive: Facilities can contract with independent vendors
- Negative: Another entity to manage
- Negative: Requires vendor onboarding workflow
ADR-013: Service Packages for Bundled Offerings
Context
Healthcare facilities offer bundled service packages (e.g., "Complete Maternity Care") that include:
- Multiple practitioner consultations
- Diagnostic services
- Vendor services (yoga, lamaze)
The current HealthcareService model handles individual services but not packages.
Decision
Add ServicePackage and PackageComponent entities to support bundled offerings.
Design
identity.service_packages
├── id, tenant_id, facility_id
├── name: "Complete Maternity Care"
├── package_type: fixed | subscription
├── duration_days: 270
├── base_price, currency
├── is_active
identity.package_components
├── package_id
├── component_type: service | practitioner_session | vendor_service
├── healthcare_service_id (for service type)
├── vendor_id (for vendor_service type)
├── quantity: 10
├── unit_pricePackage Example
Complete Maternity Care Package:
base_price: 50000 INR
duration: 270 days
components:
- type: service
service: OB-GYN Consultation
quantity: 12
- type: service
service: Ultrasound
quantity: 4
- type: vendor_service
vendor: Yoga Studio
service: Prenatal Yoga
quantity: 20
- type: vendor_service
vendor: Lamaze Instructor
service: Lamaze Classes
quantity: 8Consequences
- Positive: Facilities can create comprehensive care packages
- Positive: Vendor services can be bundled with clinical services
- Positive: Clear pricing and benefit tracking
- Negative: Package pricing complexity
- Negative: Requires benefit tracking across components
ADR-014: Clinical Trial Anonymization Pattern
Context
Clinical trials require patient enrollment tracking, but PHI cannot be shared with pharmaceutical companies. The pharma_db needs to track enrollment without accessing patient identity.
Decision
Use anonymized patient IDs for clinical trial enrollment in pharma_db.
Pattern
Kulcare DB Pharma DB
----------- ---------
clinical.patients trials.trial_enrollments
├── id: "patient-uuid" ├── id
├── [PHI fields] ├── trial_id
├── anonymous_id: "anon-xyz" (NOT patient_id)
clinical.trial_consents ├── site_id
├── patient_id ├── enrollment_status
├── trial_id (reference only) └── enrollment_date
├── consent_status
├── anonymous_id: "anon-xyz" <-------> (shared identifier)
└── consented_atProcess
-
Consent Collection (Kulcare DB)
- Patient consents to trial at facility
- Generate unique
anonymous_id - Store consent in
clinical.trial_consents
-
Enrollment Notification (API)
- Notify pharma_db of enrollment via API
- Pass only:
anonymous_id,trial_id,site_id,enrollment_date - No PHI transmitted
-
Trial Tracking (Pharma DB)
- Track progress using
anonymous_id - No way to identify patient from pharma_db
- Track progress using
Consequences
- Positive: PHI never enters pharma_db
- Positive: Enrollment can be tracked for trial management
- Positive: Meets FDA/DCGI requirements for trial isolation
- Negative: Cannot re-identify patients without Kulcare DB
- Negative: Requires consent workflow in clinical app
ADR-015: Federated Deployment Model
Status: Approved Date: 2025-12-29 Context: On-Premise Installations with Cloud Sync
Context
Many hospitals, especially in India and GCC regions, prefer or require on-premise installations due to:
- Unreliable internet connectivity
- Data residency requirements
- Desire for local infrastructure control
Meanwhile, patients expect portable health records accessible via a patient portal regardless of where they received care.
Decision
Implement a federated deployment model with:
-
Dual Deployment Options
- Cloud SaaS (existing multi-tenant architecture)
- On-Premise (local Rails app + PostgreSQL, single-organization)
-
Outbound Sync to Central Cloud
- Clinical data syncs FROM on-premise TO Kulcare central cloud
- Outbound-only for clinical data (on-premise is source of truth)
- Bi-directional for appointments (cloud-proxied booking)
-
Master Patient Index (MPI)
- Global patient identity across all facilities
- Matching via ABHA ID, phone+DOB+name, or fuzzy matching
- Enables unified patient portal view
-
New
federationSchema (Central Cloud only)- 12 tables for facility registry, patient identity, sync tracking
- No RLS (facility-based access control)
Alternatives Considered
-
Full Bi-Directional Sync
- Rejected: Too complex, conflict resolution challenging
- Clinical data should have single source of truth
-
No Patient Portal for On-Premise
- Rejected: Patients lose data portability
- Counter to patient data ownership principle
-
Peer-to-Peer Facility Sync
- Rejected: Requires all facilities to be online
- Central cloud hub is simpler
Architecture
On-Premise Facility Central Cloud
------------------- -------------
Local Rails App Full SaaS Platform
Local PostgreSQL Multi-tenant PostgreSQL
| |
+-- Encounters +-- Master Patient Index
+-- Prescriptions -----> Sync ----> +-- Federated Clinical Data
+-- Vitals +-- Patient Portal
+-- Documents +-- AI Services (proxy)
+-- Appointments <---- Bookings <--- +-- Online BookingSync Protocol
-
Clinical Data (Outbound Only)
- On-premise syncs to cloud via batch API
- Signed payloads, acknowledgment-based delivery
- Resilient to intermittent connectivity
-
Appointments (Bi-Directional)
- On-premise syncs available slots to cloud
- Patient books via cloud portal
- Cloud pushes booking to on-premise for confirmation
- On-premise confirms/rejects (source of truth)
Patient Identity Matching
| Method | Criteria | Confidence |
|---|---|---|
abha | ABHA ID match | 1.0 |
exact | Phone + DOB + Name | 0.95 |
fuzzy | Phone + similar name | 0.7 |
manual | Human verified | 1.0 |
Feature Availability
| Feature | Cloud | On-Premise | Cloud-Proxied |
|---|---|---|---|
| Core CMS | Yes | Yes | No |
| Online Booking | Yes | Yes | Yes |
| Patient Portal | Yes | Yes (read-only) | Yes |
| SMS/WhatsApp | Yes | No | Yes |
| AI Scribe | Yes | No | Yes |
| Analytics | Yes | No | Yes |
| Payment Gateway | Yes | No | No |
Consequences
Positive:
- Hospitals get local infrastructure reliability
- Patients get unified health records across all facilities
- Kulcare maintains patient relationship and can deliver premium services
- Source code protection via RubyEncoder (not a schema concern)
Negative:
- Increased complexity (two deployment modes)
- MPI requires careful identity matching
- On-premise installations need periodic connectivity for sync
- Feature parity challenges between modes
Related Documentation
- Federated Deployment - Full architecture
- Federation Schema - Entity definitions
ADR-016: Domains Directory Architecture for New Schema
Status: Approved Date: 2025-01-05 Context: Code Organization for New Schema Implementation
Context
The existing backend uses a parts/ directory structure for modular code organization. Each part contains models, controllers, interactors, and other components. However, the new schema implementation requires:
- Different patterns: Blueprinter instead of ActiveModelSerializers, RLS-aware models, structured errors
- Clean separation: New code should not mix with legacy code during migration
- Namespace isolation: New models should be namespaced (e.g.,
Identity::Tenantvs legacyClinic)
Decision
Create a new domains/ directory parallel to parts/ with Zeitwerk push_dir namespace configuration.
Directory Structure
backend/
├── parts/ # Legacy code (leave untouched)
│ ├── clinic/
│ ├── doctor/
│ └── ...
│
└── domains/ # New schema code
├── shared/ # Base classes and concerns
│ ├── base_controller.rb -> Shared::BaseController
│ ├── base_blueprint.rb -> Shared::BaseBlueprint
│ ├── base_policy.rb -> Shared::BasePolicy
│ ├── base_interactor.rb -> Shared::BaseInteractor
│ ├── errors.rb -> Shared::Errors module
│ └── concerns/
│ ├── tenant_context.rb -> Shared::Concerns::TenantContext
│ └── schema_error_handler.rb
│
├── identity/ # Identity domain (matches identity.* schema)
│ ├── models/
│ │ ├── tenant.rb -> Identity::Tenant
│ │ ├── user.rb -> Identity::User
│ │ └── ...
│ ├── controllers/
│ ├── blueprints/
│ ├── policies/
│ └── interactors/
│
├── reference/ # Reference domain (matches reference.* schema)
│ └── models/
│
└── clinical/ # Clinical domain (matches clinical.* schema)
└── models/Zeitwerk Configuration
Configured in config/application.rb using Rails initializer with before: :set_autoload_paths:
initializer 'domains.configure_autoloader', before: :set_autoload_paths do
domains_path = Rails.root.join('domains')
next unless domains_path.exist?
loader = Rails.autoloaders.main
Dir.glob(domains_path.join('*')).each do |domain_dir|
next unless File.directory?(domain_dir)
domain_name = File.basename(domain_dir)
namespace = domain_name.camelize.constantize rescue Object.const_set(domain_name.camelize, Module.new)
if domain_name == 'shared'
# Shared domain: files directly in directory
loader.push_dir(domain_dir, namespace: namespace)
else
# Regular domains: subdirectories per component type
%w[models controllers blueprints policies interactors fhir].each do |subdir|
subdir_path = File.join(domain_dir, subdir)
loader.push_dir(subdir_path, namespace: namespace) if File.directory?(subdir_path)
end
end
end
endKey Design Choices
- Separate from parts/: Legacy code remains untouched during migration
- Domain namespaces:
Identity::,Reference::,Clinical::match PostgreSQL schemas - Shared infrastructure: Common base classes avoid duplication
- Zeitwerk push_dir: Automatic namespacing without
module Identitywrappers in every file
Alternatives Considered
| Option | Pros | Cons |
|---|---|---|
| Modify parts/ | No new structure | Mixes legacy and new patterns |
| Nested namespaces | Clear hierarchy | Domains::Identity::Tenant too verbose |
| Standard autoload | Simple config | Requires module Identity in every file |
| push_dir (chosen) | Clean namespacing | Requires initializer timing |
Migration Strategy
- Phase 1: Create domains/ with shared infrastructure
- Phase 2: Implement Identity domain models
- Phase 3: Implement Reference domain
- Phase 4: Create API layer (controllers, blueprints)
- Future: Migrate parts/ code incrementally, eventually deprecating parts/
Consequences
Positive:
- Clean separation of new schema code
- Models automatically namespaced to match PostgreSQL schemas
- Legacy code unaffected during migration
- Can run both systems in parallel during transition
Negative:
- Two code organization systems temporarily
- Developers must understand which directory to use
- Some duplicate concepts (e.g., Clinic in parts/ and Facility in domains/)
ADR-017: Hybrid Service Object Pattern for API Layer
Status: Approved Date: 2025-01-05 Context: API Layer Business Logic Organization
Context
The existing parts/ codebase uses the Interactor pattern (via interactor-rails gem) for business logic. For the new domains/ API layer, we needed to decide whether to:
- Continue using Interactors
- Use direct controller logic (like Reference domain)
- Use a hybrid approach with Service Objects
Decision
Implement a Hybrid Service Object Pattern that combines direct controller logic for simple CRUD with Service Objects for complex operations.
Options Considered
| Pattern | Used In | Pros | Cons |
|---|---|---|---|
| Interactor (parts/) | TwoFactor::Enable, billing | Standardized, composable via Organizer | Gem dependency, context mutation, overkill for simple CRUD |
| Direct Controller | Reference domain | Simple, Rails-idiomatic | Logic in controllers hard to test/reuse |
| Service Objects (chosen) | Identity domain | Testable, no gem, explicit returns | Need to define pattern |
Pattern Implementation
Decision Matrix
| Operation Type | Pattern to Use | Example |
|---|---|---|
| Simple CRUD (list, show, create, update, delete) | Direct Controller | GET /tenants, POST /facilities |
| Create with related models | Service Object | Create organization with owner membership |
| Multi-step validation/business logic | Service Object | Update practitioner roles across facilities |
| External API integration | Service Object | FHIR export, ABDM sync |
Service Object Base
# domains/identity/services/base_service.rb
module Identity
module Services
class BaseService
Result = Struct.new(:success?, :data, :errors, keyword_init: true)
def self.call(...)
new(...).call
end
private
def success(data)
Result.new(success?: true, data: data, errors: [])
end
def failure(errors)
Result.new(success?: false, data: nil, errors: Array(errors))
end
end
end
endController Integration
# In controller
def create
result = Services::Practitioners::CreateService.call(
facility: current_facility,
user: find_or_create_user,
params: practitioner_params
)
handle_service_result(result, PractitionerBlueprint, created: true)
endAPI Response Format: snake_case Keys
All API responses from domains/ use snake_case keys (Ruby/Rails convention).
Configuration
# config/initializers/blueprinter.rb
Blueprinter.configure do |config|
config.datetime_format = ->(datetime) { datetime&.iso8601 }
config.sort_fields_by = :definition
# No key transformation - use snake_case as-is (Rails convention)
config.default_transformers = []
endExample Output
{
"success": true,
"data": {
"id": "uuid-here",
"created_at": "2024-01-15T10:30:00Z",
"parent_id": "uuid-parent",
"is_active": true
}
}Standard Blueprint Views
All blueprints follow a consistent view pattern:
| View | Purpose | Fields |
|---|---|---|
normal | Default list/show response | Core fields for display |
detailed | Full resource details | All fields including computed |
select | Dropdown/autocomplete | id, display name only |
fhir | FHIR R4 resource format | FHIR-compliant structure |
Pundit Policies with Tenant Scopes
Authorization uses Pundit with tenant-aware scopes:
class Identity::FacilityPolicy < Shared::BasePolicy
def index?
tenant_member?
end
def create?
has_permission?(:manage_facilities)
end
class Scope < Scope
def resolve
return scope.none unless tenant_member?
scope.where(tenant_id: user.current_tenant_id)
end
end
endConsequences
Positive:
- Most CRUD stays Rails-idiomatic and simple
- Services are easy to test in isolation
- No new gem dependencies
- Consistent with Reference domain pattern
- Explicit Result pattern vs mutable context
Negative:
- Two patterns to understand (controller vs service)
- Need to document when to use which
- Less uniform than pure interactor approach
Migration Path
- If complexity grows significantly, Service Objects can be converted to Interactors
- The
.call()interface is identical - Existing
parts/interactors can be referenced for complex orchestration patterns
ADR-018: Tenant Context and Request-Scoped State Management
Status: Approved Date: 2025-01-06 Context: ApplicationRecord Integration for Multi-Tenant Operations
Context
The new schema uses Row-Level Security (RLS) at the database level via PostgreSQL session variables. However, application-level code also needs:
- Request-scoped tenant context - Access current tenant/user across controllers, models, and services
- Automatic model scoping - Models should automatically filter by tenant without explicit tenant_id in every query
- Background job context - Sidekiq jobs need tenant context for database operations
- Authentication enforcement - Controllers need a standard way to require authentication
Decision
Implement a layered tenant context system with four components:
Shared::Current- ActiveSupport::CurrentAttributes for request-scoped storageTenantScopedconcern - Automatic model scoping with default_scope- Sidekiq middleware - Context propagation for background jobs
TenantAwareJobconcern - Explicit tenant context helpers for jobs
Implementation
1. Shared::Current (Request-Scoped Storage)
# domains/shared/current.rb
module Shared
class Current < ActiveSupport::CurrentAttributes
attribute :tenant, :user, :membership, :request_id
def tenant=(value)
super
set_rls_context if value.present?
end
def owner?
membership&.owner?
end
def admin?
membership&.admin?
end
private
def set_rls_context
return unless tenant&.id
ActiveRecord::Base.connection.execute(
ActiveRecord::Base.sanitize_sql([
"SELECT set_config('app.current_tenant_id', ?, true)",
tenant.id.to_s
])
)
end
end
endKey Features:
- Thread-safe via ActiveSupport::CurrentAttributes
- Auto-sets PostgreSQL RLS context when tenant is assigned
- Provides role-checking helpers (
owner?,admin?,staff?)
2. TenantScoped Concern (Model Auto-Scoping)
# domains/shared/concerns/tenant_scoped.rb
module Shared::Concerns::TenantScoped
extend ActiveSupport::Concern
included do
belongs_to :tenant, class_name: 'Identity::Tenant'
default_scope do
if Shared::Current.tenant.present?
where(tenant_id: Shared::Current.tenant.id)
else
all
end
end
before_validation :set_tenant_from_current, on: :create
validates :tenant_id, presence: true
end
class_methods do
def unscoped_by_tenant
unscope(where: :tenant_id)
end
def for_tenant(tenant)
unscoped_by_tenant.where(tenant_id: tenant.id)
end
end
endKey Features:
- Auto-filters queries when
Shared::Current.tenantis set - Auto-assigns
tenant_idon create from current context - Provides
unscoped_by_tenantfor admin operations
3. Sidekiq Middleware (Background Job Context)
# config/initializers/sidekiq_tenant_middleware.rb
module Sidekiq::Middleware
class TenantContextClient
def call(_worker_class, job, _queue, _redis_pool)
job['tenant_id'] = Shared::Current.tenant&.id
job['user_id'] = Shared::Current.user&.id
yield
end
end
class TenantContextServer
def call(_worker, job, _queue)
setup_tenant_context(job)
yield
ensure
Shared::Current.reset
clear_rls_context
end
end
endKey Features:
- Client middleware captures context when job is enqueued
- Server middleware restores context when job is processed
- Automatic cleanup prevents context leakage
4. TenantAwareJob Concern (Explicit Job Context)
# domains/shared/concerns/tenant_aware_job.rb
module Shared::Concerns::TenantAwareJob
class_methods do
def perform_for_tenant(tenant, *args)
Shared::Current.tenant = tenant
perform_later(*args)
ensure
Shared::Current.reset
end
end
private
def with_tenant(tenant_or_id)
# Execute block within specific tenant context
end
def each_tenant(scope = Identity::Tenant.all)
# Iterate over tenants with context switching
end
endKey Features:
perform_for_tenant- Enqueue with explicit tenantwith_tenant- Switch context within a jobeach_tenant- Iterate across tenants
Data Flow
HTTP Request
│
├── TenantContext concern (controller)
│ ├── Extracts tenant_id from JWT
│ ├── Sets Shared::Current.tenant
│ ├── Sets Shared::Current.user
│ └── Sets Shared::Current.membership
│
├── TenantScoped models (auto-scoped queries)
│ └── default_scope filters by Shared::Current.tenant
│
└── Sidekiq job enqueued
└── TenantContextClient captures tenant_id/user_id
Background Job
│
├── TenantContextServer restores context
│ ├── Loads tenant from job payload
│ ├── Sets Shared::Current.tenant
│ └── Sets RLS context
│
└── TenantScoped models work correctlyModels Updated
The following Identity models now include TenantScoped:
| Model | Purpose |
|---|---|
Identity::Organization | Healthcare organizations |
Identity::Facility | Physical/virtual locations |
Identity::PractitionerRole | Practitioner-facility assignments |
Identity::HealthcareService | Services at facilities |
Identity::UserMembership | User-tenant-role links |
Alternatives Considered
| Option | Pros | Cons |
|---|---|---|
| Thread.current storage | Simple | Not request-scoped, leaks across requests |
| RequestStore gem | Dedicated gem | Another dependency |
| Pass tenant explicitly | Explicit | Verbose, easy to forget |
| CurrentAttributes (chosen) | Rails-native, clean API | Requires careful reset |
Consequences
Positive:
- Clean API for accessing current tenant context
- Models automatically scope to current tenant
- Background jobs work seamlessly with tenant context
- Layered with RLS for defense-in-depth isolation
- No external gem dependencies
Negative:
- Must remember to reset context in after_action
- default_scope can be surprising (mitigated by
unscoped_by_tenant) - Job payload size increases slightly (tenant_id, user_id)
Files Created/Modified
| File | Purpose |
|---|---|
domains/shared/current.rb | Request-scoped storage |
domains/shared/concerns/tenant_scoped.rb | Model auto-scoping |
domains/shared/concerns/tenant_aware_job.rb | Job context helpers |
config/initializers/sidekiq_tenant_middleware.rb | Background job context |
domains/shared/concerns/tenant_context.rb | Updated to use Shared::Current |
domains/shared/base_controller.rb | Added require_authentication! |
domains/identity/controllers/base_controller.rb | Added before_action |
ADR-019: Clinical Schema FHIR-First Design
Status: Approved Date: 2025-01-10 (Updated with Phase 4 and Phase 5) Context: Clinical Domain Implementation with FHIR R4 Compliance
Context
The clinical schema needs to store patient health information including:
- Patient demographics and identifiers
- Multi-facility registrations with facility-specific MRNs
- Insurance coverage tracking
- Clinical encounters (OPD visits, IPD admissions, telemedicine)
- Observations (vitals, lab results, measurements)
- Conditions (diagnoses, problem lists)
- Allergies and intolerances
- Medication requests (prescriptions) with dosage instructions
- Medication administration tracking for IPD
- Service requests (lab orders, imaging, procedures)
- Diagnostic reports (lab results, imaging reports)
- Care plans with activities and versioning
- Patient health goals with achievement tracking
- Episodes of care for long-term treatment programs
- Episode-specific treatment goals
- Progress notes for episode documentation
The schema must support:
- FHIR R4 interoperability for ABDM, NHS, and other health exchanges
- Multi-tenant isolation via RLS
- Composite foreign keys for referential integrity
- PostgreSQL JSONB for flexible clinical data structures
Decision
Implement a FHIR-first clinical schema with:
- 17 Core Tables: Patient, PatientFacility, PatientInsuranceCoverage, Encounter, EncounterParticipant, Observation, Condition, AllergyIntolerance, MedicationRequest, MedicationAdministration, ServiceRequest, DiagnosticReport, CarePlan, Goal, EpisodeOfCare, TreatmentGoal, ProgressNote
- PostgreSQL Enums: Type-safe status values matching FHIR ValueSets
- JSONB Columns: Flexible storage for FHIR-structured data (CodeableConcept, Identifiers, etc.)
- Composite Foreign Keys:
(tenant_id, entity_id)pattern for cross-schema references - State Machines: Status transitions enforced at model level for Encounter, MedicationRequest, ServiceRequest, CarePlan, Goal, EpisodeOfCare
Implementation Phases
Phase 1: Patient Core
clinical.patients- Core patient demographics with FHIR Patient mappingclinical.patient_facilities- Multi-facility registrations with auto-MRNclinical.patient_insurance_coverages- Primary/secondary/tertiary coverage
Phase 2: Encounters & Observations
clinical.encounters- OPD/IPD/Telemedicine visits with state machineclinical.encounter_participants- Practitioner involvement with rolesclinical.observations- Vitals, labs with polymorphic valuesclinical.conditions- Diagnoses with ICD-10/SNOMED codingclinical.allergy_intolerances- Allergies with reactions JSONB array
Phase 3: Prescriptions & Orders
clinical.medication_requests- Prescriptions with state machine, dosage instructions, dispense requestsclinical.medication_administrations- IPD medication delivery tracking with completion workflowclinical.service_requests- Lab/imaging/procedure orders with fulfillment trackingclinical.diagnostic_reports- Lab results, imaging reports with critical findingsclinical.care_plans- Treatment plans with activities, goals, and versioningclinical.goals- Patient health goals with targets and achievement tracking
Phase 4: Episodes of Care
clinical.episodes_of_care- Long-term care episodes with FHIR EpisodeOfCare mappingclinical.treatment_goals- Episode-specific measurable goals with progress trackingclinical.progress_notes- Episode progress documentation with structured notes- Added
episode_of_care_idto encounters for episode-encounter linking
Phase 5: Care Plans Completion
- Added
episode_of_care_idto goals table for Goal-Episode linkage - Added
episode_of_careassociations to CarePlan and Goal models - Added episode scopes (
for_episode,with_episode,without_episode) to CarePlan and Goal - Added
care_plansendpoint on EpisodesOfCareController - Updated CarePlan FHIR export with episodeOfCare reference
Key Design Patterns
1. JSONB for FHIR Structures
# CodeableConcept pattern (ICD-10, SNOMED, LOINC)
code: {
coding: [
{ system: "http://hl7.org/fhir/sid/icd-10", code: "J06.9", display: "Acute upper respiratory infection" }
],
text: "Upper respiratory infection"
}
# Identifier pattern (ABHA, MRN, etc.)
identifiers: [
{ system: "https://ndhm.gov.in/abha", value: "12-3456-7890-1234", use: "official" },
{ system: "facility:mrn", value: "MRN-2026-000001" }
]
# Contact pattern (phone, email, address)
contact: {
phone: [{ value: "+91-9876543210", use: "mobile" }],
email: [{ value: "patient@example.com" }],
address: [{ city: "Mumbai", state: "Maharashtra", country: "IN" }]
}2. Composite Foreign Keys
-- All clinical tables reference identity.tenants
FOREIGN KEY (tenant_id) REFERENCES identity.tenants(id)
-- Patient-scoped tables use composite FK
FOREIGN KEY (tenant_id, patient_id) REFERENCES clinical.patients(tenant_id, id)
-- Encounter-scoped tables
FOREIGN KEY (tenant_id, encounter_id) REFERENCES clinical.encounters(tenant_id, id)
-- Cross-schema references
FOREIGN KEY (tenant_id, facility_id) REFERENCES identity.facilities(tenant_id, id)
FOREIGN KEY (tenant_id, performer_id) REFERENCES identity.practitioners(tenant_id, id)This pattern ensures:
- Referential integrity within tenant boundary
- Cannot accidentally reference another tenant's data
- Works with RLS policies
3. Encounter State Machine
VALID_TRANSITIONS = {
planned: [:arrived, :cancelled],
arrived: [:triaged, :in_progress, :cancelled],
triaged: [:in_progress, :cancelled],
in_progress: [:onleave, :finished, :cancelled],
onleave: [:in_progress, :finished],
finished: [],
cancelled: []
}Enforced via:
- Model validation on status change
can_transition_to?/transition_to!methods- Status history tracking via PostgreSQL trigger (future)
4. Observation Polymorphic Values
# value_type determines value_data structure
value_type: 'quantity' # { value: 98.6, unit: "Cel", system: "http://unitsofmeasure.org" }
value_type: 'string' # { value: "Patient reports mild discomfort" }
value_type: 'boolean' # { value: true }
value_type: 'code' # { coding: [...], text: "Positive" }
value_type: 'integer' # { value: 72 }
value_type: 'range' # { low: {...}, high: {...} }PostgreSQL Enums
-- Phase 1
clinical.patient_gender: male, female, other, unknown
clinical.patient_status: active, inactive, entered_in_error, deceased, merged
clinical.registration_type: opd, ipd, emergency, home_visit, telemedicine, daycare
clinical.registration_status: pending, active, inactive, transferred, discharged
clinical.insurance_priority: primary, secondary, tertiary
-- Phase 2
clinical.encounter_status: planned, arrived, triaged, in_progress, onleave, finished, cancelled, entered_in_error
clinical.encounter_class: ambulatory, inpatient, emergency, virtual, home, observation, short_stay
clinical.participant_role: primary, consultant, assistant, observer, referrer, admitter, discharger
clinical.observation_status: registered, preliminary, final, amended, corrected, cancelled, entered_in_error, unknown
clinical.observation_category: vital_signs, laboratory, imaging, social_history, exam, procedure, survey, activity
clinical.condition_clinical_status: active, recurrence, relapse, inactive, remission, resolved
clinical.condition_verification_status: unconfirmed, provisional, differential, confirmed, refuted, entered_in_error
clinical.condition_severity: mild, moderate, severe
clinical.allergy_type: allergy, intolerance
clinical.allergy_category: food, medication, environment, biologic
clinical.allergy_criticality: low, high, unable_to_assess
-- Phase 3 Enums
clinical.medication_request_status: draft, active, on_hold, cancelled, completed, entered_in_error, stopped, unknown
clinical.medication_request_intent: proposal, plan, order, original_order, reflex_order, filler_order, instance_order, option
clinical.medication_request_priority: routine, urgent, asap, stat
clinical.medication_admin_status: in_progress, not_done, on_hold, completed, entered_in_error, stopped, unknown
clinical.medication_admin_category: inpatient, outpatient, community
clinical.service_request_status: draft, active, on_hold, revoked, completed, entered_in_error, unknown
clinical.service_request_intent: proposal, plan, directive, order, original_order, reflex_order, filler_order, instance_order, option
clinical.service_request_priority: routine, urgent, asap, stat
clinical.service_request_category: laboratory, imaging, procedure, referral, consultation, counseling, social_service
clinical.report_status: registered, partial, preliminary, final, amended, corrected, appended, cancelled, entered_in_error, unknown
clinical.report_category: laboratory, radiology, pathology, cardiology, microbiology
clinical.care_plan_status: draft, active, on_hold, revoked, completed, entered_in_error, unknown
clinical.care_plan_intent: proposal, plan, order, option, directive
clinical.goal_lifecycle_status: proposed, planned, accepted, active, on_hold, completed, cancelled, entered_in_error, rejected
clinical.goal_achievement_status: in_progress, improving, worsening, no_change, achieved, sustaining, not_achieved, no_progress, not_attainable
-- Phase 4 Enums
clinical.episode_status: planned, waitlist, active, onhold, finished, cancelled, entered_in_error
clinical.treatment_goal_status: proposed, planned, active, on_hold, completed, cancelled
clinical.progress_note_type: initial_assessment, follow_up, discharge, summary, consultationRLS Policies
All clinical tables use the standard tenant isolation policy:
CREATE POLICY tenant_isolation ON clinical.patients
FOR ALL
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid);FHIR Export Services
Each clinical entity has a dedicated FHIR export service:
| Entity | Service | FHIR Resource |
|---|---|---|
| Patient | Clinical::Services::Fhir::PatientExportService | Patient |
| Encounter | Clinical::Services::Fhir::EncounterExportService | Encounter (Bundle) |
| Observation | Clinical::Services::Fhir::ObservationExportService | Observation |
| Condition | Clinical::Services::Fhir::ConditionExportService | Condition |
| AllergyIntolerance | Clinical::Services::Fhir::AllergyIntoleranceExportService | AllergyIntolerance |
| MedicationRequest | Clinical::Services::Fhir::MedicationRequestExportService | MedicationRequest |
| MedicationAdministration | Clinical::Services::Fhir::MedicationAdministrationExportService | MedicationAdministration |
| ServiceRequest | Clinical::Services::Fhir::ServiceRequestExportService | ServiceRequest |
| DiagnosticReport | Clinical::Services::Fhir::DiagnosticReportExportService | DiagnosticReport |
| CarePlan | Clinical::Services::Fhir::CarePlanExportService | CarePlan |
| Goal | Clinical::Services::Fhir::GoalExportService | Goal |
| EpisodeOfCare | Clinical::Services::Fhir::EpisodeOfCareExportService | EpisodeOfCare |
| TreatmentGoal | Clinical::Services::Fhir::TreatmentGoalExportService | Custom |
| ProgressNote | Clinical::Services::Fhir::ProgressNoteExportService | Custom |
Alternatives Considered
| Option | Pros | Cons |
|---|---|---|
| Flat columns for codes | Simple queries | Cannot store multiple codings |
| Separate coding tables | Normalized | Complex joins, poor performance |
| JSONB (chosen) | Flexible, FHIR-native | Requires JSONB indexing |
| Option | Pros | Cons |
|---|---|---|
| Simple FK (entity_id only) | Simple | Cross-tenant reference possible |
| Composite FK (chosen) | Tenant-safe | More complex migrations |
Consequences
Positive:
- Direct FHIR R4 export without transformation
- Flexible schema for varying clinical data structures
- Strong referential integrity within tenant boundaries
- Type-safe enums prevent invalid status values
- State machine prevents invalid encounter transitions
Negative:
- JSONB queries require careful indexing
- Composite foreign keys increase migration complexity
- PostgreSQL enums require migrations to add new values
- Learning curve for FHIR data patterns
Files Created
| Category | Phase 1 | Phase 2 | Phase 3 | Phase 4 | Phase 5 | Total |
|---|---|---|---|---|---|---|
| Migrations | 4 | 7 | 8 | 7 | 1 | 27 files |
| Models | 3 | 5 | 6 | 3 | 0 | 17 models |
| Blueprints | 3 | 5 | 6 | 3 | 0 | 17 blueprints |
| Controllers | 2 | 5 | 6 | 3 | 0 | 16 controllers |
| Policies | 3 | 5 | 6 | 3 | 0 | 17 policies |
| Services | 2 | 6 | 10 | 4 | 0 | 22 services |
| Model Specs | 3 | 5 | 6 | 3 | 0 | 17 spec files |
| Request Specs | 2 | 5 | 6 | 3 | 0 | 16 spec files |
Total test coverage: 815 clinical specs passing (88% code coverage)
ADR-020: Episodes of Care and Long-Term Treatment Programs
Status: Approved Date: 2025-01-10 Context: Long-term care management and FHIR EpisodeOfCare implementation
Context
Healthcare delivery often spans multiple encounters over extended periods. Examples include:
- Diabetes management programs (ongoing)
- Post-surgical rehabilitation (6-12 weeks)
- Mental health treatment programs (ongoing)
- Maternity care episodes (9 months)
- Cancer treatment protocols (variable)
The FHIR EpisodeOfCare resource provides a standard way to group encounters and clinical activities under a coherent treatment program.
Decision
Implement a three-entity Episode of Care system:
- EpisodeOfCare - Core entity mapping to FHIR EpisodeOfCare
- TreatmentGoal - Episode-specific measurable goals
- ProgressNote - Episode progress documentation
Design Patterns
1. Episode-Entity Relationships
EpisodeOfCare
├── has_many :encounters (via episode_of_care_id)
├── has_many :treatment_goals
├── has_many :progress_notes
├── has_many :care_plans (optional episode linkage)
└── has_many :goals (optional episode linkage)2. Episode State Machine
VALID_TRANSITIONS = {
planned: [:waitlist, :active, :cancelled],
waitlist: [:active, :cancelled],
active: [:onhold, :finished, :cancelled],
onhold: [:active, :finished, :cancelled],
finished: [],
cancelled: [],
entered_in_error: []
}3. Diagnosis Tracking via JSONB
# diagnoses column stores FHIR Condition references
diagnoses: [
{
condition_id: "uuid",
role: "chief_complaint", # chief_complaint, comorbidity, admission_diagnosis
rank: 1
}
]4. Care Team via JSONB
# care_team column stores practitioner references with roles
care_team: [
{
practitioner_id: "uuid",
role: "primary", # primary, specialist, nurse, therapist
period: { start: "2025-01-01", end: nil }
}
]CarePlan and Goal Episode Linkage
Phase 5 added optional episode linkage to existing CarePlan and Goal entities:
# CarePlan and Goal now have optional episode association
belongs_to :episode_of_care, optional: true
# Scopes for episode-based queries
scope :for_episode, ->(episode_id) { where(episode_of_care_id: episode_id) }
scope :with_episode, -> { where.not(episode_of_care_id: nil) }
scope :without_episode, -> { where(episode_of_care_id: nil) }This allows:
- Standalone care plans (no episode) for simple treatment plans
- Episode-linked care plans for comprehensive treatment programs
- Episode-linked goals for coordinated outcome tracking
FHIR Mapping
| Internal Field | FHIR EpisodeOfCare Field |
|---|---|
status | status |
patient_id | patient.reference |
managing_organization_id | managingOrganization.reference |
care_manager_id | careManager.reference |
episode_type | type |
diagnoses | diagnosis |
care_team | Separate CareTeam resource |
period_start, period_end | period |
API Endpoints
GET /clinical/episodes_of_care
GET /clinical/episodes_of_care/:id
POST /clinical/episodes_of_care
PATCH /clinical/episodes_of_care/:id
DELETE /clinical/episodes_of_care/:id
POST /clinical/episodes_of_care/:id/transition
GET /clinical/episodes_of_care/:id/goals # TreatmentGoals for episode
GET /clinical/episodes_of_care/:id/encounters # Encounters linked to episode
GET /clinical/episodes_of_care/:id/notes # ProgressNotes for episode
GET /clinical/episodes_of_care/:id/care_plans # CarePlans linked to episode
GET /clinical/episodes_of_care/:id/export # FHIR EpisodeOfCare exportAlternatives Considered
| Option | Pros | Cons |
|---|---|---|
| Single Episode entity only | Simple | No structured goals/notes |
| Reuse Goal model | Less duplication | Different semantics (patient vs episode) |
| Separate schema | Clean isolation | Unnecessary complexity |
| Episode + TreatmentGoal + ProgressNote (chosen) | Complete workflow | More entities |
Consequences
Positive:
- Full FHIR EpisodeOfCare compliance
- Structured episode documentation
- Clear encounter-episode relationships
- Flexible CarePlan/Goal episode linkage
- Comprehensive progress tracking
Negative:
- Three new entities to manage
- Some conceptual overlap with Goal (TreatmentGoal vs Goal)
- JSONB for diagnoses/team requires careful validation
ADR-021: AI Integration Data Model and Patterns
Status: Approved Date: 2025-01-10 Context: AI Scribe, Clinical Suggestions, and LLM Audit Logging
Context
The platform implements AI-assisted clinical care features:
- AI Scribe: Real-time transcription of clinical encounters
- Clinical Decision Support: AI-generated suggestions for diagnoses, medications, orders
- Audit Trail: LLM API call logging for compliance and cost tracking
- Configuration: Facility and practitioner-level AI settings
Key requirements:
- Suggestions must link to created clinical resources with DB-level integrity
- PHI must never be stored in audit logs
- Practitioner settings must support inheritance from facility defaults
- All AI data must be tenant-isolated via RLS
Decision
Implement a multi-schema AI integration with:
- Clinical Tables:
transcription_sessions,ai_suggestions,ai_suggestion_links - Audit Tables:
llm_call_logs(no PHI) - Identity Tables:
facility_ai_settings,practitioner_ai_settings - Normalized Link Table: Type-safe foreign keys instead of polymorphic associations
- NULLIF RLS Pattern: Safe tenant isolation when context is unset
Key Design Decisions
1. Normalized Suggestion-Resource Links
Problem: Polymorphic associations (created_resource_type, created_resource_id) cannot enforce referential integrity at the database level.
Solution: Create a normalized ai_suggestion_links table with explicit foreign key columns for each resource type.
CREATE TABLE clinical.ai_suggestion_links (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
ai_suggestion_id UUID NOT NULL,
resource_type clinical.linked_resource_type NOT NULL,
-- Resource-specific FKs (only one populated)
condition_id UUID,
medication_request_id UUID,
observation_id UUID,
allergy_intolerance_id UUID,
service_request_id UUID,
care_plan_id UUID,
goal_id UUID,
-- Exactly one resource constraint
CONSTRAINT chk_exactly_one_resource CHECK (
(CASE WHEN condition_id IS NOT NULL THEN 1 ELSE 0 END +
CASE WHEN medication_request_id IS NOT NULL THEN 1 ELSE 0 END +
-- ... other columns ...
) = 1
),
-- Composite FKs for tenant consistency
CONSTRAINT fk_suggestion_links_condition
FOREIGN KEY (tenant_id, condition_id)
REFERENCES clinical.conditions(tenant_id, id)
);Benefits:
- DB-level referential integrity
- Cannot reference non-existent resources
- Cannot cross tenant boundaries
- Clear FHIR resource type mapping
2. Per-Practitioner Settings with Override Semantics
Problem: Practitioners may need different AI settings than facility defaults (e.g., disable suggestions, different confidence threshold).
Solution: practitioner_ai_settings table with NULL = inherit pattern.
# Resolution logic
def resolve(setting_name)
practitioner_value = practitioner_settings&.send(setting_name)
facility_value = facility_settings&.send(setting_name)
# Practitioner override wins if explicitly set (non-nil)
practitioner_value.nil? ? facility_value : practitioner_value
endBehavior:
NULLin practitioner settings = inherit from facility- Explicit
true/false= override facility setting - One record per practitioner-facility combination
3. NULLIF RLS Pattern for Safety
Problem: Standard RLS policy tenant_id = current_setting(...)::uuid can leak data when context is not set because NULL = NULL returns NULL (not false) in PostgreSQL.
Solution: Use NULLIF to explicitly convert empty string to NULL.
CREATE POLICY tenant_isolation ON clinical.ai_suggestions
FOR ALL
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid);Behavior:
current_setting(..., true)returns empty string if not setNULLIF(..., '')converts to NULLtenant_id = NULLreturns NULL (falsy), blocking access- No data exposed when tenant context is missing
4. LLM Audit Logging Without PHI
Problem: Need to track LLM usage for cost and compliance, but cannot store PHI in audit logs.
Solution: Store metadata only with hashed input for debugging.
| What's Stored | What's NOT Stored |
|---|---|
| Token counts | Transcript text |
| Latency, status | Patient names |
| Provider, model | Clinical content |
| Cost estimates | Suggested resources |
| Hashed input (SHA-256) | Raw input/output |
Retention: Success logs 365 days, error logs 180 days, timeout/rate-limited 90 days.
Data Model
TranscriptionSession (clinical)
├── belongs_to :encounter
├── belongs_to :practitioner
├── has_many :ai_suggestions
└── status: recording → processing → completed/failed
AiSuggestion (clinical)
├── belongs_to :transcription_session (optional)
├── belongs_to :encounter
├── belongs_to :patient
├── belongs_to :reviewed_by (practitioner)
├── has_one :suggestion_link
└── status: pending → accepted/rejected/modified/expired
AiSuggestionLink (clinical)
├── belongs_to :ai_suggestion
├── belongs_to :condition (optional)
├── belongs_to :medication_request (optional)
├── ... other resource FKs
└── resource_type: condition/medication_request/etc.
LlmCallLog (audit)
├── belongs_to :tenant
├── belongs_to :facility (optional)
└── No PHI fields
FacilityAiSettings (identity)
├── belongs_to :facility (1:1)
├── is_enabled, transcription_enabled, suggestions_enabled
└── confidence_threshold, ai_provider, ai_model
PractitionerAiSettings (identity)
├── belongs_to :practitioner
├── belongs_to :facility
├── All settings are nullable (NULL = inherit)
└── unique: (practitioner_id, facility_id)Enums Created
| Enum | Values |
|---|---|
clinical.transcription_status | recording, processing, completed, failed |
clinical.suggestion_type | diagnosis, medication, order, observation, care_plan, goal, allergy, procedure, follow_up |
clinical.suggestion_status | pending, accepted, rejected, modified, expired |
clinical.suggestion_priority | high, medium, low |
clinical.linked_resource_type | condition, medication_request, observation, allergy_intolerance, service_request, care_plan, goal, procedure |
audit.llm_provider | openai, anthropic, google, azure_openai, aws_bedrock |
audit.llm_call_type | transcription, suggestion, summary, extraction, embedding, moderation |
audit.llm_call_status | success, error, timeout, rate_limited |
Alternatives Considered
| Decision | Chosen | Alternative | Why Chosen |
|---|---|---|---|
| Resource linking | Normalized FK table | Polymorphic association | DB-level integrity |
| Practitioner settings | Separate table with NULL | Single facility_ai_settings table | Cleaner override logic |
| RLS pattern | NULLIF pattern | Simple comparison | Safer when context unset |
| PHI in audit | Hash only | Store encrypted | Simpler compliance |
Consequences
Positive:
- Strong DB-level integrity for suggestion-resource links
- Cannot accidentally reference cross-tenant resources
- Flexible per-practitioner configuration
- Safe RLS when tenant context is missing
- Clean separation of audit data (no PHI)
Negative:
- More columns in link table (one per resource type)
- Slightly more complex settings resolution logic
- Must add new column when adding new linkable resource type
- Two tables to manage for AI settings (facility + practitioner)
Files Created
| Category | Files |
|---|---|
| Migrations | 9 files (enums, tables, indexes, RLS) |
| Models | 6 models (TranscriptionSession, AiSuggestion, AiSuggestionLink, LlmCallLog, FacilityAiSettings, PractitionerAiSettings) |
| Model Specs | 6 spec files with 155 examples passing |
ADR-022: Connect Schema Implementation - Cross-Tenant Professional Networking
Status: Approved Date: 2025-01-12 Context: Professional networking for practitioners across tenant boundaries
Context
The Kulcare Connect product enables healthcare practitioners to:
- Create public professional profiles
- Network with other practitioners across organizations
- Share credentials, experience, and specializations
- Accept referrals from other practitioners
This requires data that is NOT tenant-isolated, as practitioners maintain profiles independent of any single employer/facility.
Decision
Implement a dedicated connect schema without tenant_id columns, using practitioner ownership instead of tenant ownership.
Design Principles
- No tenant_id: Connect tables do not have tenant_id columns
- Practitioner Ownership: Data belongs to practitioners, not tenants
- Visibility Controls: Public/private/connections-only visibility per profile
- Cross-Reference: Profiles link to identity.practitioners (which may have multiple tenant roles)
Database Tables
-- Core profile table
connect.professional_profiles
id UUID PRIMARY KEY,
practitioner_id UUID NOT NULL REFERENCES identity.practitioners(id),
headline VARCHAR(200),
bio TEXT,
visibility profile_visibility NOT NULL DEFAULT 'private',
accepting_referrals BOOLEAN DEFAULT false,
accepting_connections BOOLEAN DEFAULT true,
slug VARCHAR(100) UNIQUE,
connections_count INTEGER DEFAULT 0,
endorsements_count INTEGER DEFAULT 0
-- Bidirectional connections
connect.professional_connections
id UUID PRIMARY KEY,
from_profile_id UUID NOT NULL REFERENCES connect.professional_profiles(id),
to_profile_id UUID NOT NULL REFERENCES connect.professional_profiles(id),
status connection_status NOT NULL DEFAULT 'pending',
message TEXT,
accepted_at TIMESTAMPTZ
-- Specializations with endorsements
connect.specializations
id UUID PRIMARY KEY,
professional_profile_id UUID NOT NULL,
specialty_id UUID REFERENCES reference.specialties(id),
is_primary BOOLEAN DEFAULT false,
years_experience INTEGER,
endorsements_count INTEGER DEFAULT 0,
verified BOOLEAN DEFAULT false
-- Experience entries
connect.experience_entries
id UUID PRIMARY KEY,
professional_profile_id UUID NOT NULL,
organization VARCHAR(200) NOT NULL,
title VARCHAR(200) NOT NULL,
location VARCHAR(200),
start_date DATE NOT NULL,
end_date DATE,
current BOOLEAN DEFAULT false,
verified BOOLEAN DEFAULT false
-- Education entries
connect.education_entries
id UUID PRIMARY KEY,
professional_profile_id UUID NOT NULL,
institution VARCHAR(200) NOT NULL,
degree VARCHAR(200) NOT NULL,
field_of_study VARCHAR(200),
start_year INTEGER,
end_year INTEGER,
verified BOOLEAN DEFAULT falseEnums Created
CREATE TYPE connect.profile_visibility AS ENUM ('public', 'private', 'connections_only');
CREATE TYPE connect.connection_status AS ENUM ('pending', 'accepted', 'rejected', 'blocked');Key Design Patterns
1. Bidirectional Unique Constraint
Prevents both A->B and B->A connections:
CREATE UNIQUE INDEX idx_connections_bidirectional ON connect.professional_connections (
LEAST(from_profile_id, to_profile_id),
GREATEST(from_profile_id, to_profile_id)
) WHERE status != 'rejected';2. Practitioner-Based Authorization
class Connect::BasePolicy < ApplicationPolicy
def owner?
record.professional_profile&.practitioner_id == current_practitioner&.id
end
def current_practitioner
@current_practitioner ||= Identity::Practitioner.find_by(user_id: user&.id)
end
end3. Visibility Scopes
scope :visible_to, ->(practitioner) {
profile = ProfessionalProfile.find_by(practitioner_id: practitioner&.id)
return where(visibility: 'public') unless profile
where(visibility: 'public')
.or(where(id: profile.id))
.or(where(visibility: 'connections_only', id: profile.connected_profile_ids))
}4. Auto-Derived Current Flag
Experience entries auto-derive current from end_date:
before_validation :sync_current_flag
def sync_current_flag
self.current = end_date.nil?
end5. Race-Safe Slug Generation
def generate_slug
return if slug.present?
base = "#{practitioner.first_name}-#{practitioner.last_name}".parameterize
self.slug = base
if self.class.exists?(slug: slug)
self.slug = "#{base}-#{SecureRandom.hex(3)}"
end
endAPI Layer
| Resource | Endpoints |
|---|---|
| Professional Profiles | GET /connect/professional_profiles, GET /:id, GET /me, PATCH /:id, POST /:id/publish, POST /:id/unpublish |
| Connections | GET /connect/connections, POST /, POST /:id/accept, POST /:id/reject, DELETE /:id, GET /pending, GET /sent |
| Specializations | GET /connect/specializations, POST /, PATCH /:id, DELETE /:id |
| Experience | GET /connect/experience_entries, POST /, PATCH /:id, DELETE /:id |
| Education | GET /connect/education_entries, POST /, PATCH /:id, DELETE /:id |
Files Created
| Category | Count | Files |
|---|---|---|
| Migration | 1 | create_connect_tables.rb |
| Models | 6 | base_record, professional_profile, professional_connection, specialization, experience_entry, education_entry |
| Controllers | 6 | base_controller, professional_profiles, connections, specializations, experience_entries, education_entries |
| Blueprints | 5 | professional_profile, professional_connection, specialization, experience_entry, education_entry |
| Policies | 6 | base_policy, professional_profile, professional_connection, specialization, experience_entry, education_entry |
| Specs | 2 | professional_profiles_spec, connections_spec |
Total: 30 tests passing
Alternatives Considered
| Option | Pros | Cons |
|---|---|---|
| Tenant-scoped profiles | Familiar pattern | Profiles tied to single tenant, not portable |
| Duplicate profiles per tenant | RLS works | Data duplication, sync complexity |
| Cross-tenant (chosen) | Single profile per practitioner | Different authorization pattern |
Consequences
Positive:
- Practitioners maintain single profile across all employers
- Natural networking across organizational boundaries
- Profile data portable when changing employers
- Clear ownership model (practitioner owns their data)
Negative:
- Different authorization pattern than tenant-scoped schemas
- No RLS (must rely on application-level authorization)
- Profile visibility requires careful policy design
- Must handle practitioner deletion carefully (cascade vs. orphan)
ADR-023: Frontend Encounter Query Gating by Practitioner Type
Status: Approved Date: 2026-02-19 Context: Performance optimization for encounter clinical data fetching
Context
The CMS encounter page uses the useEncounterClinicalData hook to fetch all clinical data for a patient encounter. As specialties were added (dental, mental health, physio, dermatology, aesthetics, cardiology, cardiac surgery, dietitian, ophthalmology, ENT, orthopedics, gynecology, pediatrics, pulmonology), the hook grew to fire ~40 API calls (useObservations and useServiceRequests) on every encounter load -- regardless of the practitioner type.
A general doctor encounter would fire dental, cardiac, pediatric, ophthalmology, ENT, and every other specialty query, all returning empty results. Only ~6 of those calls were actually needed. A specialist like a pediatrician needed ~10 calls but was firing all 40.
Decision
Gate specialty-specific queries using the activeTabIds parameter derived from the encounter type configuration. Each specialty query checks whether its corresponding tab is present before firing.
Implementation
Hook signature change (useEncounterClinicalData.ts):
export function useEncounterClinicalData(
encounterId: string | undefined,
patientId: string | undefined,
performerId: string | undefined,
episodeId?: string | undefined,
activeTabIds?: string[] // NEW: gates specialty queries
)Inside the hook: A Set<string> is built from activeTabIds with a hasTab() helper for O(1) lookups. When activeTabIds is undefined, hasTab() returns true for all tabs (backward compatibility).
// Gated query example -- only fires when dental tab is active
const dentalFindingsQuery = useObservations(
encounterId && hasTab('dental')
? { encounter_id: encounterId, category: 'dental_examination' }
: undefined
);Caller change (EncounterPage.tsx):
const activeTabIds = useMemo(
() => encounterTypeConfig?.tabs?.map(t => t.id) || [],
[encounterTypeConfig]
);
const { ... } = useEncounterClinicalData(
encounterId, resolvedPatientId, performerId,
clinicalEncounter?.episode_of_care_id,
activeTabIds
);Query Classification
Core queries (always fire, no gating):
- Vitals, observations (exam), medication requests, service requests, advice, follow-up
Gated queries (only fire when their tab is active):
- All specialty-specific observation and service request queries (dental, mental health, physio, dermatology, aesthetics, cardiology, cardiac surgery, dietitian, ophthalmology, ENT, orthopedics, gynecology, pediatrics, pulmonology)
Alternatives Considered
| Option | Pros | Cons |
|---|---|---|
| Lazy-load queries on tab click | Zero upfront cost | Data not ready when user switches tabs, poor UX with loading spinners |
| Single batch API endpoint | One network call | Backend complexity, can't leverage React Query caching per category |
| Tab-based gating (chosen) | Queries ready before tab is clicked, no backend changes | Slightly more calls than lazy-load, requires tab ID alignment |
| No change | Simplest code | ~40 API calls per encounter, wasted bandwidth and server load |
Consequences
Positive:
- 60-85% reduction in API calls per encounter (general doctor: ~40 to ~6, pediatrician: ~40 to ~10)
- Reduced server load and bandwidth usage
- Backward compatible -- omitting
activeTabIdspreserves existing behavior - No backend changes required
- Data is pre-fetched for all active tabs so there is no loading delay when switching tabs
Negative:
- Tab IDs in the hook must stay in sync with
useEncounterTypeConfigtab definitions - Adding a new specialty requires adding
hasTab()gating to its queries - If encounter type config is slow to load, queries are delayed until tab IDs are available
Summary of Decisions
| ADR | Decision | Status |
|---|---|---|
| ADR-001 | Shared DB + RLS for multi-tenancy | Approved |
| ADR-002 | PostgreSQL schemas per domain | Approved |
| ADR-003 | Rename Clinic to Facility | Approved |
| ADR-004 | Multi-dimensional facility classification | Approved |
| ADR-005 | FHIR R4 resource mapping | Approved |
| ADR-006 | Cross-tenant Connect schema | Approved |
| ADR-007 | Split AI data (clinical + audit) | Approved |
| ADR-008 | Dedicated reference schema | Approved |
| ADR-009 | Comprehensive audit schema | Approved |
| ADR-010 | Enterprise benefits as cross-tenant | Approved |
| ADR-011 | Pharma relations in separate DB | Approved |
| ADR-012 | Vendor model for unlicensed providers | Approved |
| ADR-013 | Service packages for bundled offerings | Approved |
| ADR-014 | Clinical trial anonymization | Approved |
| ADR-015 | Federated deployment model | Approved |
| ADR-016 | Domains directory architecture | Approved |
| ADR-017 | Hybrid Service Object Pattern for API | Approved |
| ADR-018 | Tenant Context and Request-Scoped State | Approved |
| ADR-019 | Clinical Schema FHIR-First Design | Approved |
| ADR-020 | Episodes of Care and Long-Term Treatment Programs | Approved |
| ADR-021 | AI Integration Data Model and Patterns | Approved |
| ADR-022 | Connect Schema Implementation - Cross-Tenant Professional Networking | Approved |
| ADR-023 | Frontend Encounter Query Gating by Practitioner Type | Approved |