medOS ultra

e-Kardex from Journey Cache

Purely-additive e-Kardex: summarize-and-launch tiles over journey-cache JSONB, alerts fan-in, shift-handoff continuity, bounded snooze.

31 min read diagramsUpdated 2026-05-26docs/architecture/ekardex-from-journey-cache.md

Audience: Engineers working on any IPD nursing operational view (e-Kardex, medication round, nurse focus list, ward shift report, bedside summary).

Premise: The e-Kardex is not a new system — it is one face of a shared data substrate. Medication round, focus list, hand-off / SBAR, bed board, and worklist row-detail are sibling faces of the same substrate. Build the substrate; the views are thin compositions.


1. Mental model — Kardex is a projection family, not a feature

The traditional Kardex is the bedside summary card for a single patient on one shift. Real-world nursing workflows demand the same data filtered and re-shaped several ways:

View Filter Slice
e-Kardex one encounter full snapshot (all sections)
Medication round one nurse, current shift meds due in window, batched by room
Nurse focus list one nurse, assigned beds per-patient task summary + outstanding ack items
Bed board / ward view one ward spatial; mini-Kardex per bed
Shift report / SBAR shift outgoing → incoming delta + open issues
Worklist row detail one queue ticket encounter context for the action
IPD Command Center one encounter step-driven (admission → discharge)

These are not seven separate systems with seven datasets. They are seven lenses on:

  • encounter_journey_cache (the macro per-encounter view)
  • department_queues (the operational micro view)
  • Standardised widget surfaces exported by miniapps

The architectural question is therefore not “how do we build the Kardex”; it is “how do we make encounter_journey_cache rich enough that every view becomes a thin selector + a card layout?”


2. Data layer — five-source fan-in into encounter_journey_cache

encounter_journey_cache already exists and is kept warm by the encounter-orchestrator Deno function (see docs/architecture/encounter-orchestrator-triggers.md). One row per encounter, JSONB columns projected from ~20 Moleculer events.

What’s already there

Column Source Used by
patient_id, patient_hn, demographics handleEncounterSeeded All views — header strip
pending_tickets, completed_tickets handleQueueWorkflowTransition Pending labs/imaging/consult cards
financial_summary handleFinancialUpdated Discharge readiness, billing tab
clinical_data, clinical_context handleClinicalUpdated Recent notes, problem list seed
active_alerts CDS / EWS fan-in Acute-deterioration banner
doctor_summary handleClinicalUpdated Attending-of-record card
safety_snapshot handleClinicalUpdated, handleFormAssessmentSaved Allergy / fall / isolation flags
has_active_allergies, has_active_medications Aggregate flags Quick badges

safety_snapshot is the surprise-already-there — assessment-derived precautions (Morse, Braden, allergy flags, suicide/harm risk) already land here via form_assessments → orchestrator handler. The IPD Nursing Assessment work already wired into this path.

Five gaps to add

Each is a new JSONB column + an orchestrator handler extension (or a new event subscription on existing handlers):

New column Source events Contents Drives card
medication_summary rx.prescribed, rx.verified, mar.administered, medication.auto_held, manifest.dispense_cycles_requested Scheduled meds, PRN list, IV drips with rate, due-next, hold reasons, last-dose times Med round card, Kardex meds, e-MAR drawer
active_orders manifest.order.created for diet / activity / vital-frequency / I&O / wound-care / DVT-ppx / glycemic / respiratory Diet order text, activity order (WB status), vital q-frequency, I/O monitoring flag, prophylaxis protocols, wound care schedule Orders card, focus list task chips
access_lines manifest.device.observation for IV / CVC / PICC / Foley / NG / drains Site, insertion date, line type, dressing due-date, last-flush Lines/tubes card, infection-control panel
encounter_header manifest.encounter.seeded, manifest.encounter.updated, problem-list events Code status, isolation precautions, restraints (with monitoring sched), language, attending, problem list, target discharge date, disposition Header strip on every view
care_plan (Future) NANDA-NIC-NOC events from nursing-kanban-board work Active NANDA problems, NIC interventions, NOC outcomes, progress notes Care plan card, focus list goals

Honesty check. Three of these (medication_summary, active_orders, access_lines) mostly have event data flowing already — they just aren’t being projected into the cache yet. ~2-3 days of orchestrator extension. encounter_header needs real backend work to surface code status / isolation reliably (today these live on the Mongo encounter model but aren’t reliably normalised into clinical_data). care_plan waits for the nursing-kanban work.

Schema additions

-- encounter_journey_cache extensions
ALTER TABLE encounter_journey_cache
  ADD COLUMN IF NOT EXISTS medication_summary  JSONB DEFAULT '{}'::jsonb,
  ADD COLUMN IF NOT EXISTS active_orders       JSONB DEFAULT '{}'::jsonb,
  ADD COLUMN IF NOT EXISTS access_lines        JSONB DEFAULT '[]'::jsonb,
  ADD COLUMN IF NOT EXISTS encounter_header    JSONB DEFAULT '{}'::jsonb,
  ADD COLUMN IF NOT EXISTS care_plan           JSONB DEFAULT '{}'::jsonb;

-- Lightweight indexes for filtering at the ward-level views
CREATE INDEX IF NOT EXISTS idx_ejc_isolation
  ON encounter_journey_cache ((encounter_header->>'isolation_kind'))
  WHERE encounter_header->>'isolation_kind' IS NOT NULL;

CREATE INDEX IF NOT EXISTS idx_ejc_code_status
  ON encounter_journey_cache ((encounter_header->>'code_status'))
  WHERE encounter_header->>'code_status' IS NOT NULL;

Projection contracts

Each handler diff is small. Example, handleMedicationAdministered:

// infrastructure/medbase/functions/encounter-orchestrator/handlers/medication.ts
async function handleMedicationAdministered(ev: HospitalEvent) {
  const { encounter_id, patient_id, medication_request_id, administered_at } = ev.payload;

  // existing: write medication_administrations row (permanent record)
  await insertMedicationAdministration(...);

  // NEW: project rolling med summary onto journey cache
  const summary = await buildMedicationSummary(encounter_id); // queries active rx + last admin
  await supabase
    .from('encounter_journey_cache')
    .update({ medication_summary: summary, manifest_version: ev.version })
    .eq('encounter_id', encounter_id);
}

buildMedicationSummary is a single SQL query that joins active medicationRequests with their latest medication_administrations row. No new business logic.


3. Preference layer — kardex_layouts

Layout state is a separate concern from data. Three independent dimensions:

  1. Scope — who owns this layout (user / role / ward / tenant default)
  2. Cards visible — which slots to render
  3. Card positions — where each card sits (the existing eKardex.tsx is already draggable / resizable)
CREATE TABLE kardex_layouts (
  id                 UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  scope_kind         TEXT NOT NULL CHECK (scope_kind IN ('user','ward','role','tenant_default')),
  scope_id           TEXT NOT NULL,
  name               TEXT NOT NULL,
  view_kind          TEXT NOT NULL CHECK (view_kind IN ('kardex','medication_round','focus_list','shift_report','bedside_summary')),
  is_default         BOOLEAN DEFAULT false,
  cards              JSONB NOT NULL,
    -- [{ card_id, x, y, w, h, visible, expanded, props }, ...]
  visible_categories TEXT[],
  created_by         UUID,
  created_at         TIMESTAMPTZ DEFAULT NOW(),
  updated_at         TIMESTAMPTZ DEFAULT NOW(),
  UNIQUE (scope_kind, scope_id, view_kind, name)
);

CREATE INDEX idx_kardex_layouts_scope ON kardex_layouts (view_kind, scope_kind, scope_id);

Resolver precedence (highest → lowest): userrolewardtenant_default.

A nurse can personalise; a ward can standardise; a tenant ships a sane baseline. view_kind keeps medication-round / focus-list / Kardex layouts separate so a “compact for med round, detailed for Kardex” choice doesn’t conflict.

// Pseudo-resolver shape
async function resolveLayout(userId, role, wardId, tenantId, viewKind) {
  const layouts = await supabase
    .from('kardex_layouts')
    .select('*')
    .eq('view_kind', viewKind)
    .or(`and(scope_kind.eq.user,scope_id.eq.${userId}),
         and(scope_kind.eq.role,scope_id.eq.${role}),
         and(scope_kind.eq.ward,scope_id.eq.${wardId}),
         and(scope_kind.eq.tenant_default,scope_id.eq.${tenantId})`)
    .eq('is_default', true);
  // Pick highest-precedence layout from the result
  return layouts.find(byPrecedence) ?? FALLBACK_BASELINE[viewKind];
}

4. Composition layer — summarize-and-launch pattern

Hard rule: no existing miniapp is modified by this work. e-MAR, FocusList, OrderActivityDock, LabResultsWidget, IpdNursingAssessment all stay exactly as they are today. Their pages, their default exports, their consumers — untouched.

The Kardex is built as two thin things:

  1. Summary tiles — small read-only cards that pull data straight from encounter_journey_cache JSONB. ~50 LOC each, no embedded miniapp bundles, no widget coupling.
  2. Click → open — clicking a card dispatches the existing actionModalResolver/openModal to launch the full existing component in its existing modal/drawer/dialog. No reimplementation, no compact mode to negotiate.
┌─────────────────────────────────────┐
│  Kardex page                         │
│  ┌─────────┐ ┌─────────┐ ┌────────┐ │
│  │ Meds    │ │ Vitals  │ │ Labs   │ │
│  │ (3 due) │ │ EWS:5   │ │ 2 crit │ │ ← summary tiles
│  └────┬────┘ └────┬────┘ └───┬────┘ │   read encounter_journey_cache JSONB
│       │           │          │      │
└───────┼───────────┼──────────┼──────┘
        │ click     │ click    │ click
        ▼           ▼          ▼
   ┌────────┐  ┌────────┐  ┌────────┐
   │ MAR    │  │ EWS    │  │ Lab    │  ← actionModalResolver/openModal
   │ (full) │  │ drawer │  │ widget │     opens existing component
   └────────┘  └────────┘  └────────┘     in existing modal — untouched

Two flavours of card, by component type

Tiny inline display components — already designed for inline use. Import directly into the summary tile (no modal):

Component Used inline as
EarlyWarningScoreBadge EWS chip on header / vitals tile
InlineCdsAlertBadge Active alerts chip on header
AssessmentStatusBadge Status pill on safety tile
Status chips, badges, score pills Various tile decorations

These are tiny pure-display components, no business logic, no embedded modals. Importing them adds negligible weight and they already render small.

Full working components — never embedded in Kardex. Summary tile shows the data, click opens them as-is via the existing modal infrastructure:

Component Today opens via Kardex click →
MarSystemEnhanced / DialogEmar Worklist row action, e-MAR route Same modal, same component
FocusListEnhanced Nurse-note miniapp route Same component in modal
IpdNursingAssessment Worklist row action, IPD CC Step 2 Same modal (already wired via actionModalResolver)
LabResultsWidget Widget rail Same component in modal
OrderActivityDock Patient profile widget Same component in modal
PatientHeaderCard IPD CC Reused as-is at top of Kardex

How a summary tile is wired

Pure data → display → dispatch. No coupling to the target component:

// packages/miniapps/e-kardex/cards/MedicationSummaryTile.tsx
import { useDispatch } from 'react-redux';
import { Card, Typography } from '@mui/material';

export function MedicationSummaryTile({ encounterId, patientId, summary }) {
  const dispatch = useDispatch();

  return (
    <Card
      onClick={() => dispatch(actionModalResolver.openModal({
        modalKey: 'eMar',                  // existing key, existing modal
        props: { encounterId, patientId },
      }))}
      sx={{ cursor: 'pointer' }}
    >
      <Typography variant="overline">Medications</Typography>
      <Typography variant="h6">{summary?.due_count ?? 0} due now</Typography>
      <Typography variant="caption">
        Next: {summary?.next_due?.name} at {summary?.next_due?.time}
      </Typography>
      {summary?.has_high_alert && <HighAlertChip />}
    </Card>
  );
}

That’s the entire card. summary came from encounter_journey_cache.medication_summary (the JSONB column added in §2). The actionModalResolver already exists and already knows how to open eMar — the Kardex just dispatches the same action a worklist row would dispatch.

Card catalog (what the baseline tenant_default layout ships)

Tile Reads from Click opens
Patient header strip patient_id + encounter_header (no click — header)
Alerts / awareness (§4.5) active_alerts (unified sink) Alerts drawer (grouped by source)
Safety / precautions safety_snapshot IpdNursingAssessment modal
Vitals (latest) observations subscription EWS detail drawer
Medications medication_summary MarSystemEnhanced / DialogEmar modal
Active orders active_orders OrderActivityDock modal
Labs / imaging pending_tickets filtered LabResultsWidget modal
Access / lines access_lines (inline detail expand, no modal yet)
Care plan / focus care_plan (future) FocusListEnhanced modal
Pending acks acknowledgements query AcknowledgementInbox drawer
Pending tickets pending_tickets Worklist filter / queue floater

4.5. Alerts & awareness — full fan-in, not just EWS

EWS is one input among many. The Kardex alerts tile is the unified clinical awareness surface for an encounter, fed from every alert-producing system already in the platform plus a per-encounter watch layer plus an AI assist layer with strict safety rails.

Universal sink: encounter_journey_cache.active_alerts

Already exists. Already projected to by multiple systems. Already has dedup, severity, and an ActiveAlert type. The Kardex tile is purely a renderer.

// _shared/event-contract.ts (already defined)
interface ActiveAlert {
  id:             string;
  code:           string;            // dedup key
  source:         AlertSource;       // 'ews' | 'cds-rule' | 'lab-critical' | 'transfusion' | …
  severity:       'info' | 'warning' | 'critical';
  message:        string;
  raisedAt:       string;            // ISO timestamp
  requiresAcknowledgement?: boolean;
  context?:       Record<string, unknown>;  // source-specific payload
  // §4.5 extensions:
  aiConfidence?:  number;            // 0–1, present when source startsWith 'ai-'
  aiReviewer?:    string;            // 'haiku' | 'sonnet' — when AI was involved
}

Sources that already feed it (audit, not build)

Source kind Wiring status Examples
ews refresh_patient_ews() Postgres function NEWS2 ≥ 5, MEWS ≥ 4, qSOFA ≥ 2
cds-rule /admin/cds-rules engine, seeded baseline Hypotension, hypoxia, hypoglycemia, drug-drug, sepsis screen
transfusion ✅ DB trigger on transfusion_reactions Acute hemolytic, febrile non-hemolytic
blood-bank blood_bank_clinical_notifications Crossmatch incompatibility
pathology-critical pathology_critical_values Positive blood culture, malignant cells
lab-critical ✅ Lab pipeline escalation K < 3.0, Hb < 7.0, lactate > 4
acknowledgement acknowledgement_requests overdue Doctor hasn’t ack’d critical result
policy-gate policy_gates blocks Discharge blocked: bill not closed

Sources that should feed it but currently don’t (gaps to close)

These are high-yield clinical alerts that the seeded CDS library misses. They are CDS rules to author, not new engine work — but they must be present at go-live, not “future”:

Source kind Detection Priority
allergy-conflict New med ordered whose ingredient class matches an allergy on safety_snapshot Critical at go-live — single highest-yield CDS in inpatient setting
vitals-overdue No new observation in observations within the q-frequency declared by active_orders.vital_frequency Critical at go-live — negative-space alert, a leading cause of “found deteriorating”
npo-violation Diet order ≠ NPO but med/feeding administered, or NPO active and oral med administered High
pre-op-prep-missed Scheduled OR time within N hours and checklist incomplete (consent / fasting / shave / antibiotic prophylaxis window) High
antibiotic-timing Scheduled dose missed by > 30min on time-critical antibiotics (vancomycin, aminoglycosides) High
device-alert Pump occlusion, vent disconnect, central-monitor lead-off — when device integration ships Medium (depends on device hook)

“Blood infection” is not new code. Existing wiring covers most of it: seeded sepsis CDS rule + pathology positive blood culture + lab-critical lactate + transfusion trigger. Additional rules to author in /admin/cds-rules at go-live: CLABSI suspicion (fever + access line ≥ 48h + no other source), antibiotic-stewardship duration/sensitivity mismatch. Zero engine changes.

New: per-encounter watch rules — templated, not free JSONB

The existing CDS engine handles tenant-global rules. What’s missing is per-encounter ad-hoc watches — a doctor clicks “Watch this” on an observation row and gets alerted only for this encounter.

Critical design choice: templated rule kinds with typed parameters, not raw JSONB. Free-form predicates accumulate malformed rules, silent evaluation errors, and no one to maintain them. We ship a small fixed set of rule_templates; the doctor picks a template and fills typed slots.

-- NEW table — templated per-encounter watch subscriptions
CREATE TABLE encounter_watch_rules (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  encounter_id    UUID NOT NULL,
  patient_id      UUID NOT NULL,
  authored_by     UUID NOT NULL,
  authored_role   TEXT,
  rule_template   TEXT NOT NULL CHECK (rule_template IN (
    'threshold',         -- single observation crosses a value
    'trend',             -- observation trending direction over window
    'co_occurrence',     -- two conditions true simultaneously
    'duration',          -- a state has persisted longer than threshold
    'absence'            -- expected observation missing in window
  )),
  parameters      JSONB NOT NULL,    -- schema-validated against rule_template
  message         TEXT NOT NULL,
  severity        TEXT NOT NULL CHECK (severity IN ('info','warning','critical')),
  active          BOOLEAN DEFAULT true,
  -- Lifecycle (gap closer — rules don't outlive their owner or the encounter)
  expires_at      TIMESTAMPTZ NOT NULL,           -- required; default = discharge ETA or +7d
  auto_clear_on_discharge BOOLEAN DEFAULT true,   -- always clears on discharge
  handoff_to      UUID,                            -- if author rotates off, must transfer or rule deactivates
  handoff_at      TIMESTAMPTZ,
  created_at      TIMESTAMPTZ DEFAULT NOW(),
  CONSTRAINT chk_expires_future CHECK (expires_at > created_at)
);

CREATE INDEX idx_ewr_encounter_active
  ON encounter_watch_rules (encounter_id) WHERE active = true;

-- Schema validation per template (Postgres CHECK + edge-fn validator)
COMMENT ON COLUMN encounter_watch_rules.parameters IS '
threshold:    { observation: text, op: "<"|"<="|">"|">=", value: number, unit: text }
trend:        { observation: text, window_h: int, direction: "rising"|"falling", min_delta: number }
co_occurrence:{ conditions: [<threshold>, <threshold>], window_min: int }
duration:     { state: text, op: "<"|">", value_h: number }
absence:      { observation: text, expected_within_h: number }
';

Evaluation reuses the existing CDS engine — evaluate-cds-rules edge function gets a second pass that joins encounter_watch_rules against the observation event. Each template has a typed evaluator function (5 small functions, well-tested). Hits land in active_alerts with source: 'watch', context.authoredBy, context.template.

Lifecycle (closes the “rotation” gap):

  • Every rule must declare expires_at (UI default: discharge ETA; max 7 days)
  • All rules auto_clear_on_discharge = true by default
  • Author rotation: nightly cron flags rules where authored_by user is no longer on this encounter’s care team (via clinical-roster lookup). Rules with no handoff_to within 24h auto-deactivate (not deleted — active = false with audit trail)
  • Handoff UI: when an attending hands off, system surfaces all their active watches and asks “transfer / discharge / deactivate” per rule

UI affordance: a small “watch” button on every observation row (e-MAR, vitals, lab results). Click → modal with template picker and typed inputs → POST to encounter_watch_rules. No free-text predicate input anywhere.

AI assist — deferred behind a feature flag, with corrected safety stance

Hard ground-truth corrections to the original AI design:

  1. AI does not hide alerts. Period. Suppression — even with audit logging — means nobody sees the alert at the moment it matters. Audit logs are read after harm, not during. The RUDS analogy was misleading: in security, an AI false-negative is a breach (recoverable, insurable); in clinical, an AI false-negative is death + regulator + civil exposure. Tighter rails, not the same rails.

  2. “No PHI in prompts” was factually wrong. Lab values, vital signs, and observation values are PHI under HIPAA and Thai PDPA when combined with any encounter context. The defensible exemption is not “no PHI” — it is the operational compliance stance below.

  3. AI is never synchronous in the alert critical path. Alerts surface from the engine the moment they fire. AI annotation arrives async and updates the row inline.

  4. Defer all AI roles behind a feature flag. Ship the substrate (fan-in + tile + watch rules) first. Turn AI on only when (a) the suppression-vs-rerank question is settled, (b) BAA / DPA is signed with the model provider, © data-residency is enforced, (d) audit-table is wired, (e) a named clinical owner is accountable.

Operational compliance stance (replaces the wrong “no PHI” rule)

  • BAA / DPA in place with the model provider before any inference touches prod
  • Regional model endpoint in ap-southeast-1 (or the deploying region) — no cross-border inference
  • Structured-only payload — observation values, codes, timestamps, encounter_id. No free-text notes, no patient names, no HN/MRN/DOB, no addresses, no contact info, no provider names
  • Per-inference audit keyed to encounter_id + prompt_hash + response_hash + decision + model_version in ai_alert_decisions
  • Named clinical owner per tenant — every AI role’s tenant config requires a clinician of record who signs off on its scope and can be summoned by an accreditor; “Haiku said so” is not a defensible position to HA / RCPT / a coroner

Role 1 — Re-ranking second opinion (NOT suppression)

When a CDS rule fires, an async edge function sends the structured payload to Haiku and asks “rank this against the encounter’s other active alerts.” Outcomes available to AI:

AI decision Effect on alert
rank_high Sorts higher in the visible list
rank_low Sorts lower in the visible list, but always remains visible
cluster_with Grouped under a parent-alert bundle (e.g. all sepsis-bundle hits → one expandable group)
annotate Adds non-severity context (e.g. “similar pattern preceded X event in this patient’s prior admission”)

What AI cannot do — hard product rules:

❌ AI cannot Why
Hide / suppress / remove any alert Visibility-at-time-of-firing is the safety property
Demote severity (e.g. warning → info) Warnings are the early signal — the system saying “this might be the moment.” Demotion means nobody sees it when it matters
Raise severity Severity authority lives with the rule author, not the model
Run synchronously in the alert path Adds latency where seconds matter (sepsis)
Touch policy gates AI never blocks care
See unstructured notes / identifiers See compliance stance above

Re-ranking UI affordance. AI-grouped alerts render as a collapsed bundle visible by default with a count badge and one-click expand:

┌────────────────────────────────────────┐
│ ⚠ Sepsis bundle (3 alerts) — AI grouped │ ← visible by default
│   Click to expand                       │ ← one click to see all 3
└────────────────────────────────────────┘

Never a “0 hidden by AI” state. Never an audit-log-only existence. If the tile would be over-full, the response is “group” not “hide”.

Role 2 — Inline summary (no alert behavior)

Pure-display “since last shift” / “24h trend” card. Renders an LLM summary of recent structured observations + de-identified note excerpts. Tagged source: 'ai-summary'. Cannot raise alerts. Goes in SBAR view too. Always opt-in per tenant.

Role 3 — Novel-pattern proposer — MOVED OUT OF THIS DOC

The novel-pattern proposer (Sonnet flagging clinical patterns that don’t match an existing CDS rule) is governance and research work, not bedside alerting. It belongs in the CDS-admin / clinical-analytics workstream, not the Kardex. Cohort re-identification risk on small wards is real even with anonymization, and conflating it with bedside Kardex blurs ownership.

Captured separately in docs/architecture/cds-vital-signs-rules.md follow-up, not here. The Kardex is read of active_alerts; CDS rule curation is write to the rule library and properly lives with the rule engine.

Async update protocol

1. observation arrives → orchestrator → CDS engine fires → active_alerts updated → tile re-renders (T+0ms)
2. async edge fn sees the new alert → calls Haiku → receives rank/cluster decision (T+1-3s)
3. edge fn writes ai_alert_decisions row AND patches the alert's display_rank / cluster_id (not severity, not visibility)
4. realtime channel pushes the patched alert → tile re-renders showing the new order/grouping

Step 1 is the safety property — visibility is immediate. Step 4 is the convenience layer. They are decoupled by design.

Audit table

CREATE TABLE ai_alert_decisions (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  alert_id        UUID NOT NULL,
  encounter_id    UUID NOT NULL,
  model           TEXT NOT NULL,              -- 'haiku-3.5' | etc with version
  model_version   TEXT NOT NULL,
  prompt_hash     TEXT NOT NULL,              -- sha256, full prompt stored elsewhere if needed
  response_hash   TEXT NOT NULL,
  decision        TEXT NOT NULL CHECK (decision IN ('rank_high','rank_low','cluster_with','annotate')),
  decision_context JSONB,                     -- what the AI was told, what it returned, structured
  tenant_owner_id UUID NOT NULL,              -- named clinical owner accountable for this role
  invoked_at      TIMESTAMPTZ DEFAULT NOW(),
  latency_ms      INT
);

CREATE INDEX idx_aiad_encounter ON ai_alert_decisions (encounter_id, invoked_at DESC);

Tile rendering — bundle-first, not top-N

Wrong: slice(0, 5) by severity+time. Sepsis bundles fire 3-5 alerts in close succession; a top-5 cut drops the oldest critical when a 6th fires. Clinical correlation matters more than recency.

Right: group by source-bundle (or cluster_id if AI has clustered them), order bundles by max-severity-within-bundle then earliest-raised-within-bundle, render every critical, expand-on-click for the rest.

function AlertsSummaryTile({ alerts, encounterId }: { alerts: ActiveAlert[]; encounterId: string }) {
  const dispatch  = useDispatch();
  const bundles   = bundleAlerts(alerts);          // group by cluster_id || source-family
  const orderedBundles = sortBundles(bundles);     // max severity → earliest raised
  const ewsAlert  = alerts.find(a => a.source === 'ews');
  const criticals = alerts.filter(a => a.severity === 'critical');

  return (
    <Card onClick={() => dispatch(actionModalResolver.openModal({
      modalKey: 'alertsDrawer', props: { encounterId },
    }))}>
      <Stack direction="row" spacing={1}>
        {ewsAlert && <EwsBadge value={ewsAlert.context?.score} />}
        <SeverityCount n={criticals.length} severity="critical" />
        <SeverityCount n={alerts.filter(a => a.severity === 'warning').length} severity="warning" />
      </Stack>

      {/* Every critical always rendered, no truncation */}
      {criticals.map(a => <AlertRow key={a.id} {...a} />)}

      {/* Non-critical bundles — collapsed but visible, never hidden */}
      {orderedBundles
        .filter(b => b.maxSeverity !== 'critical')
        .map(b => (
          <AlertBundleRow
            key={b.id}
            bundleLabel={b.label}                // e.g. "Sepsis bundle (3)"
            maxSeverity={b.maxSeverity}
            count={b.alerts.length}
            aiGrouped={b.aiGrouped}              // shows "AI grouped" pill if Haiku clustered
            onExpand={() => /* drawer with this bundle filtered */}
          />
        ))}
    </Card>
  );
}

The drawer groups alerts by bundle, shows full message + context per alert, and offers per-row Acknowledge / Snooze / Escalate (subject to the snooze policy below). Each row’s “Open” deeplinks to the source surface (transfusion record, lab result, watch-rule editor).

Acknowledge / Snooze / Escalate — bounded, with handoff

Free-form snooze is how alerts get killed overnight. Strict bounds, required reasons at warning+, mandatory carry-over to oncoming shift:

Severity Snooze allowed? Max duration Reason required? Survives shift change?
critical No Always re-surfaces; only acknowledge (with reason) or escalate available
warning Yes 2 hours max Yes — structured reason from picklist + free text Re-surfaces to oncoming shift regardless of snooze remaining
info Yes Until end of current shift Optional Re-surfaces if still firing at next shift start

Escalate sends an AcknowledgementRequest (via the existing acknowledgement system) up the on-call chain with structured reason + the alert payload. Lands in the recipient’s inbox + their phone/web channels per ack_channels config.

Snooze record writes to alert_snoozes:

CREATE TABLE alert_snoozes (
  id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  alert_id      UUID NOT NULL,
  encounter_id  UUID NOT NULL,
  snoozed_by    UUID NOT NULL,
  snoozed_at    TIMESTAMPTZ DEFAULT NOW(),
  expires_at    TIMESTAMPTZ NOT NULL,
  reason_code   TEXT NOT NULL,                -- structured reason from a fixed picklist
  reason_text   TEXT,                          -- free-text addendum (warning+ only)
  carried_to_shift_id UUID,                    -- set when shift change re-surfaces it
  CONSTRAINT chk_snooze_window CHECK (expires_at > snoozed_at)
);

-- Trigger: at shift change boundary, every alert with active snooze auto-re-surfaces
-- to the oncoming shift (carried_to_shift_id is recorded, the alert appears in their
-- "Alerts this shift" view regardless of remaining snooze time)

§4.6 — Shift-handoff continuity (NEW)

Kardex is most-read at shift change. The point-in-time view shows only what’s currently lit; the incoming nurse needs to see what fired this shift, what was done about it, what’s still open.

A second tile / drawer mode: “Alerts this shift” (or a filter toggle on the main alerts drawer), driven by a shift-scoped query rather than the current active_alerts snapshot:

-- shift_alert_log — append-only audit per shift × encounter
CREATE TABLE shift_alert_log (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  shift_id        UUID NOT NULL,               -- references shift roster
  encounter_id    UUID NOT NULL,
  alert_id        UUID NOT NULL,
  fired_at        TIMESTAMPTZ NOT NULL,
  resolution      TEXT CHECK (resolution IN ('acknowledged','escalated','snoozed','still_active','auto_resolved')),
  resolved_by     UUID,
  resolved_at     TIMESTAMPTZ,
  reason_code     TEXT,
  reason_text     TEXT,
  carried_forward BOOLEAN DEFAULT false        -- true if still active at end of shift
);

CREATE INDEX idx_sal_shift_enc ON shift_alert_log (shift_id, encounter_id);

The “Alerts this shift” view shows:

  • All alerts fired during the active shift for this encounter
  • Resolution status per alert (ack’d / escalated / snoozed until X / still active)
  • The handoff-out / handoff-in transition makes carried_forward = true alerts the default first card in the oncoming nurse’s Kardex view

This is also the substrate for the future SBAR view (§5) — “Recommendation” naturally maps to carried_forward = true alerts plus active encounter_watch_rules.

Why this is simpler than the original §4

  • No panelSurfaces export pattern needed. That was over-engineered. The existing actionModalResolver already does what we need.
  • No miniapp changes. Zero PRs against e-MAR, FocusList, OrderActivityDock, etc.
  • No compact prop negotiation. The full component renders at its natural size inside the modal.
  • Smaller Kardex bundle. Summary tiles are ~50 LOC each and import nothing heavy. The big components only load when the user clicks (lazy via the modal resolver, which already lazy-loads).
  • Honest UX. Kardex is a glance view. When the nurse actually needs to administer a med, they get the full e-MAR UI, not a cramped tile.

View-engine (still useful, but smaller)

The OperationalView engine simplifies too — it’s now a layout-and-tile-mounter, not a widget-mounter:

<OperationalView
  viewKind="kardex"
  encounterId={encounterId}
  scope={{ userId, role, wardId, tenantId }}
/>

Internally:

  1. Resolves layout from kardex_layouts (§3)
  2. Subscribes to encounter_journey_cache for the given encounter
  3. For each visible card in the layout, renders the matching summary tile from a local tile registry (lives inside packages/miniapps/e-kardex/cards/, not scattered across miniapps)

The tile registry is just a map in the Kardex miniapp itself:

// packages/miniapps/e-kardex/cards/registry.ts
export const TILES = {
  'medications':       MedicationSummaryTile,
  'safety':            SafetySummaryTile,
  'vitals':            VitalsSummaryTile,
  'orders':            OrdersSummaryTile,
  'labs':              LabsSummaryTile,
  'access-lines':      AccessLinesSummaryTile,
  'care-plan':         CarePlanSummaryTile,
  'pending-acks':      PendingAcksSummaryTile,
  'pending-tickets':   PendingTicketsSummaryTile,
};

Adding a new tile = add one file + one registry entry. No miniapp coupling.

The existing eKardex.tsx becomes a thin wrapper: `` with its drag/resize chrome.


5. The seven views, materialised

Once §2 + §3 + §4 are in place, every view becomes a config:

e-Kardex (single-encounter detail)

  • Source filter: encounter_journey_cache WHERE encounter_id = ?
  • Default cards: header, safety_snapshot, medication_summary, active_orders, access_lines, vitals (last 8h), care_plan, pending_tickets, acknowledgements
  • Layout: draggable grid (existing chrome)

Medication round (nurse, current shift)

  • Source filter: medication_administrations due in window, grouped by bed → encounter_id → med
  • Cards: per-encounter mini-Kardex (header + due meds + last-vitals + allergy flag)
  • Layout: linear list, sorted by room number
  • Existing widget: MarSystemEnhanced already does most of this; missing piece is the ward-wide window query

Nurse focus list (assigned beds)

  • Source filter: encounter_journey_cache WHERE bed_id IN assigned_beds
  • Cards: per-encounter row with header, EWS, outstanding acks, due-next-med, today’s tasks
  • Existing widgets: FocusListEnhanced, FocusList (nurse-note variants)
  • Already partially wired in packages/miniapps/focus-list-enhanced/

Bed board / ward view

  • Source filter: bed JOIN encounter_journey_cache WHERE ward_id = ?
  • Cards: spatial bed grid, each tile = mini-Kardex (header + EWS + isolation + due-tasks)
  • See docs/architecture/bed-board-ehr-suite.md for the full plan

Shift report / SBAR

  • Source filter: encounter_journey_cache for shift-assigned encounters
  • Cards: per-encounter SBAR rollup (Situation = problem list, Background = clinical_data, Assessment = safety_snapshot + EWS trend, Recommendation = active_orders + open tasks)
  • New view, but all data is already in the cache after §2

Worklist row detail

  • Source filter: encounter from the clicked ticket
  • Cards: compact Kardex header strip + relevant action surface
  • FUTURE / OUT OF SCOPE for this work — already implemented as UniversalTransitionModal; keeps working as today. Could become another view_kind later, but not required.

IPD Command Center

  • Source filter: one encounter, step-driven
  • Cards: step-specific (admission, assessment, rounds, discharge)
  • FUTURE / OUT OF SCOPE for this work — already exists at src/containers/ipd-command-center/ and keeps working as today. Could adopt the same tile registry later, but not required.

6. Realtime / refresh story

Single subscription per view. No card-level polling:

// Single-encounter views (Kardex, IPD CC, worklist row detail)
useEffect(() => {
  const ch = supabase
    .channel(`ejc-${encounterId}`)
    .on('postgres_changes', {
      event: 'UPDATE',
      schema: 'public',
      table: 'encounter_journey_cache',
      filter: `encounter_id=eq.${encounterId}`,
    }, (payload) => setData(payload.new))
    .subscribe();
  return () => { ch.unsubscribe(); };
}, [encounterId]);

// Multi-encounter views (med round, focus list, bed board) — subscribe by ward / nurse
// Same channel pattern, broader filter

The orchestrator already bumps manifest_version on every projection. The realtime payload IS the new card data — no second fetch.


7. Phased rollout

Absolute rule for every phase: no existing miniapp gets modified. New columns, new tables, new tiles, new view-engine — all additive.

Phase Scope Effort Unblocks
P0 medication_summary + active_orders projection (orchestrator handler diffs only — events already firing; existing handlers get a few extra lines, no logic removed) 2-3 days 80% of “Kardex without the meds” complaint
P1 kardex_layouts table + resolver RPC + a tenant_default layout seed 1 day Layout customisation; per-ward defaults
P2 OperationalView engine + tile registry inside e-kardex miniapp + 5 baseline summary tiles (header, meds, safety, vitals, orders); rewire eKardex.tsx to use it 3-4 days Real Kardex shipping; click → existing modals
P3 access_lines projection + Lines/Tubes summary tile 2 days Infection-control surface; CVC dressing due-date alerts
P4 encounter_header projection (code status, isolation, restraints) — depends on backend Mongo normalisation 5-7 days SBAR view; safety-critical header strip; isolation room-flag
P5 Medication round view: new view_kind config + tiles that read ward-scoped medication_summary rows. The med-round button still opens existing MarSystemEnhanced modal. 2 days Replaces paper med round sheet
P6 Shift report / SBAR view: new view_kind config + delta tiles 2 days Hand-off workflow
P7 care_plan projection — waits for NANDA-NIC-NOC work depends on nursing-kanban-board scope Care plan tile; nursing diagnoses surface
P8 encounter_watch_rules table (templated, 5 rule kinds) + typed evaluators + “Watch this” UI on observation rows + lifecycle (expires_at required, auto-clear on discharge, author-rotation handoff) 4 days Per-encounter awareness; doctor-tunable thresholds without raw-JSONB footgun
P9 Authoring + seeding the missing high-yield CDS rules the existing engine doesn’t ship: allergy-vs-order, vitals-overdue, NPO violation, antibiotic timing, pre-op-prep 3 days Closes negative-space alert gap — most leading-cause-of-deterioration alerts ship live
P10 alert_snoozes table + bounded snooze policy enforcement (no snooze on critical, max 2h on warning + structured reason, shift-change re-surface) + shift_alert_log for handoff continuity + “Alerts this shift” tile (§4.6) 4 days Defensible snooze policy; incoming-shift sees fired/resolved/open
P11 AI Role 1 (re-ranking second opinion, async, NEVER hide) — behind a per-tenant feature flag; ai_alert_decisions audit; named-clinical-owner config; BAA + DPA + regional-endpoint precondition 5-7 days, gated on compliance preconditions Optional alert-fatigue mitigation when prerequisites met
P12 AI Role 2 (inline “since last shift” summary) — pure display, structured-input-only, off by default 2 days Reduce hand-off prep time
P13 OUT OF SCOPE FOR THIS DOC — AI novel-pattern proposer. Governance + cohort re-identification risk + ownership ambiguity belong with the CDS rule engine, not the Kardex. Tracked separately as a follow-up to docs/architecture/cds-vital-signs-rules.md

P0 + P1 + P2 is the minimum viable substrate. ~6-8 days of additive work and the Kardex ships with real data, clicking opens the existing modals, and no other system is touched.


8. Cross-references

Doc Why it matters here
docs/architecture/encounter-orchestrator-triggers.md The fan-in pattern; where new handler diffs land
docs/architecture/widget-rail-surface-system.md The widgetSurface pattern that panelSurfaces extends
docs/architecture/bed-board-ehr-suite.md The ward-view sibling; should adopt this substrate
docs/architecture/ipd-medication-order-master-contracts.md Source events for medication_summary
docs/architecture/ipd-dispense-cycles.md Drives medication_summary via manifest.dispense_cycles_requested
docs/architecture/ipd-nursing-assessment.md Already projects into safety_snapshot — model for the new projections
docs/architecture/nursing-kanban-board.md The future care_plan source
docs/architecture/acknowledgement-system.md The “outstanding acks” card
docs/architecture/cds-vital-signs-rules.md Drives active_alerts already on the cache; baseline sepsis / NEWS2 / qSOFA / MEWS rules; admin UI at /admin/cds-rules for clinical-director rule authoring (see §4.5)
docs/architecture/rogue-user-detection-system.md Source of the AI safety-rail pattern reused for clinical alerts in §4.5: two-tier scoring, LLM second-opinion, novel-pattern promotion, strict tier ceilings (AI cannot block care, AI cannot escalate to critical), audit-table logging

9. Open questions

  1. Trigger vs orchestrator-handler for medication_summary? Most projections live in the orchestrator (Deno). But medication_administrations is high-frequency — should the projection be a Postgres trigger on that table instead, for lower latency? Trade-off: trigger is in-band (immediate, but couples Supabase to the projection logic); orchestrator is out-of-band (clean, but a few hundred ms of lag). For the med-round view, latency matters. Proposal: trigger for medication_summary, orchestrator for everything else.

  2. Multi-tenancy of kardex_layouts. On-prem deployments are single-tenant, so tenant_default is fine. Cloud multi-tenant will need a tenant_id column once that’s standardised across the platform (see docs/architecture/menu-price-ledger.md D4).

  3. Snooze reason picklist. P10 ships bounded snooze with a structured reason_code. What’s the picklist? Initial draft: 'addressed_clinically', 'awaiting_intervention', 'monitor_closely', 'known_baseline_for_patient', 'duplicate_of_open_issue', 'order_changed', 'other'. Needs clinical sign-off and Thai translations before P10 ships. Free-text addendum mandatory at warning+.

  4. Shift roster source of truth. §4.6 references shift_id for handoff continuity. Where does the active shift come from? Options: existing clinic-roster module / nursing-kanban-board’s shift model / new lightweight nurse_shifts table. Proposal: reuse clinic-roster if it ships shift granularity by P10; otherwise small nurse_shifts table keyed to ward + datetime windows + assigned nurses.

  5. Author rotation detection (P8 lifecycle). Watch-rule rotation handoff requires knowing when authored_by is no longer on the encounter’s care team. Depends on clinical-roster lookup or care-team-membership table. If neither has the right granularity at P8 time, fall back to: nightly cron deactivates watch rules whose expires_at would extend past the next discharge ETA refresh; doctor must re-author after rotation.

  6. AI compliance preconditions for P11. Before flipping the feature flag on for any tenant: signed BAA / DPA with model provider, regional endpoint enforcement (no cross-border inference), named clinical owner on tenant config, audit table writes verified end-to-end, sample-prompt review by the clinical owner. None of these are engineering questions, all are gating.

  7. Card props contract. Every summary tile takes { encounterId, patientId, data }. Is that minimal enough, or do tiles need direct realtime channels of their own (escape hatch)? Proposal: start minimal; add a useChannel(name) hook for tiles that genuinely need their own subscription (rare).

  8. Print / paper Kardex. Existing nursing workflows still want a print-out for rounds. The composition can emit a static PDF; tiles get an optional printRender adjunct. Defer to post-P10.

Ask Anything