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.
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:
- Scope — who owns this layout (user / role / ward / tenant default)
- Cards visible — which slots to render
- Card positions — where each card sits (the existing
eKardex.tsxis 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): user → role → ward → tenant_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:
- Summary tiles — small read-only cards that pull data straight from
encounter_journey_cacheJSONB. ~50 LOC each, no embedded miniapp bundles, no widget coupling. - Click → open — clicking a card dispatches the existing
actionModalResolver/openModalto 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 = trueby default - Author rotation: nightly cron flags rules where
authored_byuser 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 = falsewith 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:
-
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.
-
“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.
-
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.
-
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_versioninai_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 = truealerts 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
panelSurfacesexport pattern needed. That was over-engineered. The existingactionModalResolveralready does what we need. - No miniapp changes. Zero PRs against e-MAR, FocusList, OrderActivityDock, etc.
- No
compactprop 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:
- Resolves layout from
kardex_layouts(§3) - Subscribes to
encounter_journey_cachefor the given encounter - 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_administrationsdue in window, grouped bybed → encounter_id → med - Cards: per-encounter mini-Kardex (header + due meds + last-vitals + allergy flag)
- Layout: linear list, sorted by room number
- Existing widget:
MarSystemEnhancedalready 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.mdfor the full plan
Shift report / SBAR
- Source filter:
encounter_journey_cachefor 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 anotherview_kindlater, 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
-
Trigger vs orchestrator-handler for
medication_summary? Most projections live in the orchestrator (Deno). Butmedication_administrationsis 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 formedication_summary, orchestrator for everything else. -
Multi-tenancy of
kardex_layouts. On-prem deployments are single-tenant, sotenant_defaultis fine. Cloud multi-tenant will need atenant_idcolumn once that’s standardised across the platform (seedocs/architecture/menu-price-ledger.mdD4). -
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+. -
Shift roster source of truth. §4.6 references
shift_idfor handoff continuity. Where does the active shift come from? Options: existing clinic-roster module / nursing-kanban-board’s shift model / new lightweightnurse_shiftstable. Proposal: reuse clinic-roster if it ships shift granularity by P10; otherwise smallnurse_shiftstable keyed to ward + datetime windows + assigned nurses. -
Author rotation detection (P8 lifecycle). Watch-rule rotation handoff requires knowing when
authored_byis 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 whoseexpires_atwould extend past the next discharge ETA refresh; doctor must re-author after rotation. -
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.
-
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 auseChannel(name)hook for tiles that genuinely need their own subscription (rare). -
Print / paper Kardex. Existing nursing workflows still want a print-out for rounds. The composition can emit a static PDF; tiles get an optional
printRenderadjunct. Defer to post-P10.