Observation Unification
Unifying vitals, biomarkers, and device streams into one observation pipeline.
Status: Draft (branch claude/review-vital-signs-integration-MaPdM)
Date: 2026-05-12
Scope: All places vitals or biomarkers are captured or displayed — screening, IPD graphic sheet, nursing-home assessment, partograph, anesthesia record, lab biomarker results, device telemetry, FHIR patient handoff.
Why this exists
Audit (see docs/legacy/VITAL_SIGN_FLOW_AND_GAP_ANALYSIS.md plus the survey on the branch) found:
- Three parallel storage models —
VitalSign(Mongo),ObservationTemporary(Mongo, 90-day TTL),vital_signs_ts(Supabase/TimescaleDB, present but empty in practice). - Seven capture surfaces that don’t share data — OPD screening, IPD graphic sheet, nursing-home vitals, anesthesia/recovery, partograph, lab biomarker results, device feeds.
- Patient-facing exchange paths (
fhir-bundle-export.ts, FHIR write API, health wallet) only see whichever surface happened to write toVitalSign. - Bug surface: silent save failures, ghost-row deletions, no Mongo session transaction across ChiefComplaint + VitalSign writes.
The goal is one canonical Observation abstraction that:
- Every clinical surface reads from and writes to — via one frontend hook and one backend service.
- Is live, cleanable, auditable — patient sees real-time updates, admins can correct, audit log captures who/when/why.
- Is exchange-ready — FHIR R4 export, subscription dispatch, patient handoff QR/wallet, HL7v2 ORU.
Dual-canonical decision
| Store | Canonical for | Why |
|---|---|---|
Mongo VitalSign (existing) |
Encounter-bound clinical observations entered by humans (screening, nursing, anesthesia, partograph, OR, recovery). Preserves the existing audit envelope, saga compensation, and encounter linkage. | Matches the rest of medOS’ “Mongo primary, Supabase read mirror” pattern. Existing CDS event emission (VITALS_DOCUMENTED) stays. |
Mongo ObservationTemporary (existing) |
Device captures awaiting promotion to a clinical observation (90-day TTL). | Already there; promotion workflow now goes through the new service. |
Supabase observations (new — this migration) |
Unified read mirror for all of the above PLUS canonical for high-frequency device streaming and lab biomarker projections. | Live (Supabase Realtime), queryable, FHIR-shaped, RLS-cleanable. Frontend never writes here directly — projected from Mongo events or written by trusted edge functions. |
Supabase vital_signs_ts / lab_results_ts / device_telemetry_ts (existing hypertables) |
Time-series rollups (hourly/daily aggregates, retention, compression). | Already provisioned; this migration finally wires them to a producer (the observations insert trigger). |
Trade-off accepted: two truths to reconcile. Mitigated by:
- The backend Observation service is the only path that creates
VitalSign/ObservationTemporary— guarantees an event emission for the projector. - The Supabase
observationstable is never written by the frontend (per web/CLAUDE.md guardrail). It’s populated by:- The
encounter-orchestratoredge function listening tohospital_events. - A reverse Supabase → Mongo path only for promoted device captures (writes go back through the backend service, not direct Mongo).
- The
- A
gold-layer-refreshcron (existing) runs a daily reconciliation between Mongovital_signcollection and Supabaseobservations, flagging drift.
Architecture
┌──────────────────────────────────────────────────────────────────────────────┐
│ FRONTEND CAPTURE SURFACES │
│ screening │ graphic-sheet │ nursing-home │ partograph │ anesthesia │ lab │
└───────────────────────────────────┬──────────────────────────────────────────┘
│ recordObservation()
▼
┌─────────────────────────────────────┐
│ web/src/services/observation-core │ ← single hook +
│ types, useObservations, │ write API for
│ recordObservation, fhirMap, codes │ every surface
└─────────────────┬───────────────────┘
│ POST /v2/diagnostic/observations
▼
┌───────────────────────────────────────────┐
│ services/diagnostic/.../observation/ │ ← unified
│ observation.service.ts │ backend
│ routes by category: │ service
│ vital-signs → VitalSign (Mongo) │
│ biomarker → ObservationTemporary │
│ device → ObservationTemporary │
│ → emits hospital_event │
└─────────────────┬─────────────────────────┘
│ Moleculer event +
│ hospital_events row
▼
┌─────────────────────────────┐
│ encounter-orchestrator │ ← Deno edge fn
│ on VITALS_DOCUMENTED, │
│ LAB_RESULT_FINALIZED, │
│ DEVICE_OBSERVATION │
│ → upsert observations row │
└─────────────┬───────────────┘
│ trigger trg_observations_to_ts
▼
┌──────────────────────────────────────────────────┐
│ observations (canonical read mirror, FHIR-shaped) │
│ ├─ trigger → vital_signs_ts │
│ ├─ trigger → lab_results_ts │
│ └─ trigger → device_telemetry_ts │
│ │
│ view observations_fhir → FHIR R4 JSON │
│ view patient_observation_timeline → unified read │
└──────────────────────────┬───────────────────────┘
│ Supabase Realtime
▼
(back to frontend useObservations)
│
▼
fhir-bundle-export.ts
→ patient handoff (QR/wallet/IPFS)
→ fhir-subscription-matcher
→ HL7v2 ORU export
The observations row shape
FHIR Observation-aligned, denormalized for read efficiency:
| Column | Type | Meaning |
|---|---|---|
id |
UUID | Primary key. |
tenant_id |
TEXT | Multi-tenant scoping (RLS). |
patient_id |
TEXT | MongoDB ObjectId of patient (canonical link). |
encounter_id |
TEXT NULL | Encounter binding when present (NULL for device captures awaiting promotion). |
category |
TEXT | vital-signs | biomarker | device | assessment | imaging (FHIR observation-category). |
code_system |
TEXT | Defaults to http://loinc.org. |
code |
TEXT | LOINC code (e.g. 8480-6 for systolic BP, 4548-4 for HbA1c). |
code_display |
TEXT | Human-readable label. |
value_numeric |
DOUBLE PRECISION | Single numeric (HR, temp, glucose…). |
value_systolic / value_diastolic |
DOUBLE PRECISION | For BP composite. |
value_text |
TEXT | Non-numeric (consciousness AVPU, urinalysis colour). |
value_codeable |
JSONB | FHIR CodeableConcept for coded values. |
unit |
TEXT | UCUM unit (mm[Hg], Cel, mmol/L). |
reference_low / reference_high |
DOUBLE PRECISION | Lab reference ranges. |
interpretation |
TEXT | normal | low | high | critical-low | critical-high | abnormal. |
effective_at |
TIMESTAMPTZ | Time the reading was taken (clinical time). |
recorded_at |
TIMESTAMPTZ | Time the record was created (system time). |
source_kind |
TEXT | screening | graphic-sheet | nursing-home | partograph | anesthesia | recovery | lab | device | fhir-write-api | hl7v2. |
source_id |
TEXT | Originating doc ID (VitalSign._id, ObservationTemporary._id, DiagnosticReport._id, etc.). |
source_form_ref |
TEXT NULL | Form session ref (graphic sheet doc, partograph entry…). |
device_id |
TEXT NULL | When source_kind=‘device’. |
recorded_by |
TEXT | User ID. |
status |
TEXT | FHIR Observation.status: registered | preliminary | final | amended | corrected | cancelled | entered-in-error. |
metadata |
JSONB | Extensible (market-pack-specific fields, e.g. kaigo flags, NEWS2 sub-scores). |
created_at / updated_at / deleted_at |
TIMESTAMPTZ | Soft delete for “cleanable” semantics — frontend deletion sets status='entered-in-error' + deleted_at, never hard delete. |
Indexes: (patient_id, effective_at DESC), (encounter_id, effective_at DESC), (category, code, effective_at DESC), (tenant_id, effective_at DESC), (source_kind, effective_at DESC) WHERE status <> 'entered-in-error'.
RLS:
select:tenant_id = current_setting('app.tenant_id', true)ANDstatus <> 'cancelled'(cancelled stays hidden from clinical reads but visible to audit).insert/update/delete:service_roleonly — frontend cannot write.
Projection rules (from hospital_events)
The existing encounter-orchestrator edge function gains three handlers:
| Event type | Action |
|---|---|
VITALS_DOCUMENTED (already emitted by vitalSign.service.ts:178-197) |
Upsert one row per vitalSignComponents[] entry, mapped to LOINC. |
LAB_RESULT_FINALIZED (new — see backend service) |
Upsert one row per lab item with category='biomarker', interpretation from is_abnormal. |
DEVICE_OBSERVATION (new) |
Upsert one row per device reading with category='device'; encounter_id may be NULL until promotion. |
VITAL_DELETED (new) |
Set status='entered-in-error', deleted_at=now() (no hard delete — auditable). |
Triggers from observations to legacy hypertables
A single trigger trg_observations_to_ts on INSERT OR UPDATE of observations:
- if
category='vital-signs'→ upsertvital_signs_ts(preserves continuous aggregates) - if
category='biomarker'→ upsertlab_results_ts - if
category='device'andmetric_nameis inmetadata→ upsertdevice_telemetry_ts
This lets the existing hypertable infra (compression, retention, hourly/daily rollups) keep working without changing migration 021.
Frontend centralized layer — web/src/services/observation-core
One package every form imports from. Public surface:
import {
UnifiedObservation,
ObservationCategory,
recordObservation,
useObservations,
observationToFhir,
LOINC, // { SYSTOLIC_BP, DIASTOLIC_BP, HEART_RATE, TEMP, RR, SPO2, GLUCOSE, HBA1C, ... }
} from '@/services/observation-core';
recordObservation(input)→ POST to backend Observation service (write-through).useObservations({ patientId, encounterId?, categories?, since?, limit? })→ Supabase live subscription toobservationstable, ordered byeffective_at DESC. Auto-resubscribes; returns{ data, isLoading, error }.observationToFhir(obs)→ FHIR R4 Observation resource (for export & subscription dispatch).LOINCconstant table — single source of truth for codes (mirror ofservices/administration/.../vitalSign.controller.mixin.ts:25-43mapping but LOINC-coded).
Form integration pattern — write-through helper
web/src/utils/observation-write-through.ts (new) — single helper every form imports. Each form’s existing save path keeps working untouched; we add one line after the existing save:
// Existing line — keep it:
const result = await createVitalSigns(payload);
// New — write-through (best-effort; never blocks UI):
import { writeThroughVitals } from '@/utils/observation-write-through';
writeThroughVitals({
patientId, encounterId,
sourceKind: 'screening', // or 'graphic-sheet', 'nursing-home', etc.
sourceId: result.data._id,
components: payload.vitalSignComponents,
effectiveAt: payload.effectiveAt,
}).catch((err) => console.warn('[observation write-through] non-blocking failure', err));
This:
- Adds zero risk to the existing save path (write-through failure is logged, not thrown).
- Becomes a no-op once the backend Observation service is the only write path (planned follow-up).
FHIR bundle export integration
web/src/utils/fhir-bundle-export.ts gains a new function:
export function bundleInputFromObservations(obs: UnifiedObservation[]): {
vitals: VitalSignInput[];
biomarkers: FhirResource[]; // direct FHIR Observation resources for non-vital biomarkers
};
HealthWalletPage and ExitPathWorkflow switch from “query whatever scattered store they used to query” to calling useObservations({ patientId, since: episodeStart }) and passing the result through bundleInputFromObservations into buildBundle. Patient hand-off now sees a complete observation timeline regardless of which surface captured each reading.
Migration / roll-out plan
- Ship the additive layer (this PR) — table, projector handlers, backend service, frontend package, write-through helper, FHIR export integration.
- Hook surfaces in parallel (this PR) — every existing capture point gets a 1-3 line write-through call. Existing behavior unchanged; new table starts filling.
- Backfill (follow-up) — one-shot script reads
vital_signcollection and emits syntheticVITALS_DOCUMENTEDevents so historical readings appear inobservations. - Switch reads (follow-up) — graphic sheet, patient summary widgets, growth charts switch their read source to
useObservations. Their write paths still use the legacy services; we just centralize the read. - Retire legacy reads (follow-up) — remove now-unused vitalSign list endpoints.
- Per-region rollout — migration is idempotent and tenant-scoped; deploy to TH first (largest dataset), then JP, then PH, per
docs/architecture/cross-region-policy-gates-deployment.md.
Invariants the design preserves
- Mongo is still primary write for clinical observations — encounter audit envelope unchanged.
- Frontend never writes Supabase read model — every write goes through the backend service.
- No data loss — deletion is soft (
status='entered-in-error'); audit recoverable. - Idempotent projection — orchestrator upserts on
(source_kind, source_id, code)so replay is safe. - Multi-tenant —
tenant_idon every row + RLS; one Supabase project per region keeps a clean boundary. - No build/tooling config changes — purely additive code; existing pages keep building.
- Bilingual seed data — code_display fields ship with EN + local-language metadata where applicable.
Open questions for follow-up
- Promotion workflow for
ObservationTemporary→VitalSignwhen a device capture is associated with an encounter after the fact. The new service has apromoteaction stub; the UI for nurses to bind orphan device readings to an encounter is a separate UX track. - Critical-value alerting — the orchestrator can emit
CRITICAL_OBSERVATIONMoleculer events frominterpretation IN ('critical-low','critical-high'). Wiring that into the acknowledgement system (docs/architecture/acknowledgement-system.md) is a clean next step. - Rate-of-change rules — NEWS2, MEWS, pediatric early-warning scores can be computed by
clinical-rules-enginereading fromobservationsinstead of each surface re-implementing.
Teaching-hospital extensions (migration 20260512b)
Academic / teaching hospitals need attribution, defensibility, deterioration
scoring, and educational annotation alongside every reading. The observations
table gets these columns (all optional, all additive):
| Column group | Purpose |
|---|---|
trainee_level, supervised_by, attestation_status, attested_by, attested_at, attestation_note |
ACGME case logs; billing compliance; the supervision chain shows on every reading as an attribution chip. |
measurement_method, body_position, oxygen_status, oxygen_flow_lpm, oxygen_fio2, patient_state, reliability |
Defensible vitals — auto-cuff vs arterial line, supine vs standing, room-air vs HFNC. Required for M&M and IRB-grade research. |
teaching_note, case_flags JSONB |
Pin teaching points on the timeline; flag m_and_m / grand_rounds / case_of_the_day / free-text learning_point. Faculty can curate cases live. |
pediatric_context JSONB |
z-score, percentile, age-specific reference range, gestational + corrected age, growth-chart selection (WHO / CDC / Fenton / INTERGROWTH). |
Early Warning Scores
A new early_warning_scores table stores one row per (patient, score_type,
computation) for NEWS2, MEWS, PEWS, MEOWS, qSOFA. The orchestrator
calls refresh_patient_ews(patient_id, encounter_id) after every
VITALS_DOCUMENTED, which:
- Pulls the most-recent value of each contributing vital within a 6-hour window.
- Calls the dedicated
compute_news2/compute_mews/ etc. SQL functions. - Inserts one row per requested score type.
Frontend reads via useEarlyWarningScore({ patientId, scoreTypes }). Live
updates via Supabase Realtime — the EarlyWarningScoreBadge updates the
moment the orchestrator finishes a recompute.
refresh_patient_ews is also exposed as a Supabase RPC so the “Recalculate”
button in the badge popover works on-demand.
Visual surfaces
- `` — single chip with severity- driven colour, value, trend arrow vs prior reading, popover with breakdown + alternate scores + recalculate button. Drop into any patient header.
- `` — sparkline per LOINC code with reference-range band, critical-value chips, teaching annotation pins (M&M / GR / COD / LP), attribution + attestation chips on hover, measurement-context chips per reading. Pure SVG, no chart dependency.
Both ship under web/src/common/components/medical/observation/ and can be
imported via @components/medical/observation.
Files in this PR
| Path | Purpose |
|---|---|
docs/architecture/observation-unification.md |
This doc. |
infrastructure/medbase/migrations/20260512_observation_unification.sql |
observations table, views, RLS, triggers, projection function. |
infrastructure/medbase/functions/encounter-orchestrator/observation-handlers.ts |
Three new event handlers (split for review clarity; imported from index.ts). |
services/diagnostic/src/api/diagnostic/modules/observation/ |
New backend service module (service, controller, DTOs). |
web/src/services/observation-core/ |
New frontend package (types, hook, write API, FHIR map, codes). |
web/src/utils/observation-write-through.ts |
Shared form-integration helper. |
web/src/utils/fhir-bundle-export.ts |
New bundleInputFromObservations export. |
web/src/containers/screening-patient/page.tsx |
Write-through hook (additive). |
web/src/containers/nursing-home/VitalsTrackingRecords.tsx |
Write-through hook (additive). |
web/src/services/ever-administration/graphicSheet.service.ts |
Read augmentation (live observations alongside existing graphic-sheet doc). |
| Partograph + anesthesia form save handlers | Write-through hook (additive, where save points exist). |