Skip to main content

Fire Arrow Server 2.2.0

· 10 min read

Fire Arrow Server 2.2.0 has been released.

  • (security) Patient-role Task search with no Tasks no longer returns other patients' Tasks
  • (feature) LegitimateInterest now finds Consent resources through Consent.performer as well as Consent.patient
  • (bugfix) _include and _revinclude results ride with their matching resources instead of consuming _count slots
  • (bugfix) Task search during $renew-due-events no longer returns 403 Forbidden
  • (bugfix) Identity lookup outages return a retryable 503 instead of 403 Forbidden
  • (bugfix) $renew-due-events and $subscribe-due-events no longer fail with 409 Conflict while a Subscription is activating
  • (bugfix) Recurring CarePlan Task ids stay stable across reconciles
  • (bugfix) A transient uniqueness check no longer reports a new email as already taken
  • (bugfix) Expected subscription-create retries no longer log as ERROR
  • (maintenance) HAPI FHIR 8.12.0 platform upgrade and dependency updates including PostCSS CVE-2026-69153

Patient Task Search Could Return Other Patients' Tasks

A patient client under LegitimateInterest is meant to see only that patient's own Task resources — the work assigned to them, not the rest of the caseload.

When that patient had no Tasks yet, Task search could skip the patient restriction entirely. The empty result set was treated as "no restriction," so the query returned Tasks belonging to every patient on the server, as long as the unmatched fallback rule used the Allowed validator. This applied in the window between subscribing a CarePlan and the first materialization commit — for example while $subscribe-due-events or $renew-due-events was still creating Tasks for a new plan. A patient who already had at least one Task was not exposed to this particular leak.

What downstream applications could show

A patient session that should have seen an empty task list, or only Tasks created for them, could instead receive:

  • Task search and list screens — worklists, dashboards, and "my tasks" views populated with other patients' Tasks.
  • Reads of those TasksGET /Task/{id} and _include/_revinclude follow-ups for patients the caller was not allowed to access.

Roles that used CareTeam-scoped patient rules, or a fallback of Forbidden, were not affected. Practitioner Task search was not affected by this leak.

Risk

This is unauthorized access to protected health information: a patient app that trusts the FHIR API could display another person's scheduled work, due times, and linked clinical references. That is a confidentiality breach, and a clinical-safety risk if the UI lets the user act on the wrong Task.

Fix in 2.2.0

Patient Task search is now always restricted to the caller's own patient (patient=Patient/{me}), including when the patient has zero Tasks. An empty caseload returns an empty 200 OK bundle, not other patients' Tasks. No configuration change is required.

LegitimateInterest previously granted Consent access only through the Patient compartment: a caller could see a Consent when they were (or could reach) Consent.patient. A Consent that named them only as Consent.performer — the clinician who obtained it, an organization, a PractitionerRole, or a patient listed as a performer — was not searchable or readable on that path.

Starting with 2.2.0, LegitimateInterest also honors Consent.performer, searched through the built-in consentor parameter. REST and GraphQL search return the same set as read and write checks.

RoleCan read a Consent whenCan write a Consent when
PatientConsent.patient is themselves, or any performer is Patient/{me}Any performer is themselves. Co-performers (for example a RelatedPerson guardian) are allowed. If Consent.patient is present, it must also be themselves.
PractitionerAny performer is in scope (an accessible Organization, their own active PractitionerRole, their $me Practitioner, or an organization-compartment Patient), or Consent.patient is reachable through the organization compartmentAll performers are in scope, and a present Consent.patient is also in scope

The stricter practitioner write rule prevents disclosing a Consent to an out-of-scope patient by listing that patient as a performer — that patient would then find it because they appear as a performer.

Patient-compartment visibility is unchanged: a Consent whose Consent.patient is in scope remains readable as before. No new authorization YAML is required; existing LegitimateInterest Consent rules pick this up automatically. CareTeam-scoped Consent rules are unchanged.

Bug Fixes

Includes were counted against _count instead of riding with matches

_count is the number of matching resources per page. _include and _revinclude results are extras that should travel with the matches they belong to.

On authorization-narrowed searches of resources that can attach to a patient through more than one field — for example Task via patient or subject — the server counted matches and includes toward _count. Includes from page 1 therefore spilled onto page 2, and a page could fill up with included Patient and Condition resources while later matching Tasks waited. With _count=30 on a Task search that included patients and conditions, the first page could contain 30 Tasks and no includes; the second page then mixed leftover Tasks with the includes that belonged to page 1.

Starting with 2.2.0, _count paginates matching resources only. Includes and reverse-includes are re-attached to the page of the matches they came from. Going back with a previous-page link returns the same matches and their includes. Searches that do not use includes — including GraphQL connections and $everything — page as before. No configuration change is required.

Task search returned 403 during CarePlan renewal

A client with valid LegitimateInterest or CareTeam Task rules could receive 403 Forbidden on GET /Task (plain search, paged _getpages, _revinclude, and GET /Task/{id}) while $renew-due-events or $subscribe-due-events was still materializing Tasks for that CarePlan. The same search succeeded again after materialization finished. Newly created Tasks were already stored; the permission check still used the Task list from the start of the request, so every new Task looked unauthorized.

Starting with 2.2.0, Task search and reads during a live renewal return 200 OK with the Tasks the caller is allowed to see. Client-supplied _id values that fall outside that set now return an empty 200 OK rather than 403 Forbidden. No configuration change is required.

Identity lookup failures looked like permission denials

Patient clients that authenticate with an OpenID token without a fhir_id claim are resolved by searching Patient?identifier=… (and an email fallback). When that lookup could not complete — typically because a heavy $renew-due-events saturated the database — the request continued as if no patient identity had been found and was then denied with 403 Forbidden ("Patient FHIR resource ID not found"). The caller looked unauthorized; the real problem was a transient infrastructure failure. Tokens that already carry fhir_id were not affected.

Starting with 2.2.0, an identity lookup that cannot complete returns 503 Service Unavailable with an OperationOutcome issue code of transient. A genuine unknown identity is unchanged: the request still falls back to the Public rules. If the lookup failed, the server does not auto-create a Patient — an inconclusive search must not mint a duplicate identity.

Treat 503 on authenticated FHIR calls as retryable. Do not interpret it as a permanent permission denial. Once the database recovers, the same token resolves and the call succeeds.

Renew and subscribe failed with 409 while a Subscription was activating

CarePlan/$renew-due-events and $subscribe-due-events update the CarePlan's Subscription resources. Those subscriptions are created in requested status and flipped to active in the background. A renew or re-subscribe that overlapped that flip read a stale version and lost the write, returning 409 Conflict to the client. The race was most visible under load, when activation took longer, but any client that renewed during activation could hit it.

Starting with 2.2.0, those updates retry against the current Subscription (and the CarePlan scheduling tag on $materialize) when the version has changed. A caller no longer sees 409 Conflict for this activation race. Persistent conflicts after several retries still surface as 409 Conflict. No configuration change is required.

Recurring CarePlan Task ids churned on every reconcile

Each scheduled occurrence maps to a deterministic Task id derived from the CarePlan and when that occurrence falls in the plan. For long-lived recurring plans, that position was measured from a window that moved forward over time, so the same calendar occurrence received a new id as the window advanced. Every reconcile then treated the old Tasks as orphans: they were deleted and recreated, Task ids churned, and webhook consumers saw a delete-and-create pair for work that had not actually changed.

Starting with 2.2.0, occurrence numbering is anchored to CarePlan.period.start, which does not move. After upgrade, plans whose start is older than the current scheduling window go through one id migration on the first reconcile; after that, ids stay stable across renewals. If you persist Task ids for long-lived recurring plans, expect a one-time id change on first reconcile after upgrade, then no further churn from window movement. No configuration change is required.

Transient email uniqueness failures looked like duplicates

When fire-arrow.authentication.protect-identity-resources is configured, creating or updating a Patient (or other protected identity resource) with an email address takes a lock so two callers cannot claim the same address. If that lock timed out — for example because the database was saturated — the server answered 422 Unprocessable Entity, the same status it uses when the email is genuinely already taken. A brand-new, unique email looked duplicate.

Starting with 2.2.0, a lock timeout or a failed duplicate lookup returns 503 Service Unavailable with issue code transient. A real duplicate still returns 422 Unprocessable Entity. The response does not include the email address. Retry on 503; treat 422 as a genuine uniqueness conflict.

Subscription-create retries logged as ERROR

With hapi.fhir.subscription.immediately_queued: true (recommended for CarePlan events since 2.1.0), creating a Subscription always races the first matching attempt against the creating transaction. That first attempt fails with "Resource Id[Subscription/…] is not known" and then succeeds on retry. The failure is expected and harmless, but it was logged at ERROR with a stack trace, so exception-based alerting paged on-call for every subscription create.

Starting with 2.2.0, the first few attempts of that specific race are logged at INFO without a stack trace. Other not-found errors, other exception types, and attempts that still fail after the retry budget remain ERROR. The retry budget defaults to 3 and can be restored to always-ERROR with fire-arrow.subscription.retry.identity-race-max-benign-attempts: 0 (FIRE_ARROW_SUBSCRIPTION_RETRY_IDENTITY_RACE_MAX_BENIGN_ATTEMPTS). No change is required to benefit from the quieter default.

Dependency Upgrades

2.2.0 upgrades the embedded HAPI FHIR platform from 8.10.1 to 8.12.0, and Clinical Reasoning from 4.5.2 to 4.11.1. It also includes a connection-pool and database-migration library refresh, Jackson 2.22.2, Azure SDK and Micrometer patch updates, and administration UI dependency updates.

The administration UI's PostCSS build dependency moves to 8.5.23, which addresses CVE-2026-69153. That advisory applies to processing untrusted CSS at build time; the FHIR server runtime is not affected.

Upstream 8.12.0 withdraws the terminology delta operations $apply-codesystem-delta-add and $apply-codesystem-delta-remove (replaced by a mode parameter on $hapi.fhir.upload-terminology.create-job). Fire Arrow leaves the terminology-upload operation off by default; deployments that re-enabled it should switch to the new upload job. ValueSet pre-expansion now runs only for ValueSets whose status is active.