Skip to content

Sequence — Patient Happy Path

The end-to-end flow that produces Curaway's primary revenue path: a patient lands on the app, creates a case, uploads documents, chats through intake, gets matched to providers, sees explanations, and consents to forwarding. Split into three phases below — each is a separate runtime concern for the migration team.

Audience: Engineering team planning the GCP migration. Read with the data flow map which captures PHI residency for each box on the right side of these diagrams.


Phase A — Sign-in + case creation + document upload

The patient signs in, opens a new case, and uploads medical documents. Document text extraction is deferred to a background pipeline (Phase A2) — the upload itself returns immediately so the UI stays responsive.

A1 — Synchronous portion (presign + confirm)

sequenceDiagram
    autonumber
    actor Patient
    participant FE as Frontend (Vercel)
    participant Clerk as Clerk
    participant API as Curaway API (Railway)
    participant PG as PostgreSQL (Railway)
    participant R2 as Cloudflare R2

    Patient->>FE: open app
    FE->>Clerk: sign-in flow (Clerk hosted)
    Clerk-->>FE: session JWT (org_id, org_role, sub)
    Note over FE,Clerk: CLERK_PUBLISHABLE_KEY (frontend)

    Patient->>FE: "Start a new case"
    FE->>API: POST /api/v1/cases<br/>Authorization: Bearer JWT<br/>X-Patient-ID: pat_…
    API->>API: RBACMiddleware:<br/>verify JWT + resolve tenant_id<br/>via tenant_org_mappings
    API->>PG: INSERT case + conversation rows
    API-->>FE: 201 { case_id }

    Patient->>FE: drag medical PDF
    FE->>API: POST /documents/presign<br/>{ filename, content_type }
    API->>R2: GenerateClient.generate_presigned_url(PUT)
    Note over API,R2: AWS_ACCESS_KEY_ID (R2 creds)
    R2-->>API: presigned URL (15-min TTL)
    API->>PG: INSERT documents row (status=pending)
    API-->>FE: { upload_url, storage_key }

    FE->>R2: PUT bytes (direct, bypasses Curaway API)
    R2-->>FE: 200

    FE->>API: POST /documents/confirm { storage_key }
    API->>PG: UPDATE documents SET status='uploaded'
    API->>API: enqueue extraction job (see Phase A2)
    API-->>FE: 200

Migration callouts

Concern Today GCP target
Presign URL R2 SigV4 GCS V4 signed URLs (Cloud Storage API)
Direct upload Browser → R2 (no Curaway egress) Browser → GCS (same pattern; CORS config required)
Auth verify Clerk JWT verified in middleware Unchanged (Clerk SaaS)
Tenant resolve tenant_org_mappings SELECT in Cloud SQL Unchanged (just DB host)
Case insert PG cases + conversations tables Cloud SQL Postgres

PHI crossings: none in this sub-flow (file content goes browser→R2 directly, never traverses Curaway API). Filename and content_type may carry weak identifiers; treat metadata as PHI by policy.

A2 — Asynchronous extraction (fired by /confirm)

sequenceDiagram
    autonumber
    participant API as Curaway API
    participant QS as Upstash QStash
    participant Worker as Curaway API (worker route)
    participant R2 as Cloudflare R2
    participant Anthropic as Anthropic
    participant PG as PostgreSQL
    participant N4 as Neo4j AuraDB
    participant LF as Langfuse

    API->>QS: enqueue { document_id }<br/>X-Curaway-Signature: HMAC
    QS-->>API: 202 (deliver later)

    QS->>Worker: POST /api/v1/internal/extract<br/>X-Internal-Secret + X-Curaway-Signature
    Worker->>R2: GET object bytes
    R2-->>Worker: PDF/image bytes

    Worker->>Worker: PyMuPDF text extract
    alt OCR fallback (low text)
        Worker->>Anthropic: Claude Vision (medical_extractor agent)
        Note over Worker,Anthropic: ANTHROPIC_API_KEY<br/>traced to Langfuse
    end

    Worker->>Anthropic: extract clinical entities + ICD-10<br/>(via llm_gateway with GPT-4o-mini fallback)
    Anthropic-->>Worker: structured JSON
    Worker--xLF: trace (case_id, patient_id, model, tokens)

    Worker->>PG: INSERT fhir_resources rows
    Worker->>N4: MERGE clinical knowledge nodes<br/>(Patient → Condition → Procedure)
    Note over Worker,N4: NEO4J_URI + NEO4J_PASSWORD
    Worker->>PG: UPDATE documents SET status='extracted'<br/>UPDATE cases SET layer_state.medical=…

Migration callouts

Concern Today GCP target
Async queue Upstash QStash (HMAC-signed delivery) Cloud Tasks (preferred — same delivery model) or Pub/Sub. Cloud Tasks supports auth-token injection so you don't reimplement HMAC
Worker Same Railway container as API Cloud Run service (separate concurrency settings worth considering)
Object storage R2 GET GCS object read
OCR fallback Anthropic Claude Vision Unchanged (or Vertex AI Anthropic for in-region BAA)
ICD mapping Anthropic via llm_gateway Unchanged
Knowledge graph Neo4j AuraDB (managed SaaS) Self-host on GKE or keep Aura — decision matters for VPC-internal latency
Trace export Langfuse Cloud Self-host Langfuse on GKE if PHI compliance review demands traces stay in-VPC

PHI crossings (heavy): - Document bytes leave R2 (egress) and reach Anthropic (egress to public internet) for OCR + entity extraction - ICD codes + extracted clinical entities written to Postgres + Neo4j - LLM trace export to Langfuse Cloud carries case_id + patient_id + token-level prompt/response (PHI risk — Langfuse is the most-considered item for the BAA review)


Phase B — Chat-driven intake → matching

The patient sends messages via POST /api/v1/cases/{case_id}/chat. The orchestrator (LangGraph) classifies intent and routes to the right agent. Once enough intake context is collected, the matching engine runs against a hybrid Postgres + Neo4j + Qdrant store.

sequenceDiagram
    autonumber
    actor Patient
    participant FE as Frontend
    participant API as Curaway API
    participant Orch as Orchestrator (LangGraph)
    participant LLM as llm_gateway
    participant Anthropic as Anthropic
    participant GPT as OpenAI<br/>(fallback only)
    participant PG as PostgreSQL
    participant N4 as Neo4j
    participant QD as Qdrant Cloud
    participant LF as Langfuse
    participant Redis as Upstash Redis

    Patient->>FE: types message
    FE->>API: POST /cases/{case_id}/chat<br/>{ message }
    API->>PG: SELECT case + conversation
    API->>Redis: GET conversation_context (TTL 120s)
    API->>Orch: dispatch(state)

    Orch->>LLM: classify intent<br/>(orchestrator.classify, Haiku)
    LLM->>Anthropic: invoke
    alt fallback fires
        LLM--xGPT: gpt-4o-mini
        Note over LLM,GPT: triggered on 5xx / 429 / timeout<br/>fallback semaphore caps to 10 concurrent
    end
    Anthropic-->>LLM: { intent }
    LLM--xLF: trace
    LLM-->>Orch: intent

    alt intent = intake
        Orch->>LLM: triage agent (Haiku)
        LLM->>Anthropic: invoke
        Anthropic-->>LLM: extracted layers (PFS/HSS/FMS)
        Orch->>PG: UPDATE cases.layer_state
    else intent = match_request
        Orch->>PG: SELECT providers WHERE constraints
        Orch->>N4: graph traversal — clinical path ranking
        Orch->>QD: vector search — semantic similarity
        Orch->>Orch: weighted_v2_1 score:<br/>clinical 0.25 + outcome 0.20 + semantic 0.10<br/>+ cost 0.15 + travel 0.10 + accreditation 0.10<br/>+ preferences 0.10
        Orch->>LLM: explanation agent (Haiku)
        LLM->>Anthropic: invoke (per top-N provider)
        Orch->>PG: INSERT matches rows
    end

    Orch->>PG: INSERT events row + INSERT messages row
    Orch->>Redis: SET conversation_context (TTL 120s)
    Orch-->>API: ChatResponse
    API-->>FE: streaming SSE / JSON
    FE-->>Patient: agent reply

Migration callouts

Concern Today GCP target
Cache Upstash Redis Memorystore (managed Redis). Same client lib, just hostname change.
Streaming SSE FastAPI direct from Railway Cloud Run supports SSE; verify connection idle timeout (default 5 min) is acceptable
Vector search Qdrant Cloud Vertex AI Vector Search — but the embedding model (Voyage AI) and vector schema would need re-indexing. Or keep Qdrant Cloud.
Graph DB Neo4j AuraDB Self-host on GKE or keep Aura. Curaway's clinical knowledge graph is core IP — eval before migrating.
LLM gateway Anthropic + OpenAI fallback (public internet egress) Vertex AI Anthropic if the migration's compliance scope tightens; otherwise unchanged

PHI crossings: - Patient message + conversation history → Anthropic (per turn) - Layer state (medical / financial / family-support / etc.) written to Postgres - Match results (provider IDs + scores) written to Postgres - Each LLM call traced to Langfuse with case_id + patient_id


After matching, the patient consents to forwarding their case to selected providers. Optional MSO (Medical Second Opinion) flow gives a doctor's review. Consultations are scheduled via Daily.co video rooms.

sequenceDiagram
    autonumber
    actor Patient
    participant FE as Frontend
    participant API as Curaway API
    participant PG as PostgreSQL
    participant Daily as Daily.co
    participant SMTP as Email (provider channel)
    participant ProviderPortal as Provider Portal<br/>(Vercel)
    participant MSOPortal as MSO Portal<br/>(Vercel)

    Patient->>FE: select 3 providers, click "Forward"
    FE->>API: POST /cases/{case_id}/consent<br/>{ providers, ip, hash }
    API->>API: redaction engine — strip patient PII<br/>generate forwarding payload per provider
    API->>PG: INSERT consent_events (immutable)
    API->>PG: UPDATE case.status = 'forwarded'

    par notify providers
        API->>SMTP: deliver redacted case packet
        SMTP-->>ProviderPortal: link
    and optional MSO consult
        API->>MSOPortal: case appears in MSO queue
        MSOPortal->>API: doctor review + opinion
        API->>PG: INSERT mso_opinions
    end

    Patient->>FE: schedule consultation
    FE->>API: POST /consultations { provider_id, slot }
    API->>Daily: create video room
    Note over API,Daily: DAILY_API_KEY
    Daily-->>API: { room_url, token }
    API->>PG: INSERT teleconsultation_sessions
    API-->>FE: { join_url }

    Patient->>Daily: join (browser)
    Daily-->>Patient: WebRTC video session

Migration callouts

Concern Today GCP target
Email delivery (TBD — likely Resend / SES) Stay on existing SaaS; only egress changes
Daily.co video Daily.co API Unchanged (managed SaaS)
Redaction engine Code in app/services/redaction_* (currently always-on after Session 80 cleanup) Unchanged
Consent immutability PG with append-only convention + audit triggers Unchanged (Cloud SQL has same triggers)

PHI crossings (highest sensitivity): - Redacted case packet sent to provider via email — redaction correctness gates HIPAA compliance - MSO opinion is full PHI accessible to a non-Curaway physician (BAA must cover them) - Daily.co video session itself is PHI (video + audio in transit + at rest if recorded)


Cross-phase external dependency summary

External system Phase(s) used GCP equivalent / decision
Clerk Sign-in (A) Keep — SaaS, BAA available
Cloudflare R2 Document storage (A) Migrate → GCS
Upstash QStash Async queue (A2) Migrate → Cloud Tasks
Anthropic LLM (A2, B) Keep public — or → Vertex AI Anthropic for in-VPC
OpenAI LLM fallback only (B) Keep — fallback path
Neo4j AuraDB Clinical graph (A2, B) Keep Aura or self-host on GKE — eval VPC latency
Qdrant Cloud Semantic search (A2, B) Keep Qdrant or → Vertex AI Vector Search (re-indexing required)
Upstash Redis Cache (B) Migrate → Memorystore
Langfuse Cloud LLM trace export (A2, B) Decision pending — self-host on GKE if PHI in traces is a BAA blocker
Daily.co Video consultation (C) Keep — SaaS, BAA available
Email provider (TBD) Provider notification (C) Keep — likely already SaaS
Postgres Source of truth (all phases) Migrate → Cloud SQL Postgres
Vercel Frontend hosting (all phases) Move → Cloud Run / Firebase Hosting or keep Vercel and only migrate backend
Railway API + worker hosting Migrate → Cloud Run (or GKE for stickier services)

Failure paths / fallbacks called out in production code

The diagrams above show the happy path. The platform also has these deterministic fallbacks that the migration plan should preserve:

  • LLM gateway fallback — every Anthropic call has a GPT-4o-mini retry path on 5xx/429/timeout, capped at 10 concurrent (app/services/llm_gateway.py).
  • Matching fallback — when Neo4j or Qdrant return no result, the matching engine falls back to pure WeightedScoringV1 from Postgres (app/services/matching_engine.py).
  • Patient state cache miss — Redis miss reloads from Postgres; no failure surface.
  • R2 read failure during extraction — the QStash job is retried with exponential backoff; document stays in pending status until success.
  • Tenant lookup fallback — RBAC middleware uses an in-memory dict if tenant_org_mappings query fails; JIT sync skips when fallback fires (no portal_type available).

These fallbacks are part of the system contract, not optional. Migration plan should validate each works on GCP equivalents before cutover.