Architecture Decision Record
ADR-044: Native Push Delivery (the Third Notification Channel)
Status
Accepted (2026-07-11). Amends ADR-024. The framework pipeline is implemented and inert by
default; each consumer switches it on by provisioning a notification hub with platform
credentials and enabling the NativePush configuration section.
Context
ADR-024 established two notification channels: a durable per-user UserNotification inbox (the
source of truth) and a transient SignalR push behind IPushNotificationSender. Both stop at the
edge of a connected client: a phone with the app backgrounded, killed, or offline hears nothing
until the next launch. Conference announcements ("lunch is served", "room change") are exactly
the messages that must reach pockets, not open tabs.
OS-level delivery needs three things the framework did not have: a per-device registration store keyed to users, a sender that speaks FCM v1 (Android) and APNs (iOS), and a client-side lifecycle that registers the device after sign-in and unregisters it before sign-out. It also needs credentials (a Firebase service account, an APNs auth key) that cannot live in source and may not exist when the code ships.
Decision
- Azure Notification Hubs as the delivery fan-out. One hub abstracts both platforms behind one API, holds the platform credentials outside our code, and its installation model gives upsert semantics with client-generated stable ids. The free tier covers conference volumes.
- Two new Application abstractions, Null by default (the ADR-024 pattern):
INativePushSender(user-targeted and broadcast native sends) andIPushDeviceRegistrar(installation upsert/delete).AddInfrastructureTryAdds no-op defaults;AddNativePushNotifications(configuration)swaps in the Azure Notification Hubs implementations only when theNativePushsection is enabled AND complete (ConnectionString,HubName) - so hosts call it unconditionally and deployments flip the channel on by configuration alone. - Installations are tagged
user:{id}. Sends target users, never raw tokens; a user's every device gets the message. Tag expressions are OR-chunked at the hub's 20-tag cap (NativePushPayloads, unit-tested), so audience size is unbounded. SendPushNotificationHandlergains the third leg. After the inbox write and the SignalR attempt, it callsINativePushSender.SendToUsersAsyncinside its own non-fatal catch. The audit status (Sent/Failed) stays owned by the SignalR leg: the inbox remains the source of truth, and a hub outage must not fail the command or the other channels.DevicesController(PUT/DELETE/Notifications/Devices) ships inMMCA.Common.APIvia the existingAddNotificationControllersapplication part,[Authorize]for any signed-in user and feature-gated with the sameNotification.PushNotificationsflag as the rest of the pipeline. Ownership is stamped server-side from the current user; installation ids are client-generated GUIDs (not enumerable). DELETE is idempotent (unknown id = success).- Client orchestration behind two UI capability contracts (ADR-042 pattern):
IPushRegistrationService(register after sign-in, unregister BEFORE sign-out - the delete call is authenticated) andIPushDeviceTokenProvider(the platform token extension point). UI.Maui shipsMauiPushRegistrationService(stable installation id inIDevicePreferences, sync over the named API client); the token provider defaults toNullPushDeviceTokenProvidereverywhere, so a build WITHOUT push credentials is wired but inert end to end.PushRegistrationListener(rendered through the host layout extension point) re-registers on auth-state changes;AuthUIService.LogoutAsyncowns the unregister leg.
Consequences
- Sends fan out per 20-user chunk and per platform: an audience of N users costs
ceil(N/20) * 2hub calls. Acceptable at conference scale; a template-based send can consolidate later without touching callers. - The handler's third leg is fire-and-forget: no per-device delivery tracking. The hub's telemetry is the observability surface; the inbox remains the recovery path.
- Anyone holding an installation id could delete that registration through the authenticated DELETE. Ids are client-generated GUIDs stored only on the device, so this is not enumerable; re-registration on next launch self-heals.
- Consumers must update PRIVACY.md/store data-safety forms (push tokens are device identifiers) BEFORE store metadata mentions push. Credential provisioning (Firebase service account, APNs key) is a manual runbook step per app; until done, the hub rejects sends and the client token provider yields nothing - both by design.
SendPushNotificationHandlergained a constructor parameter (DI-resolved; source-compatible for every host, breaking only for code constructing it manually - none known).