Projo FHIR Documentation
0.1.0 - ci-build Switzerland flag

Projo FHIR Documentation - Local Development build (v0.1.0) built by the FHIR (HL7® FHIR® Standard) Build Tools. See the Directory of published versions

Architecture

Architecture

Projo is a WhatsApp FHIR Questionnaire Orchestrator. Three Azure Functions apps share a single FHIR server — Fire Arrow, a HAPI FHIR R4 server on Azure App Service backed by PostgreSQL (Azure Database for PostgreSQL - Flexible Server, passwordless via Azure AD authentication). Fire Arrow's Clinical Reasoning module (cqf-fhir-cr-hapi, on HAPI 8.10.0) evaluates CQL for PlanDefinition/$apply, and its subscription engine delivers resource notifications onto Azure Storage Queues.

App Role
projo (orchestrator) Owns business logic: Task lifecycle, identity resolution, PlanDefinition/$apply, SDC $extract, clinical routing.
whatsapp-sidecar Owns WhatsApp transport: webhook ingress, identity scans, conversational orchestration, 24h window, per-message reminders. Holds zero private state — every view is a pure function of FHIR queries.
web-functions Browser-based answer channel (ADR 0011). Renders FHIR Questionnaires with Smart Forms Renderer and writes QuestionnaireResponse back through the same CommunicationRequest contract.

The contract between the three apps is purely FHIR resources. There is no shared message queue except FHIR Subscriptions, and no shared database except the FHIR server. Every cross-app handoff is a resource write observed by a Subscription, or an internal-to-app queue that one app owns end-to-end.

System topology

The diagram below shows the three Functions apps, Fire Arrow, the Meta Cloud API, the patient, and the Azure Storage Queues that connect them. Solid arrows are the steady-state dispatch path; the dashed arrow is the fast-path HTTP shortcut described in Fast-path HTTP dispatch.

flowchart LR
    subgraph FHIR["Fire Arrow FHIR Server (HAPI R4 + PostgreSQL)"]
        direction TB
        FA[(HAPI FHIR R4)]
        CRSub["Subscription engine"]
        FA --- CRSub
    end

    subgraph Projo["projo Functions app (orchestrator)"]
        direction TB
        FER["fhirEventsRouter"]
        CPC["carePlanCreated<br/>(handler)"]
        HTD["handleTaskDue<br/>(handler)"]
        CRS["communicationRequestStatus<br/>(handler)"]
        IDR["identityResolution<br/>(handler)"]
        SWP["sweeper<br/>(timer)"]
        EXT["extractClinicalResources<br/>(handler)"]
        RTG["clinicalRouting<br/>(handler)"]
        FER --> CPC
        FER --> HTD
        FER --> IDR
        CRS --> EXT
        CRS --> RTG
    end

    subgraph Side["whatsapp-sidecar Functions app"]
        direction TB
        CRER["crEventRouter"]
        WAWH["whatsappWebhook<br/>(HTTP)"]
        WAIN["waInboundProcessor"]
        WASP["waStatusProcessor"]
        CRER -.fast-path.-> WAIN
    end

    subgraph Web["web-functions app"]
        WEBR["renderQuestionnaire / submitResponse<br/>(HTTP)"]
    end

    Meta["Meta Cloud API<br/>(WhatsApp)"]
    Pt["Patient"]

    %% Storage queues
    QE1[("fhir-events")]
    QE2[("cr-status-events")]
    QE3[("identity-resolution-events")]
    QE4[("cr-events")]
    QE5[("wa-inbound")]
    QE6[("wa-status")]

    %% FHIR -> queues (subscription delivery)
    CRSub -- "CarePlan/Task events" --> QE1
    CRSub -- "CR completed/revoked" --> QE2
    CRSub -- "CR active + identity-res category" --> QE3
    CRSub -- "CR active + medium=whatsapp" --> QE4

    %% projo queue consumption
    QE1 --> FER
    QE2 --> FER
    QE3 --> FER

    %% projo -> sidecar fast path
    HTD -.HTTP POST.-> CRER

    %% sidecar queue consumption
    QE4 --> CRER
    QE5 --> WAIN
    QE6 --> WASP

    %% sidecar <-> Meta <-> patient
    CRER -- "send message" --> Meta
    Meta -- "inbound / status" --> WAWH
    Meta <--> Pt
    WAWH --> QE5
    WAWH --> QE6

    %% web channel
    CRSub -- "CR active + medium=web" --> WEBR
    WEBR <--> Pt

    %% projo <-> FHIR (all apps read/write FHIR, shown once for clarity)
    FER <--> FA
    CRER <--> FA
    WEBR <--> FA

Note on function vs. handler. In the projo app, only fhirEventsRouter, crStatusEventsRouter, identityResolutionRouter, sweeper, carePlanMaintenance, and queueDepthMonitor are Azure Functions triggers. carePlanCreated, handleTaskDue, communicationRequestStatus, identityResolution, extractClinicalResources, and clinicalRouting are handlers that the routers dispatch to based on the focus resource of the event. They are shown as distinct nodes above because they own distinct slices of business logic.

Responsibility matrix

Concern projo (orchestrator) whatsapp-sidecar web-functions
Task lifecycle (readyacceptedcompleted/cancelled) Owns
CommunicationRequest creation (outbound) Owns
CommunicationRequest status reflection onto Task Owns
CarePlan bootstrap + due-event subscription ($subscribe-due-events) Owns
Identity resolution (reverse-CR) Owns (handler) Initiates scan
SDC QuestionnaireResponse/$extract Owns
Clinical routing (PlanDefinition/$apply, bounded actions) Owns
WhatsApp Cloud API send/receive Owns
Webhook HMAC verification Owns
24h window computation Owns
Per-question conversation advance Owns Owns (browser)
Reminders Owns (sweeper emits reminder CRs) Sends via WhatsApp
Browser questionnaire rendering Owns (Smart Forms Renderer)
Web session TTL + revocation Owns (sessionSweeper)
Managed FHIR Subscriptions 3 (cr-status, identity-res, careplan-bootstrap) 1 (cr-events)
Private database FHIR server only None (pure projection) Azure Table (webSessions, token hashes only)

End-to-end dispatch flow

The canonical path from "a Task becomes due" to "clinical resources are extracted and routed" touches all three transport layers and four queues. Steps 4a/4b are alternative deliveries of the same event — see Fast-path HTTP dispatch.

sequenceDiagram
    participant FA as Fire Arrow
    participant FE as fhir-events queue
    participant P as projo
    participant CRE as cr-events queue
    participant S as whatsapp-sidecar
    participant M as Meta Cloud API
    participant Pt as Patient
    participant WAI as wa-inbound queue
    participant CRS as cr-status-events queue

    Note over FA: CarePlan due-events engine<br/>materializes Task (status=ready)
    FA->>FE: Subscription notification (Task/{id})
    FE->>P: fhirEventsRouter → handleTaskDue
    P->>FA: transaction: Task ready→accepted<br/>+ POST CommunicationRequest (medium=whatsapp)
    P-.->>S: fast-path HTTP POST (if warm)
    FA->>CRE: Subscription notification (CR active, medium=whatsapp)
    CRE->>S: crEventRouter (no-op if fast-path already dispatched)
    S->>M: send invitation / first question
    M->>Pt: WhatsApp message
    Pt->>M: reply
    M->>S: webhook (whatsappWebhook)
    S->>WAI: enqueue inbound
    WAI->>S: waInboundProcessor
    S->>FA: write answer Communication +<br/>advance QuestionnaireResponse
    Note over S: repeat per question until QR.status=completed
    S->>FA: PUT CommunicationRequest.status=completed
    FA->>CRS: Subscription notification (CR completed)
    CRS->>P: crStatusEventsRouter → communicationRequestStatus
    P->>FA: reflect onto Task (status=completed)
    P->>P: extractClinicalResources<br/>(QuestionnaireResponse/$extract)
    P->>P: clinicalRouting<br/>(PlanDefinition/$apply, bounded actions)
    P->>FA: transaction: Observations/Conditions/<br/>Provenance + routing outcome

Numbered steps

  1. Task ready. Fire Arrow's CarePlan Events engine materializes a Task with status=ready at the scheduled time. A per-CarePlan due-event Subscription (installed via Fire Arrow's $subscribe-due-events operation during CarePlan bootstrap) fires a notification onto the fhir-events queue.
  2. fhir-events → fhirEventsRouter → handleTaskDue. The orchestrator dequeues, resolves the focus resource, and dispatches to handleTaskDue. This handler atomically claims the Task (readyaccepted via If-Match optimistic concurrency) and POSTs a CommunicationRequest (medium=whatsapp, recipient=Task.for, payload.contentReference=Questionnaire/{id}, about=[Task/{id}]) in a single FHIR transaction.
  3. cr-events subscription. Fire Arrow's cr-events Subscription (criteria CommunicationRequest?status=active,revoked&medium=whatsapp&category:not=<idRes>) delivers a notification to the cr-events queue. 4a. Fast-path (warm sidecar). handleTaskDue also pokes the sidecar's dispatchCommunicationRequest HTTP endpoint directly. If the sidecar host is warm, dispatch completes in sub-second time. 4b. Queue path (cold fallback). The cr-events queue message lands in crEventRouter. If the fast-path already processed the CR, the queue delivery is a no-op via alreadyDispatchedForCR idempotency.
  4. crEventRouter → WhatsApp. The sidecar computes the 24h window, sends the invitation template (or first question inside the window) via the Meta Cloud API, and writes Communication audit resources.
  5. Patient reply → webhook. Meta POSTs to /api/whatsappWebhook. The sidecar verifies the HMAC signature, splits messages from delivery callbacks, and enqueues inbound messages to wa-inbound and callbacks to wa-status.
  6. wa-inbound → waInboundProcessor. The sidecar runs isDuplicateInbound, performs an identity scan if needed, then advances the QuestionnaireResponse (nextUnansweredLinkId), writes the answer Communication, and sends the next question.
  7. QR completion. Answers accumulate under If-Match concurrency until QuestionnaireResponse.status=completed.
  8. CR completed. The sidecar PUTs CommunicationRequest.status=completed.
  9. cr-status-events → communicationRequestStatus. Fire Arrow's cr-status-events Subscription (criteria CommunicationRequest?status=completed,revoked&category:not=<idRes>) delivers to the cr-status-events queue. The orchestrator's crStatusEventsRouter dequeues and calls handleCommunicationRequestStatus.
  10. Task completed. The handler reflects the CR terminal status onto the originating Task (completed CR → Task.status=completed; revoked CR → Task.status=cancelled with EXPIRED business status).
  11. Extraction. extractAndPersistClinicalResources invokes FHIR SDC QuestionnaireResponse/$extract, materializing coded Observation, Condition, and MedicationStatement resources plus a linking Provenance in one atomic transaction (idempotent via If-None-Exist).
  12. Routing. routeClinicalPlanIfNeeded runs the ADR 0006 router — see Router proposes, Projo disposes.

The web channel (ADR 0011) is a parallel transport: medium=web CRs never reach the sidecar (the cr-events Subscription filters medium=whatsapp), and web-functions renders the Questionnaire, accepts the browser POST, completes the CR atomically (POST QR + PUT CR completed in one transaction), and rejoins the pipeline at step 10.

Router proposes, Projo disposes

ADR 0006 — Separate lifecycle selection from clinical routing.

Clinical routing is split into a proposer and an executor so that selection logic can evolve in CQL without granting the knowledge artifact write authority over patient records.

  • Proposer. A first-party CQL Library and a router PlanDefinition (dermacare-onboarding-routing) live under https://projo.evoleen.com, version-pinned (ADR 0007), bound to an Organization via a plan-slot[kind=onboarding-routing] extension. Fire Arrow's Clinical Reasoning module evaluates the PlanDefinition's action.condition[kind=applicability] expressions (text/cql / text/cql-identifier) over the extraction output.
  • Executor. Projo invokes PlanDefinition/$apply without _persist — the router only returns a RequestGroup of proposed actions; it cannot write. Projo reads action.code and executes only codes on the bounded-action allow-list:

    Code Status Effect
    enroll-plan Implemented Supersede the current CarePlan, then enroll the follow-up plan (supersede-then-enroll ordering).
    supersede-plan Implemented Mark the current CarePlan replaced and link its successor.
    route-to-review Implemented Raise a Flag so a clinician reviews the case. Used when no bounded action can safely enroll.
    notify-practitioner, raise-flag, send-message, create-task, assign-careteam Designed, not executed Rejected by the executor until implemented.
  • Invariants. Routing runs only after extraction succeeds (it defers with status extraction-incomplete if no extraction Provenance exists). Subject scoping is enforced at the app layer, not in CQL. The executor never silently enrolls nothing — if the router proposes no enrolled plan, route-to-review is the default. Every routing outcome is anchored by a routing Provenance so replays are idempotent.

Managed FHIR Subscriptions

The four Subscriptions below are the only cross-app wiring. They are not Terraform resources — each app declaratively asserts its own Subscriptions at runtime (self-bootstrap + drift reconciliation on every sweeper tick) and tags them with a stable meta.tag code for _tag idempotency lookup.

Subscription Managed by Managed-tag code Criteria Target queue
cr-events whatsapp-sidecar sidecar-cr-events CommunicationRequest?status=active,revoked&medium=whatsapp&category:not=<idRes> cr-events
cr-status-events projo projo-cr-status-events CommunicationRequest?status=completed,revoked&category:not=<idRes> cr-status-events
identity-resolution-events projo projo-identity-resolution-events CommunicationRequest?status=active&category=<idRes> identity-resolution-events
careplan-bootstrap projo projo-careplan-bootstrap CarePlan?status=active&_tag:not=<firearrow-scheduled-tag> fhir-events

The medium=whatsapp filter on cr-events is what keeps medium=web CRs (ADR 0011) off the sidecar. The cr-status-events Subscription does not filter on medium, so web-completed CRs still drive Task reflection and SDC extraction.

A fifth, per-CarePlan "due-events" Subscription is installed dynamically via Fire Arrow's $subscribe-due-events operation during CarePlan bootstrap. It is Fire Arrow server-side machinery (not one of projo's four managed Subscriptions) and is what delivers Task-ready notifications onto fhir-events. The careplan-bootstrap Subscription fires once per CarePlan (the _tag:not=scheduled exclusion skips CarePlans Fire Arrow has already scheduled).

Fast-path HTTP dispatch

Azure Functions Flex Consumption cold-starts a worker in roughly 20 seconds on dev (and non-trivially in production). The cr-events Subscription → queue → crEventRouter path is durable and retry-safe, but it can stall on a cold worker right when a patient is waiting.

To hide that latency, handleTaskDue also POSTs { crId, correlationId } directly to the sidecar's dispatchCommunicationRequest HTTP endpoint (POST /api/dispatchCommunicationRequest), authenticated with a shared-secret x-sidecar-dispatch-secret header verified in constant time (SHA-256 + crypto.timingSafeEqual).

The contract is deliberately asymmetric:

  • Success (HTTP 200). The sidecar processed the CR. The subsequent queue delivery is a no-op via alreadyDispatchedForCR.
  • Deferred (HTTP 202). The sidecar could not process now; it explicitly tells the orchestrator to fall back to the queue.
  • Failure (any non-2xx, timeout, network error). Fail-fast: the error is logged and swallowed. The HTTP layer never retries. The FHIR cr-events Subscription is the single durable retry surface — a transient HTTP failure simply means the queue message wins the race.

This keeps the architecture honest: the fast path is a latency optimization layered on top of the Subscription, never a replacement for it.

Stateless projection invariants (whatsapp-sidecar)

The sidecar holds no private database. Every state view it needs — duplicate detection, 24h window, current question, conversation phase, last activity — is a pure function of FHIR queries against Fire Arrow. The five canonical projection helpers are:

Helper Reads Returns
isDuplicateInbound(fhir, cfg, wamid) Communication?identifier=<wamidSystem>\|<wamid> { duplicate, existing } — prevents reprocessing a Meta redelivery.
compute24hWindow(fhir, { senderRef, now }) Communication?sender=<ref>&_sort=-received&_count=1 { withinWindow, lastInboundAt, remainingMs } — drives the template-vs-free-text decision.
currentQuestion (nextUnansweredLinkId) Questionnaire + QuestionnaireResponse (pure) The next unanswered linkId, or null when the QR is complete.
conversationState (classifyConversation) CommunicationRequest + QuestionnaireResponse + Communication[] (pure) 'pending' \| 'inProgress' \| 'completed' \| 'abandoned' \| 'terminated'.
lastActivityForCR(fhir, crId) Communication?part-of=CommunicationRequest/{id} The most recent received timestamp, or null.

Because these are pure projections, the sidecar is safe to redeploy, scale, or restart mid-conversation without losing state: the next wake-up re-derives the full conversation context from FHIR. The only sidecar-owned store is the Azure Table used by web-functions for web-session token hashes and lastSeen — and even that is a transport-layer cache, not conversation state.