diff --git a/docs/decisions/medication-correlation-design.md b/docs/decisions/medication-correlation-design.md new file mode 100644 index 0000000..c0c59c8 --- /dev/null +++ b/docs/decisions/medication-correlation-design.md @@ -0,0 +1,407 @@ +# Medication Correlation Design Decisions + +## 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 solves 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." + +--- + +## How the pieces fit together + +There are six components in Phase 15. 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 /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 ... + +New observation arrives (e.g. SYSTOLIC_BP = 95) + │ + ▼ +┌─────────────────────┐ +│ WarningEvaluator │ ◄── or News2Detector for composite scores +│ │ +│ 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 ◄ │ NEW in Phase 15 +│ 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 │ +└────────────────────────────┘ +``` + +--- + +## 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. + +--- + +### 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: +- `POST /api/v1/encounters/{encounterId}/medications` — record a new administration +- `GET /api/v1/encounters/{encounterId}/medications` — list with pagination and optional + `since` filter +- `GET /api/v1/medications/{id}` — look up a single record + +**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 adds 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 minimal insertion point means 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. + +### 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 + │ + ▼ + ┌────────────────┐ + │ Medications │ + │ Controller │ + └───────┬────────┘ + │ + ▼ + ┌────────────────┐ ┌─────────────────┐ + │ Medication │──────►│ PostgreSQL │ + │ Service │ │ medication_ │ + └────────────────┘ │ administrations │ + └────────┬────────┘ + │ + ... later, observation arrives ... │ + │ + ┌────────────────┐ │ + │ Warning │ │ + │ Evaluator │ │ + │ │ │ + │ builds details│ │ + │ │ │ │ + │ ▼ │ │ + │ Correlation │◄───────────────┘ + │ Helper │ queries recent meds + │ │ │ for this encounter + + │ ▼ │ observation code + │ annotated │ + │ details │ + │ │ │ + │ ▼ │ + │ INSERT alert │──────► PostgreSQL clinical_alerts + │ + outbox event│──────► outbox_events → Kafka + └────────────────┘ +``` + +--- + +## Testing strategy + +The tests are structured in three files, each targeting a different layer: + +**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 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 that Phase 15 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. + +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.