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.
Built atop the unified
observationstable (migration 20260512_observation_unification.sql) and the EWS / FHIR-bundle work merged fromclaude/review-vital-signs-integration-MaPdM.Replaces hardcoded GDL rules in
web/src/services/cds/cdsEngine.tswith a DB-backed, versioned, per-hospital-tunable engine. Mirrors the pattern ofpolicy_gatesanddispense_cycle_configs.
Why
The previous CDS engine had three problems:
- 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.
- CDS only fired when forms explicitly called it —
cdsThunks.runCdsThunkswas invoked from a handful of NEWS2 graphic-sheet flows. Screening dialogs, anesthesia, nursing, and pharmacy paths recorded vitals without firing any decision support. - 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.ts → dispatchCdsForObservation |
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 uselabelWithHelp(icon inside the floating label); text fields usehelpAdornment(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, matchingCdsAlertSurface’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 /usePatientBodyDatause). Each rule code resolves to a region viacdsCodeCatalog.tsand 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
- Apply
infrastructure/medbase/migrations/20260514b_cds_vital_signs_rules.sql(idempotent — re-runnable). - Apply
infrastructure/medbase/seeds/cds_rules_baseline.sql(idempotent viaON CONFLICT (organization_id, rule_code) DO NOTHING). - Deploy edge function:
supabase functions deploy evaluate-cds-rules. - Optionally enable the cron backfill: update
cron_jobsrowcds_rules_backfill_24htostatus='active'.
Tenants can then fork/disable any baseline rule via /admin/cds-rules.
Failure modes & idempotency
recordObservationfailure does NOT block on CDS — CDS is fire-and-forget.dispatchCdsForObservationcatches all errors internally; observation writes never fail because of CDS.- The edge function is idempotent on
(rule_id, observation_id)— repeated fires withindebounce_mscollapse into a single evaluation row. - If the edge function URL isn’t configured, the orchestrator falls back to
inserting a
CDS_EVALUATION_REQUESTEDhospital_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 |