From f585beb2310c704df1e1c31015a2d893257d481d Mon Sep 17 00:00:00 2001 From: Sergey Zaborovsky Date: Tue, 19 May 2026 11:34:18 +0600 Subject: [PATCH 1/9] docs(bulk-member-match): drop fictional Group lifecycle section The 30-day validity window, hourly sweep, and 90-day hard-delete were never implemented. Replace with reality-aligned note: Groups remain active indefinitely until cancellation, which sweeps Task.output. --- docs/api-reference/operations/bulk-member-match.md | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/docs/api-reference/operations/bulk-member-match.md b/docs/api-reference/operations/bulk-member-match.md index 880eeea..e665755 100644 --- a/docs/api-reference/operations/bulk-member-match.md +++ b/docs/api-reference/operations/bulk-member-match.md @@ -383,12 +383,7 @@ Each submitted member is evaluated independently. Per-member failures never fail ## Group lifecycle -Each output Group carries a 30-day validity window in `Group.characteristic[0].period`. A background job inside the interop app runs hourly: - -1. Groups whose `period.end` is in the past and whose `active = true` are flipped to `active = false`. -2. Groups with `active = false` whose `period.end` is more than 90 days in the past are hard-deleted along with the Task, Binary, and persisted Consents they belong to. - -The scan filters on `_profile=` — non-PDex Groups in the same Aidbox instance are left alone. +Output Groups carry no `period.end` and no TTL extension today. Until lifecycle management ships, `$bulk-member-match` output Groups remain `active = true` indefinitely; the only removal path is [`$bulk-member-match-cancel`](#cancellation) on a completed Task, which sweeps the Task and every resource referenced from `Task.output` (Groups, Binary, and persisted Consents). ## Errors From 71c971d740dc64d96f736fe53f7e2aff29e8f810 Mon Sep 17 00:00:00 2001 From: Sergey Zaborovsky Date: Tue, 19 May 2026 11:35:24 +0600 Subject: [PATCH 2/9] docs(bulk-member-match): document admin/Console NPI fallback Admin sessions (Aidbox Console, no client NPI) derive the requesting payer from Coverage.payor[0]. Extend Auth section and adjust 403/422 error rows accordingly. --- docs/api-reference/operations/bulk-member-match.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/api-reference/operations/bulk-member-match.md b/docs/api-reference/operations/bulk-member-match.md index e665755..2e41db0 100644 --- a/docs/api-reference/operations/bulk-member-match.md +++ b/docs/api-reference/operations/bulk-member-match.md @@ -10,8 +10,9 @@ The operation is **always asynchronous** and follows the [FHIR Bulk Data kick-of ## Auth -SMART Backend Services Claim Credentials. The requesting payer's NPI **must** be present on the OAuth `Client` resource as `identifier[system=http://hl7.org/fhir/sid/us-npi]`. -Requests with no NPI on the OAuth client are rejected with `403`. See [Authentication](../authentication.md). +SMART Backend Services Claim Credentials. The requesting payer's NPI is normally present on the OAuth `Client` resource as `identifier[system=http://hl7.org/fhir/sid/us-npi]`. See [Authentication](../authentication.md). + +An authenticated admin session (Aidbox Console) without a client NPI is also accepted: the requesting payer is derived from `Coverage.payor[0]` in the first submitted `MemberBundle`, resolved to an `Organization` by `identifier[system=us-npi]`. If the referenced Organization is unregistered or carries no `us-npi` identifier, the kick-off rejects with `422 Unprocessable Entity`. Fully anonymous callers (no client NPI and no user session) are rejected with `403`. ## Kick-off @@ -390,9 +391,9 @@ Output Groups carry no `period.end` and no TTL extension today. Until lifecycle | Status | Where | Cause | |---|---|---| | 400 | Kick-off | `Prefer: respond-async` header missing | -| 403 | Kick-off | OAuth client carries no NPI identifier | +| 403 | Kick-off | OAuth client carries no NPI identifier and no authenticated user session is present | | 404 | Status / cancel / output | Unknown ``, status `cancelled`, or caller NPI does not match `Task.requester.identifier` | -| 422 | Kick-off | Input `Parameters` failed `$validate` against the input profile | +| 422 | Kick-off | Input `Parameters` failed `$validate` against the input profile; or, for admin sessions, `Coverage.payor[0]` could not be resolved to a registered `Organization` with a `us-npi` identifier | | 500 | Kick-off | Failed to resolve requesting payer Organization (transient Aidbox read failure) | | 500 | Status | Background processing failed; generic `OperationOutcome` returned (real cause in interop-app logs) | | 500 | Kick-off / status / cancel | Upstream Aidbox read or write failed transiently | From 8727a97e9b75011a7191d6a022265a0d89eff491 Mon Sep 17 00:00:00 2001 From: Sergey Zaborovsky Date: Tue, 19 May 2026 11:35:56 +0600 Subject: [PATCH 3/9] docs(bulk-member-match): add 409 row for duplicate payer NPI Payer-Org lookup rejects ambiguity (more than one Organization registered against the requesting payer's NPI) with 409 Conflict. --- docs/api-reference/operations/bulk-member-match.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/api-reference/operations/bulk-member-match.md b/docs/api-reference/operations/bulk-member-match.md index 2e41db0..a355e74 100644 --- a/docs/api-reference/operations/bulk-member-match.md +++ b/docs/api-reference/operations/bulk-member-match.md @@ -393,6 +393,7 @@ Output Groups carry no `period.end` and no TTL extension today. Until lifecycle | 400 | Kick-off | `Prefer: respond-async` header missing | | 403 | Kick-off | OAuth client carries no NPI identifier and no authenticated user session is present | | 404 | Status / cancel / output | Unknown ``, status `cancelled`, or caller NPI does not match `Task.requester.identifier` | +| 409 | Kick-off | Requesting payer NPI is registered on more than one `Organization` in the responding payer's directory; resolve duplicates and retry | | 422 | Kick-off | Input `Parameters` failed `$validate` against the input profile; or, for admin sessions, `Coverage.payor[0]` could not be resolved to a registered `Organization` with a `us-npi` identifier | | 500 | Kick-off | Failed to resolve requesting payer Organization (transient Aidbox read failure) | | 500 | Status | Background processing failed; generic `OperationOutcome` returned (real cause in interop-app logs) | From a6c87ca6deef2e656cf58e8ea1b3923f26949922 Mon Sep 17 00:00:00 2001 From: Sergey Zaborovsky Date: Tue, 19 May 2026 11:36:18 +0600 Subject: [PATCH 4/9] docs(bulk-member-match): document stale Consent deactivation on re-match A later run landing the same (patient, payer) in ConsentConstrainedMembers flips the prior persisted Consent to status=inactive so $davinci-data-export?exportType=payertopayer does not honor stale rows. --- docs/api-reference/operations/bulk-member-match.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/api-reference/operations/bulk-member-match.md b/docs/api-reference/operations/bulk-member-match.md index a355e74..f225076 100644 --- a/docs/api-reference/operations/bulk-member-match.md +++ b/docs/api-reference/operations/bulk-member-match.md @@ -382,6 +382,8 @@ Each submitted member is evaluated independently. Per-member failures never fail **Consent persistence.** For each remaining matched member the submitted `Consent` is upserted into Aidbox with a deterministic id (`SHA-1(payer-org-id|patient-id)`); `Consent.patient` is rewritten to the matched payer Patient and `Consent.organization` to the requesting payer's Organization. The persisted Consent is what the later `$davinci-data-export?exportType=payertopayer` query reads against. If persistence fails — including the case where the requesting payer's NPI has no `Organization` registered in the responding payer's Aidbox — the member is re-bucketed to `ConsentConstrainedMembers`. +**Stale Consent deactivation.** If a later `$bulk-member-match` for the same `(matched-patient, requesting-payer)` lands the member in `ConsentConstrainedMembers` (failed match-time check, opt-out hit, or persistence failure), the prior persisted `Consent` at the deterministic id is flipped to `status = inactive`. The row is retained for audit, but `$davinci-data-export?exportType=payertopayer` will not honor it on subsequent reads. + ## Group lifecycle Output Groups carry no `period.end` and no TTL extension today. Until lifecycle management ships, `$bulk-member-match` output Groups remain `active = true` indefinitely; the only removal path is [`$bulk-member-match-cancel`](#cancellation) on a completed Task, which sweeps the Task and every resource referenced from `Task.output` (Groups, Binary, and persisted Consents). From b5403885b6be78606823ecf5f31fa4aa5b5589cc Mon Sep 17 00:00:00 2001 From: Sergey Zaborovsky Date: Tue, 19 May 2026 11:36:40 +0600 Subject: [PATCH 5/9] docs(bulk-member-match): fold opt-out check into consent-check table The active provider-access deny-Consent test is structurally a fifth consent check. Move it from a separate paragraph into the consent-check table and keep a one-line note on the underlying Aidbox query. --- docs/api-reference/operations/bulk-member-match.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/api-reference/operations/bulk-member-match.md b/docs/api-reference/operations/bulk-member-match.md index f225076..8bc9098 100644 --- a/docs/api-reference/operations/bulk-member-match.md +++ b/docs/api-reference/operations/bulk-member-match.md @@ -377,8 +377,9 @@ Each submitted member is evaluated independently. Per-member failures never fail | `Consent.provision.period` | absent, unparseable, or does not cover the current time | | `Consent.provision.actor[role=IRCP]` recipient | does not resolve to the requesting payer — checked in order: literal `Organization/` reference (when the payer Org is registered), inline `identifier` matching the OAuth client's NPI, or NPI dereferenced from an `Organization/` reference | | `Consent.policy[*].uri` | not `#sensitive` — `#regular` and missing/unknown policy URIs both constrain (fail-safe; Payerbox does not yet redact sensitive data, so non-`#sensitive` consents cannot be honored) | +| Active `provider-access` deny `Consent` on the matched Patient (opt-out) | any active hit; a failing opt-out query (non-2xx) fails safe to constrained | -**Opt-out check.** Same as [`$provider-member-match`](provider-member-match.md#matching-behavior): an Aidbox search for an active `deny` `Consent` on the matched Patient with category `provider-access`. Any hit routes the member to `ConsentConstrainedMembers`. A failing opt-out query (non-2xx) fails safe to constrained. +The opt-out check reuses the same Aidbox search as [`$provider-member-match`](provider-member-match.md#matching-behavior): `Consent?status=active&category=provider-access&patient=&decision=deny`. **Consent persistence.** For each remaining matched member the submitted `Consent` is upserted into Aidbox with a deterministic id (`SHA-1(payer-org-id|patient-id)`); `Consent.patient` is rewritten to the matched payer Patient and `Consent.organization` to the requesting payer's Organization. The persisted Consent is what the later `$davinci-data-export?exportType=payertopayer` query reads against. If persistence fails — including the case where the requesting payer's NPI has no `Organization` registered in the responding payer's Aidbox — the member is re-bucketed to `ConsentConstrainedMembers`. From ce46926e19077226f556f45c8bbcffbe0f8495af Mon Sep 17 00:00:00 2001 From: Sergey Zaborovsky Date: Tue, 19 May 2026 11:37:10 +0600 Subject: [PATCH 6/9] docs(bulk-member-match): note 410 Gone tombstone maps to 404 A hard-deleted Task returns Aidbox 410 Gone on read; the operation maps that to 404 so clients stop polling/cancelling without retrying. Update both status-polling and cancel response tables. --- docs/api-reference/operations/bulk-member-match.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/api-reference/operations/bulk-member-match.md b/docs/api-reference/operations/bulk-member-match.md index 8bc9098..0e33e85 100644 --- a/docs/api-reference/operations/bulk-member-match.md +++ b/docs/api-reference/operations/bulk-member-match.md @@ -164,7 +164,7 @@ Response shape depends on the underlying `Task` status: | `in-progress` | 202 | `Retry-After: 5`, `X-Progress: Processing members` | — | | `completed` | 200 | `Content-Type: application/json` | Bulk Data manifest | | `failed` | 500 | — | `OperationOutcome` | -| `cancelled` / not found | 404 | — | `OperationOutcome` | +| `cancelled` / not found / tombstoned (Aidbox `410 Gone`) | 404 | — | `OperationOutcome` | ### Example @@ -346,7 +346,7 @@ Behaviour depends on the current `Task` status: |---|---|---| | `requested` / `in-progress` | Set `Task.status = "cancelled"`. The background worker stops at its next checkpoint (per-member loop + pre-persist) without writing Groups, the Binary, or persisted Consents. | `202 Accepted` | | `completed` / `failed` / `cancelled` | Delete the `Task` and every resource referenced from `Task.output` (Groups, Binary, and persisted Consents). | `202 Accepted` | -| not found | — | `404` with `OperationOutcome` | +| not found / tombstoned | — | `404` with `OperationOutcome` (Aidbox `410 Gone` is mapped to `404` so clients stop polling) | ### Example From 60c0de1bae626e479b6791fe48cd1d4bb21e7bf5 Mon Sep 17 00:00:00 2001 From: Sergey Zaborovsky Date: Tue, 19 May 2026 11:37:32 +0600 Subject: [PATCH 7/9] docs(bulk-member-match): note bulk-only Patient.identifier AND scope Identifier-AND is bulk-only. $provider-member-match ignores submitted Patient.identifier entries to tolerate provider-side MRNs the payer does not store. Add a one-clause contrast in Demographic match. --- docs/api-reference/operations/bulk-member-match.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api-reference/operations/bulk-member-match.md b/docs/api-reference/operations/bulk-member-match.md index 0e33e85..877d7c4 100644 --- a/docs/api-reference/operations/bulk-member-match.md +++ b/docs/api-reference/operations/bulk-member-match.md @@ -367,7 +367,7 @@ HTTP/1.1 202 Accepted Each submitted member is evaluated independently. Per-member failures never fail the batch — problematic members are routed to `NonMatchedMembers` or `ConsentConstrainedMembers`. -**Demographic match.** Same algorithm as [`$provider-member-match`](provider-member-match.md#matching-behavior): all four of `family`, `given[0]`, `birthDate`, `gender` are required and queried against payer Patients. `Patient.identifier` entries become `identifier` search tokens (FHIR AND semantics — every submitted identifier must match). `Coverage.subscriberId`, when present, becomes `_has:Coverage:beneficiary:subscriber-id`. Zero or ambiguous (>1) results route to `NonMatchedMembers`. +**Demographic match.** Same algorithm as [`$provider-member-match`](provider-member-match.md#matching-behavior): all four of `family`, `given[0]`, `birthDate`, `gender` are required and queried against payer Patients. `Patient.identifier` entries become `identifier` search tokens (FHIR AND semantics — every submitted identifier must match). Identifier-AND is bulk-specific: [`$provider-member-match`](provider-member-match.md#matching-behavior) ignores submitted `Patient.identifier` entries, because provider-side systems carry MRNs the payer does not store. `Coverage.subscriberId`, when present, becomes `_has:Coverage:beneficiary:subscriber-id`. Zero or ambiguous (>1) results route to `NonMatchedMembers`. **Match-time consent checks.** A matched member is moved to `ConsentConstrainedMembers` if **any** of the following is true: From 3fe316c984a6da99860958d6095935d0bb564f4d Mon Sep 17 00:00:00 2001 From: Sergey Zaborovsky Date: Tue, 19 May 2026 11:37:47 +0600 Subject: [PATCH 8/9] docs(bulk-member-match): clarify Consent.organization shape Consent.organization is a 0..* array; the rewrite step today writes exactly one element. --- docs/api-reference/operations/bulk-member-match.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api-reference/operations/bulk-member-match.md b/docs/api-reference/operations/bulk-member-match.md index 877d7c4..75798dd 100644 --- a/docs/api-reference/operations/bulk-member-match.md +++ b/docs/api-reference/operations/bulk-member-match.md @@ -381,7 +381,7 @@ Each submitted member is evaluated independently. Per-member failures never fail The opt-out check reuses the same Aidbox search as [`$provider-member-match`](provider-member-match.md#matching-behavior): `Consent?status=active&category=provider-access&patient=&decision=deny`. -**Consent persistence.** For each remaining matched member the submitted `Consent` is upserted into Aidbox with a deterministic id (`SHA-1(payer-org-id|patient-id)`); `Consent.patient` is rewritten to the matched payer Patient and `Consent.organization` to the requesting payer's Organization. The persisted Consent is what the later `$davinci-data-export?exportType=payertopayer` query reads against. If persistence fails — including the case where the requesting payer's NPI has no `Organization` registered in the responding payer's Aidbox — the member is re-bucketed to `ConsentConstrainedMembers`. +**Consent persistence.** For each remaining matched member the submitted `Consent` is upserted into Aidbox with a deterministic id (`SHA-1(payer-org-id|patient-id)`); `Consent.patient` is rewritten to the matched payer Patient and `Consent.organization` to the requesting payer's Organization (FHIR shape is `0..*`; today exactly one element is written). The persisted Consent is what the later `$davinci-data-export?exportType=payertopayer` query reads against. If persistence fails — including the case where the requesting payer's NPI has no `Organization` registered in the responding payer's Aidbox — the member is re-bucketed to `ConsentConstrainedMembers`. **Stale Consent deactivation.** If a later `$bulk-member-match` for the same `(matched-patient, requesting-payer)` lands the member in `ConsentConstrainedMembers` (failed match-time check, opt-out hit, or persistence failure), the prior persisted `Consent` at the deterministic id is flipped to `status = inactive`. The row is retained for audit, but `$davinci-data-export?exportType=payertopayer` will not honor it on subsequent reads. From e3fdfe9c8dc410311f1d4c765287e6991859c5c8 Mon Sep 17 00:00:00 2001 From: Sergey Zaborovsky Date: Tue, 19 May 2026 11:40:22 +0600 Subject: [PATCH 9/9] docs(bulk-member-match): drop 410 Gone storage detail from polling/cancel rows The 410 Gone code is internal to the storage backend; integrators only see 404. Reword to "hard-deleted" so the table conveys the integrator-facing contract without exposing implementation plumbing. --- docs/api-reference/operations/bulk-member-match.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/api-reference/operations/bulk-member-match.md b/docs/api-reference/operations/bulk-member-match.md index 75798dd..906085b 100644 --- a/docs/api-reference/operations/bulk-member-match.md +++ b/docs/api-reference/operations/bulk-member-match.md @@ -164,7 +164,7 @@ Response shape depends on the underlying `Task` status: | `in-progress` | 202 | `Retry-After: 5`, `X-Progress: Processing members` | — | | `completed` | 200 | `Content-Type: application/json` | Bulk Data manifest | | `failed` | 500 | — | `OperationOutcome` | -| `cancelled` / not found / tombstoned (Aidbox `410 Gone`) | 404 | — | `OperationOutcome` | +| `cancelled` / not found / hard-deleted | 404 | — | `OperationOutcome` | ### Example @@ -346,7 +346,7 @@ Behaviour depends on the current `Task` status: |---|---|---| | `requested` / `in-progress` | Set `Task.status = "cancelled"`. The background worker stops at its next checkpoint (per-member loop + pre-persist) without writing Groups, the Binary, or persisted Consents. | `202 Accepted` | | `completed` / `failed` / `cancelled` | Delete the `Task` and every resource referenced from `Task.output` (Groups, Binary, and persisted Consents). | `202 Accepted` | -| not found / tombstoned | — | `404` with `OperationOutcome` (Aidbox `410 Gone` is mapped to `404` so clients stop polling) | +| not found / already hard-deleted | — | `404` with `OperationOutcome` | ### Example