# Patient Encounter API Lifecycle

**Audience:** API integrators, simulator authors, dashboard developers, and anyone who needs to understand how a patient moves through VigilCare from registration to discharge.

**What is VigilCare?** VigilCare is a clinical monitoring system that tracks patients in a hospital. It continuously watches vital signs (like heart rate, blood pressure, and temperature), calculates safety scores, and fires alerts when something looks wrong — so doctors and nurses can respond quickly.

**Companion docs:**

- [API Reference](../README.md#api-reference) — authoritative endpoint contracts and request bodies
- [Medication correlation design](decisions/medication-correlation-design.md) — how drug context annotates warning/NEWS2 alerts
- [Simulator guide](simulator-guide.md) — replay JSON scenarios against this lifecycle
- [Dashboard guide](dashboard-guide.md) — which endpoints the Vue ward UI polls
- [Mirth FHIR integration](integration/mirth-fhir-channels.md) — HL7v2 → FHIR R4 ingest via integration engines

---

## What this document covers

A hospital stay in VigilCare is called an **encounter**, and it belongs to a **patient**. Think of it this way: a patient is a person, and an encounter is one visit to the hospital. The same patient can have many encounters over their lifetime.

Everything clinical — measurements, alerts, medication records, lab orders, safety scores, sepsis bundles — is attached to an encounter while it is `ACTIVE` (meaning the patient is still in the hospital).

This guide walks through the lifecycle in order: what API calls to make, what happens right away in the response, what happens a few seconds later in the background, and what stops working after the patient leaves.

There is no single "admit patient" button. Instead, you combine the standard REST endpoints below.

---

## Quick glossary of medical terms

Before we dive in, here are the medical terms you will see throughout this document:

| Term | What it means |
|---|---|
| **Vital signs** | Basic body measurements that show how a patient is doing — heart rate, blood pressure, temperature, breathing rate, oxygen level |
| **Observation** | Any measurement recorded for a patient (vitals, lab results, consciousness checks) |
| **Sepsis** | A life-threatening condition where the body's response to an infection starts damaging its own organs. It can kill quickly if not treated |
| **NEWS2** | National Early Warning Score 2 — a scoring system that combines 7 vital signs into a single number (0–20) to detect patients who are getting sicker |
| **SOFA** | Sequential Organ Failure Assessment — a score (0–24) that measures how well 6 organ systems are working. Used to detect organ failure and sepsis |
| **qSOFA** | Quick SOFA — a fast bedside check using just 3 things (breathing rate, blood pressure, consciousness). Used as an early screen: "should we look more closely for sepsis?" |
| **GCS** | Glasgow Coma Scale — measures how conscious/awake a patient is (3–15). Lower is worse. A score of 3 means completely unresponsive, 15 means fully alert |
| **AVPU** | A simpler consciousness scale: **A**lert, responds to **V**oice, responds to **P**ain, **U**nresponsive |
| **Threshold** | A boundary value. If a measurement crosses a threshold, it triggers an alert |
| **Alert** | A notification to clinical staff that something needs attention — like an alarm going off |
| **Sepsis bundle** | A checklist of 4 things that must be done within 1 hour when sepsis is suspected (blood tests, antibiotics, IV fluids) |
| **MRN** | Medical Record Number — the human-readable ID for a patient (e.g. `MRN-000042`) |

---

## Quick glossary of technology terms

VigilCare uses several specialized technologies behind the scenes. Here is what each one does and why the system needs it:

| Technology | What it is | How VigilCare uses it |
|---|---|---|
| **PostgreSQL** | A relational database — think of it as a giant spreadsheet that stores data permanently and safely | The main database. Stores all patients, encounters, observations, alerts, orders, medications, scores, and sepsis bundles. When you call any API endpoint, the data is read from or written to PostgreSQL |
| **Redis** | An in-memory cache — like a sticky note board that is extremely fast to read but does not survive a full restart without backup | Stores data that needs to be looked up instantly: alert thresholds, NEWS2 parameter windows, qSOFA criteria, SOFA lab values, trend history, and alert suppression timers. Much faster than querying the database every time |
| **Kafka** | A message bus — like a conveyor belt that carries messages from one part of the system to another | When a measurement is recorded, the API puts a message on the Kafka conveyor belt. Multiple background workers pick up that message independently: one calculates NEWS2, another checks for sepsis, another updates the search index, etc. This is why scoring happens in the background, not in the HTTP response |
| **RabbitMQ** | A message queue — like a to-do list for background tasks that need to happen in order and must not be lost | Handles clinician notifications (paging), alert escalation (when nobody responds to a critical alert within 5 minutes), and discharge summary generation. Unlike Kafka (which broadcasts to many listeners), RabbitMQ ensures each task is done exactly once |
| **Elasticsearch** | A search and analytics engine — like a search engine built specifically for this hospital's data | Powers the analytics endpoints (patient search, observation trends, alert summaries). Data flows from Kafka into Elasticsearch so you can run fast searches and aggregations across all patients and encounters without slowing down the main database |
| **MinIO** | An object store — like a file cabinet for large files, compatible with Amazon S3 | Stores two things: (1) discharge summary PDFs generated when patients leave, and (2) Parquet data files (a compact format for large datasets) used for long-term analysis and machine learning |
| **Prometheus** | A monitoring tool — watches the system itself (not the patients) | Collects metrics like "how many observations were recorded this hour," "how many alerts fired," "how long does an ingest take." Helps operators know if the system is healthy |
| **Grafana** | A dashboard tool for Prometheus metrics | Displays charts and graphs about system health — things like observation ingest rate, alert volumes, consumer lag, and response times |
| **Docker** | A container platform — packages each technology into an isolated box that runs the same way everywhere | All the services above (PostgreSQL, Redis, Kafka, etc.) run as Docker containers, orchestrated by a single `docker-compose.yml` file. One command starts the entire system |

---

## How the technology stack works together

Understanding how data flows through these technologies helps explain why some things happen immediately and others take a few seconds.

### The outbox pattern — how data gets from the API to background workers

When a nurse records a heart rate of 95 bpm, here is what happens under the hood:

```
1. API receives POST /observations
2. Inside ONE database transaction (all-or-nothing):
   - Save the observation to PostgreSQL
   - If critical threshold breached: save the alert to PostgreSQL too
   - Write an "outbox event" row to PostgreSQL (a message waiting to be sent)
3. COMMIT — everything saved atomically

4. OutboxRelayService (runs every 500ms):
   - Reads unsent outbox events from PostgreSQL (using FOR UPDATE SKIP LOCKED for safe concurrency)
   - Sends them to the correct Kafka topic via an idempotent producer (broker deduplicates retries)
   - Marks them as processed
   - If Kafka produce fails: increments retry counter and tries again on next poll
   - After 10 failed attempts: marks the event as permanently failed (dead-lettered) — stops blocking other events
```

**Why this pattern?** It guarantees that if the observation is saved, the message to Kafka will also be sent — even if Kafka is temporarily down. The database acts as a reliable staging area. This is called the "transactional outbox pattern."

**What about permanently failed events?** If a message truly cannot be delivered after 10 attempts (corrupted payload, topic deleted, etc.), it is marked `FailedAt` and excluded from future relay polls. This prevents a single poisoned event from blocking all subsequent messages. Operators can query failed events via `SELECT * FROM outbox_events WHERE failed_at IS NOT NULL` for manual investigation.

### Kafka topics — the conveyor belts

Each Kafka topic carries a specific type of message. Think of them as labeled conveyor belts in a factory:

| Topic | What flows through it | Who produces it | Who consumes it |
|---|---|---|---|
| `observation.recorded` | Every measurement recorded by any device, nurse, or lab | Outbox relay (from API) | Warning evaluator, NEWS2 scorer, sepsis engine, SOFA scorer, GCS scorer, trend analyzer, ES indexer, data lake writer |
| `alert.generated` | Every alert created by any engine | Outbox relay (from scoring engines) | ES indexer, data lake writer, notification publisher (→ RabbitMQ) |
| `encounter.status.changed` | Admissions, discharges, cancellations | Outbox relay (from API) | ES indexer, data lake writer, notification publisher (→ discharge queue) |
| `sepsis.bundle.created` | New sepsis bundles | Outbox relay (from SOFA engine) | ES indexer |
| `sepsis.bundle.updated` | Bundle element completions and compliance changes | Outbox relay (from order results) | ES indexer |
| `gcs.scored` | GCS score calculations | Outbox relay (from GCS scorer) | ES indexer |

**Key detail:** One message on `observation.recorded` is consumed by up to 8 different workers independently. Each worker has its own "consumer group" — Kafka tracks where each group left off, so every worker gets every message even if they process at different speeds.

**Poison pill protection:** If a consumer receives a malformed or un-processable message (corrupted JSON, invalid format), the system does not get stuck retrying it forever. A "poison pill guard" detects permanent errors and skips them immediately. For transient errors (temporary network issues), it retries up to 5 times before giving up on that message. Skipped messages are logged and tracked by a Prometheus metric (`kafka_poison_pills_skipped_total`) so operators know if something is wrong with the data flowing through the system.

### Redis — the fast-lookup layer

Redis stores data that needs to be checked on every observation ingest or scoring calculation. Reading from Redis takes less than 1 millisecond, compared to 5–50ms for a database query.

| What is stored | Redis key pattern | Why it is in Redis | How long it stays (TTL) |
|---|---|---|---|
| Alert thresholds (critical/warning boundaries) | `threshold:{observationCode}` | Checked on every single observation — must be instant | No expiry (loaded at startup from PostgreSQL) |
| Alert suppression timers | `suppress:{encounterId}:{alertType}` | Prevents duplicate warning alerts after a clinician acknowledges one | 30 minutes |
| Trend history (recent vital sign readings) | `trend:{encounterId}:{observationCode}` | The trend analyzer needs the last 10 readings within 30 minutes to calculate rate of change | 2 hours |
| SOFA lab values | `sofa:{encounterId}:{observationCode}` | Lab results arrive infrequently; Redis caches the latest value so SOFA can be recalculated whenever any new observation arrives | 24 hours (considered stale after 12 hours) |

**Example flow:** A heart rate of 105 bpm arrives. The API reads `threshold:HEART_RATE` from Redis (instant) and finds the warning high is 100. Since 105 > 100 but < 150 (critical), it is not a critical alert — but the outbox event goes to Kafka, where the warning evaluator picks it up and checks `suppress:{encounterId}:WARNING_HEART_RATE` in Redis. If no suppression exists, a warning alert is created.

### RabbitMQ — notifications and escalation

RabbitMQ handles tasks where order matters and each task must be completed exactly once. It manages four queues:

| Queue | What it does | How it works |
|---|---|---|
| `alerts.paging.queue` | Pages clinicians when a critical alert fires | A Kafka consumer picks up `alert.generated` events and, if the severity is CRITICAL, drops a message into this queue. The paging worker processes one alert at a time and waits for acknowledgment |
| `alerts.paging.dlq` (Dead Letter Queue) | Catches unacknowledged pages | If a paging message is not acknowledged within 5 minutes, RabbitMQ automatically moves it to this "dead letter" queue. After a timeout, it re-routes to the escalation queue |
| `alerts.escalation.queue` | Escalates alerts that nobody responded to | Picks up messages from the DLQ and marks the alert as "Escalated" in the database. This would trigger an on-call page to a more senior clinician |
| `notifications.discharge.queue` | Generates discharge summary PDFs | When a patient is discharged, a message arrives here. The discharge worker gathers all encounter data (observations, alerts, orders, medications) and builds a PDF summary, then uploads it to MinIO |

**Escalation timeline example:**
```
0:00  Critical alert fires → message enters paging queue
0:00  Paging worker picks it up, simulates clinician page
5:00  Nobody acknowledged → message is NACKed → goes to DLQ
5:00+ DLQ TTL expires → message re-routes to escalation queue
      Escalation worker marks alert as "Escalated" in database
```

### Elasticsearch — search and analytics

Elasticsearch is a search-optimized copy of the data. It does not replace PostgreSQL — it mirrors selected data for fast searching and aggregation.

**Three indices (like specialized lookup tables):**

| Index | What it contains | Updated when |
|---|---|---|
| `patient_encounters` | One document per encounter: patient info, department, NEWS2 score, alert count, sepsis bundle status | Encounter created/discharged, alerts generated, bundle updated |
| `observations` | One document per observation: code, value, unit, timestamp, patient/encounter IDs | Every observation recorded |
| `clinical_alerts` | One document per alert: type, severity, status, department, timestamp | Every alert generated |

**Why not just query PostgreSQL?** Some queries are expensive on a relational database — for example, "show me hourly average heart rates for patient X over the last week" or "how many critical alerts fired in the ICU today." Elasticsearch is built for these kinds of aggregations and full-text searches. The trade-off is that data arrives in Elasticsearch 1–3 seconds after it is written to PostgreSQL.

### MinIO — long-term file storage

MinIO stores files organized by path, like a file system in the cloud:

| Path pattern | What is stored | How it gets there |
|---|---|---|
| `discharge-summaries/{encounterId}/summary.pdf` | Discharge summary for each encounter | Generated by the discharge worker (RabbitMQ) when a patient is discharged |
| `observations/{YYYY}/{MM}/{DD}/partition-{p}-offset-{o}.parquet` | Daily observation data in Parquet format | Written by the data lake writer (Kafka consumer) in batches of 1000 events or every 5 minutes; Kafka offsets are only committed for partitions where the upload succeeded — failed partitions are retried on the next flush |
| `alerts/{YYYY}/{MM}/{DD}/partition-{p}-offset-{o}.parquet` | Daily alert data in Parquet format | Same as above |
| `encounters/{YYYY}/{MM}/{DD}/partition-{p}-offset-{o}.parquet` | Daily encounter status changes in Parquet format | Same as above |

**What is Parquet?** It is a file format designed for large-scale data analysis. Unlike a CSV or JSON file, Parquet files are compressed and organized by column, making them very efficient for queries like "give me all heart rate values from the last month." This data lake is the foundation for future reporting, auditing, and machine learning.

### Prometheus and Grafana — system health monitoring

Prometheus scrapes the `/metrics` endpoint every 15 seconds and records operational metrics:

| Metric | Type | What it tracks |
|---|---|---|
| `observations_ingested_total` | Counter | Total observations recorded, labeled by code and source (DEVICE/MANUAL/LAB) |
| `clinical_alerts_total` | Counter | Total alerts generated, labeled by type and severity |
| `news2_scores_total` | Counter | NEWS2 scores calculated, labeled by risk level |
| `escalations_total` | Counter | Alerts that escalated because nobody acknowledged them |
| `observation_ingest_duration_seconds` | Histogram | How long each observation ingest takes (target: under 50ms) |
| `news2_scoring_duration_seconds` | Histogram | How long each NEWS2 calculation takes |
| `sofa_scoring_duration_seconds` | Histogram | How long each SOFA calculation takes |
| `alerts_unacknowledged_gauge` | Gauge | Current count of critical alerts open for more than 5 minutes |
| `outbox_pending_events` | Gauge | Unprocessed outbox events (if this grows, the relay is falling behind) |
| `kafka_consumer_lag` | Gauge | How far behind each Kafka consumer is (if this grows, scoring is delayed) |
| `kafka_poison_pills_skipped_total` | Counter | Messages skipped as un-processable (labeled by consumer group and topic). If this grows, investigate the source of malformed messages |

Grafana displays these metrics as charts, and operators can set up alerts (system alerts, not clinical alerts) if things like consumer lag or pending outbox events climb too high.

### Docker Compose — running everything locally

All services run in Docker containers defined in `docker-compose.yml`:

| Service | Port (host) | Purpose |
|---|---|---|
| PostgreSQL 16 | 5436 | Main database |
| Redis 7 | 6382 | Fast cache |
| Kafka 3.7 (KRaft mode) | 9092 | Message bus |
| RabbitMQ 3.13 | 5674 (AMQP), 15674 (management UI) | Task queues |
| Elasticsearch 8.13 | 9200 | Search and analytics |
| MinIO | 9005 (API), 9006 (console UI) | File/object storage |
| Prometheus | 9101 | Metrics collection |
| Grafana | 3101 | Metrics dashboards |

Starting the full stack: `docker compose up -d` brings up all 8 services. The .NET API runs separately (outside Docker) and connects to these services on the ports listed above.

**Health check endpoints:** The API exposes two health endpoints (no authentication required):
- `GET /health/live` — liveness probe. Returns 200 if the process is running. Use this for Kubernetes liveness probes or load balancer checks.
- `GET /health/ready` — readiness probe. Checks connectivity to all five infrastructure services (PostgreSQL, Redis, Kafka, RabbitMQ, Elasticsearch). Returns 200 only when all services are reachable. Use this for readiness gates — if it returns unhealthy, the API cannot process requests properly.

---

## Conventions

| Item | Value |
|---|---|
| Base path | `/api/v1` |
| Response envelope | `{ success, statusCode, data, error }` |
| Correlation | Optional `X-Correlation-Id` request header; echoed on the response |
| Authentication | JWT bearer token required on all clinical endpoints; obtain via `POST /api/v1/auth/login` |
| Health checks | `GET /health/live` (liveness) and `GET /health/ready` (readiness) — no authentication required |
| Active encounter guard | `POST` observations, medications, and orders return **409** (`ENCOUNTER_NOT_ACTIVE`) when the encounter is discharged or cancelled |
| Async latency | Warning alerts, NEWS2 scores, qSOFA, SOFA, trend alerts, and sepsis bundles are created by background workers — allow a few seconds after recording a measurement before polling for results |

---

## Lifecycle overview

```mermaid
flowchart LR
    subgraph admission [Admission]
        A1[POST /patients]
        A2[POST /patients/id/encounters]
    end
    subgraph monitoring [Active monitoring]
        M1[POST /encounters/id/observations]
        M2[POST /encounters/id/medications]
        M3[POST /encounters/id/orders]
    end
    subgraph async [Background scoring engines]
        K1[warning-evaluator]
        K2[news2-scoring]
        K3[sepsis-engine]
        K4[trend-analyzer]
        K5[sofa-scoring]
        K6[gcs-scoring]
    end
    subgraph triage [Clinician response]
        T1[GET /alerts]
        T2[POST /alerts/id/acknowledge]
        T3[POST /alerts/id/resolve]
        T4[PATCH /orders/id/result]
    end
    subgraph discharge [Discharge]
        D1[PATCH /encounters/id/status]
        D2[encounter.status.changed → discharge summary]
    end

    A1 --> A2 --> M1
    M1 --> K1 & K2 & K3 & K4 & K5 & K6
    K1 & K2 & K3 & K4 & K5 & K6 --> T1
    M2 & M3 --> T1
    T1 --> T2 --> T3
    M3 --> T4
    T3 --> D1 --> D2
```

**How encounter status works** (enforced by `PATCH /encounters/{id}/status`):

```
scheduled → active → discharged
          → cancelled
```

`POST /patients/{id}/encounters` creates encounters directly in `ACTIVE`. Illegal transitions (like going from discharged back to active) return **409** (`ILLEGAL_STATUS_TRANSITION`).

---

## All measurements the system tracks

VigilCare tracks measurements (called "observations") across three categories: vital signs, lab results, and consciousness assessments. Each measurement has a plausible range — values outside this range are rejected as likely errors.

### Vital signs (measured at the bedside)

These are the basic measurements that nurses and devices collect regularly:

| Code | What it measures | Unit | Normal range (approx.) | Plausible range | Example |
|---|---|---|---|---|---|
| `HEART_RATE` | How fast the heart beats | bpm (beats per minute) | 60–100 | 1–300 | A resting adult is ~70 bpm. Over 150 or under 30 is critical |
| `RESP_RATE` | How many breaths per minute | breaths/min | 12–20 | 1–80 | Normal adults breathe 12–20 times/min. Over 30 is critical |
| `SYSTOLIC_BP` | Blood pressure (the top number) — pressure when the heart squeezes | mmHg | 90–140 | 40–300 | 120/80 is "normal." Under 70 systolic is critical (not enough blood reaching organs) |
| `DIASTOLIC_BP` | Blood pressure (the bottom number) — pressure when the heart relaxes | mmHg | 60–90 | 20–200 | The lower number in a blood pressure reading |
| `TEMP_C` | Body temperature | °C (Celsius) | 36.1–37.2 | 15–50 | Normal is ~37°C (98.6°F). Over 40°C or under 35°C is critical |
| `SPO2` | Oxygen saturation — how much oxygen is in the blood | % | 95–100 | 50–100 | Measured with a clip on the finger. Below 88% is critical |
| `SUPPLEMENTAL_O2` | Whether the patient is receiving extra oxygen | flag (0 or 1) | 0 (room air) | 0–1 | 0 = breathing normal air, 1 = on supplemental oxygen. Being on oxygen adds points to the NEWS2 score |

### Lab results (from blood tests and other samples)

These require a blood draw or lab analysis — they take longer to get but reveal what is happening inside the body:

| Code | What it measures | Unit | Normal range (approx.) | Plausible range | Why it matters |
|---|---|---|---|---|---|
| `POTASSIUM_MEQ_L` | Potassium level in blood | mEq/L | 3.5–5.0 | 0.1–12 | Too high or too low can cause dangerous heart rhythms |
| `WBC_K_UL` | White blood cell count | k/uL (thousands per microliter) | 4.0–12.0 | 0.1–500 | White blood cells fight infection. Very high = possible infection. Very low = weak immune system |
| `GLUCOSE_MG_DL` | Blood sugar level | mg/dL | 70–100 (fasting) | 10–1000 | Too low (hypoglycemia) can cause seizures. Too high (hyperglycemia) damages organs over time |
| `LACTATE_MMOL_L` | Lactic acid in blood | mmol/L | 0.5–2.0 | 0.1–30 | High lactate means tissues are not getting enough oxygen — a key sign of sepsis or shock |
| `PAO2_MMHG` | Partial pressure of oxygen in arterial blood | mmHg | 80–100 | 20–600 | Requires an arterial blood draw. Shows how well lungs transfer oxygen. Used in SOFA respiratory scoring |
| `FIO2_PCT` | Fraction of inspired oxygen — how concentrated the oxygen the patient breathes is | % | 21 (room air) | 21–100 | Room air is 21%. A patient on a ventilator might be at 40–100%. Used with PaO2 to calculate SOFA respiratory score |
| `PLATELET_K_UL` | Platelet count | k/uL | 150–400 | 1–1500 | Platelets help blood clot. Very low platelets = bleeding risk. Used in SOFA coagulation scoring |
| `BILIRUBIN_MG_DL` | Bilirubin level | mg/dL | 0.1–1.2 | 0.1–50 | Produced when the liver breaks down old blood cells. High bilirubin = liver is struggling. Used in SOFA liver scoring |
| `CREATININE_MG_DL` | Creatinine level | mg/dL | 0.6–1.2 | 0.1–20 | A waste product filtered by the kidneys. High creatinine = kidneys are not filtering properly. Used in SOFA renal scoring |
| `URINE_OUTPUT_ML_H` | How much urine the patient produces per hour | mL/h | >50 | 0–500 | Low urine output = kidneys may be failing. Used in SOFA renal scoring |

### Consciousness assessments

These measure how awake and responsive the patient is:

| Code | What it measures | Range | What the numbers mean |
|---|---|---|---|
| `AVPU` | Quick consciousness check | 0–3 | **0** = Alert (awake and talking), **1** = responds to Voice, **2** = responds to Pain only, **3** = Unresponsive. Used in NEWS2 scoring |
| `GCS_EYE` | Eye opening response | 1–4 | **4** = opens eyes on own, **3** = opens to voice, **2** = opens to pain, **1** = no eye opening |
| `GCS_VERBAL` | Verbal response | 1–5 | **5** = oriented/normal conversation, **4** = confused, **3** = inappropriate words, **2** = incomprehensible sounds, **1** = none |
| `GCS_MOTOR` | Motor (movement) response | 1–6 | **6** = obeys commands, **5** = localizes pain, **4** = withdraws from pain, **3** = abnormal flexion, **2** = extension, **1** = none |

GCS total (3–15) is the sum of all three components. A GCS of 15 means fully alert. A GCS of 3 means completely unresponsive.

---

## Alert thresholds — when do alarms go off?

Every measurement is checked against configured thresholds. There are two levels:

- **Critical** — something is dangerously wrong right now. The alert fires immediately (in the same HTTP response). These are never suppressed.
- **Warning** — something is concerning and needs attention. The alert fires in the background (via Kafka). Warnings can be temporarily suppressed after a clinician acknowledges them (30-minute window).

| Measurement | Critical Low | Warning Low | Warning High | Critical High |
|---|---|---|---|---|
| Heart Rate (bpm) | 30 | 50 | 100 | 150 |
| Temperature (°C) | 35.0 | 36.0 | 38.3 | 40.0 |
| Potassium (mEq/L) | 2.5 | 3.5 | 5.0 | 6.5 |
| Oxygen Saturation (%) | 88 | 92 | — | — |
| Respiratory Rate (breaths/min) | — | 12 | 20 | 30 |
| White Blood Cells (k/uL) | 2.0 | 4.0 | 12.0 | 20.0 |
| Systolic BP (mmHg) | 70 | 90 | 160 | 180 |
| Diastolic BP (mmHg) | 40 | 60 | 90 | 110 |
| Lactate (mmol/L) | — | — | 2.0 | 4.0 |
| AVPU | — | — | — | 2 |
| Glucose (mg/dL) | 40 | 70 | 180 | 400 |
| PaO2 (mmHg) | 60 | 80 | — | — |
| Platelets (k/uL) | 20 | 50 | — | — |
| Bilirubin (mg/dL) | — | — | 2.0 | 6.0 |
| Creatinine (mg/dL) | — | — | 2.0 | 3.5 |

**Example:** A heart rate of 155 bpm crosses the critical high threshold (150), so a `CRITICAL_HEART_RATE` alert fires immediately. A heart rate of 105 bpm crosses only the warning high threshold (100), so a `WARNING_HEART_RATE` alert fires in the background.

A dash (—) means there is no threshold configured on that side. For example, oxygen saturation has no high threshold because high oxygen is generally not dangerous in this context.

---

## Clinical scoring systems explained

VigilCare calculates several clinical scores automatically. Each score looks at patient data from a different angle. Together, they give clinicians a complete picture of how the patient is doing.

### NEWS2 — National Early Warning Score 2

**What it does:** Combines 7 vital signs into a single number (0–20) that tells staff "how sick is this patient right now?" It is the most widely used early warning system in UK hospitals.

**Why it matters:** A rising NEWS2 score often means the patient is getting worse — even if no single measurement has crossed a critical threshold yet. It catches deterioration early.

**The 7 required measurements:**

All 7 must be recorded within a 4-hour window for a score to be calculated. If any are missing, no score is produced.

| Measurement | Score 3 | Score 2 | Score 1 | Score 0 (normal) | Score 1 | Score 2 | Score 3 |
|---|---|---|---|---|---|---|---|
| Respiratory Rate | ≤8 | | 9–11 | 12–20 | | 21–24 | ≥25 |
| Oxygen Saturation (%) | ≤91 | 92–93 | 94–95 | ≥96 | | | |
| Systolic BP (mmHg) | ≤90 | 91–100 | 101–110 | 111–219 | | | ≥220 |
| Heart Rate (bpm) | ≤40 | | 41–50 | 51–90 | 91–110 | 111–130 | ≥131 |
| Consciousness (AVPU) | | | | Alert | | | Any other (V, P, or U) |
| Temperature (°C) | ≤35.0 | | 35.1–36.0 | 36.1–38.0 | 38.1–39.0 | ≥39.1 | |
| Supplemental O2 | | On oxygen (2) | | Room air (0) | | | |

**How the total score maps to risk levels:**

| Total Score | Risk Level | What it means | Alert type |
|---|---|---|---|
| 0–4 | **LOW** | Routine monitoring. Patient is stable | No alert |
| 0–4 but any single parameter scores 3 | **LOW-MEDIUM** | One vital sign is individually concerning, even though the total looks OK | `NEWS2_WARNING` |
| 5–6 | **MEDIUM** | Patient may be deteriorating. Increase monitoring frequency | `NEWS2_WARNING` |
| 7 or higher | **HIGH** | Urgent — patient is at significant risk. Senior clinician review needed | `NEWS2_EMERGENCY` |

**Example:** A patient has: respiratory rate 24 (score 2), SpO2 94% (score 1), systolic BP 115 (score 0), heart rate 115 (score 2), AVPU alert (score 0), temperature 38.5°C (score 1), on room air (score 0). Total = 6 → **MEDIUM** risk → `NEWS2_WARNING` alert fires.

---

### SOFA — Sequential Organ Failure Assessment

**What it does:** Scores how well 6 organ systems are functioning, on a scale of 0–4 each (total 0–24). Higher scores mean worse organ function. It is the gold standard for detecting sepsis-related organ damage.

**Why it matters:** Sepsis kills by damaging organs. SOFA catches this damage early. A jump of 2+ points from the patient's baseline is the clinical definition of sepsis (per Sepsis-3 guidelines).

**The 6 organ systems scored:**

| Organ System | What it measures | Data needed | Score 0 (normal) | Score 1 | Score 2 | Score 3 | Score 4 (worst) |
|---|---|---|---|---|---|---|---|
| **Respiratory** (lungs) | How well lungs transfer oxygen | PaO2 and FiO2 (or SpO2 if PaO2 unavailable) | PaO2/FiO2 ≥400 | 300–399 | 200–299 | 100–199 | <100 |
| **Coagulation** (blood clotting) | Platelet count | Platelet count | ≥150 k/uL | 100–149 | 50–99 | 20–49 | <20 |
| **Liver** | Liver function | Bilirubin | <1.2 mg/dL | 1.2–1.9 | 2.0–5.9 | 6.0–11.9 | ≥12.0 |
| **Cardiovascular** (heart/circulation) | Blood pressure support needed | MAP (mean arterial pressure) | MAP ≥70 | MAP <70 | Low-dose vasopressor | Moderate vasopressor | High-dose vasopressor |
| **CNS** (brain) | Consciousness level | GCS total score | 15 | 13–14 | 10–12 | 6–9 | <6 |
| **Renal** (kidneys) | Kidney function | Creatinine and/or urine output | Creatinine <1.2 | 1.2–1.9 | 2.0–3.4 | 3.5–4.9 | ≥5.0 or urine <200 mL/day |

**How SOFA alerts work:**

The system establishes a "baseline" SOFA score once at least 4 of the 6 organ systems have data. Then it watches for changes:

| Change from baseline | Alert | Severity | What it means |
|---|---|---|---|
| Increase of 2+ points | `SOFA_SEPSIS` | CRITICAL | Meets the Sepsis-3 definition of sepsis. Immediate clinical response needed |
| Increase of 1 point | `SOFA_WARNING` | WARNING | Organs are trending worse. Watch closely |

**Lab staleness:** SOFA uses lab results that may not be measured frequently. The system considers lab values stale after 12 hours and expired after 24 hours, meaning the score becomes less reliable over time without fresh data.

**Example:** A patient has a baseline SOFA of 3. New labs come in showing worsening kidney function and lower platelets, pushing the SOFA to 6. That is a jump of 3 points → `SOFA_SEPSIS` alert fires.

---

### qSOFA — Quick SOFA (bedside screening)

**What it does:** A fast, simple check using just 3 things you can measure at the bedside — no lab work needed. It asks: "should we be worried about sepsis?"

**Why it matters:** Lab results take time. qSOFA gives an answer in seconds using measurements that are already being collected. It is a screening tool — it does not diagnose sepsis, but it flags patients who need deeper evaluation (like a full SOFA score).

**The 3 criteria:**

| Criteria | Threshold | What it means |
|---|---|---|
| Respiratory rate | ≥22 breaths/min | Patient is breathing faster than normal — body may be compensating for something |
| Systolic blood pressure | ≤100 mmHg | Blood pressure is dropping — organs may not be getting enough blood |
| Altered mental status | AVPU ≥1 (not fully alert) or GCS <15 | Patient is confused or not fully conscious |

**Scoring:** Each criterion met = 1 point. Range is 0–3. When **2 or more** criteria are met, a `QSOFA_SCREEN` alert fires.

**Important:** qSOFA is a screen, not a diagnosis. It says "look closer" — it does not by itself trigger a sepsis bundle. The full SOFA score is used for sepsis determination.

**Example:** A patient has a respiratory rate of 24 (meets criterion), systolic BP of 95 (meets criterion), and is alert (AVPU = 0, does not meet criterion). Score = 2 → `QSOFA_SCREEN` alert fires.

---

### GCS — Glasgow Coma Scale

**What it does:** Measures how conscious a patient is by testing three types of responses: eye opening, verbal response, and motor (movement) response. Total score ranges from 3 (completely unresponsive) to 15 (fully alert).

**Why it matters:** Changes in consciousness can signal brain injury, stroke, medication effects, or worsening illness. A dropping GCS is an emergency.

**The three components:**

| Component | Best response (highest score) | Worst response (lowest score) |
|---|---|---|
| **Eye opening** | 4 — opens eyes spontaneously | 1 — no eye opening |
| **Verbal response** | 5 — oriented, normal conversation | 1 — no verbal response |
| **Motor response** | 6 — obeys commands | 1 — no movement |

**Severity classification:**

| GCS Total | Classification | Alert | What it means |
|---|---|---|---|
| 13–15 | **Mild** | No alert | Patient is mostly or fully alert |
| 9–12 | **Moderate** | `GCS_WARNING` | Significant impairment — needs monitoring |
| 3–8 | **Severe (coma)** | `GCS_CRITICAL` | Patient is in or near coma — urgent intervention needed |

**How GCS connects to other scores:**
- **NEWS2:** GCS 15 maps to AVPU = "Alert" (score 0). Any GCS below 15 maps to "not alert" (score 3 in NEWS2). This is why even a small drop in consciousness adds 3 points to NEWS2.
- **qSOFA:** GCS below 15 counts as "altered mental status" — one of the 3 qSOFA criteria.
- **SOFA CNS component:** GCS maps directly to the SOFA brain score (15→0, 13–14→1, 10–12→2, 6–9→3, <6→4).

---

### Trend detection — Rapid Deterioration

**What it does:** Watches how fast certain vital signs are changing over time, not just whether they have crossed a threshold. Even if a value is still in a "normal" range, a rapid change can signal trouble.

**Why it matters:** A heart rate going from 70 to 100 in 30 minutes is more alarming than a stable heart rate of 100. The trend tells you the patient is getting worse fast.

**Monitored vital signs and velocity thresholds:**

The system looks at changes over a 30-minute sliding window:

| Vital Sign | Trigger rate | In plain terms | Alert |
|---|---|---|---|
| Heart Rate | ≥0.5 bpm/min | Rising by 15+ bpm in 30 minutes | `RAPID_DETERIORATION` |
| Respiratory Rate | ≥0.3 breaths/min | Rising by 9+ breaths/min in 30 minutes | `RAPID_DETERIORATION` |
| Systolic BP | ≥1.0 mmHg/min decline | Dropping by 30+ mmHg in 30 minutes | `RAPID_DETERIORATION` |
| Temperature | ≥0.05 °C/min | Rising by 1.5+°C in 30 minutes | `RAPID_DETERIORATION` |
| Oxygen Saturation | ≥0.2 %/min decline | Dropping by 6+% in 30 minutes | `RAPID_DETERIORATION` |

**Example:** A patient's heart rate readings: 75 bpm at 2:00 PM, 82 bpm at 2:10 PM, 92 bpm at 2:25 PM. That is a rise of 17 bpm in 25 minutes (0.68 bpm/min) — above the 0.5 threshold → `RAPID_DETERIORATION` alert fires, even though 92 bpm is not above the warning threshold (100) yet.

`RAPID_DETERIORATION` alerts are never suppressed — every rapid change triggers a new alert.

---

## Phase 0 — Platform prerequisites (before any patient)

These are typically set up once at deploy time, not called per patient. This is where the technology stack gets initialized.

| What | How | Technology involved | Why it matters |
|---|---|---|---|
| Alert thresholds | Seeded in PostgreSQL; `ThresholdCacheLoader` copies them into Redis at startup (retries up to 3 times with exponential backoff if Redis is unavailable; application starts without cache if Redis remains down — falls back to PostgreSQL queries); manageable via `POST/GET/PUT /alert-thresholds` | PostgreSQL → Redis | Every measurement is validated against thresholds from Redis (fast) to decide if alerts fire. Without this cache, every observation ingest would need a database query |
| Kafka topics | `KafkaTopicProvisioner` creates 7 topics on startup (6 partitions each, configurable replication factor — default 3, auto-creation disabled) | Kafka | `observation.recorded`, `alert.generated`, `encounter.status.changed`, `sepsis.bundle.created`, `sepsis.bundle.updated`, `gcs.scored`. These must exist before any messages can flow |
| Elasticsearch indices | `ElasticIndexProvisioner` creates 3 indices with proper field mappings | Elasticsearch | `patient_encounters`, `observations`, `clinical_alerts` — must exist before data can be indexed |
| RabbitMQ topology | `RabbitMqTopologyProvisioner` declares the exchange and 5 queues | RabbitMQ | Sets up the `clinical.notifications.exchange` and all queues (paging, DLQ, escalation, discharge, reconciliation) with proper routing and dead-letter configuration |
| MinIO bucket | Created if it does not exist | MinIO | The `vigilcare` bucket must exist before discharge summaries or data lake files can be written |
| Drug-vital mappings | `MedicationCorrelation` section in `appsettings.json` | Configuration | Links medications to vital signs so alerts can include context like "heart rate may be elevated due to epinephrine given 20 min ago" |

---

## Phase 1 — Patient arrives (registration and admission)

### 1.1 Register the patient

```
POST /api/v1/patients
```

| Field | Required | Notes |
|---|---|---|
| `firstName`, `lastName`, `dateOfBirth`, `gender` | yes | |
| `bloodType`, `allergies`, `emergencyContactName`, `emergencyContactPhone` | no | Stored for ward context |

**Response:** `201 Created` — `data` includes a system-generated `mrn` (e.g. `MRN-000042`) and `id` (UUID). Save both; the MRN is what nurses see on wristbands, the UUID is used in all API paths. MRNs are generated from a PostgreSQL sequence (`mrn_seq`), guaranteeing uniqueness even under concurrent registration.

**Later lookups:**

| Need | Endpoint |
|---|---|
| Search by name or MRN | `GET /patients?q=...` |
| Demographics + active encounter summary | `GET /patients/{id}` |

### 1.2 Open an encounter (admission)

```
POST /api/v1/patients/{patientId}/encounters
```

| Field | Required | Notes |
|---|---|---|
| `encounterType` | yes | `INPATIENT`, `OUTPATIENT`, `EMERGENCY` |
| `department` | yes | e.g. `ICU`, `GENERAL_MEDICINE`, `SURGERY` |
| `attendingPhysician` | yes | |
| `roomBed` | no | Ward assignment (e.g. `ICU-1A`) |
| `admissionReason` | no | Why the patient was admitted |

**Response:** `201 Created` — encounter `id`, `status: ACTIVE`, `admittedAt`.

**Side effects (what happens behind the scenes):**

- An `encounter.status.changed` outbox event is written to PostgreSQL in the same transaction as the encounter
- The outbox relay picks it up within 500ms and sends it to the Kafka `encounter.status.changed` topic
- Three Kafka consumers process it independently:
  - **ES indexer** → creates a document in the `patient_encounters` Elasticsearch index (so the patient appears in ward searches)
  - **Data lake writer** → buffers the event and writes it to a Parquet file in MinIO (for long-term records)
  - **Notification publisher** → no action for admissions (only discharges trigger the discharge queue)
- Only **one active encounter per patient per encounter type** — trying to create a duplicate returns **409** (`DUPLICATE_ACTIVE_ENCOUNTER`)

From this point, `encounterId` is the key for all clinical writes.

---

## Phase 2 — Ward visibility (who is on the floor)

While the patient is active, ward systems poll aggregated state.

### Virtual ward board

```
GET /api/v1/encounters?status=ACTIVE&department=ICU&page=1&pageSize=20
```

Each row (`WardEncounterSummary`) includes:

| Field | What it shows |
|---|---|
| `encounterId`, `patientId`, `mrn`, `firstName`, `lastName`, `roomBed`, `department`, `status` | Patient and encounter info |
| `news2Score`, `news2RiskLevel` | Latest NEWS2 score and risk level (LOW, LOW_MEDIUM, MEDIUM, HIGH) |
| `qsofaScore` | Current qSOFA criteria count (0–3) |
| `sepsisActive` | Whether the patient has a sepsis investigation in progress |
| `sepsisBundleStatus` | `IN_PROGRESS`, `COMPLIANT`, or `NON_COMPLIANT` |
| `openAlertCount` | How many unresolved alerts exist for this patient |

The dashboard sorts patients by NEWS2 score — sickest patients appear first.

### Single-patient chart header

```
GET /api/v1/encounters/{id}
```

Returns the encounter with the patient's info, the **10 most recent observations**, and **open alerts**. Use this for a quick summary; use the dedicated list endpoints for full history.

---

## Phase 3 — Continuous monitoring (recording measurements)

Observations are the heartbeat of the system. Bedside devices, manual nurse entries, and lab interfaces all use the same endpoint.

### Record measurements

```
POST /api/v1/encounters/{encounterId}/observations
```

Body: `{ "observations": [ ... ] }` — **1 to 10** measurements per call.

| Observation field | Required | Notes |
|---|---|---|
| `observationCode` | yes | One of the measurement codes listed above (e.g. `HEART_RATE`, `RESP_RATE`) |
| `value`, `unit` | yes | The numeric value and its unit. Checked for plausibility (e.g. a heart rate of 500 would be rejected) |
| `recordedAt` | yes | When the measurement was actually taken |
| `source` | no | `DEVICE` (default), `MANUAL`, `LAB` |

Optional header: `Idempotency-Key` — if you accidentally send the same measurement twice, the system returns the original record without creating a duplicate.

### What happens immediately (in the HTTP response)

1. Encounter must be `ACTIVE` — otherwise **409**
2. Idempotency check — skip if already recorded
3. Plausibility validation — reject impossible values with **422**
4. Save the observation row to **PostgreSQL**
5. Read the alert thresholds from **Redis** (sub-millisecond lookup via key `threshold:{observationCode}`)
6. **If a critical threshold is breached:** insert a `clinical_alert` row + an `alert.generated` outbox event into **PostgreSQL** — all in the same transaction (so the alert is never lost)
7. Insert an `observation.recorded` outbox event into **PostgreSQL**
8. COMMIT — everything saved atomically. Within 500ms, the **outbox relay** picks up the events and sends them to **Kafka**

**HTTP response (`201`):** for each measurement, you get `observation`, `alertGenerated` (true only for critical alerts), `alertId`, and `duplicate`.

### What happens in the background (not in the HTTP response)

Once the `observation.recorded` message lands on **Kafka**, multiple consumers pick it up independently. Each consumer belongs to its own consumer group, so Kafka delivers the same message to all of them. This is why one observation can trigger NEWS2, SOFA, qSOFA, trend detection, and search indexing all at the same time.

| Engine (Kafka consumer group) | What it does | Technology used | Alert produced |
|---|---|---|---|
| **warning-evaluator** | Checks if the value crosses a warning threshold. Reads suppression status from **Redis** (`suppress:{encounterId}:{alertType}`). Optionally adds medication context (e.g. "patient was given morphine 30 min ago") | Redis, PostgreSQL | `WARNING_*` alerts |
| **news2-scoring** | Reads latest 7 parameters from **Redis** (4-hour window). If all 7 are present, calculates NEWS2 and writes score to **PostgreSQL** | Redis, PostgreSQL | `NEWS2_WARNING` or `NEWS2_EMERGENCY` |
| **sepsis-engine** | Recalculates qSOFA criteria count using latest values from **Redis** | Redis, PostgreSQL | `QSOFA_SCREEN` (when 2+ criteria met) |
| **sofa-scoring** | Reads cached lab values from **Redis** (`sofa:{encounterId}:{code}`), recalculates per-organ SOFA scores, compares to baseline in **PostgreSQL** | Redis, PostgreSQL | `SOFA_WARNING` or `SOFA_SEPSIS` |
| **gcs-scoring** | Recalculates GCS when eye/verbal/motor components are recorded, writes score to **PostgreSQL** | PostgreSQL | `GCS_WARNING` or `GCS_CRITICAL` |
| **trend-analyzer** | Reads recent readings from **Redis** (`trend:{encounterId}:{code}`, up to 10 entries), calculates rate of change over 30-minute window | Redis, PostgreSQL | `RAPID_DETERIORATION` |
| **es-indexer** | Upserts the observation into the `observations` **Elasticsearch** index. Also updates `patient_encounters` index (e.g. `lastObservationAt`) | Elasticsearch | (no alert) |
| **data-lake-writer** | Buffers observations and writes them to **MinIO** as Parquet files (batches of 1000 events or every 5 minutes) | MinIO | (no alert) |

Any alerts created by these engines are written to **PostgreSQL** with an `alert.generated` outbox event, which flows back through **Kafka** → **Elasticsearch** (for indexing) and **RabbitMQ** (for paging, if critical).

**Important for integrators:** After posting measurements, poll `GET /encounters/{id}/alerts` or score endpoints. Warning and scoring alerts are NOT in the initial HTTP response — they arrive 1–3 seconds later (the time it takes for the message to travel through Kafka and be processed).

### Read measurement history

```
GET /api/v1/encounters/{encounterId}/observations?code=HEART_RATE&from=...&to=...&limit=50&cursor=...
```

Cursor-paginated (newest first). Use `code` to filter by measurement type.

---

## Phase 4 — Clinical interventions (medications and orders)

These run in parallel with monitoring; they do not replace recording measurements.

### Medication administrations

```
POST /api/v1/encounters/{encounterId}/medications
GET  /api/v1/encounters/{encounterId}/medications?since=...&page=1&pageSize=20
GET  /api/v1/medications/{id}
```

Recording a drug does **not** create alerts on its own. Instead, when a warning or NEWS2 alert fires later, the system checks: "was a relevant drug given recently?" If so, it adds context to the alert details. For example, a high heart rate warning might include: "Note: epinephrine administered 20 minutes ago."

The correlation window is 90 minutes by default — only drugs given within the last 90 minutes are linked.

### Clinical orders

```
POST  /api/v1/encounters/{encounterId}/orders
GET   /api/v1/encounters/{encounterId}/orders?status=PENDING
GET   /api/v1/orders/{id}
PATCH /api/v1/orders/{id}/status
PATCH /api/v1/orders/{id}/result
```

**Order status flow:**

```
pending → in_progress → resulted
        → cancelled
```

`PATCH .../result` is how clinicians record that an order is complete — for example, recording that a blood culture was collected. For orders that are part of a sepsis bundle, completing the order also marks that bundle element as done.

---

## Phase 5 — Alerts fire (detection to triage)

Alerts are created by multiple engines (critical thresholds, warning thresholds, NEWS2, qSOFA, SOFA, GCS, trend detection). All share the same read and lifecycle API.

### List and inspect

| Endpoint | Use |
|---|---|
| `GET /encounters/{id}/alerts?status=OPEN` | Alerts for one patient |
| `GET /alerts?status=OPEN&severity=CRITICAL&department=ICU` | Hospital-wide alert center |
| `GET /alerts/{id}` | Single alert with full details |

Each alert includes `alertType`, `severity`, `details` (with medication context when applicable), `status`, `triggeredAt`, and optional `observationId` (which measurement triggered it).

**All alert types:**

| Alert Type | What triggered it | Severity | Can be suppressed? |
|---|---|---|---|
| `CRITICAL_*` (e.g. `CRITICAL_HEART_RATE`) | A measurement crossed a critical threshold | CRITICAL | No |
| `WARNING_*` (e.g. `WARNING_HEART_RATE`) | A measurement crossed a warning threshold | WARNING | Yes (30 min) |
| `NEWS2_WARNING` | NEWS2 score is 5–6, or any single parameter scores 3 | WARNING | Yes (30 min) |
| `NEWS2_EMERGENCY` | NEWS2 score is 7 or higher | CRITICAL | No |
| `QSOFA_SCREEN` | 2 or more qSOFA criteria met | WARNING | Yes (30 min) |
| `SOFA_SEPSIS` | SOFA score increased 2+ points from baseline | CRITICAL | No |
| `SOFA_WARNING` | SOFA score increased 1 point from baseline | WARNING | Yes (30 min) |
| `GCS_CRITICAL` | GCS total is 8 or below (coma) | CRITICAL | No |
| `GCS_WARNING` | GCS total is 9–12 (moderate impairment) | WARNING | Yes (30 min) |
| `RAPID_DETERIORATION` | A vital sign is changing too fast | WARNING | No |

### Acknowledge and resolve

```
POST /api/v1/alerts/{id}/acknowledge
Body: { "clinicianId": "DR-SMITH", "note": "optional" }

POST /api/v1/alerts/{id}/resolve
```

**Alert lifecycle:**

```
open → acknowledged → resolved
     → escalated   (unacknowledged CRITICAL after 5 min → pages on-call staff)
```

When a clinician acknowledges a suppressible warning, the system writes a key to **Redis** (`suppress:{encounterId}:{alertType}`) with a 30-minute TTL (time-to-live). During that window, the warning evaluator checks Redis before creating a new alert — if the suppression key exists, the duplicate is silently skipped. After 30 minutes, Redis automatically deletes the key, and new warnings can fire again. Critical and SOFA-sepsis alerts are never suppressed.

### Escalation (no HTTP endpoint)

When a critical alert fires, the `alert.generated` **Kafka** message is picked up by the notification publisher, which drops a message into the **RabbitMQ** `alerts.paging.queue`. The paging worker processes it and waits for the clinician to acknowledge the alert in the database.

If nobody acknowledges within 5 minutes, the paging worker NACKs (rejects) the message. **RabbitMQ** automatically moves it to the `alerts.paging.dlq` (dead letter queue). After a timeout, the DLQ re-routes it to the `alerts.escalation.queue`, where the escalation worker marks the alert as `Escalated` in **PostgreSQL** and increments the `escalations_total` **Prometheus** metric.

Integrators see escalation through the alert's `status` field changing to `escalated`.

---

## Phase 6 — Composite scores (NEWS2, SOFA, qSOFA, GCS)

### NEWS2

Requires all 7 parameters within a 4-hour window: `RESP_RATE`, `SPO2`, `SYSTOLIC_BP`, `HEART_RATE`, `AVPU`, `TEMP_C`, `SUPPLEMENTAL_O2`. The NEWS2 scoring consumer reads each parameter's latest value from **Redis** — if all 7 are present and recent enough, it calculates the score, writes the result to **PostgreSQL** (`news2_scores` table), and creates an alert if warranted.

```
GET /api/v1/encounters/{id}/news2/current     → latest score (404 if not yet computed)
GET /api/v1/encounters/{id}/news2/history     → score history over time
```

These endpoints read from **PostgreSQL**. Scores are calculated in the background via **Kafka** — record the 7th parameter, wait a few seconds, then poll.

### SOFA

Requires lab results and vitals across 6 organ systems. The SOFA scoring consumer reads cached lab values from **Redis** (`sofa:{encounterId}:{code}`) — labs are cached there because they arrive infrequently (hours apart), but SOFA needs to be recalculated every time any new observation arrives. The score is computed once at least 4 systems have data.

```
GET /api/v1/encounters/{id}/sofa/current      → latest SOFA score with per-organ breakdown
GET /api/v1/encounters/{id}/sofa/history      → score history
```

The response (from **PostgreSQL**) includes per-organ scores (respiratory, coagulation, liver, cardiovascular, CNS, renal) so clinicians can see which organ systems are struggling.

### qSOFA

Live criteria count from **Redis** (no stored history — just the current state):

```
GET /api/v1/encounters/{id}/qsofa/current
```

Returns how many of the 3 criteria are currently met (0–3). When the count reaches 2, a `QSOFA_SCREEN` alert is written to **PostgreSQL** and flows through **Kafka** to **Elasticsearch**.

### GCS

```
GET /api/v1/encounters/{id}/gcs/current       → latest GCS with component breakdown
```

Returns the total GCS score (from **PostgreSQL**), individual component scores (eye, verbal, motor), and severity classification (mild/moderate/severe). GCS scores also flow through **Kafka** (`gcs.scored` topic) to **Elasticsearch**.

---

## Phase 7 — Sepsis bundle (when infection is suspected)

A sepsis bundle is a checklist of things that **must** be done within 1 hour when sepsis is suspected. VigilCare creates bundles automatically when a `SOFA_SEPSIS` alert fires — not by a direct API call.

```
GET /api/v1/encounters/{id}/sepsis-bundle/current
GET /api/v1/sepsis-bundles/{id}
```

**The 4 bundle elements** (each backed by an auto-created clinical order):

| Element | What it is | Why within 1 hour |
|---|---|---|
| **Blood cultures** | Draw blood samples to identify which bacteria is causing the infection | Need to identify the infection before antibiotics potentially mask it |
| **Serum lactate** | Measure lactic acid level in blood | High lactate means organs are not getting enough oxygen — guides how aggressive treatment needs to be |
| **Broad-spectrum antibiotics** | Give antibiotics that cover many types of bacteria | Every hour of delay increases mortality risk. Start broad, then narrow down when culture results come back |
| **IV fluid resuscitation** | Give fluids through an IV to restore blood pressure and organ perfusion | Sepsis causes blood vessels to leak — fluids keep blood pressure up and organs working |

**Compliance flow:**

1. Bundle is created with `complianceStatus: IN_PROGRESS` and `deadlineAt` set to 1 hour from detection. A database constraint ensures only one in-progress bundle can exist per encounter at any time — preventing duplicate bundles from race conditions.
2. Clinicians complete orders via `PATCH /orders/{id}/result`
3. Each completed order marks its bundle element `COMPLETED`
4. When all 4 elements are done:
   - **Within deadline** → `COMPLIANT` (good)
   - **Past deadline** → `NON_COMPLIANT` (needs review — delay may have worsened outcome)
5. A background monitor checks every 5 minutes and marks overdue bundles as `NON_COMPLIANT`

```
GET /api/v1/encounters/{id}/orders
```

Use this to see bundle-linked orders and their status during the stay.

---

## Phase 8 — Review during the stay (timeline and analytics)

### Encounter timeline

```
GET /api/v1/encounters/{id}/timeline
```

A combined chronological stream of **observations** and **alerts** — like a scrolling history of everything that has happened to the patient. Useful for reviewing events during ward rounds or shift handovers.

### Elasticsearch analytics (read-only, cross-encounter)

These endpoints query **Elasticsearch** — not **PostgreSQL**. They are fast for searches and aggregations across many patients, but the data may be 1–3 seconds behind the live database (because it flows through **Kafka** before landing in Elasticsearch).

| Endpoint | Purpose | Elasticsearch index used |
|---|---|---|
| `GET /analytics/patients?q=...&department=...` | Full-text search across patients and encounters | `patient_encounters` |
| `GET /analytics/observations/trend?encounterId=...&code=...` | Hourly avg/min/max time series for a measurement (uses `date_histogram` aggregation) | `observations` |
| `GET /analytics/alerts/summary?severity=...&department=...` | Alert volume by department | `clinical_alerts` |
| `GET /analytics/population?code=...&threshold=...` | Find patients above/below a measurement threshold in a time window | `observations` |

---

## Phase 9 — Discharge (end of encounter)

### Close the encounter

```
PATCH /api/v1/encounters/{encounterId}/status
Body: { "status": "DISCHARGED", "dischargeDiagnosis": "optional free text" }
```

**Response:** `200 OK` — `{ encounterId, newStatus, dischargeDiagnosis }`.

**Side effects (the full technology chain):**

- `dischargedAt` is set to the current time in **PostgreSQL**
- An `encounter.status.changed` outbox event is saved in **PostgreSQL** and relayed to **Kafka** within 500ms
- **Kafka** consumers pick it up:
  - **ES indexer** → updates the encounter's status in the `patient_encounters` **Elasticsearch** index (patient drops off the active ward search)
  - **Data lake writer** → writes the discharge event to a Parquet file in **MinIO**
  - **Notification publisher** → detects this is a discharge and drops a message into the **RabbitMQ** `notifications.discharge.queue`
- The **discharge summary worker** (RabbitMQ consumer) picks up the message, gathers all encounter data from **PostgreSQL** (observations, alerts, orders, medications, scores), builds a PDF summary, and uploads it to **MinIO** at `discharge-summaries/{encounterId}/summary.pdf`

### What stops working after discharge

| Action | Result |
|---|---|
| `POST .../observations` | **409** `ENCOUNTER_NOT_ACTIVE` — cannot record new measurements |
| `POST .../medications` | **409** `ENCOUNTER_NOT_ACTIVE` — cannot record new medications |
| `POST .../orders` | **409** `ENCOUNTER_NOT_ACTIVE` — cannot create new orders |
| `GET .../alerts`, `GET .../observations`, `GET .../news2/history` | Still work — you can read historical data |
| `GET /encounters?status=ACTIVE` | Patient drops off the active ward list |

**Cancellation:** `PATCH .../status` with `{ "status": "CANCELLED" }` from `ACTIVE` (e.g. an admission was created by mistake). Same write restrictions apply afterward.

A patient may later return and receive a new encounter via `POST /patients/{id}/encounters`. Each hospital visit is independent; the patient's MRN stays the same across their lifetime.

---

## Complete endpoint index by lifecycle phase

| Phase | Method | Path |
|---|---|---|
| **0 — Config** | `GET/POST/PUT` | `/alert-thresholds`, `/alert-thresholds/{id}` |
| **1 — Admission** | `POST` | `/patients` |
| | `POST` | `/patients/{id}/encounters` |
| | `GET` | `/patients`, `/patients/{id}` |
| **2 — Ward** | `GET` | `/encounters`, `/encounters/{id}` |
| | `GET` | `/encounters/{id}/qsofa/current` |
| **3 — Monitoring** | `POST` | `/encounters/{id}/observations` |
| | `GET` | `/encounters/{id}/observations` |
| **4 — Interventions** | `POST/GET` | `/encounters/{id}/medications`, `/medications/{id}` |
| | `POST/GET/PATCH` | `/encounters/{id}/orders`, `/orders/{id}`, `/orders/{id}/status`, `/orders/{id}/result` |
| **5 — Alerts** | `GET` | `/encounters/{id}/alerts`, `/alerts`, `/alerts/{id}` |
| | `POST` | `/alerts/{id}/acknowledge`, `/alerts/{id}/resolve` |
| **6 — Scores** | `GET` | `/encounters/{id}/news2/current`, `/encounters/{id}/news2/history` |
| | `GET` | `/encounters/{id}/sofa/current`, `/encounters/{id}/sofa/history` |
| | `GET` | `/encounters/{id}/qsofa/current` |
| | `GET` | `/encounters/{id}/gcs/current` |
| **7 — Sepsis** | `GET` | `/encounters/{id}/sepsis-bundle/current`, `/sepsis-bundles/{id}` |
| **8 — Review** | `GET` | `/encounters/{id}/timeline` |
| | `GET` | `/analytics/patients`, `/analytics/observations/trend`, `/analytics/alerts/summary`, `/analytics/population` |
| **9 — Discharge** | `PATCH` | `/encounters/{id}/status` |

**Operational (not encounter-scoped):** `GET /metrics` (Prometheus), `GET /health/live` (liveness probe), `GET /health/ready` (readiness probe — checks PostgreSQL, Redis, Kafka, RabbitMQ, Elasticsearch), Swagger UI (development only).

---

## Reference walkthrough — inpatient stay (step by step)

Here is the minimum sequence an integrator or simulator follows for a typical hospital stay:

```
# 1. Admit the patient
POST /api/v1/patients                                    ← register the person
POST /api/v1/patients/{patientId}/encounters             ← start their hospital visit

# 2. Record baseline vitals (first set of measurements)
POST /api/v1/encounters/{encounterId}/observations       ← heart rate, BP, temp, etc.

# 3. Give medications and order tests
POST /api/v1/encounters/{encounterId}/medications        ← record drugs given
POST /api/v1/encounters/{encounterId}/orders              ← order blood tests, cultures, etc.

# 4. Ongoing monitoring (repeat during the stay)
POST /api/v1/encounters/{encounterId}/observations       ← new vitals every few hours
GET  /api/v1/encounters/{encounterId}/alerts             ← check for alerts (wait 1-3s after posting vitals)
GET  /api/v1/encounters/{encounterId}/news2/current      ← check NEWS2 score
GET  /api/v1/encounters/{encounterId}/sofa/current       ← check SOFA score
GET  /api/v1/encounters/{encounterId}/qsofa/current      ← check qSOFA screen
GET  /api/v1/encounters/{encounterId}/gcs/current        ← check consciousness level
GET  /api/v1/encounters/{encounterId}/sepsis-bundle/current  ← check sepsis bundle if active
POST /api/v1/alerts/{alertId}/acknowledge                ← clinician acknowledges an alert
PATCH /api/v1/orders/{orderId}/result                    ← record order completion

# 5. Ward round (overview)
GET  /api/v1/encounters?status=ACTIVE                   ← see all active patients
GET  /api/v1/encounters/{encounterId}                    ← patient summary
GET  /api/v1/encounters/{encounterId}/timeline           ← full event history

# 6. Discharge
PATCH /api/v1/encounters/{encounterId}/status  { "status": "DISCHARGED", "dischargeDiagnosis": "..." }
```

For realistic clinical narratives (UTI progressing to sepsis, medication-related false alarms, rapid deterioration), replay the JSON scenarios in `VigilCare.Simulator/Scenarios/List/` — each scenario file walks through this lifecycle with real clinical data.

---

## Dashboard mapping

The Vue ward dashboard (`vigilcare-dashboard/`) implements this lifecycle through polling:

| UI screen | Primary API calls |
|---|---|
| Virtual Ward | `GET /encounters?status=ACTIVE` |
| Patient Detail | `GET /encounters/{id}`, `GET .../news2/current`, `GET .../sofa/current`, `GET .../medications`, `GET .../orders`, `GET .../sepsis-bundle/current`, `GET .../observations`, `GET .../news2/history` |
| Alert Center | `GET /alerts?status=OPEN` |
| Alert actions | `POST /alerts/{id}/acknowledge`, `POST /alerts/{id}/resolve` |
| Alert reasoning | `alert.explanation` from alert list/GET (narrative, score contributors, trend, medication context); legacy alerts fall back to `alert.details` |

---

## Sync vs async quick reference

| Event | In the HTTP response? | Technology path | Where to check afterward |
|---|---|---|---|
| Measurement saved | Yes (`201`) | PostgreSQL (direct write) | `GET .../observations` |
| Critical threshold alert | Yes (`alertGenerated: true`) | PostgreSQL (same transaction) + Redis (threshold lookup) | `GET .../alerts` |
| Warning threshold alert | No | PostgreSQL → Kafka → warning evaluator → PostgreSQL | `GET .../alerts` (after ~1–3 s) |
| NEWS2 score / alert | No | PostgreSQL → Kafka → NEWS2 scorer (reads Redis) → PostgreSQL | `GET .../news2/current`, `GET .../alerts` |
| SOFA score / alert | No | PostgreSQL → Kafka → SOFA scorer (reads Redis lab cache) → PostgreSQL | `GET .../sofa/current`, `GET .../alerts` |
| qSOFA screen | No | PostgreSQL → Kafka → sepsis engine (reads Redis) → PostgreSQL | `GET .../alerts`, `GET .../qsofa/current` |
| GCS score / alert | No | PostgreSQL → Kafka → GCS scorer → PostgreSQL | `GET .../gcs/current`, `GET .../alerts` |
| Trend alert | No | PostgreSQL → Kafka → trend analyzer (reads Redis history) → PostgreSQL | `GET .../alerts` |
| Medication annotation on alert | No — applied at alert creation | Kafka consumer reads recent meds from PostgreSQL | `GET .../alerts` → read `explanation.medicationContext` or legacy `details` |
| Search index updated | No | PostgreSQL → Kafka → ES indexer → Elasticsearch | `GET /analytics/...` endpoints |
| Data lake file written | No | PostgreSQL → Kafka → data lake writer → MinIO (Parquet); offsets only committed for successful partition uploads | MinIO bucket `vigilcare` |
| Clinician paged | No | Kafka → notification publisher → RabbitMQ paging queue | Alert `status` field |
| Alert escalated | No | RabbitMQ paging queue → DLQ → escalation queue → PostgreSQL | Alert `status` = `escalated` |
| Discharge summary PDF | No | Kafka → RabbitMQ discharge queue → worker → MinIO | MinIO `/discharge-summaries/{encounterId}/summary.pdf` |

---

## How it all fits together — a real example

Imagine a patient named Maria arrives at the Emergency Department with a suspected urinary tract infection (UTI). Here is what happens in both the clinical and technology layers:

1. **Admission:** A clerk registers Maria (`POST /patients`) and opens an emergency encounter (`POST /patients/{id}/encounters` with `encounterType: EMERGENCY`). Behind the scenes: patient and encounter rows are saved to **PostgreSQL**, an outbox event goes to **Kafka**, and the **ES indexer** creates a document in the `patient_encounters` **Elasticsearch** index — Maria now appears on the ward board.

2. **First vitals:** A nurse records heart rate 95, respiratory rate 20, BP 115/75, temperature 38.8°C, SpO2 97%, AVPU = Alert, room air. The API checks each value against thresholds in **Redis** — no critical thresholds are crossed, so no immediate alerts. The observations are saved to **PostgreSQL** and outbox events flow to **Kafka**. The NEWS2 consumer reads all 7 parameters from **Redis**, calculates total = 3 → LOW risk, no alert. The **trend analyzer** stores these values in **Redis** as the starting point for rate-of-change tracking.

3. **Labs ordered:** The doctor orders blood cultures, lactate, and a CBC (`POST /encounters/{id}/orders`). Broad-spectrum antibiotics are administered (`POST /encounters/{id}/medications`). These are saved to **PostgreSQL** — the medication record will be available for correlation if any warning alerts fire later.

4. **Two hours later — getting worse:** New vitals come in: heart rate 118, respiratory rate 24, BP 95/60, temperature 39.5°C, SpO2 94%. The API reads `threshold:SYSTOLIC_BP` from **Redis** and sees 95 is below warning (100) but above critical (70) — no sync alert. The observations go to **Kafka**, where multiple consumers react:
   - **Warning evaluator** → checks **Redis** suppression keys, finds none → creates `WARNING_SYSTOLIC_BP` in **PostgreSQL**
   - **NEWS2 scorer** → reads 7 parameters from **Redis**, calculates total = 8 → HIGH risk → creates `NEWS2_EMERGENCY` in **PostgreSQL**
   - **Sepsis engine** → checks qSOFA criteria in **Redis**: respiratory rate ≥22 and systolic BP ≤100 → 2 criteria met → creates `QSOFA_SCREEN` in **PostgreSQL**
   - **Trend analyzer** → reads previous heart rate values from **Redis**, calculates 23 bpm rise → `RAPID_DETERIORATION` in **PostgreSQL**
   - Each alert generates an outbox event → **Kafka** → **ES indexer** (updates **Elasticsearch**) and **notification publisher** → critical alerts go to **RabbitMQ** `alerts.paging.queue` → clinician is paged

5. **Lab results arrive:** Lactate comes back at 3.2 mmol/L (high — tissue hypoxia). Creatinine is 2.1 mg/dL (elevated — kidneys struggling). These go through **Kafka** to the **SOFA scorer**, which reads all cached lab values from **Redis** (`sofa:{encounterId}:...`) and recalculates. SOFA jumps by 3 points from baseline → `SOFA_SEPSIS` alert fires in **PostgreSQL**. The SOFA engine also creates a sepsis bundle with 4 auto-generated orders in **PostgreSQL**, and a `sepsis.bundle.created` event flows through **Kafka** to **Elasticsearch** (the ward board now shows `sepsisActive: true`).

6. **Sepsis bundle completion:** The team draws blood cultures, gives IV fluids, and confirms antibiotics were already given. Each order is completed via `PATCH /orders/{id}/result` — saved to **PostgreSQL** with a `sepsis.bundle.updated` outbox event → **Kafka** → **Elasticsearch** (bundle status updates in real time on the ward board). All 4 elements complete within the deadline → bundle status: `COMPLIANT`.

7. **Stabilization and discharge:** Over the next 48 hours, vitals normalize. NEWS2 drops back to 2. SOFA returns to baseline. Maria is discharged with `PATCH /encounters/{id}/status`. The discharge event flows through **Kafka** → **Elasticsearch** (Maria drops off the active ward list) → **RabbitMQ** `notifications.discharge.queue` → the **discharge summary worker** gathers all her encounter data from **PostgreSQL**, builds a PDF, and uploads it to **MinIO**. Meanwhile, the **data lake writer** has been steadily writing all her observations, alerts, and encounter events to Parquet files in **MinIO** — ready for future analysis.

Throughout this story, the ward board showed Maria's deterioration in real time — her NEWS2 score rising, qSOFA flagging, SOFA alerting — giving the clinical team the information they needed to act fast. All of this was powered by the data flowing through PostgreSQL → Kafka → Redis/Elasticsearch/RabbitMQ/MinIO behind the scenes.
