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's RCS integration follows the same split-brain design as WhatsApp: the orchestrator (projo) speaks FHIR only, the RCS sidecar (sidecar-rcs-cm) speaks FHIR to projo and the CM.com Business Messaging API to RCS. The two halves are coupled by a single FHIR resource — the CommunicationRequest (CR) with medium=rcs — and never share code, state, or credentials. See ADR 0012.
The orchestrator does NOT hardcode medium=rcs for every outbound CR. Instead it defaults to the patient's last-used messaging channel:
medium=rcs (the sidecar stamps medium from its config on every CR it files).identityResolution reads cr.medium[0].coding[0].code into InboundContext.channel.buildPatient), the channel is stamped onto the last-used-messaging-channel Patient extension.updatePatientWithRetry.buildOutboundCR defaults the new CR's medium to the recipient's last-used-messaging-channel, with precedence: per-CR mediumCodeOverride > patient last-used > configured default (whatsapp). The override seam exists for future channel-forcing (e.g. email-only); no caller sets it today.This means a patient who last messaged via RCS receives their next questionnaire via RCS automatically; a patient who last messaged via WhatsApp receives it via WhatsApp.
Policy invariant (ADR 0012): RCS networks forbid playing a questionnaire through direct in-messenger messages. The RCS sidecar therefore NEVER renders questionnaire items as in-band suggested replies / rich cards. Instead, every questionnaire CR is delivered as a single rich message: a short text ("A health questionnaire is ready, answer it securely by pressing the button below.") combined with an in-messenger webview button linking to the Questionnaire's web form URL.
Task?status=ready notification onto the fhir-events queue, consumed by handleTaskDue.handleTaskDue resolves the recipient context (buildOutboundRecipientContext) — a single Patient GET yields both the web-answer payload (token hash + link) AND the last-used-messaging-channel. The locale gate is bypassed for RCS (the webview button text is localized by the sidecar and the web form localizes itself).rcs and the CR references a Questionnaire but no web-answer-link could be minted (e.g. WEB_ANSWER_BASE_URL unconfigured), handleTaskDue refuses to dispatch and releases the Task — an RCS questionnaire CR without a web link is policy-violating (the sidecar would have nothing to render). This surfaces as a stuck-but-visible Task rather than a broken patient message.handleTaskDue creates a CommunicationRequest with medium=rcs, the Questionnaire reference in payload[], and the web-answer-link + web-answer-token extensions.cr-events Subscription (criteria CommunicationRequest?status=active,revoked&medium=rcs&category:not=identity-resolution) delivers the new CR to the rcs-cr-events queue.crEventRouter on the RCS sidecar resolves the CR, reads the web-answer-link, and sends a CM.com rich message: text + an Openurl suggestion with openUrlProperties.application=WEBVIEW pointing at the link. It writes a category=invitation Communication linked back via partOf.crEventRouter therefore has no template-fallback / 24h-window branch.sequenceDiagram
participant FA as Fire Arrow
participant Q as fhir-events queue
participant P as projo (handleTaskDue)
participant FHIR as FHIR server
participant CRQ as rcs-cr-events queue
participant S as RCS sidecar (crEventRouter)
participant CM as CM.com API
FA->>Q: Task?status=ready notification
Q->>P: dequeue
P->>FHIR: GET Patient (last-used-channel + locale)
P->>FHIR: POST CommunicationRequest (medium=rcs, payload=[Questionnaire], web-answer-link)
FHIR-->>P: 201 Created
Note over FHIR,S: rcs cr-events Subscription fires
FHIR->>CRQ: deliver CR
CRQ->>S: dequeue
S->>FHIR: GET CR + web-answer-link
S->>CM: POST /v1.0/message (rich card + webview button)
S->>FHIR: POST Communication (category=invitation, partOf=CR)
/rcsWebhook endpoint (unified MO envelope with channel: "RCS").rcsWebhook authenticates via source-IP allowlist (CM.com CIDR ranges) + a shared-secret query param (?token=..., constant-time compared), demultiplexes the payload: user messages → rcs-inbound queue; status callbacks → rcs-status queue; events (SuggestionClick, IsTyping) → event handling.rcsInboundProcessor runs the same identity-resolution pipeline as the WhatsApp sidecar (idempotency, identity scan, active-CR lookup), filing a reverse CR (category=identity-resolution-required, medium=rcs) when the sender cannot be routed. Note: since questionnaires are never played in-band over RCS, there is no in-band QR advancement on the RCS channel — the QR is advanced by the web-functions app when the patient submits the web form. An RCS inbound text is recorded as a Communication and routed through identity resolution, but does not advance a questionnaire.Same reverse-CR pattern as WhatsApp, but the sidecar stamps medium=rcs on the reverse CR. The orchestrator's identityResolution reads the channel from cr.medium, stamps/updates the Patient's last-used-messaging-channel, and resolves identity exactly as for WhatsApp — the only channel-aware step is the channel stamping.
CM.com does not provide HMAC signature verification on inbound webhooks. The RCS sidecar authenticates inbound requests with two layers:
31.169.56.0/21, 85.119.48.0/21, 188.94.184.0/22).?token=<long-random-secret>; the sidecar constant-time-compares the presented token against a Key Vault secret. This works because CM.com calls whatever URL we register verbatim (query param included) — they need not "support" shared secrets.The medium field uses the https://firearrow.io/fhir/CodeSystem/communication-medium CodeSystem. Codes: whatsapp, rcs (ADR 0012), web (ADR 0011). The orchestrator's cr-status-events Subscription does NOT filter on medium, so RCS CRs drive Task reflection + SDC $extract exactly like WhatsApp-submitted responses. Each sidecar's cr-events Subscription is medium-filtered to its own channel, so a sidecar only sees CRs for its channel.