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

Identity Resolution

Identity Resolution

Projo must reattach each inbound WhatsApp message to a FHIR conversational identity before it can be answered. This page documents the two symmetric scans the sidecar performs, the identity types they cover, the reverse-CR matrix that surfaces unresolved senders, and the downstream triggered-plan selection that gives new (or returning) patients a CarePlan.

Two symmetric scans

scanSenderIdentities({ phone, bsuid })

Runs over IDENTITY_LOOKUP_SENDER_TYPES (default: Patient, RelatedPerson, Practitioner, Organization). Execution model: parallel-and-aggregate — each resource type is queried concurrently and the results are merged into a single identity set.

scanReceiverIdentities(businessPhoneNumber)

Runs over IDENTITY_LOOKUP_RECEIVER_TYPES (default: Organization, Practitioner, Patient, RelatedPerson). Execution model: sequential-with-short-circuit — types are tried in order; the first type to yield a match wins and subsequent types are skipped. Receiver resolution is expected to terminate at Organization in the overwhelming majority of cases.

HAPI indexing caveat

HAPI ships indexed telecom SearchParameters for Patient, Practitioner, RelatedPerson, and Person. Organization has no indexed telecom SearchParameter, so Organization receiver lookups fall back to paginated in-memory matching: _count=500 per page, with the deployment sized for an expected 20–200 tenant Organizations.

Identity types

Type Role Conversational?
Patient Default conversational identity — DEFAULT_IDENTITY_RESOURCE_TYPE Yes
Practitioner Alternative conversational identity (e.g. telemedicine line owner) Yes
RelatedPerson Alternative conversational identity Yes
Organization Receiver identity only; the tenant whose WABA received the message No
Person Walked via Person.link for the non-conversational-only reverse-CR reason No

On this deployment, Person and RelatedPerson are not granted to the Device client role used by the sidecar. The sidecar's scanIdentities receives a 403 for those types; the resulting UnauthorizedFhirError is swallowed per type and the scan continues with the remaining types. The default config still lists them so the same code path works on a deployment that does grant them.

Reverse-CR identity-resolution matrix

When the sidecar cannot reattach an inbound message, it files a reverse CommunicationRequest with category=identity-resolution-required. Projo's identity-resolution pipeline inspects the sender and receiver scan results and selects one of the following reasons:

Reason Meaning Projo response
no-match No existing identity matches the sender's phone or BSUID Create identity (Patient by default) + apply triggered plan (onboarding)
multiple-matches More than one identity matches Refuse; mark the reverse CR entered-in-error
non-conversational-only Matches exist but none of a conversational type Walk Person.link; if none, create
no-active-cr Identity matched but no active CommunicationRequest Reattach + apply triggered plan (default / re-engagement); skip if an active CarePlan exists
unresolved-receiver Receiver Organization unknown Idempotent stub Organization upsert; then create identity + apply plan

Triggered plan selection

On a cold inbound (a brand-new sender or a no-active-cr reattachment), Projo selects a PlanDefinition to $apply for the patient. The plan-slot extension on the receiver Organization drives selection, with the following precedence on cold inbound:

  1. onboarding (highest)
  2. re-engagement
  3. default (lowest)

The trigger-aware selector applyTriggeredPlanIfNeeded only fires when both of the following hold:

  • the receiver is an Organization, and
  • either (a) the Patient was just created by this resolution, or (b) the no-active-cr reason is reattaching a returning patient.

If neither condition holds, no triggered plan is applied and the inbound is answered against whatever existing context (active CarePlan, etc.) the patient already has.

Organization stub upsert

upsertOrganizationFromInboundBusinessPhone creates a stub Organization from the business phone number that received the unroutable message. The stub carries:

  • telecom[] = { system: phone, value: <E.164> }
  • wa-phone-number-id extension (resolved from Meta)
  • waba-account-id extension (resolved from Meta)

The upsert uses createOrganization unconditionally — no If-None-Exist header — because HAPI does not index Organization.telecom, so a conditional create on telecom would not deduplicate. Idempotency is instead provided by the reverse-CR pipeline: the unresolved-receiver reason is only emitted once per receiver phone, and subsequent inbounds from that receiver find the stub via the receiver scan.

Patient creation

When the resolution outcome is to create a new identity (default type Patient), buildPatient constructs the resource with:

  • telecom[] = [{ system: phone, use: mobile, value: <E.164> }]
  • identifier[] = [{ system: whatsapp-bsuid, value: <BSUID> }]
  • name[] = HumanName parsed from the WhatsApp profile name
  • managingOrganization = the resolved receiver Organization
  • communication[] = [{ language, preferred: true }] (from the inbound's detected language)

The new Patient, the applied CarePlan (from the triggered-plan selection), and the now-attached inbound Communication are written together in a single FHIR transaction so the patient never exists in a half-attached state.