Projo FHIR Documentation
0.1.0 - ci-build
Projo FHIR Documentation - Local Development build (v0.1.0) built by the FHIR (HL7® FHIR® Standard) Build Tools. See the Directory of published versions
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.
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, andqueueDepthMonitorare Azure Functions triggers.carePlanCreated,handleTaskDue,communicationRequestStatus,identityResolution,extractClinicalResources, andclinicalRoutingare 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.
| Concern | projo (orchestrator) | whatsapp-sidecar | web-functions |
|---|---|---|---|
Task lifecycle (ready → accepted → completed/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) |
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
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.handleTaskDue.
This handler atomically claims the Task (ready → accepted 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.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.Communication audit resources./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.isDuplicateInbound,
performs an identity scan if needed, then advances the
QuestionnaireResponse (nextUnansweredLinkId), writes the answer
Communication, and sends the next question.If-Match concurrency until
QuestionnaireResponse.status=completed.CommunicationRequest.status=completed.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.completed CR → Task.status=completed; revoked CR →
Task.status=cancelled with EXPIRED business status).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).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.
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.
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. |
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.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).
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:
alreadyDispatchedForCR.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.
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.