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
Phase C — Consent + forwarding + MSO + consultation¶
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
pendingstatus until success. - Tenant lookup fallback — RBAC middleware uses an in-memory dict if
tenant_org_mappingsquery 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.