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
- Overview
- Schema Architecture
- Implementation Steps
- Working with Reference Tables
- Working with Identity Tables
- Working with Clinical Tables
- UUIDv7 Primary Keys
- Tenant Context and RLS
- Application-Level Tenant Context
- Migration Patterns
- Model Patterns
- API Layer Overview
- Blueprints (API Serialization)
- Reference API
- Identity API
- Clinical API
- Connect API
- Service Objects
- Policies (Authorization)
- Running API Tests
- 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:
| Schema | Purpose | Tenant-Scoped | RLS |
|---|---|---|---|
reference | Global lookup data (countries, specialties) | No | No |
identity | Tenants, organizations, facilities, users | Yes | Yes |
clinical | Patients, encounters, observations | Yes | Yes |
billing | Invoices, payments, subscriptions | Yes | Yes |
growth | Campaigns, leads (cloud only) | Yes | Yes |
connect | Professional profiles (cross-tenant) | Partial | Partial |
enterprise | Employer benefits (cloud only) | Partial | Partial |
integration | ABDM, webhooks, external APIs | Yes | Yes |
audit | Activity logs, audit trails | Yes | Yes |
system | Feature flags, email templates | No | No |
Verification
Run the verification rake task to confirm all schemas are properly configured:
bundle exec rake new_schema:verifyOutput 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}"
endStep 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-d08ffa8f5081Benefits 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:
| Table | Purpose |
|---|---|
countries | ISO 3166 country codes |
states | States/provinces per country |
cities | Cities with tier classification |
languages | ISO 639 language codes |
currencies | ISO 4217 currency codes |
specialties | Medical specialty hierarchy |
facility_types | Healthcare facility classifications |
identifier_systems | FHIR identifier system registry |
Key patterns:
- 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- 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:
| Table | Purpose | RLS |
|---|---|---|
tenants | Multi-tenant isolation root | No (IS the tenant) |
users | User accounts (cross-tenant) | No |
user_memberships | Links users to tenants with roles | No — user-scoped bridge table; see row-level-security.md §2.4 |
organizations | Healthcare organizations | Yes |
facilities | Physical/virtual care locations | Yes |
facility_locations | Multiple addresses per facility | Yes (via facility) |
practitioners | Healthcare practitioners (cross-tenant) | No |
practitioner_roles | Practitioner assignments to facilities | Yes |
Enum types created:
tenant_status: draft, active, suspended, cancelledplan_tier: free, starter, professional, enterpriseorganization_type: healthcare_provider, hospital_system, clinic_group, etc.service_level: primary, secondary, tertiary, quaternarygender: male, female, other, prefer_not_to_saymembership_role: owner, admin, member, viewermembership_status: pending, active, suspended, removedpractitioner_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 todomains/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.rbUsing 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) }
endReference Seed Data
Reference tables are populated with seed data from JSON files in lib/data/reference/:
| Data File | Records | Coverage |
|---|---|---|
countries.json | 25 | India, US, GCC (6), UK, Canada, etc. |
languages.json | 28 | English, Hindi, Arabic, regional Indian |
currencies.json | 29 | INR, USD, EUR, GBP, GCC currencies |
indian_states.json | 36 | All states and union territories |
us_states.json | 51 | All 50 states + DC |
gcc_states.json | 55 | Emirates/regions for UAE, SA, QA, KW, BH, OM |
indian_cities.json | 49 | Major metros and tier-1/tier-2 cities |
us_cities.json | 40 | Major US cities |
gcc_cities.json | 40 | Dubai, Abu Dhabi, Riyadh, Doha, etc. |
specialties.json | 73 | Medical specialties with SNOMED codes |
facility_types.json | 20 | Healthcare facility types with FHIR codes |
identifier_systems.json | 20 | ABHA, 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_dataAdding New Reference Data
- Create or update JSON file in
lib/data/reference/ - Create seed script in
db/seeds/reference/(numbered for order) - 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
endWorking 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 tenantUser— users can belong to multiple tenants via membershipsPractitioner— practitioners can work at multiple facilities/tenantsPatient(theidentity.patientsglobal 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_locationBase 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) }
endWorking 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
| Model | Purpose | FHIR Resource | Key Features |
|---|---|---|---|
Patient | Core patient demographics | Patient | JSONB for identifiers, contact, names |
PatientFacility | Multi-facility registrations | N/A | Facility-specific MRN generation |
PatientInsuranceCoverage | Insurance tracking | Coverage | Primary/secondary/tertiary priority |
Encounter | Clinical visits | Encounter | State machine, class (OPD/IPD/etc) |
EncounterParticipant | Practitioners in encounter | N/A | Participant roles |
Observation | Vitals, lab results | Observation | Polymorphic values, LOINC codes |
Condition | Diagnoses, problems | Condition | ICD-10/SNOMED coding, clinical status |
AllergyIntolerance | Patient allergies | AllergyIntolerance | Reactions array, criticality |
MedicationRequest | Prescriptions | MedicationRequest | State machine, dosage instructions, dispense |
MedicationAdministration | IPD medication delivery | MedicationAdministration | Tracks when/how medications were given |
ServiceRequest | Lab/imaging/procedure orders | ServiceRequest | Order workflows, fulfillment tracking |
DiagnosticReport | Lab results, imaging reports | DiagnosticReport | Results, critical findings, LOINC codes |
CarePlan | Treatment plans | CarePlan | Activities, goals, versioning, episode linkage |
Goal | Patient health goals | Goal | Targets, achievement status, due dates, episode linkage |
EpisodeOfCare | Long-term care episodes | EpisodeOfCare | State machine, diagnosis tracking, team management |
TreatmentGoal | Episode-specific goals | N/A | Episode-scoped, measurable targets, progress tracking |
ProgressNote | Episode progress notes | N/A | Structured 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 Sign | LOINC Code | Unit |
|---|---|---|
| Systolic BP | 8480-6 | mmHg |
| Diastolic BP | 8462-4 | mmHg |
| Heart Rate | 8867-4 | /min |
| Respiratory Rate | 9279-1 | /min |
| Temperature | 8310-5 | Cel |
| SpO2 | 2708-6 | % |
| Weight | 29463-7 | kg |
| Height | 8302-2 | cm |
| BMI | 39156-5 | kg/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.dataBase 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
endUUIDv7 Primary Keys
All new schema tables use UUIDv7 primary keys.
Why UUIDv7?
| Feature | UUIDv4 | UUIDv7 |
|---|---|---|
| Randomness | Fully random | Timestamp + random |
| Sortable | No | Yes (by creation time) |
| Index performance | Poor (random inserts) | Good (sequential inserts) |
| FHIR compatible | Yes | Yes |
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 tenantRLS 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.resetWhen 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 IDModels using TenantScoped:
Identity::OrganizationIdentity::FacilityIdentity::PractitionerRoleIdentity::HealthcareService
Two-layer enforcement, two-layer fix.
TenantScopedworks hand-in-hand with the database-level RLS policy on the same table: RLS filters rows postgres returns,TenantScopedinjects 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 theincludeon a model whose RLS has been dropped produces adefault_scopethat fights any explicitwhere(tenant_id: ...)and silently returns zero rows. See row-level-security.md §2.4.Identity::UserMembershipused 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
endThe TenantContext concern:
- Extracts
tenant_idfrom JWT claims - Loads the tenant and sets
Shared::Current.tenant - Sets
Shared::Current.userandShared::Current.membership - Sets PostgreSQL RLS context via
set_config - 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
endTenantAwareJob 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
endTenantAwareJob Methods:
perform_for_tenant(tenant, *args)- Enqueue with explicit tenantwith_tenant(tenant_or_id) { }- Execute block in tenant contexteach_tenant(scope = Tenant.all) { }- Iterate over tenantstenant_context?- Check if context is setcurrent_tenant- Get current tenantrequire_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 policyMigration 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"
endIndex 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
endModel 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
endUsing 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
endAPI 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:
| Domain | Base Path | Description | Authentication |
|---|---|---|---|
| Reference | /reference/ | Global lookup data | No (public endpoints) |
| Identity | /identity/ | Tenants, organizations, facilities, practitioners | Yes (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
- Blueprinter Serialization: Fast, view-based JSON serialization
- camelCase Keys: All responses use JavaScript-style camelCase
- Standard Views: normal, detailed, select, fhir
- Pundit Authorization: Policy-based access control with tenant scopes
- 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.rbcamelCase 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
endDatabase column: created_at, parent_id, is_active
API Response: createdAt, parentId, isActive
Standard Views
All blueprints follow a consistent view pattern:
| View | Purpose | Usage |
|---|---|---|
normal | Default list/show response | Core fields for display |
detailed | Full resource details | All fields including computed |
select | Dropdown/autocomplete | id, display name only |
fhir | FHIR R4 resource format | Interoperability 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
endUsing 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 statusReference 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/geographyQuery 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,currenciesResponse 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.rbIdentity 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/fhirQuery 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/hierarchyController 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.rbClinical 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/exportQuery Parameters
Search & Filter:
search- Search by patient name (partial match)patient_id- Filter by patientencounter_id- Filter by encounterfacility_id- Filter by facilitystatus- Filter by statuscategory- Filter observations/conditions by categorypage- 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 exportController 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 overridesService 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 orchestrationConnect 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
- Professional Profiles: Public profiles for practitioners, linked to
identity.practitioners - Connections: Bidirectional relationships between practitioners
- Specializations: Practitioner's areas of expertise with endorsement tracking
- Experience/Education: Career history and credentials
Cross-Tenant Design
Connect operates differently from tenant-scoped schemas:
| Aspect | Tenant-Scoped (Clinical, Identity) | Cross-Tenant (Connect) |
|---|---|---|
| RLS | Yes, by tenant_id | No tenant_id |
| Ownership | Tenant owns data | Practitioner owns data |
| Access | Via tenant membership | Via practitioner linkage |
| Visibility | Private to tenant | Public/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, verifiedConnect 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 entryConnect 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 credentialsConnect 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
endConnect 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
endConnect 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
endKey Implementation Details
- Slug Generation: Profiles generate URL-safe slugs from practitioner name with collision handling
- Auto-Create Profile:
/meendpoint creates profile automatically if missing - Bidirectional Connections: LEAST/GREATEST index prevents duplicate A->B and B->A
- Connection Counters: Maintained via callbacks on accept/destroy
- 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 Type | Pattern | Example |
|---|---|---|
| Simple CRUD | Direct Controller | GET /tenants, POST /facilities |
| Create with related models | Service Object | Create organization with owner |
| Multi-step validation | Service Object | Update practitioner roles |
| External API integration | Service Object | FHIR 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 BundleBase 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
endService 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
endUsing 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)
endPolicies (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.rbBase 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
endPolicy 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
endUsing 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
endRunning 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 testsRunning 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))
endAll specs include schema availability guards:
before do
skip 'Identity schema not available' unless Identity::Tenant.table_exists?
endKey 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
qparameter:params: { q: 'India' } - Identity domain uses
searchparameter:params: { search: 'John' }
Tenant Isolation Tests (RLS Verification)
The tenant_isolation_spec.rb file verifies Row-Level Security works correctly:
- Organization Isolation: Users from Tenant A cannot see/modify Tenant B's organizations
- Facility Isolation: Facilities are scoped to their tenant
- Practitioner Isolation: Practitioners only visible through their roles in the tenant
- PractitionerRole Isolation: Role assignments are tenant-scoped
- HealthcareService Isolation: Services are tenant-scoped through facilities
- Cross-Tenant Creation Prevention: New resources always belong to current tenant
- Data Integrity After Tenant Switch: Switching tenants shows different data
Domains Directory Architecture
Note: New schema code uses the
domains/directory structure instead ofparts/. 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:
- Blueprinter instead of ActiveModelSerializers
- RLS-aware models with tenant context
- Structured error handling with error codes
- Namespaced models (e.g.,
Identity::TenantvsClinic)
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
endKey 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
endShared Infrastructure
The domains/shared/ directory contains base classes used by all domains:
| Class | Purpose |
|---|---|
Shared::BaseController | Base controller with Blueprinter rendering |
Shared::BaseBlueprint | Blueprint base with ISO8601 formatting |
Shared::BasePolicy | Multi-tenant Pundit policy base |
Shared::BaseInteractor | Domain interactor base |
Shared::Current | Request-scoped storage (tenant, user, membership) |
Shared::Errors | Structured error classes |
Shared::Concerns::TenantContext | JWT to tenant context concern |
Shared::Concerns::TenantScoped | Model auto-scoping by tenant |
Shared::Concerns::TenantAwareJob | Job tenant context helpers |
Shared::Concerns::SchemaErrorHandler | Error rendering concern |
Identity Models Available
Currently implemented in domains/identity/models/:
| Model | Description | RLS |
|---|---|---|
Identity::Tenant | Multi-tenancy root | No |
Identity::User | User accounts (cross-tenant) | No |
Identity::UserMembership | User-tenant-role linking | Yes |
Identity::Organization | Healthcare organizations | Yes |
Identity::Facility | Physical/virtual locations | Yes |
Identity::FacilityLocation | Addresses per facility | Yes |
Identity::Practitioner | Healthcare practitioners | No |
Identity::PractitionerRole | Practitioner-facility assignments | Yes |
Identity::HealthcareService | Services at facilities | Yes |
Identity::Vendor | Unlicensed service providers | Yes |
Identity::FacilityVendor | Vendor-facility contracts | Yes |
Identity::ServicePackage | Bundled service offerings | Yes |
Identity::PackageComponent | Package components | Yes |
Identity::FacilityAiSettings | AI configuration per facility | Yes |
Identity::PractitionerAiSettings | Per-practitioner AI setting overrides | Yes |
Clinical Models Available
Currently implemented in domains/clinical/models/:
| Model | Description | RLS | FHIR Resource |
|---|---|---|---|
Clinical::Patient | Patient demographics | Yes | Patient |
Clinical::PatientFacility | Facility registrations with MRN | Yes | N/A |
Clinical::PatientInsuranceCoverage | Insurance coverage tracking | Yes | Coverage |
Clinical::Encounter | Clinical encounters (OPD/IPD/etc) | Yes | Encounter |
Clinical::EncounterParticipant | Practitioners in encounters | Yes | N/A |
Clinical::Observation | Vitals, lab results | Yes | Observation |
Clinical::Condition | Diagnoses, problems | Yes | Condition |
Clinical::AllergyIntolerance | Patient allergies | Yes | AllergyIntolerance |
Clinical::MedicationRequest | Prescriptions, medication orders | Yes | MedicationRequest |
Clinical::MedicationAdministration | IPD medication delivery | Yes | MedicationAdministration |
Clinical::ServiceRequest | Lab/imaging/procedure orders | Yes | ServiceRequest |
Clinical::DiagnosticReport | Lab results, imaging reports | Yes | DiagnosticReport |
Clinical::CarePlan | Treatment plans | Yes | CarePlan |
Clinical::Goal | Patient health goals | Yes | Goal |
Clinical::EpisodeOfCare | Long-term care episodes | Yes | EpisodeOfCare |
Clinical::TreatmentGoal | Episode-specific goals | Yes | N/A |
Clinical::ProgressNote | Episode progress notes | Yes | N/A |
Clinical::TranscriptionSession | AI Scribe transcription data | Yes | N/A |
Clinical::AiSuggestion | AI-generated clinical suggestions | Yes | N/A |
Clinical::AiSuggestionLink | Links suggestions to created resources | Yes | N/A |
Audit Models Available
Currently implemented in domains/audit/models/:
| Model | Description | RLS |
|---|---|---|
Audit::LlmCallLog | LLM 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 loadOr 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:resetFile 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.mdCare 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 episodeFHIR 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/goalsAI 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)AI-Related Tables
| Schema | Table | Description |
|---|---|---|
clinical | transcription_sessions | AI Scribe recording and transcript data |
clinical | ai_suggestions | AI-generated clinical suggestions |
clinical | ai_suggestion_links | Links accepted suggestions to created resources |
audit | llm_call_logs | LLM API audit trail (no PHI) |
identity | facility_ai_settings | Facility-level AI configuration |
identity | practitioner_ai_settings | Per-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.recentWorking 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.
endAI Suggestion Link Pattern
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_idAI 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 settenant_id = NULLreturns 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):
| Component | Files | Description |
|---|---|---|
| Controllers | 4 files | TranscriptionSessions, AiSuggestions, FacilityAiSettings, PractitionerAiSettings |
| Blueprints | 6 files | Session, Suggestion, SuggestionLink, FacilitySettings, PractitionerSettings, LlmCallLog |
| Services | 3 files | AiSettingsResolver, SuggestionAcceptanceService, TranscriptionService |
| Policies | 4 files | Authorization for each controller |
Background Jobs
Phase 6 includes scheduled and event-driven background jobs:
| Job | Schedule | Purpose |
|---|---|---|
TranscriptionProcessingJob | On recording end | Process transcript, extract entities, generate suggestions |
SuggestionExpirationJob | Hourly | Expire suggestions past their expires_at timestamp |
LlmUsageReportJob | Daily | Generate LLM usage reports for cost tracking |
LlmLogsRetentionJob | Weekly | Clean 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_settingsFrontend 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 signsnotesQuery-- exam observationsmedsQuery-- medication requestsinvestigationsQuery-- service requestsadviceQuery-- advice observationsfollowUpQuery-- follow-up observations
Gated queries (only fire when their tab is active):
| Tab ID | Queries |
|---|---|
dental | dentalFindingsQuery, dentalTreatmentsQuery |
session-notes | sessionNotesQuery |
assessments | assessmentsQuery |
treatment-plan | treatmentGoalsQuery |
homework | homeworkQuery |
clinical-history | clinicalHistoryQuery |
physio-assessment | physioAssessmentQuery |
exercises | exercisesQuery |
progress | progressQuery |
skin-examination | skinExaminationQuery |
derm-procedures | dermProceduresQuery |
photo-documentation | photoDocQuery |
skin-assessment | skinAssessmentQuery |
cosmetic-procedures | cosmeticProceduresQuery |
consent-management | consentQuery |
cardiac-exam | cardiacExaminationQuery |
ecg-interpretation | ecgInterpretationQuery |
cardiac-risk | cardiacRiskQuery, cardiacProceduresQuery |
surgical-procedures | surgicalProceduresQuery |
post-op-assessment | postOpAssessmentQuery |
nutritional-assessment | nutritionalAssessmentQuery |
meal-plan | mealPlanQuery |
eye-examination | eyeExaminationQuery |
vision-assessment | visionAssessmentQuery, eyeProceduresQuery |
ent-examination | entExaminationQuery |
hearing-assessment | hearingAssessmentQuery, entProceduresQuery |
orthopedic-examination | orthopedicExaminationQuery |
orthopedic-procedures | orthopedicProceduresQuery |
gynec-examination | gynecExaminationQuery |
obstetric-assessment | obstetricAssessmentQuery, gynecProceduresQuery |
pediatric-examination | pediatricExaminationQuery |
immunization | immunizationRecordQuery |
growth-assessment | growthAssessmentQuery, pediatricProceduresQuery |
pulmonary-examination | pulmonaryExaminationQuery |
respiratory-assessment | respiratoryAssessmentQuery, 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:
- Add the query in
useEncounterClinicalData.tswithhasTab('your-tab-id')gating - Ensure the tab ID matches what is configured in
useEncounterTypeConfig.ts - Verify with
pnpm typecheck --filter=@kulcare/cms
Next Steps
- See Testing Guide for detailed testing instructions
- See Audit Trail Instrumentation for adding create/update/delete audit logging to a controller (HIPAA / compliance)
- See Future TODOs for upcoming implementation work