# 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` 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.