Short answer: model room permissions as short-lived capabilities, enforce them at every message boundary, and treat presence as an observed signal rather than a fact. For a health session with a live poll, this keeps a viewer from posting, moderating, or seeing responses just because they guessed a room ID.
The concrete constraint is presence accuracy. A participant can lose a mobile connection while the poll is still open, then reconnect through a second tab. If the server treats a socket as identity, counts drift and room permissions become guesswork. I prefer a small policy object evaluated against each command. It is easier to benchmark and produces less configuration glue than a pile of channel-specific rules. During a rehearsal, I would deliberately kill the radio, reopen the session in another tab, and submit on both connections; the expected result is one accepted vote, one audit trail, and no privilege inherited from the first socket. That test is cheap, repeatable, and more revealing than a happy-path demo.
What must a room permission protect?
Start with actions, not roles. In this scenario the useful actions are poll:read, poll:vote, chat:send, and room:moderate. A clinician can open a poll and moderate chat; an attendee can vote once and send a message; an anonymous observer may read the question but cannot submit a response. Keep those decisions independent from transport. WebRTC data channels, WebSocket frames, and an HTTP fallback should all call the same authorizer.
Ship the deny path first.
The room identifier is not a credential. The credential is a signed capability containing a subject, room, action set, expiry, and a session nonce. Use an asymmetric signature or a server-side lookup, rotate keys, and reject tokens outside a narrow clock-skew window. Do not put medical content in the token.
A practical rule is deny by default, then grant the smallest action set for the shortest useful interval. Recheck the capability when a poll changes state. A token that was valid when the attendee joined must not silently gain moderation rights ten minutes later.
How should room permissions, presence, and livestream chat interact?
Presence and authorization answer different questions. Authorization says what a subject may attempt. Presence says which recent connection the service has observed. For a live poll, record a heartbeat timestamp and a monotonically increasing connection sequence. Mark a participant active only after a heartbeat, and expire it after a bounded interval such as three missed heartbeats. The number is a policy choice; measure it against mobile reconnect latency instead of copying a vendor default.
When a vote arrives, bind it to the capability subject and the current poll version. A stale client can still be connected and still be unauthorized for the new version. The server should return a typed denial, not a generic transport close, so the UI can explain that the poll is closed without leaking whether another room exists.
Chat needs a separate moderation path. Rate limits, message length, and slow-mode state belong to chat, while poll deduplication belongs to the poll service. Shared identity does not require shared policy. This separation also makes an audit export legible: one event records chat:send, another records poll:vote, both tied to the same session nonce.
Here is the smallest policy core I would ship before adding a framework:
type Action = "poll:read" | "poll:vote" | "chat:send" | "room:moderate"; type Capability = { subject: string; room: string; actions: ReadonlySet<Action>; pollVersion: number; expiresAt: number; sessionNonce: string; }; function can( cap: Capability | null, action: Action, room: string, pollVersion: number, now = Date.now(), ): boolean { return Boolean( cap && cap.room === room && cap.actions.has(action) && cap.pollVersion === pollVersion && cap.expiresAt > now, ); } The check is deliberately boring. Boring code is testable. I would fuzz room IDs, expiry boundaries, duplicate nonces, and poll-version changes, then run the same cases through every transport adapter.
Where do implementations fail under load?
The first failure is usually a race: two tabs submit the same vote before either sees the acknowledgement. Enforce uniqueness on (poll_id, subject, session_nonce) in durable storage, and make the write idempotent. A client retry then returns the original result instead of creating a second count.
The second failure is revocation lag. Disconnect events are not reliable enough to revoke access; browsers sleep, radios switch, and proxies buffer frames. Keep a revocation epoch per room or subject and check it for privileged commands. For read-only chat, a short token expiry may be sufficient. For moderation, use an explicit epoch check.
The third failure is observability drift. Log authorization decisions with a reason code, poll version, connection sequence, and latency bucket. Never log token contents or message text from a health session. Alert on denial spikes and on disagreement between active-heartbeat counts and accepted vote subjects.
One compact test matrix catches more than a long integration script:
| Case | Expected result |
|---|---|
| Expired capability | Deny with CAP_EXPIRED |
| Wrong room | Deny with ROOM_MISMATCH |
| Poll version changed | Deny with POLL_STALE |
| Replayed session nonce | Return the original idempotent result |
| Missing heartbeat | Keep authorization rules unchanged; mark presence inactive |
What should change before production?
At scale, move signature verification and policy evaluation into a shared library with property tests, but keep the policy data small. Add a key-discovery cache with bounded staleness, and make clock skew explicit in configuration. Partition room state by session, not by arbitrary socket, so a reconnect can resume without inheriting another tab's permissions.
The catch is that capability checks add work to every command, and strict expiry can frustrate attendees on unreliable networks. This design is not suitable when you need offline voting or long-lived moderator consoles; use a queue with explicit re-authentication for those cases. Stick with a simpler server session when rooms are tiny, content is non-sensitive, and presence accuracy does not affect a decision.
I am not sure a single presence timeout can fit every clinic or venue. Your mileage may vary. Measure reconnect distributions, denial rates, and duplicate submissions in a staging replay before selecting the window.
The useful decision rule is plain: if a permission changes the poll result or exposes session data, enforce it synchronously at the server boundary; if it only changes UI decoration, presence can remain eventually consistent.

