A production-quality clinical backend built with ASP.NET Core 8, PostgreSQL, Apache Kafka, RabbitMQ, Elasticsearch, Redis, and MinIO. The domain models the observe-alert-acknowledge lifecycle at the center of any clinical monitoring system: patient encounters, continuous vital sign and lab result ingest, real-time sepsis and NEWS2 scoring, and clinician notification with automatic escalation.
A production-quality clinical backend built with ASP.NET Core 8, PostgreSQL, Apache Kafka, RabbitMQ, Elasticsearch, Redis, and MinIO. The domain models the observe-alert-acknowledge lifecycle at the center of any clinical monitoring system: patient encounters, continuous vital sign and lab result ingest, real-time sepsis and NEWS2 scoring, and clinician notification with automatic escalation.
**Implementation status:** All twelve planned phases are complete — from schema and CRUD through Kafka, Elasticsearch CQRS, sepsis detection, RabbitMQ paging with DLQ escalation, reconciliation jobs, Prometheus/Grafana observability, the MinIO Parquet data lake, clinical data model expansion (patient demographics, encounter enrichment, 12 observation codes), warning alerts and orders, and the NEWS2 composite scoring engine. See [Implemented Phases](#implemented-phases) for the full breakdown.
**Implementation status:** All fourteen planned phases are complete — from schema and CRUD through Kafka, Elasticsearch CQRS, sepsis detection, RabbitMQ paging with DLQ escalation, reconciliation jobs, Prometheus/Grafana observability, the MinIO Parquet data lake, clinical data model expansion (patient demographics, encounter enrichment, 12 observation codes), warning alerts and orders, the NEWS2 composite scoring engine, trend detection with alert suppression, and qSOFA scoring with sepsis bundle compliance tracking. See [Implemented Phases](#implemented-phases) for the full breakdown.
## Domain Model — How It Maps to a Real Clinical System
## Domain Model — How It Maps to a Real Clinical System
In a hospital, a patient presents for care and an encounter is opened. Bedside monitors and lab systems post observations continuously against that encounter. A rules engine evaluates each observation against configured thresholds and flags abnormal values as clinical alerts. Composite scoring engines (NEWS2, SIRS/sepsis) aggregate multiple vitals into acuity scores. Clinicians acknowledge and resolve alerts. If a critical alert goes unacknowledged for five minutes, the system escalates to the on-call backup. All events flow through Kafka so the Elasticsearch dashboard, scoring engines, and data lake writer consume the same stream independently.
In a hospital, a patient presents for care and an encounter is opened. Bedside monitors and lab systems post observations continuously against that encounter. A rules engine evaluates each observation against configured thresholds and flags abnormal values as clinical alerts. Composite scoring engines (NEWS2, SIRS/sepsis, qSOFA) aggregate multiple vitals into acuity scores. When sepsis is suspected (via SIRS or qSOFA), a four-element treatment bundle is automatically created with a one-hour compliance deadline. Clinicians acknowledge and resolve alerts. If a critical alert goes unacknowledged for five minutes, the system escalates to the on-call backup. All events flow through Kafka so the Elasticsearch dashboard, scoring engines, and data lake writer consume the same stream independently.
```
```
Patient ─────────────────────────── one patient = one MRN, many lifetime encounters
Patient ─────────────────────────── one patient = one MRN, many lifetime encounters
└── Encounter one clinical episode (inpatient, outpatient, ED)
└── Encounter one clinical episode (inpatient, outpatient, ED)
├── Observation one measurement: vital sign, lab value, SpO₂
├── Observation one measurement: vital sign, lab value, SpO₂
│ └── OutboxEvent written in the same transaction → relayed to Kafka
│ └── OutboxEvent written in the same transaction → relayed to Kafka
└── ClinicalAlert generated on threshold breach, SIRS detection, or NEWS2 composite score
└── ClinicalAlert generated on threshold breach, SIRS detection, qSOFA score, or NEWS2 composite score
└── SepsisBundle auto-created on SIRS/qSOFA alert → four treatment orders → compliance tracking
```
```
### Patient
### Patient
@@ -37,7 +38,11 @@ An `idempotencyKey` (partial unique index) prevents duplicate observations when
### ClinicalAlert
### ClinicalAlert
A `ClinicalAlert` is generated when an observation breaches a threshold, when the sepsis engine detects two or more concurrent SIRS criteria, or when the NEWS2 engine computes a medium/high-risk composite score (or a single-parameter score of 3). Lifecycle: `open → acknowledged → resolved` (or `escalated` after a five-minute NACK cycle through the RabbitMQ dead-letter queue). Alerts carry an audit trail: who acknowledged, when, and with what note.
A `ClinicalAlert` is generated when an observation breaches a threshold, when the sepsis engine detects two or more concurrent SIRS criteria, when the qSOFA engine detects two or more organ-dysfunction criteria, or when the NEWS2 engine computes a medium/high-risk composite score (or a single-parameter score of 3). Lifecycle: `open → acknowledged → resolved` (or `escalated` after a five-minute NACK cycle through the RabbitMQ dead-letter queue). Alerts carry an audit trail: who acknowledged, when, and with what note. SIRS and qSOFA alerts additionally trigger automatic sepsis bundle creation.
### SepsisBundle
A `SepsisBundle` is created automatically when either the SIRS detector or the qSOFA scorer generates a sepsis-related alert. Each bundle contains four mandatory treatment elements (blood cultures, serum lactate, broad-spectrum antibiotics, IV fluid resuscitation) mapped to clinical orders that are created simultaneously. A one-hour compliance deadline is set from the recognition time. As linked orders are resulted, bundle elements transition to `COMPLETED`; when all four are done, the bundle is marked `COMPLIANT` (within deadline) or `NON_COMPLIANT` (past deadline). Only one in-progress bundle can exist per encounter at a time.
### OutboxEvent
### OutboxEvent
@@ -56,8 +61,9 @@ An `OutboxEvent` is written in the same transaction as any observation or alert,
- **Clinical Alert Lifecycle** — paginated alert list per encounter and globally; acknowledge with clinician ID and optional note; resolve (must be acknowledged first); global list filterable by status, severity, and department
- **Clinical Alert Lifecycle** — paginated alert list per encounter and globally; acknowledge with clinician ID and optional note; resolve (must be acknowledged first); global list filterable by status, severity, and department
- **Outbox Relay** — `IHostedService` polling every 500ms; reads unprocessed outbox rows, publishes to Kafka, marks processed; partitioned by `encounterId` for per-encounter ordering
- **Outbox Relay** — `IHostedService` polling every 500ms; reads unprocessed outbox rows, publishes to Kafka, marks processed; partitioned by `encounterId` for per-encounter ordering
- **Kafka Pipeline** — three topics (`observation.recorded`, `alert.generated`, `encounter.status.changed`) with six partitions each; KRaft mode, no Zookeeper; `KAFKA_AUTO_CREATE_TOPICS_ENABLE=false` — topics are provisioned explicitly by `KafkaTopicProvisioner`
- **Kafka Pipeline** — three topics (`observation.recorded`, `alert.generated`, `encounter.status.changed`) with six partitions each; KRaft mode, no Zookeeper; `KAFKA_AUTO_CREATE_TOPICS_ENABLE=false` — topics are provisioned explicitly by `KafkaTopicProvisioner`
- **Elasticsearch CQRS Projection** — `EsIndexerService` consumer group upserts `patient_encounters` documents, appends to the `observations` index, increments `openAlertCount` on alert events, and stamps `news2Score` / `news2RiskLevel` when a NEWS2 alert is generated; patient/encounter search; per-encounter observation trend (hourly avg/min/max); alert volume summary by department and severity; population query (numeric range aggregation across all patients)
- **Elasticsearch CQRS Projection** — `EsIndexerService` consumer group upserts `patient_encounters` documents, appends to the `observations` index, increments `openAlertCount` on alert events, stamps `news2Score` / `news2RiskLevel` when a NEWS2 alert is generated, and projects `sepsisBundleStatus` / `sepsisBundleElementsCompleted` / `sepsisBundleDeadlineAt` from sepsis bundle events; patient/encounter search; per-encounter observation trend (hourly avg/min/max); alert volume summary by department and severity; population query (numeric range aggregation across all patients)
- **Sepsis Early Warning Engine** — `SepsisEngineService` Kafka consumer evaluates SIRS criteria (temperature, heart rate, respiratory rate, WBC) per encounter using Redis keys with a 30-minute TTL sliding window; on ≥2 active criteria, inserts a `SEPSIS_WARNING / CRITICAL` alert idempotently (`INSERT WHERE NOT EXISTS`)
- **Sepsis Early Warning Engine** — `SepsisEngineService` Kafka consumer evaluates both SIRS and qSOFA criteria per encounter using Redis keys with a 30-minute TTL sliding window; SIRS evaluates temperature, heart rate, respiratory rate, and WBC; qSOFA evaluates respiratory rate ≥ 22, systolic BP ≤ 100, and altered mentation (AVPU ≥ 1); on ≥2 active criteria in either system, inserts a `SEPSIS_WARNING` or `QSOFA_WARNING / CRITICAL` alert idempotently (`INSERT WHERE NOT EXISTS`); both alert types trigger automatic sepsis bundle creation via `SepsisAlertHandler`
- **Sepsis Bundle Compliance** — `SepsisBundleService` creates a four-element treatment bundle (blood cultures, serum lactate, broad-spectrum antibiotics, IV fluid resuscitation) when a SIRS or qSOFA alert fires; each element maps to an auto-created clinical order (`orderedBy: sepsis-bundle-engine`); one-hour compliance deadline from recognition; `OrderService.RecordResult` calls back to `OnOrderResultedAsync` to mark elements complete; final element completion sets bundle to `COMPLIANT` or `NON_COMPLIANT`; `SepsisBundleMonitorService` scans every 5 minutes for overdue in-progress bundles past their deadline and marks them `NON_COMPLIANT`; idempotent — only one in-progress bundle per encounter; `GET /encounters/:id/sepsis-bundle/current` and `GET /sepsis-bundles/:id` expose bundle state; Kafka topics `sepsis.bundle.created` / `sepsis.bundle.updated`; Prometheus `qsofa_detections_total` and `sepsis_bundle_compliance_total`
- **NEWS2 Composite Scoring Engine** — `News2ScoringService` Kafka consumer (`news2-scoring`) evaluates seven vital parameters per encounter (`RESP_RATE`, `SPO2`, `SYSTOLIC_BP`, `HEART_RATE`, `AVPU`, `TEMP_C`, `SUPPLEMENTAL_O2`) using Redis keys with a 4-hour TTL; when all seven are present, computes the official NEWS2 aggregate score, persists to `news2_scores`, and creates `NEWS2_WARNING` (score 5–6 or single param = 3) or `NEWS2_EMERGENCY` (score ≥ 7) alerts idempotently; `GET /encounters/:id/news2/current` and `/history` expose score history; Prometheus `news2_scores_total` and `news2_scoring_duration_seconds`
- **NEWS2 Composite Scoring Engine** — `News2ScoringService` Kafka consumer (`news2-scoring`) evaluates seven vital parameters per encounter (`RESP_RATE`, `SPO2`, `SYSTOLIC_BP`, `HEART_RATE`, `AVPU`, `TEMP_C`, `SUPPLEMENTAL_O2`) using Redis keys with a 4-hour TTL; when all seven are present, computes the official NEWS2 aggregate score, persists to `news2_scores`, and creates `NEWS2_WARNING` (score 5–6 or single param = 3) or `NEWS2_EMERGENCY` (score ≥ 7) alerts idempotently; `GET /encounters/:id/news2/current` and `/history` expose score history; Prometheus `news2_scores_total` and `news2_scoring_duration_seconds`
- **Trend Detection Engine** — `TrendAnalyzerService` Kafka consumer (`trend-analyzer`) tracks rate-of-change for five vital parameters (`HEART_RATE`, `RESP_RATE`, `SYSTOLIC_BP`, `TEMP_C`, `SPO2`) using Redis sliding-window history; when velocity exceeds configured thresholds (e.g. 72→95 bpm in 30 min), creates a `RAPID_DETERIORATION` alert even if the current value is below warning thresholds; Prometheus `trend_alerts_total` and `trend_analysis_duration_seconds`
- **Trend Detection Engine** — `TrendAnalyzerService` Kafka consumer (`trend-analyzer`) tracks rate-of-change for five vital parameters (`HEART_RATE`, `RESP_RATE`, `SYSTOLIC_BP`, `TEMP_C`, `SPO2`) using Redis sliding-window history; when velocity exceeds configured thresholds (e.g. 72→95 bpm in 30 min), creates a `RAPID_DETERIORATION` alert even if the current value is below warning thresholds; Prometheus `trend_alerts_total` and `trend_analysis_duration_seconds`
- **Alert Suppression Windows** — acknowledging a suppressible alert (`WARNING_*`, `NEWS2_WARNING`) sets a Redis key `suppress:{encounterId}:{alertType}` with a configurable TTL (default 30 min from `AlertSuppression` config; optional per-code override via `alert_thresholds.suppression_window_minutes`); `WarningEvaluator` and `News2Detector` check suppression before creating new warning alerts; critical alerts (`CRITICAL_*`, `NEWS2_EMERGENCY`, `SEPSIS_WARNING`, `RAPID_DETERIORATION`) are never suppressed; observations and NEWS2 scores continue to persist during suppression; Prometheus `alert_suppressions_total`
- **Alert Suppression Windows** — acknowledging a suppressible alert (`WARNING_*`, `NEWS2_WARNING`) sets a Redis key `suppress:{encounterId}:{alertType}` with a configurable TTL (default 30 min from `AlertSuppression` config; optional per-code override via `alert_thresholds.suppression_window_minutes`); `WarningEvaluator` and `News2Detector` check suppression before creating new warning alerts; critical alerts (`CRITICAL_*`, `NEWS2_EMERGENCY`, `SEPSIS_WARNING`, `RAPID_DETERIORATION`) are never suppressed; observations and NEWS2 scores continue to persist during suppression; Prometheus `alert_suppressions_total`
@@ -66,7 +72,7 @@ An `OutboxEvent` is written in the same transaction as any observation or alert,
- **Reconciliation Jobs** — three scheduled checks: (1) unacknowledged CRITICAL alerts older than 30 minutes, (2) pending orders without results after 4 hours, (3) active inpatients with no observation in 2 hours; each finding creates a `reconciliation_alerts` row and publishes to RabbitMQ
- **Reconciliation Jobs** — three scheduled checks: (1) unacknowledged CRITICAL alerts older than 30 minutes, (2) pending orders without results after 4 hours, (3) active inpatients with no observation in 2 hours; each finding creates a `reconciliation_alerts` row and publishes to RabbitMQ
- **Standard Envelope** — all responses use a consistent `{ success, statusCode, data, error }` wrapper; validation errors use the same shape; `ApiBehaviorOptions` overridden so model validation also produces the standard envelope with field-level `details`
- **Standard Envelope** — all responses use a consistent `{ success, statusCode, data, error }` wrapper; validation errors use the same shape; `ApiBehaviorOptions` overridden so model validation also produces the standard envelope with field-level `details`
- **Input Validation** — FluentValidation validators on all request DTOs (patient registration, encounter open, observation ingest, alert acknowledge, alert thresholds, orders); invalid requests return 400 before reaching the service layer
- **Input Validation** — FluentValidation validators on all request DTOs (patient registration, encounter open, observation ingest, alert acknowledge, alert thresholds, orders); invalid requests return 400 before reaching the service layer
- **Observability** — Serilog structured logging enriched with `correlationId`, `encounterId`, `patientId` on alert paths; Seq sink (`http://localhost:5345`); Prometheus (`http://localhost:9101`) scrapes `GET /metrics`; thirteen application metric families via `ClinicalMetrics` and three background collectors (`AlertsUnacknowledgedCollector`, `OutboxPendingCollector`, `KafkaConsumerLagCollector`); Grafana clinical dashboard (`http://localhost:3101`, admin/admin) with `alerts_unacknowledged_gauge` as the primary safety panel; per-request correlation IDs in request logs and `X-Correlation-Id` response headers
- **Observability** — Serilog structured logging enriched with `correlationId`, `encounterId`, `patientId` on alert paths; Seq sink (`http://localhost:5345`); Prometheus (`http://localhost:9101`) scrapes `GET /metrics`; fifteen application metric families via `ClinicalMetrics` and three background collectors (`AlertsUnacknowledgedCollector`, `OutboxPendingCollector`, `KafkaConsumerLagCollector`); Grafana clinical dashboard (`http://localhost:3101`, admin/admin) with `alerts_unacknowledged_gauge` as the primary safety panel; per-request correlation IDs in request logs and `X-Correlation-Id` response headers
- **Swagger UI** — OpenAPI spec via Swashbuckle (Development only)
- **Swagger UI** — OpenAPI spec via Swashbuckle (Development only)
**Why Kafka and RabbitMQ coexist:** Kafka is an append-only log — the same observation event reaches the Elasticsearch indexer, the sepsis engine, and the data lake independently without coordination. Each consumer holds its own offset and can replay from the beginning. RabbitMQ handles the action side: one message, one worker, one page. A duplicate page at 3am is a patient safety concern, not a minor inconvenience — RabbitMQ's acknowledgment-then-delete model is correct here. The DLQ TTL-based escalation has no equivalent in Kafka.
**Why Kafka and RabbitMQ coexist:** Kafka is an append-only log — the same observation event reaches the Elasticsearch indexer, the sepsis engine, and the data lake independently without coordination. Each consumer holds its own offset and can replay from the beginning. RabbitMQ handles the action side: one message, one worker, one page. A duplicate page at 3am is a patient safety concern, not a minor inconvenience — RabbitMQ's acknowledgment-then-delete model is correct here. The DLQ TTL-based escalation has no equivalent in Kafka.
@@ -327,15 +349,19 @@ This is the architectural decision that separates thinking about healthcare syst
Observations, alerts, and orders belong to an encounter, not directly to a patient. A patient's blood pressure taken during a 2022 admission belongs to that admission. This bounds queries naturally: "show me all observations for this encounter" is a bounded query. "Show me all observations ever recorded for this patient" is a cross-encounter aggregation that belongs in the data lake.
Observations, alerts, and orders belong to an encounter, not directly to a patient. A patient's blood pressure taken during a 2022 admission belongs to that admission. This bounds queries naturally: "show me all observations for this encounter" is a bounded query. "Show me all observations ever recorded for this patient" is a cross-encounter aggregation that belongs in the data lake.
### Redis for Three Distinct Purposes
### Redis for Five Distinct Purposes
Redis serves three independent roles with different semantics:
Redis serves five independent roles with different semantics:
1.**Threshold cache:** write-through invalidation on every threshold update. Staleness here has clinical consequences — a stale threshold could suppress a critical alert. TTL expiry is not sufficient; invalidation must be immediate on write.
1.**Threshold cache:** write-through invalidation on every threshold update. Staleness here has clinical consequences — a stale threshold could suppress a critical alert. TTL expiry is not sufficient; invalidation must be immediate on write.
2.**SIRS sliding window:**`SET sirs:{encounterId}:{code} EX 1800`. The TTL does real work — a heart rate that was abnormal 31 minutes ago stops contributing to the SIRS count without any cleanup job. The 30-minute TTL is a clinical parameter, not an arbitrary cache timeout.
2.**SIRS sliding window:**`SET sirs:{encounterId}:{code} EX 1800`. The TTL does real work — a heart rate that was abnormal 31 minutes ago stops contributing to the SIRS count without any cleanup job. The 30-minute TTL is a clinical parameter, not an arbitrary cache timeout.
3.**NEWS2 parameter state:**`SET news2:{encounterId}:{code}` with a 4-hour TTL. Each of the seven NEWS2 parameters is scored individually and stored in Redis; `MGET` across all seven keys determines completeness. An incomplete set (fewer than seven present keys) does not produce a score — expired parameters must be re-recorded before the aggregate is computed.
3.**qSOFA sliding window:**`SET qsofa:{encounterId}:{code} EX 1800`. Same 30-minute TTL as SIRS, but tracking three organ-dysfunction criteria instead of four inflammatory markers. When a value normalizes, the key is explicitly deleted rather than waiting for TTL — a systolic BP that recovers from 90 to 120 should immediately reduce the qSOFA count.
4.**NEWS2 parameter state:**`SET news2:{encounterId}:{code}` with a 4-hour TTL. Each of the seven NEWS2 parameters is scored individually and stored in Redis; `MGET` across all seven keys determines completeness. An incomplete set (fewer than seven present keys) does not produce a score — expired parameters must be re-recorded before the aggregate is computed.
5.**Alert suppression windows:**`SET suppress:{encounterId}:{alertType}` with a configurable TTL (default 30 min). Set on acknowledge of suppressible alerts; checked by `WarningEvaluator` and `News2Detector` before creating new warning alerts. Critical alerts are never suppressed.
### Outbox Pattern
### Outbox Pattern
@@ -359,6 +385,10 @@ The five-minute escalation is not a retry — it is a clinical workflow. When an
A medium hospital with 200 concurrent inpatients at five observations per patient per minute produces approximately 17 observations per second at steady state. PostgreSQL with the composite index `(encounter_id, observation_code, recorded_at DESC)` handles this volume with headroom. TimescaleDB would be the correct next step at 10,000+ observations/second — it is PostgreSQL with automatic time partitioning, meaning the query layer would not change. The Parquet data lake handles the analytics workload that would otherwise stress the operational database over a 10-year horizon.
A medium hospital with 200 concurrent inpatients at five observations per patient per minute produces approximately 17 observations per second at steady state. PostgreSQL with the composite index `(encounter_id, observation_code, recorded_at DESC)` handles this volume with headroom. TimescaleDB would be the correct next step at 10,000+ observations/second — it is PostgreSQL with automatic time partitioning, meaning the query layer would not change. The Parquet data lake handles the analytics workload that would otherwise stress the operational database over a 10-year horizon.
### Dual-Path Sepsis Detection (SIRS + qSOFA)
Both SIRS and qSOFA run within the same `SepsisEngineService` Kafka consumer against every `observation.recorded` event. SIRS detects systemic inflammatory response (temperature, heart rate, respiratory rate, WBC — Sepsis-2 criteria). qSOFA detects organ dysfunction (respiratory rate, systolic BP, altered mentation — Sepsis-3 consensus). They fire independently because they measure different clinical dimensions of the same disease process. A patient can trigger SIRS without qSOFA (infection with inflammation but no organ failure) or qSOFA without SIRS (organ dysfunction without classic inflammatory markers). Both paths feed into the same sepsis bundle workflow — the bundle is idempotent, so the second alert for the same encounter does not create a duplicate bundle. This dual-path design reflects current clinical practice where neither scoring system alone captures all sepsis presentations.
---
---
## Getting Started
## Getting Started
@@ -423,7 +453,7 @@ On startup the application:
3. Pre-loads all thresholds into Redis
3. Pre-loads all thresholds into Redis
4. Provisions Kafka topics and Elasticsearch indices
4. Provisions Kafka topics and Elasticsearch indices
5. Declares the RabbitMQ exchange and queue topology
5. Declares the RabbitMQ exchange and queue topology
6. Starts all background consumers (outbox relay, ES indexer, sepsis engine, warning evaluator, NEWS2 scoring, notification workers, data lake writer, reconciliation scheduler)
6. Starts all background consumers (outbox relay, ES indexer, sepsis engine with SIRS + qSOFA, warning evaluator, NEWS2 scoring, trend analyzer, notification workers, data lake writer, reconciliation scheduler, sepsis bundle monitor)
| `SepsisBundleTests` | 14 | Bundle creation from SIRS/qSOFA, four auto-orders, element completion, compliant/non-compliant outcomes, monitor marks overdue bundles, idempotency |
### Verification Scripts
### Verification Scripts
@@ -504,14 +537,16 @@ See `docs/plans/phase-8-plan.md` through `docs/plans/phase-12-plan.md` for manua
## Prometheus Metrics
## Prometheus Metrics
`GET /metrics` exposes thirteen application metric families registered in `ClinicalMetrics`. Three background collectors poll PostgreSQL and Kafka every 30 seconds; counters and histograms are updated inline during request handling and background processing.
`GET /metrics` exposes fifteen application metric families registered in `ClinicalMetrics`. Three background collectors poll PostgreSQL and Kafka every 30 seconds; counters and histograms are updated inline during request handling and background processing.
| Metric | Type | Labels | Source |
| Metric | Type | Labels | Source |
|---|---|---|---|
|---|---|---|---|
| `observations_ingested_total` | Counter | `observation_code`, `source` | `ObservationService` on each committed observation |
| `observations_ingested_total` | Counter | `observation_code`, `source` | `ObservationService` on each committed observation |
| `observation_ingest_duration_seconds` | Histogram | — | `ObservationService` — full ingest transaction to COMMIT |
| `observation_ingest_duration_seconds` | Histogram | — | `ObservationService` — full ingest transaction to COMMIT |
| `trend_alerts_total` | Counter | `observation_code` | `TrendDetector` — on each `RAPID_DETERIORATION` alert created |
| `trend_alerts_total` | Counter | `observation_code` | `TrendDetector` — on each `RAPID_DETERIORATION` alert created |
@@ -784,6 +819,17 @@ The `population` query uses Elasticsearch's numeric range aggregation engine —
Scores are computed asynchronously by `News2ScoringService` after observations are ingested — allow a few seconds for the Kafka consumer to process all seven parameters before querying.
Scores are computed asynchronously by `News2ScoringService` after observations are ingested — allow a few seconds for the Kafka consumer to process all seven parameters before querying.
### Sepsis Bundles
| Method | Path | Description |
|---|---|---|
| GET | `/encounters/{id}/sepsis-bundle/current` | Current (most recent) sepsis bundle for an encounter (404 if none) |
| GET | `/sepsis-bundles/{id}` | Bundle detail with all elements and linked orders |
**`GET /sepsis-bundle/current` response** includes `encounterId`, `triggeringAlertId`, `triggeringAlertType` (`QSOFA_WARNING` or `SEPSIS_WARNING`), `recognizedAt`, `deadlineAt`, `complianceStatus` (`IN_PROGRESS`, `COMPLIANT`, `NON_COMPLIANT`), `completedAt`, and `elements[]` — each with `elementType`, `status`, `orderId`, and `completedAt`.
Bundles are created automatically by `SepsisAlertHandler` when a SIRS or qSOFA alert fires. Four clinical orders (blood cultures, serum lactate, broad-spectrum antibiotics, IV fluid resuscitation) are auto-created with `orderedBy: sepsis-bundle-engine`. As orders are resulted via `PATCH /orders/{id}/result`, the corresponding bundle element is marked complete. When all four elements are done, the bundle transitions to `COMPLIANT` (within the 1-hour deadline) or `NON_COMPLIANT`.
---
---
## Data Models
## Data Models
@@ -910,6 +956,38 @@ resultSummary string? Free-text result summary (set on record result)
Indexes: `(encounter_id, ordered_at DESC)`, partial `(status, ordered_at) WHERE status IN ('pending', 'in_progress')`
Indexes: `(encounter_id, ordered_at DESC)`, partial `(status, ordered_at) WHERE status IN ('pending', 'in_progress')`
Check constraints: `element_type IN (...)`, `status IN ('PENDING', 'COMPLETED')`
### OutboxEvent
### OutboxEvent
```
```
@@ -956,12 +1034,17 @@ createdAt DateTimeOffset
"openAlertCount": 2,
"openAlertCount": 2,
"news2Score": 6,
"news2Score": 6,
"news2RiskLevel": "MEDIUM",
"news2RiskLevel": "MEDIUM",
"sepsisBundleStatus": "IN_PROGRESS",
"sepsisBundleElementsCompleted": 2,
"sepsisBundleDeadlineAt": "2025-01-01T09:00:00Z",
"lastObservationAt": "2025-01-01T09:45:00Z"
"lastObservationAt": "2025-01-01T09:45:00Z"
}
}
```
```
`news2Score` and `news2RiskLevel` are optional — populated by `EsIndexerService` when a `NEWS2_WARNING` or `NEWS2_EMERGENCY` alert is indexed (payload fields `news2Score` / `news2RiskLevel` from `News2Detector`). LOW-risk scores with no alert are not projected to Elasticsearch.
`news2Score` and `news2RiskLevel` are optional — populated by `EsIndexerService` when a `NEWS2_WARNING` or `NEWS2_EMERGENCY` alert is indexed (payload fields `news2Score` / `news2RiskLevel` from `News2Detector`). LOW-risk scores with no alert are not projected to Elasticsearch.
`sepsisBundleStatus`, `sepsisBundleElementsCompleted`, and `sepsisBundleDeadlineAt` are optional — populated by `EsIndexerService` from `sepsis.bundle.created` and `sepsis.bundle.updated` Kafka events. Enables department dashboards to filter/sort encounters by sepsis bundle compliance in real time.
All topics use 6 partitions. `KAFKA_AUTO_CREATE_TOPICS_ENABLE=false` — topics are provisioned explicitly by `KafkaTopicProvisioner` to guarantee correct partition count.
All topics use 6 partitions. `KAFKA_AUTO_CREATE_TOPICS_ENABLE=false` — topics are provisioned explicitly by `KafkaTopicProvisioner` to guarantee correct partition count.
@@ -1044,7 +1129,49 @@ The sepsis engine evaluates four SIRS (Systemic Inflammatory Response Syndrome)
When ≥ 2 criteria are active simultaneously (keys present in Redis) for the same encounter and no open `SEPSIS_WARNING` alert already exists, the engine inserts a `CRITICAL` alert and outbox event. The 30-minute TTL is a clinical parameter — it bounds the window within which simultaneous SIRS criteria must co-occur.
When ≥ 2 criteria are active simultaneously (keys present in Redis) for the same encounter and no open `SEPSIS_WARNING` alert already exists, the engine inserts a `CRITICAL` alert and outbox event. The 30-minute TTL is a clinical parameter — it bounds the window within which simultaneous SIRS criteria must co-occur. A successful SIRS alert triggers sepsis bundle creation via `SepsisAlertHandler`.
---
## qSOFA Scoring (Sepsis-3)
The qSOFA (quick Sequential Organ Failure Assessment) engine evaluates three organ-dysfunction criteria per encounter, running in parallel with SIRS within the same `SepsisEngineService` Kafka consumer. Redis keys use a 30-minute TTL sliding window, identical to SIRS:
Redis key pattern: `qsofa:{encounterId}:{code}` with 30-minute TTL. When a criterion normalizes, the key is deleted immediately. When ≥ 2 of 3 criteria are active simultaneously and no open `QSOFA_WARNING` alert exists, the engine inserts a `CRITICAL` alert with details formatted as `"qSOFA score 2/3: RESP_RATE=24, SYSTOLIC_BP=95"`. A successful qSOFA alert triggers sepsis bundle creation via `SepsisAlertHandler`.
**Clinical distinction:** SIRS detects systemic inflammation (infection response); qSOFA detects organ dysfunction (Sepsis-3 consensus). Both can fire independently for the same encounter. The sepsis bundle is idempotent — if one is already in progress, the second alert does not create a duplicate.
---
## Sepsis Bundle Compliance (SEP-1)
When either a SIRS or qSOFA alert fires, `SepsisAlertHandler` calls `SepsisBundleService.TryCreateBundleAsync()` to initiate a four-element treatment bundle with a one-hour compliance deadline:
| Bundle Element | Order Type | Auto-Created Order Description |
2. Bundle created with `complianceStatus = IN_PROGRESS`, `deadlineAt = recognizedAt + 1 hour`
3. Four orders created (`orderedBy: sepsis-bundle-engine`), each linked to a bundle element
4. Outbox event → Kafka topic `sepsis.bundle.created` → ES indexer projects `sepsisBundleStatus` on encounter
5. Clinicians work through orders; `PATCH /orders/{id}/result` → `OrderService` → `SepsisBundleService.OnOrderResultedAsync()`
6. Each resulted order marks its bundle element `COMPLETED`; outbox event → `sepsis.bundle.updated` → ES update
7. When all four elements are complete: `complianceStatus = COMPLIANT` (within deadline) or `NON_COMPLIANT` (past deadline); Prometheus `sepsis_bundle_compliance_total{status}` incremented
8. If the deadline passes with incomplete elements, `SepsisBundleMonitorService` (polling every 5 min) marks the bundle `NON_COMPLIANT` — elements remain `PENDING` but the bundle status reflects the missed deadline
**Idempotency:** Only one in-progress bundle can exist per encounter. A second alert for the same encounter returns early without creating a duplicate.
---
---
@@ -1152,7 +1279,7 @@ Observation history uses cursor pagination on `(recorded_at DESC, id DESC)`. Off
## Implemented Phases
## Implemented Phases
Twelve phases from the project roadmap are implemented and verified. Integration tests (`dotnet test` — 126 passing) and per-phase verification scripts cover Phases 8–12.
Fourteen phases from the project roadmap are implemented and verified. Integration tests (`dotnet test` — 106 test methods) and per-phase verification scripts cover Phases 8–14.
| Phase | Feature | Status |
| Phase | Feature | Status |
|---|---|---|
|---|---|---|
@@ -1169,5 +1296,6 @@ Twelve phases from the project roadmap are implemented and verified. Integration
| 11 | Warning alert consumer (`WarningAlertService` / `warning-evaluator`); 10 `Warning*` alert types; Orders API (`OrdersController`, `OrderService`); FluentValidation on all request DTOs; `WarningAlertTests`, `OrderLifecycleTests`, `ValidationTests`; `run-phase11-verification.sh` | Done |
| 11 | Warning alert consumer (`WarningAlertService` / `warning-evaluator`); 10 `Warning*` alert types; Orders API (`OrdersController`, `OrderService`); FluentValidation on all request DTOs; `WarningAlertTests`, `OrderLifecycleTests`, `ValidationTests`; `run-phase11-verification.sh` | Done |
**Optional follow-up:** execute and document the Kafka replay demonstration for the data lake (reset `data-lake-writer` offsets, clear MinIO prefixes, restart API, confirm Parquet rebuild). See `docs/plans/phase-9-plan.md` § Replay demonstration.
**Optional follow-up:** execute and document the Kafka replay demonstration for the data lake (reset `data-lake-writer` offsets, clear MinIO prefixes, restart API, confirm Parquet rebuild). See `docs/plans/phase-9-plan.md` § Replay demonstration.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.