Skip to content

Multi-Tenancy Phase 2 — Risk Assessment + MSO Second Opinion

Status: Implemented in Session 49 ADR: 0018-multi-tenancy-platform-architecture Prerequisites: Phase 0 (RBAC, case shares, redaction, state machine) ✓, Phase 1 (provider flow) ✓ Estimated effort: 4–5 weeks (backend + frontend vertical slice)


Goal

Two workflows that sit between provider selection and payment:

  1. Risk Assessment — rule-based risk scoring → human review queue → approve/reject/request more info
  2. MSO Second Opinion — optional medical second opinion from an independent specialist via document review or teleconsult
consent_given
risk_review_pending ← [Rule engine scores, human reviews]
risk_cleared
providers_notified → quoting → quotes_pooled → patient_reviewing → provider_selected
mso_offered ← [Patient chooses: document review, video, or skip]
mso_complete (or decline_mso → payment_locked)
payment_locked

NOTE: State machine needs TWO skip paths:
  - skip_mso: provider_selected → payment_locked (patient never enters mso_offered)
  - decline_mso: mso_offered → payment_locked (patient entered but declined)
  Both are pre-existing gaps — add decline_mso transition in case_machine.py.

Scope

What's In

# Deliverable Layer
2.1 Risk scoring service (wraps existing risk_assessor.py) Backend
2.2 Risk review queue + reviewer API Backend
2.3 Reviewer dashboard (coordinator app) Frontend
2.4 MSO doctor model extensions Backend
2.5 MSO matching service Backend
2.6 MSO consultation booking + management Backend
2.7 MSO consultation document model Backend
2.8 MSO portal (doctor app or coordinator app section) Frontend
2.9 Video integration stub (Daily.co room creation) Backend

What's Out (Phase 3+)

  • Actual video SDK embedded in frontend (Phase 3 — just room URL for now)
  • MSO doctor onboarding wizard (Phase 3)
  • Patient self-service MSO booking (Phase 3 — coordinator-mediated for now)
  • Payment/escrow for MSO consultation (Phase 3)
  • Consultation recording (deferred — consent compliance)

2.1 — Risk Scoring Service

Existing Foundation

app/services/risk_assessor.py already implements ~30 deterministic rules. Phase 2 wraps it in a workflow.

New Files

File Purpose
app/services/risk_scoring_service.py Orchestrates risk assessment

Service

@dataclass
class RiskAssessmentResult:
    """Typed return from risk scoring — never a raw dict."""
    risk_score: int           # 0-100
    risk_level: str           # low | moderate | high
    blocking_count: int
    risk_factors: list[dict]
    auto_cleared: bool
    review_required: bool


class RiskScoringService:
    """Scores risk but does NOT mutate case state.

    DESIGN: This is a domain service, not an orchestrator. It returns
    RiskAssessmentResult and the CALLER (case lifecycle service or router)
    is responsible for state transitions. This avoids cross-domain session sharing.
    """

    async def assess_case(
        self, db: AsyncSession, case_id: str, tenant_id: str
    ) -> RiskAssessmentResult:
        """Run risk assessment on a case.

        1. Load EHR snapshot
        2. Call risk_assessor.assess_risks(ehr)
        3. Compute aggregate risk score (0-100)
        4. Determine auto-clear eligibility
        5. Store results on case (risk_assessment JSONB)

        Does NOT transition case state — caller handles that.
        """

    def compute_risk_score(self, risk_factors: list[dict]) -> int:
        """Aggregate risk factors into a 0-100 score.

        Scoring:
        - Each high severity: +20
        - Each moderate: +10
        - Each low: +3
        - Each blocking: +25 (additive with severity)
        - Cap at 100
        """

    def determine_auto_clear(self, risk_score: int, blocking_count: int) -> bool:
        """Low-risk cases with no blocking factors can auto-clear.

        Requires risk_review_human_required=false flag.
        """

Case Model Extension

Add to app/models/case.py:

risk_assessment: Mapped[dict | None] = mapped_column(FlexibleJSON, nullable=True)
# Stores: { risk_score, risk_level, risk_factors, assessed_at, assessed_by }

Migration

ALTER TABLE cases ADD COLUMN risk_assessment JSONB;

2.2 — Risk Review Queue + Reviewer API

New Files

File Purpose
app/routers/risk_review.py Reviewer endpoints
app/services/risk_review_service.py Review workflow
app/schemas/risk_review.py Pydantic schemas

Endpoints

GET  /api/v1/risk/queue
  → Cases in risk_review_pending, sorted by risk_score desc
  → Requires: @require_permission("case:assign")

GET  /api/v1/risk/{case_id}/assessment
  → Risk factors + EHR snapshot + patient context
  → Requires: @require_permission("case:read:assigned")

POST /api/v1/risk/{case_id}/decision
  Body: { decision: "approved|rejected|more_info", notes, override_blocking: bool, override_reason }
  → Transitions case state + audit log
  → If approved: risk_review_pending → risk_cleared
  → If more_info: stays in risk_review_pending, patient notified
  → If rejected: case → closed with reason
  → Requires: @require_permission("case:assign")

GET  /api/v1/risk/{case_id}/history
  → Audit trail of risk decisions

Review Decision Model

# Stored in case.risk_assessment after review:
{
    "risk_score": 35,
    "risk_level": "moderate",
    "risk_factors": [...],
    "assessed_at": "2026-04-19T...",
    "review_decision": "approved",
    "reviewed_by": "coordinator-clerk-id",
    "reviewed_at": "2026-04-19T...",
    "review_notes": "Low risk, no blocking factors",
    "override_blocking": false,
    "override_reason": null
}

2.3 — Reviewer Dashboard

Location

apps/coordinator-app/ — existing scaffold. Add risk review section.

Pages

Risk Queue — table of cases pending review: - Case #, procedure, patient age, risk score (color-coded), blocking count, time in queue - Sort by risk score (high first) - Filter: high-risk only, blocking only

Risk Detail — case assessment view: - Risk factors list with severity badges + source provenance - EHR snapshot (read-only, using shared FullEHRDrawerV2 inline) - Decision form: Approve / Request More Info / Reject + notes

Design Direction

Same Linear-style as provider portal. Coordinator is also a professional tool — efficiency over warmth. Reuse UrgencyBadge pattern from provider-app.


2.4 — MSO Doctor Model Extensions

Modify app/models/doctor.py

Add fields:

# MSO credentialing
mso_credentialing_status: Mapped[str | None] = mapped_column(String(30))
# pending_review | verified | suspended | inactive
mso_verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))

# Consultation rates (USD cents)
consultation_hourly_rate_cents: Mapped[int | None] = mapped_column(Integer)
document_review_rate_cents: Mapped[int | None] = mapped_column(Integer)

# Availability
max_concurrent_consultations: Mapped[int] = mapped_column(Integer, default=3)
consultation_languages: Mapped[list | None] = mapped_column(FlexibleJSON)

Migration

ALTER TABLE doctors
    ADD COLUMN mso_credentialing_status VARCHAR(30),
    ADD COLUMN mso_verified_at TIMESTAMPTZ,
    ADD COLUMN consultation_hourly_rate_cents INTEGER,
    ADD COLUMN document_review_rate_cents INTEGER,
    ADD COLUMN max_concurrent_consultations INTEGER DEFAULT 3;
-- NOTE: consultation_languages already exists on doctors table — do NOT re-add

2.5 — MSO Matching Service

New Files

File Purpose
app/services/mso_matching_service.py Match MSO doctors to cases

Algorithm

class MSOMatchingService:

    async def match_mso_doctors(
        self, db: AsyncSession, *,
        case_id: str,
        procedure_code: str,
        patient_language: str,
        selected_provider_id: str,  # Exclude same-hospital doctors
        consultation_format: str,  # document_review | video | both
    ) -> list[dict]:
        """Find top 3 MSO doctors for a case.

        Ranking factors:
        - Specialty match (procedure ↔ doctor specialty): 0.35
        - Language concordance: 0.25
        - Credentials (board certs): 0.20
        - Availability + response time: 0.15
        - Patient ratings: 0.05

        Exclusions:
        - Doctors affiliated with selected_provider_id
        - mso_credentialing_status != 'verified'
        - is_active != true
        """

2.6 — MSO Consultation Booking + Management

Modify app/models/consultation.py

IMPORTANT: The existing model has provider_id which refers to a hospital/clinic (Provider table). For MSO, we need doctor_id which refers to an individual specialist (Doctor table). These are different entities — do NOT conflate them.

Add fields:

case_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("cases.id"))
doctor_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("doctors.id"))
# The MSO specialist — distinct from provider_id (hospital). Both can be set:
# provider_id = the hospital context, doctor_id = the individual MSO doctor.
consultation_format: Mapped[str | None] = mapped_column(String(30))
# document_review | video_call
cost_cents: Mapped[int | None] = mapped_column(Integer)
cost_currency: Mapped[str] = mapped_column(String(3), default="USD")
consultation_document: Mapped[dict | None] = mapped_column(FlexibleJSON)
# The MSO's structured written outcome (JSONB, not a separate table)

New Files

File Purpose
app/services/mso_consultation_service.py Booking, lifecycle, document
app/routers/mso_portal.py MSO-facing endpoints
app/schemas/mso.py Pydantic schemas

MSO Endpoints

# MSO doctor-facing
GET  /api/v1/mso/consultations
  → List consultations assigned to this MSO doctor
  → Requires: @require_permission("consultation:read")

GET  /api/v1/mso/consultations/{consultation_id}
  → Case data (MSO redaction policy — clinical only, no PII, no financial)

POST /api/v1/mso/consultations/{consultation_id}/document
  Body: { sections: { clinical_summary, procedure_assessment, ... }, recommendation }
  → Submit consultation document
  → Transitions consultation status → completed

# Patient/coordinator-facing
POST /api/v1/cases/{case_id}/mso/request
  Body: { consultation_format, doctor_id }
  → Create consultation + transition case to mso_offered
  → Requires: case owner or coordinator

GET  /api/v1/cases/{case_id}/mso/status
  → Current MSO consultation status + doctor info + document (if complete)

2.7 — Consultation Document Model

New table or JSONB on consultation

# Stored as consultation.consultation_document (FlexibleJSON):
{
    "clinical_summary": "MSO's interpretation...",
    "procedure_assessment": "Procedure is appropriate for...",
    "risk_mitigation": "Specific recommendations...",
    "provider_assessment": "Selected provider is suitable...",
    "alternative_options": "None recommended",
    "patient_facing_summary": "Plain language version...",
    "recommendation": "proceed_with_plan",  # proceed_with_plan | consider_alternatives | request_more_info
    "submitted_at": "2026-04-19T..."
}

No separate table needed — JSONB on the consultation row is sufficient for Phase 2.


2.9 — Video Integration Stub

Phase 2 Scope: Room creation only

class VideoRoomService:
    """Stub for Daily.co/100ms room management.

    Phase 2: Create room URL, return to frontend.
    Phase 3: Embedded SDK, recording, participant tracking.
    """

    async def create_room(
        self, consultation_id: str, duration_minutes: int = 30
    ) -> str:
        """Create a video room and return the join URL.

        Phase 2: returns a mock URL or Daily.co API call (gated by flag).
        """

    async def close_room(self, room_id: str) -> None:
        """Close a video room after consultation ends."""

Feature flag: mso_video_embedded controls whether real Daily.co API is called.


Implementation Order

Week 1: Risk assessment
  Day 1-2: RiskScoringService + case model extension + migration
  Day 3-4: Risk review API (queue, assessment, decision)
  Day 5:   Feature flags + integration tests

Week 2: Reviewer dashboard + MSO foundation
  Day 1-2: Coordinator app risk review pages
  Day 3:   MSO doctor model extensions + migration
  Day 4-5: MSO matching service

Week 3: MSO consultation flow
  Day 1-2: Consultation booking service + endpoints
  Day 3-4: Consultation document model + MSO portal
  Day 5:   Video room stub + feature flags

Week 4: Integration + polish
  Day 1-2: End-to-end integration testing
  Day 3:   Coordinator dashboard MSO section
  Day 4-5: Design review + polish

Feature Flags

Flag Default Purpose
risk_assessment_enabled true Gate the entire risk workflow (when false, consent_given skips to providers_notified)
risk_review_human_required true All cases need human review (when false, low-risk auto-clears)
risk_blocking_override_allowed false Reviewers can override blocking risks
mso_consultation_enabled false Enable MSO matching + consultation flow (parent gate for all MSO features)
mso_video_embedded false Use Daily.co API for rooms (when false, mock URL). Only effective when mso_consultation_enabled=true.

Test Plan

Area Tests (est.)
Risk scoring (aggregate, auto-clear) 10–12
Risk review API (queue, decision, history) 12–15
Risk state transitions 8–10
MSO matching (specialty, language, exclusion) 10–12
MSO consultation booking 10–12
Consultation document submission 6–8
Video room stub 4–6
Integration (risk → MSO → payment) 8–10
Total ~70–85

Security Considerations

  1. MSO sees clinical data only — RedactionPolicy.MSO_CLINICAL already defined in Phase 0. No PII, no financial data.
  2. MSO exclusion from provider — MSO doctor must NOT be affiliated with the selected surgical provider.
  3. Risk override audit — Every blocking override is logged with reviewer ID, reason, and timestamp.
  4. Consultation document immutable — Once submitted, the MSO document cannot be edited (append-only correction notes instead).
  5. Video room access — Room URL contains a time-limited token. Room auto-closes after duration + buffer.

State Machine Reconciliation

VALID_CASE_STATUSES in case.py is stale — it lists 9 statuses but the state machine has 27. Phase 2 must reconcile this:

  1. Add risk_assessment_enabled top-level feature flag to gate the entire risk workflow
  2. Add decline_mso transition: mso_offered → payment_locked (patient declines after seeing MSO options)
  3. When case_state_machine_v2=true, the state machine is the single source of truth
  4. VALID_CASE_STATUSES remains as a backward-compat fallback when flag is off
  5. New statuses used by Phase 2 (risk_review_pending, risk_cleared, mso_offered, mso_complete) are already in the state machine enum

RBAC Permissions for Phase 2

Add to config/rbac_defaults.yaml:

coordinator:
  permissions:
    # ... existing ...
    - case:risk_review:read    # View risk queue
    - case:risk_review:decide  # Approve/reject/request more info

Separate read vs decide permissions — viewing a queue and making decisions are different privilege levels.


Open Questions

  1. Auto-clear threshold: What risk_score threshold should auto-clear? Recommendation: risk_score < 20 AND blocking_count == 0.

  2. MSO cost model: Fixed platform rate or per-doctor rate? Recommendation: Per-doctor rate (already modeled) with platform minimum floor.

  3. Consultation chat window: How long after consultation does the chat stay open? Recommendation: 7 days after MSO submits document. Then read-only.

  4. Multiple MSO opinions: Can a patient request a second MSO if unsatisfied? Recommendation: Yes, one additional opinion (max 2 total). Coordinator approval required for the second.

Deviations from Spec

  • Auto-clear for low-risk cases (risk_score < 20, no blocking factors) is implemented but gated behind risk_review_human_required=false (default: true). Human review is required by default in production.
  • MSO video rooms use a mock Daily.co URL in Phase 2; live Daily.co API integration gated behind mso_video_embedded flag (default: false).
  • Consultation chat window (7 days read-only after submission) deferred to Phase 3 — not yet implemented.
  • Multiple MSO opinions (max 2) deferred to Phase 3.