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:
- Risk Assessment — rule-based risk scoring → human review queue → approve/reject/request more info
- 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¶
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¶
- MSO sees clinical data only — RedactionPolicy.MSO_CLINICAL already defined in Phase 0. No PII, no financial data.
- MSO exclusion from provider — MSO doctor must NOT be affiliated with the selected surgical provider.
- Risk override audit — Every blocking override is logged with reviewer ID, reason, and timestamp.
- Consultation document immutable — Once submitted, the MSO document cannot be edited (append-only correction notes instead).
- 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:
- Add
risk_assessment_enabledtop-level feature flag to gate the entire risk workflow - Add
decline_msotransition:mso_offered → payment_locked(patient declines after seeing MSO options) - When
case_state_machine_v2=true, the state machine is the single source of truth VALID_CASE_STATUSESremains as a backward-compat fallback when flag is off- 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¶
-
Auto-clear threshold: What risk_score threshold should auto-clear? Recommendation: risk_score < 20 AND blocking_count == 0.
-
MSO cost model: Fixed platform rate or per-doctor rate? Recommendation: Per-doctor rate (already modeled) with platform minimum floor.
-
Consultation chat window: How long after consultation does the chat stay open? Recommendation: 7 days after MSO submits document. Then read-only.
-
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 behindrisk_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_embeddedflag (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.