Architecture Decision Record
ADR-039: Live channel push (ephemeral events over the notification hub)
Status
Accepted (2026-07-09).
Revised 2026-09-07 (channel subscription can be gated by an app-supplied authorizer, connections
are capped per user, the backplane channel is namespaced per application, and ADC scopes live poll
reads to the audience-visible states).
Revised 2026-09-25 (re-anchored the backplane-prefix citation, which now lives in the
DependencyInjection.Notifications.cs partial, and the ADC poll manage endpoint).
Context
Conference-day features (live polls, session Q&A, live result counters) need sub-second fan-out of
small events to whoever is looking at a page right now. The existing notification pipeline
(ADR-024) is deliberately durable: one use case writes a per-user inbox row and fires a transient
SignalR push, with the inbox as the source of truth. Live updates are the opposite shape: high
frequency, broadcast to an interest group rather than a user list, worthless seconds later, and
wrong to persist. Routing them through the outbox/broker path adds seconds of latency
(Outbox:ProcessingDelaySeconds, ADR-003) and per-user rows nobody will read.
A second WebSocket/hub for live traffic would double the connection count per client, need its own auth, reconnect, and backplane story, and split the client-side connection management in two.
Decision
One realtime transport, two publisher boundaries:
NotificationHubstays the single hub and gains its first client-invokable methods:JoinChannel/LeaveChannelmap the calling connection into a SignalR group named by a channel key (event:1,session:123). Keys are validated againstPushNotificationSettings.ChannelKeyPattern(default^(event|session):[0-9]+$); invalid keys throwHubException, so clients cannot join arbitrary group names.- A new
ILiveChannelPublisherabstraction in Application (PublishAsync(channelKey, eventName, payloadJson)) sits besideIPushNotificationSender. Payloads are pre-serialized JSON strings: one serialization point at the publishing edge, and the same payload shape rides any transport hop unchanged. - Infrastructure ships
SignalRLiveChannelPublisher(group send ofReceiveChannelEventviaIHubContext<NotificationHub>) and a no-opNullLiveChannelPublisherdefault, swapped in byAddPushNotifications()exactly like the ADR-024 sender pair. A host that does not map the hub can replace the registration with its own transport (for example a gRPC adapter that forwards to the service that does map the hub). NotificationHubService(UI) carries both traffic kinds on its existing connection:JoinChannelAsync/LeaveChannelAsynctrack membership,OnChannelEventregisters multicast subscriptions (an invisible layout listener and a page can observe the same channel concurrently), and every tracked channel is re-joined onReconnected, because SignalR group membership does not survive an automatic reconnect.
The durable-vs-ephemeral split is decided at the publisher boundary, not the transport:
IPushNotificationSender for anything a user must be able to find later (inbox-backed),
ILiveChannelPublisher for anything that only matters while it is on screen (no persistence, no
delivery guarantee).
Rationale
- One WebSocket per client keeps connection management, token refresh, reconnect, and backplane behavior in one place; channel membership is a property of the existing connection.
- An Application-layer abstraction keeps application code transport-free (the same rule as
IMessageBus, enforced by the architecture tests), and the Null default preserves the "resolvable everywhere, active only where configured" convention. - String-JSON payloads avoid a serializer dependency in the contract and make cross-service ingress trivial (the payload is opaque to every hop).
- Group-name validation by configurable pattern closes the obvious abuse vector of client-supplied group names without hardcoding app-specific channel vocabularies into the framework.
Trade-offs
- Ephemeral means lossy: a client that connects after an event was published never sees it. Features must treat channel events as cache-invalidation hints over fetchable state, not as the state itself.
- Handlers receive raw JSON and deserialize themselves; type safety is by convention (shared payload records in the consuming app's Shared project).
- The single settable
NotificationCallbackfor durable notifications remains single-subscriber; only channel events are multicast. Unifying them is deliberate future work, not blocked by this decision. - If a hub-hosting service ever runs more than one replica, the already-detected Redis backplane (ADR-024) is required for group sends to reach connections on other replicas; single-replica deployments need nothing.
Revision (2026-07-24)
Two corrections from a code review; the best-effort, per-session-ordered decision is unchanged.
Broadcasts are enqueued after commit, not during the command.
CastVoteHandlerandToggleUpvoteHandlerboth emitted their broadcast inside the handler, while the ADR-014 transactional decorator still had the transaction open, so a rollback left clients already told about a vote or upvote that never persisted. Both moved to domain-event handlers onLivePollVoteChanged/SessionQuestionUpvoteChanged, events the aggregates already raised but nothing consumed. In-process dispatch inside a transactional command is deferred until after commit and dropped on rollback (ADR-003 Revision 2026-07-19), so post-commit delivery follows from where the work is attached rather than from extra sequencing code.ToggleUpvoteHandleradditionally awaited the gRPC publish on the request thread, so a slow Notification peer added its latency to every upvote: precisely what the queue exists to prevent. The upvote path now uses the same off-request-path queue as the poll path.Drops under backpressure are observable. The queue uses
BoundedChannelFullMode.DropOldest, under whichTryWritealways returns true because it evicts to make room. The caller's "if the enqueue failed, log it" branch was therefore unreachable and every drop was silent. Drops now go through the channel'sitemDroppedcallback: counted onDroppedCountand logged with a running total, so a drain falling behind is visible rather than inferred from missing client updates.
Revision (2026-09-07)
The channel model is unchanged: a client subscribes to a channel key and the server pushes to it. What a signed-in client is allowed to subscribe to, and how much it may hold open, is now bounded (SEC-Common-18 / SEC-ADC-25 / SEC-ADC-27).
- Subscription can be authorized per channel.
NotificationHubtakes an optionalIChannelJoinAuthorizer(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Notifications/NotificationHub.cs:27; contract atMMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Notifications/IChannelJoinAuthorizer.cs) and consults it after the channel-key shape check. It stays optional because the framework cannot know what a channel key means: a host publishing anything that is not public to every signed-in user registers one, and a host whose channels are event-wide announcements does not. - Connections are capped per user. A connection past
PushNotifications:MaxConnectionsPerUser(default 20,MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Notifications/Push/PushNotificationSettings.cs:42) is refused (NotificationHub.cs:56). A hub connection is long-lived server state that the request rate limiter never sees, so it needs its own bound. - The backplane is namespaced per application. The Redis backplane channel prefix defaults to
the resolved application namespace
(
MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Notifications.cs:59-61, resolver atMMCA.Common/Source/Core/MMCA.Common.Infrastructure/Configuration/ApplicationNamespace.cs:53). - ADC scopes what a live poll read returns.
GetPollResultsHandlertreats a poll as audience-readable only when it has reachedOpenorClosedand its event or session is published (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/LivePolls/UseCases/GetPollResults/GetPollResultsHandler.cs:65-66, applied at:111and:126). ADraftpoll carries the question and every option an organizer has staged, so reading one by id was a preview of unannounced content to any authenticated caller. The authoring rights in BR-236 are unchanged: this is a read-scope rule, and the manage surface (GET /api/livepolls/manage,MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/Controllers/LivePollsController.cs:183) still returns every state to a caller who passes that check.
Revision (2026-10-06)
No content change: the channel model, the per-user connection cap and its default are as recorded on 2026-09-07. Two anchors recorded in that section have moved:
- The
MaxConnectionsPerUserdefault of 20 is now declared atMMCA.Common/Source/Core/MMCA.Common.Infrastructure/Notifications/Push/PushNotificationSettings.cs:46(itsRange(0, 10_000)bound at:45). - The refusal of a connection past the cap is now in
NotificationHub.OnConnectedAsync(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Notifications/NotificationHub.cs:61), which reads the cap at:63, compares at:69and throwsHubExceptionat:74.
Anchors were re-verified against current source.