// AUTHORITATIVE SOURCE: akashic-docs/e-visit/telehealth-video-server-implementation-design.md §3 // (Lane A / ). Every shape below is transcribed from §3.1-§3.2 - do not "improve" them, and do // not re-derive them from the split plan. Any change goes through the Captain. // // THE SPLIT-PLAN DOC IS STALE and is no longer the seam - it still describes the ring protocol // Spencer cut on 2026-08-01, the WS-inbound control plane he replaced the same day, and a // `ringingactiveended` status Lane A has since dropped (§4.3). Read the server design doc, not §B/§C. // // CONTROL PLANE = OPTION A (Spencer, 2026-08-01): **HTTP in, WebSocket out.** // Every client server action is an authenticated call on the broker's HTTP surface (§A); the socket is // used ONLY for server client pushes (`CALL_EVENTS` below). The deciding fact was Lane A's, not // aesthetic: the roster/authz evaluators live in the `api` service, so gating an inbound WS action would // force either forking those evaluators or a callback from `websocket-api` into `api`. A refusal is now // an HTTP status code instead of `send()` returning `false` - which only ever told you the socket was // down, never that the action was denied. // There is deliberately NO outbound-action list in this file. If you find yourself adding one, the // action belongs on `VideoTransport` as an HTTP method instead. // // `encounter#{encounterId}` is CONFIRMED by Lane A (server design §5.1) - it is no longer proposed. /** `encounter#{encounterId}` (a 1:1 clinical visit) or `collabteam#{teamId}` (a case conference). * `direct#{threadId}` is NOT built at all - Lane A §5.3 cut colleague-to-colleague video with the * message-thread entry point, so there is no such context on either side of the seam. */ export type VideoContext = string; export const encounterContext = (encounterId: string): VideoContext => `encounter#${encounterId}`; export const collabTeamContext = (teamId: string): VideoContext => `collabteam#${teamId}`; /** The Chime SDK's own Meeting + Attendee objects, passed through untouched. */ export interface ChimeJoinInfo { Meeting: unknown; Attendee: unknown; } /** `'ringing'` is GONE (Lane A §3.4 delta 1, §4.3): nothing rings under the meeting model, and a * host alone in a live meeting is simply `active`. Both repos dropped it in the same change, which is * the condition the previous comment here set for removing it. */ export type VideoSessionStatus = 'active' | 'ended'; export interface VideoSessionRecord { sessionId: string; status: VideoSessionStatus; context: VideoContext; /** What the host is showing from the record, when anything. A reference only. */ share?: SessionShare | null; /** Why the automatic visit start was held back, until a start is recorded; absent from an older server. */ startWithheld?: StartWithheld | null; } /** The automatic start was held back before anything was written, and why. */ export interface StartWithheld { at: string; code: 'agreements-outstanding' | 'agreements-unreadable' | 'agreements-changed' | 'basis-not-retained' | string; } /** A record item the host is showing. The server stores and pushes only the reference; each viewer * reads the item under their own authorization. */ export interface SessionShare { reference: string; sharedBy: string; startedAt: string; } /** `POST /{id}/share`. `share: null` is the answer to a stop. */ export interface ShareItemResult { sessionId: string; share: SessionShare | null; } /** The room (`encounter-room` thread) behind this meeting. `known`: published and readable. * `pending`: entitlement not yet written, the call is still usable. `refused`: a terminal conflict. * Optional - an older server never sent it, and that must type-check as "no claim made", not "known". */ export type RoomPublicationState = 'known' | 'pending' | 'refused'; export interface CreateSessionResult extends VideoSessionRecord { chime: ChimeJoinInfo; /** When the meeting was created, as the push announcing it says. Absent from an older server. */ createdAt?: string; room?: RoomPublicationState; /** Whether the roster announced at create can find the call; the same meaning as on an invite. */ discovery?: InviteTeamResult['discovery']; noticeUnconfirmed?: string[]; } export interface JoinSessionResult { sessionId: string; chime: ChimeJoinInfo; room?: RoomPublicationState; } /** What `/leave` and `/end` return (§3.1) - deliberately NOT a `VideoSessionRecord`: neither route * echoes the context back, and typing them as one would invite a caller to read a field the wire does * not carry. On `/leave` the status is the load-bearing part: `'active'` means others are still in * the room, `'ended'` means you were the last one out and the server deleted the meeting. */ export interface SessionStatusResult { sessionId: string; status: VideoSessionStatus; } /** `POST /{id}/invite` (§3.1). Fan-out failures are RETURNED, not swallowed (§7), so the UI can say * "3 of 5 reached" instead of silently claiming success. */ export interface InviteTeamResult { sessionId: string; reached: number; missed: string[]; /** Whether the newly-invited team can find the call. `known`: every eligible original recipient * is discoverable. `pending`: a definite failure, still queued. `unconfirmed`: a read-back * failed, so the server cannot say either way. Optional - an older server made no such claim. */ discovery?: 'known' | 'pending' | 'unconfirmed'; /** Refs whose notice was admitted but whose delivery outcome is unknown. Never counted in `reached`. */ noticeUnconfirmed?: string[]; } /** Inbound WS events (server client). Under control-plane option A this is the socket's ONLY job. * * `AVAILABLE` replaces the old `call.ring`. It means "a meeting exists that you may join" - it is a * standing fact, not an interruption, so it does not expire and cannot conflict with a call the user * is already in. * * IT SUBSUMES THE PENDING-VISIT MECHANISM IN THE UI, NOT IN THE TRANSPORT (Lane A's correction, * and they are right). This push only reaches a client that was already connected when the meeting * started. A patient who opens the portal AFTERWARDS is reached by `listAvailable()` instead - one * join affordance, two transports. Task 13 owns that lookup; do not assume this event covers it. * * CONFIRMED by Lane A (server design §3.2) - these four are the socket's entire outbound surface. */ export const CALL_EVENTS = { AVAILABLE: 'call.available', PARTICIPANT_JOINED: 'call.participant.joined', PARTICIPANT_LEFT: 'call.participant.left', /** Sent ONLY to the person removed; everyone else gets an ordinary `PARTICIPANT_LEFT`. Payload is * id-only - the wording belongs to the client. */ PARTICIPANT_REMOVED: 'call.participant.removed', /** The host started or stopped showing a record item. `ref` is the item reference. */ SHARE_STARTED: 'call.share.started', SHARE_STOPPED: 'call.share.stopped', ENDED: 'call.ended', } as const; /** `POST /{id}/remove`. Idempotent: `removed: true` is also the answer to a retry. */ export interface RemoveParticipantResult { sessionId: string; participantRef: string; removed: true; } /** A meeting this user may join. * * ONE TYPE ON PURPOSE. `GET /R4/video-session` returns `{ sessions: [...] }` of exactly this shape, * which is exactly the `call.available` payload (Lane A §3.2, verbatim: "the same object shape... so * the client renders both through one path regardless of whether it learned by push or by poll"). * Splitting these into two lookalike interfaces is how the two paths drift apart. */ export interface AvailableSession { sessionId: string; context: VideoContext; /** When the meeting was created. Orders two offers: pushes and lookups arrive in any order, and the * newer meeting must win. Absent from an older server, where the latest arrival wins as before. */ createdAt?: string; /** Display reference of whoever started it, e.g. `Practitioner/abc`. Optional - the UI must * render sensibly without it rather than showing a raw reference or an empty string. */ initiator?: string; /** * (R9) - has this person already been in this visit? * * Spencer: *"The button should indicate 'rejoin' once the user has left the meeting."* * It comes from the SERVER (the caller's own `leftAt`) because the client cannot know it: a reload * is exactly the case the grace window exists for, and a reload destroys whatever the client knew. * Optional and defaulting to false - a missing field must read as "first invitation", which is the * wording that was already there and is never wrong for a newcomer. */ returning?: boolean; } /** Payload of `call.available` - the same shape `listAvailable()` yields, deliberately. */ export type CallAvailableEvent = AvailableSession;