Skip to content

feat: Add patient registry and per-patient polling for athenahealth integration#20

Merged
rsalus merged 28 commits intomainfrom
feature/integration-patient-registry-polling
Feb 2, 2026
Merged

feat: Add patient registry and per-patient polling for athenahealth integration#20
rsalus merged 28 commits intomainfrom
feature/integration-patient-registry-polling

Conversation

@rsalus
Copy link
Contributor

@rsalus rsalus commented Feb 2, 2026

Summary

Implements patient registry infrastructure and per-patient encounter polling for the athenahealth SMART on FHIR embedded app architecture. This enables dashboard-driven patient registration with automatic encounter monitoring and work item lifecycle management.

Changes

  • Patient RegistryIPatientRegistry interface with InMemoryPatientRegistry implementation for tracking registered patients with 12-hour expiration
  • Registration EndpointsPOST/GET/DELETE /api/patients/* for patient registration and management with automatic work item creation in Pending status
  • Per-Patient Polling — Refactored AthenaPollingService from global polling to per-patient polling using Parallel.ForEachAsync with max 5 concurrent
  • Status Transition DetectionPollPatientEncounterAsync detects encounter completion and emits EncounterCompletedEvent with auto-unregistration
  • EncounterProcessor Integration — Updated to consume EncounterCompletedEvent, update work items with ServiceRequestId/ProcedureCode, and send WORK_ITEM_STATUS_CHANGED SSE notifications
  • Alba Integration Tests — End-to-end tests for patient registration flow and encounter completion flow

Test Plan

Unit and integration tests covering all new functionality with TDD approach.


Results: Tests 291 ✓ · Build 0 errors
Design: docs/designs/2026-02-01-athenahealth-workflow.md
Plan: docs/plans/2026-02-01-patient-registry-polling.md

@coderabbitai
Copy link

coderabbitai bot commented Feb 2, 2026

📝 Walkthrough

Walkthrough

Adds a patient registry and in-memory implementation, per-patient polling that emits EncounterCompletedEvent, patient CRUD endpoints, updated encounter processing to accept events and update work items (nullable ServiceRequestId/ProcedureCode), new contracts/mappers/utilities, and extensive unit and integration tests and DI wiring.

Changes

Cohort / File(s) Summary
Contracts & DTOs / Models
apps/gateway/Gateway.API/Contracts/IPatientRegistry.cs, apps/gateway/Gateway.API/Contracts/IEncounterPollingService.cs, apps/gateway/Gateway.API/Contracts/IEncounterProcessor.cs, apps/gateway/Gateway.API/Contracts/IWorkItemStore.cs, apps/gateway/Gateway.API/Contracts/Notification.cs, apps/gateway/Gateway.API/Contracts/IFhirDataAggregator.cs, apps/gateway/Gateway.API/Models/RegisteredPatient.cs, apps/gateway/Gateway.API/Models/EncounterCompletedEvent.cs, apps/gateway/Gateway.API/Models/RegisterPatientRequest.cs, apps/gateway/Gateway.API/Models/RegisterPatientResponse.cs, apps/gateway/Gateway.API/Models/WorkItemStatus.cs
New IPatientRegistry and EncounterCompletedEvent; IEncounterPollingService exposes ChannelReader; IEncounterProcessor adds ProcessAsync(evt,ct) and marks old API obsolete; IFhirDataAggregator gains optional encounterId; IWorkItemStore adds UpdateAsync; Notification extended; RegisteredPatient and request/response DTOs added; WorkItemStatus values adjusted.
Services (implementations & helpers)
apps/gateway/Gateway.API/Services/InMemoryPatientRegistry.cs, apps/gateway/Gateway.API/Services/InMemoryWorkItemStore.cs, apps/gateway/Gateway.API/Services/AthenaQueryBuilder.cs, apps/gateway/Gateway.API/Services/RecommendationMapper.cs, apps/gateway/Gateway.API/Services/Polling/AthenaPollingService.cs, apps/gateway/Gateway.API/Services/EncounterProcessor.cs, apps/gateway/Gateway.API/Services/InMemoryWorkItemStore.cs
Adds InMemoryPatientRegistry; InMemoryWorkItemStore: full UpdateAsync with retry; AthenaQueryBuilder and RecommendationMapper added; AthenaPollingService switched to per-patient polling, uses IPatientRegistry, emits EncounterCompletedEvent, exposes processed-encounter helpers; EncounterProcessor gains ProcessAsync(evt,ct), updated constructor to accept IWorkItemStore and updates WorkItem fields/status and notifications.
API endpoints & wiring
apps/gateway/Gateway.API/Endpoints/PatientEndpoints.cs, apps/gateway/Gateway.API/Endpoints/WorkItemEndpoints.cs, apps/gateway/Gateway.API/DependencyExtensions.cs, apps/gateway/Gateway.API/Program.cs
Adds patient endpoints (POST /api/patients/register, GET, DELETE) that create work items and register/unregister patients; wires IPatientRegistry→InMemoryPatientRegistry in DI; Program maps patient endpoints; WorkItem Rehydrate flow validates ProcedureCode and uses RecommendationMapper.
Model nullability changes
apps/gateway/Gateway.API/Models/WorkItem.cs, apps/gateway/Gateway.API/Models/CreateWorkItemRequest.cs
ServiceRequestId and ProcedureCode made nullable (string?) to reflect post-analysis population.
Polling behavior & channels
apps/gateway/Gateway.API/Services/Polling/AthenaPollingService.cs, apps/gateway/Gateway.API/Contracts/IEncounterPollingService.cs
Polling moves from global bundle polling to per-registered-patient queries; uses AthenaQueryBuilder, queries FHIR per patient, emits EncounterCompletedEvent on finished encounters, updates or unregisters registry entries, and exposes helper APIs for processed encounter tracking and purging.
IFhirDataAggregator & FHIR plumbing
apps/gateway/Gateway.API/Contracts/IFhirDataAggregator.cs, apps/gateway/Gateway.API/Services/FhirDataAggregator.cs
AggregateClinicalDataAsync gains optional encounterId parameter and propagates it to SearchServiceRequestsAsync; tests and bootstraps updated accordingly.
Tests — unit & integration
apps/gateway/Gateway.API.Tests/...
Large additions/updates: IPatientRegistry contract tests, InMemoryPatientRegistry and InMemoryWorkItemStore tests, updated AthenaPollingService and EncounterProcessor tests, endpoint unit tests, Alba-based integration tests for registration→processing flows, DI resolution tests, and many model tests.
Docs
docs/designs/2026-01-31-workitem-crud-preview.md
Design updated: Rehydrate no longer accepts accessToken in body; RehydrateRequest removed; IWorkItemStore GetAllAsync added; acceptance criteria/tests updated.

Sequence Diagram(s)

sequenceDiagram
    participant Client as Client
    participant PatientAPI as PatientEndpoints
    participant WorkStore as IWorkItemStore
    participant Registry as IPatientRegistry

    Client->>PatientAPI: POST /api/patients/register
    activate PatientAPI
    PatientAPI->>WorkStore: CreateAsync(workItem Pending)
    WorkStore-->>PatientAPI: workItemId
    PatientAPI->>Registry: RegisterAsync(RegisteredPatient with WorkItemId)
    Registry-->>PatientAPI: void
    PatientAPI-->>Client: 200 OK {WorkItemId, Message}
    deactivate PatientAPI
Loading
sequenceDiagram
    participant Poller as AthenaPollingService
    participant Registry as IPatientRegistry
    participant QueryBuilder as AthenaQueryBuilder
    participant Fhir as IFhirHttpClient
    participant Channel as EncounterChannel
    participant Processor as EncounterProcessor
    participant WorkStore as IWorkItemStore

    Poller->>Registry: GetActiveAsync()
    Registry-->>Poller: [RegisteredPatient...]
    loop per-patient (concurrent)
        Poller->>QueryBuilder: BuildEncounterQuery(patient...)
        QueryBuilder-->>Poller: query
        Poller->>Fhir: SearchAsync(query)
        Fhir-->>Poller: FHIR Bundle
        alt bundle shows finished encounter
            Poller->>Channel: Write(EncounterCompletedEvent)
            Channel-->>Processor: EncounterCompletedEvent
            Processor->>WorkStore: UpdateAsync(workItem with ServiceRequestId/ProcedureCode/status)
            WorkStore-->>Processor: success
            Processor->>Processor: notify WORK_ITEM_STATUS_CHANGED, PA_FORM_READY
            Poller->>Registry: UnregisterAsync(patientId)
        else not finished
            Poller->>Registry: UpdateAsync(patientId, lastPolled, status)
        end
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🏥 A registry waits with patient names,
Pollers chase FHIR for finished claims,
Events flow down channels into hands,
Processors update work items and send,
Tests cheer the path across the lands 🎉

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 21.53% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The PR title 'feat: Add patient registry and per-patient polling for athenahealth integration' clearly and concisely summarizes the main changes: adding patient registry infrastructure and refactoring polling to be per-patient instead of global.
Description check ✅ Passed The PR description is well-structured, directly related to the changeset, and provides clear context about patient registry implementation, registration endpoints, per-patient polling refactoring, event emission, and integration tests.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feature/integration-patient-registry-polling

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/gateway/Gateway.API/Services/FhirDataAggregator.cs (1)

45-64: ⚠️ Potential issue | 🟠 Major

Thread optional encounterId through aggregator to scope ServiceRequest queries.

EncounterProcessor has encounter context available (lines 63, 226) but cannot pass it when calling AggregateClinicalDataAsync. This causes SearchServiceRequestsAsync to receive null, bypassing encounter-scoped filtering and risking ServiceRequest/ProcedureCode misassociation in encounter-driven flows.

Update IFhirDataAggregator.AggregateClinicalDataAsync and FhirDataAggregator.AggregateClinicalDataAsync to accept an optional encounterId parameter and thread it through to SearchServiceRequestsAsync. The underlying FHIR client already supports this filtering (tested at line 157 in FhirClientServiceRequestTests.cs). Note: WorkItemEndpoints calls without encounter context, so the parameter must remain optional.

Suggested changes
- Task<ClinicalBundle> AggregateClinicalDataAsync(
-     string patientId,
-     CancellationToken cancellationToken = default);

+ Task<ClinicalBundle> AggregateClinicalDataAsync(
+     string patientId,
+     string? encounterId = null,
+     CancellationToken cancellationToken = default);
- var serviceRequestsTask = _fhirClient.SearchServiceRequestsAsync(patientId, null, cancellationToken);

+ var serviceRequestsTask = _fhirClient.SearchServiceRequestsAsync(patientId, encounterId, cancellationToken);

Update call sites in EncounterProcessor (lines 68–70, 231–233) to pass evt.EncounterId or encounterId.

🤖 Fix all issues with AI agents
In `@apps/gateway/Gateway.API/DependencyExtensions.cs`:
- Around line 22-41: Replace the hard-coded origin in AddCorsPolicy by
introducing a CorsSettings POCO and binding it via
AddOptions<CorsSettings>().BindConfiguration("Cors") (same pattern used by
AddApiKeyAuthentication/AddAthenaServices/AddGatewayServices), add a "Cors"
section to appsettings.json with an Origins array (and env overrides), inject
IOptions<CorsSettings> (or resolve options in the extension) into AddCorsPolicy
to read configured origins and call policy.WithOrigins(...) with that array
before AllowAnyHeader/AllowAnyMethod/AllowCredentials; ensure null/empty
handling falls back to no origins or throws a clear configuration error.

In `@apps/gateway/Gateway.API/Endpoints/PatientEndpoints.cs`:
- Around line 23-39: The patient endpoints in MapPatientEndpoints (group with
RegisterAsync, UnregisterAsync, GetAsync) are exposed without API-key
protection; update this route group to apply the same API-key filtering used by
WorkItemEndpoints so these handlers require the API key. Specifically, locate
MapPatientEndpoints and apply the same group-level protection mechanism (the
API-key filter/authorization policy or endpoint filter used in
WorkItemEndpoints) to the MapGroup("/api/patients") so RegisterAsync,
UnregisterAsync and GetAsync inherit the API-key requirement.

In `@apps/gateway/Gateway.API/Endpoints/WorkItemEndpoints.cs`:
- Around line 91-114: The endpoint currently calls
intelligenceClient.AnalyzeAsync with workItem.ProcedureCode which can be null;
before calling AggregateClinicalDataAsync/AnalyzeAsync, validate
workItem.ProcedureCode and if missing return Results.BadRequest(new
ErrorResponse { Message = "ProcedureCode is required", Code =
"MISSING_PROCEDURE_CODE" }); also update the endpoint metadata (OpenAPI/route
documentation) to document the 400 response; adjust flow around
fhirAggregator.AggregateClinicalDataAsync, AnalyzeAsync, DetermineStatus and
workItemStore.UpdateStatusAsync so they only run when ProcedureCode is present.
- Around line 133-148: The DetermineStatus(PAFormData analysisResult) method is
doing case-sensitive comparisons against analysisResult.Recommendation which the
IntelligenceClient returns in uppercase; update the comparisons to be
case-insensitive (e.g., use string.Equals(analysisResult.Recommendation,
"APPROVE", StringComparison.OrdinalIgnoreCase) and similarly for
"NOT_REQUIRED"/"no_pa_required") or normalize analysisResult.Recommendation
(ToUpperInvariant or ToLowerInvariant) before comparing so that the checks for
WorkItemStatus.NoPaRequired and WorkItemStatus.ReadyForReview actually match the
service output; reference DetermineStatus and PAFormData.Recommendation as the
touch points.

In `@apps/gateway/Gateway.API/Services/EncounterProcessor.cs`:
- Around line 98-133: The code currently ignores the boolean results from
_workItemStore.UpdateAsync and _workItemStore.UpdateStatusAsync so notifications
may be emitted even if the update failed; modify the existingWorkItem branch
(after calling _workItemStore.UpdateAsync(evt.WorkItemId, updatedWorkItem, ct))
to capture the returned success flag, log an error and return/abort (do not
proceed to emit WORK_ITEM_STATUS_CHANGED/PA_FORM_READY) when it is false, and
likewise capture and handle the boolean result from
_workItemStore.UpdateStatusAsync in the else branch (log error and abort when
false); ensure you reference the same identifiers (_workItemStore.UpdateAsync,
UpdateStatusAsync, existingWorkItem, updatedWorkItem, evt.WorkItemId) when
adding the checks.
- Around line 89-96: The recommendation-to-status mappings are duplicated and
case-sensitive (EncounterProcessor's switch vs WorkItemEndpoints.DetermineStatus
and PAFormData docs), so create a shared utility (e.g., a static
RecommendationMapper type) that normalizes the recommendation
(recommendation?.ToUpperInvariant() or ToLowerInvariant()) and returns the
appropriate WorkItemStatus; replace the inline switch in EncounterProcessor (and
update WorkItemEndpoints.DetermineStatus to call RecommendationMapper) so all
callers use the same normalized mapping and follow the existing Strategy pattern
(similar to TokenStrategyResolver).

In `@apps/gateway/Gateway.API/Services/Polling/AthenaPollingService.cs`:
- Around line 196-255: The processed-encounter set (_processedEncounters) is
never updated in PollPatientEncounterAsync so
GetProcessedEncounterCount/IsEncounterProcessed remain empty; when you detect a
transition to finished inside PollPatientEncounterAsync (where you build evt =
new EncounterCompletedEvent and write to _encounterChannel), add the encounter
identifier (e.g., patient.PatientId + ":" + patient.EncounterId or the same key
used by IsEncounterProcessed) into _processedEncounters (ensuring thread-safety
consistent with how _processedEncounters is declared), and short-circuit early
at the start of PollPatientEncounterAsync if IsEncounterProcessed indicates this
encounter is already processed to avoid duplicate emits and keep metrics
accurate; also ensure any unregister logic still runs or is skipped as intended
after marking processed.
- Around line 38-52: Constructor in AthenaPollingService sets the encounter
channel with SingleWriter = true which conflicts with concurrent writes from
Parallel.ForEachAsync (used with MaxDegreeOfParallelism = 5); update the
Channel.CreateUnbounded<EncounterCompletedEvent> options to set SingleWriter =
false so multiple parallel writers can safely write to _encounterChannel,
ensuring the change is applied where _encounterChannel is initialized in the
AthenaPollingService constructor.

In `@docs/designs/2026-01-31-workitem-crud-preview.md`:
- Around line 144-151: The docs currently show POST
/api/work-items/{id}/rehydrate accepting a JSON body with an "accessToken",
which is incorrect; update the POST /api/work-items/{id}/rehydrate section and
the other occurrence describing the RehydrateAsync endpoint to state that the
endpoint accepts only the route parameter id and no request body, remove the
example JSON with "accessToken" and any mention of optional token/fallback
logic, and clarify that authentication/dependency resolution is handled via
service injection (RehydrateAsync) rather than a body parameter.
🟡 Minor comments (20)
docs/designs/2026-02-01-athenahealth-workflow.md-427-429 (1)

427-429: ⚠️ Potential issue | 🟡 Minor

Add a language to the fenced code block.

Markdownlint flags the block as missing a language; use something like http or text.

docs/designs/2026-02-01-athenahealth-workflow.md-11-18 (1)

11-18: ⚠️ Potential issue | 🟡 Minor

Fix table pipe spacing/alignment to satisfy MD060.

Multiple tables are missing the required space/alignment around pipes.

Also applies to: 261-268, 359-366, 374-407, 439-442

docs/integration/Current FIGMA.md-20-20 (1)

20-20: ⚠️ Potential issue | 🟡 Minor

Use standard horizontal rule style (---) for markdownlint compliance.

markdownlint flags the current ------ style. Simple replacement keeps content unchanged.

Proposed fix
-------
+---

Also applies to: 57-57

docs/integration/Current FIGMA.md-82-88 (1)

82-88: ⚠️ Potential issue | 🟡 Minor

Normalize table pipe spacing to satisfy MD060.

The tables are missing spaces around pipes in the header rows. Add spaces to align with the configured “compact” style.

Proposed fix (example for the first table)
-| Figma Assumption | Technical Reality | Impact |
-|------------------|-------------------|--------|
+| Figma Assumption | Technical Reality | Impact |
+| ---------------- | ----------------- | ------ |

Also applies to: 117-124, 129-135, 141-147, 151-158, 162-167, 171-175

docs/integration/Current FIGMA.md-92-103 (1)

92-103: ⚠️ Potential issue | 🟡 Minor

Promote emphasized headings to actual headings.

MD036 flags emphasis used as a heading. Consider upgrading the emphasized labels to #### headings for consistency and accessibility.

Proposed fix
-**1. ADT Feed vs SMART Launch**
+#### 1. ADT Feed vs SMART Launch

-**2. Practice-Wide Queue vs Patient Context**
+#### 2. Practice-Wide Queue vs Patient Context
docs/pitch/proposed-miro.md-71-71 (1)

71-71: ⚠️ Potential issue | 🟡 Minor

Incorrect step reference — "Step 8" doesn't exist.

The loop refers to "Step 8 (Check for completeness)" but the completeness check is Phase 2, Step 4 (line 37). Consider updating to reference "Phase 2, Step 4" for clarity.

apps/gateway/Gateway.API/Configuration/AthenaOptions.cs-43-123 (1)

43-123: ⚠️ Potential issue | 🟡 Minor

Remove redundant OAuth scope variants to align with SMART v2 best practices.

The scope set requests .r, .rs, and .s separately for each resource (e.g., Condition.r, Condition.rs, Condition.s), but .rs already grants both read and search permissions—making the individual .r and .s scopes redundant. SMART v2 guidance explicitly recommends "preferring shorter equivalent scopes" and "factoring scopes to reduce token size," as scope string bloat impacts token size and may cause issues with stricter OAuth servers.

Consolidate to only the .rs variant per resource (or .s if search-only is sufficient), and remove category-filtered duplicates that overlap with broader resource scopes. This reduces scope bloat, improves maintainability, and aligns with SMART v2 best practices.

docs/debugging/2026-02-01-athenahealth-api-constraints.md-96-103 (1)

96-103: ⚠️ Potential issue | 🟡 Minor

Normalize table pipe spacing (MD060).
The tables at these lines are missing spaces around pipes. Please adjust the table formatting to satisfy MD060.

📝 Example
-| Resource | Required Parameters |
-|----------|-------------------|
+| Resource | Required Parameters |
+| -------- | ------------------- |

Also applies to: 129-137, 242-249

docs/debugging/2026-02-01-athenahealth-api-constraints.md-19-21 (1)

19-21: ⚠️ Potential issue | 🟡 Minor

Add language identifiers to fenced code blocks (MD040).
markdownlint flagged multiple fenced blocks without languages. Please annotate them (e.g., json, text, csharp) to satisfy MD040 and improve readability.

📝 Example
-```
+```json
 Token request failed with status 401: {"detailedmessage":"Policy evaluation failed for this request, please check the policy configurations.","error":"access_denied"}
-```
+```

Also applies to: 42-44, 47-51, 56-62, 69-71, 84-91, 114-118, 121-123, 145-150, 170-176, 197-202

docs/designs/2026-01-31-workitem-crud-preview.md-40-46 (1)

40-46: ⚠️ Potential issue | 🟡 Minor

Normalize table spacing for markdownlint MD060.

Adding spaces around pipes resolves the “compact” table style warnings.

✏️ Suggested fix
-| Method | Path | Description |
-|--------|------|-------------|
+| Method | Path | Description |
+| ------ | ---- | ----------- |

-| **1** | Create request/response models | 3 new | 0 |
+| **1** | Create request/response models | 3 new | 0 |

Also applies to: 894-899

docs/designs/2026-01-31-workitem-crud-preview.md-33-36 (1)

33-36: ⚠️ Potential issue | 🟡 Minor

Add language identifiers to fenced code blocks (markdownlint MD040).

This clears the MD040 warnings and improves readability.

✏️ Suggested fix
-```
+```text
 /api/work-items
-```
+```

-```
+```text
 Task 1 → Task 2 → Task 3 → Task 4 → Task 6
               ↘
                Task 5 (after Task 3)
-```
+```

Also applies to: 914-918

apps/gateway/Gateway.API.Tests/Endpoints/WorkItemEndpointsTests.cs-105-140 (1)

105-140: ⚠️ Potential issue | 🟡 Minor

Clarify non‑null ProcedureCode in AnalyzeAsync stub.

WorkItem.ProcedureCode is nullable, so the stub triggers NRT warnings. Since this test sets it, an explicit non‑null assertion keeps the intent clear.

🔧 Suggested adjustment
-        _intelligenceClient
-            .AnalyzeAsync(clinicalBundle, workItem.ProcedureCode, Arg.Any<CancellationToken>())
+        _intelligenceClient
+            .AnalyzeAsync(clinicalBundle, workItem.ProcedureCode!, Arg.Any<CancellationToken>())
             .Returns(formData);
apps/gateway/Gateway.API.Tests/Endpoints/WorkItemEndpointsTests.cs-165-213 (1)

165-213: ⚠️ Potential issue | 🟡 Minor

Clarify non‑null ProcedureCode in AnalyzeAsync stub.

Same nullable warning as above; make the expectation explicit.

🔧 Suggested adjustment
-        _intelligenceClient
-            .AnalyzeAsync(clinicalBundle, workItem.ProcedureCode, Arg.Any<CancellationToken>())
+        _intelligenceClient
+            .AnalyzeAsync(clinicalBundle, workItem.ProcedureCode!, Arg.Any<CancellationToken>())
             .Returns(formData);
docs/pitch/design-reconciliation.md-91-99 (1)

91-99: ⚠️ Potential issue | 🟡 Minor

Fix markdownlint table alignment (MD060).

Align the pipes in the Work Item States table to satisfy the linter.

🧹 Example alignment fix
-| **State** | **Trigger** | **UI Visibility** |
-| --------- | ----------- | ----------------- |
-| `READY_FOR_REVIEW` | All required fields populated | Yes - "Ready for Review" queue |
-| `MISSING_DATA` | Required fields incomplete | Yes - "Missing Data" queue |
-| `PAYER_REQUIREMENTS_NOT_MET` | User marks as unsubmittable | Yes - "Closed" with reason |
-| `SUBMITTED` | User approves, PDF written to chart | Yes - "Submitted" queue |
-| `NO_PA_REQUIRED` | AI determines no PA needed for CPT/payer | No - auto-closed |
+| **State**                 | **Trigger**                              | **UI Visibility**                 |
+|:--------------------------|:-----------------------------------------|:----------------------------------|
+| `READY_FOR_REVIEW`        | All required fields populated            | Yes - "Ready for Review" queue    |
+| `MISSING_DATA`            | Required fields incomplete                | Yes - "Missing Data" queue        |
+| `PAYER_REQUIREMENTS_NOT_MET` | User marks as unsubmittable            | Yes - "Closed" with reason        |
+| `SUBMITTED`               | User approves, PDF written to chart       | Yes - "Submitted" queue           |
+| `NO_PA_REQUIRED`          | AI determines no PA needed for CPT/payer  | No - auto-closed                  |
apps/gateway/Gateway.API.Tests/Services/FhirDataAggregatorTests.cs-113-119 (1)

113-119: ⚠️ Potential issue | 🟡 Minor

Address nullability mismatch flagged by static analysis.

The async lambda returns Task<PatientInfo?> but the Returns extension expects Task<PatientInfo>. This causes a nullability warning that should be addressed.

🔧 Proposed fix
         _fhirClient.GetPatientAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
             .Returns(async _ =>
             {
                 callOrder.Add("Patient");
                 await Task.Delay(10);
-                return CreateTestPatient();
+                return CreateTestPatient()!;
             });

Alternatively, update the mock setup to explicitly handle the nullable return:

         _fhirClient.GetPatientAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
-            .Returns(async _ =>
+            .Returns(async (_) =>
             {
                 callOrder.Add("Patient");
                 await Task.Delay(10);
-                return CreateTestPatient();
+                return (PatientInfo?)CreateTestPatient();
             });
apps/gateway/Gateway.API/Endpoints/PatientEndpoints.cs-56-65 (1)

56-65: ⚠️ Potential issue | 🟡 Minor

Standardize WorkItem ID format.
WorkItemEndpoints prefixes IDs with wi- while registration uses a bare GUID. If consumers assume a prefix, this will be inconsistent.

♻️ Align ID format with WorkItemEndpoints
-            Id = Guid.NewGuid().ToString("N"),
+            Id = $"wi-{Guid.NewGuid():N}",
docs/designs/2026-01-29-athenahealth-pivot-mvp.md-44-74 (1)

44-74: ⚠️ Potential issue | 🟡 Minor

Add language identifiers to fenced blocks.
markdownlint MD040 flags these; text works well for ASCII diagrams/mockups.

🧩 Example adjustment
-```
+```text
┌──────────────────────────────────────────────────────────────────────────────────┐
...
-```
+```

Also applies to: 536-572, 596-627, 642-677, 692-723

docs/designs/2026-01-29-athenahealth-pivot-mvp.md-17-22 (1)

17-22: ⚠️ Potential issue | 🟡 Minor

Normalize table pipe spacing.
markdownlint MD060 reports inconsistent table column spacing across these sections; aligning pipes or running a formatter will clear it.

Also applies to: 117-121, 421-439, 451-457, 466-487, 735-745, 752-758, 761-767, 781-789

docs/designs/2026-01-29-athenahealth-pivot-mvp.md-443-443 (1)

443-443: ⚠️ Potential issue | 🟡 Minor

Minor wording cleanup per LanguageTool.
Hyphenate compound adjectives and prefer “actual” over “real” in formal docs.

✍️ Suggested edits
-### Rate Limiting Strategy
+### Rate-Limiting Strategy
-- Implement real SMART OAuth in Dashboard
+- Implement actual SMART OAuth in Dashboard

Also applies to: 792-797

apps/gateway/Gateway.API/Services/FhirClient.cs-439-455 (1)

439-455: ⚠️ Potential issue | 🟡 Minor

Verify encounter reference format with real athena payloads before implementing.

The current implementation handles bare Encounter/{id} references, which matches all test data and current usage. However, FHIR servers can return absolute references (e.g., https://.../Encounter/{id}) or versioned references. If athena's FHIR API sends these formats, the method will return null and drop the EncounterId. Before adding complexity to handle additional formats, confirm whether athena's actual payloads include anything beyond the bare format.

🧹 Nitpick comments (36)
apps/gateway/Gateway.API.Tests/Endpoints/SseEndpointsTests.cs (2)

92-93: Good improvement: polling-based subscriber wait is more deterministic.

The WaitForSubscriberAsync helper is a solid replacement for the fixed delay—eliminates race conditions and provides a clear failure mode via TimeoutException.

However, the other tests in this file (SseEndpoint_Get_ReturnsTextEventStreamContentType, SseEndpoint_Get_SetsCacheControlNoCache, SseEndpoint_Get_SetsConnectionKeepAlive) still use await Task.Delay(50). Consider applying the same polling approach for consistency and reliability.

♻️ Suggested refactor for consistency
         // Start streaming (subscriber must be active before write)
         var streamTask = SseEndpoints.StreamEventsAsync(context, hub, cts.Token);
 
-        // Give subscriber time to register
-        await Task.Delay(50);
+        // Wait for subscriber to register (polling instead of fixed delay)
+        await WaitForSubscriberAsync(hub, TimeSpan.FromMilliseconds(200));

Apply this pattern to all four tests for consistent behavior.

Also applies to: 146-159


103-103: Undisposed StreamReader.

The StreamReader wraps the MemoryStream but isn't disposed. While minor in test code, it's good hygiene to wrap it in a using statement or declaration.

♻️ Proposed fix
         // Assert - Check the response body
         memoryStream.Seek(0, SeekOrigin.Begin);
-        var streamedContent = new StreamReader(memoryStream).ReadToEnd();
+        using var reader = new StreamReader(memoryStream);
+        var streamedContent = reader.ReadToEnd();
docs/integration/embedded-apps.md (1)

73-73: Minor grammar and style improvements.

Two small refinements for consistency:

  • Line 73: "outside of" can be simplified to "outside"
  • Line 148: "patient specific" should be hyphenated as "patient-specific" when used as a compound adjective
✏️ Proposed fixes

Line 73:

-- The App Dock is visible from anywhere in athenaOne; we will pass the `patientID` & `encounterID` if they are on a related page.
+- The App Dock is visible from anywhere in athenaOne; we will pass the `patientID` & `encounterID` if they are on a related page.
 - **SMART apps** are launchable from this location, however, they will be disabled until a user navigates to a patient record.
-- **Recommended use:** Admin Tools, Worklists, Patient Lists & Queues, Dashboards, Messaging & Telehealth. This launch point is ideal for universally accessible apps in athenaOne that don't require users to navigate to a specific patient.
+- **Recommended use:** Admin Tools, Worklists, Patient Lists & Queues, Dashboards, Messaging & Telehealth. This launch point is ideal for universally accessible apps in athenaOne that don't require users to navigate to a specific patient.

Line 148:

-- **Description:** New `patientID`, broadcast after an encounter (or any patient specific page) is finished fully loading.
+- **Description:** New `patientID`, broadcast after an encounter (or any patient-specific page) is finished fully loading.

Also applies to: 148-148

orchestration/AuthScript.AppHost/AppHost.cs (2)

72-77: Parameterize Athena endpoints instead of hardcoding preview URLs.

Hardcoding preview endpoints makes switching to prod/stage require a code change. Prefer parameters so the environment drives these values.

♻️ Proposed change
-    .WithEnvironment("Athena__FhirBaseUrl", "https://api.preview.platform.athenahealth.com/fhir/r4/")
-    .WithEnvironment("Athena__TokenEndpoint", "https://api.preview.platform.athenahealth.com/oauth2/v1/token")
+    .WithEnvironment("Athena__FhirBaseUrl", athenaFhirBaseUrl)
+    .WithEnvironment("Athena__TokenEndpoint", athenaTokenEndpoint)

Add these parameters near the other Athena parameters:

var athenaFhirBaseUrl = builder.AddParameter("athena-fhir-base-url");
var athenaTokenEndpoint = builder.AddParameter("athena-token-endpoint");

As per coding guidelines, "Focus on configuration correctness".


91-93: Gate --add-host to Linux/Docker runtimes.

--add-host is Docker-specific; on other runtimes or publish targets it can be unsupported or redundant. Consider applying it conditionally by OS/runtime.

♻️ Proposed change
 var dashboard = builder
     .AddDockerfile("dashboard", "../..", "apps/dashboard/Dockerfile.build")
     .WithHttpEndpoint(port: 3000, targetPort: 80, name: "dashboard-ui")
     .WaitFor(gateway)
-    .WithExternalHttpEndpoints()
-    // Enable host.docker.internal resolution on Linux
-    .WithContainerRuntimeArgs("--add-host=host.docker.internal:host-gateway");
+    .WithExternalHttpEndpoints();
+
+if (OperatingSystem.IsLinux())
+{
+    // Enable host.docker.internal resolution on Linux
+    dashboard = dashboard.WithContainerRuntimeArgs("--add-host=host.docker.internal:host-gateway");
+}

As per coding guidelines, "Focus on configuration correctness".

apps/gateway/Gateway.API.Tests/Models/RegisterPatientRequestTests.cs (1)

16-31: Consider renaming the test to reflect what's actually being verified.

The test name RegisterPatientRequest_RequiredProperties_AreEnforced suggests compile-time enforcement testing, but it's actually verifying property assignment and retrieval. The required keyword is enforced at compile-time, not runtime.

✨ Suggested rename for clarity
 [Test]
-public async Task RegisterPatientRequest_RequiredProperties_AreEnforced()
+public async Task RegisterPatientRequest_CanBeConstructedWithRequiredProperties()
 {
apps/gateway/Gateway.API.Tests/Models/RegisterPatientResponseTests.cs (1)

13-26: Test name doesn't reflect full scope.

The test is named RegisterPatientResponse_ContainsWorkItemId but also asserts on Message. Consider renaming to RegisterPatientResponse_Properties_InitializeCorrectly or splitting into separate tests.

✏️ Suggested rename
 [Test]
-public async Task RegisterPatientResponse_ContainsWorkItemId()
+public async Task RegisterPatientResponse_Properties_InitializeCorrectly()
 {
apps/gateway/Gateway.API.Tests/Services/AthenaQueryBuilderTests.cs (1)

14-15: Seal the test class to match project standards.

Keeping test classes sealed aligns with the default type guidance and avoids accidental inheritance.

♻️ Suggested change
-public class AthenaQueryBuilderTests
+public sealed class AthenaQueryBuilderTests

As per coding guidelines: "Types: Sealed by default, records for DTOs, nullable enabled".

apps/gateway/Gateway.API.Tests/Services/EncounterProcessorTests.cs (2)

15-15: Seal the test class to align with the default type guidance.

♻️ Suggested change
-public class EncounterProcessorTests
+public sealed class EncounterProcessorTests

As per coding guidelines: "Types: Sealed by default, records for DTOs, nullable enabled".


393-399: Avoid expression-bodied members here to match style guidance.

♻️ Suggested change
-    private static EncounterCompletedEvent CreateEvent() => new()
-    {
-        PatientId = "patient-1",
-        EncounterId = "encounter-1",
-        PracticeId = "practice-1",
-        WorkItemId = "workitem-1"
-    };
+    private static EncounterCompletedEvent CreateEvent()
+    {
+        return new EncounterCompletedEvent
+        {
+            PatientId = "patient-1",
+            EncounterId = "encounter-1",
+            PracticeId = "practice-1",
+            WorkItemId = "workitem-1"
+        };
+    }

As per coding guidelines: "Control: Guard clauses first, early return, no arrow code".

apps/gateway/Gateway.API.Tests/Models/WorkItemStatusTests.cs (1)

11-11: Seal the test class to match the default type guidance.

♻️ Suggested change
-public class WorkItemStatusTests
+public sealed class WorkItemStatusTests

As per coding guidelines: "Types: Sealed by default, records for DTOs, nullable enabled".

apps/gateway/Gateway.API.Tests/Models/CreateWorkItemRequestTests.cs (2)

7-24: Test name doesn't match what's being tested.

The method CreateWorkItemRequest_RequiredProperties_InitializesCorrectly also tests ServiceRequestId and ProcedureCode, which according to the AI summary are optional properties. Consider renaming to CreateWorkItemRequest_AllProperties_InitializesCorrectly or splitting into separate tests for required vs optional properties.


26-40: Duplicate setup with first test.

This test duplicates the object initialization from the first test. Consider using a shared fixture or [SetUp] method, or consolidating this assertion into the first test since both verify the same object's behavior.

apps/gateway/Gateway.API/Services/FhirTokenProvider.cs (2)

20-24: Add constructor guard clauses.

Per coding guidelines, guard clauses should come first. The constructor should validate its dependencies are not null.

🛡️ Proposed fix
 public FhirTokenProvider(ITokenStrategyResolver resolver, ILogger<FhirTokenProvider> logger)
 {
-    _resolver = resolver;
-    _logger = logger;
+    _resolver = resolver ?? throw new ArgumentNullException(nameof(resolver));
+    _logger = logger ?? throw new ArgumentNullException(nameof(logger));
 }

27-39: Consider logging success at Debug level.

The error path is logged, but successful token acquisition isn't. A debug-level log on success can aid troubleshooting without being noisy in production.

📝 Optional enhancement
     public async Task<string> GetTokenAsync(CancellationToken cancellationToken = default)
     {
         var strategy = _resolver.Resolve();
         var token = await strategy.AcquireTokenAsync(cancellationToken);

         if (token is null)
         {
             _logger.LogError("Failed to acquire FHIR access token");
             throw new InvalidOperationException("Unable to acquire FHIR access token");
         }

+        _logger.LogDebug("Successfully acquired FHIR access token");
         return token;
     }
apps/gateway/Gateway.API/Configuration/ApiKeySettings.cs (1)

13-16: Consider IReadOnlyList<string> for immutability.

For security-sensitive configuration like API keys, using IReadOnlyList<string> prevents accidental mutation after configuration binding.

🔒 Proposed change
     /// <summary>
     /// List of valid API keys.
     /// </summary>
-    public List<string> ValidApiKeys { get; init; } = [];
+    public IReadOnlyList<string> ValidApiKeys { get; init; } = [];
apps/gateway/Gateway.API.Tests/DependencyExtensionsTests.cs (1)

24-25: Consider disposing the ServiceProvider after use.

BuildServiceProvider() returns an IDisposable. While not critical in short-lived tests, disposing it ensures any registered IDisposable services are properly cleaned up.

♻️ Suggested fix using `using` statement
         services.AddGatewayServices();
-        var provider = services.BuildServiceProvider();
-        var workItemStore = provider.GetService<IWorkItemStore>();
+        using var provider = services.BuildServiceProvider();
+        var workItemStore = provider.GetService<IWorkItemStore>();

Apply the same pattern in AddGatewayServices_RegistersPatientRegistry.

Also applies to: 43-44

apps/gateway/Gateway.API.Tests/Contracts/IFhirClientTests.cs (1)

28-29: Nullable parameter type not verified by reflection.

The interface declares encounterId as string? (nullable), but typeof(string) and typeof(string?) are identical at runtime. This assertion passes but doesn't validate the nullability annotation. Consider checking NullabilityInfoContext if nullability verification is important, or acknowledge this limitation.

apps/gateway/Gateway.API.Tests/Contracts/IWorkItemStoreTests.cs (1)

18-32: Add UpdateAsync/signature assertions to strengthen the contract test.
Right now the test only validates a subset of methods and doesn’t guard against signature drift. Consider asserting UpdateAsync (and optionally parameter/return types) to keep the interface contract tight.

🔧 Suggested addition
         // Verify UpdateStatusAsync method
         var updateMethod = interfaceType.GetMethod("UpdateStatusAsync");
         await Assert.That(updateMethod).IsNotNull();

+        // Verify UpdateAsync method
+        var updateAsyncMethod = interfaceType.GetMethod("UpdateAsync");
+        await Assert.That(updateAsyncMethod).IsNotNull();
apps/gateway/Gateway.API.Tests/Services/FhirClientServiceRequestTests.cs (2)

12-13: Seal the test class to match the default-sealed convention.

✅ Suggested change
-public class FhirClientServiceRequestTests
+public sealed class FhirClientServiceRequestTests

As per coding guidelines: Sealed by default.


74-76: Dispose JsonDocument instances to avoid test-time leaks.
JsonDocument is IDisposable. Consider using var (or a shared helper) so documents are released after each test.

🧹 Example for one test (apply similarly elsewhere)
-        var jsonDocument = JsonDocument.Parse(fhirBundle);
+        using var jsonDocument = JsonDocument.Parse(fhirBundle);

Also applies to: 121-123, 152-154, 207-209, 247-249, 288-290

apps/gateway/Gateway.API/Filters/ApiKeySecuritySchemeTransformer.cs (1)

19-32: Consider merging into existing SecuritySchemes for defensive robustness.

The current code directly assigns document.Components.SecuritySchemes = securitySchemes;, which works safely in the current architecture since this is the only transformer populating security schemes. However, for future-proofing, merging into the existing dictionary follows a more defensive pattern:

        document.Components ??= new OpenApiComponents();
-       document.Components.SecuritySchemes = securitySchemes;
+       document.Components.SecuritySchemes ??= new Dictionary<string, IOpenApiSecurityScheme>();
+       document.Components.SecuritySchemes[SecuritySchemeName] = new OpenApiSecurityScheme
+       {
+           Type = SecuritySchemeType.ApiKey,
+           Name = "X-API-Key",
+           In = ParameterLocation.Header,
+           Description = "API key required for protected endpoints"
+       };

This eliminates the redundant intermediate dictionary and ensures compatibility if other scheme sources are added later.

apps/gateway/Gateway.API.Tests/Models/RegisteredPatientTests.cs (1)

14-15: Consider sealing this test class for consistency.

This follows the project’s “sealed by default” standard and keeps intent explicit.

As per coding guidelines: “apps/gateway/**/*.cs: Types: Sealed by default”.

apps/gateway/Gateway.API.Tests/Services/InMemoryPatientRegistryTests.cs (1)

15-17: Consider sealing this test class for consistency.

Aligns with the “sealed by default” standard.

As per coding guidelines: “apps/gateway/**/*.cs: Types: Sealed by default”.

apps/gateway/Gateway.API.Tests/Models/WorkItemTests.cs (1)

5-6: Consider sealing this test class for consistency.

Aligns with the “sealed by default” standard.

As per coding guidelines: “apps/gateway/**/*.cs: Types: Sealed by default”.

apps/gateway/Gateway.API.Tests/Integration/PatientEndpointsIntegrationTests.cs (1)

32-32: Avoid expression-bodied helpers to match the guideline.

🔧 Suggested change
-    private void AddApiKey(Scenario s) => s.WithRequestHeader(ApiKeyHeader, GatewayAlbaBootstrap.TestApiKey);
+    private void AddApiKey(Scenario s)
+    {
+        s.WithRequestHeader(ApiKeyHeader, GatewayAlbaBootstrap.TestApiKey);
+    }

As per coding guidelines: Control: Guard clauses first, early return, no arrow code.

apps/gateway/Gateway.API.Tests/Contracts/IPatientRegistryTests.cs (1)

9-9: Seal the test class to align with the guideline.

🔧 Suggested change
-public class IPatientRegistryTests
+public sealed class IPatientRegistryTests

As per coding guidelines: Types: Sealed by default, records for DTOs, nullable enabled.

apps/gateway/Gateway.API.Tests/Models/WorkItemListResponseTests.cs (1)

5-5: Seal the test class to align with the guideline.

🔧 Suggested change
-public class WorkItemListResponseTests
+public sealed class WorkItemListResponseTests

As per coding guidelines: Types: Sealed by default, records for DTOs, nullable enabled.

apps/gateway/Gateway.API/Filters/ApiKeyEndpointFilter.cs (1)

17-20: Add a null guard for the injected validator.

🔧 Suggested change
-    public ApiKeyEndpointFilter(IApiKeyValidator validator)
-    {
-        _validator = validator;
-    }
+    public ApiKeyEndpointFilter(IApiKeyValidator validator)
+    {
+        _validator = validator ?? throw new ArgumentNullException(nameof(validator));
+    }

As per coding guidelines: Control: Guard clauses first, early return, no arrow code.

apps/gateway/Gateway.API.Tests/Integration/WorkItemEndpointsIntegrationTests.cs (1)

92-122: Status filter test relies on test isolation.

The filter test creates data and immediately queries for it. This works with PerTestSession shared state. Be aware that test execution order could affect results if other tests create Submitted items.

Consider using a more unique identifier pattern (e.g., $"enc-filter-test-{Guid.NewGuid()}") if test isolation becomes an issue, though for current scope this is fine.

apps/gateway/Gateway.API.Tests/Services/Polling/AthenaPollingServiceTests.cs (1)

167-178: Unused helper method CreateEmptyBundle.

This method is defined but not referenced in any test. Consider removing it or adding a test that uses it.

♻️ Remove if unused
-    private static JsonElement CreateEmptyBundle()
-    {
-        var json = """
-        {
-            "resourceType": "Bundle",
-            "type": "searchset",
-            "total": 0,
-            "entry": []
-        }
-        """;
-        return JsonDocument.Parse(json).RootElement;
-    }
apps/gateway/Gateway.API.Tests/Services/FhirTokenProviderTests.cs (1)

32-55: Consider using a more idiomatic exception assertion.

The manual try-catch pattern works but is verbose. If TUnit supports Assert.ThrowsAsync, that would be more concise.

♻️ Alternative using ThrowsAsync (if supported)
     [Test]
     public async Task GetTokenAsync_WhenStrategyReturnsNull_ThrowsInvalidOperationException()
     {
         // Arrange
         _resolver.Resolve().Returns(_strategy);
         _strategy.AcquireTokenAsync(Arg.Any<CancellationToken>()).Returns((string?)null);

         var provider = new FhirTokenProvider(_resolver, NullLogger<FhirTokenProvider>.Instance);

         // Act & Assert
-        InvalidOperationException? exception = null;
-        try
-        {
-            await provider.GetTokenAsync();
-        }
-        catch (InvalidOperationException ex)
-        {
-            exception = ex;
-        }
-
-        // Assert
-        await Assert.That(exception).IsNotNull();
-        await Assert.That(exception!.Message).Contains("FHIR access token");
+        var exception = await Assert.ThrowsAsync<InvalidOperationException>(
+            async () => await provider.GetTokenAsync());
+        await Assert.That(exception.Message).Contains("FHIR access token");
     }
apps/gateway/Gateway.API/Endpoints/PatientEndpoints.cs (1)

96-103: Simplify the idempotent delete signature.
This method always returns Ok, so the NotFound alternative in the return type is unused.

🔧 Simplify return type
-    public static async Task<Results<Ok, NotFound>> UnregisterAsync(
+    public static async Task<Ok> UnregisterAsync(
         string patientId,
         [FromServices] IPatientRegistry patientRegistry,
         CancellationToken ct = default)
     {
         await patientRegistry.UnregisterAsync(patientId, ct).ConfigureAwait(false);
         return TypedResults.Ok();
     }
apps/gateway/Gateway.API/Services/Polling/AthenaPollingService.cs (1)

263-282: Use Firely SDK to parse and validate the FHIR Bundle.
The current code assumes entry[0] is an Encounter without validating the resource type. Since Hl7.Fhir.R4 is already available, use FhirJsonParser.Parse<Bundle>() to parse the JSON and validate each entry before extracting the status. This ensures type safety and catches structural issues early, per coding guidelines.

apps/gateway/Gateway.API.Tests/Services/InMemoryWorkItemStoreTests.cs (2)

9-16: Seal the test class by default.
Keeps the type non-inheritable and aligns with the “sealed by default” guideline. Please confirm your test framework supports sealed test classes.

🛠️ Suggested change
-public class InMemoryWorkItemStoreTests
+public sealed class InMemoryWorkItemStoreTests

As per coding guidelines: Types: Sealed by default.


309-338: Assert the exact UpdatedAt value.
Only checking non-null could miss regressions where UpdatedAt is overwritten. Consider asserting equality with the expected timestamp and verify your assertion library supports this check.

✅ Suggested tightening
-        await Assert.That(retrieved!.UpdatedAt).IsNotNull();
+        await Assert.That(retrieved!.UpdatedAt).IsEqualTo(updateTime);

rsalus and others added 24 commits February 1, 2026 20:44
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add interface for managing registered patients awaiting encounter
completion. Methods include RegisterAsync, GetActiveAsync,
UnregisterAsync, GetAsync, and UpdateAsync for tracking patient
registration state during encounter polling workflows.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Implement the in-memory patient registry for development and testing.
Uses ConcurrentDictionary for thread-safe storage keyed by PatientId.
Implements RegisterAsync and GetAsync methods; other methods stubbed.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add GetActiveAsync implementation that returns all active (non-expired) patients
from the in-memory registry. Patients are considered expired if their
RegisteredAt timestamp is older than 12 hours.

Also adds test coverage for GetAsync returning null for non-existent patients.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
…istry

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
These fields are populated after analysis, not at registration time.
Updated both WorkItem and CreateWorkItemRequest models with updated
XML documentation explaining when these fields are populated.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add a static utility class that properly formats athenahealth-specific
FHIR query parameters, including the required ah-practice parameter
with the correct Organization/a-1.Practice-{practiceId} format.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add new Pending status (value 0) to WorkItemStatus enum for representing
work items where the patient is registered but awaiting encounter completion.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add IPatientRegistry as a constructor dependency to AthenaPollingService,
preparing for the refactor from global polling to per-patient polling.
The registry dependency is stored but not yet used - actual per-patient
polling logic will be added in a subsequent change.

- Add _patientRegistry field to store the dependency
- Update constructor to accept IPatientRegistry parameter
- Add new test for constructor with patient registry
- Update all existing tests to include the new parameter

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Refactored ExecuteAsync in AthenaPollingService to poll registered
patients using Parallel.ForEachAsync instead of the previous global
polling approach. The service now:

- Gets active patients from IPatientRegistry before polling
- Returns early if no patients are registered (no FHIR calls made)
- Polls each registered patient in parallel (max 5 concurrent)
- Per-patient FHIR query logic to be implemented in Task 016

Updated tests to reflect new per-patient polling model:
- Added tests verifying registry is queried
- Added test verifying FHIR client not called when no patients
- Marked encounter processing tests as skipped (Task 016)
- Removed obsolete global polling tests

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add event model emitted when a monitored encounter transitions to
"finished" status. The event is published to a channel and consumed
by the EncounterProcessor.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add request body model for patient registration API endpoint with
required properties: PatientId, EncounterId, and PracticeId.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add response model for patient registration API endpoint containing
WorkItemId and Message properties.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
…ction

Add per-patient polling that queries FHIR for encounter status and
detects when encounters transition to "finished". When completed:
- Emits encounter ID to channel for downstream processing
- Auto-unregisters patient from active polling
- Updates registry with LastPolledAt and CurrentEncounterStatus

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add test to verify the edge case where a provider registers a patient
whose encounter is ALREADY finished. The existing condition
(status == "finished" && patient.CurrentEncounterStatus != "finished")
already handles this correctly because null != "finished" is true in C#.

The test confirms that when CurrentEncounterStatus is null (first poll)
and the FHIR status is "finished", the event is emitted immediately
and the patient is unregistered.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Update the encounter polling channel from Channel<string> to
Channel<EncounterCompletedEvent> to pass full context (PatientId,
EncounterId, PracticeId, WorkItemId) to the processor.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
…d update work item status

Add new ProcessAsync method that accepts EncounterCompletedEvent with full context
(PatientId, EncounterId, PracticeId, WorkItemId) and updates work item status based
on analysis recommendation:
- APPROVE/DENY -> ReadyForReview
- NEEDS_INFO -> MissingData
- NOT_REQUIRED -> NoPaRequired

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
…vice request info

- Add UpdateAsync method to IWorkItemStore interface for updating work items
  with arbitrary fields
- Implement UpdateAsync in InMemoryWorkItemStore with retry logic for
  concurrent modifications
- Update EncounterProcessor.ProcessAsync to set ServiceRequestId from
  ClinicalBundle and ProcedureCode from PAFormData after analysis
- Add comprehensive tests for UpdateAsync and work item field updates

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Send a WORK_ITEM_STATUS_CHANGED notification via SSE when the
EncounterProcessor updates the work item status. The notification
includes WorkItemId, NewStatus, PatientId, ServiceRequestId (if
available), and ProcedureCode (if available).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add integration tests verifying the full HTTP flow for patient
registration endpoints using Alba test host.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add integration tests that verify the end-to-end encounter processing flow:
- EncounterCompletion_UpdatesWorkItemStatus: Verifies status changes from Pending to ReadyForReview
- EncounterCompletion_WorkItemHasProcedureCode: Verifies procedure code is set after processing
- EncounterCompletion_WorkItemHasServiceRequestId: Verifies ServiceRequestId is extracted from clinical bundle
- EncounterCompletion_WorkItemHasUpdatedAt: Verifies UpdatedAt timestamp is set

Includes a dedicated Alba bootstrap (EncounterProcessingAlbaBootstrap) that:
- Mocks FHIR services with clinical bundle containing service requests
- Provides null IConnectionMultiplexer to skip Redis storage in tests
- Inherits API key and Athena configuration patterns from existing bootstrap

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@rsalus rsalus force-pushed the feature/integration-patient-registry-polling branch from 7ab03c7 to 23062ab Compare February 2, 2026 04:46
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In `@apps/gateway/Gateway.API.Tests/Services/EncounterProcessorTests.cs`:
- Around line 458-540: The tests
ProcessAsync_AnalysisApprove_UpdatesWorkItemToReadyForReview,
ProcessAsync_AnalysisDeny_UpdatesWorkItemToReadyForReview,
ProcessAsync_AnalysisNeedsInfo_UpdatesWorkItemToMissingData, and
ProcessAsync_AnalysisNotRequired_UpdatesWorkItemToNoPaRequired are currently
hitting the fallback path because GetByIdAsync on _workItemStore is not mocked;
either mock _workItemStore.GetByIdAsync to return a valid WorkItem (so the
primary flow is exercised) or, if testing the fallback is intentional, rename
the tests to indicate the "WorkItemNotFound" fallback case; update the test
setup in each test (or SetupSuccessfulMocks) to call
_workItemStore.GetByIdAsync(...).Returns(Task.FromResult(workItem)) when
validating primary UpdateStatusAsync calls, or rename the test methods to
include "_WorkItemNotFound_FallsBack" to reflect the current behavior.
- Around line 430-456: The test currently triggers the fallback path because
_workItemStore.GetByIdAsync is not mocked and returns null; update the Arrange
section to mock _workItemStore.GetByIdAsync to return an existing WorkItem
(matching evt.WorkItemId) so the primary path in _sut.ProcessAsync executes and
calls UpdateAsync; then change the Assert to verify
_workItemStore.Received(1).UpdateAsync(...) was called with the expected updated
WorkItem (instead of asserting UpdateStatusAsync), keeping references to
ProcessAsync, _workItemStore.GetByIdAsync, _workItemStore.UpdateAsync, and the
work item id "workitem-abc".

In `@apps/gateway/Gateway.API/Endpoints/PatientEndpoints.cs`:
- Around line 49-87: RegisterAsync currently creates a WorkItem and
RegisteredPatient without validating request fields; add a guard clause at the
start of RegisterAsync to validate request.PatientId, request.EncounterId, and
request.PracticeId using string.IsNullOrWhiteSpace (or equivalent) and return a
BadRequest<string> (e.g., TypedResults.BadRequest) with a clear message naming
the missing/invalid field(s) if any validation fails; ensure this check occurs
before constructing WorkItem or calling workItemStore.CreateAsync and
patientRegistry.RegisterAsync so only valid inputs proceed.
🧹 Nitpick comments (9)
apps/gateway/Gateway.API.Tests/Services/AthenaQueryBuilderTests.cs (1)

14-14: Consider sealing the test class.

Per coding guidelines, types should be sealed by default. Test classes are rarely inherited.

-public class AthenaQueryBuilderTests
+public sealed class AthenaQueryBuilderTests
apps/gateway/Gateway.API.Tests/Contracts/IPatientRegistryTests.cs (1)

33-128: Remove parameter name assertions from signature tests.

While the project currently has no trimming/AOT enabled, parameter names aren't part of the public contract and asserting them couples tests to implementation details. Stick to type and arity assertions for stable, resilient tests.

♻️ Suggested change (apply similarly to other signature tests)
-        await Assert.That(parameters[0].Name).IsEqualTo("patient");
         await Assert.That(parameters[0].ParameterType).IsEqualTo(typeof(RegisteredPatient));
-        await Assert.That(parameters[1].Name).IsEqualTo("ct");
         await Assert.That(parameters[1].ParameterType).IsEqualTo(typeof(CancellationToken));
apps/gateway/Gateway.API/Services/InMemoryWorkItemStore.cs (1)

59-81: Retry loop may be ineffective under contention.

Unlike UpdateStatusAsync (which recomputes the update inside the loop), this method uses the caller-provided updated on every retry. If TryUpdate fails due to concurrent modification, subsequent retries will likely fail too because:

  1. The caller's updated was derived from a snapshot taken before calling this method
  2. On retry, existing is fresh but updated remains stale and won't match

For an MVP in-memory store with low contention this is acceptable, but the retry loop gives a false sense of resilience. Consider either:

  • Documenting that this performs a simple compare-and-swap (single attempt is semantically equivalent)
  • Accepting Func<WorkItem, WorkItem> to allow recomputation on retry (like UpdateStatusAsync does internally)
♻️ Alternative: Accept a transform function for true retry support
-    public Task<bool> UpdateAsync(string id, WorkItem updated, CancellationToken cancellationToken = default)
+    public Task<bool> UpdateAsync(string id, Func<WorkItem, WorkItem> transform, CancellationToken cancellationToken = default)
     {
         const int maxRetries = 10;

         for (var attempt = 0; attempt < maxRetries; attempt++)
         {
             if (!_store.TryGetValue(id, out var existing))
             {
                 return Task.FromResult(false);
             }

+            var updated = transform(existing);
             if (_store.TryUpdate(id, updated, existing))
             {
                 return Task.FromResult(true);
             }
         }

         return Task.FromResult(false);
     }
apps/gateway/Gateway.API.Tests/Services/InMemoryPatientRegistryTests.cs (1)

17-17: Shared registry instance may cause flaky tests.

The _registry field is initialized once and shared across all test methods. Since InMemoryPatientRegistry maintains state, tests like GetActiveAsync_ReturnsAllActivePatients (expecting exactly 2 patients) may fail depending on execution order—patients registered by other tests could still be active.

Consider recreating the registry per test via [SetUp] or using a local variable in each test method.

♻️ Proposed fix for test isolation
 public class InMemoryPatientRegistryTests
 {
-    private readonly InMemoryPatientRegistry _registry = new();
+    private InMemoryPatientRegistry _registry = null!;
+
+    [SetUp]
+    public void SetUp()
+    {
+        _registry = new InMemoryPatientRegistry();
+    }
apps/gateway/Gateway.API/Services/InMemoryPatientRegistry.cs (2)

36-44: Expired entries accumulate in memory.

GetActiveAsync filters out expired patients but doesn't remove them from the dictionary. For a dev/test implementation this may be acceptable, but long-running instances will accumulate stale entries.

Consider adding periodic cleanup or lazy eviction:

♻️ Optional: Add cleanup during GetActiveAsync
 public Task<IReadOnlyList<RegisteredPatient>> GetActiveAsync(CancellationToken ct = default)
 {
     var cutoff = DateTimeOffset.UtcNow - ExpirationTime;
-    var active = _patients.Values
-        .Where(p => p.RegisteredAt > cutoff)
-        .ToList();
+    var expired = _patients.Where(kvp => kvp.Value.RegisteredAt <= cutoff).Select(kvp => kvp.Key).ToList();
+    foreach (var key in expired)
+    {
+        _patients.TryRemove(key, out _);
+    }
+    var active = _patients.Values.ToList();
     return Task.FromResult<IReadOnlyList<RegisteredPatient>>(active);
 }

53-69: Race condition between read and update.

The TryGetValue followed by TryUpdate can fail if another thread modifies the entry between these calls. While returning false is safe, callers may not expect this behavior during normal operation.

Consider a retry loop similar to InMemoryWorkItemStore.UpdateAsync (per the relevant snippet showing max 10 retries):

♻️ Suggested retry pattern
 public Task<bool> UpdateAsync(string patientId, DateTimeOffset lastPolled, string status, CancellationToken ct = default)
 {
-    if (!_patients.TryGetValue(patientId, out var existing))
+    const int maxRetries = 10;
+    for (var attempt = 0; attempt < maxRetries; attempt++)
     {
-        return Task.FromResult(false);
-    }
+        if (!_patients.TryGetValue(patientId, out var existing))
+        {
+            return Task.FromResult(false);
+        }
 
-    var updated = existing with
-    {
-        LastPolledAt = lastPolled,
-        CurrentEncounterStatus = status
-    };
+        var updated = existing with
+        {
+            LastPolledAt = lastPolled,
+            CurrentEncounterStatus = status
+        };
 
-    var success = _patients.TryUpdate(patientId, updated, existing);
-    return Task.FromResult(success);
+        if (_patients.TryUpdate(patientId, updated, existing))
+        {
+            return Task.FromResult(true);
+        }
+    }
+    return Task.FromResult(false);
 }
apps/gateway/Gateway.API.Tests/Services/Polling/AthenaPollingServiceTests.cs (1)

78-106: Timing-based test may be flaky in CI.

The test relies on Task.Delay(180) with a 1-second polling interval to verify "at least once" behavior. Under CI load, timing can vary causing intermittent failures.

Consider using a TaskCompletionSource or similar synchronization primitive instead of fixed delays for more deterministic tests.

apps/gateway/Gateway.API/Endpoints/PatientEndpoints.cs (1)

96-103: Return type declares NotFound but it's never returned.

The signature Results<Ok, NotFound> suggests the endpoint may return 404, but the implementation always returns Ok(). Either simplify the return type to just Ok, or return NotFound when the patient wasn't actually registered (if UnregisterAsync should be non-idempotent).

♻️ Suggested fix for idempotent semantics (current behavior)
-    public static async Task<Results<Ok, NotFound>> UnregisterAsync(
+    public static async Task<Ok> UnregisterAsync(
         string patientId,
         [FromServices] IPatientRegistry patientRegistry,
         CancellationToken ct = default)
     {
         await patientRegistry.UnregisterAsync(patientId, ct).ConfigureAwait(false);
         return TypedResults.Ok();
     }
apps/gateway/Gateway.API.Tests/Services/InMemoryWorkItemStoreTests.cs (1)

256-338: Consider adding a concurrent update test.

The InMemoryWorkItemStore.UpdateAsync implementation includes retry logic (up to 10 retries with TryUpdate) to handle concurrent modifications. A test exercising concurrent updates would validate this behavior and prevent regressions in the thread-safety guarantees.

Fixes:
- P3 Major: 9 issues addressed

Changes:
- Add API-key filter to PatientEndpoints for PHI protection
- Add ProcedureCode guard in rehydrate endpoint (returns 400 if missing)
- Create RecommendationMapper utility for case-insensitive recommendation matching
- Handle UpdateAsync/UpdateStatusAsync failures before emitting notifications
- Fix SingleWriter=false for concurrent channel writes in AthenaPollingService
- Update processed-encounter tracking with early return and mark on completion
- Update docs to remove outdated accessToken references

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
docs/designs/2026-01-31-workitem-crud-preview.md (1)

559-593: ⚠️ Potential issue | 🟡 Minor

Test examples still reference obsolete RehydrateRequest with AccessToken.

The endpoint documentation at lines 144-146 states the rehydrate endpoint takes no request body, but the test examples here still construct a RehydrateRequest with WorkItemId and AccessToken properties and POST it as a JSON body. Update these test examples to align with the documented behavior (POST to the URL with no body).

🤖 Fix all issues with AI agents
In `@apps/gateway/Gateway.API.Tests/Services/EncounterProcessorTests.cs`:
- Around line 430-456: The test currently triggers the fallback path because
_workItemStore.GetByIdAsync isn't mocked; update the test so it exercises the
primary path by mocking _workItemStore.GetByIdAsync to return a WorkItem
instance and then assert that UpdateAsync (not just UpdateStatusAsync) on
_workItemStore is called after calling _sut.ProcessAsync(evt, ...);
alternatively, if you intend to test the fallback behavior, rename the test to
ProcessAsync_WorkItemNotFound_UpdatesStatusViaFallback and keep the current
assertions that UpdateStatusAsync("workitem-abc", WorkItemStatus.ReadyForReview,
...) is invoked.
🧹 Nitpick comments (2)
apps/gateway/Gateway.API/Endpoints/WorkItemEndpoints.cs (1)

51-56: Add .Produces<ErrorResponse>(StatusCodes.Status400BadRequest) metadata for the rehydrate endpoint.

The endpoint can now return 400 when ProcedureCode is missing (lines 92-101), but the endpoint metadata only declares 200 and 404 responses. Adding the 400 response improves OpenAPI documentation accuracy.

📝 Suggested metadata update
         // REHYDRATE
         group.MapPost("/{id}/rehydrate", RehydrateAsync)
             .WithName("RehydrateWorkItem")
             .WithSummary("Re-fetch clinical data and re-analyze work item")
             .Produces<RehydrateResponse>(StatusCodes.Status200OK)
-            .Produces<ErrorResponse>(StatusCodes.Status404NotFound);
+            .Produces<ErrorResponse>(StatusCodes.Status404NotFound)
+            .Produces<ErrorResponse>(StatusCodes.Status400BadRequest);
apps/gateway/Gateway.API/Services/Polling/AthenaPollingService.cs (1)

284-303: Simple status extraction helper.

The ExtractEncounterStatus method correctly navigates the FHIR bundle structure with null-safe property access. Consider adding a brief XML doc comment for consistency with the rest of the file.

@rsalus
Copy link
Contributor Author

rsalus commented Feb 2, 2026

@coderabbitai Full review.

@coderabbitai
Copy link

coderabbitai bot commented Feb 2, 2026

@rsalus I'll conduct a full review of all the changes in this PR.

✅ Actions performed

Full review triggered.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/gateway/Gateway.API/Endpoints/WorkItemEndpoints.cs (1)

51-56: ⚠️ Potential issue | 🟡 Minor

Add 400 response to endpoint metadata.
RehydrateAsync returns BadRequest on line 96 when ProcedureCode is missing, but the endpoint only declares 200 and 404 responses. Add .Produces<ErrorResponse>(StatusCodes.Status400BadRequest) to keep the OpenAPI contract complete.

Proposed update
 group.MapPost("/{id}/rehydrate", RehydrateAsync)
     .WithName("RehydrateWorkItem")
     .WithSummary("Re-fetch clinical data and re-analyze work item")
     .Produces<RehydrateResponse>(StatusCodes.Status200OK)
+    .Produces<ErrorResponse>(StatusCodes.Status400BadRequest)
     .Produces<ErrorResponse>(StatusCodes.Status404NotFound);
🤖 Fix all issues with AI agents
In
`@apps/gateway/Gateway.API.Tests/Integration/EncounterProcessingAlbaBootstrap.cs`:
- Around line 87-90: The test bootstrap registers a nullable generic type which
prevents resolving IConnectionMultiplexer and triggers static-analysis warnings;
update the registration after services.RemoveAll<IConnectionMultiplexer>() to
register the non-nullable service type (IConnectionMultiplexer) and supply a
null-forgiving value (null!) so DI keys match and warnings stop — change the
call to use services.AddSingleton<IConnectionMultiplexer>(...) (keeping the
factory or direct null-forgiving value) in EncounterProcessingAlbaBootstrap.cs.

In `@apps/gateway/Gateway.API.Tests/Services/InMemoryPatientRegistryTests.cs`:
- Around line 81-95: The test GetActiveAsync_ReturnsAllActivePatients assumes a
clean registry; ensure test isolation by resetting or recreating the registry
before arranging: either call a clear/reset method on _registry (e.g.,
_registry.ClearAsync()/Reset()) or instantiate a fresh InMemoryPatientRegistry
for this test so prior registrations don't affect results; keep using
CreatePatient and RegisterAsync as-is and then assert that
_registry.GetActiveAsync() returns exactly the two patients registered.
- Around line 15-17: The test class InMemoryPatientRegistryTests uses a shared
field _registry that preserves state across tests causing isolation failures
(e.g., GetActiveAsync_ReturnsAllActivePatients fails if
RegisterAsync_SamePatientTwice_OverwritesPrevious ran earlier); fix by
reinitializing the registry before each test — replace the readonly field usage
with a per-test setup that assigns _registry = new InMemoryPatientRegistry() in
a method annotated with the test framework's setup attribute (use the same
Before(Test) pattern used in AthenaPollingServiceTests.cs) so each test gets a
fresh instance.

In
`@apps/gateway/Gateway.API.Tests/Services/Polling/AthenaPollingServiceTests.cs`:
- Around line 79-105: The test method
ExecuteAsync_RespectsPollingInterval_CallsRegistryMultipleTimes is misleading
because the polling loop in PollForFinishedEncountersAsync() invokes a poll
immediately on StartAsync(_sut) and then waits the interval, so with the current
1s interval and ~180ms wait only one poll occurs; rename the test method to
something like ExecuteAsync_StartsPollingImmediately or
ExecuteAsync_CallsRegistryAtLeastOnce and update any references to that method
name to reflect the new intent (keep assertions and Arrange/Act/Assert
unchanged, referencing _sut.StartAsync, _patientRegistry.GetActiveAsync, and
_sut.StopAsync).

In `@apps/gateway/Gateway.API/Services/InMemoryPatientRegistry.cs`:
- Around line 54-68: UpdateAsync can return false spuriously under concurrent
updates because a single TryUpdate may fail; implement a retry loop similar to
InMemoryWorkItemStore.UpdateStatusAsync: repeatedly read the current entry from
_patients (use TryGetValue or indexer), build the updated record (as done with
existing with { ... }), and attempt _patients.TryUpdate(patientId, updated,
current) until TryUpdate succeeds or the patient no longer exists or the
CancellationToken is canceled; ensure you return true on success and false if
the patient disappears or cancellation occurs, and avoid infinite loops (respect
ct or include a reasonable retry limit).

In `@apps/gateway/Gateway.API/Services/Polling/AthenaPollingService.cs`:
- Around line 244-267: The code currently marks the encounter as processed
(writes to _processedEncounters inside lock) before calling
_encounterChannel.Writer.WriteAsync, which can silently drop the event if
WriteAsync fails; move the processed-marking to after WriteAsync completes
successfully: call await _encounterChannel.Writer.WriteAsync(evt,
ct).ConfigureAwait(false) first, then inside the lock set
_processedEncounters[patient.EncounterId] = DateTimeOffset.UtcNow; keep the
LogInformation and the subsequent await
_patientRegistry.UnregisterAsync(patient.PatientId, ct).ConfigureAwait(false)
unchanged so that failures from WriteAsync leave the encounter unmarked and
eligible for retry.
🧹 Nitpick comments (13)
apps/gateway/Gateway.API/Services/AthenaQueryBuilder.cs (1)

29-30: Add guard clauses + encode query values, and use a block body.

This prevents invalid requests when inputs are empty and avoids malformed query strings if values contain reserved characters.

♻️ Proposed change
-    public static string BuildEncounterQuery(string patientId, string encounterId, string practiceId)
-        => $"patient={patientId}&_id={encounterId}&ah-practice=Organization/a-1.Practice-{practiceId}";
+    public static string BuildEncounterQuery(string patientId, string encounterId, string practiceId)
+    {
+        if (string.IsNullOrWhiteSpace(patientId))
+        {
+            throw new ArgumentException("Patient ID is required.", nameof(patientId));
+        }
+
+        if (string.IsNullOrWhiteSpace(encounterId))
+        {
+            throw new ArgumentException("Encounter ID is required.", nameof(encounterId));
+        }
+
+        if (string.IsNullOrWhiteSpace(practiceId))
+        {
+            throw new ArgumentException("Practice ID is required.", nameof(practiceId));
+        }
+
+        var encodedPatientId = Uri.EscapeDataString(patientId);
+        var encodedEncounterId = Uri.EscapeDataString(encounterId);
+        var encodedPracticeId = Uri.EscapeDataString(practiceId);
+
+        return $"patient={encodedPatientId}&_id={encodedEncounterId}&ah-practice=Organization/a-1.Practice-{encodedPracticeId}";
+    }

As per coding guidelines: Guard clauses first, early return, no arrow code.

apps/gateway/Gateway.API.Tests/Services/AthenaQueryBuilderTests.cs (1)

14-14: Seal the test class by default.

♻️ Proposed change
-public class AthenaQueryBuilderTests
+public sealed class AthenaQueryBuilderTests

As per coding guidelines: Types sealed by default.

apps/gateway/Gateway.API/Contracts/IWorkItemStore.cs (1)

35-42: Good addition for full work item updates.

The method complements UpdateStatusAsync well for scenarios requiring atomic updates to multiple fields.

One consideration: the contract doesn't specify whether updated.Id must match the id parameter. While the current usage (via with expressions) preserves the Id, consider adding a note to the XML doc or enforcing this invariant in implementations to prevent accidental misuse.

📝 Optional: Clarify id consistency in documentation
     /// <summary>
     /// Updates a work item with new values.
     /// </summary>
     /// <param name="id">The work item ID.</param>
-    /// <param name="updated">The updated work item.</param>
+    /// <param name="updated">The updated work item. Must have the same Id as the <paramref name="id"/> parameter.</param>
     /// <param name="cancellationToken">Cancellation token.</param>
     /// <returns>True if update succeeded, false if work item not found.</returns>
apps/gateway/Gateway.API/Services/InMemoryWorkItemStore.cs (1)

59-81: Correct retry pattern, but consider validating id consistency.

The implementation correctly mirrors UpdateStatusAsync's atomic retry pattern. However, TryUpdate(id, updated, existing) stores the updated item at key id without verifying that updated.Id matches id. If they differ, the stored work item's Id property won't match its dictionary key, leading to inconsistent state.

While current callers use existingWorkItem with {...} (preserving the Id), adding a guard clause would make this defensive:

🛡️ Proposed defensive validation
 public Task<bool> UpdateAsync(string id, WorkItem updated, CancellationToken cancellationToken = default)
 {
+    if (updated.Id != id)
+    {
+        throw new ArgumentException($"Updated work item Id '{updated.Id}' must match the provided id '{id}'", nameof(updated));
+    }
+
     const int maxRetries = 10;

     for (var attempt = 0; attempt < maxRetries; attempt++)
apps/gateway/Gateway.API/Endpoints/WorkItemEndpoints.cs (1)

145-146: Avoid expression-bodied members here to align with “no arrow code”.
Prefer a block-bodied method for DetermineStatus to match the project’s C# style rules.

Style-aligned refactor
-private static WorkItemStatus DetermineStatus(PAFormData analysisResult) =>
-    RecommendationMapper.MapToStatus(analysisResult.Recommendation, analysisResult.ConfidenceScore);
+private static WorkItemStatus DetermineStatus(PAFormData analysisResult)
+{
+    return RecommendationMapper.MapToStatus(analysisResult.Recommendation, analysisResult.ConfidenceScore);
+}

As per coding guidelines, Control: Guard clauses first, early return, no arrow code.

apps/gateway/Gateway.API.Tests/Models/RegisterPatientResponseTests.cs (1)

13-26: Test provides minimal value for a record type.

This test only verifies that record properties retain their assigned values, which is guaranteed by the compiler for required init properties. Consider either removing this trivial test or expanding it to cover serialization/deserialization behavior if that's a concern for the API response.

apps/gateway/Gateway.API.Tests/Endpoints/PatientEndpointsTests.cs (1)

73-86: Minor: .Returns(Task.CompletedTask) is redundant.

NSubstitute returns Task.CompletedTask by default for methods returning Task. This line can be removed without affecting test behavior.

♻️ Simplification
         RegisteredPatient? capturedPatient = null;
-        _patientRegistry.RegisterAsync(Arg.Do<RegisteredPatient>(p => capturedPatient = p), Arg.Any<CancellationToken>())
-            .Returns(Task.CompletedTask);
+        _patientRegistry.RegisterAsync(Arg.Do<RegisteredPatient>(p => capturedPatient = p), Arg.Any<CancellationToken>());
apps/gateway/Gateway.API/Services/InMemoryPatientRegistry.cs (1)

23-26: Add guard clauses for invalid registration input.
Avoid null/empty patient IDs to prevent NREs and invalid dictionary keys.

✅ Suggested guard clauses
 public Task RegisterAsync(RegisteredPatient patient, CancellationToken ct = default)
 {
+    ArgumentNullException.ThrowIfNull(patient);
+    if (string.IsNullOrWhiteSpace(patient.PatientId))
+    {
+        throw new ArgumentException("PatientId is required.", nameof(patient));
+    }
     _patients[patient.PatientId] = patient;
     return Task.CompletedTask;
 }

As per coding guidelines: Control: Guard clauses first, early return, no arrow code.

apps/gateway/Gateway.API.Tests/Integration/EncounterProcessingIntegrationTests.cs (1)

34-34: Prefer block-bodied helper (no arrow code guideline).

🔧 Suggested change
-    private void AddApiKey(Scenario s) => s.WithRequestHeader(ApiKeyHeader, EncounterProcessingAlbaBootstrap.TestApiKey);
+    private void AddApiKey(Scenario s)
+    {
+        s.WithRequestHeader(ApiKeyHeader, EncounterProcessingAlbaBootstrap.TestApiKey);
+    }

As per coding guidelines: Control: Guard clauses first, early return, no arrow code.

apps/gateway/Gateway.API.Tests/Services/EncounterProcessorTests.cs (1)

393-399: Convert expression-bodied CreateEvent to block form.

🔧 Suggested change
-    private static EncounterCompletedEvent CreateEvent() => new()
-    {
-        PatientId = "patient-1",
-        EncounterId = "encounter-1",
-        PracticeId = "practice-1",
-        WorkItemId = "workitem-1"
-    };
+    private static EncounterCompletedEvent CreateEvent()
+    {
+        return new EncounterCompletedEvent
+        {
+            PatientId = "patient-1",
+            EncounterId = "encounter-1",
+            PracticeId = "practice-1",
+            WorkItemId = "workitem-1"
+        };
+    }

As per coding guidelines: Control: Guard clauses first, early return, no arrow code.

apps/gateway/Gateway.API.Tests/Models/EncounterCompletedEventTests.cs (1)

13-30: Test provides limited value but serves as documentation.

This test essentially verifies that record properties can be initialized and read—something the compiler guarantees. Consider expanding to test edge cases (e.g., equality semantics, with expressions) if the model evolves, or removing if test coverage budget is tight.

apps/gateway/Gateway.API/Services/EncounterProcessor.cs (1)

235-333: Consider removing the obsolete method or extracting shared logic.

The [Obsolete] annotation guides callers away, but ~100 lines of duplicate code remain. If removal is imminent, that's fine. If this will linger, consider extracting the common hydration/analysis/PDF-generation steps into a private helper to reduce maintenance burden.

apps/gateway/Gateway.API/Endpoints/PatientEndpoints.cs (1)

98-105: Return type includes NotFound but method always returns Ok.

The signature declares Results<Ok, NotFound>, but the implementation always returns Ok(). If this is intentional for idempotency (documented in the XML comment), simplify the return type to just Ok. Otherwise, if you plan to return NotFound when the patient doesn't exist, the logic needs updating.

♻️ Simplify return type if idempotent behavior is intentional
-    public static async Task<Results<Ok, NotFound>> UnregisterAsync(
+    public static async Task<Ok> UnregisterAsync(
         string patientId,
         [FromServices] IPatientRegistry patientRegistry,
         CancellationToken ct = default)
     {
         await patientRegistry.UnregisterAsync(patientId, ct).ConfigureAwait(false);
         return TypedResults.Ok();
     }

Major:
- Thread encounterId through FhirDataAggregator to scope ServiceRequest queries

Minor:
- Simplify UnregisterAsync return type from Results<Ok, NotFound> to Ok
- Convert AddApiKey helper to block form
- Convert CreateEvent helper to block form

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@apps/gateway/Gateway.API/Services/EncounterProcessor.cs`:
- Around line 79-108: AnalyzeAsync is always called with DefaultProcedureCode
causing incorrect analysis for non-default procedures; before calling
_intelligenceClient.AnalyzeAsync derive the procedure code from the encounter
bundle by selecting the first active ServiceRequest's procedure code (e.g.,
clinicalBundle.ServiceRequests.FirstOrDefault(sr => sr.Status ==
"active")?.Code) and fall back to DefaultProcedureCode if missing, then pass
that derivedCode into AnalyzeAsync; keep extracting ServiceRequestId the same
and ensure updatedWorkItem.ProcedureCode reflects the procedure code returned by
AnalyzeAsync (or the derived code if AnalyzeAsync doesn't override it).
🧹 Nitpick comments (1)
apps/gateway/Gateway.API.Tests/Services/EncounterProcessorTests.cs (1)

15-16: Consider sealing the test fixture to match project conventions.
This class doesn’t appear to be designed for inheritance; marking it sealed aligns with the “sealed by default” rule.

🔧 Suggested change
-public class EncounterProcessorTests
+public sealed class EncounterProcessorTests

As per coding guidelines: Types: Sealed by default, records for DTOs, nullable enabled.

rsalus and others added 2 commits February 1, 2026 22:39
- Seal AthenaQueryBuilderTests class
- Add id validation to InMemoryWorkItemStore.UpdateAsync
- Convert DetermineStatus to block form
- Remove redundant Returns(Task.CompletedTask)
- Add guard clauses to InMemoryPatientRegistry.RegisterAsync

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Extract procedure code from the first active ServiceRequest in the clinical
bundle, falling back to DefaultProcedureCode when not available. This ensures
correct analysis for non-default procedures.

Addresses: #20 (comment)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@rsalus
Copy link
Contributor Author

rsalus commented Feb 2, 2026

@coderabbitai If there are no remaining unaddressed critical/major issues, approve this PR.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@apps/gateway/Gateway.API.Tests/Endpoints/PatientEndpointsTests.cs`:
- Around line 73-74: The test's substitute setup uses Arg.Do<RegisteredPatient>
to capture the argument but doesn't return an awaited Task, risking race
conditions; update the _patientRegistry.RegisterAsync setup (the call using
Arg.Do<RegisteredPatient> and capturedPatient) to return a completed Task (e.g.,
append .Returns(Task.CompletedTask)) so the mocked RegisterAsync is awaitable
and the capturedPatient is reliably set before assertions.

In `@apps/gateway/Gateway.API/Services/EncounterProcessor.cs`:
- Around line 79-170: The PA analysis can return a null/empty
formData.ProcedureCode which currently overwrites the existing
WorkItem.ProcedureCode and sends null in the notification; change the update and
notification to fall back to the derived procedureCode variable when
formData.ProcedureCode is null or empty. Specifically, in EncounterProcessor.cs
where AnalyzeAsync returns formData, set the updatedWorkItem.ProcedureCode =
string.IsNullOrEmpty(formData.ProcedureCode) ? procedureCode :
formData.ProcedureCode and likewise set the Notification's ProcedureCode to the
same fallback value (use the local procedureCode and formData variables to
compute the finalProcedureCode before calling _workItemStore.UpdateAsync and
creating the statusChangeNotification).

@coderabbitai
Copy link

coderabbitai bot commented Feb 2, 2026

@rsalus I've reviewed the PR for critical and major issues. This is a well-engineered implementation with:

Strong Architecture

  • Clean separation of concerns with proper interfaces (IPatientRegistry, IEncounterProcessor, IWorkItemStore)
  • Dependency injection throughout
  • Per-patient polling architecture with controlled concurrency (max 5 workers)

Thread Safety

  • ConcurrentDictionary usage in registries and stores
  • Proper TryUpdate patterns with retry logic in UpdateAsync methods
  • Safe parallel processing with Parallel.ForEachAsync

Memory Management

  • 12-hour expiration window for patient registry
  • Periodic purging of processed encounters
  • Auto-unregistration of completed patients

Comprehensive Testing

  • 291 passing tests with 0 errors
  • Unit, integration, and contract test coverage
  • Alba-based end-to-end tests validating full workflows

Error Handling

  • Try-catch blocks with appropriate logging
  • Graceful degradation paths
  • Proper status code responses

API Design

  • RESTful endpoints with correct HTTP semantics
  • Proper validation and error responses
  • Idempotent operations where appropriate

No critical or major issues found. The code is production-ready with excellent test coverage and follows .NET best practices.

✅ Actions performed

Comments resolved and changes approved.

@rsalus rsalus merged commit eaa212a into main Feb 2, 2026
6 checks passed
@github-project-automation github-project-automation bot moved this from Todo to Done in Authscript Demo Feb 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

1 participant