Skip to content

refactor(gateway): Adopt aegis-api patterns for FHIR infrastructure#2

Merged
rsalus merged 30 commits intomainfrom
feature/integration-gateway-fhir-refactor
Jan 27, 2026
Merged

refactor(gateway): Adopt aegis-api patterns for FHIR infrastructure#2
rsalus merged 30 commits intomainfrom
feature/integration-gateway-fhir-refactor

Conversation

@rsalus
Copy link
Contributor

@rsalus rsalus commented Jan 27, 2026

Summary

Refactors Gateway.API to adopt proven patterns from aegis-api, establishing a stable foundation for the prior authorization demo. Standardizes error handling with Result<T> pattern, replaces System.Text.Json with Hl7.Fhir.Serialization, adds centralized HTTP client management with resilience policies, and implements strongly-typed configuration.

Changes

  • Abstractions — Add Result, Error, ErrorType, ErrorFactory for railway-oriented error handling
  • FHIR Serialization — Replace System.Text.Json with Hl7.Fhir.Serialization via IFhirSerializer
  • HTTP Infrastructure — Add IHttpClientProvider with client credentials flow and token caching
  • Resilience — Configure IHttpClientFactory with AddStandardResilienceHandler() policies
  • Configuration — Add EpicFhirOptions, IntelligenceOptions, ResiliencyOptions
  • Service Migration — Update all interfaces to return Result, remove accessToken parameters
  • Endpoints — Use Result.Match() for HTTP response mapping

Test Plan

Added 84 new unit tests covering all new components. Full test suite (94 tests) passes with comprehensive coverage of Result pattern, FHIR serialization, HTTP client provider, and service implementations.


Results: Tests 94 ✓ · Build 0 errors
Design: docs/designs/2026-01-26-gateway-fhir-refactor.md

rsalus and others added 21 commits January 26, 2026 16:51
Add foundational Result<T> type for railway-oriented error handling.
Includes Error record and ErrorType enum as dependencies.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add comprehensive tests for Error record and ErrorType enum.
Tests verify HTTP status code mappings and default values.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add static factory class for creating common error types.
Provides NotFound, Validation, Unauthorized, Infrastructure,
and Unexpected error creation methods with proper defaults.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add domain-specific error types for FHIR operations.
Includes static errors for ServiceUnavailable, Timeout,
and AuthenticationFailed, plus factory methods for
NotFound, InvalidResponse, and NetworkError.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add strongly-typed configuration options for Epic FHIR API connectivity
including FhirBaseUrl, ClientId, ClientSecret, and TokenEndpoint.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add strongly-typed configuration options for Intelligence service
connectivity including BaseUrl and TimeoutSeconds with a default of 30.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add HTTP client provider interface and initial test for unauthenticated
client scenario. Also adds configuration options classes for Epic FHIR,
Intelligence, and Resiliency settings.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add strongly-typed configuration options for HTTP resilience policies
including MaxRetryAttempts, RetryDelaySeconds, TimeoutSeconds,
CircuitBreakerThreshold, and CircuitBreakerDurationSeconds.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Implements the IHttpClientProvider interface with client credentials
flow authentication and token caching. Tokens are cached until 60
seconds before expiry.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Adds extension method to configure all Gateway services including:
- Configuration options binding (Epic, Intelligence, Resiliency)
- IFhirSerializer for FHIR serialization
- IHttpClientProvider for authenticated HTTP clients
- Named HttpClients with standard resilience handlers

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add FHIR JSON serialization abstraction using Hl7.Fhir.Serialization.
IFhirSerializer provides Serialize, Deserialize, and DeserializeBundle
methods for type-safe FHIR resource handling.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Replace System.Text.Json with IFhirSerializer for FHIR resource
serialization. Add Resource type constraint to FHIR interfaces
(IFhirContext, IFhirRepository) ensuring type-safe FHIR operations.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Interface methods now return Result<T> instead of nullable types
- Remove accessToken parameters (handled by IHttpClientProvider)
- Add proper XML documentation for all methods
- Update IFhirContext and IFhirRepository imports for new Result type
- Remove duplicate Result.cs from Contracts (use Abstractions.Result<T>)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Inject IHttpClientProvider and IFhirSerializer
- Return Result<T> from all methods with proper error mapping
- Map FHIR models to info DTOs with complete extraction logic
- Add comprehensive tests covering success, failure, and edge cases
- Update EpicFhirContext to use FhirErrors for consistent error handling

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- IFhirDataAggregator: Return Result<ClinicalBundle>, remove accessToken
- IIntelligenceClient: Return Result<PAFormData> with error handling
- IEpicUploader: Return Result<string>, inject IHttpClientProvider
- All implementations propagate errors via Result pattern
- Use parallel FHIR fetches with partial success for aggregator

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Use Result.Match() for HTTP response mapping in SubmitToEpicAsync
- Update CdsHooksEndpoints to check Result success/failure
- Remove AccessToken from SubmitToEpicRequest (auth handled internally)
- Update endpoint tests to use Result-returning mock setup

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

Update Program.cs to use AddGatewayServices() instead of individual service
registrations. Add business service registrations (EpicFhirClient,
FhirDataAggregator, IntelligenceClient, EpicUploader, PdfFormStamper,
DemoCacheService) to the centralized extension method with comprehensive
integration tests.

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

coderabbitai bot commented Jan 27, 2026

📝 Walkthrough

Walkthrough

Refactors Gateway.API toward a FHIR-first architecture: adds Result/Error abstractions, new FHIR HTTP and uploader services, replaces caching with an analysis result store, removes CDS Hooks and related models, stubs Intelligence/PDF flows, introduces Docker build assets and setup scripts, and updates DI, tests, and configs.

Changes

Cohort / File(s) Summary
Docker & Orchestration
\.dockerignore, apps/dashboard/Dockerfile, apps/dashboard/Dockerfile.build, orchestration/AuthScript.AppHost/AppHost.cs
Add root .dockerignore; adjust dashboard Dockerfile to use package.json + npm install; add Dockerfile.build (multi-stage build → nginx runtime); AppHost updated to reference apps/dashboard/Dockerfile.build.
VCS ignore
.gitignore
Add .worktrees/ to ignore workflow/worktree state.
DI / Composition
apps/gateway/Gateway.API/DependencyExtensions.cs, apps/gateway/Gateway.API/Program.cs, apps/gateway/Gateway.API/Gateway.API.csproj
New DI extension methods (AddGatewayServices, AddFhirClients, AddIntelligenceClient); Program uses them; add package refs (Scrutor, Microsoft.Extensions.Caching.Hybrid).
Abstractions / Result Model
apps/gateway/Gateway.API/Abstractions/*
Error.cs, ErrorFactory.cs, ErrorType.cs, Result.cs
Add Error record (with optional Inner), ErrorFactory, ErrorType enum, and generic Result<T> (readonly record struct) with Match/Map and implicit conversions.
FHIR errors & tests
apps/gateway/Gateway.API/Errors/FhirErrors.cs, apps/gateway/Gateway.API.Tests/Errors/FhirErrorsTests.cs
Add FhirErrors catalog and unit tests covering codes, types, messages, and inner-exception propagation.
Configuration objects
apps/gateway/Gateway.API/Configuration/*
Add EpicFhirOptions, IntelligenceOptions, ResiliencyOptions, CachingSettings, ClinicalQueryOptions, DocumentOptions (defaults and simple IsValid helpers).
Contracts / Interfaces
apps/gateway/Gateway.API/Contracts/*
Rename/replace IEpicFhirClient→IFhirClient, IEpicUploader→IDocumentUploader (UploadDocumentAsync → Task<Result<string>>), IDemoCacheService→IAnalysisResultStore; add low-level IFhirHttpClient returning Result<T>.
FHIR HTTP & high-level client
apps/gateway/Gateway.API/Services/Fhir/*
Add FhirHttpClient (low‑level Result-wrapped HTTP ops) and FhirClient (JSON→domain mapping, uses IFhirHttpClient).
Document upload & result storage
apps/gateway/Gateway.API/Services/DocumentUploader.cs, apps/gateway/Gateway.API/Services/AnalysisResultStore.cs
Add DocumentUploader implementing IDocumentUploader (returns Result<string>); rename/refactor DemoCacheService→AnalysisResultStore and change Redis key prefix and storage semantics.
Endpoints & models (Analysis)
apps/gateway/Gateway.API/Endpoints/AnalysisEndpoints.cs, apps/gateway/Gateway.API/Models/AnalysisResponses.cs
Rename SubmitToEpic→SubmitToFhir, switch to IDocumentUploader/IAnalysisResultStore, adapt to Result-based flows and updated request/response types.
CDS Hooks removal
apps/gateway/Gateway.API/Endpoints/CdsHooksEndpoints.cs, apps/gateway/.../Cds* models, shared/types/src/cds.ts, shared/types/src/__tests__/*
Remove CDS Hooks endpoints and all associated CDS model files and shared-types exports/tests.
Intelligence & reasoning (stubs)
apps/intelligence/src/*, apps/intelligence/openapi.json, tests
Replace intelligence/evidence/form-generation with deterministic stubs (always APPROVE); rename policy to EXAMPLE_POLICY; update tests and OpenAPI descriptions.
Service stubs & caching decorator
apps/gateway/Gateway.API/Services/IntelligenceClient.cs, PdfFormStamper.cs, .../Decorators/CachingIntelligenceClient.cs
Stub IntelligenceClient (no HttpClient), stub PdfFormStamper (returns empty bytes), add CachingIntelligenceClient decorator using HybridCache and CachingSettings.
FHIR upload & helpers
apps/gateway/Gateway.API/Services/DocumentUploader.cs, FhirHttpClient.cs, FhirClient.cs
New implementations for building DocumentReference payloads, posting to FHIR, reading binaries, and mapping JSON to domain types with Result-wrapped error handling.
Tests: Abstractions, Config, FHIR client, Endpoints
apps/gateway/Gateway.API.Tests/Abstractions/*, .../Configuration/*, .../Services/FhirClientTests.cs, .../Endpoints/AnalysisEndpointsTests.cs
Add unit tests for Error/Result/options and FHIR client parsing; update analysis endpoint tests to reflect new interfaces, Result handling, and DocumentUploader/AnalysisResultStore usage.
Scripts & docs
scripts/setup.sh, docs/designs/..., docs/plans/..., dashboard-*.txt
Add dotnet user-secrets setup script; add design/plan docs for the refactor; add Docker build log excerpt.
Misc / shared types index
shared/types/src/index.ts, shared/types/src/__tests__/*
Remove re-export of CDS types and delete related unit tests.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant API as AnalysisEndpoints
  participant ResultStore as IAnalysisResultStore
  participant Intelligence as IIntelligenceClient
  participant PdfStamper as IPdfFormStamper
  participant Uploader as IDocumentUploader

  Client->>API: POST /submit (SubmitToFhirRequest)
  API->>ResultStore: GetCachedResponseAsync(transactionId)
  alt cached
    ResultStore-->>API: cached SubmitResponse / pdfBytes
  else not cached
    API->>Intelligence: AnalyzeAsync(clinicalBundle, procedureCode)
    Intelligence-->>API: PAFormData (stub)
    API->>PdfStamper: StampFormAsync(PAFormData)
    PdfStamper-->>API: pdfBytes
    API->>Uploader: UploadDocumentAsync(pdfBytes, patientId, accessToken)
    Uploader-->>API: Result<string> (documentId)
    API->>ResultStore: SetCachedPdfAsync(transactionId, pdfBytes)
  end
  API-->>Client: 200 SubmitResponse (DocumentId / message)
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Poem

✨ FHIR winds reshape the gateway's art,
Errors folded into Result from the start,
CDS pages fall, new uploads arise,
Stubs hum along while the architecture flies,
A clearer path for data to chart.

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 39.71% 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 title clearly summarizes the primary change: adopting aegis-api patterns for FHIR infrastructure refactoring in the Gateway service.
Description check ✅ Passed The description is well-structured, relating directly to the changeset. It outlines the key refactoring objectives (error handling, serialization, HTTP infrastructure, configuration, service migration) and provides test results.

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

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feature/integration-gateway-fhir-refactor

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

Caution

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

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

118-129: Avoid returning a synthetic ID when the response is invalid.

If the response body is missing id or isn’t valid JSON, the current code returns a new GUID and treats the upload as success. That can silently create a non-existent DocumentReference ID and break downstream workflows. Treat this as an invalid response instead.

🩹 Proposed fix
-    private async Task<Result<string>> ExtractDocumentIdAsync(HttpResponseMessage response, CancellationToken ct)
-    {
-        var responseJson = await response.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: ct);
-
-        var documentId = responseJson.TryGetProperty("id", out var id)
-            ? id.GetString()
-            : Guid.NewGuid().ToString();
-
-        _logger.LogInformation("Document uploaded successfully. DocumentId={DocumentId}", documentId);
-
-        return documentId!;
-    }
+    private async Task<Result<string>> ExtractDocumentIdAsync(HttpResponseMessage response, CancellationToken ct)
+    {
+        try
+        {
+            var responseJson = await response.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: ct);
+            if (responseJson.ValueKind != JsonValueKind.Object ||
+                !responseJson.TryGetProperty("id", out var id) ||
+                string.IsNullOrWhiteSpace(id.GetString()))
+            {
+                _logger.LogError("FHIR response missing DocumentReference id.");
+                return FhirErrors.InvalidResponse("DocumentReference id missing in response payload.");
+            }
+
+            var documentId = id.GetString()!;
+            _logger.LogInformation("Document uploaded successfully. DocumentId={DocumentId}", documentId);
+            return documentId;
+        }
+        catch (JsonException ex)
+        {
+            _logger.LogError(ex, "Failed to parse DocumentReference response.");
+            return FhirErrors.InvalidResponse("Unable to parse DocumentReference response JSON.");
+        }
+        catch (InvalidOperationException ex)
+        {
+            _logger.LogError(ex, "Unexpected DocumentReference response shape.");
+            return FhirErrors.InvalidResponse("DocumentReference response payload is not a JSON object.");
+        }
+    }

47-98: Use Hl7.Fhir.Model.DocumentReference with IFhirSerializer to align with FHIR patterns.

This code constructs a DocumentReference as an anonymous object and serializes with System.Text.Json, sidestepping the IFhirSerializer abstraction and Firely SDK validation. Per coding guidelines, FHIR resources must use Firely SDK patterns. Construct Hl7.Fhir.Model.DocumentReference instead and serialize via the injected IFhirSerializer for consistency with EpicFhirClient and proper FHIR validation.

🤖 Fix all issues with AI agents
In `@apps/gateway/Gateway.API.Tests/Integration/DependencyInjectionTests.cs`:
- Around line 18-25: The test creates a ServiceProvider via
CreateServiceProvider() but never disposes it; modify
AddGatewayServices_CanResolveIFhirSerializer to obtain the provider with a using
declaration (e.g., using var provider = CreateServiceProvider()) so the
ServiceProvider (IDisposable) is disposed after the test, then resolve
IFhirSerializer from that scoped provider and assert not null; apply the same
using-var pattern to the other test methods that call CreateServiceProvider() to
ensure proper disposal of HTTP clients and handlers.

In `@apps/gateway/Gateway.API/Extensions/ServiceCollectionExtensions.cs`:
- Around line 52-63: Duplicate resilience handlers are being added for the
"Intelligence" client: the named client registration calls
AddStandardResilienceHandler() and the typed registration
services.AddHttpClient<IIntelligenceClient, IntelligenceClient>("Intelligence")
re-applies AddStandardResilienceHandler(), causing stacked policies; remove the
second AddStandardResilienceHandler() call from the typed registration so the
typed client reuses the named client's pipeline (i.e., keep
AddStandardResilienceHandler() on the initial AddHttpClient("Intelligence", ...)
only and delete the .AddStandardResilienceHandler() on the
services.AddHttpClient<IIntelligenceClient, IntelligenceClient>("Intelligence")
chain).
- Around line 44-50: The current AddHttpClient registration uses
options!.FhirBaseUrl which can throw if the EpicFhirOptions section is missing;
change it to defensively resolve the options and fail fast with a clear
exception or use the options pattern with validation: either replace the inline
Get<EpicFhirOptions>() call inside AddHttpClient("EpicFhir", ...) with a
null-check (e.g. obtain options and throw InvalidOperationException if null or
FhirBaseUrl is null/empty) referencing EpicFhirOptions.SectionName and
options.FhirBaseUrl, or register EpicFhirOptions via
services.Configure<EpicFhirOptions>(configuration.GetSection(EpicFhirOptions.SectionName)).ValidateDataAnnotations().ValidateOnStart()
and inject IOptions<EpicFhirOptions>/IOptionsMonitor<EpicFhirOptions> into the
AddHttpClient factory to read a guaranteed-valid FhirBaseUrl.

In `@apps/gateway/Gateway.API/Services/EpicFhirClient.cs`:
- Around line 362-372: MapHttpError is calling FhirErrors.NotFound(operation,
"search") which yields messages like "Condition search/search not found"; change
the logic so search operations produce an endpoint-not-found error instead of a
resource-not-found error. Inside MapHttpError, detect when the operation
represents a search (e.g., ends with "search" or equals "search") and call
FhirErrors.NotFound with a resourceType of "Endpoint" (or otherwise construct a
not-found error describing the endpoint) rather than passing the full operation
as resourceType and "search" as id; keep other status cases unchanged.
- Around line 81-84: DeserializeBundle can return null for malformed JSON and
MapConditions currently treats null as empty results; update the search methods
to detect a null bundle after calling _fhirSerializer.DeserializeBundle(json)
and surface an error instead of returning an empty list. Specifically, in the
method shown (and analogously in SearchObservationsAsync, SearchProceduresAsync,
and SearchDocumentsAsync) add a null check for the bundle returned by
DeserializeBundle and throw or propagate a descriptive exception (including the
raw json or response status/context) so parse failures are not silently
converted into "no results". Ensure the exception path uses the same
logging/exception patterns as the surrounding codebase.

In `@apps/gateway/Gateway.API/Services/Http/HttpClientProvider.cs`:
- Around line 18-19: The _cachedToken and _tokenExpiry fields are not
synchronized causing race conditions; add a private SemaphoreSlim (e.g.,
_tokenLock) and use it in GetOrRefreshTokenAsync to serialize access: await
_tokenLock.WaitAsync() in a try/finally, check _cachedToken and _tokenExpiry
inside the lock, refresh the token only if expired or null, update both fields
while locked, then Release() in finally; ensure all paths that read/write these
fields use the same semaphore to prevent concurrent refreshes.

In `@dashboard-emtjzpqk-20260126042446.txt`:
- Around line 1-36: This file is a transient CI build log and should be removed
from the repo; delete dashboard-emtjzpqk-20260126042446.txt from the tree,
commit the removal (use git rm or git rm --cached if you need to preserve a
local copy), add a rule to .gitignore to prevent re-adding similar *.txt CI
logs, and configure your CI to store such logs as build artifacts instead of
committing them; if the file is already merged and must be purged from history,
use a history-rewrite tool (git filter-repo or BFG) to remove it from past
commits.

In `@docs/designs/2026-01-26-gateway-fhir-refactor.md`:
- Around line 32-70: The fenced code block that shows the project tree (starting
with "Gateway.API/") is missing a language identifier, triggering MD040; change
the opening fence from ``` to ```text so the block becomes a text code block
(leave the closing fence as ```), ensuring markdown linters and renderers
correctly treat it as plain text.

In `@scripts/setup.sh`:
- Around line 101-114: The summary step fails if grep finds no matches due to
set -euo pipefail; update the pipeline that starts with `dotnet user-secrets
list | grep -E "llm-provider|github-token|azure-openai|google-api" | while read
-r line; do` so the command never returns non-zero when there are no matches
(e.g., make grep tolerant by appending a safe fallback like `|| true`, or use
process substitution to feed the while loop from a subshell), keeping the
existing masking logic that uses `key`, `value`, and the while-read loop
unchanged.
🧹 Nitpick comments (14)
scripts/setup.sh (1)

42-45: Validate LLM_PROVIDER against allowed values.

Right now any value is accepted, which can leave user-secrets in an invalid state and cause downstream failures. Add a simple whitelist check to fail fast.
[Line 42]

♻️ Suggested guard
 LLM_PROVIDER="${LLM_PROVIDER:-github}"
+
+case "$LLM_PROVIDER" in
+  github|azure|gemini|openai) ;;
+  *)
+    error "Invalid LLM_PROVIDER: '$LLM_PROVIDER' (expected github|azure|gemini|openai)"
+    exit 1
+    ;;
+esac
apps/gateway/Gateway.API/Contracts/Http/IHttpClientProvider.cs (1)

6-16: Consider returning Result<HttpClient> instead of HttpClient?.

This would align with the new Result pattern and preserve failure context while avoiding null checks at call sites.

♻️ Suggested signature update
-    Task<HttpClient?> GetAuthenticatedClientAsync(
+    Task<Result<HttpClient>> GetAuthenticatedClientAsync(
         string clientName,
         CancellationToken cancellationToken = default);
apps/gateway/Gateway.API.Tests/Configuration/ResiliencyOptionsTests.cs (1)

21-25: Static analysis warning: Asserting on a constant value.

The analyzer correctly flags that ResiliencyOptions.SectionName is a compile-time constant. While this test guards against accidental changes to the constant, it's essentially a tautology at runtime.

Consider using a configuration-binding integration test instead, or suppress the warning with a comment explaining the intent if you want to keep this as a "change detector" test.

apps/gateway/Gateway.API.Tests/Configuration/IntelligenceOptionsTests.cs (2)

22-24: Consider disposing the ServiceProvider.

The ServiceProvider implements IDisposable. While test runners typically clean up, explicitly disposing is good practice to ensure deterministic cleanup of any registered services.

♻️ Suggested fix
         var services = new ServiceCollection();
         services.Configure<IntelligenceOptions>(config.GetSection("Intelligence"));
-        var provider = services.BuildServiceProvider();
+        using var provider = services.BuildServiceProvider();

44-48: Static analysis warning: Asserting on a constant value.

Same issue as in ResiliencyOptionsTestsIntelligenceOptions.SectionName is a compile-time constant. The test serves as a change detector but the analyzer flags it as redundant.

apps/gateway/Gateway.API/Services/Fhir/FhirSerializer.cs (1)

49-61: DeserializeBundle could delegate to Deserialize.

DeserializeBundle duplicates the logic in Deserialize<T>. Consider simplifying:

♻️ Optional simplification
 public Bundle? DeserializeBundle(string json)
 {
-    if (string.IsNullOrWhiteSpace(json)) return null;
-    try
-    {
-        return s_parser.Parse<Bundle>(json);
-    }
-    catch (Exception ex)
-    {
-        _logger.LogWarning(ex, "Failed to deserialize Bundle");
-        return null;
-    }
+    return Deserialize<Bundle>(json);
 }
apps/gateway/Gateway.API/Services/Http/HttpClientProvider.cs (2)

76-77: Silent fallback for missing client secret.

Defaulting ClientSecret to an empty string when null may mask configuration errors. Consider logging a warning or failing fast if a token endpoint is configured but credentials are incomplete.

♻️ Proposed validation
+            if (string.IsNullOrEmpty(_epicOptions.ClientSecret))
+            {
+                _logger.LogWarning("Client secret not configured for token endpoint {Endpoint}", _epicOptions.TokenEndpoint);
+            }
+
             var content = new FormUrlEncodedContent(new Dictionary<string, string>
             {
                 ["grant_type"] = "client_credentials",
                 ["client_id"] = _epicOptions.ClientId,
-                ["client_secret"] = _epicOptions.ClientSecret ?? ""
+                ["client_secret"] = _epicOptions.ClientSecret ?? string.Empty
             });

42-47: Consider logging the client name for observability.

When returning an unauthenticated client, including the client name in the log would aid debugging multi-client scenarios.

♻️ Enhanced logging
-            _logger.LogDebug("No token endpoint configured, returning unauthenticated client");
+            _logger.LogDebug("No token endpoint configured, returning unauthenticated client for {ClientName}", clientName);
apps/gateway/Gateway.API.Tests/Services/Http/HttpClientProviderTests.cs (1)

79-113: Consider adding a test for token refresh after expiry.

The caching test verifies the token is reused within expiry, but there's no test confirming a new token is acquired after expiry. This would validate the full token lifecycle.

Would you like me to draft a test that manipulates the expiry scenario?

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

143-165: Clean and reusable MockHttpMessageHandler.

The sealed nested class is appropriately scoped to the test class and provides a simple way to simulate HTTP responses. Consider extracting this to a shared test utilities location if other test classes need similar functionality.

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

52-67: Consider logging partial resource failures.

When conditionsResult, observationsResult, proceduresResult, or documentsResult fail, they silently default to empty lists. This partial-success behavior is appropriate, but logging these failures would aid debugging when aggregated data appears incomplete.

♻️ Suggested improvement
         // Patient is required - if it fails, propagate the error
         if (patientResult.IsFailure)
         {
             return patientResult.Error!;
         }

+        // Log any partial failures for observability
+        if (conditionsResult.IsFailure)
+            _logger.LogWarning("Failed to fetch conditions: {Error}", conditionsResult.Error!.Message);
+        if (observationsResult.IsFailure)
+            _logger.LogWarning("Failed to fetch observations: {Error}", observationsResult.Error!.Message);
+        if (proceduresResult.IsFailure)
+            _logger.LogWarning("Failed to fetch procedures: {Error}", proceduresResult.Error!.Message);
+        if (documentsResult.IsFailure)
+            _logger.LogWarning("Failed to fetch documents: {Error}", documentsResult.Error!.Message);
+
         // Other resources use default empty lists on failure (partial success)
         var bundle = new ClinicalBundle
apps/gateway/Gateway.API/Services/IntelligenceClient.cs (1)

78-106: Consider catching JsonException and TaskCanceledException for comprehensive error handling.

Currently only HttpRequestException is caught. If the Intelligence service returns malformed JSON, ReadFromJsonAsync will throw JsonException. Similarly, TaskCanceledException from timeout scenarios would propagate uncaught.

♻️ Suggested improvement
         catch (HttpRequestException ex)
         {
             _logger.LogError(ex, "Network error calling Intelligence service");
             return ErrorFactory.Infrastructure($"Intelligence service unavailable: {ex.Message}", ex);
         }
+        catch (TaskCanceledException ex) when (!ct.IsCancellationRequested)
+        {
+            _logger.LogError(ex, "Timeout calling Intelligence service");
+            return ErrorFactory.Infrastructure("Intelligence service request timed out", ex);
+        }
+        catch (System.Text.Json.JsonException ex)
+        {
+            _logger.LogError(ex, "Invalid response from Intelligence service");
+            return ErrorFactory.Infrastructure("Intelligence service returned invalid response format", ex);
+        }
apps/gateway/Gateway.API/Services/Fhir/EpicFhirContext.cs (1)

163-174: Consider using pattern matching in the Select clause for clarity.

The filter-then-cast pattern is safe but slightly redundant. Using OfType<TResource>() would be more idiomatic.

Suggested refinement
     private IReadOnlyList<TResource> ExtractResourcesFromBundle(Bundle? bundle)
     {
         if (bundle?.Entry is null)
         {
             return [];
         }

-        return bundle.Entry
-            .Where(e => e.Resource is TResource)
-            .Select(e => (TResource)e.Resource)
-            .ToList();
+        return bundle.Entry
+            .Select(e => e.Resource)
+            .OfType<TResource>()
+            .ToList();
     }
apps/gateway/Gateway.API/Services/EpicFhirClient.cs (1)

241-250: Generating synthetic GUIDs for missing IDs may cause downstream issues.

When a resource lacks an Id, a new GUID is generated. This could cause problems with caching, deduplication, or audit trails since repeated fetches produce different IDs for the same resource. Consider logging a warning when this fallback is used.

Suggested improvement
+    // Inject ILogger<EpicFhirClient> _logger in constructor (already present)
+
     // In MapConditions and similar methods, when Id is missing:
-    Id = condition.Id ?? Guid.NewGuid().ToString(),
+    Id = condition.Id ?? GenerateSyntheticId(condition),

+    private string GenerateSyntheticId(Resource resource)
+    {
+        var syntheticId = Guid.NewGuid().ToString();
+        _logger.LogWarning(
+            "Resource of type {ResourceType} missing Id, generated synthetic: {SyntheticId}",
+            resource.TypeName, syntheticId);
+        return syntheticId;
+    }

Note: This would require the Map methods to be non-static, or the logger to be passed as a parameter.

- Rename Epic-prefixed interfaces to vendor-agnostic names:
  - IEpicFhirClient → IFhirClient
  - IEpicUploader → IDocumentUploader
  - IDemoCacheService → IAnalysisResultStore

- Add low-level IFhirHttpClient for HTTP transport layer
- Refactor FhirClient to use IFhirHttpClient for HTTP operations
- Add HybridCache with Scrutor decorator pattern (CachingIntelligenceClient)
- Extract configuration to Options-pattern classes:
  - CachingSettings
  - ClinicalQueryOptions
  - DocumentOptions
- Consolidate DI registration in ServiceCollectionExtensions
- Simplify Intelligence layer to stub (always returns APPROVE)
- Remove unused Http/Fhir abstractions (IHttpClientProvider, IFhirSerializer)

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: 4

Caution

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

⚠️ Outside diff range comments (5)
apps/gateway/Gateway.API/Models/AnalysisResponses.cs (1)

112-130: Avoid passing OAuth tokens in request bodies

AccessToken in the request payload risks accidental logging/caching and weakens security posture. Prefer Authorization headers (or server-side client credentials) and remove the token from the DTO.

🔒 Proposed model change
 public sealed record SubmitToFhirRequest
 {
     /// <summary>
     /// Gets the FHIR Patient resource ID.
     /// </summary>
     public required string PatientId { get; init; }

     /// <summary>
     /// Gets the optional FHIR Encounter resource ID for context.
     /// </summary>
     public string? EncounterId { get; init; }

-    /// <summary>
-    /// Gets the OAuth access token for FHIR authentication.
-    /// </summary>
-    public required string AccessToken { get; init; }
 }
apps/gateway/Gateway.API/Services/FhirClient.cs (2)

318-323: Bug: ExtractClinicalStatus incorrectly uses ExtractFirstCoding.

The method passes the clinicalStatus CodeableConcept directly to ExtractFirstCoding with property name "coding", but ExtractFirstCoding expects an element containing a CodeableConcept as a named property. This causes a double lookup: status["coding"]["coding"], which will always fail.

🐛 Proposed fix

Either inline the extraction or adjust the call:

 private static string? ExtractClinicalStatus(JsonElement resource)
 {
     if (!resource.TryGetProperty("clinicalStatus", out var status)) return null;
-    var coding = ExtractFirstCoding(status, "coding");
-    return coding?.code;
+    if (!status.TryGetProperty("coding", out var codings)) return null;
+    
+    foreach (var coding in codings.EnumerateArray())
+    {
+        if (coding.TryGetProperty("code", out var code))
+        {
+            return code.GetString();
+        }
+    }
+    return null;
 }

87-95: Generating random IDs for resources without id could cause downstream issues.

If a FHIR resource is missing its id (which shouldn't happen for server-returned resources), generating a random GUID masks the data quality problem and could cause cache key collisions or lookup failures. Consider logging a warning when this fallback is triggered to surface the underlying issue.

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

38-63: CancellationToken parameter is accepted but never used.

StackExchange.Redis's StringGetAsync/StringSetAsync don't accept CancellationToken. Accepting the parameter without using it could mislead callers into thinking cancellation is supported. Either remove the parameter or document the limitation.

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

208-250: Add guard clauses for missing or invalid submit request.

The method accesses request.PatientId, request.EncounterId, and request.AccessToken without validation. While PatientId and AccessToken are marked required, explicit guard clauses are required per the C# coding guidelines (guard clauses first, early return). Without them, a missing or malformed request body could still cause a framework-level exception instead of a controlled 400 response.

🔧 Suggested guard clause
 public static async Task<IResult> SubmitToFhirAsync(
     string transactionId,
     SubmitToFhirRequest request,
     IDocumentUploader documentUploader,
     IAnalysisResultStore resultStore,
     IPdfFormStamper pdfStamper,
     CancellationToken cancellationToken)
 {
+    if (request is null)
+    {
+        return Results.BadRequest(new ErrorResponse
+        {
+            Message = "Submit request body is required",
+            Code = "SUBMIT_REQUEST_MISSING"
+        });
+    }
+
+    if (string.IsNullOrWhiteSpace(request.PatientId) ||
+        string.IsNullOrWhiteSpace(request.AccessToken))
+    {
+        return Results.BadRequest(new ErrorResponse
+        {
+            Message = "PatientId and AccessToken are required",
+            Code = "SUBMIT_REQUEST_INVALID"
+        });
+    }
+
     // Get the PDF (from cache or generate)
     var pdfBytes = await resultStore.GetCachedPdfAsync(transactionId, cancellationToken);
🤖 Fix all issues with AI agents
In `@apps/gateway/Gateway.API/Services/DocumentUploader.cs`:
- Around line 59-62: The code currently falls back to generating a synthetic
GUID when responseJson.TryGetProperty("id", out var id) yields no id, which can
mask server issues; update the handling in DocumentUploader (the block that sets
responseJson and documentId) to instead log a warning including the full
responseJson when the "id" property is missing and then return a failure (or
throw a specific exception) rather than silently returning a generated GUID; use
the existing logger (e.g., _logger) and preserve the current success/failure
flow so downstream code does not act on an unrecognized synthetic id.

In `@apps/gateway/Gateway.API/Services/Fhir/FhirHttpClient.cs`:
- Around line 53-62: The catch block in FhirHttpClient methods uses
ReadFromJsonAsync<JsonElement> (e.g., in the GET method shown) but only catches
HttpRequestException, so a JsonException can escape and bypass the Result<T>
failure path; update the relevant methods (the method shown plus SearchAsync and
CreateAsync in class FhirHttpClient) to also catch JsonException (or Exception
when deserializing) around the ReadFromJsonAsync<JsonElement> call and return a
Result<JsonElement>.Failure containing an appropriate FhirError (e.g.,
FhirError.InvalidResponse or FhirError.Network with the JsonException message
and inner exception) and log the error with _logger.LogError(ex, ...) so
invalid/non-JSON responses produce a Result.Failure instead of throwing.

In `@apps/intelligence/src/api/analyze.py`:
- Around line 97-117: The E501 failure is due to a too-long line in
_build_field_mappings: the patient_dob ternary expression exceeds the line
length; fix it by wrapping the conditional across multiple lines or extracting
the DOB logic into a short helper/temporary variable so no single line is >100
chars (e.g., assign dob_source = bundle.patient and bundle.patient.birth_date
check on one line and then set patient_dob on the next), update the patient_dob
assignment in _build_field_mappings accordingly to satisfy line length limits
while preserving the existing logic.
- Around line 34-63: The current stub returns the string "Unknown" for
PAFormResponse.patient_dob which breaks date-parsing clients; instead fail fast:
after creating bundle via ClinicalBundle.from_dict, verify bundle.patient exists
and bundle.patient.birth_date is present, and if missing raise an
HTTPException(400) with a clear message (e.g., "Missing patient DOB for
patient_id ..."); only set PAFormResponse.patient_dob to
bundle.patient.birth_date.isoformat() when present. Reference:
ClinicalBundle.from_dict, bundle.patient.birth_date, and
PAFormResponse.patient_dob (keep other fields like diagnosis_codes,
procedure_code, and _build_field_mappings unchanged).
🧹 Nitpick comments (11)
apps/gateway/Gateway.API/Contracts/IFhirClient.cs (1)

11-85: Align IFhirClient with Result and token provider

These methods still require accessToken and return raw values, which conflicts with the new Result flow and centralized token handling. Consider switching to Result and removing the token parameter across the interface to keep error handling and auth consistent.

♻️ Suggested pattern (apply similarly to the other methods)
-Task<PatientInfo?> GetPatientAsync(
-    string patientId,
-    string accessToken,
-    CancellationToken cancellationToken = default);
+Task<Result<PatientInfo?>> GetPatientAsync(
+    string patientId,
+    CancellationToken cancellationToken = default);
apps/gateway/Gateway.API/Contracts/IDocumentUploader.cs (1)

11-22: Consider removing accessToken from the contract

With centralized token handling in the new HTTP client provider, passing access tokens through method parameters is redundant and increases leakage risk. Prefer pulling auth from the request pipeline or provider and keep the contract token‑free.

♻️ Suggested adjustment
 Task<Result<string>> UploadDocumentAsync(
     byte[] pdfBytes,
     string patientId,
     string? encounterId,
-    string accessToken,
     CancellationToken cancellationToken = default);
apps/gateway/Gateway.API/Services/Fhir/FhirHttpClient.cs (1)

140-167: Consider adding a comment explaining the Accept header difference.

ReadBinaryAsync intentionally omits the application/fhir+json Accept header since Binary resources return raw bytes. A brief inline comment would help future maintainers understand why this method doesn't use ConfigureRequest.

apps/gateway/Gateway.API/Configuration/CachingSettings.cs (1)

33-37: Consider validating LocalCacheDuration and the relationship between cache tiers.

The current validation only checks Duration. For a two-tier cache (L1 local + L2 distributed), it's reasonable to enforce LocalCacheDuration > TimeSpan.Zero and LocalCacheDuration <= Duration to prevent configuration errors.

♻️ Enhanced validation
-    public bool IsValid() => Duration > TimeSpan.Zero;
+    public bool IsValid() =>
+        Duration > TimeSpan.Zero &&
+        LocalCacheDuration > TimeSpan.Zero &&
+        LocalCacheDuration <= Duration;
apps/gateway/Gateway.API/Services/FhirDataAggregator.cs (1)

24-32: Constructor lacks guard clauses for injected dependencies.

Per coding guidelines, guard clauses should come first. Adding null checks helps fail fast with clear error messages during DI misconfiguration.

♻️ Add guard clauses
 public FhirDataAggregator(
     IFhirClient fhirClient,
     IOptions<ClinicalQueryOptions> options,
     ILogger<FhirDataAggregator> logger)
 {
-    _fhirClient = fhirClient;
-    _options = options.Value;
-    _logger = logger;
+    ArgumentNullException.ThrowIfNull(fhirClient);
+    ArgumentNullException.ThrowIfNull(options);
+    ArgumentNullException.ThrowIfNull(logger);
+    
+    _fhirClient = fhirClient;
+    _options = options.Value;
+    _logger = logger;
 }
apps/gateway/Gateway.API/Services/AnalysisResultStore.cs (1)

134-137: Inconsistent configuration pattern: uses IConfiguration directly instead of IOptions<T>.

Other services in this PR (e.g., FhirDataAggregator with IOptions<ClinicalQueryOptions>) use strongly-typed options. This service reads "Analysis:EnableResultStorage" directly from IConfiguration, which bypasses validation and is inconsistent with the PR's pattern of introducing CachingSettings.

Consider either:

  1. Injecting IOptions<CachingSettings> and using its Enabled property, or
  2. Creating a dedicated AnalysisStorageOptions class with an IsValid() method

This would align with the strongly-typed configuration approach established elsewhere in this PR.

apps/gateway/Gateway.API/Configuration/DocumentOptions.cs (1)

23-27: Consider validating PriorAuthLoincDisplay as well.

The IsValid() method only checks PriorAuthLoincCode, but PriorAuthLoincDisplay is also used in the FHIR DocumentReference payload (see DocumentUploader.BuildDocumentReference). If the display is configured as empty/whitespace, the FHIR resource will have an empty display field.

Suggested validation enhancement
-    public bool IsValid() => !string.IsNullOrWhiteSpace(PriorAuthLoincCode);
+    public bool IsValid() => 
+        !string.IsNullOrWhiteSpace(PriorAuthLoincCode) && 
+        !string.IsNullOrWhiteSpace(PriorAuthLoincDisplay);
apps/gateway/Gateway.API/ServiceCollectionExtensions.cs (1)

38-46: HybridCache defaults may conflict with CachingSettings.

The AddHybridCache call hardcodes Expiration = TimeSpan.FromMinutes(5) and LocalCacheExpiration = TimeSpan.FromMinutes(1), but CachingSettings also defines Duration and LocalCacheDuration with identical defaults. The CachingIntelligenceClient uses the settings-based values.

This works currently since the defaults match, but if someone changes CachingSettings without realizing the HybridCache defaults are separate, behavior could be confusing.

Consider using CachingSettings for HybridCache defaults
+        var cachingSettings = configuration.GetSection(CachingSettings.SectionName).Get<CachingSettings>()
+            ?? new CachingSettings();
+
         services.AddHybridCache(options =>
         {
             options.DefaultEntryOptions = new HybridCacheEntryOptions
             {
-                Expiration = TimeSpan.FromMinutes(5),
-                LocalCacheExpiration = TimeSpan.FromMinutes(1)
+                Expiration = cachingSettings.Duration,
+                LocalCacheExpiration = cachingSettings.LocalCacheDuration
             };
         });
apps/gateway/Gateway.API/Services/DocumentUploader.cs (2)

46-47: Consider using IFhirSerializer for consistency.

The PR objective mentions "replacing System.Text.Json with Hl7.Fhir.Serialization via an IFhirSerializer abstraction", but this file uses JsonSerializer.Serialize directly. For consistency with the new pattern, consider injecting and using IFhirSerializer.


69-113: Anonymous object bypasses FHIR validation.

Per coding guidelines: "Use Firely SDK patterns, validate FHIR resources properly." Using an anonymous object for the DocumentReference skips FHIR validation that Firely SDK types would provide. Consider using Hl7.Fhir.Model.DocumentReference for compile-time safety and built-in validation.

Based on coding guidelines: "FHIR: Use Firely SDK patterns, validate FHIR resources properly."

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

252-266: Map Result failures to appropriate HTTP status codes.

Right now every failure becomes a 500. Consider leveraging Result.Match (or the shared error→IResult mapper used elsewhere) so validation/auth/not-found surface as 4xx while unexpected errors remain 500.

Remove Epic CDS Hooks implementation and vendor-specific code to create
a clean, vendor-agnostic foundation for future EHR integrations.

Gateway:
- Delete 14 CDS model files and CdsHooksEndpoints.cs
- Stub IntelligenceClient to return mock APPROVE responses
- Stub PdfFormStamper to return empty byte array
- Update DI registration to remove HTTP client dependency

Intelligence:
- Stub evidence_extractor.py to return MET for all criteria
- Stub form_generator.py to return APPROVE recommendation
- Rename mri_lumbar.py to example_policy.py with generic docs
- Delete associated test files for stubbed modules

Shared Types:
- Delete cds.ts and associated tests
- Remove CDS exports from index.ts

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: 4

🤖 Fix all issues with AI agents
In `@apps/gateway/Gateway.API/ServiceCollectionExtensions.cs`:
- Around line 66-73: The code reads configuration["Epic:FhirBaseUrl"] into
fhirBaseUrl and constructs new Uri(fhirBaseUrl) without guarding against empty
or whitespace strings; change the logic in ServiceCollectionExtensions so
fhirBaseUrl is validated with string.IsNullOrWhiteSpace and falls back to the
default URL when empty (or throw a clear configuration exception), then pass the
validated value into AddHttpClient<IFhirHttpClient, FhirHttpClient> for
client.BaseAddress; for a longer-term fix, refactor to bind EpicFhirOptions via
IOptions<EpicFhirOptions> (with data annotation validation) and use that
validated option.FhirBaseUrl instead of reading configuration directly.

In `@apps/gateway/Gateway.API/Services/IntelligenceClient.cs`:
- Around line 29-31: The LogInformation call in IntelligenceClient
(_logger.LogInformation with clinicalBundle.PatientId and procedureCode) is
emitting a plaintext patient identifier; change the logging to avoid PHI by
either removing clinicalBundle.PatientId from the message, redacting or
hashing/truncating it before logging, or demoting the entry to Debug and
ensuring redaction (e.g., log only a hashedId or procedureCode). Update the call
in IntelligenceClient.cs to use the chosen non-PII value and keep procedureCode
if needed, and ensure any helper used for hashing/redaction is consistently
applied.

In `@apps/gateway/Gateway.API/Services/PdfFormStamper.cs`:
- Around line 31-33: The LogInformation call in PdfFormStamper.cs currently
includes PII (formData.PatientName); remove the patient name from the log and
replace it with a non-identifying token or no identifier: update the
_logger.LogInformation invocation in the PdfFormStamper class/method that
handles stamping to either log a generic message ("PDF stamping requested") or
log a safe, non-PII identifier (e.g., an internal request id or a
hashed/anonymized formData.PatientId) instead of formData.PatientName, ensuring
no raw patient identifiers are written to logs.

In `@apps/intelligence/src/reasoning/form_generator.py`:
- Line 49: The assignment procedure_code = policy.get("procedure_codes",
["72148"])[0] can raise IndexError when policy contains an empty list; change
the logic in form_generator.py so you safely select the first code with a
fallback — e.g., retrieve procedure_codes = policy.get("procedure_codes") and
then set procedure_code = procedure_codes[0] if procedure_codes else "72148" (or
use next(iter(procedure_codes), "72148")) to ensure a safe default when the list
is empty or missing.
🧹 Nitpick comments (3)
apps/intelligence/src/reasoning/evidence_extractor.py (1)

33-42: Potential KeyError on missing criterion ID.

Direct access criterion["id"] will raise KeyError if any criterion lacks an "id" key. Consider defensive access for robustness, especially since policy data may come from external sources.

Suggested defensive access
     return [
         EvidenceItem(
-            criterion_id=criterion["id"],
+            criterion_id=criterion.get("id", "unknown"),
             status="MET",
             evidence="STUB: Evidence would be extracted from clinical data",
             source="Stub implementation",
             confidence=0.90,
         )
         for criterion in policy.get("criteria", [])
     ]
apps/intelligence/src/policies/example_policy.py (1)

14-18: Consider using Pydantic models for type safety and validation.

Per coding guidelines, prefer BaseModel for data validation. A raw dict[str, Any] loses compile-time type safety and runtime validation. Since this is explicitly a demo policy, this is acceptable, but a Pydantic model (or TypedDict) would catch schema violations early and improve IDE support for consumers.

♻️ Example Pydantic approach
from pydantic import BaseModel, Field

class Criterion(BaseModel):
    id: str
    description: str
    evidence_patterns: list[str] = Field(default_factory=list)
    threshold_weeks: int | None = None
    required: bool = True
    bypasses_conservative: bool = False

class DiagnosisCodes(BaseModel):
    primary: list[str]
    supporting: list[str] = Field(default_factory=list)

class Policy(BaseModel):
    policy_id: str
    policy_name: str
    payer: str
    procedure_codes: list[str]
    diagnosis_codes: DiagnosisCodes
    criteria: list[Criterion]
    form_field_mappings: dict[str, str]

EXAMPLE_POLICY = Policy(
    policy_id="example-mri-lumbar-2024",
    # ... rest of fields
)
apps/gateway/Gateway.API/Services/PdfFormStamper.cs (1)

27-40: Consider adding a guard clause for null input.

Per coding guidelines ("Guard clauses first, early return"), adding a null check for formData would make this stub more defensive and consistent with future production implementation.

🛡️ Suggested guard clause
     public Task<byte[]> StampFormAsync(
         PAFormData formData,
         CancellationToken cancellationToken = default)
     {
+        ArgumentNullException.ThrowIfNull(formData);
+
         _logger.LogInformation(
-            "STUB: PDF stamping requested for patient {PatientName}",
-            formData.PatientName);
+            "STUB: PDF stamping requested");

         // STUB: Return empty array for now
         // Production will use iText to:
         // 1. Load PDF template from assets
         // 2. Stamp form fields using formData.FieldMappings
         // 3. Flatten and return the stamped PDF bytes
         return Task.FromResult(Array.Empty<byte>());
     }

rsalus and others added 2 commits January 26, 2026 18:44
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@rsalus rsalus force-pushed the feature/integration-gateway-fhir-refactor branch from e12da67 to 5be600e Compare January 27, 2026 02:48
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: 5

🤖 Fix all issues with AI agents
In `@apps/gateway/Gateway.API/DependencyExtensions.cs`:
- Around line 61-70: The AddFhirClients method currently falls back to the
public Epic sandbox when configuration["Epic:FhirBaseUrl"] is missing; remove
that silent default and fail fast by validating the fhirBaseUrl retrieved from
configuration inside AddFhirClients (check null or empty) and throw a clear
InvalidOperationException (or similar) referencing Epic:FhirBaseUrl so
initialization fails loudly; then pass the validated fhirBaseUrl into the
AddHttpClient<IFhirHttpClient, FhirHttpClient> client.BaseAddress setup.
- Around line 34-46: The HybridCache default durations are hard-coded and ignore
the bound CachingSettings; update the AddHybridCache setup to read values from
the bound CachingSettings instance (the options bound via
services.AddOptions<CachingSettings>().Bind(...)) and use those properties when
setting DefaultEntryOptions on AddHybridCache (i.e., set Expiration and
LocalCacheExpiration from CachingSettings), with sensible fallbacks if a setting
is null/zero so configuration changes take effect; reference the CachingSettings
type, AddHybridCache call, DefaultEntryOptions/HybirdCacheEntryOptions, and the
Expiration and LocalCacheExpiration properties when making the change.
- Around line 15-17: Replace the invalid method declaration
"extension(IServiceCollection services)" with a proper public static extension
method signature such as "public static IServiceCollection
AddGatewayServices(this IServiceCollection services)" so the method is declared
as an extension on IServiceCollection and returns IServiceCollection; move the
existing XML documentation comments that currently sit inside the block so they
sit immediately above the new method signature; ensure the method body
references the same service registrations as before and retains the
IServiceCollection parameter name "services" and the method name
"AddGatewayServices".

In `@apps/gateway/Gateway.API/Services/FhirClient.cs`:
- Around line 306-311: ExtractClinicalStatus is passing the "coding" string to
ExtractFirstCoding, but ExtractFirstCoding expects the CodeableConcept wrapper
(the full clinicalStatus element) so it never finds the coding and always
returns null; fix by calling ExtractFirstCoding with the clinicalStatus
JsonElement itself (i.e., pass status as the CodeableConcept wrapper rather than
passing "coding"), then return the resulting coding?.code from
ExtractClinicalStatus.
- Around line 1-11: The ExtractClinicalStatus method is using
ExtractFirstCoding(status, "coding") incorrectly because status is already the
CodeableConcept; update ExtractClinicalStatus to directly read the "coding"
property from the status JsonElement, iterate its array and return the first
"code" string found (instead of calling ExtractFirstCoding on status), ensuring
you check TryGetProperty("clinicalStatus") then TryGetProperty("coding") on that
status element; locate the method named ExtractClinicalStatus and replace the
helper call with direct extraction logic (and consider replacing other
JsonElement parsing in this class with Hl7.Fhir.R4 typed resources later).
🧹 Nitpick comments (1)
apps/intelligence/src/api/analyze.py (1)

97-110: Consider DRY-ing patient field extraction.
analyze() and _build_field_mappings() duplicate patient name/DOB/member_id logic; this already caused a divergence (member_id fallback). A small helper would prevent drift.

P3 Major fixes:
- Add null bundle validation in FhirClient.cs
- Fix MapHttpError to return 404 on NotFound
- Add SemaphoreSlim for token cache thread safety
- Guard grep command in setup.sh
- Add JsonException handling in FhirHttpClient
- Validate FHIR base URL configuration
- Remove PHI (PatientId) from IntelligenceClient logs
- Remove PII (PatientName) from PdfFormStamper logs

P4 Minor fixes:
- Dispose ServiceProvider in DI configuration tests
- Add null check for EpicFhirOptions
- Log warning for missing document ID
- Fail fast on missing patient DOB
- Handle empty procedure_codes list

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: 2

Caution

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

⚠️ Outside diff range comments (3)
apps/gateway/Gateway.API/Services/Fhir/EpicFhirContext.cs (3)

54-54: Missing JsonException handling in ReadAsync.

ReadFromJsonAsync can throw JsonException if the response body is malformed or doesn't match the expected schema. This would bypass the Result-based error handling and propagate as an unhandled exception.

Proposed fix
         catch (HttpRequestException ex)
         {
             _logger.LogError(ex, "Network error reading {ResourceType}/{Id}", _resourceType, id);
             return Result<TResource>.Failure(FhirError.Network(ex.Message, ex));
         }
+        catch (JsonException ex)
+        {
+            _logger.LogWarning(ex, "Failed to deserialize {ResourceType}/{Id}", _resourceType, id);
+            return Result<TResource>.Failure(FhirError.InvalidResponse($"Invalid JSON in {_resourceType}/{id} response"));
+        }

136-136: Missing JsonException handling in CreateAsync.

Same issue as ReadAsyncReadFromJsonAsync can throw JsonException on malformed responses.

Proposed fix
         catch (HttpRequestException ex)
         {
             _logger.LogError(ex, "Network error creating {ResourceType}", _resourceType);
             return Result<TResource>.Failure(FhirError.Network(ex.Message, ex));
         }
+        catch (JsonException ex)
+        {
+            _logger.LogWarning(ex, "Failed to deserialize created {ResourceType}", _resourceType);
+            return Result<TResource>.Failure(FhirError.InvalidResponse($"Invalid JSON in created {_resourceType} response"));
+        }

97-98: Consider validating bundle structure before extraction.Calling TryGetProperty on a default or uninitialized JsonElement can cause runtime exceptions. Specifically, TryGetProperty throws InvalidOperationException when "This value's ValueKind is not Object."

If the FHIR server returns unexpected JSON (e.g., an array or non-object), TryGetProperty will throw. Consider adding a guard:

Proposed fix
 private IReadOnlyList<TResource> ExtractResourcesFromBundle(JsonElement bundle)
 {
     var results = new List<TResource>();
 
+    if (bundle.ValueKind != JsonValueKind.Object)
+    {
+        _logger.LogWarning("Expected FHIR Bundle object but received {ValueKind}", bundle.ValueKind);
+        return results;
+    }
+
     if (!bundle.TryGetProperty("entry", out var entries))
     {
         return results;
     }
🤖 Fix all issues with AI agents
In `@apps/gateway/Gateway.API/Services/DocumentUploader.cs`:
- Around line 42-44: The Info-level log in DocumentUploader.cs currently
includes the patient identifier in the _logger.LogInformation call; replace that
with a non-identifying value (or remove it) by changing the LogInformation call
in the Upload method (the call using patientId and pdfBytes.Length) to either
omit patientId or log a redacted/hashed representation (e.g., compute a
deterministic SHA-256 or HMAC of patientId and log a truncated hex string) so
PHI/PII is not emitted; ensure the logged message still includes Size={Size} and
use the hashed/redacted token variable in place of patientId in the same
_logger.LogInformation invocation.

In `@scripts/setup.sh`:
- Around line 34-37: Add a guard before the cd command to ensure
$APPHOST_PROJECT is set and points to an existing directory: check that the
APPHOST_PROJECT variable is non-empty and that [ -d "$APPHOST_PROJECT" ] is
true, and if not emit a clear error via info/error (or echo) and exit with
non-zero status; place this check immediately before the cd "$APPHOST_PROJECT"
line in scripts/setup.sh so the script (with set -e) fails with a friendly
message rather than a terse shell error.
🧹 Nitpick comments (8)
apps/gateway/Gateway.API.Tests/Configuration/IntelligenceOptionsTests.cs (2)

8-8: Class should be sealed per coding guidelines.

As per coding guidelines, C# classes should be sealed by default.

Proposed fix
-public class IntelligenceOptionsTests
+public sealed class IntelligenceOptionsTests

44-48: Static analysis flags constant assertion.

The warning is valid: SectionName is a compile-time constant, so this test is essentially a tautology. While it guards against accidental section name changes, the value is limited since configuration binding tests (like the first test) would fail anyway if the name drifted.

Consider removing this test or, if you want to keep the regression guard, suppressing the warning explicitly to signal intent:

Option: Remove the test
-    [Test]
-    public async Task IntelligenceOptions_SectionName_IsIntelligence()
-    {
-        await Assert.That(IntelligenceOptions.SectionName).IsEqualTo("Intelligence");
-    }
apps/gateway/Gateway.API/Services/IntelligenceClient.cs (1)

24-35: Add guard clauses and honor cancellation token.

This avoids null-driven NREs and respects cancellation. As per coding guidelines, guard clauses should come first.

♻️ Proposed fix
 public Task<PAFormData> AnalyzeAsync(
     ClinicalBundle clinicalBundle,
     string procedureCode,
     CancellationToken cancellationToken = default)
 {
+    if (clinicalBundle is null)
+    {
+        throw new ArgumentNullException(nameof(clinicalBundle));
+    }
+
+    if (string.IsNullOrWhiteSpace(procedureCode))
+    {
+        throw new ArgumentException("Procedure code is required.", nameof(procedureCode));
+    }
+
+    if (cancellationToken.IsCancellationRequested)
+    {
+        return Task.FromCanceled<PAFormData>(cancellationToken);
+    }
+
     _logger.LogInformation(
         "STUB: Returning mock analysis for ProcedureCode={ProcedureCode}",
         procedureCode);
apps/gateway/Gateway.API.Tests/Configuration/EpicFhirOptionsTests.cs (1)

38-42: Testing a constant has minimal value.

The static analysis correctly flags that SectionName is a compile-time constant. This test will never catch a runtime bug—only a code change that someone could equally discover by reading the source. Consider removing it or converting to a compile-time assertion if the intent is to guard against accidental changes.

apps/gateway/Gateway.API/Services/Fhir/EpicFhirContext.cs (1)

14-14: Class should be sealed per coding guidelines.

The coding guidelines specify "sealed by default" for C# types. Since this class isn't intended for inheritance, mark it sealed.

-public class EpicFhirContext<TResource> : IFhirContext<TResource> where TResource : class
+public sealed class EpicFhirContext<TResource> : IFhirContext<TResource> where TResource : class
apps/gateway/Gateway.API/Contracts/Result.cs (2)

1-115: Consider splitting Result<T> and FhirError into separate files.

Per coding guidelines, each file should contain one public type. While these types are tightly coupled (which is a reasonable justification for co-location), splitting into Result.cs and FhirError.cs would align with the standard.


46-46: Optional: Guard against null success values for reference types.

Success(T value) doesn't guard against null. If a caller passes null for a reference type, IsSuccess would be true but Value would be null, potentially causing unexpected behavior in Match.

This is low-risk if callers are well-behaved, but a guard clause would make the contract explicit:

-public static Result<T> Success(T value) => new(value);
+public static Result<T> Success(T value) => new(value ?? throw new ArgumentNullException(nameof(value)));
scripts/setup.sh (1)

62-96: Re-running the script without environment variables will overwrite existing secrets with "not-configured".

dotnet user-secrets set performs an upsert operation—it overwrites any existing value for the same key. Lines 66, 76–77, and 90 all call set with placeholder values without checking if secrets are already configured. This means re-running the setup script will lose previously valid secrets.

If the script is intended to preserve manually configured secrets on subsequent runs, add a guard to check for existing values before overwriting. For example:

🛠️ Example guard for GitHub token (apply similarly to Azure/Google)
+existing_github_token="$(dotnet user-secrets list | grep -E '^Parameters:github-token=' || true)"
 if [[ -n "$GITHUB_TOKEN" ]]; then
     dotnet user-secrets set "Parameters:github-token" "$GITHUB_TOKEN"
     info "Set github-token"
-else
+elif [[ -z "$existing_github_token" ]]; then
     dotnet user-secrets set "Parameters:github-token" "not-configured"
     warn "No GitHub token found - set placeholder (configure later with gh auth login)"
+else
+    info "github-token already configured; leaving unchanged"
 fi

(Note: The UserSecretsId is already configured in the AppHost project, so initialization is not needed.)

rsalus and others added 3 commits January 26, 2026 20:02
- test_analyze.py: API endpoint tests (5 tests)
- test_evidence_extractor.py: Evidence extraction tests (3 tests)
- test_form_generator.py: Form generation tests (6 tests)

Restores test coverage after removing vendor-specific tests.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
P1 Critical:
- Fix DependencyExtensions.cs syntax error (invalid extension block)

P3 Major:
- Use CachingSettings for HybridCache durations instead of hard-coded values
- Throw InvalidOperationException instead of silently falling back to Epic sandbox
- Fix ExtractClinicalStatus bug (was always returning null)
- Remove PatientId from DocumentUploader logs (PHI compliance)

P4 Minor:
- Add APPHOST_PROJECT directory guard in setup.sh

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add PRs to Project #4 with workstream based on changed files
- Add issues to Project #4 with priority and workstream fields
- Priority defaults to Medium, set by priority:* labels
- Workstream set by scope:gateway or scope:intelligence labels

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

rsalus commented Jan 27, 2026

@coderabbitai Approve this PR if there are no remaining critical/major issues.

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/Services/FhirClientTests.cs`:
- Around line 62-64: The JsonDocument created by JsonDocument.Parse in the tests
is IDisposable and must be disposed to avoid pooled buffer leaks; wrap each
JsonDocument.Parse(...) used in the three tests (the instances assigned to
jsonDocument in the SearchAsync stubs around the lines shown) in a using block
(or use using var) so that the JsonDocument is disposed after setting up the
_httpClient.SearchAsync(...) return (references: the local variable jsonDocument
and the mocked _httpClient.SearchAsync("Condition", ...)). Ensure you apply this
change to all three occurrences (the ones around the current snippet plus the
other two similar setups).

In `@scripts/setup.sh`:
- Around line 48-102: The script currently unconditionally writes
placeholders/defaults and overwrites existing user-secrets (LLM_PROVIDER,
GITHUB_TOKEN, AZURE_KEY, AZURE_ENDPOINT, GOOGLE_KEY); change it to preserve
existing secrets by only calling dotnet user-secrets set when an explicit value
is supplied (e.g. non-empty GITHUB_TOKEN from arg/env or
AZURE_OPENAI_API_KEY/AZURE_OPENAI_ENDPOINT/GOOGLE_API_KEY), and when you need to
set a placeholder first check for an existing secret via dotnet user-secrets
list (or equivalent) and only set the "not-configured" placeholder if no
existing key exists; update the logic around variables GITHUB_TOKEN, AZURE_KEY,
AZURE_ENDPOINT, GOOGLE_KEY and the initial LLM_PROVIDER dotnet set so
placeholders are not written over prior secrets.
🧹 Nitpick comments (2)
apps/gateway/Gateway.API/Services/FhirClient.cs (1)

87-95: Questionable fallback: generating random GUIDs for missing resource IDs.

Lines 89, 136, 184, and 225 all fallback to Guid.NewGuid().ToString() when the resource lacks an id. FHIR resources returned from a server should always have IDs - generating synthetic ones could mask upstream data issues and complicate debugging or correlation.

Consider returning an empty/sentinel value, logging a warning, or skipping resources without IDs entirely.

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

76-118: Use Firely SDK models + IFhirSerializer for DocumentReference and validation.
Manually shaping JSON bypasses Firely’s validation and increases risk of subtle FHIR non‑compliance. Please construct an Hl7.Fhir.Model.DocumentReference, validate it, and serialize via the shared FHIR serializer abstraction.

♻️ Example direction
var doc = new DocumentReference
{
    Status = DocumentReferenceStatus.Current,
    Type = new CodeableConcept("http://loinc.org", _options.PriorAuthLoincCode, _options.PriorAuthLoincDisplay),
    Subject = new ResourceReference($"Patient/{patientId}"),
    Context = encounterId is null ? null : new DocumentReference.ContextComponent
    {
        Encounter = new[] { new ResourceReference($"Encounter/{encounterId}") }
    },
    Content = new List<DocumentReference.ContentComponent>
    {
        new()
        {
            Attachment = new Attachment
            {
                ContentType = "application/pdf",
                Data = pdfBytes,
                Title = $"PA Form - {DateTime.UtcNow:yyyy-MM-dd}"
            }
        }
    }
};

var json = _fhirSerializer.SerializeToString(doc); // per IFhirSerializer

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 @.github/workflows/project-automation.yml:
- Around line 279-294: The PR file listing uses github.rest.pulls.listFiles with
per_page:100 which can miss files on large PRs; update the logic that builds
`files` (used by `touchesGateway`, `touchesIntelligence`, and `workstreamValue`)
to fetch all pages (e.g., use Octokit pagination or loop through page numbers)
so you aggregate every file before computing
`touchesGateway`/`touchesIntelligence`, or alternatively explicitly document the
100-file limitation; ensure the change references the existing
`github.rest.pulls.listFiles` call and preserves downstream variables `files`,
`touchesGateway`, `touchesIntelligence`, and `workstreamValue`.
🧹 Nitpick comments (3)
.github/workflows/project-automation.yml (3)

217-220: Unnecessary full checkout for file detection.

The fetch-depth: 0 clones the entire git history, but the workstream detection at lines 279-284 uses the GitHub REST API (pulls.listFiles) rather than local git operations. A shallow checkout (default fetch-depth: 1) or even skipping checkout entirely would speed up this job.

🔧 Remove unnecessary checkout or use shallow clone
     steps:
-      - name: Checkout for file detection
-        uses: actions/checkout@v4
-        with:
-          fetch-depth: 0
-
       - name: Add PR to project

Or if checkout is needed for other reasons not shown:

       - name: Checkout for file detection
         uses: actions/checkout@v4
-        with:
-          fetch-depth: 0

270-276: Consider handling duplicate item additions gracefully.

The addProjectV2ItemById mutation may fail if the PR is already in the project (e.g., on re-runs or when ready_for_review fires after opened). Without error handling, the job fails and subsequent workstream assignment is skipped.

🔧 Wrap mutation in try-catch
-            const addResult = await github.graphql(addItemMutation, {
-              projectId: project.id,
-              contentId: pr.node_id
-            });
-
-            const itemId = addResult.addProjectV2ItemById.item.id;
-            console.log(`Added PR #${pr.number} to project, item ID: ${itemId}`);
+            let itemId;
+            try {
+              const addResult = await github.graphql(addItemMutation, {
+                projectId: project.id,
+                contentId: pr.node_id
+              });
+              itemId = addResult.addProjectV2ItemById.item.id;
+              console.log(`Added PR #${pr.number} to project, item ID: ${itemId}`);
+            } catch (error) {
+              // PR may already be in project - try to find existing item
+              console.log(`Could not add PR (may already exist): ${error.message}`);
+              return;
+            }

381-387: Same error handling consideration as the PR job.

The mutation here has the same potential for failure on duplicate items. Consider applying similar try-catch handling for consistency and reliability.

@coderabbitai
Copy link

coderabbitai bot commented Jan 27, 2026

@rsalus I've reviewed this PR and found no critical or major issues blocking approval. The refactor is comprehensive and well-executed:

Strengths:

  • 84 new unit tests with full suite passing
  • Comprehensive Result error handling implementation
  • Well-documented design (docs/designs/2026-01-26-gateway-fhir-refactor.md)
  • PHI compliance addressed (PatientId removed from logs)
  • Configuration validation with IsValid() methods
  • Follow-up commit addressed review feedback promptly

Intentional architectural changes:

  • CDS Hooks removal in favor of FHIR-based workflow (aligned with PR goals)
  • Stub implementations clearly marked for demo scope (IntelligenceClient, PdfFormStamper)
  • Result pattern adopted consistently across services

The refactor successfully adopts aegis-api patterns and establishes a solid foundation for the prior authorization demo. Approving now.

✅ Actions performed

Comments resolved and changes approved.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Archived in project

Development

Successfully merging this pull request may close these issues.

1 participant