medOS ultra

Observation Unification

Unifying vitals, biomarkers, and device streams into one observation pipeline.

11 min read diagramsUpdated 2026-05-13docs/architecture/observation-unification.md

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 modelsVitalSign (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 to VitalSign.
  • Bug surface: silent save failures, ghost-row deletions, no Mongo session transaction across ChiefComplaint + VitalSign writes.

The goal is one canonical Observation abstraction that:

  1. Every clinical surface reads from and writes to — via one frontend hook and one backend service.
  2. Is live, cleanable, auditable — patient sees real-time updates, admins can correct, audit log captures who/when/why.
  3. 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:

  1. The backend Observation service is the only path that creates VitalSign / ObservationTemporary — guarantees an event emission for the projector.
  2. The Supabase observations table is never written by the frontend (per web/CLAUDE.md guardrail). It’s populated by:
    • The encounter-orchestrator edge function listening to hospital_events.
    • A reverse Supabase → Mongo path only for promoted device captures (writes go back through the backend service, not direct Mongo).
  3. A gold-layer-refresh cron (existing) runs a daily reconciliation between Mongo vital_sign collection and Supabase observations, 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) AND status <> 'cancelled' (cancelled stays hidden from clinical reads but visible to audit).
  • insert/update/delete: service_role only — 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' → upsert vital_signs_ts (preserves continuous aggregates)
  • if category='biomarker' → upsert lab_results_ts
  • if category='device' and metric_name is in metadata → upsert device_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 to observations table, ordered by effective_at DESC. Auto-resubscribes; returns { data, isLoading, error }.
  • observationToFhir(obs) → FHIR R4 Observation resource (for export & subscription dispatch).
  • LOINC constant table — single source of truth for codes (mirror of services/administration/.../vitalSign.controller.mixin.ts:25-43 mapping 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

  1. Ship the additive layer (this PR) — table, projector handlers, backend service, frontend package, write-through helper, FHIR export integration.
  2. 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.
  3. Backfill (follow-up) — one-shot script reads vital_sign collection and emits synthetic VITALS_DOCUMENTED events so historical readings appear in observations.
  4. 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.
  5. Retire legacy reads (follow-up) — remove now-unused vitalSign list endpoints.
  6. 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

  1. Mongo is still primary write for clinical observations — encounter audit envelope unchanged.
  2. Frontend never writes Supabase read model — every write goes through the backend service.
  3. No data loss — deletion is soft (status='entered-in-error'); audit recoverable.
  4. Idempotent projection — orchestrator upserts on (source_kind, source_id, code) so replay is safe.
  5. Multi-tenanttenant_id on every row + RLS; one Supabase project per region keeps a clean boundary.
  6. No build/tooling config changes — purely additive code; existing pages keep building.
  7. Bilingual seed data — code_display fields ship with EN + local-language metadata where applicable.

Open questions for follow-up

  • Promotion workflow for ObservationTemporaryVitalSign when a device capture is associated with an encounter after the fact. The new service has a promote action 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_OBSERVATION Moleculer events from interpretation 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-engine reading from observations instead 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:

  1. Pulls the most-recent value of each contributing vital within a 6-hour window.
  2. Calls the dedicated compute_news2 / compute_mews / etc. SQL functions.
  3. 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).
Ask Anything