Kulcare Docs
Developer

New Schema Developer Guide

This guide walks through the new PostgreSQL multi-schema architecture implemented for Kulcare. It explains what has been built, why decisions were made, and how to work with the new system.


Table of Contents

  1. Overview
  2. Schema Architecture
  3. Implementation Steps
  4. Working with Reference Tables
  5. Working with Identity Tables
  6. Working with Clinical Tables
  7. UUIDv7 Primary Keys
  8. Tenant Context and RLS
  9. Application-Level Tenant Context
  10. Migration Patterns
  11. Model Patterns
  12. API Layer Overview
  13. Blueprints (API Serialization)
  14. Reference API
  15. Identity API
  16. Clinical API
  17. Connect API
  18. Service Objects
  19. Policies (Authorization)
  20. Running API Tests
  21. Domains Directory Architecture

Overview

The new schema architecture organizes Kulcare's data into logical PostgreSQL schemas, enabling:

  • Clear domain separation: Clinical data separate from billing, growth, etc.
  • Row-Level Security (RLS): Automatic tenant isolation at the database level
  • FHIR compatibility: UUIDv7 primary keys and standardized identifiers
  • Deployment flexibility: Cloud vs on-premise schema subsets

Key Principle: Greenfield Approach

We're NOT migrating existing tables. New features use the new schemas while existing code continues to work with the public schema. This reduces risk and allows gradual adoption.


Schema Architecture

Ten PostgreSQL schemas organize data by domain:

SchemaPurposeTenant-ScopedRLS
referenceGlobal lookup data (countries, specialties)NoNo
identityTenants, organizations, facilities, usersYesYes
clinicalPatients, encounters, observationsYesYes
billingInvoices, payments, subscriptionsYesYes
growthCampaigns, leads (cloud only)YesYes
connectProfessional profiles (cross-tenant)PartialPartial
enterpriseEmployer benefits (cloud only)PartialPartial
integrationABDM, webhooks, external APIsYesYes
auditActivity logs, audit trailsYesYes
systemFeature flags, email templatesNoNo

Verification

Run the verification rake task to confirm all schemas are properly configured:

bundle exec rake new_schema:verify

Output shows status of schemas, tenant context functions, and RLS policies:

=== Verifying PostgreSQL Schemas ===
  [OK] reference
  [OK] identity
  ... (all 10 schemas)

=== Verifying Tenant Context Functions ===
  [OK] set_tenant_context
  [OK] current_tenant_id
  [OK] clear_tenant_context

=== Verifying Row-Level Security ===
  (tables checked once created)

Implementation Steps

Here's what has been implemented so far, in order:

Step 1: Create PostgreSQL Schemas

Migration: 20251231000001_create_new_pg_schemas.rb

Creates the 10 PostgreSQL schemas. Cloud-only schemas (growth, connect, enterprise) are skipped when DEPLOYMENT_MODE=onpremise.

# Key pattern: Conditional schema creation
SCHEMAS.each do |schema|
  next if cloud_only?(schema) && on_premise?
  execute "CREATE SCHEMA IF NOT EXISTS #{schema}"
end

Step 2: Create Tenant Context Functions

Migration: 20251231000002_create_tenant_context_function.rb

Creates PostgreSQL functions for setting and retrieving tenant context:

-- Set tenant context for current session
SELECT set_tenant_context('tenant-uuid-here');

-- Get current tenant ID (used by RLS policies)
SELECT current_tenant_id();

-- Clear tenant context
SELECT clear_tenant_context();

These functions store the tenant ID in a PostgreSQL session variable (app.current_tenant_id) that RLS policies reference.

Step 3: Add System Schema

Migration: 20251231000003_add_system_schema.rb

Adds the system schema for application configuration tables (feature flags, email templates, service types). This was added separately as it was missed in the initial schema list.

Step 4: Add UUIDv7 Support

Migration: 20251231000004_add_uuidv7_support.rb

Creates a PostgreSQL function to generate UUIDv7 (time-sortable UUIDs):

SELECT generate_uuidv7();
-- Returns: 019b7d57-2257-7b1e-bf8c-d08ffa8f5081

Benefits over UUIDv4:

  • Time-sortable: First 48 bits contain Unix timestamp
  • Better B-tree performance: Sequential inserts reduce page splits
  • RFC 9562 compliant: Standard format

Forward Compatibility: The function checks if native uuidv7() exists (expected in PostgreSQL 18+) and uses it when available. Otherwise, it falls back to our custom RFC 9562 implementation:

  • PostgreSQL 17: Uses custom implementation
  • PostgreSQL 18+: Automatically uses native function (better performance)
  • No code changes needed when upgrading PostgreSQL

This function must be created BEFORE any tables that use UUIDv7 primary keys.

Step 5: Create Reference Foundation Tables

Migration: 20251231000005_create_reference_foundation_tables.rb

Creates 8 foundation tables in the reference schema, all using UUIDv7 primary keys:

TablePurpose
countriesISO 3166 country codes
statesStates/provinces per country
citiesCities with tier classification
languagesISO 639 language codes
currenciesISO 4217 currency codes
specialtiesMedical specialty hierarchy
facility_typesHealthcare facility classifications
identifier_systemsFHIR identifier system registry

Key patterns:

  1. UUIDv7 primary keys:
UUIDV7_DEFAULT = -> { 'generate_uuidv7()' }

create_table :countries, id: false do |t|
  t.uuid :id, primary_key: true, null: false, default: UUIDV7_DEFAULT
  # ... other columns
end
  1. Explicit index naming to avoid conflicts with public schema:
# Use idx_ref_ prefix for reference schema indexes
add_index :countries, :alpha_2_code, unique: true, name: 'idx_ref_countries_alpha_2'

Step 6: Create Identity Foundation Tables

Migration: 20251231000006_create_identity_foundation_tables.rb

Creates 8 core identity tables with PostgreSQL enums for type safety:

TablePurposeRLS
tenantsMulti-tenant isolation rootNo (IS the tenant)
usersUser accounts (cross-tenant)No
user_membershipsLinks users to tenants with rolesNo — user-scoped bridge table; see row-level-security.md §2.4
organizationsHealthcare organizationsYes
facilitiesPhysical/virtual care locationsYes
facility_locationsMultiple addresses per facilityYes (via facility)
practitionersHealthcare practitioners (cross-tenant)No
practitioner_rolesPractitioner assignments to facilitiesYes

Enum types created:

  • tenant_status: draft, active, suspended, cancelled
  • plan_tier: free, starter, professional, enterprise
  • organization_type: healthcare_provider, hospital_system, clinic_group, etc.
  • service_level: primary, secondary, tertiary, quaternary
  • gender: male, female, other, prefer_not_to_say
  • membership_role: owner, admin, member, viewer
  • membership_status: pending, active, suspended, removed
  • practitioner_role_type: physician, nurse, technician, admin, etc.
  • practitioner_role_status: pending, active, on_leave, suspended, terminated

Step 7: Setup Identity RLS Policies

Migration: 20251231000007_setup_identity_rls.rb

Enables Row-Level Security on tenant-scoped identity tables:

-- Standard tenant isolation policy
CREATE POLICY tenant_isolation ON identity.facilities
  FOR ALL
  USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid);

-- Special policy for facility_locations (tenant via parent)
CREATE POLICY tenant_isolation ON identity.facility_locations
  FOR ALL
  USING (
    EXISTS (
      SELECT 1 FROM identity.facilities f
      WHERE f.id = facility_id
      AND f.tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid
    )
  );

Cross-tenant tables (tenants, users, practitioners) do NOT have RLS policies as they can be accessed across tenants.

User↔tenant bridge tables (user_memberships, sessions, profile_switches, refresh_tokens, devices) also do NOT have RLS — the row belongs to the user, the tenant_id is metadata describing one of the user's relationships, and the primary read pattern ("what tenants can this user access?") runs before a tenant has been selected. Authorization is enforced at the application layer (Pundit, scope-by-current_user). The original setup_identity_rls.rb migration incorrectly applied RLS to user_memberships; this was reverted by 20260520160000_disable_rls_on_user_memberships.rb after the auth-flow redesign exposed the bug. See row-level-security.md §7.4 for the full case study.


Working with Reference Tables

Reference tables contain global lookup data that is NOT tenant-scoped.

Model Location

Note: Reference models are currently in parts/reference/models/. They will be migrated to domains/reference/models/ in a future phase.

parts/reference/models/          # Current location (legacy)
  reference_record.rb            # Base class
  reference/
    country.rb
    state.rb
    city.rb
    language.rb
    currency.rb
    specialty.rb
    facility_type.rb
    identifier_system.rb

Using Reference Models

# Find a country by ISO code
country = Reference::Country.find_by_code('IN')

# Get active states for a country
states = Reference::State.where(country: country).active.alphabetical

# Find major medical specialties
specialties = Reference::Specialty.major.active.by_priority

# Get identifier systems for India (practitioners)
systems = Reference::IdentifierSystem.available_for(india.id, 'practitioner')

Base Class Pattern

All reference models inherit from ReferenceRecord:

class ReferenceRecord < ApplicationRecord
  self.abstract_class = true

  # All reference tables use UUID primary keys
  self.implicit_order_column = :created_at

  # Connect to the reference schema
  def self.table_name_prefix
    'reference.'
  end

  # Common scopes
  scope :active, -> { where(active: true) }
  scope :inactive, -> { where(active: false) }
  scope :alphabetical, -> { order(:name) }
end

Reference Seed Data

Reference tables are populated with seed data from JSON files in lib/data/reference/:

Data FileRecordsCoverage
countries.json25India, US, GCC (6), UK, Canada, etc.
languages.json28English, Hindi, Arabic, regional Indian
currencies.json29INR, USD, EUR, GBP, GCC currencies
indian_states.json36All states and union territories
us_states.json51All 50 states + DC
gcc_states.json55Emirates/regions for UAE, SA, QA, KW, BH, OM
indian_cities.json49Major metros and tier-1/tier-2 cities
us_cities.json40Major US cities
gcc_cities.json40Dubai, Abu Dhabi, Riyadh, Doha, etc.
specialties.json73Medical specialties with SNOMED codes
facility_types.json20Healthcare facility types with FHIR codes
identifier_systems.json20ABHA, Aadhaar, NPI, SSN, etc.

Seeding Reference Data

# Seed all reference data
bundle exec rake new_schema:seed_reference

# Verify reference data counts
bundle exec rake new_schema:verify_reference_data

Adding New Reference Data

  1. Create or update JSON file in lib/data/reference/
  2. Create seed script in db/seeds/reference/ (numbered for order)
  3. Use find_or_create_by! for idempotent seeding:
# db/seeds/reference/001_seed_countries.rb
countries_data.each do |country|
  Reference::Country.find_or_create_by!(alpha_2_code: country['alpha_2_code']) do |c|
    c.name = country['name']
    c.currency_code = country['currency_code']
    # ... other attributes
  end
end

Working with Identity Tables

Identity tables contain core organizational entities. Some are tenant-scoped (with RLS), others are cross-tenant.

Model Location

Note: Identity models are now in domains/identity/models/ (see Domains Directory Architecture section).

domains/identity/models/
  base_record.rb               # Base class for Identity models
  tenant.rb                    # Multi-tenant root (no RLS)
  user.rb                      # User accounts (no RLS, cross-tenant)
  user_membership.rb           # User-tenant bridge (no RLS — user-scoped, app-layer auth)
  organization.rb              # Healthcare organizations (RLS)
  facility.rb                  # Care locations (RLS)
  facility_location.rb         # Physical addresses (RLS via facility)
  practitioner.rb              # Healthcare practitioners (no RLS)
  practitioner_role.rb         # Facility assignments (RLS)
  healthcare_service.rb        # Services at facilities (RLS)
  vendor.rb                    # Unlicensed service providers (RLS)
  facility_vendor.rb           # Vendor-facility contracts (RLS)
  service_package.rb           # Bundled offerings (RLS)
  package_component.rb         # Package components (RLS)
  facility_ai_settings.rb      # AI settings per facility (RLS)

Understanding Cross-Tenant vs Tenant-Scoped

Identity tables fall into three categories. The RLS posture differs for each. See row-level-security.md §2.4 for the full classification rule and the case study that motivated it.

Cross-tenant entities (NO RLS) — the row exists independently of any tenant; deleting a tenant doesn't delete the row.

  • Tenant — IS the tenant
  • User — users can belong to multiple tenants via memberships
  • Practitioner — practitioners can work at multiple facilities/tenants
  • Patient (the identity.patients global row) — phone-keyed, cross-tenant identity

User↔tenant bridges (NO RLS) — row is owned by the user; tenant_id is metadata. The primary read path is "what does this user have?" which often runs before any tenant has been selected.

  • UserMembership, Session, ProfileSwitch, RefreshToken, Device

These rely on application-layer authorization (Pundit, scope-by-current_user), not on RLS. RLS would actively break the user-scoped read while adding no protection that the app layer doesn't already provide.

Tenant-scoped resources (WITH RLS) — the row is owned by the tenant; deleting the tenant cascades the row.

  • Organization, Facility, FacilityLocation, PractitionerRole, FacilitySetting, FacilityAiSetting, PractitionerAiSetting, HealthcareService, Vendor, FacilityVendor, ServicePackage, PackageComponent

These tables have RLS enabled and forced, with a tenant_isolation policy reading app.current_tenant_id.

Using Identity Models

# Create a tenant
tenant = Identity::Tenant.create!(
  name: 'Acme Healthcare',
  slug: 'acme-healthcare',
  status: 'active',
  plan_tier: 'professional'
)

# Create a user (cross-tenant, no tenant context needed)
user = Identity::User.create!(
  email: 'john@example.com',
  first_name: 'John',
  last_name: 'Doe',
  password: 'secure_password'
)

# Add user to tenant (requires tenant context for RLS)
ActiveRecord::Base.connection.execute("SELECT set_tenant_context('#{tenant.id}')")
membership = Identity::UserMembership.create!(
  tenant: tenant,
  user: user,
  role: 'admin',
  status: 'active'
)

# Create a facility
facility = Identity::Facility.create!(
  tenant: tenant,
  name: 'Downtown Clinic',
  service_level: 'primary',
  accepts_appointments: true
)

# Create a practitioner (cross-tenant)
practitioner = Identity::Practitioner.create!(
  user: user,
  first_name: 'John',
  last_name: 'Doe',
  display_name: 'Dr. John Doe, MD'
)

# Assign practitioner to facility (RLS)
role = Identity::PractitionerRole.create!(
  tenant: tenant,
  practitioner: practitioner,
  facility: facility,
  role_type: 'physician',
  status: 'active',
  consultation_fee: 500
)

FHIR Compatibility

Identity models include FHIR resource mappings:

# Get FHIR Organization representation
tenant.to_fhir_organization

# Get FHIR Practitioner representation
practitioner.to_fhir

# Get FHIR PractitionerRole representation
role.to_fhir

# Get FHIR Location representation
facility.to_fhir_location

Base Class Pattern

All identity models inherit from IdentityRecord:

class IdentityRecord < ApplicationRecord
  self.abstract_class = true
  self.implicit_order_column = :created_at

  def self.table_name_prefix
    'identity.'
  end

  scope :active, -> { where(active: true) }
  scope :inactive, -> { where(active: false) }
end

Working with Clinical Tables

Clinical tables contain patient health information, encounters, observations, conditions, and allergies. All clinical tables are tenant-scoped with RLS.

Model Location

Clinical models are in domains/clinical/models/:

domains/clinical/models/
  base_record.rb                  # Base class for Clinical models
  # Phase 1: Patient Core
  patient.rb                      # Patient demographics (RLS)
  patient_facility.rb             # Patient-facility registrations with MRN (RLS)
  patient_insurance_coverage.rb   # Insurance coverage tracking (RLS)
  # Phase 2: Encounters & Observations
  encounter.rb                    # Clinical encounters - OPD, IPD, etc. (RLS)
  encounter_participant.rb        # Practitioners involved in encounters (RLS)
  observation.rb                  # Vitals, lab results, measurements (RLS)
  condition.rb                    # Diagnoses, problems (RLS)
  allergy_intolerance.rb          # Patient allergies and intolerances (RLS)
  # Phase 3: Prescriptions & Orders
  medication_request.rb           # Prescriptions, medication orders (RLS)
  medication_administration.rb    # IPD medication delivery tracking (RLS)
  service_request.rb              # Lab/imaging/procedure orders (RLS)
  diagnostic_report.rb            # Lab results, imaging reports (RLS)
  care_plan.rb                    # Treatment plans with activities (RLS)
  goal.rb                         # Patient health goals (RLS)
  # Phase 4: Episodes of Care
  episode_of_care.rb              # Long-term care episodes (RLS)
  treatment_goal.rb               # Episode-specific treatment goals (RLS)
  progress_note.rb                # Episode progress documentation (RLS)

Clinical Models Summary

ModelPurposeFHIR ResourceKey Features
PatientCore patient demographicsPatientJSONB for identifiers, contact, names
PatientFacilityMulti-facility registrationsN/AFacility-specific MRN generation
PatientInsuranceCoverageInsurance trackingCoveragePrimary/secondary/tertiary priority
EncounterClinical visitsEncounterState machine, class (OPD/IPD/etc)
EncounterParticipantPractitioners in encounterN/AParticipant roles
ObservationVitals, lab resultsObservationPolymorphic values, LOINC codes
ConditionDiagnoses, problemsConditionICD-10/SNOMED coding, clinical status
AllergyIntolerancePatient allergiesAllergyIntoleranceReactions array, criticality
MedicationRequestPrescriptionsMedicationRequestState machine, dosage instructions, dispense
MedicationAdministrationIPD medication deliveryMedicationAdministrationTracks when/how medications were given
ServiceRequestLab/imaging/procedure ordersServiceRequestOrder workflows, fulfillment tracking
DiagnosticReportLab results, imaging reportsDiagnosticReportResults, critical findings, LOINC codes
CarePlanTreatment plansCarePlanActivities, goals, versioning, episode linkage
GoalPatient health goalsGoalTargets, achievement status, due dates, episode linkage
EpisodeOfCareLong-term care episodesEpisodeOfCareState machine, diagnosis tracking, team management
TreatmentGoalEpisode-specific goalsN/AEpisode-scoped, measurable targets, progress tracking
ProgressNoteEpisode progress notesN/AStructured notes, outcome tracking, next steps

Using Clinical Models

# Set tenant context (required for RLS)
Shared::Current.tenant = tenant

# Create a patient
patient = Clinical::Patient.create!(
  tenant: tenant,
  first_name: 'John',
  last_name: 'Doe',
  birth_date: Date.new(1985, 3, 15),
  gender: 'male',
  contact: {
    phone: [{ value: '+91-9876543210', use: 'mobile' }],
    email: [{ value: 'john@example.com' }],
    address: [{ city: 'Mumbai', state: 'Maharashtra', country: 'IN' }]
  }
)

# Register patient at a facility (auto-generates MRN)
patient_facility = Clinical::PatientFacility.create!(
  tenant: tenant,
  patient: patient,
  facility: facility,
  registration_type: 'opd',
  status: 'active'
)
# patient_facility.mrn => "MRN-2026-000001"

# Create an encounter
encounter = Clinical::Encounter.create!(
  tenant: tenant,
  patient: patient,
  facility: facility,
  status: 'arrived',
  encounter_class: 'ambulatory',
  start_time: Time.current
)

# Add a practitioner to the encounter
Clinical::EncounterParticipant.create!(
  tenant: tenant,
  encounter: encounter,
  practitioner: practitioner,
  role: 'primary'
)

# Record vital signs
Clinical::Observation.create!(
  tenant: tenant,
  patient: patient,
  encounter: encounter,
  performer: practitioner,
  status: 'final',
  category: 'vital_signs',
  code: { coding: [{ system: 'http://loinc.org', code: '8867-4', display: 'Heart rate' }] },
  value_type: 'quantity',
  value_data: { value: 72, unit: '/min', system: 'http://unitsofmeasure.org', code: '/min' },
  effective_datetime: Time.current
)

# Record a diagnosis
Clinical::Condition.create!(
  tenant: tenant,
  patient: patient,
  encounter: encounter,
  recorder: practitioner,
  clinical_status: 'active',
  verification_status: 'confirmed',
  category: 'encounter_diagnosis',
  severity: 'moderate',
  code: { coding: [{ system: 'http://hl7.org/fhir/sid/icd-10', code: 'J06.9', display: 'Acute upper respiratory infection' }] },
  recorded_date: Date.current
)

# Record an allergy
allergy = Clinical::AllergyIntolerance.create!(
  tenant: tenant,
  patient: patient,
  recorder: practitioner,
  clinical_status: 'active',
  verification_status: 'confirmed',
  allergy_type: 'allergy',
  category: 'medication',
  criticality: 'high',
  code: { coding: [{ display: 'Penicillin' }] },
  recorded_date: Date.current
)

# Add a reaction to the allergy
allergy.add_reaction(
  manifestations: [{ coding: [{ display: 'Anaphylaxis' }] }],
  severity: 'severe'
)
allergy.save!

Encounter State Machine

Encounters use a state machine for status transitions:

# Valid transitions
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: []
}

# Check if transition is valid
encounter.can_transition_to?(:in_progress)  # => true/false

# Transition encounter status
encounter.transition_to!(:in_progress)

Observation Polymorphic Values

Observations support multiple value types via value_type and value_data:

# Quantity value (most common for vitals)
observation.value_type = 'quantity'
observation.value_data = { value: 98.6, unit: 'Cel', system: 'http://unitsofmeasure.org' }

# String value
observation.value_type = 'string'
observation.value_data = { value: 'Patient reports mild discomfort' }

# Boolean value
observation.value_type = 'boolean'
observation.value_data = { value: true }

# CodeableConcept value
observation.value_type = 'code'
observation.value_data = { coding: [{ system: '...', code: 'positive', display: 'Positive' }] }

LOINC Codes for Common Vitals

Vital SignLOINC CodeUnit
Systolic BP8480-6mmHg
Diastolic BP8462-4mmHg
Heart Rate8867-4/min
Respiratory Rate9279-1/min
Temperature8310-5Cel
SpO22708-6%
Weight29463-7kg
Height8302-2cm
BMI39156-5kg/m2

FHIR Export

All clinical models support FHIR R4 export via blueprints:

# Export patient as FHIR
Clinical::PatientBlueprint.render_as_json(patient, view: :fhir)

# Export encounter as FHIR Bundle
Clinical::EncounterBlueprint.render_as_json(encounter, view: :fhir)

# Export observation as FHIR
Clinical::ObservationBlueprint.render_as_json(observation, view: :fhir)

# Using FHIR export services
result = Clinical::Services::Fhir::PatientExportService.call(patient: patient)
fhir_bundle = result.data

Base Class Pattern

All clinical models inherit from Clinical::BaseRecord:

module Clinical
  class BaseRecord < ApplicationRecord
    self.abstract_class = true
    self.implicit_order_column = :created_at

    def self.table_name_prefix
      'clinical.'
    end

    include Shared::Concerns::TenantScoped
  end
end

UUIDv7 Primary Keys

All new schema tables use UUIDv7 primary keys.

Why UUIDv7?

FeatureUUIDv4UUIDv7
RandomnessFully randomTimestamp + random
SortableNoYes (by creation time)
Index performancePoor (random inserts)Good (sequential inserts)
FHIR compatibleYesYes

Generating UUIDv7

-- In SQL
SELECT generate_uuidv7();

-- In Rails (happens automatically for new records)
country = Reference::Country.create!(alpha_2_code: 'US', name: 'United States')
country.id  # => "019b7d4b-17f3-7abb-bff4-f110682a261d"

UUIDv7 Structure

019b7d4b-17f3-7abb-bff4-f110682a261d
^^^^^^^^      ^
timestamp     version (7)
(48 bits)

The first 12 hex characters encode a Unix timestamp in milliseconds, making UUIDs naturally sortable by creation time.


Tenant Context and RLS

For tenant-scoped tables (identity, clinical, billing, etc.), Row-Level Security enforces data isolation.

Setting Tenant Context

Before querying tenant-scoped tables, set the tenant context:

# In a controller or service
ActiveRecord::Base.connection.execute(
  "SELECT set_tenant_context('#{current_tenant.id}')"
)

# Now all queries are automatically filtered by tenant
patients = Patient.all  # Only returns patients for current tenant

RLS Policy Pattern

RLS policies reference the tenant context function:

CREATE POLICY tenant_isolation ON clinical.patients
  FOR ALL
  USING (tenant_id = current_tenant_id());

This means:

  • Queries automatically filter by tenant
  • Inserts require matching tenant_id
  • No way to accidentally access other tenants' data

Application-Level Tenant Context

In addition to database-level RLS, the application layer provides convenient tenant context management.

Shared::Current (Request-Scoped Storage)

Shared::Current provides thread-safe, request-scoped storage for the current tenant, user, and membership:

# Access current context
Shared::Current.tenant      # => Identity::Tenant
Shared::Current.user        # => Identity::User
Shared::Current.membership  # => Identity::UserMembership

# Role-checking helpers
Shared::Current.owner?      # => true/false
Shared::Current.admin?      # => true/false
Shared::Current.staff?      # => true/false
Shared::Current.role        # => "owner" | "admin" | "staff" | "practitioner"

# Set context (usually done by TenantContext concern)
Shared::Current.tenant = tenant
Shared::Current.user = user
Shared::Current.membership = membership

# Reset at end of request (done automatically)
Shared::Current.reset

When you set Shared::Current.tenant, it automatically sets the PostgreSQL RLS context variable.

TenantScoped Concern (Model Auto-Scoping)

Models that include TenantScoped are automatically filtered by the current tenant:

# Model includes the concern
class Organization < BaseRecord
  include Shared::Concerns::TenantScoped
  # ...
end

# Queries automatically scope to current tenant
Shared::Current.tenant = tenant_a
Organization.count  # => Only counts Tenant A's organizations

# Create without explicit tenant_id (auto-assigned)
org = Organization.create!(name: 'New Org', organization_type: :clinic_group)
org.tenant_id  # => tenant_a.id

# Admin queries (bypass tenant scoping)
Organization.unscoped_by_tenant.count  # => All organizations across tenants
Organization.for_tenant(tenant_b)       # => Tenant B's organizations
Organization.for_tenant_id(tenant_b.id) # => Same, by ID

Models using TenantScoped:

  • Identity::Organization
  • Identity::Facility
  • Identity::PractitionerRole
  • Identity::HealthcareService

Two-layer enforcement, two-layer fix. TenantScoped works hand-in-hand with the database-level RLS policy on the same table: RLS filters rows postgres returns, TenantScoped injects the matching predicate into ActiveRecord-generated SQL. If you ever need to disable tenant-scoping on a model (e.g. you discover it's a user↔tenant bridge, not a tenant resource), both layers must come off together — leaving the include on a model whose RLS has been dropped produces a default_scope that fights any explicit where(tenant_id: ...) and silently returns zero rows. See row-level-security.md §2.4. Identity::UserMembership used to be on this list and was removed in the May 2026 audit (see §7.4 of the RLS guide for the case study) — the canonical example of why bridge tables don't get either form of tenant scoping.

TenantContext Concern (Controllers)

Controllers include TenantContext which automatically sets up context from JWT:

class MyController < Shared::BaseController
  # TenantContext is already included via Shared::BaseController
  # It sets Shared::Current.tenant, user, and membership from JWT

  def index
    # Shared::Current.tenant is already set
    @records = SomeModel.all  # Auto-scoped to current tenant
  end
end

The TenantContext concern:

  1. Extracts tenant_id from JWT claims
  2. Loads the tenant and sets Shared::Current.tenant
  3. Sets Shared::Current.user and Shared::Current.membership
  4. Sets PostgreSQL RLS context via set_config
  5. Clears context after the request completes

Background Jobs with Tenant Context

Sidekiq middleware automatically propagates tenant context to background jobs:

# Enqueueing a job (context captured automatically)
Shared::Current.tenant = tenant
SendNotificationJob.perform_later(notification_id)
# Job payload includes tenant_id and user_id

# Inside the job (context restored automatically)
class SendNotificationJob < ApplicationJob
  def perform(notification_id)
    # Shared::Current.tenant is available
    # All queries are scoped to the tenant
    notification = Notification.find(notification_id)
    notification.send!
  end
end

TenantAwareJob Concern (Explicit Job Context)

For jobs that need explicit tenant context control:

class CrossTenantReportJob < ApplicationJob
  include Shared::Concerns::TenantAwareJob

  def perform
    # Process data for each tenant
    each_tenant do |tenant|
      # Context is set for this tenant
      generate_report_for(tenant)
    end
  end
end

# Enqueue for a specific tenant (from console or non-request context)
ReportJob.perform_for_tenant(tenant, report_id)

# Switch context within a job
class DataMigrationJob < ApplicationJob
  include Shared::Concerns::TenantAwareJob

  def perform(source_tenant_id, target_tenant_id, data_id)
    data = with_tenant(source_tenant_id) do
      SomeModel.find(data_id).attributes
    end

    with_tenant(target_tenant_id) do
      SomeModel.create!(data)
    end
  end
end

TenantAwareJob Methods:

  • perform_for_tenant(tenant, *args) - Enqueue with explicit tenant
  • with_tenant(tenant_or_id) { } - Execute block in tenant context
  • each_tenant(scope = Tenant.all) { } - Iterate over tenants
  • tenant_context? - Check if context is set
  • current_tenant - Get current tenant
  • require_tenant_context! - Raise if no tenant

Background Job Files

domains/clinical/jobs/
  # Phase 6: AI Integration Jobs
  transcription_processing_job.rb      # Process transcription after recording ends
  suggestion_expiration_job.rb         # Expire stale AI suggestions (scheduled)

domains/audit/jobs/
  # Phase 6: AI Integration Jobs
  llm_usage_report_job.rb              # Generate LLM usage reports (scheduled)
  llm_logs_retention_job.rb            # Clean up old LLM logs per retention policy

Migration Patterns

Creating Tables in a Schema

def up
  # Set search path to target schema
  execute "SET search_path TO clinical, public"

  create_table :patients, id: :uuid do |t|
    t.uuid :tenant_id, null: false
    t.string :first_name, null: false
    # ... other columns
    t.timestamps
  end

  # Reset search path
  execute "SET search_path TO public"
end

Index Naming Convention

Use schema-prefixed names to avoid conflicts:

# Pattern: idx_{schema}_{table}_{columns}
add_index :patients, :tenant_id, name: 'idx_clinical_patients_tenant'
add_index :patients, [:last_name, :first_name], name: 'idx_clinical_patients_name'

RLS Setup

def up
  execute <<-SQL
    ALTER TABLE clinical.patients ENABLE ROW LEVEL SECURITY;
    ALTER TABLE clinical.patients FORCE ROW LEVEL SECURITY;

    CREATE POLICY tenant_isolation ON clinical.patients
      FOR ALL
      USING (tenant_id = current_tenant_id());
  SQL
end

Model Patterns

Schema-Specific Base Class

Each schema should have a base class:

# parts/clinical/models/clinical_record.rb
class ClinicalRecord < ApplicationRecord
  self.abstract_class = true

  def self.table_name_prefix
    'clinical.'
  end

  # All clinical tables are tenant-scoped
  belongs_to :tenant, class_name: 'Identity::Tenant'

  # Validation to ensure tenant_id is set
  validates :tenant_id, presence: true
end

Using the Base Class

# parts/clinical/models/clinical/patient.rb
module Clinical
  class Patient < ClinicalRecord
    has_many :encounters, class_name: 'Clinical::Encounter'
    has_many :patient_facilities, class_name: 'Clinical::PatientFacility'

    validates :first_name, presence: true
    # ... other validations
  end
end

API Layer Overview

The new schema includes a complete API layer with Blueprinter serialization, camelCase responses, and Pundit authorization.

API Base Path

The new schema uses clean, domain-based routing without API versioning in URLs:

DomainBase PathDescriptionAuthentication
Reference/reference/Global lookup dataNo (public endpoints)
Identity/identity/Tenants, organizations, facilities, practitionersYes (JWT required)

Note: API versioning can be implemented via headers in the future if needed (e.g., Accept: application/vnd.kulcare.v2+json).

Key Patterns

  1. Blueprinter Serialization: Fast, view-based JSON serialization
  2. camelCase Keys: All responses use JavaScript-style camelCase
  3. Standard Views: normal, detailed, select, fhir
  4. Pundit Authorization: Policy-based access control with tenant scopes
  5. Service Objects: Complex operations use Service Objects (not Interactors)

Blueprints (API Serialization)

Blueprints define how models are serialized to JSON. They replace ActiveModelSerializers.

Blueprint Location

domains/
  shared/
    base_blueprint.rb           # Base class with camelCase transform
  reference/
    blueprints/
      country_blueprint.rb
      state_blueprint.rb
      city_blueprint.rb
      language_blueprint.rb
      currency_blueprint.rb
      specialty_blueprint.rb
      facility_type_blueprint.rb
      identifier_system_blueprint.rb
  identity/
    blueprints/
      tenant_blueprint.rb
      user_blueprint.rb
      organization_blueprint.rb
      facility_blueprint.rb
      practitioner_blueprint.rb
      practitioner_role_blueprint.rb
      healthcare_service_blueprint.rb
      user_membership_blueprint.rb
      service_package_blueprint.rb
      package_component_blueprint.rb
      # Phase 6 AI Settings
      facility_ai_settings_blueprint.rb
      practitioner_ai_settings_blueprint.rb
  clinical/
    blueprints/
      # Phase 1: Patient Core
      patient_blueprint.rb
      patient_insurance_coverage_blueprint.rb
      # Phase 2: Encounters & Observations
      encounter_blueprint.rb
      encounter_participant_blueprint.rb
      observation_blueprint.rb
      condition_blueprint.rb
      allergy_intolerance_blueprint.rb
      # Phase 3: Prescriptions & Orders
      medication_request_blueprint.rb
      medication_administration_blueprint.rb
      service_request_blueprint.rb
      diagnostic_report_blueprint.rb
      care_plan_blueprint.rb
      goal_blueprint.rb
      # Phase 4: Episodes of Care
      episode_of_care_blueprint.rb
      treatment_goal_blueprint.rb
      progress_note_blueprint.rb
      # Phase 6: AI Integration
      transcription_session_blueprint.rb
      ai_suggestion_blueprint.rb
      ai_suggestion_link_blueprint.rb
  audit/
    blueprints/
      # Phase 6: AI Integration
      llm_call_log_blueprint.rb

camelCase Transformation

All blueprints inherit from Shared::BaseBlueprint which applies camelCase transformation:

# domains/shared/base_blueprint.rb
class Shared::BaseBlueprint < Blueprinter::Base
  transform CamelLowerTransformer  # snake_case -> camelCase

  # ISO8601 date/time formatting
  field :created_at do |obj|
    obj.created_at&.iso8601
  end
end

Database column: created_at, parent_id, is_active API Response: createdAt, parentId, isActive

Standard Views

All blueprints follow a consistent view pattern:

ViewPurposeUsage
normalDefault list/show responseCore fields for display
detailedFull resource detailsAll fields including computed
selectDropdown/autocompleteid, display name only
fhirFHIR R4 resource formatInteroperability exports

Blueprint Example

# domains/identity/blueprints/practitioner_blueprint.rb
module Identity
  class PractitionerBlueprint < Shared::BaseBlueprint
    identifier :id

    view :normal do
      fields :id, :first_name, :last_name, :display_name, :gender, :is_active

      field :full_name do |practitioner|
        practitioner.full_name
      end
    end

    view :detailed do
      include_view :normal
      fields :photo_url, :bio, :languages, :birth_date, :user_id

      field :mci_number do |practitioner|
        practitioner.mci_number
      end
    end

    view :select do
      fields :id, :display_name

      field :display do |practitioner|
        practitioner.display_name_or_full
      end
    end

    view :fhir do
      field :resource_type do
        'Practitioner'
      end

      field :name do |practitioner|
        [{
          use: 'official',
          family: practitioner.last_name,
          given: [practitioner.first_name],
          text: practitioner.full_name
        }]
      end
      # ... other FHIR fields
    end
  end
end

Using Blueprints in Controllers

# Render single resource
render_blueprint(@facility, FacilityBlueprint)
render_blueprint(@facility, FacilityBlueprint, view: :detailed)

# Render collection
render_blueprint_collection(facilities, FacilityBlueprint)

# Custom status
render_created(@facility, FacilityBlueprint)  # 201 status

Reference API

The Reference domain provides read-only access to global lookup data.

Endpoints

GET /reference/countries
GET /reference/countries/:id
GET /reference/countries/:id/states
GET /reference/states
GET /reference/states/:id
GET /reference/states/:id/cities
GET /reference/cities
GET /reference/cities/:id
GET /reference/languages
GET /reference/languages/:id
GET /reference/currencies
GET /reference/currencies/:id
GET /reference/specialties
GET /reference/specialties/:id
GET /reference/specialties/tree
GET /reference/specialties/:id/children
GET /reference/facility_types
GET /reference/facility_types/:id
GET /reference/identifier_systems
GET /reference/identifier_systems/:id
GET /reference/identifier_systems/available
GET /reference/all
GET /reference/geography

Query Parameters

Search & Filter:

  • q - Search by name (partial match, case-insensitive)
  • active - Filter by status (true/false)
  • page - Page number (default: 1)
  • per_page - Items per page (default: 50, max: 100)

Examples:

# Search countries
GET /reference/countries?q=india

# Filter active states
GET /reference/states?active=true&country_id={uuid}

# Paginated results
GET /reference/cities?page=2&per_page=20

# Get specialty tree
GET /reference/specialties/tree

# Bulk reference data
GET /reference/all?types=countries,languages,currencies

Response Format

{
  "success": true,
  "data": {
    "id": "019b7d57-2257-7b1e-bf8c-d08ffa8f5081",
    "name": "India",
    "alpha2Code": "IN",
    "alpha3Code": "IND",
    "phoneCode": "+91",
    "isActive": true
  }
}

Controller Location

domains/reference/controllers/
  countries_controller.rb
  states_controller.rb
  cities_controller.rb
  languages_controller.rb
  currencies_controller.rb
  specialties_controller.rb
  facility_types_controller.rb
  identifier_systems_controller.rb

Identity API

The Identity domain manages tenants, organizations, facilities, and practitioners.

Endpoints

# Tenant (current tenant context)
GET    /identity/tenant
GET    /identity/tenants           # Admin only

# Users
GET    /identity/users
GET    /identity/users/:id
PATCH  /identity/users/:id
GET    /identity/users/profile     # Current user profile

# Organizations
GET    /identity/organizations
GET    /identity/organizations/:id
POST   /identity/organizations
PATCH  /identity/organizations/:id
DELETE /identity/organizations/:id
GET    /identity/organizations/hierarchy  # Org hierarchy

# Facilities
GET    /identity/facilities
GET    /identity/facilities/:id
POST   /identity/facilities
PATCH  /identity/facilities/:id
DELETE /identity/facilities/:id
GET    /identity/facilities/:id/practitioners

# Practitioners
GET    /identity/practitioners
GET    /identity/practitioners/:id
POST   /identity/practitioners
PATCH  /identity/practitioners/:id
DELETE /identity/practitioners/:id
GET    /identity/practitioners/:id/roles
GET    /identity/practitioners/:id/fhir

# Practitioner Roles
GET    /identity/practitioner_roles
GET    /identity/practitioner_roles/:id
POST   /identity/practitioner_roles
PATCH  /identity/practitioner_roles/:id
DELETE /identity/practitioner_roles/:id
GET    /identity/practitioner_roles/:id/fhir

# Healthcare Services
GET    /identity/healthcare_services
GET    /identity/healthcare_services/:id
POST   /identity/healthcare_services
PATCH  /identity/healthcare_services/:id
DELETE /identity/healthcare_services/:id
GET    /identity/healthcare_services/:id/fhir

Query Parameters

Search & Filter:

  • search - Search by name (partial match, case-insensitive)
  • page - Page number (default: 1)
  • per_page - Items per page (default: 25, max: 100)

Examples:

# Search facilities
GET /identity/facilities?search=clinic

# Paginated practitioners
GET /identity/practitioners?page=2&per_page=20

# Get organization hierarchy
GET /identity/organizations/hierarchy

Controller Location

domains/identity/controllers/
  base_controller.rb              # Tenant context enforcement
  tenants_controller.rb
  users_controller.rb
  organizations_controller.rb
  facilities_controller.rb
  practitioners_controller.rb
  practitioner_roles_controller.rb
  healthcare_services_controller.rb

Clinical API

The Clinical domain manages patients, encounters, observations, conditions, and allergies.

Endpoints

# Patients
GET    /clinical/patients
GET    /clinical/patients/:id
POST   /clinical/patients
PATCH  /clinical/patients/:id
DELETE /clinical/patients/:id
GET    /clinical/patients/:id/facilities
GET    /clinical/patients/:id/encounters
GET    /clinical/patients/:id/observations
GET    /clinical/patients/:id/conditions
GET    /clinical/patients/:id/allergies
GET    /clinical/patients/:id/vitals
GET    /clinical/patients/:id/fhir

# Patient Registrations (Patient-Facility)
POST   /clinical/patients/:patient_id/facilities
GET    /clinical/patient_facilities/:id
PATCH  /clinical/patient_facilities/:id
DELETE /clinical/patient_facilities/:id

# Insurance Coverages
GET    /clinical/patients/:patient_id/insurance_coverages
POST   /clinical/patients/:patient_id/insurance_coverages
GET    /clinical/patient_insurance_coverages/:id
PATCH  /clinical/patient_insurance_coverages/:id
DELETE /clinical/patient_insurance_coverages/:id

# Encounters
GET    /clinical/encounters
GET    /clinical/encounters/:id
POST   /clinical/encounters
PATCH  /clinical/encounters/:id
DELETE /clinical/encounters/:id
POST   /clinical/encounters/:id/transition
GET    /clinical/encounters/:id/participants
GET    /clinical/encounters/:id/observations
GET    /clinical/encounters/:id/conditions
GET    /clinical/encounters/:id/fhir

# Encounter Participants
POST   /clinical/encounters/:encounter_id/participants
GET    /clinical/encounter_participants/:id
PATCH  /clinical/encounter_participants/:id
DELETE /clinical/encounter_participants/:id

# Observations
GET    /clinical/observations
GET    /clinical/observations/:id
POST   /clinical/observations
PATCH  /clinical/observations/:id
DELETE /clinical/observations/:id
GET    /clinical/observations/:id/fhir

# Conditions
GET    /clinical/conditions
GET    /clinical/conditions/:id
POST   /clinical/conditions
PATCH  /clinical/conditions/:id
DELETE /clinical/conditions/:id
GET    /clinical/conditions/:id/fhir

# Allergy Intolerances
GET    /clinical/allergy_intolerances
GET    /clinical/allergy_intolerances/:id
POST   /clinical/allergy_intolerances
PATCH  /clinical/allergy_intolerances/:id
DELETE /clinical/allergy_intolerances/:id
GET    /clinical/allergy_intolerances/:id/fhir

# Medication Requests (Prescriptions)
GET    /clinical/medication_requests
GET    /clinical/medication_requests/:id
POST   /clinical/medication_requests
PATCH  /clinical/medication_requests/:id
DELETE /clinical/medication_requests/:id
POST   /clinical/medication_requests/:id/activate
POST   /clinical/medication_requests/:id/complete
POST   /clinical/medication_requests/:id/cancel
GET    /clinical/medication_requests/:id/export

# Medication Administrations
GET    /clinical/medication_administrations
GET    /clinical/medication_administrations/:id
POST   /clinical/medication_administrations
PATCH  /clinical/medication_administrations/:id
DELETE /clinical/medication_administrations/:id
POST   /clinical/medication_administrations/:id/complete
GET    /clinical/medication_administrations/:id/export

# Service Requests (Lab/Imaging/Procedure Orders)
GET    /clinical/service_requests
GET    /clinical/service_requests/:id
POST   /clinical/service_requests
PATCH  /clinical/service_requests/:id
DELETE /clinical/service_requests/:id
POST   /clinical/service_requests/:id/activate
POST   /clinical/service_requests/:id/complete
POST   /clinical/service_requests/:id/fulfill
POST   /clinical/service_requests/:id/cancel
GET    /clinical/service_requests/:id/export

# Diagnostic Reports
GET    /clinical/diagnostic_reports
GET    /clinical/diagnostic_reports/:id
POST   /clinical/diagnostic_reports
PATCH  /clinical/diagnostic_reports/:id
DELETE /clinical/diagnostic_reports/:id
POST   /clinical/diagnostic_reports/:id/finalize
GET    /clinical/diagnostic_reports/:id/export

# Care Plans
GET    /clinical/care_plans
GET    /clinical/care_plans/:id
POST   /clinical/care_plans
PATCH  /clinical/care_plans/:id
DELETE /clinical/care_plans/:id
POST   /clinical/care_plans/:id/activate
POST   /clinical/care_plans/:id/complete
POST   /clinical/care_plans/:id/create_revision
GET    /clinical/care_plans/:id/export
GET    /clinical/care_plans/:id/version_history

# Goals
GET    /clinical/goals
GET    /clinical/goals/:id
POST   /clinical/goals
PATCH  /clinical/goals/:id
DELETE /clinical/goals/:id
POST   /clinical/goals/:id/accept
POST   /clinical/goals/:id/achieve
POST   /clinical/goals/:id/cancel
GET    /clinical/goals/:id/export

# Episodes of Care (Phase 4)
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
GET    /clinical/episodes_of_care/:id/encounters
GET    /clinical/episodes_of_care/:id/notes
GET    /clinical/episodes_of_care/:id/care_plans
GET    /clinical/episodes_of_care/:id/export

# Treatment Goals (Phase 4)
GET    /clinical/treatment_goals
GET    /clinical/treatment_goals/:id
POST   /clinical/treatment_goals
PATCH  /clinical/treatment_goals/:id
DELETE /clinical/treatment_goals/:id
POST   /clinical/treatment_goals/:id/achieve
GET    /clinical/treatment_goals/:id/export

# Progress Notes (Phase 4)
GET    /clinical/progress_notes
GET    /clinical/progress_notes/:id
POST   /clinical/progress_notes
PATCH  /clinical/progress_notes/:id
DELETE /clinical/progress_notes/:id
GET    /clinical/progress_notes/:id/export

Query Parameters

Search & Filter:

  • search - Search by patient name (partial match)
  • patient_id - Filter by patient
  • encounter_id - Filter by encounter
  • facility_id - Filter by facility
  • status - Filter by status
  • category - Filter observations/conditions by category
  • page - Page number (default: 1)
  • per_page - Items per page (default: 25, max: 100)

Examples:

# Search patients
GET /clinical/patients?search=john

# Get patient encounters at a facility
GET /clinical/patients/:id/encounters?facility_id=xxx

# Get vital signs for a patient
GET /clinical/observations?patient_id=xxx&category=vital_signs

# Get active conditions
GET /clinical/conditions?patient_id=xxx&clinical_status=active

# Transition encounter status
POST /clinical/encounters/:id/transition
Content-Type: application/json
{ "status": "in_progress" }

Response Format

{
  "success": true,
  "data": {
    "id": "019b7d57-2257-7b1e-bf8c-d08ffa8f5081",
    "firstName": "John",
    "lastName": "Doe",
    "birthDate": "1985-03-15",
    "gender": "male",
    "mrn": "MRN-2026-000001",
    "isActive": true,
    "createdAt": "2026-01-09T10:30:00Z"
  }
}

FHIR Export Endpoints

All clinical entities support FHIR R4 export:

# Phase 1 & 2 FHIR Exports
GET /clinical/patients/:id/fhir              # FHIR Patient
GET /clinical/encounters/:id/fhir            # FHIR Encounter Bundle
GET /clinical/observations/:id/fhir          # FHIR Observation
GET /clinical/conditions/:id/fhir            # FHIR Condition
GET /clinical/allergy_intolerances/:id/fhir  # FHIR AllergyIntolerance

# Phase 3 FHIR Exports
GET /clinical/medication_requests/:id/export       # FHIR MedicationRequest
GET /clinical/medication_administrations/:id/export # FHIR MedicationAdministration
GET /clinical/service_requests/:id/export          # FHIR ServiceRequest
GET /clinical/diagnostic_reports/:id/export        # FHIR DiagnosticReport
GET /clinical/care_plans/:id/export                # FHIR CarePlan
GET /clinical/goals/:id/export                     # FHIR Goal

# Phase 4 FHIR Exports
GET /clinical/episodes_of_care/:id/export          # FHIR EpisodeOfCare
GET /clinical/treatment_goals/:id/export           # Custom TreatmentGoal export
GET /clinical/progress_notes/:id/export            # Custom ProgressNote export

Controller Location

domains/clinical/controllers/
  base_controller.rb                         # Clinical-specific base controller
  # Phase 1 Controllers
  patients_controller.rb                     # Patient CRUD + nested resources
  patient_insurance_coverages_controller.rb  # Insurance coverage management
  # Phase 2 Controllers
  encounters_controller.rb                   # Encounter management + transitions
  encounter_participants_controller.rb       # Participant management
  observations_controller.rb                 # Observations/vitals CRUD
  conditions_controller.rb                   # Diagnosis/conditions CRUD
  allergy_intolerances_controller.rb         # Allergy management
  # Phase 3 Controllers
  medication_requests_controller.rb          # Prescription management + state machine
  medication_administrations_controller.rb   # IPD medication delivery tracking
  service_requests_controller.rb             # Lab/imaging order management
  diagnostic_reports_controller.rb           # Lab results/imaging reports
  care_plans_controller.rb                   # Treatment plan management + versioning (Phase 5: episode linkage)
  goals_controller.rb                        # Patient goal tracking (Phase 5: episode linkage)
  # Phase 4 Controllers
  episodes_of_care_controller.rb             # Episode management + state machine
  treatment_goals_controller.rb              # Episode-specific goal tracking
  progress_notes_controller.rb               # Episode progress documentation
  # Phase 6 Controllers
  transcription_sessions_controller.rb       # AI Scribe recording management
  ai_suggestions_controller.rb               # AI suggestion review and acceptance
  facility_ai_settings_controller.rb         # Facility-level AI configuration
  practitioner_ai_settings_controller.rb     # Per-practitioner AI overrides

Service Objects

domains/clinical/services/
  base_service.rb                              # Result pattern base
  # Phase 1 Services
  patients/
    registration_service.rb                    # Patient registration with auto-MRN
  # Phase 2 Services
  encounters/
    status_service.rb                          # Encounter state machine transitions
  vitals/
    recorder_service.rb                        # Batch vital sign recording
  # Phase 3 Services
  prescriptions/
    dispense_service.rb                        # Medication dispensing workflow
    refill_service.rb                          # Prescription refill handling
  orders/
    fulfillment_service.rb                     # Service request fulfillment
  # FHIR Export Services
  fhir/
    patient_export_service.rb                  # FHIR Patient Bundle export
    encounter_export_service.rb                # FHIR Encounter Bundle export
    observation_export_service.rb              # FHIR Observation export
    condition_export_service.rb                # FHIR Condition export
    allergy_intolerance_export_service.rb      # FHIR AllergyIntolerance export
    medication_request_export_service.rb       # FHIR MedicationRequest export
    medication_administration_export_service.rb # FHIR MedicationAdministration export
    service_request_export_service.rb          # FHIR ServiceRequest export
    diagnostic_report_export_service.rb        # FHIR DiagnosticReport export
    care_plan_export_service.rb                # FHIR CarePlan export
    goal_export_service.rb                     # FHIR Goal export
    episode_of_care_export_service.rb          # FHIR EpisodeOfCare export
    treatment_goal_export_service.rb           # TreatmentGoal export
    progress_note_export_service.rb            # ProgressNote export
  # Phase 4 Services
  episodes/
    management_service.rb                      # Episode state transitions
    enrollment_service.rb                      # Patient enrollment/discharge
  # Phase 6 Services
  ai/
    settings_resolver.rb                       # Merge facility + practitioner AI settings
    suggestion_acceptance_service.rb           # Accept/reject/modify suggestions
    transcription_service.rb                   # Transcription processing orchestration

Connect API

The Connect domain enables cross-tenant professional networking. Unlike other domains, Connect tables do NOT have tenant_id since practitioners maintain profiles independent of any single tenant.

Key Concepts

  1. Professional Profiles: Public profiles for practitioners, linked to identity.practitioners
  2. Connections: Bidirectional relationships between practitioners
  3. Specializations: Practitioner's areas of expertise with endorsement tracking
  4. Experience/Education: Career history and credentials

Cross-Tenant Design

Connect operates differently from tenant-scoped schemas:

AspectTenant-Scoped (Clinical, Identity)Cross-Tenant (Connect)
RLSYes, by tenant_idNo tenant_id
OwnershipTenant owns dataPractitioner owns data
AccessVia tenant membershipVia practitioner linkage
VisibilityPrivate to tenantPublic/private per profile

Connect Database Tables

connect.professional_profiles
  - id, practitioner_id (FK to identity.practitioners)
  - headline, bio, visibility (public/private/connections_only)
  - accepting_referrals, accepting_connections
  - slug (for public URLs)
  - connections_count, endorsements_count

connect.professional_connections
  - id, from_profile_id, to_profile_id
  - status (pending/accepted/rejected/blocked)
  - message, accepted_at
  - Bidirectional unique constraint via LEAST/GREATEST index

connect.specializations
  - id, professional_profile_id, specialty_id (FK to reference.specialties)
  - primary (boolean), years_experience
  - endorsements_count, verified

connect.experience_entries
  - id, professional_profile_id
  - organization, title, location
  - start_date, end_date, current
  - verified

connect.education_entries
  - id, professional_profile_id
  - institution, degree, field_of_study
  - start_year, end_year, verified

Connect API Endpoints

/connect/professional_profiles
  GET    /                    # List public profiles (search, filter)
  GET    /:id                 # Show profile by ID or slug
  GET    /me                  # Current user's profile (auto-creates if missing)
  GET    /search              # Search by name, specialty
  PATCH  /:id                 # Update own profile
  POST   /:id/publish         # Make profile public
  POST   /:id/unpublish       # Make profile private

/connect/connections
  GET    /                    # List accepted connections
  GET    /pending             # Incoming connection requests
  GET    /sent                # Outgoing pending requests
  POST   /                    # Send connection request
  POST   /:id/accept          # Accept request
  POST   /:id/reject          # Reject request
  DELETE /:id                 # Remove connection

/connect/specializations
  GET    /                    # List specializations for current profile
  POST   /                    # Add specialization
  PATCH  /:id                 # Update specialization
  DELETE /:id                 # Remove specialization

/connect/experience_entries
  GET    /                    # List experience for current profile
  POST   /                    # Add experience entry
  PATCH  /:id                 # Update entry
  DELETE /:id                 # Remove entry

/connect/education_entries
  GET    /                    # List education for current profile
  POST   /                    # Add education entry
  PATCH  /:id                 # Update entry
  DELETE /:id                 # Remove entry

Connect Models

domains/connect/models/
  base_record.rb              # Schema: connect, no tenant_id
  professional_profile.rb     # Main profile entity
  professional_connection.rb  # Connection between profiles
  specialization.rb           # Specialty with endorsements
  experience_entry.rb         # Work history
  education_entry.rb          # Education credentials

Connect Policies

Authorization in Connect is practitioner-based, not tenant-based:

# domains/connect/policies/professional_profile_policy.rb
class Connect::ProfessionalProfilePolicy < Connect::BasePolicy
  def show?
    profile_is_public? || owner? || connected?
  end

  def update?
    owner?
  end

  def publish?
    owner?
  end

  class Scope < Scope
    def resolve
      scope.visible_to(current_practitioner)
    end
  end
end

Connect Controller Patterns

# domains/connect/controllers/base_controller.rb
module Connect
  class BaseController < Shared::BaseController
    before_action :require_authentication!
    before_action :set_current_profile, only: [:index, :show]

    private

    def current_practitioner
      @current_practitioner ||= Identity::Practitioner.find_by(user_id: current_user&.id)
    end

    def current_profile
      @current_profile ||= ProfessionalProfile.find_by(practitioner_id: current_practitioner&.id)
    end

    def require_profile!
      return if current_profile.present?
      render_validation_errors(['Professional profile required'], status: :unprocessable_entity) and return
    end
  end
end

Connect Blueprints

# domains/connect/blueprints/professional_profile_blueprint.rb
module Connect
  class ProfessionalProfileBlueprint < Shared::BaseBlueprint
    identifier :id

    view :normal do
      fields :id, :slug, :headline, :visibility, :accepting_referrals,
             :connections_count, :endorsements_count

      field :practitioner_name do |profile|
        profile.practitioner&.display_name
      end
    end

    view :detailed do
      include_view :normal
      fields :bio, :accepting_connections

      association :specializations, blueprint: SpecializationBlueprint
      association :experience_entries, blueprint: ExperienceEntryBlueprint
      association :education_entries, blueprint: EducationEntryBlueprint
    end
  end
end

Key Implementation Details

  1. Slug Generation: Profiles generate URL-safe slugs from practitioner name with collision handling
  2. Auto-Create Profile: /me endpoint creates profile automatically if missing
  3. Bidirectional Connections: LEAST/GREATEST index prevents duplicate A->B and B->A
  4. Connection Counters: Maintained via callbacks on accept/destroy
  5. Primary Specialization: Only one specialization can be marked primary per profile

Service Objects

Complex operations use Service Objects instead of Interactors. Service Objects are plain Ruby classes with a call method that returns a Result.

When to Use Service Objects

Operation TypePatternExample
Simple CRUDDirect ControllerGET /tenants, POST /facilities
Create with related modelsService ObjectCreate organization with owner
Multi-step validationService ObjectUpdate practitioner roles
External API integrationService ObjectFHIR export, ABDM sync

Service Object Location

domains/identity/services/
  base_service.rb                      # Result pattern base
  tenants/
    onboard_service.rb                 # Create tenant with owner
  practitioners/
    create_service.rb                  # Create with initial role
    assign_role_service.rb             # Assign to facility
  fhir/
    practitioner_export_service.rb     # Export FHIR Bundle

Base Service Pattern

# 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

Service Object Example

# domains/identity/services/practitioners/create_service.rb
module Identity
  module Services
    module Practitioners
      class CreateService < BaseService
        def initialize(tenant:, user:, facility: nil, params:)
          @tenant = tenant
          @user = user
          @facility = facility
          @params = params
        end

        def call
          practitioner = Identity::Practitioner.new(
            tenant: @tenant,
            user: @user,
            **@params
          )

          if practitioner.save
            create_initial_role! if @facility
            success(practitioner)
          else
            failure(practitioner.errors.full_messages)
          end
        end

        private

        def create_initial_role!
          Identity::PractitionerRole.create!(
            tenant: @tenant,
            practitioner: practitioner,
            facility: @facility,
            status: 'active'
          )
        end
      end
    end
  end
end

Using Services in Controllers

def create
  authorize Identity::Practitioner

  result = Services::Practitioners::CreateService.call(
    tenant: current_tenant,
    user: find_or_create_user,
    facility: current_facility,
    params: practitioner_params
  )

  handle_service_result(result, PractitionerBlueprint, created: true)
end

Policies (Authorization)

Authorization uses Pundit with tenant-aware policies.

Policy Location

domains/identity/policies/
  tenant_policy.rb
  user_policy.rb
  organization_policy.rb
  facility_policy.rb
  practitioner_policy.rb
  practitioner_role_policy.rb
  healthcare_service_policy.rb

Base Policy Pattern

# domains/shared/base_policy.rb
class Shared::BasePolicy
  attr_reader :user, :record

  def initialize(user, record)
    @user = user
    @record = record
  end

  protected

  def tenant_member?
    user&.current_tenant_id.present?
  end

  def record_in_tenant?
    record.respond_to?(:tenant_id) &&
      record.tenant_id == user.current_tenant_id
  end

  def has_permission?(permission)
    user&.has_permission?(permission)
  end

  class Scope
    def initialize(user, scope)
      @user = user
      @scope = scope
    end

    def resolve
      raise NotImplementedError
    end

    protected

    attr_reader :user, :scope

    def tenant_member?
      user&.current_tenant_id.present?
    end
  end
end

Policy Example

# domains/identity/policies/facility_policy.rb
module Identity
  class FacilityPolicy < Shared::BasePolicy
    def index?
      tenant_member?
    end

    def show?
      tenant_member? && record_in_tenant?
    end

    def create?
      has_permission?(:manage_facilities)
    end

    def update?
      has_permission?(:manage_facilities) && record_in_tenant?
    end

    def destroy?
      has_permission?(:manage_facilities) && record_in_tenant?
    end

    class Scope < Scope
      def resolve
        return scope.none unless tenant_member?
        scope.where(tenant_id: user.current_tenant_id)
      end
    end
  end
end

Using Policies in Controllers

def index
  facilities = policy_scope(Identity::Facility)
  render_blueprint_collection(facilities, FacilityBlueprint)
end

def show
  authorize @facility
  render_blueprint(@facility, FacilityBlueprint, view: :detailed)
end

def create
  @facility = current_tenant.facilities.build(facility_params)
  authorize @facility

  if @facility.save
    render_created(@facility, FacilityBlueprint)
  else
    render_validation_errors(@facility.errors.full_messages)
  end
end

Running API Tests

Blueprint, request, and model tests are located in each domain's spec directory.

Test Directory Structure

domains/
  reference/
    spec/
      rails_helper.rb           # Reference domain test config
      blueprints/               # Blueprint serialization tests
        country_blueprint_spec.rb
        state_blueprint_spec.rb
        ...
      requests/                 # API integration tests
        countries_spec.rb
        states_spec.rb
        cities_spec.rb
        languages_spec.rb
        currencies_spec.rb
        specialties_spec.rb
        facility_types_spec.rb
        identifier_systems_spec.rb

  identity/
    spec/
      rails_helper.rb           # Identity domain test config
      blueprints/               # Blueprint serialization tests
        tenant_blueprint_spec.rb
        ...
      models/                   # Model unit tests
        tenant_spec.rb
      requests/                 # API integration tests
        tenants_spec.rb
        users_spec.rb
        organizations_spec.rb
        facilities_spec.rb
        practitioners_spec.rb
        practitioner_roles_spec.rb
        healthcare_services_spec.rb
        tenant_isolation_spec.rb    # RLS verification tests

Running Tests

# Run all Reference domain tests
bundle exec rspec backend/domains/reference/spec/

# Run all Identity domain tests
bundle exec rspec backend/domains/identity/spec/

# Run specific blueprint tests
bundle exec rspec backend/domains/identity/spec/blueprints/

# Run specific request tests (API integration)
bundle exec rspec backend/domains/reference/spec/requests/
bundle exec rspec backend/domains/identity/spec/requests/

# Run tenant isolation tests (RLS verification)
bundle exec rspec backend/domains/identity/spec/requests/tenant_isolation_spec.rb

# Run all domain tests
bundle exec rspec backend/domains/*/spec/

Request Spec Patterns

Request specs use mock tenant context for Identity endpoints:

# Mock tenant context for authenticated requests
before do
  allow_any_instance_of(Identity::FacilitiesController)
    .to receive(:current_tenant).and_return(tenant)
  allow_any_instance_of(Identity::FacilitiesController)
    .to receive(:current_user).and_return(double('User', id: SecureRandom.uuid))
end

All specs include schema availability guards:

before do
  skip 'Identity schema not available' unless Identity::Tenant.table_exists?
end

Key Test Patterns

Response Format Assertions:

# camelCase keys from Blueprinter
expect(json['data']['firstName']).to eq('John')
expect(json['data']['organizationType']).to eq('hospital_system')

# Pagination format
expect(json['meta']['pagination']).to include(
  'total_entries', 'current_page', 'per_page'
)

Search Parameter:

  • Reference domain uses q parameter: params: { q: 'India' }
  • Identity domain uses search parameter: params: { search: 'John' }

Tenant Isolation Tests (RLS Verification)

The tenant_isolation_spec.rb file verifies Row-Level Security works correctly:

  1. Organization Isolation: Users from Tenant A cannot see/modify Tenant B's organizations
  2. Facility Isolation: Facilities are scoped to their tenant
  3. Practitioner Isolation: Practitioners only visible through their roles in the tenant
  4. PractitionerRole Isolation: Role assignments are tenant-scoped
  5. HealthcareService Isolation: Services are tenant-scoped through facilities
  6. Cross-Tenant Creation Prevention: New resources always belong to current tenant
  7. Data Integrity After Tenant Switch: Switching tenants shows different data

Domains Directory Architecture

Note: New schema code uses the domains/ directory structure instead of parts/. This section explains the new architecture.

Why Domains Instead of Parts?

The existing parts/ directory is legacy code that should not be modified. New schema implementation uses:

  1. Blueprinter instead of ActiveModelSerializers
  2. RLS-aware models with tenant context
  3. Structured error handling with error codes
  4. Namespaced models (e.g., Identity::Tenant vs Clinic)

Directory Structure

backend/
├── parts/              # Legacy code (DO NOT MODIFY)
│   ├── 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
    │   ├── models/
    │   │   ├── base_record.rb      -> Identity::BaseRecord
    │   │   ├── tenant.rb           -> Identity::Tenant
    │   │   ├── user.rb             -> Identity::User
    │   │   └── ...
    │   ├── controllers/
    │   ├── blueprints/
    │   ├── policies/
    │   └── interactors/
    │
    ├── reference/      # Reference domain
    │   ├── models/
    │   ├── controllers/
    │   ├── blueprints/
    │   └── spec/
    │
    └── clinical/       # Clinical domain
        ├── models/
        │   ├── base_record.rb      -> Clinical::BaseRecord
        │   ├── patient.rb          -> Clinical::Patient
        │   ├── encounter.rb        -> Clinical::Encounter
        │   └── ...
        ├── controllers/
        ├── blueprints/
        ├── policies/
        ├── services/
        └── spec/

Zeitwerk Autoloading

The domains/ directory uses Zeitwerk push_dir with namespace configuration. This is set up in config/application.rb:

# From config/application.rb
initializer 'domains.configure_autoloader', before: :set_autoload_paths do
  domains_path = Rails.root.join('domains')
  loader = Rails.autoloaders.main

  Dir.glob(domains_path.join('*')).each do |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'
      loader.push_dir(domain_dir, namespace: namespace)
    else
      %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 benefit: Files don't need module Identity wrappers - Zeitwerk handles namespacing automatically.

Model File Structure

Files in domains/identity/models/tenant.rb:

# frozen_string_literal: true

module Identity
  class Tenant < BaseRecord
    # Model code...
  end
end

Shared Infrastructure

The domains/shared/ directory contains base classes used by all domains:

ClassPurpose
Shared::BaseControllerBase controller with Blueprinter rendering
Shared::BaseBlueprintBlueprint base with ISO8601 formatting
Shared::BasePolicyMulti-tenant Pundit policy base
Shared::BaseInteractorDomain interactor base
Shared::CurrentRequest-scoped storage (tenant, user, membership)
Shared::ErrorsStructured error classes
Shared::Concerns::TenantContextJWT to tenant context concern
Shared::Concerns::TenantScopedModel auto-scoping by tenant
Shared::Concerns::TenantAwareJobJob tenant context helpers
Shared::Concerns::SchemaErrorHandlerError rendering concern

Identity Models Available

Currently implemented in domains/identity/models/:

ModelDescriptionRLS
Identity::TenantMulti-tenancy rootNo
Identity::UserUser accounts (cross-tenant)No
Identity::UserMembershipUser-tenant-role linkingYes
Identity::OrganizationHealthcare organizationsYes
Identity::FacilityPhysical/virtual locationsYes
Identity::FacilityLocationAddresses per facilityYes
Identity::PractitionerHealthcare practitionersNo
Identity::PractitionerRolePractitioner-facility assignmentsYes
Identity::HealthcareServiceServices at facilitiesYes
Identity::VendorUnlicensed service providersYes
Identity::FacilityVendorVendor-facility contractsYes
Identity::ServicePackageBundled service offeringsYes
Identity::PackageComponentPackage componentsYes
Identity::FacilityAiSettingsAI configuration per facilityYes
Identity::PractitionerAiSettingsPer-practitioner AI setting overridesYes

Clinical Models Available

Currently implemented in domains/clinical/models/:

ModelDescriptionRLSFHIR Resource
Clinical::PatientPatient demographicsYesPatient
Clinical::PatientFacilityFacility registrations with MRNYesN/A
Clinical::PatientInsuranceCoverageInsurance coverage trackingYesCoverage
Clinical::EncounterClinical encounters (OPD/IPD/etc)YesEncounter
Clinical::EncounterParticipantPractitioners in encountersYesN/A
Clinical::ObservationVitals, lab resultsYesObservation
Clinical::ConditionDiagnoses, problemsYesCondition
Clinical::AllergyIntolerancePatient allergiesYesAllergyIntolerance
Clinical::MedicationRequestPrescriptions, medication ordersYesMedicationRequest
Clinical::MedicationAdministrationIPD medication deliveryYesMedicationAdministration
Clinical::ServiceRequestLab/imaging/procedure ordersYesServiceRequest
Clinical::DiagnosticReportLab results, imaging reportsYesDiagnosticReport
Clinical::CarePlanTreatment plansYesCarePlan
Clinical::GoalPatient health goalsYesGoal
Clinical::EpisodeOfCareLong-term care episodesYesEpisodeOfCare
Clinical::TreatmentGoalEpisode-specific goalsYesN/A
Clinical::ProgressNoteEpisode progress notesYesN/A
Clinical::TranscriptionSessionAI Scribe transcription dataYesN/A
Clinical::AiSuggestionAI-generated clinical suggestionsYesN/A
Clinical::AiSuggestionLinkLinks suggestions to created resourcesYesN/A

Audit Models Available

Currently implemented in domains/audit/models/:

ModelDescriptionRLS
Audit::LlmCallLogLLM API call audit trail (no PHI)Yes

Testing Autoloading

Verify all models load correctly:

# In rails console
Identity::Tenant           # Should load
Identity::User             # Should load
Shared::BaseController     # Should load
Shared::Errors::BaseError  # Should load

Or run:

bundle exec rails runner "
  [
    'Identity::Tenant',
    'Identity::User',
    'Shared::BaseController',
    'Shared::Errors'
  ].each { |c| puts c.constantize }
"

Quick Reference

Rake Tasks

# Verify all schemas exist
bundle exec rake new_schema:verify_schemas

# Verify tenant context functions
bundle exec rake new_schema:verify_functions

# Verify RLS is enabled
bundle exec rake new_schema:verify_rls

# Run all verifications
bundle exec rake new_schema:verify

# Show current status
bundle exec rake new_schema:status

# Seed reference data (countries, specialties, etc.)
bundle exec rake new_schema:seed_reference

# Verify reference data counts
bundle exec rake new_schema:verify_reference_data

# Reset schemas (development only)
bundle exec rake new_schema:reset

File Locations

backend/
  config/
    application.rb        # Zeitwerk domains/ configuration

  db/migrate/
    20251231000001_create_new_pg_schemas.rb
    20251231000002_create_tenant_context_function.rb
    20251231000003_add_system_schema.rb
    20251231000004_add_uuidv7_support.rb
    20251231000005_create_reference_foundation_tables.rb
    20251231000006_create_identity_foundation_tables.rb
    20251231000007_setup_identity_rls.rb

  db/seeds/
    seeds.rb              # Main seed loader
    reference/            # Reference schema seeds
      001_seed_countries.rb
      ...

  lib/data/reference/     # Seed data JSON files
    countries.json
    ...

  lib/tasks/
    new_schema.rake

  domains/                # NEW SCHEMA CODE (use this for new work)
    shared/               # Shared base classes
      base_controller.rb
      base_blueprint.rb
      base_policy.rb
      base_interactor.rb
      current.rb            # Request-scoped storage (Shared::Current)
      errors.rb
      camel_lower_transformer.rb
      concerns/
        tenant_context.rb
        tenant_scoped.rb    # Model auto-scoping concern
        tenant_aware_job.rb # Job tenant context helpers
        schema_error_handler.rb

  config/initializers/
    sidekiq_tenant_middleware.rb  # Sidekiq tenant context propagation

    reference/            # Reference domain (global lookup data)
      models/
        base_record.rb
        country.rb, state.rb, city.rb
        language.rb, currency.rb
        specialty.rb, facility_type.rb
        identifier_system.rb
      blueprints/
        country_blueprint.rb, state_blueprint.rb, city_blueprint.rb
        language_blueprint.rb, currency_blueprint.rb
        specialty_blueprint.rb, facility_type_blueprint.rb
        identifier_system_blueprint.rb
      controllers/
        countries_controller.rb, states_controller.rb, cities_controller.rb
        languages_controller.rb, currencies_controller.rb
        specialties_controller.rb, facility_types_controller.rb
        identifier_systems_controller.rb
      routes.rb
      spec/
        rails_helper.rb
        blueprints/       # Blueprint tests
        requests/         # API integration tests (8 controllers)

    identity/             # Identity domain (tenant-scoped)
      models/
        base_record.rb
        tenant.rb, user.rb, user_membership.rb
        organization.rb, facility.rb, facility_location.rb
        practitioner.rb, practitioner_role.rb
        healthcare_service.rb
        vendor.rb, facility_vendor.rb
        service_package.rb, package_component.rb
        facility_ai_settings.rb
      blueprints/
        tenant_blueprint.rb, user_blueprint.rb
        organization_blueprint.rb, facility_blueprint.rb
        practitioner_blueprint.rb, practitioner_role_blueprint.rb
        healthcare_service_blueprint.rb, user_membership_blueprint.rb
        service_package_blueprint.rb, package_component_blueprint.rb
      controllers/
        base_controller.rb
        tenants_controller.rb, users_controller.rb
        organizations_controller.rb, facilities_controller.rb
        practitioners_controller.rb, practitioner_roles_controller.rb
        healthcare_services_controller.rb
      services/
        base_service.rb
        tenants/onboard_service.rb
        practitioners/create_service.rb, assign_role_service.rb
        fhir/practitioner_export_service.rb
      policies/
        tenant_policy.rb, user_policy.rb
        organization_policy.rb, facility_policy.rb
        practitioner_policy.rb, practitioner_role_policy.rb
        healthcare_service_policy.rb
      routes.rb
      spec/
        rails_helper.rb
        blueprints/       # Blueprint tests
        models/           # Model unit tests
        requests/         # API integration tests (7 controllers + tenant isolation)

    clinical/             # Clinical domain (tenant-scoped, FHIR R4)
      models/
        base_record.rb
        patient.rb, patient_facility.rb, patient_insurance_coverage.rb
        encounter.rb, encounter_participant.rb
        observation.rb, condition.rb, allergy_intolerance.rb
      blueprints/
        patient_blueprint.rb, patient_facility_blueprint.rb
        patient_insurance_coverage_blueprint.rb
        encounter_blueprint.rb, encounter_participant_blueprint.rb
        observation_blueprint.rb, condition_blueprint.rb
        allergy_intolerance_blueprint.rb
      controllers/
        base_controller.rb
        patients_controller.rb, patient_insurance_coverages_controller.rb
        encounters_controller.rb, encounter_participants_controller.rb
        observations_controller.rb, conditions_controller.rb
        allergy_intolerances_controller.rb
      services/
        base_service.rb
        patients/registration_service.rb
        encounters/status_service.rb
        vitals/recorder_service.rb
        fhir/patient_export_service.rb, encounter_export_service.rb
        fhir/observation_export_service.rb, condition_export_service.rb
        fhir/allergy_intolerance_export_service.rb
      policies/
        patient_policy.rb, patient_facility_policy.rb
        patient_insurance_coverage_policy.rb
        encounter_policy.rb, encounter_participant_policy.rb
        observation_policy.rb, condition_policy.rb
        allergy_intolerance_policy.rb
      routes.rb
      spec/
        rails_helper.rb
        models/           # Model unit tests (8 models)
        requests/         # API integration tests (7 controllers)

  parts/                  # LEGACY CODE (do not modify)
    reference/models/     # Legacy reference models
    identity/models/      # Legacy identity models

docs/new-schema/
  guides/
    architectural-decisions.md  # ADRs (ADR-001 to ADR-017)
    developer-guide.md          # This file
    testing-guide.md            # Testing instructions
    future-todos.md             # Upcoming work items
  plans/
    implementation-sequence.md
    authentication-system-design.md
    api-foundation-design.md
  schemas/
    reference.md
    identity.md

Care Plans Completion (Phase 5)

Phase 5 completes the Care Plans implementation by adding episode linkage between EpisodeOfCare, CarePlan, and Goal entities. This enables longitudinal care tracking where care plans and goals can be associated with specific treatment episodes.

Episode Linkage

CarePlan and Goal models now support optional association to an EpisodeOfCare:

# CarePlan with episode association
care_plan = Clinical::CarePlan.create!(
  tenant_id: current_tenant.id,
  patient_id: patient.id,
  author_id: current_practitioner.id,
  episode_of_care_id: episode.id,  # Optional episode linkage
  status: 'active',
  title: "Diabetes Management Plan"
)

# Goal with episode association
goal = Clinical::Goal.create!(
  tenant_id: current_tenant.id,
  patient_id: patient.id,
  care_plan_id: care_plan.id,
  episode_of_care_id: episode.id,  # Optional episode linkage
  lifecycle_status: 'active',
  description: "Reduce HbA1c to below 7%"
)

Episode Filtering Scopes

Query care plans and goals by episode:

# Get care plans for a specific episode
Clinical::CarePlan.for_episode(episode.id)

# Get goals linked to any episode
Clinical::Goal.with_episode

# Get standalone goals (not linked to an episode)
Clinical::Goal.without_episode

# Episode has_many associations
episode.care_plans  # All care plans in this episode
episode.goals       # All goals in this episode

FHIR Export with Episode Reference

FHIR exports include the episode reference when present:

# CarePlan FHIR export includes episodeOfCare reference
result = Clinical::Services::Fhir::CarePlanExportService.call(care_plan: care_plan)
# {
#   "resourceType": "CarePlan",
#   "episodeOfCare": [{ "reference": "EpisodeOfCare/uuid-here" }],
#   ...
# }

API Endpoints

Care plans and goals can be filtered by episode:

GET /clinical/care_plans?episode_of_care_id=:id
GET /clinical/goals?episode_of_care_id=:id

# Nested routes for episode-scoped resources
GET /clinical/episodes_of_care/:id/care_plans
GET /clinical/episodes_of_care/:id/goals

AI Integration (Phase 6)

The AI Integration phase adds support for AI Scribe transcription and clinical decision support suggestions.

AI Integration Architecture

Encounter
    |
    +-- TranscriptionSession (AI Scribe recording)
    |       |
    |       +-- AiSuggestions (derived from transcript)
    |               |
    |               +-- AiSuggestionLink (to created resource)
    |
    +-- FacilityAiSettings (facility-level config)
            |
            +-- PractitionerAiSettings (per-practitioner overrides)
SchemaTableDescription
clinicaltranscription_sessionsAI Scribe recording and transcript data
clinicalai_suggestionsAI-generated clinical suggestions
clinicalai_suggestion_linksLinks accepted suggestions to created resources
auditllm_call_logsLLM API audit trail (no PHI)
identityfacility_ai_settingsFacility-level AI configuration
identitypractitioner_ai_settingsPer-practitioner setting overrides

Working with Transcription Sessions

# Create a transcription session for an encounter
session = Clinical::TranscriptionSession.create!(
  tenant_id: current_tenant.id,
  encounter_id: encounter.id,
  practitioner_id: current_practitioner.id,
  status: 'recording'
)

# Complete a transcription session
session.complete!(
  raw_transcript: transcript_text,
  processed_transcript: { segments: [...] },
  summary: "Patient presents with...",
  extracted_entities: { symptoms: [...], medications: [...] },
  model_version: "whisper-large-v3",
  confidence_score: 0.95
)

# Query transcription sessions
Clinical::TranscriptionSession.for_encounter(encounter.id)
Clinical::TranscriptionSession.for_practitioner(practitioner.id)
Clinical::TranscriptionSession.active  # recording or processing
Clinical::TranscriptionSession.completed
Clinical::TranscriptionSession.recent

Working with AI Suggestions

# Create an AI suggestion
suggestion = Clinical::AiSuggestion.create!(
  tenant_id: current_tenant.id,
  transcription_session_id: session.id,
  encounter_id: encounter.id,
  patient_id: patient.id,
  suggestion_type: 'diagnosis',
  status: 'pending',
  suggested_resource: {
    code: { coding: [{ system: 'http://hl7.org/fhir/sid/icd-10', code: 'J06.9' }] },
    clinical_status: 'active'
  },
  display_text: "Acute upper respiratory infection",
  reasoning: "Patient presents with sore throat, runny nose, and mild fever...",
  confidence_score: 0.87,
  priority: 'medium',
  expires_at: 24.hours.from_now
)

# Accept a suggestion
suggestion.accept!(
  practitioner: current_practitioner,
  modified_resource: nil,  # or modified FHIR resource
  notes: "Confirmed based on examination"
)

# Reject a suggestion
suggestion.reject!(
  practitioner: current_practitioner,
  notes: "Different diagnosis based on lab results"
)

# Query suggestions
Clinical::AiSuggestion.for_encounter(encounter.id)
Clinical::AiSuggestion.pending
Clinical::AiSuggestion.high_confidence(0.8)
Clinical::AiSuggestion.by_type('diagnosis')
Clinical::AiSuggestion.not_expired

# Check if suggestion created a resource
if suggestion.created_resource?
  resource = suggestion.created_resource  # Returns the linked Condition, etc.
end

When a suggestion is accepted, create a link to the created resource:

# After creating a clinical resource from accepted suggestion
condition = Clinical::Condition.create!(resource_params)

# Link the suggestion to the created resource
Clinical::AiSuggestionLink.create_for_resource!(
  suggestion: suggestion,
  resource: condition
)

# The link table uses normalized columns for type-safe FKs:
# - condition_id
# - medication_request_id
# - observation_id
# - allergy_intolerance_id
# - service_request_id
# - care_plan_id
# - goal_id

AI Settings Resolution

Settings cascade from facility to practitioner with override semantics:

# Get effective AI settings for a practitioner at a facility
resolver = Shared::AiSettingsResolver.for(
  facility: facility,
  practitioner: practitioner
)

# Check capabilities
resolver.ai_enabled?              # Master toggle
resolver.transcription_enabled?   # Can use AI Scribe
resolver.suggestions_enabled?     # Can receive suggestions
resolver.auto_documentation_enabled?

# Get configuration values
resolver.ai_provider              # 'openai', 'anthropic', etc.
resolver.ai_model                 # 'gpt-4', 'claude-3-opus', etc.
resolver.confidence_threshold     # Minimum confidence for suggestions
resolver.language                 # 'en', 'hi', etc.
resolver.suggestion_expiry_time   # Time when suggestions expire

# Get all effective settings as hash
resolver.effective_settings
# => {
#   ai_enabled: true,
#   transcription_enabled: true,
#   suggestions_enabled: true,
#   ai_provider: 'openai',
#   ai_model: 'gpt-4',
#   confidence_threshold: 0.75,
#   has_practitioner_overrides: true
# }

Per-Practitioner Override Semantics

Practitioner settings use NULL = inherit pattern:

# Facility settings (base)
facility_settings = Identity::FacilityAiSettings.find_by(facility_id: facility.id)
# => { transcription_enabled: true, suggestions_enabled: true, confidence_threshold: 0.75 }

# Practitioner override (NULL means inherit, explicit value means override)
practitioner_settings = Identity::PractitionerAiSettings.find_by(
  practitioner_id: practitioner.id,
  facility_id: facility.id
)
# => { transcription_enabled: nil, suggestions_enabled: false, confidence_threshold: 0.9 }

# Effective settings:
# - transcription_enabled: true (inherited from facility)
# - suggestions_enabled: false (overridden by practitioner)
# - confidence_threshold: 0.9 (overridden by practitioner)

LLM Call Logging

All LLM API calls are logged for audit without storing PHI:

# Log an LLM call
Audit::LlmCallLog.create!(
  tenant_id: current_tenant.id,
  facility_id: facility.id,
  call_type: 'transcription',
  provider: 'openai',
  model: 'whisper-large-v3',
  source_type: 'transcription_session',
  source_id: session.id,
  input_tokens: 1500,
  output_tokens: 800,
  total_tokens: 2300,
  latency_ms: 2500,
  status: 'success',
  confidence_score: 0.95,
  estimated_cost_usd: 0.0023,
  input_hash: Digest::SHA256.hexdigest(input_text)  # Hashed, no PHI
)

# Query logs for analytics
Audit::LlmCallLog.for_facility(facility.id)
Audit::LlmCallLog.successful
Audit::LlmCallLog.failed
Audit::LlmCallLog.by_provider('openai')
Audit::LlmCallLog.by_type('transcription')
Audit::LlmCallLog.today
Audit::LlmCallLog.this_month

# Analytics methods
Audit::LlmCallLog.total_tokens_used(scope)
Audit::LlmCallLog.total_cost(scope)
Audit::LlmCallLog.error_rate(scope)
Audit::LlmCallLog.average_latency(scope)

RLS Policies for AI Tables

All AI-related tables use the NULLIF pattern for safe RLS:

-- Blocks access when tenant context is not set
CREATE POLICY tenant_isolation ON clinical.ai_suggestions
  FOR ALL
  USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid);

This pattern ensures:

  • NULLIF(..., '') converts empty string to NULL when context not set
  • tenant_id = NULL returns NULL (falsy), blocking access
  • No data is exposed when tenant context is missing

API Layer Components

Phase 6 adds the following API layer components (see file listings in Blueprints, Controllers, and Services sections):

ComponentFilesDescription
Controllers4 filesTranscriptionSessions, AiSuggestions, FacilityAiSettings, PractitionerAiSettings
Blueprints6 filesSession, Suggestion, SuggestionLink, FacilitySettings, PractitionerSettings, LlmCallLog
Services3 filesAiSettingsResolver, SuggestionAcceptanceService, TranscriptionService
Policies4 filesAuthorization for each controller

Background Jobs

Phase 6 includes scheduled and event-driven background jobs:

JobSchedulePurpose
TranscriptionProcessingJobOn recording endProcess transcript, extract entities, generate suggestions
SuggestionExpirationJobHourlyExpire suggestions past their expires_at timestamp
LlmUsageReportJobDailyGenerate LLM usage reports for cost tracking
LlmLogsRetentionJobWeeklyClean up logs per retention policy (success: 365d, error: 180d, timeout: 90d)

API Endpoints

# Transcription Sessions
GET    /clinical/transcription_sessions
POST   /clinical/transcription_sessions
GET    /clinical/transcription_sessions/:id
PATCH  /clinical/transcription_sessions/:id
POST   /clinical/transcription_sessions/:id/complete
POST   /clinical/transcription_sessions/:id/fail

# AI Suggestions
GET    /clinical/ai_suggestions
GET    /clinical/ai_suggestions/:id
POST   /clinical/ai_suggestions/:id/accept
POST   /clinical/ai_suggestions/:id/reject
POST   /clinical/ai_suggestions/:id/modify

# AI Settings (Identity domain)
GET    /identity/facilities/:facility_id/ai_settings
PATCH  /identity/facilities/:facility_id/ai_settings
GET    /identity/practitioners/:practitioner_id/facilities/:facility_id/ai_settings
PATCH  /identity/practitioners/:practitioner_id/facilities/:facility_id/ai_settings

Frontend Encounter Query Optimization

The CMS encounter page fetches clinical data through the useEncounterClinicalData hook. This hook supports query gating so that only the API calls relevant to the current practitioner type are executed.

How It Works

The hook accepts an optional activeTabIds parameter (the list of tab IDs configured for the current encounter type). Each specialty-specific query is gated by checking whether its corresponding tab is present:

// In useEncounterClinicalData.ts
export function useEncounterClinicalData(
  encounterId: string | undefined,
  patientId: string | undefined,
  performerId: string | undefined,
  episodeId?: string | undefined,
  activeTabIds?: string[]    // <-- gates specialty queries
)

The caller in EncounterPage.tsx derives tab IDs from encounterTypeConfig:

const activeTabIds = useMemo(
  () => encounterTypeConfig?.tabs?.map(t => t.id) || [],
  [encounterTypeConfig]
);

const { ... } = useEncounterClinicalData(
  encounterId, resolvedPatientId, performerId,
  clinicalEncounter?.episode_of_care_id,
  activeTabIds
);

Core vs Gated Queries

Core queries (always fire):

  • vitalsQuery -- vital signs
  • notesQuery -- exam observations
  • medsQuery -- medication requests
  • investigationsQuery -- service requests
  • adviceQuery -- advice observations
  • followUpQuery -- follow-up observations

Gated queries (only fire when their tab is active):

Tab IDQueries
dentaldentalFindingsQuery, dentalTreatmentsQuery
session-notessessionNotesQuery
assessmentsassessmentsQuery
treatment-plantreatmentGoalsQuery
homeworkhomeworkQuery
clinical-historyclinicalHistoryQuery
physio-assessmentphysioAssessmentQuery
exercisesexercisesQuery
progressprogressQuery
skin-examinationskinExaminationQuery
derm-proceduresdermProceduresQuery
photo-documentationphotoDocQuery
skin-assessmentskinAssessmentQuery
cosmetic-procedurescosmeticProceduresQuery
consent-managementconsentQuery
cardiac-examcardiacExaminationQuery
ecg-interpretationecgInterpretationQuery
cardiac-riskcardiacRiskQuery, cardiacProceduresQuery
surgical-proceduressurgicalProceduresQuery
post-op-assessmentpostOpAssessmentQuery
nutritional-assessmentnutritionalAssessmentQuery
meal-planmealPlanQuery
eye-examinationeyeExaminationQuery
vision-assessmentvisionAssessmentQuery, eyeProceduresQuery
ent-examinationentExaminationQuery
hearing-assessmenthearingAssessmentQuery, entProceduresQuery
orthopedic-examinationorthopedicExaminationQuery
orthopedic-proceduresorthopedicProceduresQuery
gynec-examinationgynecExaminationQuery
obstetric-assessmentobstetricAssessmentQuery, gynecProceduresQuery
pediatric-examinationpediatricExaminationQuery
immunizationimmunizationRecordQuery
growth-assessmentgrowthAssessmentQuery, pediatricProceduresQuery
pulmonary-examinationpulmonaryExaminationQuery
respiratory-assessmentrespiratoryAssessmentQuery, pulmonaryProceduresQuery

Backward Compatibility

When activeTabIds is undefined (e.g., called without the parameter), all queries fire as before. This preserves backward compatibility for any other callers of the hook.

Adding New Specialty Queries

When adding a new specialty:

  1. Add the query in useEncounterClinicalData.ts with hasTab('your-tab-id') gating
  2. Ensure the tab ID matches what is configured in useEncounterTypeConfig.ts
  3. Verify with pnpm typecheck --filter=@kulcare/cms

Next Steps

On this page

Table of ContentsOverviewKey Principle: Greenfield ApproachSchema ArchitectureVerificationImplementation StepsStep 1: Create PostgreSQL SchemasStep 2: Create Tenant Context FunctionsStep 3: Add System SchemaStep 4: Add UUIDv7 SupportStep 5: Create Reference Foundation TablesStep 6: Create Identity Foundation TablesStep 7: Setup Identity RLS PoliciesWorking with Reference TablesModel LocationUsing Reference ModelsBase Class PatternReference Seed DataSeeding Reference DataAdding New Reference DataWorking with Identity TablesModel LocationUnderstanding Cross-Tenant vs Tenant-ScopedUsing Identity ModelsFHIR CompatibilityBase Class PatternWorking with Clinical TablesModel LocationClinical Models SummaryUsing Clinical ModelsEncounter State MachineObservation Polymorphic ValuesLOINC Codes for Common VitalsFHIR ExportBase Class PatternUUIDv7 Primary KeysWhy UUIDv7?Generating UUIDv7UUIDv7 StructureTenant Context and RLSSetting Tenant ContextRLS Policy PatternApplication-Level Tenant ContextShared::Current (Request-Scoped Storage)TenantScoped Concern (Model Auto-Scoping)TenantContext Concern (Controllers)Background Jobs with Tenant ContextTenantAwareJob Concern (Explicit Job Context)Background Job FilesMigration PatternsCreating Tables in a SchemaIndex Naming ConventionRLS SetupModel PatternsSchema-Specific Base ClassUsing the Base ClassAPI Layer OverviewAPI Base PathKey PatternsBlueprints (API Serialization)Blueprint LocationcamelCase TransformationStandard ViewsBlueprint ExampleUsing Blueprints in ControllersReference APIEndpointsQuery ParametersResponse FormatController LocationIdentity APIEndpointsQuery ParametersController LocationClinical APIEndpointsQuery ParametersResponse FormatFHIR Export EndpointsController LocationService ObjectsConnect APIKey ConceptsCross-Tenant DesignConnect Database TablesConnect API EndpointsConnect ModelsConnect PoliciesConnect Controller PatternsConnect BlueprintsKey Implementation DetailsService ObjectsWhen to Use Service ObjectsService Object LocationBase Service PatternService Object ExampleUsing Services in ControllersPolicies (Authorization)Policy LocationBase Policy PatternPolicy ExampleUsing Policies in ControllersRunning API TestsTest Directory StructureRunning TestsRequest Spec PatternsKey Test PatternsTenant Isolation Tests (RLS Verification)Domains Directory ArchitectureWhy Domains Instead of Parts?Directory StructureZeitwerk AutoloadingModel File StructureShared InfrastructureIdentity Models AvailableClinical Models AvailableAudit Models AvailableTesting AutoloadingQuick ReferenceRake TasksFile LocationsCare Plans Completion (Phase 5)Episode LinkageEpisode Filtering ScopesFHIR Export with Episode ReferenceAPI EndpointsAI Integration (Phase 6)AI Integration ArchitectureAI-Related TablesWorking with Transcription SessionsWorking with AI SuggestionsAI Suggestion Link PatternAI Settings ResolutionPer-Practitioner Override SemanticsLLM Call LoggingRLS Policies for AI TablesAPI Layer ComponentsBackground JobsAPI EndpointsFrontend Encounter Query OptimizationHow It WorksCore vs Gated QueriesBackward CompatibilityAdding New Specialty QueriesNext Steps