medOS ultra

CDS Vital-Signs Rules Engine

Configurable CDS engine: every observation write auto-fires CDS, alerts surface via global FAB/drawer/modal + inline badges, NEWS2/MEWS/qSOFA library.

8 min read diagramsUpdated 2026-06-03docs/architecture/cds-vital-signs-rules.md

Built atop the unified observations table (migration 20260512_observation_unification.sql) and the EWS / FHIR-bundle work merged from claude/review-vital-signs-integration-MaPdM.

Replaces hardcoded GDL rules in web/src/services/cds/cdsEngine.ts with a DB-backed, versioned, per-hospital-tunable engine. Mirrors the pattern of policy_gates and dispense_cycle_configs.

Why

The previous CDS engine had three problems:

  1. Rules were hardcoded — every threshold (HR > 130, SpO2 < 92, …) lived in TypeScript. A hospital wanting to adjust a threshold or add a new rule had to wait for a release.
  2. CDS only fired when forms explicitly called itcdsThunks.runCdsThunks was invoked from a handful of NEWS2 graphic-sheet flows. Screening dialogs, anesthesia, nursing, and pharmacy paths recorded vitals without firing any decision support.
  3. No audit trail — fired alerts went to Redux only; there was no record of which rule fired, on whose vitals, when, and whether anyone acknowledged.

The new system fixes all three by introducing a configurable rule table (cds_rules), a unified dispatcher that hooks into every observation write, and an audit log (cds_rule_evaluations).

MVC stack

                         ┌──────────────────────────────────┐
                         │  /admin/cds-rules                │ ← VIEW (admin)
                         │  CdsRulesPage + CdsRuleEdit-     │
                         │  Dialog + CdsRuleTesterPanel     │
                         └────────────────┬─────────────────┘
                                          │
       ┌──────────────────────────────────┴──────────────────────────────┐
       │                  @services/super-admin/cdsRules.service          │ ← MODEL
       │  CRUD · listActive · publishVersion · recordEvaluation · realtime│
       └────┬──────────────────────────────────────────────────────────┬──┘
            │                                                          │
   ┌────────▼────────────────────┐                          ┌──────────▼─────────────┐
   │  cdsEngineDb.ts (frontend)   │                          │  evaluate-cds-rules    │
   │  evaluateCdsRulesAgainst-    │                          │  edge function (Deno)  │
   │  Observation()               │                          │  (backend / HL7v2 /    │
   │  + rule cache + debounce      │                          │   lab / device path)  │
   └────────┬────────────────────┘                          └──────────┬─────────────┘
            │                                                          │
            └──────────────┬───────────────────────────────────────────┘
                           │
                  ┌────────▼──────────┐
                  │  cdsDispatcher    │ ← CONTROLLER (frontend)
                  │  · Redux fanout   │
                  │  · hospital_events│
                  │  · webhooks       │
                  └────────┬──────────┘
                           │
   ┌───────────────────────┼───────────────────────────────────────┐
   │                       │                                       │
┌──▼───────────────┐  ┌────▼─────────────────┐   ┌─────────────────▼──────────┐
│  CdsAlertSurface │  │  InlineCdsAlertBadge │   │  Subscribers (acks, webhk) │
│  (App-level)     │  │  (form-level)         │   │                            │
│  FAB · modal ·    │  │  Per encounter        │   │                            │
│  drawer · toast   │  │                       │   │                            │
└──────────────────┘  └──────────────────────┘   └────────────────────────────┘

Data model

Table Purpose
cds_rules The editable rule rows (rule_code, scope, body, surface, display).
cds_rules_history Audit snapshot of every UPDATE/DELETE.
cds_rule_versions Explicit published versions (publish_cds_rule RPC bumps and snapshots).
cds_rule_evaluations Every fired/missing/error evaluation, with inputs + outputs + surfaces.
cds_rule_acknowledgements Clinician acks of fired alerts (acknowledge/override/snooze/escalate).
v_cds_rules_active Helper view — active rules indexed by listened codes.

Rule shape

{
  "rule_code": "VS.HR.TACHYCARDIA",
  "name": "Tachycardia (HR > 130)",
  "name_th": "หัวใจเต้นเร็ว (HR > 130)",
  "category": "vital-signs",
  "sub_type": "threshold",
  "severity": "warning",
  "action": "WARN",
  "status": "active",
  "priority": 200,

  "scope_json": {
    "codes": ["8867-4"],          // listened LOINC codes
    "categories": ["vital-signs"],
    "sources": [],                 // empty = applies to all sources
    "departments": [],
    "patient_classes": [],
    "age_min": 18,
    "age_max": null,
    "sex": [],
    "encounter_types": []
  },

  "rule_body": {
    "when": [
      "loinc:8867-4 > 130"         // array entries are AND; the array is OR
    ],
    "then": {
      "message": "Tachycardia: HR > 130 bpm",
      "message_th": "หัวใจเต้นเร็ว: HR > 130 ครั้ง/นาที"
    },
    "missing_policy": "skip",      // skip | warn | block
    "debounce_ms": 30000           // coalesce repeat fires
  },

  "surface_json": {
    "inline": true,
    "global_panel": true,
    "modal": false,
    "toast": false,
    "acknowledgement_request": false,
    "webhook": false,
    "notify_role": null
  },

  "display_json": {
    "icon": "warning",
    "color": "amber",
    "show_outputs": true,
    "show_reference_range": true,
    "cta_label": null,
    "cta_route": null
  }
}

Condition language

Conditions are simple token-based, not evaluated as JS (safe):

loinc:8867-4 > 130                              # HR > 130
loinc:8480-6 >= 140 && loinc:8462-4 >= 90        # both SBP & DBP high
loinc:38214-3 >= 7                              # pain ≥ 7
loinc:80288-4 != null && loinc:80288-4 <= 13     # GCS recorded AND ≤ 13

Operators: >, <, >=, <=, ==, !=, == null, != null. Refs: loinc:, snomed:, or display:<lower-kebab-name>.

Integration points (where CDS fires)

Path Wired by
Any frontend form calling recordObservation() Auto via recordObservation.tsdispatchCdsForObservation
writeThroughVitals / writeThroughBiomarkers Inherits via recordObservation
Backend NestJS observation.service write Auto via Moleculer event → encounter-orchestrator/observation-handlers
HL7v2 ORU result inbound Same orchestrator path
FHIR write-API submission Same orchestrator path
Lab feed (LAB_RESULT_FINALIZED event) projectLabResultFinalized → fanOut
Device capture (DEVICE_OBSERVATION event) projectDeviceObservation → fanOut
Inline display in any form Drop in ``
Backfill / catch-up Cron job cds_rules_backfill_24h (disabled by default)

The screening, anesthesia (AnesthesiaVitalSignsForm), nursing-home (VitalsTrackingRecords, DailyNursingCareForm), pharmacy (DetailConsult), and patient-dialog (VitalSignsSection) flows all route through one of these — so they all inherit CDS automatically.

Surfaces (where alerts appear)

A single fired rule fans out to one or more surfaces depending on its surface_json:

Surface Renderer Behavior
inline `` Drop into a form; shows live alerts for the patient
global_panel CdsAlertSurface (mounted in App.tsx) FAB + side drawer
modal CdsAlertSurface Blocking dialog requiring explicit ack
toast CdsAlertSurface Auto-hide snackbar
acknowledgement_request services/messaging dispatcher Creates an AcknowledgementRequest entity
webhook webhook-management config Fires HTTP POST to configured URL
notify_role Push to all users in role Real-time notification

Critical-severity alerts and ASK_ACK / BLOCK actions force the modal path regardless of surface_json.modal, so they cannot be missed.

Admin UI — /admin/cds-rules

  • Rules tab: searchable/filterable table of all rules. Inline toggle to activate/inactivate, edit, publish version, delete (soft).
  • Evaluations tab: live audit feed of every fired rule across the system.
  • Tester tab: enter synthetic vitals → see which rules would fire without persisting anything.

The edit dialog has six tabs: Identity · Scope · Rule Body · Surface · Display · Raw JSON. The Raw JSON tab is the escape hatch for power users who want to paste in a full rule definition.

Edit dialog — guided form + live simulation (2026-06-03)

The edit dialog is a two-pane surface: the guided tabs on the left, and a live preview & simulation pane on the right that updates as you type.

  • Field help — every non-obvious field carries an info / calculator / tune icon with a rich tooltip (CdsFieldHelp.tsx). Selects use labelWithHelp (icon inside the floating label); text fields use helpAdornment (end icon).
  • Live alert simulation (CdsAlertPreview.tsx) — sample observation values (seeded to TRIP the rule, with Trigger / Normal toggles) are fed through the real engine (evaluateCdsRulesAgainstObservation, skipPersistence), yielding a FIRES / Stays-quiet / Waiting-on-inputs verdict plus faithful mockups of every enabled surface (toast, inline badge, global drawer row, blocking modal) and the side-effects (ack-request / webhook / notify-role). Critical severity and Block / Require-Ack actions show the modal as auto-forced, matching CdsAlertSurface’s real routing.
  • Body map (CdsBodyMap.tsx) — a 2D projection of the shared body-region registry (@medical-kit/body-model-3d/bodyRegionCoordinates, the same anatomy Patient 360 / usePatientBodyData use). Each rule code resolves to a region via cdsCodeCatalog.ts and drops a severity-tinted pin + legend.

Coverage tab — “what rules apply where”

A fourth list-page tab (CdsCoveragePanel.tsx) groups all rules three ways: hospital-wide (no department scope → runs everywhere), department-specific (by each department named in a scope), and by clinical system (derived from each rule’s codes). Clicking a rule opens the editor.

cdsCodeCatalog.ts is the shared knowledge powering all of the above — a curated LOINC → {label, body region, clinical system, unit, reference range, typical departments, sample-abnormal value} table. It’s additive and degrades gracefully on unknown codes (→ systemic region).

Versioning

publish_cds_rule(rule_id, notes) bumps version and writes a snapshot to cds_rule_versions. Rollback is a matter of UPSERTing the snapshot back into cds_rules. The orchestrator and edge function always read the current v_cds_rules_active view, so rolling back is immediate.

Seeding new regions

  1. Apply infrastructure/medbase/migrations/20260514b_cds_vital_signs_rules.sql (idempotent — re-runnable).
  2. Apply infrastructure/medbase/seeds/cds_rules_baseline.sql (idempotent via ON CONFLICT (organization_id, rule_code) DO NOTHING).
  3. Deploy edge function: supabase functions deploy evaluate-cds-rules.
  4. Optionally enable the cron backfill: update cron_jobs row cds_rules_backfill_24h to status='active'.

Tenants can then fork/disable any baseline rule via /admin/cds-rules.

Failure modes & idempotency

  • recordObservation failure does NOT block on CDS — CDS is fire-and-forget.
  • dispatchCdsForObservation catches all errors internally; observation writes never fail because of CDS.
  • The edge function is idempotent on (rule_id, observation_id) — repeated fires within debounce_ms collapse into a single evaluation row.
  • If the edge function URL isn’t configured, the orchestrator falls back to inserting a CDS_EVALUATION_REQUESTED hospital_event which the cron backfill will pick up.

Files

File Role
infrastructure/medbase/migrations/20260514b_cds_vital_signs_rules.sql Schema
infrastructure/medbase/seeds/cds_rules_baseline.sql Seed library
infrastructure/medbase/functions/evaluate-cds-rules/index.ts Backend evaluator
infrastructure/medbase/functions/encounter-orchestrator/observation-handlers.ts Fan-out from orchestrator
web/src/services/super-admin/cdsRules.service.ts Data layer (Model)
web/src/services/cds/cdsEngineDb.ts Frontend evaluator (Controller)
web/src/services/cds/cdsDispatcher.ts Redux + event fanout (Controller)
web/src/services/cds/useCdsAlertsForEncounter.ts Per-encounter hook
web/src/services/observation-core/recordObservation.ts Hot path — auto fires CDS
web/src/common/components/super-admin/cds-rules/CdsRulesPage.tsx Admin View (Rules · Coverage · Evaluations · Tester tabs)
web/src/common/components/super-admin/cds-rules/CdsRuleEditDialog.tsx Edit dialog (two-pane: guided form + live preview)
web/src/common/components/super-admin/cds-rules/CdsRuleTesterPanel.tsx Sandbox tester
web/src/common/components/super-admin/cds-rules/CdsAlertPreview.tsx Live alert simulation pane (verdict + faithful surface mockups)
web/src/common/components/super-admin/cds-rules/CdsBodyMap.tsx 2D body map of the regions a rule watches
web/src/common/components/super-admin/cds-rules/CdsCoveragePanel.tsx “What rules apply to what departments / systems”
web/src/common/components/super-admin/cds-rules/CdsFieldHelp.tsx Field help icons + tooltips + section headers
web/src/common/components/super-admin/cds-rules/cdsCodeCatalog.ts LOINC → body region / system / unit / range / departments catalog
web/sandbox/targets/CdsRuleStudioTarget.tsx Sandbox verification harness (?target=CdsRuleStudio)
web/src/common/components/medical/surface/cds-alert/CdsAlertSurface.tsx Global FAB + drawer + modal + toast
web/src/common/components/medical/surface/cds-alert/InlineCdsAlertBadge.tsx Inline form badge
web/src/routes/AdminRoutes.tsx Registers /admin/cds-rules route
web/src/App.tsx Mounts CdsAlertSurface
Ask Anything