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

Lifecycles

Lifecycles

Projo orchestrates work across four FHIR resource types whose state machines interlock: Task drives a clinical to-do; CommunicationRequest drives a transport action; Communication records the WhatsApp message itself; and QuestionnaireResponse captures the patient's answers. CarePlan holds the clinical envelope, and a webhook circuit breaker protects the inbound path from FHIR-server overload.

This page documents each lifecycle, the codes Projo writes, and the convergence points where one resource's state transition triggers the next.

Task lifecycle

A Task is the orchestrator's unit of clinical work — typically "deliver this Questionnaire to this patient and collect the answers". Its FHIR status follows the standard R4 progression; Projo additionally writes a businessStatus (from the task-business-status code system) so that operators and downstream subscriptions can distinguish why a Task is sitting in a given status.

stateDiagram-v2
    [*] --> requested : created by Task due event
    requested --> ready : identity resolved
    ready --> accepted : delivery channel picked up
    accepted --> in_progress : first message sent / QR opened
    in_progress --> completed : responses-collected + extraction ok
    in_progress --> cancelled : cancelled by operator
    accepted --> cancelled : cancelled by operator
    ready --> cancelled : expired
    completed --> [*]
    cancelled --> [*]

Business status codes

The task-business-status code system carries the full set below. Projo currently writes only the three marked active; the remainder are historical and retained for forward-compatibility of the code system.

Code Projo writes Meaning
waiting-for-delivery active Task created, identity resolved, awaiting transport pickup
invited historical Patient has been contacted at least once
reminder-sent historical A reminder has been dispatched
collecting-responses historical QR opened, answers being received
responses-collected active All expected answers captured, QR completed
delivery-failed historical Transport could not deliver after retries
expired active Task exceeded its due window without completion

The active triple (waiting-for-deliveryresponses-collectedexpired) is the only set the taskDue and communicationRequestStatus handlers emit today. Any other code in this system is read-only signal for legacy data.

CommunicationRequest lifecycle

CommunicationRequest (CR) is the cross-app transport contract. Projo creates four variants, each with its own terminal pair. All four start in active and are driven to a terminal status by either the sidecar (outbound/reminder), the orchestrator (reverse), or the web-functions runtime (web).

stateDiagram-v2
    [*] --> active : CR created

    active --> completed : outbound — sidecar sent
    active --> revoked    : outbound — sweeper timeout

    active --> completed          : reverse — resolved to single patient
    active --> entered_in_error    : reverse — multiple-matches

    active --> completed : reminder — sidecar sent reminder template

    active --> completed : web — browser POST received
    active --> revoked    : web — session sweeper TTL expired

    completed --> [*]
    revoked --> [*]
    entered_in_error --> [*]

Variant notes

  1. Outbound CR — created by the orchestrator when a Task is ready. The sidecar's outbound subscription picks it up, sends the WhatsApp message, and patches it to completed. A sweeper revokes CRs that exceed the outbound delivery window without a send confirmation.
  2. Reverse CR — created by the sidecar when an inbound message cannot be attributed to a single patient. The orchestrator resolves the patient via identity resolution. A clean match → completed; an ambiguous match (multiple-matches) → entered-in-error.
  3. Reminder CR — created by the sidecar's reminder scheduler inside the 24h customer-care window. Terminal completed as soon as the reminder template is dispatched.
  4. Web CR — created by web-functions when a browser session opens a QR channel. The browser POSTs answers; on final submission the CR is completed. If the session TTL lapses before submission, a session sweeper marks the CR revoked.

STOP / UNSUBSCRIBE lifecycle

When a patient sends STOP or UNSUBSCRIBE (any capitalization), the sidecar files a reverse CR with reason stop-command instead of advancing the conversation. The orchestrator's identity-resolution handler then:

  1. Revokes all active CarePlans for the patient (status → revoked). Fire Arrow's CarePlanLifecycleInterceptor asynchronously deletes non-terminal Tasks and disables Subscriptions.
  2. Cancels all ready Tasks for the patient (status → cancelled, businessStatus → expired). This removes them from startNextReadyTask's search results so the CR revocation cascade in step 3 finds nothing to dispatch.
  3. Revokes all active CRs for the patient (status → revoked). Each revocation triggers communicationRequestStatus, which calls startNextReadyTask — but all ready Tasks are already cancelled, so it is a no-op.
  4. Creates a confirmation CR with a translated contentString payload ("All protocols have been stopped and no further messages will be sent."). The sidecar dispatches this as a freeform WhatsApp text bubble inside the 24h customer-care window (opened by the inbound STOP command).

The revocation order (CarePlans → ready Tasks → CRs) is critical: if CRs are revoked before ready Tasks are cancelled, the startNextReadyTask cascade would find still-ready Tasks and immediately dispatch them — re-engaging the patient with a new questionnaire, the exact opposite of the STOP intent.

Confirmation messages are available in all supported languages: en, de, es, fr, it, pt, nl.

QuestionnaireResponse lifecycle

A QuestionnaireResponse (QR) is the patient's captured answer set. Its lifecycle is short and intentionally simple — Projo does not author a questionnaire-specific state machine here, only the standard R4 progression.

stateDiagram-v2
    [*] --> in_progress : first answer received / pre-populated
    in_progress --> in_progress : per-answer update (If-Match)
    in_progress --> completed : last answer submitted
    completed --> [*]
  • Created on first answer, or pre-populated from initial values when the QR is seeded from a Task's input parameters before any patient interaction.
  • Updated per-answer with If-Match optimistic concurrency. The orchestrator writes the version-aware update and relies on the FHIR server's 412 Precondition Failed to reject concurrent writers.
  • Marked completed on the last expected answer. The completed QR is the trigger for SDC $extract and downstream clinical routing.

Communication lifecycle

Communication is the durable record of a single WhatsApp message. Outbound messages start life as a placeholder (no wamid) and converge once the WhatsApp API returns the platform message id; inbound messages are receipts and are completed immediately.

stateDiagram-v2
    [*] --> in_progress : outbound — placeholder created
    in_progress --> completed : outbound — wamid returned by WA API

    [*] --> completed : inbound — webhook = receipt

    [*] --> entered_in_error : abandoned — with statusReason
    in_progress --> entered_in_error : abandoned — with statusReason

    completed --> [*]
    entered_in_error --> [*]
  • Outbound: created in-progress with a placeholder body. On the WhatsApp Cloud API response, Projo patches in the wamid extension and flips status to completed.
  • Inbound: a webhook delivery is the receipt — the message already exists on WhatsApp's side. Projo writes the Communication straight to completed.
  • Abandoned: if a placeholder could never be reconciled (e.g. the transport gave up before any wamid), the Communication is marked entered-in-error with a statusReason explaining the failure mode.

CarePlan lifecycle (server-side)

CarePlan is the clinical envelope for a patient's engagement: an intake plan represents "this patient is onboarding", a follow-up plan represents "this patient is now in programme X". Projo manages CarePlans entirely server-side via $apply — clients never write them directly.

stateDiagram-v2
    [*] --> active : $apply enrolls
    active --> completed : supersede-then-enroll
    active --> cancelled : operator / programme exit
    active --> entered_in_error : corrective
    active --> revoked : withdrawn
    completed --> [*]
    cancelled --> [*]
    entered_in_error --> [*]
    revoked --> [*]
  • Tagged scheduled (via the careplan-category tag) after $subscribe-due-events enrolls the plan into the due-events schedule.
  • Supersede-then-enroll ordering is enforced in clinicalRouting.ts: the active intake CarePlan is moved to completed before the follow-up plan is enrolled, so a patient is never simultaneously on two programme plans for the same trigger.

Identity-resolution reason state machine

Identity resolution runs on every inbound message whose sender is not already attributed to a single patient. The reason code returned by the resolver drives a deterministic Projo response — there is no operator branch in the inbound path.

stateDiagram-v2
    [*] --> resolving : inbound message
    resolving --> no_match : zero candidates
    resolving --> multiple_matches : >1 candidates
    resolving --> non_conversational_only : only non-conversational matches
    resolving --> no_active_cr : patient known, no open CR
    resolving --> unresolved_receiver : receiver unresolvable

    no_match --> [*] : create identity + apply triggered plan (onboarding)
    multiple_matches --> [*] : mark reverse CR entered-in-error
    non_conversational_only --> [*] : walk Person.link, else create
    no_active_cr --> [*] : reattach + apply triggered plan (re-engagement/default)
    unresolved_receiver --> [*] : upsert stub Organization + create identity + apply triggered plan
Reason Projo response
no-match Create identity + apply triggered plan (onboarding)
multiple-matches Mark reverse CR entered-in-error
non-conversational-only Walk Person.link, else create
no-active-cr Reattach + apply triggered plan (re-engagement/default)
unresolved-receiver Upsert stub Organization + create identity + apply triggered plan

The reason code is persisted on the resulting Communication (extension) so that downstream subscriptions and operators can replay the decision path.

Extraction & routing convergence

Extraction and routing are the two convergence points where multiple lifecycles collide: a completed QR triggers extraction, and a successful extraction triggers clinical routing. Both are designed to be idempotent and self-healing.

Extraction

stateDiagram-v2
    [*] --> qr_completed : QR.status = completed
    qr_completed --> extracting : SDC $extract invoked
    extracting --> committed : atomic transaction succeeds
    extracting --> failed : transaction rolls back
    failed --> extracting : self-healing sweep (oldest-first)
    committed --> [*]
  • A QR reaching completed is the trigger for SDC $extract.
  • Extraction writes an atomic transaction containing the derived Observation / Condition / Provenance resources, keyed by deterministic If-None-Exist keys so re-runs are no-ops.
  • A self-healing sweep reconciles QRs whose extraction never committed, oldest-first, so a transient FHIR failure cannot strand a completed QR.

Routing

stateDiagram-v2
    [*] --> extraction_ok : atomic transaction committed
    extraction_ok --> routing : routeClinicalPlanIfNeeded
    routing --> cql_gated : $apply (no _persist)
    cql_gated --> bounded_action : single action selected
    bounded_action --> enroll_plan : supersede intake, then enroll follow-up
    bounded_action --> supersede_plan : complete active intake CarePlan
    bounded_action --> route_to_review : raise operator Flag
    bounded_action --> anchored : routing Provenance anchors idempotency
    anchored --> [*]
  • After extraction succeeds, routeClinicalPlanIfNeeded is invoked.
  • CQL evaluation runs through $apply without _persist — the PlanDefinition proposes, the orchestrator decides.
  • The router selects exactly one bounded action: enroll-plan, supersede-plan, or route-to-review. Ambiguity (multiple enroll-plan proposals) routes to review rather than picking arbitrarily.
  • A routing Provenance anchors idempotency: the bounded action's effect is recorded against the originating QR + CarePlan so a replay is a no-op.

Webhook circuit breaker (ADR 0005)

The whatsappWebhook function has two execution paths: an inline path that processes the webhook synchronously within the HTTP request, and a queued path that enqueues and returns 200 immediately. ADR 0005 introduces an in-process circuit breaker that sheds the inline path when the FHIR server is overloaded, so Meta's webhook retries are not burned on FHIR latency.

stateDiagram-v2
    [*] --> closed : steady state
    closed --> open : inline > WEBHOOK_INLINE_TIMEOUT_MS
    open --> half_open : WEBHOOK_CB_COOLDOWN_MS elapsed
    half_open --> closed : probe succeeds
    half_open --> open : probe fails
    closed --> [*]
State Behaviour
Closed Inline path runs. Webhooks are processed synchronously.
Open Inline path is skipped. Webhooks are enqueued only; the function returns 200 immediately.
Half-open A single probe attempt runs inline. Success re-closes; failure re-opens.
  • Trip: an inline attempt whose wall-clock exceeds WEBHOOK_INLINE_TIMEOUT_MS (default 8000 ms).
  • Recovery: after WEBHOOK_CB_COOLDOWN_MS (default 60 s) elapses, the breaker moves to half-open and admits one probe.

The breaker is in-process (per function instance), not distributed. Each instance independently sheds load; recovery is per-instance. See ADRs for the full rationale.