How changes reach other users: the WebSocket endpoints and close codes, the connection manager and per-user pushes, the two broadcast tiers, and on the client the reconnecting hook and the entity → data-slice map that turns change:<entity> events into debounced refetches. It was moved verbatim out of the repo-root CLAUDE.md.
Code described: app/websocket/ (handler.py, manager.py, broadcast.py), app/middleware/broadcast.py, frontend/src/hooks/useWebSocket.ts, frontend/src/contexts/WebSocketContext.tsx, frontend/src/contexts/wsSlices.ts.
- Endpoints:
/ws(single-tenant) and/t/{slug}/ws(multi-tenant), inhandler.py. Origin check first (is_origin_allowed: theOriginmust matchHost/X-Forwarded-Hostor a configured CORS origin; absentOrigin= non-browser client, allowed) → else close 4003. Auth = theconnect.sidcookie validated against the tenant DB. Close codes: 4001 no/invalid/expired session or revoked (disconnect_user), 4002 auth error, 4003 origin not allowed / user disabled, 4004 tenant not found, 4005 tenant not active, 4006 tenant DB not configured. ConnectionManager(manager.py, globalmanager): per-tenant rooms, multiple connections per user (multi-tab), presence join/leave/list with deduped online users,broadcast_to_tenant(exclude_user/exclude_connection),send_to_user(tenant, user_id, message)for per-user delivery (notifications),disconnect_user(tenant, user_id, except_session_id=…)for session revocation (per process, best effort), asyncio-lock guarded. Fan-outs count only successful sends and prune a connection whose send failed (which emitspresence:leave).- Two broadcast tiers: routers with meaningful UX events call
broadcast_change(request, user, entity_type, entity_id, project_id, action, summary)(broadcast.py) → clients receive an attributedchange:<entity>with user name and summary. All other successful writes get a coarsechange:<entity>fromBroadcastMiddleware(sender excluded;SKIP_PATTERNSprevents doubles). - Receive loop handles
ping→pong(client pings every 25 s). notification:newis sent per-user viasend_notification()(websocket/broadcast.py). It is a live hint only — the manager is per-process, so a user on another worker misses it; the DB row is the source of truth and the bell reloads on mount/focus. On the client it needs its owncaseinuseWebSocket(thedefault:branch only handleschange:*).
useWebSocket(src/hooks/useWebSocket.ts): connects tows(s)://<host><tenantPrefix>/ws, exponential backoff (2 s base, 60 s max, 5 attempts), does not reconnect on close codes 1000/1001/4000–4006; after giving up (or a non-terminal drop) it retries immediately on the browseronlineevent and when the tab becomes visible (visibilitychange) — never after 4000–4006. 4001/4003 callonSessionInvalid→WebSocketContextre-checks/auth/meand callsexpireSession()if the session is gone; if/auth/mesays the session is still valid (a handshake cookie race), the hook retries once with a fresh backoff budget, and a repeated 4001/4003 stays terminal. Exposesoffline(browser offline, or settled and not connected/connecting) → the Header'sSyncStatus"Offline" badge. TracksonlineUsers(presence messages) andrecentChanges(anychange:*, expiring 30 s after local receipt to dodge clock skew).WebSocketContext(src/contexts/WebSocketContext.tsx) turns changes into data refreshes:slicesForEntity()maps entity type → data slices (project/phase/subphase/assignment/staff_assignment/equipment_assignment/predefined_phase→projects,staff→staff+projects,equipment→equipment+projects,equipment_block→equipmentBlocks,vacation→vacations,skill→skills+staff,bank_holiday/company_event→siteCalendar(current site's holidays + events),absence_type→absenceTypes+vacations,site→sites+siteCalendar+projects+tags+equipment(site writes are rare; a site delete cascades its projects, site-only tags and site-only equipment),tag→tags+projects,tag_group→tags(thetagsslice reloads tags and groups),custom_column/custom_column_value→customColumns(customColumnStore only, no project reload),settings→settings,user→staff+currentUser+projects(role/site changes and deactivation of the signed-in user apply live),note/card_comment→ nothing, unknown → everything; the mapping lives insrc/contexts/wsSlices.ts; Kanban writes reusephase/subphase/staff_assignment/card_comment, there is no Kanban-specific entity), then refreshes via a 200 ms debounced, coalesced runner with an in-flight guard that re-stages if more changes arrive mid-fetch. A phase drag producing N child updates results in one refetch per slice. Unknown entity types refresh everything, so new backend entities need no client change.- While What-If is active, refreshes of the sandboxed slices (
projects,customColumns—WHAT_IF_SANDBOXED_SLICESinwsSlices.ts) are deferred and run once What-If ends, so a teammate's change never replaces the plan being edited. notification:new→WebSocketContext.handleNotificationReceived→notificationStore.receive()(dedupes by id). It is only a live hint; the bell reloads from the server on mount and focus.OnlineUsersandActivityFeedrender presence and the change feed (recentChangesfromuseWebSocket). A per-entity "changed by X" badge (ChangeIndicator+useEntityChangeIndicator) was built but never wired up and has been removed as dead code, together with its per-entity query helpers; the context exposesrecentChangesonly.