Clinical Backend Gaps and Future State Requirements
Status: Planning Created: 2025-12-26 Last Updated: 2025-12-26 Purpose: Document clinical backend gaps for completeness and FHIR alignment
Overview
This document identifies gaps in the current clinical backend architecture and provides specifications for addressing them. These gaps were identified during architectural review to ensure:
- FHIR Alignment: Proper mapping to HL7 FHIR resources
- Clinical Workflow Support: Real-world clinical documentation needs
- Audit/Legal Compliance: Immutability and tracking for medico-legal defensibility
- Scalability: Unified models that support multiple practitioner types
Non-Negotiables for Industry-Leading Execution
These three architectural requirements are non-negotiable for a clinical-grade system:
1. Document Workflow Immutability (Gap B)
Requirement: EncounterDocument must follow draft -> signed/locked -> amended lifecycle with versioning.
| Status | Description | Editable | Creates New Version |
|---|---|---|---|
draft | Work in progress | Yes | No |
pending_signature | Awaiting attestation | No | No |
signed | Attested by author | No | No |
locked | Permanently immutable | No | No |
amended | Superseded by new version | No | Yes (new document) |
void | Cancelled before signing | No | No |
Implementation: AASM state machine with guards, immutability enforcement, DocumentAuditLog for compliance.
2. Episodes Anchored to Clinical Problem/Condition (EpisodeCondition)
Requirement: Every EpisodeOfCare must be linkable to the clinical condition(s) being treated.
# Link table: episode_conditions
t.references :episode_of_care # The treatment program
t.references :patient_problem # The condition being treated
t.string :relationship # primary | secondary | complicationBenefits:
- Problem-focused treatment programs
- Clear clinical reasoning for episodes
- Problem-list integration in LHR view
- Outcome tracking per condition
3. CarePlan Revision History (Gap C - Versioning)
Requirement: CarePlan supports "active + superseded" pattern to track plan evolution.
| Field | Purpose |
|---|---|
version | Integer version number (1, 2, 3...) |
replaces_id | Foreign key to previous version |
revision_reason | Why was the plan revised |
superseded_at | When replaced by newer version |
Statuses: draft | active | on-hold | revoked | completed | superseded | entered-in-error
Key Constraint: Only ONE active CarePlan per EpisodeOfCare at a time.
Gold Standard Clinical Data Hierarchy
Patient
|
+-- Longitudinal Health Record (LHR) <-- COMPUTED AGGREGATE VIEW
| |
| +-- Problems/Conditions (linked to episodes)
| +-- Medications (linked to encounters/episodes)
| +-- Vitals/Measurements (from encounters)
| +-- Assessments (from encounters, trended by episode)
| +-- Encounter History (grouped by episode)
|
+-- Episodes of Care <-- PROGRAM CONTAINERS (anchored to conditions)
|
+-- Goals (FHIR Goal - desired outcomes)
+-- CarePlans (FHIR CarePlan - versioned, active + past)
+-- Encounters (FHIR Encounter - individual sessions)
|
+-- EncounterDocuments (practitioner-specific documentation)
+-- Observations/Measurements
+-- Clinical AttachmentsKey Separation:
- Program Intent: Goals, CarePlans (episode-level planning)
- Session Artifacts: EncounterDocuments, Observations (encounter-level execution)
Clinical Data Backbone Architecture
Unify all clinical documentation across provider types under a single, extensible backbone.
Core Entities
| Entity | Purpose | Scope |
|---|---|---|
| Encounter | Time-bounded session/visit | Single interaction |
| EncounterDocument | Any artifact created in session | Notes, orders, assessments, plan updates |
| EpisodeOfCare | Optional longitudinal container | Multi-visit program |
| CarePlan | Plan of action across time | Versioned, evolves |
| Goal | Measurable targets | Progress tracked via EncounterDocuments |
Best-Practice Bridge Pattern
Critical Design Decision: CarePlan does NOT directly belong to an Encounter.
Instead, use EncounterDocument as the bridge:
# EncounterDocument with subject reference
class EncounterDocument < ApplicationRecord
belongs_to :encounter
belongs_to :episode_of_care, optional: true # Denormalized convenience
# Bridge to longitudinal objects (polymorphic)
belongs_to :subject, polymorphic: true, optional: true
# subject_type: 'CarePlan' | 'Goal' | 'EpisodeOfCare'
# subject_id: ID of the affected object
endSchema Addition:
# Add to encounter_documents table
t.string :subject_type # CarePlan | Goal | EpisodeOfCare | nil
t.references :subject, polymorphic: true, optional: true
add_index :encounter_documents, [:subject_type, :subject_id]How It Works:
When a CarePlan is created or updated during a visit:
# Creating a new CarePlan during an encounter
care_plan = CarePlan.create!(
patient: patient,
episode_of_care: episode,
author: current_user,
status: 'draft',
# ... plan data
)
# Create the EncounterDocument that records this creation
EncounterDocument.create!(
encounter: current_encounter,
doc_type: 'careplan.create',
author: current_user,
subject_type: 'CarePlan',
subject_id: care_plan.id,
data: {
plan_summary: care_plan.description,
activities: care_plan.activities,
goals_linked: care_plan.goal_ids
}
)Benefits:
- "Which encounter updated this plan?" - Query EncounterDocuments by subject
- "Show plan evolution over time" - Chain of careplan.update documents
- "Who signed the change?" - Full audit trail on each document
- Plans can span multiple encounters naturally
EncounterDocument Type Taxonomy (doc_type)
Use namespaced strings: <domain>.<subtype>
Doctor Documentation
| doc_type | Description |
|---|---|
doctor.prescription | Medication orders |
doctor.visit_note | SOAP/DAP clinical notes |
order.lab | Laboratory test order |
order.imaging | Imaging study order |
order.procedure | Procedure order |
result.lab_report | Lab results |
result.imaging_report | Imaging results |
discharge.summary | Discharge documentation |
careplan.create | Initial plan creation |
careplan.update | Plan modification |
Therapist/Psychologist Documentation
| doc_type | Description |
|---|---|
therapy.session_note | Session notes (SOAP/DAP) |
therapy.assessment | Standardized assessments (PHQ-9, GAD-7) |
therapy.safety_plan | Safety planning document |
goal.progress_update | Goal progress documentation |
careplan.update | Therapy plan updates |
Physiotherapist Documentation
| doc_type | Description |
|---|---|
physio.initial_evaluation | Initial assessment |
physio.session_note | Session documentation |
physio.exercise_program | Exercise prescription |
physio.outcome_measure | Functional outcome measures |
careplan.create | Rehab plan creation |
careplan.update | Rehab plan updates |
Dietitian Documentation
| doc_type | Description |
|---|---|
nutrition.initial_assessment | Nutritional evaluation |
nutrition.meal_plan | Meal plan prescription |
nutrition.follow_up_note | Follow-up documentation |
careplan.create | Nutrition plan creation |
careplan.update | Nutrition plan updates |
Cross-Domain Types
| doc_type | Description |
|---|---|
careplan.create | Creates a new CarePlan (subject_type='CarePlan') |
careplan.update | Updates existing CarePlan (subject_type='CarePlan') |
goal.create | Creates a new Goal (subject_type='Goal') |
goal.progress_update | Updates Goal progress (subject_type='Goal') |
episode.note | Episode-level progress note |
Rule: Anything created/decided "today" belongs in EncounterDocument. Longitudinal intent belongs to Episode/CarePlan/Goals, and EncounterDocuments record the creation/updates to them.
Data Placement Decision Rules
Where does this data belong?
Put it in EncounterDocument when...
| Condition | Example |
|---|---|
| Recorded/decided during the session | Visit notes, prescriptions |
| Needs signing/locking | Clinical documentation |
| Provider-specific note/assessment | Therapy session note, physio evaluation |
| Order or result/report | Lab order, imaging report |
| Creates/updates a CarePlan/Goal/Episode | Use subject_type/subject_id bridge |
Put it in EpisodeOfCare when...
| Condition | Example |
|---|---|
| Multi-visit program container | Rehab program, therapy course |
| Tracks start/end/status/reason | 12-session physio program |
| Groups related encounters | Diabetes management program |
Put it in CarePlan when...
| Condition | Example |
|---|---|
| Plan of action across time | Treatment protocol |
| Needs versioning (active + superseded) | Evolving rehab plan |
| Contains activities/interventions | Structured care activities |
Put it in Goal when...
| Condition | Example |
|---|---|
| Measurable target | "Achieve 90 degrees ROM" |
| Progress tracked over time | Progress updated via EncounterDocuments |
| Defines success criteria | "PHQ-9 < 5 for 3 consecutive visits" |
CarePlan ↔ Encounter Connection Pattern
Design Intent: CarePlan and Encounter are connected through EncounterDocument, not directly.
Encounter
|
+-- EncounterDocument (doc_type: 'careplan.create')
| |
| +-- subject_type: 'CarePlan'
| +-- subject_id: 42
| +-- data: { plan_summary, activities, change_description }
| +-- signed_at, signed_by (audit)
|
+-- EncounterDocument (doc_type: 'goal.progress_update')
|
+-- subject_type: 'Goal'
+-- subject_id: 17
+-- data: { previous_progress: 60, new_progress: 75, notes }Query Examples:
# Which encounters updated this CarePlan?
EncounterDocument.where(subject_type: 'CarePlan', subject_id: care_plan.id)
.where(doc_type: ['careplan.create', 'careplan.update'])
.order(created_at: :asc)
# Show CarePlan evolution timeline
care_plan.encounter_documents_as_subject
.includes(:encounter, :author)
.order(created_at: :asc)
# Who signed the plan change?
doc = EncounterDocument.find(id)
{ signed_by: doc.signed_by, signed_at: doc.signed_at, encounter: doc.encounter }Canonical Relationships
class EpisodeOfCare < ApplicationRecord
belongs_to :patient
belongs_to :clinic
has_many :encounters
has_many :care_plans
has_many :goals
has_many :episode_conditions
has_many :patient_problems, through: :episode_conditions
end
class Encounter < ApplicationRecord
belongs_to :patient
belongs_to :clinic
belongs_to :episode_of_care, optional: true # Can be standalone
has_many :encounter_documents
has_many :encounter_participants
end
class EncounterDocument < ApplicationRecord
belongs_to :encounter
belongs_to :episode_of_care, optional: true # Denormalized convenience
belongs_to :author, class_name: 'User'
# Bridge to longitudinal objects
belongs_to :subject, polymorphic: true, optional: true
# subject_type: 'CarePlan' | 'Goal' | 'EpisodeOfCare'
end
class CarePlan < ApplicationRecord
belongs_to :patient
belongs_to :episode_of_care, optional: true
belongs_to :author, class_name: 'User'
belongs_to :replaces, class_name: 'CarePlan', optional: true # Version chain
has_many :goals
has_many :encounter_documents_as_subject,
class_name: 'EncounterDocument',
as: :subject # Documents that created/updated this plan
end
class Goal < ApplicationRecord
belongs_to :episode_of_care, optional: true
belongs_to :care_plan, optional: true
has_many :encounter_documents_as_subject,
class_name: 'EncounterDocument',
as: :subject # Documents that updated this goal
endGap A: Orders vs Results Separation
Problem
Labs and imaging are currently conflated. A clinical lab workflow has distinct phases:
- Order (ServiceRequest): Doctor orders a test
- Specimen Collection: Sample collected (optional for some tests)
- Result (Observation): Lab returns results
FHIR Resources
| Phase | FHIR Resource | Description |
|---|---|---|
| Order | ServiceRequest | Request for a procedure/test |
| Specimen | Specimen | Collected sample details |
| Result | Observation | Test results and values |
| Report | DiagnosticReport | Grouped observations |
Proposed Data Model
ServiceRequest (Order)
create_table :service_requests do |t|
t.references :patient, null: false, foreign_key: true
t.references :encounter, foreign_key: true
t.references :episode_of_care, foreign_key: true
t.references :requester, null: false, foreign_key: { to_table: :users }
t.references :performer, foreign_key: { to_table: :users } # Assigned lab/imaging provider
t.references :clinic, null: false, foreign_key: true
# Request details
t.string :category, null: false # laboratory | imaging | procedure
t.string :code, null: false # LOINC code for labs
t.string :code_system # LOINC | SNOMED | local
t.string :display_name # Human-readable name
# Order metadata
t.string :intent, default: 'order' # proposal | plan | order | original-order | reflex-order
t.string :priority, default: 'routine'# routine | urgent | asap | stat
t.string :status, null: false, default: 'draft'
# draft | active | on-hold | revoked | completed | entered-in-error
t.datetime :ordered_at
t.datetime :occurrence_datetime # When to perform
t.text :indication # Clinical reason for order
t.text :notes # Additional instructions
t.jsonb :metadata, default: {} # Fasting requirements, special instructions
t.boolean :deleted, default: false
t.datetime :deleted_at
t.timestamps
end
add_index :service_requests, [:patient_id, :status]
add_index :service_requests, [:encounter_id, :category]
add_index :service_requests, :codeDiagnosticResult (Observation)
create_table :diagnostic_results do |t|
t.references :patient, null: false, foreign_key: true
t.references :service_request, foreign_key: true # Link to order
t.references :encounter, foreign_key: true
t.references :recorded_by, foreign_key: { to_table: :users }
t.references :clinic, null: false, foreign_key: true
# Result identification
t.string :code, null: false # LOINC code
t.string :code_system # LOINC | SNOMED | local
t.string :display_name
t.string :category # laboratory | imaging | vital-signs | survey
# Value (one of these will be populated)
t.decimal :value_quantity, precision: 10, scale: 4
t.string :value_string
t.boolean :value_boolean
t.string :value_codeable_concept # For coded results
t.string :unit
# Reference ranges
t.decimal :reference_range_low, precision: 10, scale: 4
t.decimal :reference_range_high, precision: 10, scale: 4
t.string :reference_range_text # For non-numeric ranges
# Interpretation
t.string :interpretation # normal | abnormal | critical | high | low
t.string :abnormal_flag # H | L | HH | LL | A | AA
t.text :notes
# Timing
t.datetime :effective_datetime # When specimen collected / observation made
t.datetime :issued_at # When result was issued
t.string :status, null: false, default: 'final'
# registered | preliminary | final | amended | corrected | cancelled | entered-in-error
t.jsonb :metadata, default: {}
t.boolean :deleted, default: false
t.datetime :deleted_at
t.timestamps
end
add_index :diagnostic_results, [:patient_id, :category]
add_index :diagnostic_results, [:service_request_id]
add_index :diagnostic_results, [:encounter_id]
add_index :diagnostic_results, :code
add_index :diagnostic_results, :interpretationDiagnosticReport (Grouped Results)
create_table :diagnostic_reports do |t|
t.references :patient, null: false, foreign_key: true
t.references :service_request, foreign_key: true
t.references :encounter, foreign_key: true
t.references :clinic, null: false, foreign_key: true
t.references :performer, foreign_key: { to_table: :users }
t.string :category # LAB | RAD | etc
t.string :code # Report type code
t.string :display_name
t.string :status, null: false # registered | partial | preliminary | final
t.datetime :effective_datetime
t.datetime :issued_at
t.text :conclusion # Clinical interpretation
t.text :coded_diagnosis # Coded findings
t.jsonb :metadata, default: {}
t.timestamps
end
# Join table for report -> results
create_table :diagnostic_report_results do |t|
t.references :diagnostic_report, null: false, foreign_key: true
t.references :diagnostic_result, null: false, foreign_key: true
t.timestamps
endModel Associations
class ServiceRequest < ApplicationRecord
belongs_to :patient
belongs_to :encounter, optional: true
belongs_to :episode_of_care, optional: true
belongs_to :requester, class_name: 'User'
belongs_to :performer, class_name: 'User', optional: true
belongs_to :clinic
has_many :diagnostic_results
has_many :diagnostic_reports
end
class DiagnosticResult < ApplicationRecord
belongs_to :patient
belongs_to :service_request, optional: true
belongs_to :encounter, optional: true
belongs_to :recorded_by, class_name: 'User', optional: true
belongs_to :clinic
has_many :diagnostic_report_results
has_many :diagnostic_reports, through: :diagnostic_report_results
endGap B: Document Workflow Immutability
Problem
Clinical documents require strict lifecycle management for legal defensibility:
- Draft: Can be edited freely
- Signed: Attested by author, locked for edits
- Locked: Permanently immutable
- Amended: Creates new version, original preserved
Current Gap
The EncounterDocument model has status field but lacks:
- State machine with valid transitions
- Immutability enforcement
- Amendment chain tracking
- Comprehensive audit trail
Enhanced EncounterDocument Model
class EncounterDocument < ApplicationRecord
include AASM
# Relationships
belongs_to :encounter
belongs_to :author, class_name: 'User'
belongs_to :signed_by, class_name: 'User', optional: true
belongs_to :amends_document, class_name: 'EncounterDocument', optional: true
has_many :amendments, class_name: 'EncounterDocument',
foreign_key: :amends_document_id
has_many :audit_logs, class_name: 'DocumentAuditLog', dependent: :destroy
# State machine
aasm column: :status do
state :draft, initial: true
state :pending_signature
state :signed
state :locked
state :amended
state :void
event :submit_for_signature do
transitions from: :draft, to: :pending_signature
end
event :sign do
transitions from: [:draft, :pending_signature], to: :signed,
guard: :signable?,
after: :record_signature
end
event :lock do
transitions from: :signed, to: :locked,
after: :enforce_immutability
end
event :amend do
transitions from: [:signed, :locked], to: :amended,
after: :create_amendment
end
event :void do
transitions from: [:draft, :pending_signature], to: :void,
after: :record_voiding
end
end
# Schema versioning
validates :schema_version, presence: true
before_create :set_schema_version
# Immutability enforcement
before_update :prevent_modification_if_locked
private
def set_schema_version
self.schema_version ||= DocumentSchemaRegistry.current_version(doc_type)
end
def signable?
author.present? && data.present?
end
def record_signature
update!(
signed_at: Time.current,
signed_by: Current.user
)
audit_log!(:signed)
end
def enforce_immutability
update!(locked_at: Time.current)
audit_log!(:locked)
end
def create_amendment
# Original document marked as amended
# New document created with reference to original
audit_log!(:amended)
end
def record_voiding
update!(voided_at: Time.current)
audit_log!(:voided)
end
def prevent_modification_if_locked
if locked? || amended?
errors.add(:base, 'Document is locked and cannot be modified')
throw(:abort)
end
end
def audit_log!(action)
DocumentAuditLog.create!(
encounter_document: self,
user: Current.user,
action: action,
changes: saved_changes,
previous_data: saved_changes.transform_values(&:first),
ip_address: Current.ip_address,
user_agent: Current.user_agent,
performed_at: Time.current
)
end
endDocumentAuditLog
create_table :document_audit_logs do |t|
t.references :encounter_document, null: false, foreign_key: true
t.references :user, null: false, foreign_key: true
t.string :action, null: false
# created | viewed | edited | signed | locked | amended | voided
t.jsonb :changes, default: {} # What changed
t.jsonb :previous_data, default: {} # Snapshot before change
t.jsonb :current_data, default: {} # Snapshot after change
t.string :ip_address
t.string :user_agent
t.string :session_id
t.datetime :performed_at, null: false
t.timestamps
end
add_index :document_audit_logs, [:encounter_document_id, :performed_at]
add_index :document_audit_logs, [:user_id, :action]
add_index :document_audit_logs, :actionSchema Versioning
# Document schema registry for version management
class DocumentSchemaRegistry
SCHEMAS = {
'prescription' => {
versions: {
'1.0' => PrescriptionSchemaV1,
'1.1' => PrescriptionSchemaV1_1,
'2.0' => PrescriptionSchemaV2
},
current: '2.0'
},
'therapy_session' => {
versions: {
'1.0' => TherapySessionSchemaV1
},
current: '1.0'
}
# ... other doc types
}
def self.current_version(doc_type)
SCHEMAS.dig(doc_type, :current) || '1.0'
end
def self.schema_for(doc_type, version)
SCHEMAS.dig(doc_type, :versions, version)
end
def self.validate(doc_type, version, data)
schema = schema_for(doc_type, version)
schema&.validate(data)
end
endGap C: Plan vs Session Split + CarePlan Versioning
Problem
Episode-level planning and encounter-level session notes are conflated:
- Episode Level (Plan): Long-term goals, treatment plans, overall progress
- Encounter Level (Session): What happened today, session-specific notes
Additionally, CarePlan needs revision history to track how treatment plans evolve over time. Clinical best practice requires maintaining a chain of superseded plans so clinicians can understand plan evolution.
FHIR Alignment
| Level | FHIR Resource | Purpose |
|---|---|---|
| Episode | CarePlan + Goal | Treatment planning |
| Encounter | Observation + ClinicalImpression | Session documentation |
FHIR CarePlan supports versioning through the replaces reference and status lifecycle (draft, active, on-hold, revoked, completed, entered-in-error).
Proposed Model Clarification
Episode Level (already exists - enhance)
class EpisodeOfCare < ApplicationRecord
has_many :care_plans # Treatment planning documents (versioned)
has_many :goals # Long-term treatment goals
has_many :progress_notes # Longitudinal progress summaries
# Get active care plan
def active_care_plan
care_plans.active.order(created_at: :desc).first
end
# Get plan history (active + superseded)
def care_plan_history
care_plans.order(version: :desc)
end
end
class CarePlan < ApplicationRecord
belongs_to :episode_of_care, optional: true
belongs_to :patient, optional: false
belongs_to :author, class_name: 'User'
belongs_to :replaces, class_name: 'CarePlan', optional: true # Previous version
has_many :goals
has_many :superseded_by, class_name: 'CarePlan', foreign_key: :replaces_id
# Versioning
validates :version, presence: true
before_create :set_version_number
end
class Goal < ApplicationRecord
belongs_to :episode_of_care, optional: true # Already planned
belongs_to :care_plan, optional: true # Already exists
endCarePlan Versioning Model (Non-Negotiable)
Treatment plans must support revision history with "active + superseded" pattern:
# Enhanced CarePlan table
create_table :care_plans do |t|
t.references :patient, null: false, foreign_key: true
t.references :episode_of_care, foreign_key: true
t.references :author, null: false, foreign_key: { to_table: :users }
t.references :clinic, null: false, foreign_key: true
# Versioning (Non-Negotiable)
t.integer :version, null: false, default: 1
t.references :replaces, foreign_key: { to_table: :care_plans } # Previous version
t.string :revision_reason # Why was plan revised
# Status (FHIR aligned)
t.string :status, null: false, default: 'draft'
# draft | active | on-hold | revoked | completed | superseded | entered-in-error
# Content
t.string :title
t.text :description
t.jsonb :activities, default: [] # Planned activities
t.jsonb :data, default: {} # Structured plan data
# Dates
t.date :period_start
t.date :period_end
t.datetime :activated_at # When status changed to active
t.datetime :superseded_at # When replaced by new version
# Audit
t.boolean :deleted, default: false
t.datetime :deleted_at
t.timestamps
end
add_index :care_plans, [:episode_of_care_id, :status]
add_index :care_plans, [:patient_id, :status]
add_index :care_plans, [:episode_of_care_id, :version]
add_index :care_plans, :replaces_id
# Only one active plan per episode
add_index :care_plans, :episode_of_care_id,
where: "status = 'active'",
unique: true,
name: 'idx_one_active_plan_per_episode'CarePlan Model with Version Management
class CarePlan < ApplicationRecord
include AASM
belongs_to :patient
belongs_to :episode_of_care, optional: true
belongs_to :author, class_name: 'User'
belongs_to :clinic
belongs_to :replaces, class_name: 'CarePlan', optional: true
has_many :goals, dependent: :destroy
has_many :superseded_by, class_name: 'CarePlan', foreign_key: :replaces_id
has_many :care_plan_audit_logs, dependent: :destroy
validates :version, presence: true
validates :status, presence: true
before_create :set_version_number
# State machine (FHIR-aligned statuses)
aasm column: :status do
state :draft, initial: true
state :active
state :on_hold
state :revoked
state :completed
state :superseded
state :entered_in_error
event :activate do
transitions from: :draft, to: :active,
guard: :can_activate?,
after: :record_activation
end
event :put_on_hold do
transitions from: :active, to: :on_hold
end
event :resume do
transitions from: :on_hold, to: :active
end
event :complete do
transitions from: [:active, :on_hold], to: :completed
end
event :revoke do
transitions from: [:draft, :active, :on_hold], to: :revoked
end
event :supersede do
transitions from: [:active, :on_hold], to: :superseded,
after: :record_superseded
end
event :mark_entered_in_error do
transitions from: [:draft], to: :entered_in_error
end
end
# Scopes
scope :active_plans, -> { where(status: 'active') }
scope :plan_history, -> { where.not(status: 'entered_in_error').order(version: :desc) }
scope :for_episode, ->(episode_id) { where(episode_of_care_id: episode_id) }
# Create new version from existing plan
def create_revision(author:, reason:)
raise 'Can only revise active or on_hold plans' unless active? || on_hold?
transaction do
# Create new version
new_plan = dup
new_plan.assign_attributes(
replaces: self,
version: version + 1,
revision_reason: reason,
author: author,
status: 'draft',
activated_at: nil,
superseded_at: nil,
created_at: nil,
updated_at: nil
)
# Copy goals to new plan
goals.each do |goal|
new_goal = goal.dup
new_goal.care_plan = new_plan
new_plan.goals << new_goal
end
new_plan.save!
# Mark current plan as superseded
supersede!
new_plan
end
end
# Get full version history (newest first)
def version_history
history = [self]
current = self
# Walk back through replaces chain
while current.replaces.present?
history << current.replaces
current = current.replaces
end
# Walk forward through superseded_by chain
current = self
while current.superseded_by.any?
newer = current.superseded_by.order(version: :desc).first
history.unshift(newer)
current = newer
end
history.uniq.sort_by(&:version).reverse
end
private
def set_version_number
return if version.present? && version > 1
if replaces.present?
self.version = replaces.version + 1
else
self.version = 1
end
end
def can_activate?
# Ensure no other active plan for this episode
if episode_of_care_id.present?
!CarePlan.where(episode_of_care_id: episode_of_care_id, status: 'active')
.where.not(id: id)
.exists?
else
true
end
end
def record_activation
update!(activated_at: Time.current)
audit_log!(:activated)
end
def record_superseded
update!(superseded_at: Time.current)
audit_log!(:superseded)
end
def audit_log!(action)
CarePlanAuditLog.create!(
care_plan: self,
user: Current.user,
action: action,
performed_at: Time.current
)
end
endCarePlan Audit Log
create_table :care_plan_audit_logs do |t|
t.references :care_plan, null: false, foreign_key: true
t.references :user, null: false, foreign_key: true
t.string :action, null: false
# created | updated | activated | superseded | completed | revoked
t.jsonb :changes, default: {}
t.jsonb :previous_data, default: {}
t.datetime :performed_at, null: false
t.timestamps
end
add_index :care_plan_audit_logs, [:care_plan_id, :performed_at]API Endpoints for CarePlan Versioning
# CarePlan CRUD with version support
GET /api/v1/episodes_of_care/:episode_id/care_plans # All versions
GET /api/v1/episodes_of_care/:episode_id/care_plans/active # Active plan only
POST /api/v1/episodes_of_care/:episode_id/care_plans # Create new plan
GET /api/v1/care_plans/:id # View specific version
PATCH /api/v1/care_plans/:id # Update draft
POST /api/v1/care_plans/:id/activate # Activate plan
POST /api/v1/care_plans/:id/revise # Create new version
GET /api/v1/care_plans/:id/history # Version historyUI: Plan Evolution Timeline
+-----------------------------------------------------------+
| Care Plan History |
+-----------------------------------------------------------+
| v3 (Active) - "Revised rehab protocol" |
| Created: Jan 15, 2025 by Dr. Smith |
| Reason: Added aquatic therapy based on progress |
+-----------------------------------------------------------+
| v2 (Superseded) - "Post-surgery rehab" |
| Active: Dec 1 - Jan 15, 2025 |
| Reason: Increased ROM exercises after 6-week assessment |
+-----------------------------------------------------------+
| v1 (Superseded) - "Initial ACL rehab plan" |
| Active: Nov 1 - Dec 1, 2024 |
| Initial plan |
+-----------------------------------------------------------+Encounter Level (session documentation)
# EncounterDocument handles session-level documentation
# doc_type: 'therapy_session' | 'physio_session' | 'nutrition_session'
# Session notes should reference episode goals
class EncounterDocument < ApplicationRecord
belongs_to :episode_of_care, optional: true # Link to episode
# Session can update goal progress
def update_goal_progress(goal_id, progress_percent, notes)
goal = episode_of_care&.goals&.find(goal_id)
goal&.update!(
progress_percent: progress_percent,
progress_notes: notes,
last_reviewed_at: Time.current,
last_reviewed_by: author
)
end
endProgressNote (Episode-Level Summaries)
create_table :progress_notes do |t|
t.references :episode_of_care, null: false, foreign_key: true
t.references :author, null: false, foreign_key: { to_table: :users }
t.string :note_type # periodic_review | milestone | discharge_summary
t.text :summary # Overall progress narrative
t.text :clinical_impression
t.jsonb :goals_status, default: [] # Snapshot of goal progress
t.jsonb :treatment_modifications, default: {}
t.date :review_date
t.date :next_review_date
t.string :status, default: 'draft' # draft | final
t.datetime :finalized_at
t.boolean :deleted, default: false
t.datetime :deleted_at
t.timestamps
end
add_index :progress_notes, [:episode_of_care_id, :note_type]Gap D: Unified ClinicalMeasurement Model
Problem
Current model focuses on "vitals" but clinical measurements are broader:
- Vitals: BP, HR, temp, SpO2, RR
- Anthropometrics: Weight, height, BMI, waist circumference, body fat %
- ROM (Range of Motion): Joint measurements by movement type
- Pain: VAS, NRS, body location mapping
- Functional: Grip strength, gait speed, 6MWT
- Scales: PHQ-9, GAD-7 scores (summary values)
Enhanced ClinicalMeasurement Model
create_table :clinical_measurements do |t|
t.references :patient, null: false, foreign_key: true
t.references :encounter, foreign_key: true
t.references :encounter_document, foreign_key: true
t.references :episode_of_care, foreign_key: true
t.references :recorded_by, null: false, foreign_key: { to_table: :users }
t.references :clinic, null: false, foreign_key: true
# Measurement identification
t.string :category, null: false
# vital-signs | anthropometric | rom | pain | functional | scale | other
t.string :measurement_type, null: false
# bp_systolic | bp_diastolic | heart_rate | temperature | spo2 | respiratory_rate |
# weight | height | bmi | waist_circumference | body_fat_pct |
# rom_[joint]_[movement] | pain_vas | pain_nrs | grip_strength | etc
t.string :code # LOINC code for standardization
t.string :code_system, default: 'LOINC'
# Value (use appropriate field)
t.decimal :value_quantity, precision: 10, scale: 4
t.string :value_string # For non-numeric (e.g., "regular" for pulse rhythm)
t.string :unit # mmHg | bpm | F | C | kg | lb | cm | in | degrees | %
# Context
t.string :body_site # Anatomical location
t.string :laterality # left | right | bilateral
t.string :method # measured | self_reported | calculated | device
t.string :position # sitting | standing | supine | prone
t.string :device_type # manual | automatic | wearable
# Timing
t.datetime :occurred_at # When measurement was taken
t.datetime :recorded_at # When entered in system (auto-set)
# Interpretation
t.string :interpretation # normal | abnormal | high | low | critical
t.decimal :reference_low, precision: 10, scale: 4
t.decimal :reference_high, precision: 10, scale: 4
t.text :notes
t.jsonb :metadata, default: {} # Additional context, device info
t.boolean :deleted, default: false
t.datetime :deleted_at
t.timestamps
end
add_index :clinical_measurements, [:patient_id, :measurement_type]
add_index :clinical_measurements, [:encounter_id, :category]
add_index :clinical_measurements, [:episode_of_care_id, :measurement_type]
add_index :clinical_measurements, [:patient_id, :occurred_at]
add_index :clinical_measurements, :codeMeasurement Type Registry
class MeasurementTypeRegistry
TYPES = {
# Vital Signs
'bp_systolic' => { category: 'vital-signs', unit: 'mmHg', loinc: '8480-6' },
'bp_diastolic' => { category: 'vital-signs', unit: 'mmHg', loinc: '8462-4' },
'heart_rate' => { category: 'vital-signs', unit: 'bpm', loinc: '8867-4' },
'temperature' => { category: 'vital-signs', unit: 'C', loinc: '8310-5' },
'spo2' => { category: 'vital-signs', unit: '%', loinc: '2708-6' },
'respiratory_rate' => { category: 'vital-signs', unit: '/min', loinc: '9279-1' },
# Anthropometrics
'weight' => { category: 'anthropometric', unit: 'kg', loinc: '29463-7' },
'height' => { category: 'anthropometric', unit: 'cm', loinc: '8302-2' },
'bmi' => { category: 'anthropometric', unit: 'kg/m2', loinc: '39156-5' },
'waist_circumference' => { category: 'anthropometric', unit: 'cm', loinc: '8280-0' },
'body_fat_pct' => { category: 'anthropometric', unit: '%', loinc: '41982-0' },
# Pain
'pain_vas' => { category: 'pain', unit: 'score', loinc: '38214-3', range: [0, 10] },
'pain_nrs' => { category: 'pain', unit: 'score', loinc: '72514-3', range: [0, 10] },
# ROM (pattern: rom_[joint]_[movement])
'rom_shoulder_flexion' => { category: 'rom', unit: 'degrees', requires: [:laterality] },
'rom_shoulder_extension' => { category: 'rom', unit: 'degrees', requires: [:laterality] },
'rom_knee_flexion' => { category: 'rom', unit: 'degrees', requires: [:laterality] },
'rom_knee_extension' => { category: 'rom', unit: 'degrees', requires: [:laterality] },
# ... complete ROM taxonomy
# Functional
'grip_strength' => { category: 'functional', unit: 'kg', requires: [:laterality] },
'gait_speed' => { category: 'functional', unit: 'm/s' },
'6mwt_distance' => { category: 'functional', unit: 'm', loinc: '64098-7' }
}.freeze
def self.for_category(category)
TYPES.select { |_, v| v[:category] == category }
end
def self.validate(type, value, laterality: nil)
config = TYPES[type]
return false unless config
if config[:requires]&.include?(:laterality) && laterality.nil?
return false
end
if config[:range]
return value.between?(config[:range][0], config[:range][1])
end
true
end
endGap E: Coding System Consistency
Problem
Inconsistent use of clinical coding systems across models:
- Problems/Conditions: Sometimes ICD-10, sometimes free text
- Lab tests: LOINC optional
- Procedures: No standard coding
- Medications: No NDC/RxNorm
FHIR-Aligned Coding Model
CodeableConcept Pattern
Every clinically-coded field should support:
# Embedded in JSONB or as separate columns
{
coding: [
{ system: 'ICD-10', code: 'J06.9', display: 'Acute upper respiratory infection' },
{ system: 'SNOMED-CT', code: '54150009', display: 'Upper respiratory infection' }
],
text: 'Common cold' # Human-readable fallback
}Standard Code Systems
| Domain | Primary System | Secondary |
|---|---|---|
| Diagnoses/Problems | ICD-10 | SNOMED-CT |
| Lab Tests | LOINC | Local codes |
| Procedures | CPT / HCPCS | SNOMED-CT |
| Medications | RxNorm | NDC |
| Allergies | SNOMED-CT | NDF-RT |
| Body Sites | SNOMED-CT | - |
Implementation Pattern
# Concern for codeable concept support
module CodeableConcept
extend ActiveSupport::Concern
included do
# For each coded field, store as JSONB
# e.g., condition_code: { coding: [...], text: '...' }
end
def primary_code
condition_code&.dig('coding', 0, 'code')
end
def primary_system
condition_code&.dig('coding', 0, 'system')
end
def display_text
condition_code&.dig('text') ||
condition_code&.dig('coding', 0, 'display')
end
def add_coding(system, code, display)
self.condition_code ||= { 'coding' => [], 'text' => nil }
self.condition_code['coding'] << {
'system' => system,
'code' => code,
'display' => display
}
end
end
# Usage in model
class PatientProblem < ApplicationRecord
include CodeableConcept
# condition_code is JSONB column
# Stores: { coding: [{system: 'ICD-10', code: 'J06.9', display: '...'}], text: '...' }
endMigration for Existing Models
# Add coded concept columns to existing models
add_column :patient_problems, :condition_code, :jsonb, default: {}
add_column :investigations, :test_code, :jsonb, default: {}
add_column :medications, :medication_code, :jsonb, default: {}
# GIN indexes for querying
add_index :patient_problems, :condition_code, using: :gin
add_index :investigations, :test_code, using: :ginGap F: Attachments as First-Class Model
Problem
File attachments scattered across different upload patterns. Need unified attachment system that can link to:
- Encounters
- Documents
- Notes
- Progress records
- Orders/Results
ClinicalAttachment Model
create_table :clinical_attachments do |t|
t.references :clinic, null: false, foreign_key: true
t.references :patient, null: false, foreign_key: true
t.references :uploaded_by, null: false, foreign_key: { to_table: :users }
# Polymorphic association for flexibility
t.references :attachable, polymorphic: true, null: false
# attachable_type: 'Encounter' | 'EncounterDocument' | 'EncounterNote' |
# 'ServiceRequest' | 'DiagnosticResult' | 'EpisodeOfCare'
# File metadata
t.string :file_type, null: false # image | video | pdf | document | audio | other
t.string :category # photo | consent | lab_report | imaging | worksheet
t.string :title
t.text :description
# Storage (CarrierWave / ActiveStorage)
t.string :file # CarrierWave path
t.string :content_type
t.bigint :file_size # bytes
t.string :original_filename
t.string :checksum # For integrity verification
# Visibility
t.string :visibility, default: 'care_team'
# private | care_team | clinic | patient_visible
t.boolean :is_sensitive, default: false
# Clinical context
t.datetime :clinical_date # Date of the clinical event (not upload)
t.text :clinical_notes
t.boolean :deleted, default: false
t.datetime :deleted_at
t.timestamps
end
add_index :clinical_attachments, [:attachable_type, :attachable_id]
add_index :clinical_attachments, [:patient_id, :file_type]
add_index :clinical_attachments, [:clinic_id, :category]
add_index :clinical_attachments, :visibilityModel Implementation
class ClinicalAttachment < ApplicationRecord
belongs_to :clinic
belongs_to :patient
belongs_to :uploaded_by, class_name: 'User'
belongs_to :attachable, polymorphic: true
mount_uploader :file, ClinicalAttachmentUploader
validates :file, presence: true
validates :file_type, presence: true
validates :visibility, inclusion: { in: %w[private care_team clinic patient_visible] }
before_save :set_file_metadata
scope :visible_to, ->(user) {
# Implement visibility logic based on user role
}
scope :for_patient, ->(patient_id) { where(patient_id: patient_id) }
scope :by_category, ->(cat) { where(category: cat) }
private
def set_file_metadata
return unless file.present?
self.content_type = file.content_type
self.file_size = file.size
self.original_filename = file.original_filename
self.checksum = Digest::MD5.file(file.path).hexdigest if file.path.present?
end
end
# Add has_many to attachable models
class Encounter < ApplicationRecord
has_many :clinical_attachments, as: :attachable, dependent: :destroy
end
class EncounterDocument < ApplicationRecord
has_many :clinical_attachments, as: :attachable, dependent: :destroy
end
class EpisodeOfCare < ApplicationRecord
has_many :clinical_attachments, as: :attachable, dependent: :destroy
endImprovements to EncounterDocument Plan
Based on the gap analysis, update the EncounterDocument migration to include:
Schema Versioning
t.string :schema_version, null: false, default: '1.0'Enhanced Visibility Model
t.string :visibility, null: false, default: 'care_team'
# Values: private_to_author | care_team | clinic | patient_visible
t.boolean :is_psychotherapy_note, default: false # 42 CFR Part 2 protected
t.jsonb :allowed_user_ids, default: [] # Explicit access listKey Constraints and Indexes
# Ensure only one primary participant per encounter
add_index :encounter_participants, [:encounter_id, :role],
where: "role = 'primary'",
unique: true,
name: 'idx_one_primary_participant_per_encounter'
# Status-based partial indexes for common queries
add_index :encounter_documents, :encounter_id,
where: "status = 'draft'",
name: 'idx_draft_documents'
add_index :encounter_documents, [:author_id, :created_at],
where: "status = 'draft'",
name: 'idx_author_draft_documents'
# Amendment chain
add_foreign_key :encounter_documents, :encounter_documents,
column: :amends_document_id,
on_delete: :restrict # Prevent orphaned amendments
# GIN indexes for JSONB
add_index :encounter_documents, :data, using: :gin
add_index :encounter_documents, :allowed_user_ids, using: :ginImprovements to EpisodeOfCare Plan
Add ProgressNote Table
create_table :progress_notes do |t|
t.references :episode_of_care, null: false, foreign_key: true
t.references :author, null: false, foreign_key: { to_table: :users }
t.string :note_type # periodic_review | milestone | discharge_summary
t.text :summary
t.text :clinical_impression
t.jsonb :goals_status, default: []
t.jsonb :treatment_modifications, default: {}
t.date :review_date
t.date :next_review_date
t.string :status, default: 'draft'
t.datetime :finalized_at
t.boolean :deleted, default: false
t.datetime :deleted_at
t.timestamps
endAdd Condition Linkage
# Episodes linked to conditions being treated
create_table :episode_conditions do |t|
t.references :episode_of_care, null: false, foreign_key: true
t.references :patient_problem, null: false, foreign_key: true
t.string :relationship # primary | secondary | complication
t.timestamps
end
add_index :episode_conditions, [:episode_of_care_id, :patient_problem_id],
unique: true, name: 'idx_episode_condition_unique'Add Referral Source
# Add to episodes_of_care table
t.string :referral_source # self | physician | hospital | insurance
t.references :referring_provider, foreign_key: { to_table: :users }
t.text :referral_notes
t.date :referral_dateUse deleted_at Pattern Consistently
All soft-deleted models should use:
t.boolean :deleted, default: false
t.datetime :deleted_at
# Scope
scope :active, -> { where(deleted: false) }
scope :deleted, -> { where(deleted: true) }
# Method
def soft_delete!
update!(deleted: true, deleted_at: Time.current)
endClinical Backend Completeness Checklist
Core Clinical Resources
- Patient - Demographics, identifiers
- Problem/Condition - Diagnoses with ICD-10/SNOMED coding
- Allergy - Allergies with severity and reaction
- Medication - Current medications with dosing
- Immunization - Vaccination records
Encounter Resources
- Encounter - Visit/session tracking
- EncounterDocument - Practitioner-specific documentation
- EncounterParticipant - Multi-provider support
- ClinicalMeasurement - Unified vitals/ROM/anthropometrics
- ClinicalAttachment - Polymorphic file storage
Orders and Results
- ServiceRequest - Lab/imaging orders
- DiagnosticResult - Individual test results (Observation)
- DiagnosticReport - Grouped results
Treatment Planning
- EpisodeOfCare - Treatment programs
- CarePlan - Treatment plans (linked to episodes)
- Goal - Treatment goals (FHIR aligned)
- ProgressNote - Longitudinal summaries
Assessments
- AssessmentInstrument - Assessment definitions (PHQ-9, etc.)
- AssessmentResponse - Completed assessments with scoring
Safety and Compliance
- SafetyAlertRule - Configurable alert thresholds
- SafetyAlert - Triggered safety alerts
- DocumentAuditLog - Immutable audit trail
- SafetyPlan - Safety planning for therapy
Coding Consistency
- Problems use ICD-10 / SNOMED-CT
- Labs use LOINC codes
- Measurements use LOINC where available
- Medications use RxNorm / NDC
- Procedures use CPT / SNOMED-CT
Related Documentation
- Clinical Data Hierarchy - LHR/Episode/Encounter relationships
- Multi-Practitioner Encounter Types - Document architecture
- Episode of Care Plan - Episode implementation
- Multi-Practitioner Documents - Document types
Implementation Priority
Non-Negotiables (Must Have)
These three items are foundational for a clinical-grade system:
- Document Workflow Immutability (Gap B) - AASM state machine, audit logging
- Episode-Condition Linkage (EpisodeCondition table) - Problem-focused care
- CarePlan Versioning (Gap C) - Active + superseded plan history
High Priority (Address Early)
- Unified ClinicalMeasurement Model (Gap D) - Vitals, ROM, anthropometrics
- Attachments as First-Class Model (Gap F) - Polymorphic clinical attachments
Medium Priority
- Orders vs Results Separation (Gap A) - ServiceRequest/DiagnosticResult
- Coding System Consistency (Gap E) - ICD-10, LOINC, SNOMED-CT
Lower Priority (Can Iterate)
- Plan vs Session Split (Gap C remainder) - Architectural clarity for documentation
Migration Strategy (Minimal Rework, Safest Path)
The following phased approach minimizes risk and avoids breaking existing functionality:
Phase 1: Add New Primitives (No Breakage)
Create new tables without modifying existing ones:
# New tables - additive, no breaking changes
create_table :encounter_documents # Replaces prescription-centric docs
create_table :encounter_participants # Multi-practitioner support
create_table :episodes_of_care # Treatment program containers
create_table :episode_goals # Goals tied to episodes
create_table :clinical_attachments # Clean polymorphic replacement
create_table :document_audit_logs # Immutable audit trailOutcome: New system runs in parallel with old. No existing functionality affected.
Phase 2: Bridge Old World to New World
Add encounter_id to existing tables that currently use event_id/event_type or appointment_id:
# Tables to update with encounter_id
add_reference :patient_vitals, :encounter, foreign_key: true
add_reference :patient_therapy_notes, :encounter, foreign_key: true
add_reference :clinical_observations, :encounter, foreign_key: true
add_reference :lab_orders, :encounter, foreign_key: true
add_reference :imaging_studies, :encounter, foreign_key: true
add_reference :diagnostic_reports, :encounter, foreign_key: true
# ... other clinical tablesBackfill Strategy:
# Backfill encounter_id using existing relationships
class BackfillEncounterIds < ActiveRecord::Migration[7.0]
def up
# For records with appointment_id
PatientVital.where.not(appointment_id: nil).find_each do |vital|
encounter = Encounter.find_by(appointment_id: vital.appointment_id)
vital.update_column(:encounter_id, encounter&.id)
end
# For records with event_type/event_id (polymorphic)
ClinicalObservation.where(event_type: 'Appointment').find_each do |obs|
encounter = Encounter.find_by(appointment_id: obs.event_id)
obs.update_column(:encounter_id, encounter&.id)
end
end
endOutcome: Existing data linked to Encounters. Old associations preserved during transition.
Phase 3: Decouple from Prescriptions
Make prescription_id nullable and introduce new linkage:
# Make prescription_id nullable
change_column_null :medication_requests, :prescription_id, true
change_column_null :care_plans, :prescription_id, true
change_column_null :clinical_observations, :prescription_id, true
change_column_null :diagnostic_reports, :prescription_id, true
change_column_null :prescription_procedures, :prescription_id, true
# Add new linkage
add_reference :medication_requests, :encounter_document, foreign_key: true
add_reference :care_plans, :encounter_document, foreign_key: true
add_reference :care_plans, :episode_of_care, foreign_key: trueMigration Path:
- New records use
encounter_document_id/encounter_id - Old records continue to use
prescription_id - Queries use
COALESCE(encounter_document_id, prescription_id)during transition - Eventually deprecate
prescription_idonce all data migrated
Outcome: Prescription model becomes optional, not required.
Phase 4: Consolidate Duplicates
Identify canonical models and deprecate redundant ones:
| Keep (Canonical) | Deprecate | Migration |
|---|---|---|
care_plans | careplans | Merge data, drop old table |
clinical_observations | observations | Merge data, drop old table |
encounter_documents | Specialty exam tables | Convert to doc_type JSON |
Specialty Exams as Documents:
Instead of maintaining bespoke tables for each specialty exam type, convert to generic encounter_documents:
# Before: separate tables for each exam type
# patient_eye_exams, patient_dermatology_exams, patient_cardio_exams, etc.
# After: single encounter_documents table with doc_type
EncounterDocument.create!(
encounter: encounter,
doc_type: 'ophthalmology_exam',
schema_version: '1.0',
data: {
visual_acuity_od: '20/20',
visual_acuity_os: '20/25',
iop_od: 14,
iop_os: 16,
# ... structured exam data
}
)Benefits:
- Single table with consistent lifecycle (draft/signed/locked)
- Schema versioning for data evolution
- Audit trail applies to all document types
- New exam types don't require new tables
Outcome: Simpler data model, easier maintenance, consistent patterns.
Migration Sequence
Week 1-2: Phase 1
├── Create new tables (migrations)
├── Add models, associations, validations
├── Seed assessment instruments
└── Deploy (no user-facing changes)
Week 3-4: Phase 2
├── Add encounter_id columns
├── Write backfill scripts
├── Run backfill in batches (off-peak)
├── Verify data integrity
└── Update queries to use encounter_id
Week 5-6: Phase 3
├── Make prescription_id nullable
├── Add encounter_document_id columns
├── Update interactors to use new paths
├── Dual-write: old + new associations
└── Test both code paths
Week 7-8: Phase 4
├── Audit duplicate tables
├── Create merge migrations
├── Convert specialty exams to documents
├── Deprecate old tables (soft delete)
└── Final data validation
Week 9+: Cleanup
├── Remove deprecated code paths
├── Drop unused columns/tables
├── Update documentation
└── Performance optimizationRisk Mitigation
Data Integrity
# Validation: ensure no orphaned records
def validate_migration_phase_2
orphaned_vitals = PatientVital.where(encounter_id: nil)
.where.not(appointment_id: nil)
raise "Orphaned vitals: #{orphaned_vitals.count}" if orphaned_vitals.exists?
endRollback Strategy
Each phase should be independently rollback-able:
# Feature flags for gradual rollout
FEATURE_FLAGS = {
use_encounter_documents: false, # Phase 1
link_via_encounter_id: false, # Phase 2
decouple_prescriptions: false, # Phase 3
use_consolidated_models: false # Phase 4
}Dual-Write Pattern
During transition, write to both old and new:
class CreateClinicalObservation
def call
# Write to old system
observation = ClinicalObservation.create!(legacy_params)
# Write to new system (if enabled)
if FeatureFlag.enabled?(:use_encounter_documents)
EncounterDocument.create!(new_system_params)
end
observation
end
end