medOS ultra

Modal Presence Lock

Multi-user lock for global modals: core + registry coverage + opt-in primitive.

4 min read diagramsUpdated 2026-06-03docs/architecture/modal-presence-lock.md

Status: shipped (core + registry coverage + opt-in primitive). 2026-06-04.

A self-contained, provider-agnostic multi-user lock that greys out a modal and offers an authority-gated take-over when another user is already editing the same modal for the same clinical entity. Closes the gap audited in presence-systems-and-modal-lock-gap (memory): before this, no modal — including the medical-worklist screening modal — had any concurrent-edit awareness, so two users editing the same patient’s screening was silent last-write-wins.

Why a new system (not the patient-profile presence)

The existing usePatientProfilePresence / useModalLock (src/common/components/medical/patient/profile-system/presence/) is provider- scoped and per-patient: it soft-fails to inert values when consumed outside PatientProfilePresenceProvider, and the worklist (where the screening modal launches) is not inside that provider. So reusing it would never light up the exact case we care about. This system opens its own Supabase room keyed by (modalId + entity) and works anywhere — worklist, queue, profile.

It reuses the pure TakeoverAuthority engine (tiers 0–4, decideTakeover) for the auto/request/force decision, so the authority model is shared.

Files

File Role
web/src/common/components/medical/builder/modal/presence/modalPresenceCore.ts Pure logic (no React/Supabase): deriveOwner, evaluateModalLock, extractEntityKey. Unit-tested.
…/presence/modalPresenceCore.test.ts 23 unit tests.
…/presence/useModalPresenceLock.ts The realtime hook — own Supabase room, presence + takeover broadcast lifecycle. Fail-soft.
…/presence/ModalPresenceGate.tsx UI: grey backdrop + take-over banner (non-owner), “N viewing” pill (owner), incoming accept/decline dialog. Exports presentational atoms + SafeModalPresenceGate (silent error boundary).
…/builder/modal/GlobalModalRenderer.tsx Mounts one SafeModalPresenceGate over the topmost registry modal (single insertion; no per-modal-branch edits).
web/sandbox/targets/ModalPresenceLockTarget.tsx Sandbox visual harness — ?target=ModalPresenceLock.

How it works

  • Room: modal-presence:{modalId}:{enc|pat:id} (one Supabase realtime channel per modal × entity). extractEntityKey prefers encounter, falls back to patient, namespaced enc:/pat: so the two id-spaces never collide.
  • Ownership = pure function of shared presence (so every client agrees with zero extra messaging): owner = highest claimedAt, tiebreak earliest openedAt, then lowest userId. First opener owns by default; “take over” bumps your claimedAt to now and re-tracks → everyone recomputes you as owner.
  • Authority-gated takeover (reuses decideTakeover): higher authority → instant claim (notify old owner); same tier → request, force allowed after a 30 s timeout; lower tier → request, no force. Messages flow on a broadcast event (request / accepted / declined / force / auto).
  • Fail-soft: no Supabase, no user, or no entity key → enabled=false, the gate renders nothing, the modal behaves exactly as before. The gate is also wrapped in a silent error boundary — a lock failure can never break a modal.

Coverage

Auto-covered (zero per-modal work): every modal in modalRegistry.ts opened via dispatch(openModal({modalId, data})) — the renderer gates the topmost one. This includes DIALOG_SCREENING, DIALOG_FUTURE_ORDER, DIALOG_ORDER, IpdNursingAssessment, DIALOG_EMAR, discharge, register, etc. — provided the modal data carries an encounter/patient id (almost all do).

Opt-in needed (surfaces that bypass GlobalModalRenderer) — these render their own / with local useState, so they are NOT auto- covered. Ranked by concurrent-edit risk (from the 2026-06-04 audit):

Surface File Risk
ConsultDetailDialog packages/medical-kit/src/medical-worklist/components/ConsultDetailDialog.tsx high — concurrent consult responses
IpdPatientDetailSlider (MAR/orders tabs) src/containers/ipd-command-center/components/right-slider/IpdPatientDetailSlider.tsx high — two nurses on same patient
DialogNurseNote packages/miniapps/nurse-note/components/dialogs/DialogNurseNote.tsx high — focus list / assessment
DialogOrder packages/miniapps/right-side/appointment/DialogOrder.tsx high — concurrent order entry
RoomAppointments dialogs packages/miniapps/room-appointments/RoomAppointments.tsx med — surgery/labour scheduling
AnesthesiaWorklist dialogs src/containers/anesthesia/AnesthesiaWorklist.tsx med — recovery/OR records
FutureOrderCheckedIn (periops) packages/periops-kit/src/surgery-record/components/future-order-checked-in/FutureOrderCheckedIn.tsx med
EnhancedMedicationOrder, RehabPlanForm, RadiationOncologyConsultRequest, MainNurseNote various lower traffic

Opt-in recipe (one line)

Drop SafeModalPresenceGate inside the surface’s dialog, pass an explicit entityKey + your own onClose:

import { SafeModalPresenceGate } from '@components/medical/builder/modal/presence/ModalPresenceGate';

// inside the editing Dialog, only while actually editing (not read-only 'view'):
{mode !== 'view' && (
  <SafeModalPresenceGate
    modalId="consult-detail"
    entityKey={consultRequestId}      // any stable id; room is namespaced by modalId
    surfaceLabel="this consult"
    onClose={onClose}                 // closes THIS dialog (not a registry modal)
  />
)}

No bulk migration was done — the guardrails forbid bulk edits across many clinical files. The primitive is ready; wire surfaces individually as needed.

Verification

  • Unit: modalPresenceCore.test.ts — 23/23 (ownership tiebreaks, order- independence, takeover-decision tiers, entity-key extraction/namespacing).
  • Realtime: two live Supabase clients — 6/6 (presence sync, cross-client ownership consensus, takeover-request broadcast delivery, ownership transfer after claim).
  • UI: sandbox ?target=ModalPresenceLock — 4 states render, 0 console errors.
  • Types: full-repo tsc --noEmit — 0 errors.

Realtime gotchas (for future tests): Supabase realtime in Node needs Node 22 (global WebSocket); presenceState() only populates if ch.on('presence', {event:'sync'|'join'|'leave'}) is registered before subscribe (the hook does this). Anon key is sufficient for presence on this project.

Invariants

  1. Additive / fail-soft — disabled (anon-less, entity-less, Supabase-less) ⇒ the modal is byte-identical to before.
  2. Never breaks a modal — the gate is isolated behind a silent error boundary and rendered as a sibling, never wrapping modal markup.
  3. Ownership is deterministic — a pure function of shared presence; all clients converge with no coordinator.
  4. No new authority model — reuses TakeoverAuthority.
  5. Soft lock — the backdrop blocks editing but the banner always offers Take-over + Close, so a stale presence entry can never trap a user.
  6. Topmost only — the renderer gates the topmost registry modal; stacked modals lock one at a time.
Ask Anything