Kulcare Docs
DeveloperCare plansCare planArchitecture

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:

  1. FHIR Alignment: Proper mapping to HL7 FHIR resources
  2. Clinical Workflow Support: Real-world clinical documentation needs
  3. Audit/Legal Compliance: Immutability and tracking for medico-legal defensibility
  4. 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.

StatusDescriptionEditableCreates New Version
draftWork in progressYesNo
pending_signatureAwaiting attestationNoNo
signedAttested by authorNoNo
lockedPermanently immutableNoNo
amendedSuperseded by new versionNoYes (new document)
voidCancelled before signingNoNo

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 | complication

Benefits:

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

FieldPurpose
versionInteger version number (1, 2, 3...)
replaces_idForeign key to previous version
revision_reasonWhy was the plan revised
superseded_atWhen 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 Attachments

Key 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

EntityPurposeScope
EncounterTime-bounded session/visitSingle interaction
EncounterDocumentAny artifact created in sessionNotes, orders, assessments, plan updates
EpisodeOfCareOptional longitudinal containerMulti-visit program
CarePlanPlan of action across timeVersioned, evolves
GoalMeasurable targetsProgress 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
end

Schema 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_typeDescription
doctor.prescriptionMedication orders
doctor.visit_noteSOAP/DAP clinical notes
order.labLaboratory test order
order.imagingImaging study order
order.procedureProcedure order
result.lab_reportLab results
result.imaging_reportImaging results
discharge.summaryDischarge documentation
careplan.createInitial plan creation
careplan.updatePlan modification

Therapist/Psychologist Documentation

doc_typeDescription
therapy.session_noteSession notes (SOAP/DAP)
therapy.assessmentStandardized assessments (PHQ-9, GAD-7)
therapy.safety_planSafety planning document
goal.progress_updateGoal progress documentation
careplan.updateTherapy plan updates

Physiotherapist Documentation

doc_typeDescription
physio.initial_evaluationInitial assessment
physio.session_noteSession documentation
physio.exercise_programExercise prescription
physio.outcome_measureFunctional outcome measures
careplan.createRehab plan creation
careplan.updateRehab plan updates

Dietitian Documentation

doc_typeDescription
nutrition.initial_assessmentNutritional evaluation
nutrition.meal_planMeal plan prescription
nutrition.follow_up_noteFollow-up documentation
careplan.createNutrition plan creation
careplan.updateNutrition plan updates

Cross-Domain Types

doc_typeDescription
careplan.createCreates a new CarePlan (subject_type='CarePlan')
careplan.updateUpdates existing CarePlan (subject_type='CarePlan')
goal.createCreates a new Goal (subject_type='Goal')
goal.progress_updateUpdates Goal progress (subject_type='Goal')
episode.noteEpisode-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...

ConditionExample
Recorded/decided during the sessionVisit notes, prescriptions
Needs signing/lockingClinical documentation
Provider-specific note/assessmentTherapy session note, physio evaluation
Order or result/reportLab order, imaging report
Creates/updates a CarePlan/Goal/EpisodeUse subject_type/subject_id bridge

Put it in EpisodeOfCare when...

ConditionExample
Multi-visit program containerRehab program, therapy course
Tracks start/end/status/reason12-session physio program
Groups related encountersDiabetes management program

Put it in CarePlan when...

ConditionExample
Plan of action across timeTreatment protocol
Needs versioning (active + superseded)Evolving rehab plan
Contains activities/interventionsStructured care activities

Put it in Goal when...

ConditionExample
Measurable target"Achieve 90 degrees ROM"
Progress tracked over timeProgress 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
end

Gap A: Orders vs Results Separation

Problem

Labs and imaging are currently conflated. A clinical lab workflow has distinct phases:

  1. Order (ServiceRequest): Doctor orders a test
  2. Specimen Collection: Sample collected (optional for some tests)
  3. Result (Observation): Lab returns results

FHIR Resources

PhaseFHIR ResourceDescription
OrderServiceRequestRequest for a procedure/test
SpecimenSpecimenCollected sample details
ResultObservationTest results and values
ReportDiagnosticReportGrouped 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, :code

DiagnosticResult (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, :interpretation

DiagnosticReport (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
end

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

Gap B: Document Workflow Immutability

Problem

Clinical documents require strict lifecycle management for legal defensibility:

  1. Draft: Can be edited freely
  2. Signed: Attested by author, locked for edits
  3. Locked: Permanently immutable
  4. 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
end

DocumentAuditLog

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, :action

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

Gap 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

LevelFHIR ResourcePurpose
EpisodeCarePlan + GoalTreatment planning
EncounterObservation + ClinicalImpressionSession 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
end

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

CarePlan 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 history

UI: 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
end

ProgressNote (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, :code

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

Gap 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

DomainPrimary SystemSecondary
Diagnoses/ProblemsICD-10SNOMED-CT
Lab TestsLOINCLocal codes
ProceduresCPT / HCPCSSNOMED-CT
MedicationsRxNormNDC
AllergiesSNOMED-CTNDF-RT
Body SitesSNOMED-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: '...' }
end

Migration 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: :gin

Gap 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, :visibility

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

Improvements 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 list

Key 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: :gin

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

Add 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_date

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

Clinical 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


Implementation Priority

Non-Negotiables (Must Have)

These three items are foundational for a clinical-grade system:

  1. Document Workflow Immutability (Gap B) - AASM state machine, audit logging
  2. Episode-Condition Linkage (EpisodeCondition table) - Problem-focused care
  3. CarePlan Versioning (Gap C) - Active + superseded plan history

High Priority (Address Early)

  1. Unified ClinicalMeasurement Model (Gap D) - Vitals, ROM, anthropometrics
  2. Attachments as First-Class Model (Gap F) - Polymorphic clinical attachments

Medium Priority

  1. Orders vs Results Separation (Gap A) - ServiceRequest/DiagnosticResult
  2. Coding System Consistency (Gap E) - ICD-10, LOINC, SNOMED-CT

Lower Priority (Can Iterate)

  1. 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 trail

Outcome: 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 tables

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

Outcome: 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: true

Migration Path:

  1. New records use encounter_document_id / encounter_id
  2. Old records continue to use prescription_id
  3. Queries use COALESCE(encounter_document_id, prescription_id) during transition
  4. Eventually deprecate prescription_id once all data migrated

Outcome: Prescription model becomes optional, not required.

Phase 4: Consolidate Duplicates

Identify canonical models and deprecate redundant ones:

Keep (Canonical)DeprecateMigration
care_planscareplansMerge data, drop old table
clinical_observationsobservationsMerge data, drop old table
encounter_documentsSpecialty exam tablesConvert 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 optimization

Risk 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?
end

Rollback 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

On this page

OverviewNon-Negotiables for Industry-Leading Execution1. Document Workflow Immutability (Gap B)2. Episodes Anchored to Clinical Problem/Condition (EpisodeCondition)3. CarePlan Revision History (Gap C - Versioning)Gold Standard Clinical Data HierarchyClinical Data Backbone ArchitectureCore EntitiesBest-Practice Bridge PatternEncounterDocument Type Taxonomy (doc_type)Doctor DocumentationTherapist/Psychologist DocumentationPhysiotherapist DocumentationDietitian DocumentationCross-Domain TypesData Placement Decision RulesPut it in EncounterDocument when...Put it in EpisodeOfCare when...Put it in CarePlan when...Put it in Goal when...CarePlan ↔ Encounter Connection PatternCanonical RelationshipsGap A: Orders vs Results SeparationProblemFHIR ResourcesProposed Data ModelServiceRequest (Order)DiagnosticResult (Observation)DiagnosticReport (Grouped Results)Model AssociationsGap B: Document Workflow ImmutabilityProblemCurrent GapEnhanced EncounterDocument ModelDocumentAuditLogSchema VersioningGap C: Plan vs Session Split + CarePlan VersioningProblemFHIR AlignmentProposed Model ClarificationEpisode Level (already exists - enhance)CarePlan Versioning Model (Non-Negotiable)CarePlan Model with Version ManagementCarePlan Audit LogAPI Endpoints for CarePlan VersioningUI: Plan Evolution TimelineEncounter Level (session documentation)ProgressNote (Episode-Level Summaries)Gap D: Unified ClinicalMeasurement ModelProblemEnhanced ClinicalMeasurement ModelMeasurement Type RegistryGap E: Coding System ConsistencyProblemFHIR-Aligned Coding ModelCodeableConcept PatternStandard Code SystemsImplementation PatternMigration for Existing ModelsGap F: Attachments as First-Class ModelProblemClinicalAttachment ModelModel ImplementationImprovements to EncounterDocument PlanSchema VersioningEnhanced Visibility ModelKey Constraints and IndexesImprovements to EpisodeOfCare PlanAdd ProgressNote TableAdd Condition LinkageAdd Referral SourceUse deleted_at Pattern ConsistentlyClinical Backend Completeness ChecklistCore Clinical ResourcesEncounter ResourcesOrders and ResultsTreatment PlanningAssessmentsSafety and ComplianceCoding ConsistencyRelated DocumentationImplementation PriorityNon-Negotiables (Must Have)High Priority (Address Early)Medium PriorityLower Priority (Can Iterate)Migration Strategy (Minimal Rework, Safest Path)Phase 1: Add New Primitives (No Breakage)Phase 2: Bridge Old World to New WorldPhase 3: Decouple from PrescriptionsPhase 4: Consolidate DuplicatesMigration SequenceRisk MitigationData IntegrityRollback StrategyDual-Write Pattern