# Identity Resolution - Projo FHIR Documentation v0.1.0

* [**Table of Contents**](toc.md)
* **Identity Resolution**

## 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 `SearchParameter`s 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

| | | |
| :--- | :--- | :--- |
| `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:

| | | |
| :--- | :--- | :--- |
| `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)
1. `re-engagement`
1. `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.

