Skip to content

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:

ALTER TABLE case_shares ADD COLUMN redacted_snapshot JSONB;

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

  1. Admin creates provider tenant via admin API:
    POST /api/v1/admin/providers/{provider_id}/tenant
    
  2. Creates Tenant record with clerk_org_id
  3. Creates Clerk Organization via Clerk API
  4. Assigns provider_admin role to the provider contact

  5. Provider staff sign in:

  6. Standard Clerk sign-in flow
  7. Clerk resolves their org membership → clerk_org_id
  8. Frontend reads org from Clerk session → sets X-Tenant-ID

  9. Tenant resolution middleware:

  10. Existing TenantContextMiddleware already handles X-Tenant-ID
  11. RBAC middleware resolves roles for the user in that tenant
  12. 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:

  1. Email via QStash → SendGrid:

    Subject: New case referral: CRW-2026-00247 — Total Knee Replacement
    Body: You've received a new case referral. Log in to review.
    CTA: [View Case →]
    

  2. Webhook (if provider has webhook_url in tenant_settings):

    {
      "event": "case.forwarded",
      "case_share_id": "...",
      "case_number": "CRW-2026-00247",
      "procedure": "Total Knee Replacement",
      "expires_at": "2026-05-18T00:00:00Z"
    }
    

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

  1. Provider never queries live patient data. They read from redacted_snapshot — a frozen copy created at forwarding time.

  2. RLS on provider_quotes: Scoped to provider's tenant. Provider A cannot see Provider B's quotes.

  3. Patient cannot see other providers' quotes directly. The GET /cases/{id}/quotes endpoint aggregates quotes but does not expose provider-to-provider comparisons.

  4. Quote submission validates that the case_share is active, not expired, and belongs to the provider's tenant.

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

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

  2. AI assistant scope: Can access current case data AND provider's historical quotes for pricing suggestions.

  3. Multi-staff access: Multiple staff can view simultaneously. Optimistic locking on quote edit (first submitter wins, second gets conflict error).

  4. Notifications: Both email (QStash → SendGrid) and in-app push for new/urgent cases.

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

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

  2. Minimum quote threshold: Should we wait for N providers to quote before showing quotes to the patient? Recommendation: Show quotes as they arrive.

  3. 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_staff endpoint 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.EXPIRED is computed at query time via WHERE expires_at < NOW() rather than a scheduled job — deferred to Phase 2.
  • AI quote assistant (spec item: AI pricing suggestions) deferred to Phase 2.