Modal Presence Lock
Multi-user lock for global modals: core + registry coverage + opt-in primitive.
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).extractEntityKeyprefers encounter, falls back to patient, namespacedenc:/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 earliestopenedAt, then lowestuserId. First opener owns by default; “take over” bumps yourclaimedAtto 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 abroadcastevent (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 ifch.on('presence', {event:'sync'|'join'|'leave'})is registered before subscribe (the hook does this). Anon key is sufficient for presence on this project.
Invariants
- Additive / fail-soft — disabled (anon-less, entity-less, Supabase-less) ⇒ the modal is byte-identical to before.
- Never breaks a modal — the gate is isolated behind a silent error boundary and rendered as a sibling, never wrapping modal markup.
- Ownership is deterministic — a pure function of shared presence; all clients converge with no coordinator.
- No new authority model — reuses
TakeoverAuthority. - Soft lock — the backdrop blocks editing but the banner always offers Take-over + Close, so a stale presence entry can never trap a user.
- Topmost only — the renderer gates the topmost registry modal; stacked modals lock one at a time.