Kulcare Docs
General

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

OptionProsCons
Separate databases per tenantHighest isolation, easy backup/restoreComplex operations, connection pooling issues
Separate schemas per tenantGood isolation, PostgreSQL nativeSchema proliferation, migration complexity
Shared schema with tenant_idSimple, single migration pathRequires RLS for isolation
Hybrid (our choice)Best of both: domain schemas + shared tenant_idRequires careful design

Approach

  • Single PostgreSQL database
  • Multiple schemas for domain separation (not tenant separation)
  • tenant_id column 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

SchemaPurposeTenant Scoped
referenceGlobal lookup/catalog dataNo
identityWho provides what, where: providers, organizations, facilities, servicesPartial
clinicalPatient care, encounters, medicationsYes
billingInvoices, payments, subscriptionsYes
growthCampaigns, leads, communicationsYes
connectProfessional networking, jobsCross-tenant
integrationExternal services, ABDM, webhooksYes
auditActivity logs, PHI access, LLM logsYes

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

CurrentNewFHIR Mapping
ClinicFacilityOrganization + Location
ClinicGroupOrganizationOrganization (parent)
ClinicLocationFacilityLocationLocation
ClinicDoctorPractitionerRolePractitionerRole
ClinicServiceHealthcareServiceHealthcareService

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+ OTs

Dimension 3: Ownership Model

doctor_owned      - Individual practitioner owned
partnership       - Doctor partnership/group
corporate         - Corporate/chain owned
trust             - Trust/charitable organization
government        - Government operated

Dimension 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 care

Consequences

  • 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 EntityFHIR ResourceNotes
TenantN/APlatform concept, not FHIR
OrganizationOrganizationWith parent hierarchy (partOf)
FacilityOrganization + LocationDual mapping
FacilityLocationLocationPhysical address
PractitionerPractitionerGlobal identity
PractitionerRolePractitionerRoleFacility-specific role
HealthcareServiceHealthcareServiceServices offered
PatientPatientCore identity
EncounterEncounterClinical visit
EpisodeOfCareEpisodeOfCareTreatment program
CarePlanCarePlanTreatment planning
GoalGoalDesired outcomes
ConditionConditionDiagnoses/problems
ObservationObservationVitals, measurements
MedicationRequestMedicationRequestPrescriptions
ServiceRequestServiceRequestLab/imaging orders
DiagnosticReportDiagnosticReportResults

Key Patterns

  1. LHR as Computed View - Not a FHIR resource, aggregated on demand
  2. Identifier Arrays - JSONB for multiple identifiers (ABHA, NPI, etc.)
  3. Coding Systems - ICD-10, SNOMED, LOINC support
  4. 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

  1. Practitioner vs Profile

    • identity.practitioners - Core identity (can belong to multiple tenants)
    • connect.professional_profiles - Public-facing profile (1:1 with practitioner)
  2. No tenant_id on Connect tables

    • Profiles are personal to the practitioner
    • Connections cross organizational boundaries
    • Jobs can be from any tenant (facility)
  3. 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

TableSchemaRationale
transcription_sessionsclinicalLinked to encounters, clinical workflow
ai_suggestionsclinicalLinked to encounters, clinical content
llm_call_logsauditAudit trail, not clinical content
clinic_ai_settingsidentityConfiguration per facility

AI Suggestion Linkage

Encounter
    |
    +-- TranscriptionSession
    |       |
    |       +-- TranscriptionSegments
    |
    +-- AiSuggestions (linked via encounter_id)
            |
            +-- Accepted/Rejected status
            +-- Modifications tracked

Consequences

  • 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

CategoryExamplesGovernance
Medical CodingICD codes, SNOMED concepts, LOINC codesExternal standards
CatalogDrugs, procedures, lab testsPlatform-managed
GeographicCountries, states, citiesPlatform-managed
Facility TypesService levels, ownership modelsPlatform-defined
SpecialtiesMedical specialties hierarchyPlatform-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

TablePurposeRetention
activity_logsGeneral user actions2 years
phi_access_logsPatient data access6 years (HIPAA)
llm_call_logsAI/LLM audit trail2 years
document_audit_logsClinical document changesPermanent

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_at

Consequences

  • 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:

  1. A new tenant type (like facilities)
  2. A cross-tenant entity (like Connect)
  3. A separate database (like Pharma)

Decision

Implement enterprises as a cross-tenant schema within the main Kulcare database.

Rationale

OptionProsCons
New tenant typeRLS isolationEmployees span multiple facility tenants
Cross-tenant (chosen)Natural employee-patient linkageDifferent access patterns
Separate databaseFull isolationRequires 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, initiatives

Consequences

  • 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

FactorKulcare DBPharma DB
Tenant modelFacilitiesPharma companies
Data typePHI (patient data)B2B marketing
ComplianceHIPAA/DPDPSunshine Act, FDA
ScaleModerateHigh-volume campaigns
IntegrationDirect joinsAPI 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 links

Consequences

  • 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:

  1. Licensed practitioners: Doctors, nurses, physiotherapists, dietitians
  2. 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 TypeEntityLicense RequiredExamples
PhysicianPractitionerYes (Medical Council)MD, MBBS
Allied HealthPractitionerYes (Allied Council)PT, RD, OT
UnlicensedVendorNo (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: JSONB

Consequences

  • 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_price

Package 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: 8

Consequences

  • 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_at

Process

  1. Consent Collection (Kulcare DB)

    • Patient consents to trial at facility
    • Generate unique anonymous_id
    • Store consent in clinical.trial_consents
  2. Enrollment Notification (API)

    • Notify pharma_db of enrollment via API
    • Pass only: anonymous_id, trial_id, site_id, enrollment_date
    • No PHI transmitted
  3. Trial Tracking (Pharma DB)

    • Track progress using anonymous_id
    • No way to identify patient from pharma_db

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:

  1. Dual Deployment Options

    • Cloud SaaS (existing multi-tenant architecture)
    • On-Premise (local Rails app + PostgreSQL, single-organization)
  2. 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)
  3. 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
  4. New federation Schema (Central Cloud only)

    • 12 tables for facility registry, patient identity, sync tracking
    • No RLS (facility-based access control)

Alternatives Considered

  1. Full Bi-Directional Sync

    • Rejected: Too complex, conflict resolution challenging
    • Clinical data should have single source of truth
  2. No Patient Portal for On-Premise

    • Rejected: Patients lose data portability
    • Counter to patient data ownership principle
  3. 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 Booking

Sync Protocol

  1. Clinical Data (Outbound Only)

    • On-premise syncs to cloud via batch API
    • Signed payloads, acknowledgment-based delivery
    • Resilient to intermittent connectivity
  2. 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

MethodCriteriaConfidence
abhaABHA ID match1.0
exactPhone + DOB + Name0.95
fuzzyPhone + similar name0.7
manualHuman verified1.0

Feature Availability

FeatureCloudOn-PremiseCloud-Proxied
Core CMSYesYesNo
Online BookingYesYesYes
Patient PortalYesYes (read-only)Yes
SMS/WhatsAppYesNoYes
AI ScribeYesNoYes
AnalyticsYesNoYes
Payment GatewayYesNoNo

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

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:

  1. Different patterns: Blueprinter instead of ActiveModelSerializers, RLS-aware models, structured errors
  2. Clean separation: New code should not mix with legacy code during migration
  3. Namespace isolation: New models should be namespaced (e.g., Identity::Tenant vs legacy Clinic)

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
end

Key Design Choices

  1. Separate from parts/: Legacy code remains untouched during migration
  2. Domain namespaces: Identity::, Reference::, Clinical:: match PostgreSQL schemas
  3. Shared infrastructure: Common base classes avoid duplication
  4. Zeitwerk push_dir: Automatic namespacing without module Identity wrappers in every file

Alternatives Considered

OptionProsCons
Modify parts/No new structureMixes legacy and new patterns
Nested namespacesClear hierarchyDomains::Identity::Tenant too verbose
Standard autoloadSimple configRequires module Identity in every file
push_dir (chosen)Clean namespacingRequires initializer timing

Migration Strategy

  1. Phase 1: Create domains/ with shared infrastructure
  2. Phase 2: Implement Identity domain models
  3. Phase 3: Implement Reference domain
  4. Phase 4: Create API layer (controllers, blueprints)
  5. 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:

  1. Continue using Interactors
  2. Use direct controller logic (like Reference domain)
  3. 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

PatternUsed InProsCons
Interactor (parts/)TwoFactor::Enable, billingStandardized, composable via OrganizerGem dependency, context mutation, overkill for simple CRUD
Direct ControllerReference domainSimple, Rails-idiomaticLogic in controllers hard to test/reuse
Service Objects (chosen)Identity domainTestable, no gem, explicit returnsNeed to define pattern

Pattern Implementation

Decision Matrix

Operation TypePattern to UseExample
Simple CRUD (list, show, create, update, delete)Direct ControllerGET /tenants, POST /facilities
Create with related modelsService ObjectCreate organization with owner membership
Multi-step validation/business logicService ObjectUpdate practitioner roles across facilities
External API integrationService ObjectFHIR 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
end

Controller 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)
end

API 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 = []
end

Example 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:

ViewPurposeFields
normalDefault list/show responseCore fields for display
detailedFull resource detailsAll fields including computed
selectDropdown/autocompleteid, display name only
fhirFHIR R4 resource formatFHIR-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
end

Consequences

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:

  1. Request-scoped tenant context - Access current tenant/user across controllers, models, and services
  2. Automatic model scoping - Models should automatically filter by tenant without explicit tenant_id in every query
  3. Background job context - Sidekiq jobs need tenant context for database operations
  4. Authentication enforcement - Controllers need a standard way to require authentication

Decision

Implement a layered tenant context system with four components:

  1. Shared::Current - ActiveSupport::CurrentAttributes for request-scoped storage
  2. TenantScoped concern - Automatic model scoping with default_scope
  3. Sidekiq middleware - Context propagation for background jobs
  4. TenantAwareJob concern - 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
end

Key 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
end

Key Features:

  • Auto-filters queries when Shared::Current.tenant is set
  • Auto-assigns tenant_id on create from current context
  • Provides unscoped_by_tenant for 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
end

Key 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
end

Key Features:

  • perform_for_tenant - Enqueue with explicit tenant
  • with_tenant - Switch context within a job
  • each_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 correctly

Models Updated

The following Identity models now include TenantScoped:

ModelPurpose
Identity::OrganizationHealthcare organizations
Identity::FacilityPhysical/virtual locations
Identity::PractitionerRolePractitioner-facility assignments
Identity::HealthcareServiceServices at facilities
Identity::UserMembershipUser-tenant-role links

Alternatives Considered

OptionProsCons
Thread.current storageSimpleNot request-scoped, leaks across requests
RequestStore gemDedicated gemAnother dependency
Pass tenant explicitlyExplicitVerbose, easy to forget
CurrentAttributes (chosen)Rails-native, clean APIRequires 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

FilePurpose
domains/shared/current.rbRequest-scoped storage
domains/shared/concerns/tenant_scoped.rbModel auto-scoping
domains/shared/concerns/tenant_aware_job.rbJob context helpers
config/initializers/sidekiq_tenant_middleware.rbBackground job context
domains/shared/concerns/tenant_context.rbUpdated to use Shared::Current
domains/shared/base_controller.rbAdded require_authentication!
domains/identity/controllers/base_controller.rbAdded 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:

  1. FHIR R4 interoperability for ABDM, NHS, and other health exchanges
  2. Multi-tenant isolation via RLS
  3. Composite foreign keys for referential integrity
  4. PostgreSQL JSONB for flexible clinical data structures

Decision

Implement a FHIR-first clinical schema with:

  1. 17 Core Tables: Patient, PatientFacility, PatientInsuranceCoverage, Encounter, EncounterParticipant, Observation, Condition, AllergyIntolerance, MedicationRequest, MedicationAdministration, ServiceRequest, DiagnosticReport, CarePlan, Goal, EpisodeOfCare, TreatmentGoal, ProgressNote
  2. PostgreSQL Enums: Type-safe status values matching FHIR ValueSets
  3. JSONB Columns: Flexible storage for FHIR-structured data (CodeableConcept, Identifiers, etc.)
  4. Composite Foreign Keys: (tenant_id, entity_id) pattern for cross-schema references
  5. 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 mapping
  • clinical.patient_facilities - Multi-facility registrations with auto-MRN
  • clinical.patient_insurance_coverages - Primary/secondary/tertiary coverage

Phase 2: Encounters & Observations

  • clinical.encounters - OPD/IPD/Telemedicine visits with state machine
  • clinical.encounter_participants - Practitioner involvement with roles
  • clinical.observations - Vitals, labs with polymorphic values
  • clinical.conditions - Diagnoses with ICD-10/SNOMED coding
  • clinical.allergy_intolerances - Allergies with reactions JSONB array

Phase 3: Prescriptions & Orders

  • clinical.medication_requests - Prescriptions with state machine, dosage instructions, dispense requests
  • clinical.medication_administrations - IPD medication delivery tracking with completion workflow
  • clinical.service_requests - Lab/imaging/procedure orders with fulfillment tracking
  • clinical.diagnostic_reports - Lab results, imaging reports with critical findings
  • clinical.care_plans - Treatment plans with activities, goals, and versioning
  • clinical.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 mapping
  • clinical.treatment_goals - Episode-specific measurable goals with progress tracking
  • clinical.progress_notes - Episode progress documentation with structured notes
  • Added episode_of_care_id to encounters for episode-encounter linking

Phase 5: Care Plans Completion

  • Added episode_of_care_id to goals table for Goal-Episode linkage
  • Added episode_of_care associations to CarePlan and Goal models
  • Added episode scopes (for_episode, with_episode, without_episode) to CarePlan and Goal
  • Added care_plans endpoint 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, consultation

RLS 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:

EntityServiceFHIR Resource
PatientClinical::Services::Fhir::PatientExportServicePatient
EncounterClinical::Services::Fhir::EncounterExportServiceEncounter (Bundle)
ObservationClinical::Services::Fhir::ObservationExportServiceObservation
ConditionClinical::Services::Fhir::ConditionExportServiceCondition
AllergyIntoleranceClinical::Services::Fhir::AllergyIntoleranceExportServiceAllergyIntolerance
MedicationRequestClinical::Services::Fhir::MedicationRequestExportServiceMedicationRequest
MedicationAdministrationClinical::Services::Fhir::MedicationAdministrationExportServiceMedicationAdministration
ServiceRequestClinical::Services::Fhir::ServiceRequestExportServiceServiceRequest
DiagnosticReportClinical::Services::Fhir::DiagnosticReportExportServiceDiagnosticReport
CarePlanClinical::Services::Fhir::CarePlanExportServiceCarePlan
GoalClinical::Services::Fhir::GoalExportServiceGoal
EpisodeOfCareClinical::Services::Fhir::EpisodeOfCareExportServiceEpisodeOfCare
TreatmentGoalClinical::Services::Fhir::TreatmentGoalExportServiceCustom
ProgressNoteClinical::Services::Fhir::ProgressNoteExportServiceCustom

Alternatives Considered

OptionProsCons
Flat columns for codesSimple queriesCannot store multiple codings
Separate coding tablesNormalizedComplex joins, poor performance
JSONB (chosen)Flexible, FHIR-nativeRequires JSONB indexing
OptionProsCons
Simple FK (entity_id only)SimpleCross-tenant reference possible
Composite FK (chosen)Tenant-safeMore 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

CategoryPhase 1Phase 2Phase 3Phase 4Phase 5Total
Migrations4787127 files
Models3563017 models
Blueprints3563017 blueprints
Controllers2563016 controllers
Policies3563017 policies
Services26104022 services
Model Specs3563017 spec files
Request Specs2563016 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:

  1. EpisodeOfCare - Core entity mapping to FHIR EpisodeOfCare
  2. TreatmentGoal - Episode-specific measurable goals
  3. 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 FieldFHIR EpisodeOfCare Field
statusstatus
patient_idpatient.reference
managing_organization_idmanagingOrganization.reference
care_manager_idcareManager.reference
episode_typetype
diagnosesdiagnosis
care_teamSeparate CareTeam resource
period_start, period_endperiod

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 export

Alternatives Considered

OptionProsCons
Single Episode entity onlySimpleNo structured goals/notes
Reuse Goal modelLess duplicationDifferent semantics (patient vs episode)
Separate schemaClean isolationUnnecessary complexity
Episode + TreatmentGoal + ProgressNote (chosen)Complete workflowMore 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:

  1. Suggestions must link to created clinical resources with DB-level integrity
  2. PHI must never be stored in audit logs
  3. Practitioner settings must support inheritance from facility defaults
  4. All AI data must be tenant-isolated via RLS

Decision

Implement a multi-schema AI integration with:

  1. Clinical Tables: transcription_sessions, ai_suggestions, ai_suggestion_links
  2. Audit Tables: llm_call_logs (no PHI)
  3. Identity Tables: facility_ai_settings, practitioner_ai_settings
  4. Normalized Link Table: Type-safe foreign keys instead of polymorphic associations
  5. NULLIF RLS Pattern: Safe tenant isolation when context is unset

Key Design Decisions

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
end

Behavior:

  • NULL in 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 set
  • NULLIF(..., '') converts to NULL
  • tenant_id = NULL returns 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 StoredWhat's NOT Stored
Token countsTranscript text
Latency, statusPatient names
Provider, modelClinical content
Cost estimatesSuggested 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

EnumValues
clinical.transcription_statusrecording, processing, completed, failed
clinical.suggestion_typediagnosis, medication, order, observation, care_plan, goal, allergy, procedure, follow_up
clinical.suggestion_statuspending, accepted, rejected, modified, expired
clinical.suggestion_priorityhigh, medium, low
clinical.linked_resource_typecondition, medication_request, observation, allergy_intolerance, service_request, care_plan, goal, procedure
audit.llm_provideropenai, anthropic, google, azure_openai, aws_bedrock
audit.llm_call_typetranscription, suggestion, summary, extraction, embedding, moderation
audit.llm_call_statussuccess, error, timeout, rate_limited

Alternatives Considered

DecisionChosenAlternativeWhy Chosen
Resource linkingNormalized FK tablePolymorphic associationDB-level integrity
Practitioner settingsSeparate table with NULLSingle facility_ai_settings tableCleaner override logic
RLS patternNULLIF patternSimple comparisonSafer when context unset
PHI in auditHash onlyStore encryptedSimpler 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

CategoryFiles
Migrations9 files (enums, tables, indexes, RLS)
Models6 models (TranscriptionSession, AiSuggestion, AiSuggestionLink, LlmCallLog, FacilityAiSettings, PractitionerAiSettings)
Model Specs6 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

  1. No tenant_id: Connect tables do not have tenant_id columns
  2. Practitioner Ownership: Data belongs to practitioners, not tenants
  3. Visibility Controls: Public/private/connections-only visibility per profile
  4. 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 false

Enums 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
end

3. 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?
end

5. 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
end

API Layer

ResourceEndpoints
Professional ProfilesGET /connect/professional_profiles, GET /:id, GET /me, PATCH /:id, POST /:id/publish, POST /:id/unpublish
ConnectionsGET /connect/connections, POST /, POST /:id/accept, POST /:id/reject, DELETE /:id, GET /pending, GET /sent
SpecializationsGET /connect/specializations, POST /, PATCH /:id, DELETE /:id
ExperienceGET /connect/experience_entries, POST /, PATCH /:id, DELETE /:id
EducationGET /connect/education_entries, POST /, PATCH /:id, DELETE /:id

Files Created

CategoryCountFiles
Migration1create_connect_tables.rb
Models6base_record, professional_profile, professional_connection, specialization, experience_entry, education_entry
Controllers6base_controller, professional_profiles, connections, specializations, experience_entries, education_entries
Blueprints5professional_profile, professional_connection, specialization, experience_entry, education_entry
Policies6base_policy, professional_profile, professional_connection, specialization, experience_entry, education_entry
Specs2professional_profiles_spec, connections_spec

Total: 30 tests passing

Alternatives Considered

OptionProsCons
Tenant-scoped profilesFamiliar patternProfiles tied to single tenant, not portable
Duplicate profiles per tenantRLS worksData duplication, sync complexity
Cross-tenant (chosen)Single profile per practitionerDifferent 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

OptionProsCons
Lazy-load queries on tab clickZero upfront costData not ready when user switches tabs, poor UX with loading spinners
Single batch API endpointOne network callBackend complexity, can't leverage React Query caching per category
Tab-based gating (chosen)Queries ready before tab is clicked, no backend changesSlightly more calls than lazy-load, requires tab ID alignment
No changeSimplest 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 activeTabIds preserves 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 useEncounterTypeConfig tab 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

ADRDecisionStatus
ADR-001Shared DB + RLS for multi-tenancyApproved
ADR-002PostgreSQL schemas per domainApproved
ADR-003Rename Clinic to FacilityApproved
ADR-004Multi-dimensional facility classificationApproved
ADR-005FHIR R4 resource mappingApproved
ADR-006Cross-tenant Connect schemaApproved
ADR-007Split AI data (clinical + audit)Approved
ADR-008Dedicated reference schemaApproved
ADR-009Comprehensive audit schemaApproved
ADR-010Enterprise benefits as cross-tenantApproved
ADR-011Pharma relations in separate DBApproved
ADR-012Vendor model for unlicensed providersApproved
ADR-013Service packages for bundled offeringsApproved
ADR-014Clinical trial anonymizationApproved
ADR-015Federated deployment modelApproved
ADR-016Domains directory architectureApproved
ADR-017Hybrid Service Object Pattern for APIApproved
ADR-018Tenant Context and Request-Scoped StateApproved
ADR-019Clinical Schema FHIR-First DesignApproved
ADR-020Episodes of Care and Long-Term Treatment ProgramsApproved
ADR-021AI Integration Data Model and PatternsApproved
ADR-022Connect Schema Implementation - Cross-Tenant Professional NetworkingApproved
ADR-023Frontend Encounter Query Gating by Practitioner TypeApproved

On this page

ADR-001: Multi-Tenancy StrategyContextDecisionOptions ConsideredApproachConsequencesADR-002: PostgreSQL Schema-per-DomainContextDecisionSchema OrganizationConsequencesADR-003: Facility vs Clinic NamingContextDecisionTerminology ChangeConsequencesADR-004: Multi-Dimensional Facility ClassificationContextDecisionClassification DimensionsDimension 1: Service LevelDimension 2: Facility ScaleDimension 3: Ownership ModelDimension 4: Specialty ScopeConsequencesADR-005: FHIR R4 Resource MappingContextDecisionResource MappingKey PatternsConsequencesADR-006: Cross-Tenant Connect ProductContextDecisionDesign PrinciplesConsequencesADR-007: AI-Assisted Care Data OrganizationContextDecisionData PlacementAI Suggestion LinkageConsequencesADR-008: Reference Data OrganizationContextDecisionCategoriesConsequencesADR-009: Audit and Compliance DataContextDecisionAudit CategoriesLog StructureConsequencesADR-010: Enterprise Benefits as Cross-Tenant SchemaContextDecisionRationaleDesignConsequencesADR-011: Pharma Relations in Separate DatabaseContextDecisionRationaleIntegration PatternConsequencesADR-012: Vendor Model for Unlicensed Service ProvidersContextDecisionClassificationDesignConsequencesADR-013: Service Packages for Bundled OfferingsContextDecisionDesignPackage ExampleConsequencesADR-014: Clinical Trial Anonymization PatternContextDecisionPatternProcessConsequencesADR-015: Federated Deployment ModelContextDecisionAlternatives ConsideredArchitectureSync ProtocolPatient Identity MatchingFeature AvailabilityConsequencesRelated DocumentationADR-016: Domains Directory Architecture for New SchemaContextDecisionDirectory StructureZeitwerk ConfigurationKey Design ChoicesAlternatives ConsideredMigration StrategyConsequencesADR-017: Hybrid Service Object Pattern for API LayerContextDecisionOptions ConsideredPattern ImplementationDecision MatrixService Object BaseController IntegrationAPI Response Format: snake_case KeysConfigurationExample OutputStandard Blueprint ViewsPundit Policies with Tenant ScopesConsequencesMigration PathADR-018: Tenant Context and Request-Scoped State ManagementContextDecisionImplementation1. Shared::Current (Request-Scoped Storage)2. TenantScoped Concern (Model Auto-Scoping)3. Sidekiq Middleware (Background Job Context)4. TenantAwareJob Concern (Explicit Job Context)Data FlowModels UpdatedAlternatives ConsideredConsequencesFiles Created/ModifiedADR-019: Clinical Schema FHIR-First DesignContextDecisionImplementation PhasesPhase 1: Patient CorePhase 2: Encounters & ObservationsPhase 3: Prescriptions & OrdersPhase 4: Episodes of CarePhase 5: Care Plans CompletionKey Design Patterns1. JSONB for FHIR Structures2. Composite Foreign Keys3. Encounter State Machine4. Observation Polymorphic ValuesPostgreSQL EnumsRLS PoliciesFHIR Export ServicesAlternatives ConsideredConsequencesFiles CreatedADR-020: Episodes of Care and Long-Term Treatment ProgramsContextDecisionDesign Patterns1. Episode-Entity Relationships2. Episode State Machine3. Diagnosis Tracking via JSONB4. Care Team via JSONBCarePlan and Goal Episode LinkageFHIR MappingAPI EndpointsAlternatives ConsideredConsequencesADR-021: AI Integration Data Model and PatternsContextDecisionKey Design Decisions1. Normalized Suggestion-Resource Links2. Per-Practitioner Settings with Override Semantics3. NULLIF RLS Pattern for Safety4. LLM Audit Logging Without PHIData ModelEnums CreatedAlternatives ConsideredConsequencesFiles CreatedADR-022: Connect Schema Implementation - Cross-Tenant Professional NetworkingContextDecisionDesign PrinciplesDatabase TablesEnums CreatedKey Design Patterns1. Bidirectional Unique Constraint2. Practitioner-Based Authorization3. Visibility Scopes4. Auto-Derived Current Flag5. Race-Safe Slug GenerationAPI LayerFiles CreatedAlternatives ConsideredConsequencesADR-023: Frontend Encounter Query Gating by Practitioner TypeContextDecisionImplementationQuery ClassificationAlternatives ConsideredConsequencesSummary of Decisions