Multi-Tenancy Phase 1 — Provider Flow Implementation Spec¶
Status: Implemented in Session 47 ADR: 0018-multi-tenancy-platform-architecture Prerequisites: Phase 0 complete (RBAC, case shares, redaction engine, state machine) Estimated effort: 4–5 weeks (backend + frontend vertical slice)
Goal¶
A provider can receive a forwarded case, view the redacted patient EHR, and submit a quote. End-to-end flow:
Patient completes intake → Consent given → Coordinator forwards case
↓
CaseShare created (copy mode, provider_snapshot redaction)
↓
Provider receives notification → Opens case in Provider Portal
↓
Views redacted patient data (pseudonymized, no PII)
↓
Submits quote (cost breakdown, timeline, notes)
↓
Quote appears in patient's match results for comparison
Scope¶
What's In¶
| # | Deliverable | Layer |
|---|---|---|
| 1.1 | Provider quote model + repository | Backend |
| 1.2 | Case forwarding service | Backend |
| 1.3 | Provider API endpoints (inbox, case view, quote, decline) | Backend |
| 1.4 | Clerk multi-org provider tenant wiring | Backend |
| 1.5 | Provider notification on case forward | Backend |
| 1.6 | Provider portal: auth + case inbox | Frontend |
| 1.7 | Provider portal: redacted case viewer | Frontend |
| 1.8 | Provider portal: quote builder form | Frontend |
| 1.9 | Patient quote comparison view | Frontend |
What's Out (Phase 2+)¶
- Provider ↔ coordinator messaging (Phase 2)
- Provider analytics dashboard (Phase 3)
- Provider onboarding wizard (Phase 2)
- MSO second opinion flow (Phase 2)
- Payment/escrow integration (Phase 3)
1.1 — Provider Quote Model¶
New Files¶
| File | Purpose |
|---|---|
app/models/provider_quote.py |
ProviderQuote model |
app/repositories/provider_quote_repository.py |
Data access |
alembic/versions/xxxx_add_provider_quotes.py |
Migration |
Model¶
# app/models/provider_quote.py
class QuoteStatus(str, Enum):
SUBMITTED = "submitted"
EXPIRED = "expired" # Set by query-time check: WHERE expires_at < NOW()
ACCEPTED = "accepted" # Patient selected this provider
REJECTED = "rejected" # Patient selected a different provider
class ProviderQuote(Base, UUIDPrimaryKeyMixin, TenantScopedMixin, TimestampMixin):
"""A provider's cost quote for a forwarded case."""
__tablename__ = "provider_quotes"
case_share_id: Mapped[str] = mapped_column(
String(36), ForeignKey("case_shares.id"), nullable=False, index=True
)
provider_id: Mapped[str] = mapped_column(
String(36), ForeignKey("providers.id"), nullable=False, index=True
)
# Cost breakdown (all in smallest currency unit)
procedure_cost: Mapped[int] = mapped_column(Integer, nullable=False)
currency: Mapped[str] = mapped_column(String(3), nullable=False, default="USD")
# Itemized components (JSONB for flexibility)
cost_breakdown: Mapped[dict] = mapped_column(FlexibleJSON, nullable=False, default=dict)
# {
# "hospital_stay_nights": 5,
# "hospital_stay_cost": 150000,
# "implants_cost": 50000,
# "anesthesia_cost": 15000,
# "follow_up_visits": 2,
# "follow_up_cost": 20000,
# "other_items": [{"label": "...", "cost": ...}]
# }
total_cost: Mapped[int] = mapped_column(Integer, nullable=False)
# Sum of procedure_cost + all breakdown items
# Timeline
estimated_start_date: Mapped[date | None] = mapped_column(Date, nullable=True)
estimated_duration_days: Mapped[int | None] = mapped_column(Integer, nullable=True)
validity_days: Mapped[int] = mapped_column(Integer, nullable=False, default=30)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
# Status
status: Mapped[str] = mapped_column(String(20), nullable=False, default="submitted")
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
# Who submitted
submitted_by: Mapped[str] = mapped_column(String(255), nullable=False)
# Clerk user ID of the provider staff member
Why a separate table (not JSONB on case_shares)¶
- Quotes need their own lifecycle (draft → submitted → expired → accepted)
- Cost analytics across providers requires queryable columns
- Multiple quote revisions per case_share possible (provider updates quote)
- Audit trail per quote, not per share
1.2 — Case Forwarding Service¶
New Files¶
| File | Purpose |
|---|---|
app/services/case_forwarding_service.py |
Orchestrates forwarding |
Flow¶
class CaseForwardingService:
async def forward_case_to_providers(
self, db: AsyncSession, *,
case_id: str,
tenant_id: str,
provider_tenant_ids: list[str],
forwarded_by: str, # coordinator's clerk user ID
message: str | None = None,
) -> list[CaseShare]:
"""Forward a case to one or more provider tenants.
IMPORTANT: Case must be in risk_cleared state. The state machine
enforces consent_given → risk_review_pending → risk_cleared →
providers_notified. Forwarding from consent_given is NOT allowed
(risk review is mandatory per ADR-0018).
This is ORCHESTRATION-LAYER code (like case_orchestrator.py), not
a domain service. It coordinates across domains: case state, shares,
redaction, notifications. Place in app/services/orchestration/.
1. Validate case is in risk_cleared state
2. Load case + EHR + patient data
3. For each provider:
a. Apply provider_snapshot redaction → RedactedCaseData
b. Create CaseShare (copy mode, 30-day expiry)
c. Store serialized RedactedCaseData in case_share.redacted_snapshot
d. Send notification to provider
4. Advance case state to providers_notified (via CaseLifecycleService)
5. Return created shares
"""
async def get_forwarded_case(
self, db: AsyncSession, *,
case_share_id: str,
provider_tenant_id: str,
) -> RedactedCaseData:
"""Get redacted case data for a provider.
Deserializes RedactedCaseData from the stored snapshot.
Provider never queries the live patient data.
"""
Redacted Snapshot Storage¶
At forwarding time, the redacted data is stored as JSONB on the CaseShare:
# In case_shares table, add:
redacted_snapshot: Mapped[dict | None] = mapped_column(FlexibleJSON, nullable=True)
This means: - Provider reads from the snapshot (fast, no live query) - Patient data changes after forwarding don't affect what the provider sees - Snapshot is immutable once created (audit-safe)
Migration¶
Add redacted_snapshot column to case_shares:
1.3 — Provider API Endpoints¶
New Files¶
| File | Purpose |
|---|---|
app/routers/provider_portal.py |
All provider-facing endpoints |
Endpoints¶
# Case inbox
GET /api/v1/provider/cases
→ List all case_shares for the provider's tenant
→ Returns: case_number, procedure, age, status, forwarded_at, expires_at
# Case detail (redacted)
GET /api/v1/provider/cases/{case_share_id}
→ Returns redacted_snapshot from CaseShare
→ Updates provider_status: RECEIVED → REVIEWING (first access)
# Submit quote
POST /api/v1/provider/cases/{case_share_id}/quote
Body: { procedure_cost, currency, cost_breakdown, estimated_start_date,
validity_days, notes }
→ Creates ProviderQuote, updates provider_status → QUOTED
→ Notifies coordinator
# Decline case
POST /api/v1/provider/cases/{case_share_id}/decline
Body: { reason }
→ Updates provider_status → REJECTED
→ Notifies coordinator
# Request info (coordinator-initiated, provider sees result)
GET /api/v1/provider/cases/{case_share_id}/info-requests
→ List pending info requests from coordinator
Auth¶
All endpoints require:
- Clerk JWT with provider org context
- X-Tenant-ID header matching provider's tenant
- RBAC: case:read:forwarded or quote:write permission
@router.get("/provider/cases")
@require_permission("case:read:forwarded")
async def list_provider_cases(...):
1.4 — Clerk Multi-Org Wiring¶
How It Works¶
- Admin creates provider tenant via admin API:
- Creates Tenant record with
clerk_org_id - Creates Clerk Organization via Clerk API
-
Assigns
provider_adminrole to the provider contact -
Provider staff sign in:
- Standard Clerk sign-in flow
- Clerk resolves their org membership →
clerk_org_id -
Frontend reads org from Clerk session → sets
X-Tenant-ID -
Tenant resolution middleware:
- Existing
TenantContextMiddlewarealready handlesX-Tenant-ID - RBAC middleware resolves roles for the user in that tenant
- No new middleware needed
New Files¶
| File | Purpose |
|---|---|
app/services/provider_onboarding.py |
Create tenant + Clerk org |
Clerk API Integration¶
async def create_provider_tenant(
provider_id: str,
provider_name: str,
admin_email: str,
) -> Tenant:
"""Create a Clerk organization and Curaway tenant for a provider."""
# 1. Create Clerk org
clerk_org = await clerk_client.organizations.create(
name=provider_name,
slug=slugify(provider_name),
)
# 2. Create Curaway tenant
tenant = Tenant(
name=provider_name,
slug=slugify(provider_name),
clerk_org_id=clerk_org.id,
contact_email=admin_email,
country_code=provider.country_code,
)
# 3. Invite admin
await clerk_client.organizations.invite_member(
org_id=clerk_org.id,
email=admin_email,
role="admin",
)
return tenant
Feature Flag¶
provider_clerk_integration:
default: false
description: "Enable Clerk multi-org for provider tenants. When false, provider tenants are created without Clerk org."
1.5 — Provider Notification¶
On Case Forward¶
When a case is forwarded to a provider:
-
Email via QStash → SendGrid:
-
Webhook (if provider has webhook_url in tenant_settings):
On Quote Events¶
- Quote submitted → notify coordinator
- Case expired (no quote) → notify coordinator
- Provider selected → notify provider
1.6–1.8 — Provider Portal Frontend¶
Architecture¶
apps/provider-app/ (existing scaffold)
src/
App.tsx (Clerk provider + router)
pages/
CaseInbox.tsx (list of forwarded cases)
CaseDetail.tsx (redacted case viewer)
QuoteBuilder.tsx (cost breakdown form)
components/
CaseCard.tsx (inbox card)
RedactedEHRViewer.tsx (shared-web EHR with redaction)
QuoteForm.tsx (cost breakdown form fields)
ProviderNav.tsx (sidebar navigation)
services/
providerApi.ts (API client for provider endpoints)
lib/
provider-auth.ts (Clerk provider context)
Pages¶
CaseInbox — List of forwarded cases with status badges
| Column | Source |
|---|---|
| Case # | case_share.case_number (from redacted_snapshot) |
| Procedure | redacted_snapshot.procedure.name |
| Patient Age | redacted_snapshot.age |
| Status | case_share.provider_status |
| Forwarded | case_share.created_at |
| Expires | case_share.expires_at |
| Actions | View / Quote / Decline |
Status badges: RECEIVED (teal), REVIEWING (amber), QUOTED (green), SELECTED (green), REJECTED (red), EXPIRED (gray)
CaseDetail — Redacted patient data
Uses FullEHRDrawerV2 from shared-web (now a pure presentational component) with the redacted snapshot passed as ehr prop. No PII visible — patient name shows as "Patient CRW-2026-00247".
QuoteBuilder — Cost breakdown form
Fields: - Procedure cost (required, currency selector) - Hospital stay: nights + cost per night - Implants/devices cost - Anesthesia cost - Follow-up: visit count + cost per visit - Other line items (add/remove) - Estimated surgery date (date picker) - Quote validity (default 30 days) - Notes (free text) - Auto-calculated total
Validation: - Total must be > 0 - Currency must be valid ISO 4217 - Start date must be in the future - All costs in smallest currency unit (cents)
Shared Components¶
| Component | Package | Reuse |
|---|---|---|
FullEHRDrawerV2 |
shared-web | Provider sees redacted version |
CompletionRing |
shared-web | Case progress indicator |
ScoreBar |
shared-web | Not shown to providers (no PFS/HSS) |
DocumentViewer |
shared-web | View uploaded medical documents |
1.9 — Patient Quote Comparison View¶
In Patient App¶
When quotes arrive, the patient sees them in their match results:
┌──────────────────────────────────────────────┐
│ Acibadem Maslak Hospital, Istanbul │
│ │
│ Hospital Match 86% │
│ Your Readiness 78% │
│ Overall Fit 72% │
│ │
│ QUOTE RECEIVED │
│ Procedure $6,500 │
│ Hospital (5 nights) $1,500 │
│ Follow-up (2 visits) $350 │
│ ────────────────────── │
│ Total $8,350 │
│ Valid until May 18 │
│ │
│ [Select This Provider] │
└──────────────────────────────────────────────┘
API¶
GET /api/v1/cases/{case_id}/quotes
→ Returns all quotes for the patient's case (from all providers)
→ Each quote includes provider name, total, breakdown, validity
→ No provider contact info until selection
Implementation Order¶
Week 1: Backend foundation
Day 1-2: ProviderQuote model + migration + repository
Day 3-4: CaseForwardingService + redacted_snapshot column
Day 5: Provider API endpoints (inbox, detail, quote, decline)
Week 2: Auth + notifications
Day 1-2: Clerk multi-org wiring (provider tenant creation)
Day 3: Provider notification service (email + webhook)
Day 4-5: Feature flags + integration tests
Week 3: Provider portal frontend
Day 1: Provider app auth (Clerk) + routing + API client
Day 2-3: CaseInbox + CaseDetail pages
Day 4-5: QuoteBuilder form + submission
Week 4: Patient side + polish
Day 1-2: Patient quote comparison view
Day 3: Provider selection flow (select → notify)
Day 4-5: Integration testing + design review
Feature Flags¶
| Flag | Default | Purpose |
|---|---|---|
provider_forwarding_v1 |
false |
Enable case forwarding + quote APIs |
provider_clerk_integration |
false |
Create Clerk orgs for provider tenants |
provider_portal_v1 |
false |
Enable provider portal frontend |
Test Plan¶
| Area | Test Type | Count (est.) |
|---|---|---|
| ProviderQuote model/repo | Integration | 10–12 |
| CaseForwardingService | Integration | 12–15 |
| Provider API endpoints | Integration | 15–20 |
| Redaction at forwarding | Unit | 8–10 |
| Provider status transitions | Unit | 8–10 |
| Quote validation | Unit | 10–12 |
| Clerk multi-org | Integration (mocked) | 5–8 |
| Patient quote view | Integration | 5–8 |
| Total | ~75–95 |
Migration Strategy¶
Two migrations:
xxxx_01_add_provider_quotes.py → provider_quotes table + RLS
xxxx_02_add_redacted_snapshot.py → redacted_snapshot column on case_shares
Both use DATABASE_URL_ADMIN (superuser) per the Alembic admin connection fix.
Security Considerations¶
-
Provider never queries live patient data. They read from
redacted_snapshot— a frozen copy created at forwarding time. -
RLS on provider_quotes: Scoped to provider's tenant. Provider A cannot see Provider B's quotes.
-
Patient cannot see other providers' quotes directly. The
GET /cases/{id}/quotesendpoint aggregates quotes but does not expose provider-to-provider comparisons. -
Quote submission validates that the case_share is active, not expired, and belongs to the provider's tenant.
-
Clerk org isolation: Each provider gets their own Clerk organization. Staff members can only access their org's data.
Quote Expiration Strategy¶
Phase 1: query-time check (no cron job).
# In provider_quote_repository.py
def _with_expiry_status(self, query):
"""Quotes past expires_at are treated as EXPIRED regardless of DB status."""
return query # Status resolved in the service layer, not the query
# In provider API response serialization:
effective_status = quote.status
if quote.expires_at and quote.expires_at < datetime.now(timezone.utc):
effective_status = "expired"
Phase 2: Add QStash scheduled task to batch-update expired quotes nightly.
Quote Idempotency¶
POST .../quote requires X-Idempotency-Key header (per API conventions).
Unique constraint: (case_share_id, provider_id) — one active quote per provider per case.
If a quote already exists for this case_share, return the existing quote (idempotent).
Server-Side Total Validation¶
The total_cost field is recalculated server-side from procedure_cost + sum(cost_breakdown items).
Client-provided total is ignored. This prevents financial discrepancies from client bugs.
Additional RBAC Permissions¶
Add to provider_admin role:
- case:decline:forwarded — decline a forwarded case
The quote:write permission covers quote submission. Declining is a separate action
that should be separately auditable.
Resolved Decisions¶
-
Quote line items: Backend config provides procedure-specific templates (expected line items per procedure type) + provider can start from blank. Seed data needed for showcase/demo.
-
AI assistant scope: Can access current case data AND provider's historical quotes for pricing suggestions.
-
Multi-staff access: Multiple staff can view simultaneously. Optimistic locking on quote edit (first submitter wins, second gets conflict error).
-
Notifications: Both email (QStash → SendGrid) and in-app push for new/urgent cases.
-
Portal design: Linear-style (minimal, fast, keyboard-navigable). AI chat assistant as right panel (Option B), evolving to primary interface (Option A) in future phase.
Open Questions¶
-
Quote revision: Can a provider update a submitted quote, or must they submit a new one? Recommendation: Allow one update within 48 hours. After that, the original stands.
-
Minimum quote threshold: Should we wait for N providers to quote before showing quotes to the patient? Recommendation: Show quotes as they arrive.
-
Currency conversion: If provider quotes in TRY and patient budget is in USD, who converts? Recommendation: Display in provider's currency with approximate USD equivalent. Full multi-currency in Phase 2 (ADR-0017).
Deviations from Spec¶
- Clerk multi-org webhook not wired;
invite_staffendpoint uses a mock Clerk call in Phase 1. Live webhook integration shipped in Session 48. - Provider Portal frontend (items 1.6–1.9) bootstrapped in Session 46 (Wave 2 design system) rather than alongside the backend in Session 47.
QuoteStatus.EXPIREDis computed at query time viaWHERE expires_at < NOW()rather than a scheduled job — deferred to Phase 2.- AI quote assistant (spec item: AI pricing suggestions) deferred to Phase 2.