# Medication Correlation Design Decisions

**Status:** Implemented (Phase 15). Medication administration CRUD, `MedicationCorrelationHelper`, and integration with `WarningEvaluator` and `News2Detector` are in production. Verification: `./scripts/run-phase15-verification.sh` and `MedicationCorrelationTests`. The simulator scenario `VigilCare.Simulator/Scenarios/List/medication-false-alarm-01.json` exercises the end-to-end flow.

## The problem this solves

A patient with a blood pressure of 140/90 receives metoprolol (a beta-blocker that
lowers blood pressure and heart rate). Forty-five minutes later, their systolic BP reads
95 mmHg. Without medication context, the system fires a `WARNING_SYSTOLIC_BP` alert, a
NEWS2 score increase, and potentially a `RAPID_DETERIORATION` trend alert — all because
the BP dropped. But the BP dropped *because the medication is working as intended*.

Clinicians who see these false positives repeatedly stop trusting the alert system. At
that point, the system is worse than useless — it trains people to ignore alerts,
including the real ones.

Phase 15 addresses this by **annotating** alerts with medication context. The alert still
fires (the BP is genuinely low and may need monitoring), but the details say:

> SYSTOLIC_BP value 95 is below warning low of 90. — note: metoprolol 25mg (PO)
> administered 45 min ago

That single line changes the clinical interpretation from "something is wrong" to "the
medication is working — keep monitoring."

The authoritative endpoint list lives in the [API Reference](../../README.md#api-reference)
(`/api/v1` prefix, standard `{ success, statusCode, data, error }` envelope). For the
full patient journey from registration through discharge — not only medication correlation —
see [Patient Encounter API Lifecycle](../patient-encounter-api-lifecycle.md). This
document explains how medication correlation fits into that surface — which endpoints
write administrations, which paths produce annotated alerts, and which endpoints are
deliberately outside the correlation scope.

---

## Relationship to the full API

Medication correlation is not a standalone micro-API. It is a cross-cutting concern that
connects medication write endpoints, observation ingest, asynchronous alert consumers, and
alert read endpoints. The table below maps every `/api/v1` resource group to its role.

| Resource group | Endpoints | Role in medication correlation |
|---|---|---|
| **Patients** | `POST/GET /patients`, `GET /patients/{id}` | Prerequisite only — patients must exist before encounters are opened. No correlation logic. |
| **Encounters** | `GET/POST /encounters`, `PATCH /encounters/{id}/status`, `GET /encounters/{id}/timeline`, `GET /encounters/{id}/qsofa/current` | Medications and observations are scoped to an **active** encounter. `GET /encounters/{id}` and `GET /encounters/{id}/timeline` return open alerts whose `details` may already include server-side annotations; the timeline does **not** include medication administrations. |
| **Alert thresholds** | `POST/GET/PUT /alert-thresholds` | Defines the warning/critical bands that `WarningEvaluator` compares against. Threshold changes invalidate the Redis cache but do not affect drug-vital mappings (`MedicationCorrelation` config is separate). |
| **Observations** | `POST/GET /encounters/{id}/observations` | **Write path** that eventually triggers correlation. `POST` returns `alertGenerated` only for **critical** breaches created synchronously (never annotated). Warning and NEWS2 alerts are created asynchronously by Kafka consumers and include annotations when applicable. |
| **Clinical alerts** | `GET /encounters/{id}/alerts`, `GET /alerts`, `GET /alerts/{id}`, `POST /alerts/{id}/acknowledge`, `POST /alerts/{id}/resolve` | **Read path** where annotations surface. The `details` field on `ClinicalAlert` carries the `— note: …` suffix. Acknowledge/resolve lifecycle is unchanged. |
| **Orders** | `POST/GET /encounters/{id}/orders`, `GET/PATCH /orders/{id}` | Unrelated to correlation. Sepsis-bundle orders are auto-created by the sepsis engine, not by medication recording. |
| **Analytics** | `GET /analytics/patients`, `/observations/trend`, `/alerts/summary`, `/population` | Elasticsearch-backed reporting. Alert summaries index the annotated `details` text but do not run correlation themselves. |
| **NEWS2** | `GET /encounters/{id}/news2/current`, `GET /encounters/{id}/news2/history` | Scores are computed asynchronously (`news2-scoring` consumer). When a NEWS2 warning/emergency alert is created, its `details` may be annotated the same way as threshold warnings. Score history endpoints return numeric components only — annotations live on the alert, not the score row. |
| **Sepsis bundles** | `GET /encounters/{id}/sepsis-bundle/current`, `GET /sepsis-bundles/{id}` | Deliberately excluded from annotation (see [What is NOT annotated](#what-is-not-annotated-and-why)). |
| **Medications** | `POST/GET /encounters/{id}/medications`, `GET /medications/{id}` | **Write and read path** for administrations. Correlation reads via `MedicationService.GetRecentForEncounterAsync`; HTTP clients (dashboard, simulator) use the list endpoint to show recent drugs alongside alert reasoning. |

There is no dedicated "correlate" or "annotate" endpoint. Correlation runs inside background
consumers at alert-creation time and is visible only through alert `details` and
medication list responses.

### End-to-end API choreography (metoprolol example)

A typical integration or simulator run exercises these endpoints in order:

```
1. POST /api/v1/patients                          → register patient (MRN assigned)
2. POST /api/v1/patients/{id}/encounters          → open encounter (status: active)
3. POST /api/v1/encounters/{id}/medications       → record metoprolol 25mg PO
4. POST /api/v1/encounters/{id}/observations      → ingest SYSTOLIC_BP = 95
        │
        ├─ HTTP 201: observation row created; alertGenerated = false
        │            (warning path is async — no annotation in this response)
        │
        └─ Kafka observation.recorded
                → WarningAlertService (consumer group: warning-evaluator)
                → MedicationCorrelationHelper annotates details
                → clinical_alerts row + alert.generated outbox event
5. GET  /api/v1/encounters/{id}/alerts            → poll until WARNING_SYSTOLIC_BP appears
        details: "SYSTOLIC_BP value 95 is below warning low of 90. — note: metoprolol 25mg (PO) administered 45 min ago"
6. GET  /api/v1/encounters/{id}/medications       → optional; dashboard uses this for the
        "Recent medications" panel in alert reasoning (client-side 90-min window)
```

Allow a few seconds between steps 4 and 5 for the Kafka consumer to process the
`observation.recorded` event. NEWS2 follows the same pattern via `news2-scoring` after
all seven parameters are present.

### Where annotations appear (and where they do not)

| Response field / endpoint | Contains annotation? | Notes |
|---|---|---|
| `POST …/observations` → `alertGenerated` / inline alert | No (warnings) | Only critical alerts return synchronously; critical path never calls `MedicationCorrelationHelper`. |
| `GET …/alerts`, `GET /alerts/{id}`, `GET /encounters/{id}` (embedded alerts) | Yes (warning + NEWS2) | Primary consumer surface. `details` is plain text with appended `— note: …`. |
| `GET …/encounters/{id}/timeline` | Yes (on alert events) | Timeline merges observations and alerts; alert events include annotated `details`. |
| `alert.generated` Kafka / outbox payload | Yes | `details` in the event matches the persisted alert row. |
| `GET …/news2/current`, `GET …/news2/history` | No | Scores only; read the corresponding NEWS2 alert for annotated context. |
| `GET …/medications` | No | Returns raw administration rows; dashboard correlates client-side for the reasoning panel. |

---

## How the pieces fit together

There are six components. Here is how a request flows through them, starting
from when a nurse records a medication and ending when an annotated alert is created.

```
Nurse records medication
        │
        ▼
┌─────────────────────┐
│ MedicationsController│ ◄── thin HTTP layer, no business logic
│  POST /api/v1/      │
│  encounters/{id}/   │
│  medications        │
└────────┬────────────┘
         │
         ▼
┌─────────────────────┐
│ FluentValidation    │ ◄── rejects bad input before it reaches the service
│ (auto-registered)   │     (empty drug name, zero dose, future timestamps)
└────────┬────────────┘
         │
         ▼
┌─────────────────────┐
│ MedicationService   │ ◄── checks encounter exists and is active,
│                     │     persists to PostgreSQL
└─────────────────────┘

         ... time passes, vital signs arrive ...

POST /api/v1/encounters/{id}/observations  (e.g. SYSTOLIC_BP = 95)
        │
        ▼
┌─────────────────────┐
│ ObservationService  │ ◄── sync path: critical alerts only (never annotated)
│  COMMIT + outbox    │     always emits observation.recorded → Kafka
└────────┬────────────┘
         │
         ▼ (async, consumer group: warning-evaluator)
┌─────────────────────┐
│ WarningEvaluator    │ ◄── or News2Detector (consumer group: news2-scoring)
│                     │
│  1. Load threshold  │     (from Redis cache)
│  2. Check breach    │     (is 95 < warningLow of 90?)
│  3. Build details   │     ("SYSTOLIC_BP value 95 is below warning low of 90.")
│  4. ► Annotate ◄    │     MedicationCorrelationHelper
│  5. INSERT alert    │     (idempotent — skips if one already open)
└────────┬────────────┘
         │
    step 4 calls
         │
         ▼
┌────────────────────────────┐
│ MedicationCorrelationHelper│
│                            │
│  "Was a relevant drug      │
│   given to this patient    │
│   within the last 90       │
│   minutes?"                │
│                            │
│  Uses MedicationService    │
│  .GetRecentForEncounterAsync│
│  to query PostgreSQL       │
│                            │
│  Uses DrugVitalMappings    │
│  from config to decide     │
│  which drugs are "relevant"│
│  to which vital signs      │
└────────────────────────────┘

         ... client reads result ...

GET /api/v1/encounters/{id}/alerts  →  annotated details in response
```

---

## Component-by-component: what it does and why it was built that way

### 1. MedicationAdministration entity

**What it is:** A database table (`medication_administrations`) that records when a drug
was given to a patient. Each row captures the drug name, dose, unit, route (PO = oral,
IV = intravenous, etc.), timestamp, and who gave it.

**Why it's a separate table (not a column on `observations`):**
Medications and vital sign observations are fundamentally different things. An
observation is a measurement — the patient's heart rate *is* 105 bpm right now. A
medication administration is an action — a nurse *gave* metoprolol 25mg at 10:15 AM.
They have different fields (dose, route, administered_by vs. value, unit, source),
different lifecycles, and different access patterns. Mixing them into one table would
require nullable columns everywhere and make queries harder to reason about.

**Why `decimal(10,4)` for dose:**
Medication doses can be fractional — 0.5mg of atropine, 2.5mg of metoprolol. Using an
integer would lose precision. Using `float`/`double` introduces floating-point rounding
errors (0.1 + 0.2 = 0.30000000000000004), which is unacceptable for medical dosing.
`decimal` stores exact values. The `(10,4)` precision allows doses up to 999999.9999,
which covers everything from micrograms to grams.

**Why two composite indexes:**
- `(encounter_id, administered_at)` — the correlation query asks "what drugs were given
  to this encounter in the last 90 minutes?" This index makes that query fast.
- `(encounter_id, drug_name)` — supports the list endpoint when filtering by drug.

Without these indexes, every correlation check would do a full table scan on the
medication_administrations table. On a busy hospital with thousands of medication records
per day, that would add visible latency to every alert evaluation.

**Why `DeleteBehavior.Restrict` on the Encounter FK:**
If someone accidentally tries to delete an encounter that has medication records, the
database will reject the delete rather than silently cascade-deleting the medication
history. Medical records should never be silently deleted.

---

### 2. MedicationCorrelationOptions (configuration)

**What it is:** A C# class that maps drug names to the vital signs they affect. For
example, metoprolol maps to `["SYSTOLIC_BP", "HEART_RATE", "DIASTOLIC_BP"]` because
beta-blockers lower blood pressure and heart rate. It also holds the correlation window
(default 90 minutes).

**Why this is config, not a database table:**
Drug-vital mappings change rarely — new drugs don't appear every week, and the
physiological effects of existing drugs don't change. Putting them in `appsettings.json`
means:
- They're version-controlled with the code (you can see in git who changed them and why)
- They're loaded once at startup, not queried from the database on every alert
- They can be overridden per environment (test vs. production) using the standard
  ASP.NET Core configuration hierarchy
- No migration needed when a mapping changes — just update the config and restart

If this were a database table, you'd need an admin UI, migration scripts, and a cache
layer to avoid querying the table on every observation. The config approach avoids all of
that complexity for data that changes maybe once a quarter.

**Why 90 minutes as the default window:**
Most oral medications reach peak effect within 60-90 minutes. IV medications act faster
(minutes), but their effects also persist. A 90-minute window catches most therapeutic
scenarios. If it were too short (30 minutes), oral medications like metoprolol (peak
effect at 60 minutes) would be missed. If it were too long (6 hours), unrelated
medication effects would pollute the annotation. The value is configurable per
environment if a clinical team wants to adjust it.

**Why drug names are lowercase keys:**
Drug names in clinical systems come in all forms — "Metoprolol", "METOPROLOL",
"metoprolol". By normalizing keys to lowercase in the config and doing case-insensitive
lookups, the correlation works regardless of how the nurse typed the drug name. This
avoids a class of bugs where correlation silently fails because the case doesn't match.

The shipped `appsettings.json` includes mappings for common cardiovascular, vasopressor,
opioid, sedative, diuretic, and antibiotic agents — not just metoprolol. Add or adjust
entries under `MedicationCorrelation:DrugVitalMappings` without a migration.

---

### 3. MedicationService

**What it is:** The business logic layer for medication administration CRUD. It handles
creating records, listing them with pagination, looking up by ID, and the critical
`GetRecentForEncounterAsync` method used by the correlation helper.

**Why the service throws exceptions instead of returning error codes:**
The project uses a pattern where services throw typed domain exceptions
(`NotFoundException`, `ConflictException`) and a global `ExceptionHandlerMiddleware`
converts them into HTTP responses (404, 409). This pattern means:
- The service doesn't know or care about HTTP — it could be called from a Kafka consumer,
  a background job, or a test, and the error handling still works
- The controller stays thin (no `if (result.IsError) return NotFound(...)` chains)
- Error handling is consistent across every endpoint — one middleware, one set of rules

**Why `GetRecentForEncounterAsync` reverses the mapping:**
The config maps drugs → vital signs (metoprolol → SYSTOLIC_BP). But the correlation
query starts from the other direction: "I have a SYSTOLIC_BP alert — which drugs affect
SYSTOLIC_BP?" The method inverts the mapping at query time: it scans all entries in
`DrugVitalMappings` to find keys whose values contain the target observation code, then
queries the database for those specific drug names.

You might ask: "Why not store the mapping both ways?" Because maintaining two maps that
must stay in sync is a bug waiting to happen. The drug → vitals direction is the natural
way clinicians think about it ("metoprolol affects blood pressure"), so that's how it's
stored. The reverse lookup happens in code, where it's guaranteed to be consistent.

**Why the DB query uses `relevantDrugs.Contains(m.DrugName.ToLower())`:**
This translates to a SQL `WHERE drug_name IN (...)` clause, which PostgreSQL handles
efficiently with the composite index. The alternative — loading all medications and
filtering in memory — would transfer unnecessary data from the database. Let the database
do what it's good at.

---

### 4. MedicationsController

**What it is:** Three HTTP endpoints under `/api/v1` (see also
[Medications in the API Reference](../../README.md#medications)):

| Method | Path | Purpose |
|---|---|---|
| `POST` | `/encounters/{encounterId}/medications` | Record a new administration |
| `GET` | `/encounters/{encounterId}/medications` | List with pagination and optional `since` filter |
| `GET` | `/medications/{id}` | Look up a single record (includes nested `encounter`) |

**POST body** (`CreateMedicationAdministrationRequest`):

| Field | Type | Required | Notes |
|---|---|---|---|
| `drugName` | string | yes | Trimmed on persist; case-insensitive for `DrugVitalMappings` lookup |
| `dose` | decimal | yes | Must be > 0 |
| `doseUnit` | string | yes | e.g. `mg`, `g`, `mcg`, `units` |
| `route` | string | yes | e.g. `PO`, `IV`, `SubQ` |
| `administeredAt` | DateTimeOffset | no | Defaults to server UTC time if omitted |
| `administeredBy` | string | yes | Clinician or nurse identifier |

**POST response:** `201 Created` with `ApiResponse<MedicationAdministration>` in the
standard envelope. The `data` object includes `id`, `encounterId`, `drugName`, `dose`,
`doseUnit`, `route`, `administeredAt`, `administeredBy`.

**POST status codes:**

| Code | When |
|---|---|
| `201` | Administration recorded |
| `400` | FluentValidation failure (empty drug name, zero dose, `administeredAt` > 5 min in future, field length exceeded) |
| `404` | Encounter not found (`ENCOUNTER_NOT_FOUND`) |
| `409` | Encounter not active (`ENCOUNTER_NOT_ACTIVE`) |

**GET list response:** `200 OK` with paginated `{ items, page, pageSize, totalCount, totalPages }`.
Query params: `since` (ISO 8601 — administrations at or after this time), `page` (default 1),
`pageSize` (default 20). The dashboard's `fetchMedications` helper pages through this
endpoint (default `pageSize` 50) to populate the alert-reasoning panel.

**GET by id response:** `200 OK` with full `MedicationAdministration` (404 if not found).

**Why medication POST does not trigger correlation:**
Recording a drug does not create or modify alerts. Correlation is evaluated only when a
warning or NEWS2 alert is **created** — triggered by observation ingest (async) or NEWS2
scoring (async). This keeps the medication write path fast and idempotent with no
side effects beyond the `medication_administrations` row.

**Why the controller is almost empty:**
The controller's only job is to translate HTTP concepts (route parameters, query strings,
status codes) into service calls and back. All business logic lives in
`MedicationService`. This is the same pattern used by `OrdersController` and every other
controller in the project. The benefit: you can test business logic by calling the
service directly (no need to spin up an HTTP server), and the controller is so simple
that bugs in it are immediately obvious.

**Why `POST` returns 201, not 200:**
HTTP 201 means "a new resource was created." This follows REST conventions and tells the
client that the medication record now exists and can be fetched at the returned URL. It's
a small detail, but API consumers (other teams, integration partners) expect it.

---

### 5. FluentValidation

**What it is:** A validator that rejects invalid requests before they reach the service
layer. It checks: drug name is not empty, dose is greater than zero, future timestamps
are rejected (a medication can't be recorded as given tomorrow), and string fields don't
exceed database column limits.

**Why validation is separate from the service:**
Validation and business rules are different things. Validation asks "is this request
well-formed?" (non-empty drug name, positive dose). Business rules ask "is this operation
allowed?" (encounter must be active). Keeping them separate means:
- Validation runs automatically via FluentValidation middleware — no manual calls needed
- Validation errors return 400 (bad request), business rule violations return 404/409
- The service can trust that inputs are well-formed and focus on domain logic
- Validators are auto-registered by scanning the assembly — adding a new validator just
  means creating a class

**Why `AdministeredAt` allows up to 5 minutes in the future:**
Clock skew. The nurse's tablet might be a few minutes ahead of the server. Rejecting a
timestamp that's 30 seconds in the future because of clock drift would be frustrating.
Five minutes is generous enough to handle clock differences without allowing genuinely
wrong timestamps (like recording a medication as given tomorrow).

---

### 6. MedicationCorrelationHelper

**What it is:** The bridge between the medication subsystem and the alerting subsystem. It
has one public method: `TryAnnotateDetailsAsync`. Given an encounter ID, an observation
code, and the existing alert details string, it checks if a relevant medication was given
within the correlation window. If yes, it appends a note. If no, it returns the original
string unchanged.

**Why it's a separate class (not inline in WarningEvaluator):**
Two different alert paths need medication annotation: `WarningEvaluator` (for threshold
warnings) and `News2Detector` (for composite NEWS2 scores). If the annotation logic lived
inside `WarningEvaluator`, it would need to be duplicated in `News2Detector`. The helper
class means both callers share the same logic, and if the annotation format changes, it
changes in one place.

**Why it annotates rather than suppresses:**
A patient's BP of 95 after metoprolol is *expected* but still *clinically relevant*. The
BP is genuinely low. If a nurse doubled the dose by mistake, or if the patient is
hypotensive beyond what the medication should cause, the alert needs to fire. Suppressing
it entirely would be a patient safety risk. Annotation gives the clinician the context to
make that judgment — it says "this might be the medication" without saying "ignore this."

Smart suppression (automatically silencing alerts that are clearly medication-related) is
deferred to a future phase because it requires more sophisticated logic: was the drop
proportional to the dose? Is the patient also septic? These judgment calls are hard to
encode safely in software.

**Why it only annotates the most recent matching medication:**
If a patient received metoprolol at 9:00 AM and again at 10:30 AM, and their BP drops at
11:00 AM, the 10:30 AM dose is the one the clinician cares about — it's the most likely
cause. Listing every medication in the window would clutter the alert. The most recent
match gives the most actionable context.

---

## What is NOT annotated and why

| Alert path | Annotated? | Reason |
|---|---|---|
| `WarningEvaluator` | Yes | Threshold warnings are the primary source of medication-related false positives |
| `News2Detector` | Yes | NEWS2 is a composite of 7 vital signs — any of which could be medication-affected |
| `TrendDetector` | No | A rapid trajectory change after medication is still clinically important — the *rate* of change matters even if the medication explains it |
| `SirsDetector` / `QsofaDetector` | No | Sepsis alerts should never be downplayed by medication context — missing sepsis kills patients |
| Critical alerts (sync path) | No | Critical values (e.g., SBP below 70) are emergencies regardless of medication context — patient safety takes priority |

This is a deliberate clinical safety decision. The cost of a false negative (missing a
real emergency because the system said "it's just the medication") is much higher than the
cost of a false positive (an extra annotation on an expected alert).

---

## How the annotation hooks into existing alert creation

Both `WarningEvaluator` and `News2Detector` already follow a pattern:

1. Check if a threshold is breached
2. Build a details string describing the breach
3. INSERT the alert into the database

Phase 15 inserts one step between 2 and 3:

```
2. Build details string
2.5 ► Call MedicationCorrelationHelper.TryAnnotateDetailsAsync ◄
3. INSERT the alert (with the possibly-annotated details)
```

This insertion point required no changes to the threshold logic, the idempotent
INSERT pattern, the outbox event publishing, or the alert suppression logic. Each of
those systems continues to work exactly as before.

The annotated details string is also included in the outbox event payload, so downstream
consumers (notifications, dashboards) receive the medication context without needing
their own correlation logic.

Clients that poll `GET /api/v1/encounters/{id}/alerts` or `GET /api/v1/alerts/{id}` read
the same annotated `details` string. The ward dashboard (`vigilcare-dashboard`) adds a
second layer: `AlertReasoning.vue` fetches `GET …/medications` and shows administrations
within 90 minutes before `alert.triggeredAt`, independent of whether the server appended
the `— note:` suffix (e.g. when the drug name is not in `DrugVitalMappings`).

### News2Detector annotation strategy

NEWS2 is different from single-vital warnings because it's a composite score of 7
parameters (respiratory rate, SpO2, systolic BP, heart rate, consciousness,
temperature, supplemental O2). A single NEWS2 alert doesn't correspond to one
observation code — it's a rollup.

The annotation loops through all 7 parameter codes and checks if any has a correlated
medication. It appends the first match found. This keeps the annotation concise (one
drug per alert) while still catching the most relevant context. If a patient received
both morphine (affects RESP_RATE) and metoprolol (affects SYSTOLIC_BP), the annotation
shows whichever parameter code comes first in the loop — which is fine, because the
purpose is to flag "there's medication context here" rather than to provide a complete
pharmaceutical review.

---

## Data flow summary

```
                    HTTP POST /api/v1/encounters/{id}/medications
                        │
                        ▼
               ┌────────────────┐
               │  Medications   │
               │  Controller    │
               └───────┬────────┘
                       │
                       ▼
               ┌────────────────┐       ┌─────────────────┐
               │  Medication    │──────►│   PostgreSQL     │
               │  Service       │       │  medication_     │
               └────────────────┘       │  administrations │
                                        └────────┬────────┘
                                                 │
   HTTP POST /api/v1/encounters/{id}/observations
   (observation row + observation.recorded outbox)
                        │                        │
                        ▼                        │
               ┌────────────────┐                │
               │  Kafka         │                │
               │  observation.  │                │
               │  recorded      │                │
               └───────┬────────┘                │
                       │                         │
          ┌────────────┴────────────┐           │
          ▼                         ▼           │
   ┌──────────────┐         ┌──────────────┐    │
   │ WarningAlert │         │ News2Scoring │    │
   │ Service      │         │ Service      │    │
   └──────┬───────┘         └──────┬───────┘    │
          │                        │            │
          ▼                        ▼            │
   ┌────────────────┐       ┌────────────────┐  │
   │  Warning       │       │  News2         │  │
   │  Evaluator     │       │  Detector      │  │
   │       │        │       │       │        │  │
   │       ▼        │       │       ▼        │  │
   │  Correlation   │◄──────┴───────┘        │
   │  Helper        │◄────────────────────────┘
   │       │        │   queries recent meds
   │       ▼        │   for encounter + code
   │  annotated     │
   │  details       │
   │       │        │
   │       ▼        │
   │  INSERT alert  │──────► PostgreSQL clinical_alerts
   │  + outbox event│──────► outbox_events → Kafka alert.generated
   └────────────────┘
                        │
                        ▼
               GET /api/v1/encounters/{id}/alerts
               GET /api/v1/alerts/{id}
               (annotated details in JSON response)
```

---

## Testing strategy

Twelve tests across three files cover the medication subsystem:

**MedicationServiceTests (4 tests)** — tests the service layer directly. Can a medication
be created on an active encounter? Does a discharged encounter get rejected? Does
pagination work? Does the time-window filter exclude old records? These tests call
`IMedicationService` methods directly, bypassing HTTP.

**MedicationCorrelationTests (5 tests)** — tests the full integration from medication
recording through alert creation. These seed a medication into the database, then invoke
`WarningEvaluator.EvaluateAsync` (and NEWS2 paths where applicable) and check whether the
resulting alert's `details` field contains the medication annotation. This is the most
important test file because it verifies the end-to-end behavior the feature exists to provide.

**MedicationValidationTests (3 tests)** — tests the HTTP validation layer. These send
invalid requests via `HttpClient` and assert 400 responses. They don't seed encounters
because the validator rejects the request before the service layer runs.

Run with `dotnet test --filter "FullyQualifiedName~Medication"` or
`./scripts/run-phase15-verification.sh` (requires API + Docker Compose).

All tests run against a real PostgreSQL database and real Redis instance (using test
containers on different ports). No mocking. This means the tests catch real issues like
SQL translation failures, index problems, and configuration registration mistakes that
mocks would miss.
