Ably realtime channel naming
Cross-cutting contract for Ably channel names across all domains. Defines how channels are scoped, what they carry, and how to add a new one.
Naming convention
<scope>:<resource>:<id>| Scope prefix | Capability requirement | Used by |
|---|---|---|
private: | Token-scoped (subscribe; never publish from clients) | All current channels |
(future) public: | No token required | Reserved; nothing uses it yet |
Channels in use
Every publish goes through one seam: Shared::RealtimePublisher is the only place in the app that talks to ::AblyClient (#2465). Callers reach it as publish_to_user(user_id:, event:, payload:, source:) for a user channel, or publish(channel:, ...) for the handful that are not user-scoped.
| Channel | Lifetime | Who is granted subscribe | Publishes from | Purpose |
|---|---|---|---|---|
private:user:<user_id> | Long-lived per user | the owning user | Notifications::PublishRealtimeJob, Clinical::TeleconsultationWaitingEventJob, Clinical::VideoSessions::NotificationService, Phr::RealtimePublisher, Uhi::RealtimePublisher | Notification, waiting-room, video-session, Scan & Share and UHI invalidation signals |
admin-patients | Domain-wide | platform admins only | Clinical::ProcessCsvUploadJob | Patient CSV upload progress for the Kulcare admin app |
There are only two. Every signal is addressed to a person or to the platform-admin audience, and the capability follows from that without a per-resource authorization check.
private-encounter-<encounter_id> used to be a third, carrying the video-session lifecycle. It was removed in #2797: it had had no possible subscriber since #2350 dropped the encounter-* grant from Notifications::AblyAuthController#capability_for, so Ably refused the capability and every publish was a billed no-op. Those events now go to the participants' own user channels. The lesson is the one in step 3 below — a channel nobody is granted is a publish into nothing, and nothing in the system complains about it.
Event names on the user channel are dotted and namespaced by domain — notification.created, teleconsultation.patient_waiting, video_session.call_started, abdm.share_profile.completed. The channel is shared, so a bare event name is a collision risk; the video-session events were bare until #2797 and were safe only because the channel was unreachable.
Channel name resolution
Backend channel names are produced by a single helper per domain. Frontend names mirror the backend exactly — drift is silent (a frontend subscribed to the wrong channel just never receives events) so resolution should always go through:
- Backend:
Shared::RealtimePublisher.user_channel_for(user_id), whichNotifications::Realtime.user_channel_fordelegates to.publish_to_userapplies it for you, so a call site normally passes auser_idand never a channel string at all. - Frontend:
useNotificationsRealtime(userId)(or the underlyinguseRealTimeEvent(channelName, ...)from@kulcare/api-client)
Do NOT inline channel name strings at call sites.
Token capability
Channels are protected by Ably tokenRequests issued from POST /api/ably/auth (Notifications::AblyAuthController). The endpoint issues capability scoped to only the requesting user's channel:
{
"private:user:<current_user.id>": ["subscribe"]
}Specifically:
- Always subscribe-only. No client should publish to Ably; all publishes originate from server jobs.
- No
historycapability. If a token is exfiltrated, the holder can subscribe for the remaining TTL but cannot enumerate past events. - TTL is configurable via
Rails.application.config.x.ably.token_ttl_ms(default 30 min viaABLY_TOKEN_TTL_MSenv).
Adding a new channel
- Decide the scope. What's the resource id pattern? Is it per-user, per-resource (encounter, order, etc.), or domain-wide?
- Add the channel name to
Shared::RealtimePublisher, next touser_channel_for. Do not add a per-domainchannels.get(...)call — that is the drift #2465 removed, and the seam owns theenabled?check and the transient-error set along with the name. - Decide who can subscribe, and check that somebody does. Update
Notifications::AblyAuthController#capability_forto issue subscribe permission for the new pattern based oncurrent_user, scoped — not broadened across all channels. A channel with no grant is a publish into nothing, which is what happened toprivate-encounter-*. - Publish from a Sidekiq job, not from a controller.
Ably::Rest#channel#publishis HTTP-blocking; controllers shouldn't pay for it. - Add a frontend hook in
@kulcare/shared-servicesthat wrapsuseRealTimeEventwith the channel name. Don't inline channel strings in components. - Add a smoke spec that asserts the auth endpoint returns capability scoped to the new channel — not all channels — for an authenticated user.
Failure modes
- Ably API key missing.
Shared::RealtimePublisher.enabled?returns false (Notifications::Realtime.enabled?delegates to it). Server-side publishes silently no-op; client-side reconnects fail at the auth endpoint (returns 503). The inbox / domain data still works via the polling fallback (React Query staleTime). - Token expired mid-session. Ably client auto-renews via
authUrl. If renewal fails, the client falls back to its disconnect-retry loop and the frontend hook's staleTime takes over. - Stale event during reconnect. Frontend re-receives
notification.createdevents after the connection comes back. Hooks treat these as invalidation signals (idempotent refetches), not data — so duplicates are harmless.
Related
- Backend auth controller:
backend/domains/notifications/controllers/ably_auth_controller.rb - Backend publisher seam:
backend/domains/shared/services/realtime_publisher.rb - Backend publisher example:
backend/domains/notifications/jobs/publish_realtime_job.rb - Frontend Ably provider:
frontend/packages/api-client/src/services/realtime/ablyProvider.ts - Frontend hooks:
frontend/packages/api-client/src/hooks/useRealTime.ts