32 KiB
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
(/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. 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). |
| 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):
| 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:
- Check if a threshold is breached
- Build a details string describing the breach
- 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.