From f90d1df284a06937510ad729cf2f1ff403262928 Mon Sep 17 00:00:00 2001
From: glebmark <87950237+glebmark@users.noreply.github.com>
Date: Mon, 27 Apr 2026 12:18:25 +0400
Subject: [PATCH] [misc] provider-member-match updated doc
---
SUMMARY.md | 4 +-
.../CMS-0057-F/developer-docs/README.md | 9 +
.../provider-access-api/README.md | 14 ++
.../provider-member-match.md} | 166 +++++++++++++-----
redirects.yaml | 2 +
5 files changed, 148 insertions(+), 47 deletions(-)
create mode 100644 docs/solutions/CMS-0057-F/developer-docs/README.md
create mode 100644 docs/solutions/CMS-0057-F/developer-docs/provider-access-api/README.md
rename docs/{api/other/plan-api/provider-member-match/README.md => solutions/CMS-0057-F/developer-docs/provider-access-api/provider-member-match.md} (75%)
diff --git a/SUMMARY.md b/SUMMARY.md
index cf8fe9734..56d592bb6 100644
--- a/SUMMARY.md
+++ b/SUMMARY.md
@@ -204,7 +204,6 @@
* [PractitionerRole](api/other/plan-api/provider-directory-api/practitionerrole.md)
* [Organization](api/other/plan-api/provider-directory-api/organization.md)
* [OrganizationAffiliation](api/other/plan-api/provider-directory-api/organizationaffiliation.md)
- * [$provider-member-match](api/other/plan-api/provider-member-match/README.md)
* [Archive/Restore API](api/other/archive-restore-api/README.md)
* [Create-archive](api/other/archive-restore-api/create-archive.md)
* [Restore-archive](api/other/archive-restore-api/restore-archive.md)
@@ -501,6 +500,9 @@
* [Coverage Requirements Discovery (CRD)](solutions/CMS-0057-F/coverage-requirements-discovery-crd/README.md)
* [CDS Hooks API](solutions/CMS-0057-F/coverage-requirements-discovery-crd/cds-hooks-api.md)
* [Prior Authorization Support (PAS) API](solutions/CMS-0057-F/prior-authorization-support-pas-api.md)
+ * [Developer Docs](solutions/CMS-0057-F/developer-docs/README.md)
+ * [Provider Access API](solutions/CMS-0057-F/developer-docs/provider-access-api/README.md)
+ * [$provider-member-match](solutions/CMS-0057-F/developer-docs/provider-access-api/provider-member-match.md)
## Reference
diff --git a/docs/solutions/CMS-0057-F/developer-docs/README.md b/docs/solutions/CMS-0057-F/developer-docs/README.md
new file mode 100644
index 000000000..b9f3a0823
--- /dev/null
+++ b/docs/solutions/CMS-0057-F/developer-docs/README.md
@@ -0,0 +1,9 @@
+---
+description: Developer documentation for the CMS-0057-F APIs delivered with Aidbox — Patient Access, Provider Directory, Provider Access, and Payer-to-Payer.
+---
+
+# Developer Docs
+
+Developer-facing reference for the CMS-0057-F API suite. Each API has its own page covering the base URL, the authentication flow, supported FHIR operations, search parameters, and request/response examples.
+
+* [Provider Access API](provider-access-api/README.md) — backend services authentication, member matching, bulk data export.
diff --git a/docs/solutions/CMS-0057-F/developer-docs/provider-access-api/README.md b/docs/solutions/CMS-0057-F/developer-docs/provider-access-api/README.md
new file mode 100644
index 000000000..fb57414b4
--- /dev/null
+++ b/docs/solutions/CMS-0057-F/developer-docs/provider-access-api/README.md
@@ -0,0 +1,14 @@
+---
+description: Da Vinci PDex Provider Access API — backend services auth, member matching, and bulk clinical data export for treating providers.
+---
+
+# Provider Access API
+
+The [Da Vinci PDex Provider Access API](https://build.fhir.org/ig/HL7/davinci-epdx/) lets a provider attest to a treatment relationship with a payer's members and pull their clinical data in bulk. The flow has two stages:
+
+1. **Match members.** Submit a batch of patient demographics + coverage + treatment-relationship attestation. The payer returns one or more `Group` resources identifying matched members, members the payer could not match, and members who have opted out of provider data sharing.
+2. **Export data.** Use the `MatchedMembers` Group ID with `$davinci-data-export` to retrieve the patients' clinical data as ndjson via the FHIR Bulk Data async pattern.
+
+{% content-ref url="provider-member-match.md" %}
+[provider-member-match.md](provider-member-match.md)
+{% endcontent-ref %}
diff --git a/docs/api/other/plan-api/provider-member-match/README.md b/docs/solutions/CMS-0057-F/developer-docs/provider-access-api/provider-member-match.md
similarity index 75%
rename from docs/api/other/plan-api/provider-member-match/README.md
rename to docs/solutions/CMS-0057-F/developer-docs/provider-access-api/provider-member-match.md
index b5b1890ac..ca97c207c 100644
--- a/docs/api/other/plan-api/provider-member-match/README.md
+++ b/docs/solutions/CMS-0057-F/developer-docs/provider-access-api/provider-member-match.md
@@ -4,50 +4,71 @@ description: Da Vinci PDex Provider Access $provider-member-match operation —
# $provider-member-match
-The `$provider-member-match` operation implements the [Da Vinci PDex Provider Access API](http://hl7.org/fhir/us/davinci-pdex/STU2.1/). It lets a provider submit a batch of members (patient demographics, coverage, and a treatment-relationship attestation) and receive one or more `Group` resources whose IDs can then be used with `$davinci-data-export` to pull clinical data in bulk.
+The `$provider-member-match` operation implements the [Da Vinci PDex Provider Access API](https://build.fhir.org/ig/HL7/davinci-epdx/). It lets a provider submit a batch of members (patient demographics, coverage, and a treatment-relationship attestation) and receive one or more `Group` resources whose IDs can then be used with `$davinci-data-export` to pull clinical data in bulk.
-The operation follows the [FHIR Bulk Data async pattern](https://hl7.org/fhir/uv/bulkdata/export.html#bulk-data-kick-off-request): kick-off returns `202 Accepted` with a `Content-Location` header, the client polls the status URL, and once processing is complete downloads an ndjson file containing the output `Parameters` resource.
+The operation follows the [FHIR Bulk Data async pattern](https://hl7.org/fhir/uv/bulkdata/export.html#bulk-data-kick-off-request): the kick-off request `POST [base]/fhir/Group/$provider-member-match` returns `202 Accepted` with a `Content-Location` header, the client polls the status URL, and once processing is complete downloads an ndjson file containing the output `Parameters` resource.
-{% hint style="info" %}
-**Spec references** (pinned to PDex STU 2.1):
-
-* [OperationDefinition-ProviderMemberMatch](http://hl7.org/fhir/us/davinci-pdex/STU2.1/OperationDefinition-ProviderMemberMatch.html)
-* [Input profile: provider-parameters-multi-member-match-bundle-in](http://hl7.org/fhir/us/davinci-pdex/STU2.1/StructureDefinition-provider-parameters-multi-member-match-bundle-in.html)
-* [Output profile: provider-parameters-multi-member-match-bundle-out](http://hl7.org/fhir/us/davinci-pdex/STU2.1/StructureDefinition-provider-parameters-multi-member-match-bundle-out.html)
+{% hint style="warning" %}
+`$provider-member-match` is **not part of Aidbox core**. It is delivered by the [Aidbox PDex / Provider Access interop app](https://github.com/HealthSamurai/interop), which registers itself with Aidbox as a FHIR `App` resource and handles the operation via HTTP-RPC. You must run and register the interop app against your Aidbox instance before these endpoints are available. The PDex 2.1 FHIR package (`hl7.fhir.us.davinci-pdex#2.1.0`) also needs to be loaded in Aidbox so the input/output profiles are known to `$validate`.
{% endhint %}
{% hint style="warning" %}
`$provider-member-match` is **always asynchronous**. Clients must include the `Prefer: respond-async` header on the kick-off request. Requests without this header are rejected with `400 Bad Request`.
{% endhint %}
-## How it works
+{% hint style="info" %}
+**Spec references** (PDex 2.1):
+
+* [OperationDefinition: PDex Provider-Member-Match Operation](https://build.fhir.org/ig/HL7/davinci-epdx/OperationDefinition-ProviderMemberMatch.html)
+* [Input profile: Provider $multi-member-match Request](https://build.fhir.org/ig/HL7/davinci-epdx/StructureDefinition-provider-parameters-multi-member-match-bundle-in.html)
+* [Output profile: Provider $multi-member-match Response](https://build.fhir.org/ig/HL7/davinci-epdx/StructureDefinition-provider-parameters-multi-member-match-bundle-out.html)
+* Output Group profiles: [pdex-treatment-relationship](https://build.fhir.org/ig/HL7/davinci-epdx/StructureDefinition-pdex-treatment-relationship.html), [pdex-member-no-match-group](https://build.fhir.org/ig/HL7/davinci-epdx/StructureDefinition-pdex-member-no-match-group.html), [pdex-member-opt-out](https://build.fhir.org/ig/HL7/davinci-epdx/StructureDefinition-pdex-member-opt-out.html)
+{% endhint %}
+
+## Overview
Each submitted member goes through a deterministic evaluation pipeline and lands in exactly one of three output buckets:
| Bucket | Profile | Code | Meaning |
| --- | --- | --- | --- |
-| `MatchedMembers` | `pdex-treatment-relationship` | `match` | Demographics matched a payer member, treatment attestation is valid, and the member has not opted out. The Group ID of this bucket is what the provider feeds into `$davinci-data-export`. |
-| `NonMatchedMembers` | `pdex-member-no-match-group` | `nomatch` | No match was found, the match was ambiguous, or the treatment attestation was invalid. |
-| `ConsentConstrainedMembers` | `pdex-member-opt-out` | `consentconstraint` | Demographics matched, but the member has an active opt-out `Consent` on file. |
+|`MatchedMembers` |`pdex-treatment-relationship` |`match` | Demographics matched a payer member, treatment attestation is valid, and the member has not opted out. The Group ID of this bucket is what the provider feeds into `$davinci-data-export`. |
+|`NonMatchedMembers` |`pdex-member-no-match-group` |`nomatch` | No match was found, the match was ambiguous, or the treatment attestation was invalid. |
+|`ConsentConstrainedMembers` |`pdex-member-opt-out` |`consentconstraint` | Demographics matched, but the member has an active opt-out `Consent` on file. |
Per-member failures do not fail the whole batch — problematic members are routed to `NonMatchedMembers`.
+### Per-member evaluation pipeline
+
+
+
```mermaid
graph TD
A(Input member):::blue2 --> B(Validate attestation):::green2
- B -->|invalid| N(NonMatchedMembers):::neutral1
B -->|valid| C(Match demographics):::green2
- C -->|no match| N
C -->|matched| D(Check opt-out):::green2
+ B -->|invalid| N(NonMatchedMembers):::neutral1
+ C -->|no match| N
D -->|opted out| O(ConsentConstrainedMembers):::yellow2
D -->|not opted out| M(MatchedMembers):::violet2
+ N ~~~ O ~~~ M
```
-## Endpoint
+
-```
-POST [base]/fhir/Group/$provider-member-match
-```
+### Per-member edge cases
+
+The pipeline never rejects an entire batch. Every input member resolves to exactly one bucket, even when the input is malformed:
+
+| Condition | Bucket |
+| --- | --- |
+| `Consent` part missing or `status` not `active` | `NonMatchedMembers` |
+| Submitted Patient missing any of `family`, `given[0]`, `birthDate`, `gender` | `NonMatchedMembers` |
+| Demographic search returns zero payer Patients | `NonMatchedMembers` |
+| Demographic search returns more than one payer Patient (ambiguous) | `NonMatchedMembers` |
+| `Coverage.subscriberId` is present but no payer Patient has a matching Coverage | `NonMatchedMembers` |
+| Unhandled exception while evaluating a single member | `NonMatchedMembers` (with reason in `Task` logs) |
+| Match succeeds and matched Patient has an active opt-out `Consent` | `ConsentConstrainedMembers` |
+| Match succeeds and no opt-out exists | `MatchedMembers` |
## Workflow
@@ -69,6 +90,53 @@ POST [base]/fhir/Group/$provider-member-match
{% endstep %}
{% endstepper %}
+### Async sequence
+
+
+
+```mermaid
+sequenceDiagram
+ participant C as Provider client
+ participant A as Aidbox
+ participant I as Interop app
+ C->>A: POST /fhir/Group/$provider-member-match
Prefer: respond-async
+ A->>I: HTTP-RPC kick-off
+ I->>A: PUT Task (status=requested)
+ I-->>A: 202 + Content-Location
+ A-->>C: 202 + Content-Location
+ Note over I: background future:
match → opt-out → group build
+ loop Poll
+ C->>A: GET .../$provider-member-match-status/{id}
+ A->>I: HTTP-RPC status
+ alt Task in-progress
+ I-->>A: 202 + Retry-After
+ A-->>C: 202 + Retry-After
+ else Task completed
+ I-->>A: 200 + Bulk Data manifest
+ A-->>C: 200 + manifest
+ end
+ end
+ C->>A: GET /output/{id}.ndjson
+ A->>I: HTTP-RPC ndjson
+ I-->>A: 200 + Parameters (ndjson)
+ A-->>C: 200 + Parameters (ndjson)
+```
+
+
+
+## Authorization
+
+`$provider-member-match` is a backend-services API: callers are provider organizations, not end users. The interop app authenticates the request via the OAuth client that Aidbox issues a token to (typically through the SMART [Backend Services / `client_credentials`](https://www.hl7.org/fhir/smart-app-launch/backend-services.html) flow).
+
+| Concern | Requirement |
+| --- | --- |
+| Auth flow | OAuth 2.0 `client_credentials` (SMART Backend Services). The bearer token is presented to Aidbox; Aidbox forwards the resolved `oauth/client` to the interop app over HTTP-RPC. |
+| Provider identity | The OAuth `Client` resource representing the provider organization must carry an NPI in `Client.identifier[*]` with `system = http://hl7.org/fhir/sid/us-npi`. The interop app reads this entry and stamps it on `Task.requester` and on the `MatchedMembers` Group. If no NPI is found the provider is recorded as `"unknown"`. |
+| Required Aidbox access | The interop app needs read access to `Patient`, `Coverage`, `Consent`, `Organization` (for payer NPI lookup) and read/write access to `Task`, `Group`, `Binary` in the same Aidbox instance. The app is registered as a FHIR `App` resource on startup; granting the `App` an access policy that allows these resource types is sufficient. |
+| App registration | The interop app must be running and registered (its `App` resource present in Aidbox). Without it, `POST /fhir/Group/$provider-member-match` returns Aidbox's default "operation not found" response. |
+
+UDAP B2B token validation, mTLS, and dynamic client registration are **not** implemented today — see [Limitations](#limitations).
+
## Input
A FHIR `Parameters` resource with one or more `MemberBundle` parameters. Each `MemberBundle` contains:
@@ -254,11 +322,11 @@ The operation tracks progress with a standard FHIR `Task` resource. Responses ma
| Task status | HTTP | Headers | Body |
| --- | --- | --- | --- |
-| `requested` | 202 | `Retry-After: 5` | (empty) |
-| `in-progress` | 202 | `Retry-After: 5`, `X-Progress: Processing members` | (empty) |
+| `requested` | 202 | `Retry-After: 5` | — |
+| `in-progress` | 202 | `Retry-After: 5`, `X-Progress: Processing members` | — |
| `completed` | 200 | `Content-Type: application/json` | Bulk Data manifest |
-| `failed` | 500 | | `OperationOutcome` with failure diagnostics |
-| not found | 404 | | `OperationOutcome` |
+| `failed` | 500 | — | `OperationOutcome` with failure diagnostics |
+| not found | 404 | — | `OperationOutcome` |
{% tabs %}
{% tab title="In progress" %}
@@ -545,15 +613,6 @@ A background job inside the interop app runs hourly and:
Cleanup only touches PDex Groups — the scan filters on `_profile=`. Non-PDex Groups in the same Aidbox instance are left alone.
-## Provider identity
-
-The provider's NPI is extracted from the authenticated OAuth client's `identifier` array (entry with system `http://hl7.org/fhir/sid/us-npi`). The NPI is used to:
-
-* Populate `Task.requester.identifier` on the kick-off Task.
-* Populate the `identifier` array and the `characteristic[0].valueReference` on the `MatchedMembers` Group.
-
-If the OAuth client has no NPI identifier, the provider NPI falls back to `"unknown"` in the output Groups.
-
## Error responses
| Status | Condition |
@@ -569,11 +628,11 @@ Individual member failures (validation issues, exceptions while evaluating one m
## End-to-end example
-The snippets below reproduce the three-bucket demo used in internal testing. They assume an Aidbox instance with the PDex STU 2.1 package loaded and an OAuth client authorized to call the interop app.
+The snippets below reproduce the three-bucket demo used in internal testing. They assume an Aidbox instance with the PDex STU 2.1 package loaded, the interop app registered, and an OAuth client authorized to call it.
-{% stepper %}
-{% step %}
-**Seed the payer-side test data.** Post this `Bundle` once to create the Organization, payer-side Patients, Coverages, and an opt-out `Consent` for member-002.
+### 1. Seed the payer-side test data
+
+Post this `Bundle` once to create the Organization, payer-side Patients, Coverages, and an opt-out `Consent` for `test-member-002`.
```http
POST /fhir
@@ -641,10 +700,10 @@ Content-Type: application/fhir+json
]
}
```
-{% endstep %}
-{% step %}
-**Kick off.** Submit three members: one will match, one will hit the opt-out, one has no match.
+### 2. Kick off
+
+Submit three members: one will match, one will hit the opt-out, one has no match.
```http
POST /fhir/Group/$provider-member-match
@@ -685,18 +744,18 @@ Prefer: respond-async
```
Response: `202 Accepted`, `Content-Location: [base]/fhir/Group/$provider-member-match-status/`.
-{% endstep %}
-{% step %}
-**Poll.** Call the status URL until it returns `200 OK`.
+### 3. Poll
+
+Call the status URL until it returns `200 OK`.
```http
GET /fhir/Group/$provider-member-match-status/
```
-{% endstep %}
-{% step %}
-**Download.** Use the `output[0].url` from the manifest to fetch the ndjson.
+### 4. Download
+
+Use the `output[0].url` from the manifest to fetch the ndjson.
```http
GET /output/.ndjson
@@ -709,5 +768,20 @@ Expected result distribution:
| Johnson, Robert | Yes (`test-member-001`) | No | `MatchedMembers` |
| Williams, Sarah | Yes (`test-member-002`) | Yes | `ConsentConstrainedMembers` |
| Unknown, Nobody | No | — | `NonMatchedMembers` |
-{% endstep %}
-{% endstepper %}
+
+## Limitations
+
+The current implementation is intentionally MVP-scoped. The following items are recognized gaps versus the PDex 2.1 spec / production expectations:
+
+| Area | Status |
+| --- | --- |
+| Matching algorithm | Deterministic exact match only (`family`, `given[0]`, `birthDate`, `gender`, optional `subscriberId`). No fuzzy / probabilistic matching, no confidence scoring, no multi-tier fall-through. |
+| Opt-out scope enforcement | All opt-out scopes (`global`, `provider-specific`, `purpose-specific`, `payer-specific`, `provider-category`) are treated identically — any active `deny` constrains the match. Targeting the requesting provider's NPI for `provider-specific` opt-outs is not yet enforced. |
+| Authentication hardening | UDAP B2B token validation, mTLS, and dynamic client registration are not implemented. The operation relies on whatever OAuth flow Aidbox is configured for. |
+| Rate limiting | No throttling, no `429 Too Many Requests`, no per-client concurrency caps. |
+| Treatment-relationship verification | Limited to `Consent.status = "active"` and presence of the resource. The attestation claim itself is not cross-checked against external sources. |
+| Sandbox / interactive console | None — all examples are run against a real Aidbox instance. |
+
+## What's next
+
+After receiving a `MatchedMembers` Group, the provider uses its ID to call `$davinci-data-export` (FHIR Bulk Data group export) on the same Aidbox instance to retrieve the matched patients' clinical data as ndjson. Documentation for that operation will live alongside this page in the [Provider Access API](README.md) section.
diff --git a/redirects.yaml b/redirects.yaml
index 1a5591fe2..2ce8a13c4 100644
--- a/redirects.yaml
+++ b/redirects.yaml
@@ -3,6 +3,8 @@ redirects:
contact-us: overview/contact-us.md
access-control/authentication/access-to-aidboxui: access-control/authentication/README.md
api/other/plan-api/plan-api-overview: api/other/plan-api/README.md
+ api/other/plan-api/provider-member-match: solutions/CMS-0057-F/developer-docs/provider-access-api/provider-member-match.md
+ api/other/plan-api/provider-member-match/README: solutions/CMS-0057-F/developer-docs/provider-access-api/provider-member-match.md
developer-experience/aidbox-sdk/aidbox-javascript-sdk: deprecated/deprecated/other/aidbox-sdk/aidbox-javascript-sdk.md
developer-experience/aidbox-sdk/nodejs-sdk: deprecated/deprecated/other/aidbox-sdk/nodejs-sdk.md
developer-experience/aidbox-sdk/python-sdk: deprecated/deprecated/other/aidbox-sdk/python-sdk.md