Today the module is a single empty NotificationAdapter. The target is the equivalent of Expo Notifications + Expo Push Service for Reaktor, with engagement-platform features layered on top, integrated as a first-class graph node and bus-coordinated server pipeline. The decomposition mirrors the canonical IntakeGraph / BusGraph / DeliveryGraph / ClientGraph shape from graph-kernel-analysis Example 9, slotting into the six runtime planes that govern every Reaktor module.
reaktor-notification (singular). The kernel canon — every other design doc and the pill list in graph-kernel-analysis — uses reaktor-notifications (plural). This document follows the canonical plural for prose and the singular path only when referring to the actual gradle target. Renaming the gradle module to reaktor-notifications is a recommended Phase 1 chore.
The reaktor-notification module currently exists as a thin shared seam. It declares all five target source sets in its Gradle build but ships a single abstract class. Everything in this document is forward-looking; nothing below is implemented yet.
package dev.shibasis.reaktor.notification import dev.shibasis.reaktor.core.framework.Adapter abstract class NotificationAdapter<Controller>( controller: Controller ): Adapter<Controller>(controller) { }
No NotificationIntent, NotificationTarget, NotificationContent, NotificationRoute, NotificationEndpoint, NotificationRecord, DeliveryTask, ProviderReceipt, or NotificationInteractionEvent.
No NotificationsService, NotificationOrchestrator, NotificationEndpointStore, NotificationStore, PushProvider, or NotificationRouteDispatcher interfaces.
No FirebaseMessagingService subclass, no channel registry, no MessagingStyle renderer, no conversation/shortcut bridge, no token provider.
No UNUserNotificationCenterDelegate implementation, no APNs registration, no category registry, no communication-notification builder, no Live Activity controller.
No Durable Object intake, no D1 schema, no FanoutService, no WebPushProvider, no outbox relay wiring.
No FCM Admin worker, no APNs-direct worker, no receipt worker, no Postgres adapters, no Arrow retry/circuit-breaker integration, no error classifiers.
No NotificationsNode, no GraphNotificationRouteDispatcher, no wiring to TactileActionRegistry.
No structured trace emission, no correlationId propagation, no Reaktor Desktop dashboard panels for the notification flow.
Effectively the entire module is greenfield. The rest of this document specifies what to build, how it fits the rest of Reaktor, and the order in which to ship it.
Notifications look simple from the product surface but are one of the most cross-cutting subsystems in modern apps. Nine concerns must be solved in concert; ignoring any one of them produces a system that works in demos and rots in production.
Channels vs categories. importance vs interruptionLevel. MessagingStyle vs communication notifications. POST_NOTIFICATIONS vs requestAuthorization. Web push and desktop add further variants.
FCM, APNs direct, Web Push (VAPID + service workers), Expo Push. Each has its own auth model, error taxonomy, rate limits, and quota.
FCM and APNs tokens rotate on reinstall, restore, and sometimes for no reason. Production deployments see 5–15% token churn per month. Dead tokens must be detected from server-side error responses and pruned.
Push is best-effort by design. Neither FCM nor APNs guarantee delivery. Delivered, seen, and opened are distinct states that need client-side feedback to observe.
A tap should deep-link to the right screen with state restored. The app may be dead, auth may be lost, the tap may arrive before the graph is ready. This is operationally painful.
Per-category mute, quiet hours, time-zone awareness, priority overrides. Preferences must sync across the user's devices.
"Send to users who completed onboarding, live in NA, and haven't logged in for three days" is table-stakes for engagement.
Campaigns are sequences with conditions. Event-triggered notifications fire on user actions. Retention reminders fire on absence. Each needs a different execution model.
Delivery, open, conversion, downstream engagement. Tying a notification to a purchase or message reply requires joining telemetry across the entire product lifecycle.
The question is not whether all nine get solved — every production app solves them, somewhere — but where each one is solved: in app code, in the notification module, in the delivery provider, or in an engagement platform. reaktor-notifications takes positions on every layer.
Expo provides two layers that, taken together, are the benchmark every cross-platform notification library is compared against: expo-notifications (the client library) and Expo Push Service (the hosted delivery API). Understanding both is essential because Reaktor is positioned as the Expo-Push-plus-engagement-platform equivalent for KMP apps.
A React Native package with a unified API across Android and iOS for the things app code actually needs to do:
| Area | API | What it abstracts |
|---|---|---|
| Permissions | getPermissionsAsync, requestPermissionsAsync |
POST_NOTIFICATIONS (Android 13+) and requestAuthorization (iOS) including iOS interruption-level granularity. |
| Tokens | getExpoPushTokenAsync, getDevicePushTokenAsync |
Opaque ExpoPushToken vs raw FCM/APNs token. Auto re-fetch on rotation. |
| Local | scheduleNotificationAsync(request) |
Time-interval, calendar-date, daily/weekly/yearly triggers; iOS location triggers. |
| Categories | setNotificationCategoryAsync |
Maps to Android InboxStyle + actions and iOS UNNotificationCategory. |
| Channels | setNotificationChannelAsync |
Android channel creation with importance/sound/vibration. |
| Handlers | setNotificationHandler, addNotificationReceivedListener, addNotificationResponseReceivedListener |
Foreground presentation decision and received/response hooks. |
| Badge | setBadgeCountAsync, getBadgeCountAsync |
iOS badge management. |
| Presented / dismiss | getPresentedNotificationsAsync, dismissNotificationAsync |
Currently-displayed inspection (Android) and explicit dismissal. |
The server POSTs an array of messages (each with to: ExpoPushToken[], title, body, data, sound, badge, …) to exp.host/--/api/v2/push/send. Expo translates to FCM or APNs internally, returns tickets immediately, and exposes a separate endpoint for eventually-consistent receipts (~15 min later).
Expo Push is a delivery primitive, not an engagement platform. It does not target by attribute/cohort/behavior, does not schedule campaigns with conditions, does not A/B test, does not personalize per recipient, does not provide open or conversion analytics, does not cap frequency across campaigns, does not respect quiet hours, and does not coordinate with email or SMS.
NotificationIntent + NotificationRoute (not opaque strings)reaktor-graph: a tap is a typed graph actionreaktor-notifications occupies the space between the delivery primitive (Expo Push, FCM Admin, APNs-direct) and the engagement platform (OneSignal, Braze, CleverTap). It covers ~80% of what engagement platforms provide, stays open-source and self-hostable, and integrates with the rest of the Reaktor framework.
The caller (chat DO, payment worker, moderation agent) decides a domain event deserves a notification. The module owns the workflow from intent → delivery → interaction.
reaktor-bus for async, reaktor-db for the operational store, reaktor-auth for authorization, reaktor-capability for degradation tiers, reaktor-telemetry for traces, reaktor-graphify to surface the subgraph. The module is thin because the framework is thick.
Channels, categories, MessagingStyle, communication notifications, Live Activities — all exposed as typed building blocks rather than hidden behind a lowest-common-denominator API.
A tap produces a NotificationInteractionEvent resolved to an ActionRef or a route Push. Tap-as-graph-event is the Reaktor differentiator versus deep-link strings.
Outbox, idempotency, retry with circuit breaker, DLQ with replay — all via reaktor-bus. No "we'll add that later" for the parts that matter.
Embedded (run your own delivery cluster) and hosted (Reaktor Cloud multi-tenant). Same commonMain contracts. Migration is a deployment-config change.
commonMain, androidMain, iosMain, jsMain, jvmMain. No sub-packages beyond standard source sets.
D1 is the operational store for Cloudflare deployments; Postgres for Spring-first. Supabase is business truth; notification truth is separate.
Durable Objects are the intake surface on Cloudflare. reaktor-bus (PubSub or CF Queue) is the async spine.
Firebase Admin SDK and APNs direct run on JVM workers. Worker (jsMain) talks to JVM via bus, not by re-implementing the SDKs.
No bespoke retry DSL, no custom circuit breaker, no parallel effect system. Schedule.exponential().jittered() and CircuitBreaker from Arrow Resilience compose with coroutines naturally.
NotificationRoute is a sealed class with OpenPath and GraphAction cases. Deep-link string parsing is forbidden at the route surface.
Every bus envelope carries correlationId and causationId so a notification flow is traceable across kinds.
Reaktor describes every module's responsibilities along six canonical runtime planes (per graph-kernel-analysis §Canonical runtime planes). Planes describe ownership, not dependencies. A healthy module knows which plane each of its concerns belongs to and resists the urge to mix them. reaktor-notifications touches all six.
| Plane | What it owns | Notifications' surface area |
|---|---|---|
| Capability / environment | Device, platform, execution, network, renderer, permission, power, thermal, storage. | PushNotification & LocalNotification capabilities · POST_NOTIFICATIONS / requestAuthorization permission gating · FCM and APNs token providers · channel / category registries. |
| Graph / action | Routes, panes, payloads, lifecycle, ports, services, repositories, actions. | NotificationsNode · RouteFacet on OpenPath · ActionFacet on GraphAction · ServiceFacet on NotificationsService · TactileActionRegistry dispatch. |
| Transport / messaging | HTTP, WebSocket, queues, Pub/Sub, mesh, FFI, envelopes, retries, DLQ, idempotency. | QueueFacet on every bus kind · outbox pattern · Arrow Schedule + CircuitBreaker · FCM HTTP v1 / APNs HTTP/2 / Web Push VAPID transport. |
| Data / persistence | Local cache, object store, SQL, CRDTs, blobs, sync, archival, tenant isolation. | D1 (Worker) / Postgres (Spring) operational store · 6 tables · per-tenant DB binding · endpoint & record & receipt & idempotency & interaction & attempt rows. |
| UI / interaction | Compose, React, design tokens, tactile state, adaptive layouts, components, accessibility. | Foreground presentation policy · in-app inbox surface (P3) · permission rationale screen · interaction-back-to-UI dispatch via TactileActionRegistry. |
| Intelligence / tooling | Compiler, graphify, shadow, devtools, deployment, telemetry, MCP, AI copilot. | TelemetryFacet on each leg · Reaktor Desktop flow panels · reaktor-graphify Leiden community for the notification subgraph · reaktor-shadow recorded traces of the full pipeline · DeploymentFacet partitioning intake/fanout/delivery across CF Worker / DO / Spring. |
graph-kernel-analysis Example 9 ("Notification and Campaign System") defines the canonical four-graph decomposition. reaktor-notifications implements exactly these graphs; each is independently deployable to a different target.
Where the world hands a NotificationIntent to the system. Nodes:
NotificationRequestService — REST / SDK entrypoint, validates & idempotency-checks the intent.CampaignTriggerService P2 — listens for bus events, materializes intents from campaign DSL.WebhookReceiver — third-party event hook (Stripe, Mailgun) that produces intents.Where async coordination lives. Each queue is a node with a QueueFacet; failures flow to the DLQ.
NotificationRequestedQueue — outbox-relayed from the intake transaction.FanoutQueue — per-endpoint delivery tasks.ReceiptQueue — provider acceptance & device-delivery receipts.DeadLetterQueue — retries-exhausted or terminal-failure intents, replayable from Reaktor Desktop.Where bytes leave the system. Each sender is a node bound to a provider:
AndroidFcmSender — Firebase Admin SDK on JVM.IosApnsSender — FCM-for-iOS in Phase 1; APNs-direct in P2 for Live Activities / critical alerts.WebPushSender P2 — VAPID JWT + AES-GCM payload encryption on the Worker.RetryClassifier — pure-function node that turns provider errors into RetryDecision.Where the user actually sees and touches a notification. Lives on the device, attached to the root graph.
NotificationsNode — lifecycle host, listener registration, permission flow.NotificationActionRouter — converts NotificationInteractionEvent to graph dispatch.OpenRouteAction — typed action for the path-based route case.MarkReadAction — typed action for the dismiss / read-receipt case.Facets ("typed meaning attached to a node, port, or edge without subclass explosion" — per graph-kernel-analysis §Build next #2) are how the notification module participates in the rest of the kernel without hardcoded coupling.
| Facet | Carried by | Consumed by |
|---|---|---|
ServiceFacet | NotificationsService, NotificationOrchestrator | Routing layer, Reaktor Desktop service inspector. |
QueueFacet | Every bus kind: notifications.fanout.requested, .delivery.task, .delivery.receipt, … | reaktor-bus, DLQ replay tool, telemetry. |
RouteFacet | NotificationRoute.OpenPath | Navigation runtime, deep-link validator, graphify route community. |
ActionFacet | NotificationRoute.GraphAction | TactileActionRegistry, action codegen, AI copilot suggestions. |
DeploymentFacet | Each sender node (FCM, APNs, Web Push) | reaktor-devops deployment planner — picks JVM Spring vs Worker per provider. |
TelemetryFacet | Every leg of the pipeline | reaktor-telemetry, Reaktor Desktop dashboards. |
CapabilityProfile | PushNotification, LocalNotification | Strategy selector — falls back from remote → local → suppressed. |
A notification is not a "send-push" call — it is a workflow that begins with a domain intent inside a service and ends with a typed graph action on the user's device. Every async hop is a bus kind. Every step is idempotent. Every failure is observable.
NotificationRecord · outbox publish to NotificationRequestedQueueFanoutQueue tasksRetryClassifierOpenRouteAction / MarkReadAction → graph dispatchNotificationIntent from a domain event (e.g. chat.mention)createNotificationInTransaction writes NotificationRecord + outbox row atomically (idempotency-keyed)notifications.fanout.requestedNotificationTarget.User → endpoints · applies DeliveryPolicy (quiet hours · category mute · frequency cap)notifications.delivery.task per surviving endpointFirebaseDeliveryWorker consumes task · constructs provider message · sends through Arrow retry + circuit breakernotifications.delivery.receipt (Accepted or EndpointInvalid)NotificationEnvelopeNotificationInteractionEvent → GraphNotificationRouteDispatcher → graph.dispatch(...)NotificationRecord. There is no path through this system where a chat message exists without the matching notification intent being published, or where a notification is published without the message being persisted. One transaction, two intents — never one without the other.
Each file is a single concern. No god-objects. The platform source sets do not hide platform primitives — they expose them as typed, composable building blocks.
Semantic, provider-neutral. No FCM types. No APNs types. No platform types. All nine types live in commonMain/domain/.
What the caller wants delivered: tenant · app · type · target · content · route · priority · category · scheduledAt · idempotencyKey · correlationId.
Sealed: User · Users · Installation · Installations · Segment (P2) · Topic (P2).
title · subtitle (iOS) · body · imageUrl · categoryId · threadId · person · badge · sound · interruptionLevel · data.
Sealed: OpenPath(path) · GraphAction(type, payloadJson) · None.
id · tenant · app · user · installation · platform · provider · token · environment · locale · timezone · deviceInfo · status · preferences · timestamps.
The persisted intent: tenant · type · target · content · route · status · createdAt · sentAt · completedAt.
The unit of work for the JVM worker: notificationId · endpointId · attemptNumber · deadline.
Outcome of a delivery attempt: notificationId · endpointId · providerMessageId · result · receivedAt.
What the device emits when the user taps or actions a notification: notificationId · endpointId · actionId · route · occurredAt.
data class NotificationIntent( val tenantId: String, val appId: String, val type: String, // "chat.message" | "event.invite" val target: NotificationTarget, val content: NotificationContent, val route: NotificationRoute, val priority: NotificationPriority = NotificationPriority.Normal, val category: String? = null, // user-preference category val scheduledAt: Instant? = null, val idempotencyKey: String? = null, val correlationId: String? = null, ) sealed class NotificationTarget { data class User(val userId: String) : NotificationTarget() data class Users(val userIds: List<String>) : NotificationTarget() data class Installation(val installationId: String) : NotificationTarget() data class Installations(val installationIds: List<String>) : NotificationTarget() data class Segment(val segmentId: String) : NotificationTarget() // Phase 2 data class Topic(val topic: String) : NotificationTarget() // Phase 2 } data class NotificationContent( val title: String, val subtitle: String? = null, // iOS only val body: String, val imageUrl: String? = null, val categoryId: String? = null, // iOS category / Android channel action set val threadId: String? = null, // iOS threadIdentifier / Android conversation id val person: NotificationPerson? = null, // communication notifications val badge: Int? = null, val sound: NotificationSound = NotificationSound.Default, val interruptionLevel: InterruptionLevel = InterruptionLevel.Active, val data: Map<String, String> = emptyMap(), ) sealed class NotificationRoute { data class OpenPath(val path: String) : NotificationRoute() data class GraphAction(val type: String, val payloadJson: String) : NotificationRoute() data object None : NotificationRoute() } enum class InterruptionLevel { Passive, Active, TimeSensitive, Critical } enum class NotificationPriority { Low, Normal, High, Urgent }
Six interfaces in commonMain/service/, each a small concern. The interface boundary is identical across embedded and hosted deployment shapes.
interface NotificationsService { suspend fun registerEndpoint(cmd: RegisterEndpointCommand): RegisterEndpointResult suspend fun unregisterEndpoint(cmd: UnregisterEndpointCommand) suspend fun updatePreferences(cmd: UpdatePreferencesCommand) suspend fun createNotification(intent: NotificationIntent): NotificationAccepted suspend fun getNotification(id: String): NotificationRecord? suspend fun recordInteraction(event: NotificationInteractionEvent) } interface NotificationOrchestrator { suspend fun accept(intent: NotificationIntent): NotificationAccepted suspend fun fanOut(notificationId: String) suspend fun deliver(task: DeliveryTask): DeliveryResult suspend fun handleReceipt(receipt: ProviderReceipt) } interface NotificationEndpointStore { suspend fun upsert(endpoint: NotificationEndpoint) suspend fun findByUser(tenantId: String, appId: String, userId: String): List<NotificationEndpoint> suspend fun findByInstallation(tenantId: String, appId: String, installationId: String): NotificationEndpoint? suspend fun markInvalid(endpointId: String, reason: String) suspend fun updatePreferences(endpointId: String, prefs: PerCategoryPreferences) } interface NotificationStore { suspend fun insert(record: NotificationRecord) suspend fun updateStatus(id: String, status: NotificationStatus) suspend fun appendAttempt(attempt: DeliveryAttempt) suspend fun recordReceipt(receipt: ProviderReceipt) suspend fun recordInteraction(event: NotificationInteractionEvent) } interface PushProvider { val kind: PushProviderKind suspend fun send(batch: ProviderBatch): ProviderBatchResult } interface NotificationRouteDispatcher { suspend fun dispatch(event: NotificationInteractionEvent) }
D1 (Cloudflare) or Postgres (Spring). Six tables in Phase 1; campaign tables land in Phase 2 if scheduled.
| Table | Purpose | Key indexes |
|---|---|---|
notification_endpoints | Tokens keyed by tenant + app + installation + provider. | (tenant,app,user) · (tenant,status) |
notification_records | Each accepted NotificationIntent as a persisted record. | (tenant,app,created_at DESC) · (correlation_id) |
notification_attempts | One row per per-endpoint delivery attempt. | (notification_id) |
notification_receipts | Outcomes: Accepted · DeliveredToDevice · Opened · Dismissed · Failed. | (notification_id) |
notification_idempotency | Dedup table on idempotencyKey with TTL. | PK only |
notification_interactions | Tap and action-button events from devices. | (notification_id) |
CREATE TABLE notification_endpoints ( id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, app_id TEXT NOT NULL, user_id TEXT, installation_id TEXT NOT NULL, platform TEXT NOT NULL, provider TEXT NOT NULL, token TEXT NOT NULL, environment TEXT NOT NULL, locale TEXT, timezone TEXT, device_info_json TEXT NOT NULL, preferences_json TEXT NOT NULL, status TEXT NOT NULL, last_seen_at INTEGER NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, UNIQUE(tenant_id, app_id, installation_id, provider) ); CREATE INDEX idx_endpoints_user ON notification_endpoints(tenant_id, app_id, user_id); CREATE INDEX idx_endpoints_status ON notification_endpoints(tenant_id, status); CREATE TABLE notification_records ( id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, app_id TEXT NOT NULL, type TEXT NOT NULL, correlation_id TEXT, target_type TEXT NOT NULL, target_value TEXT NOT NULL, content_json TEXT NOT NULL, route_json TEXT NOT NULL, category TEXT, priority TEXT NOT NULL, status TEXT NOT NULL, created_at INTEGER NOT NULL, scheduled_at INTEGER, sent_at INTEGER, completed_at INTEGER ); -- + notification_attempts, notification_receipts, -- notification_idempotency, notification_interactions
Translates commonMain contracts to Android primitives. Every Android-specific concept the OS exposes is available as a typed building block; nothing is hidden behind a lowest-common-denominator abstraction.
Implements the common NotificationsClient interface. Wires permissions (POST_NOTIFICATIONS), token acquisition, listener registration, and local-notification scheduling onto Android primitives.
Wraps FirebaseMessaging.getToken() with Arrow Either. Listens for onNewToken via the Reaktor-installed FCM service subclass and posts the new token to the endpoint store.
Idempotent channel + channel-group lifecycle at app start. Conversation channels are created on demand when a conversation first appears. Tracks what's registered to avoid recreation.
Translates NotificationContent to NotificationCompat.Builder picking BigTextStyle, MessagingStyle, BigPictureStyle, InboxStyle, or MediaStyle based on content shape. Resolves threadId to conversation channel + shortcut; resolves person to MessagingStyle.Person.
Maintains long-lived ShortcutInfo entries for active chats. Enables bubbles and conversation-priority on Android 11+. Pushes via ShortcutManager.
Receives tap and action intents. Extracts notificationId, endpointId, actionId, route metadata; for direct reply, extracts RemoteInput text and attaches it to the event.
Extends FirebaseMessagingService. Deserializes reaktor-specific fields from the data payload, constructs a NotificationEnvelope, dispatches through the client's received listener. Foreground delivery hands off to the renderer.
| Capability | Module surface | Notes |
|---|---|---|
| Channels & channel groups | AndroidChannelRegistry | Locked behaviors respected; only name & description editable post-creation. |
| MessagingStyle | AndroidNotificationRenderer | Multi-message threading + self-message attribution. |
| Conversations (API 30+) | ConversationShortcutManager | Unlocks priority conversations, bubbles, top-of-shade placement. |
| Direct reply | AndroidNotificationInteractionBridge | RemoteInput bound to a category's reply action. |
| Group summaries | AndroidNotificationRenderer | Explicit cross-channel aggregation when needed. |
POST_NOTIFICATIONS permission | AndroidNotificationsClient | Requested in context (post-onboarding), not on first launch. |
| Importance vs priority bridging | AndroidNotificationRenderer | NotificationCompat bridges both worlds. |
| Foreground-service notifications | — | Out of scope; owned by the specific service. |
Wraps UNUserNotificationCenter and APNs registration. Communication-notification & Live-Activity helpers expose iOS-specific features without leaking UNMutableNotificationContent into common code.
Implements the common interface against UNUserNotificationCenter: authorization, pending/delivered queries, dismissal, badge, schedule.
Calls registerForRemoteNotifications. Receives the NSData token via the app delegate, converts to hex, returns to common. When FCM-on-iOS is enabled, exchanges the APNs token for an FCM token before upload.
Registers UNNotificationCategory entries at launch via setNotificationCategories. Re-registration is cheap and idempotent; categories key off app version.
Handles INPerson + INSendMessageIntent donation + threadIdentifier + intent image. Engaged whenever content.person is non-null. Required for sender-avatar rendering, Focus filters, CarPlay read-aloud.
Wraps ActivityKit. Starts a Live Activity with a typed ContentState; updates via APNs liveactivity push type; ends on workflow completion. Hard caps: 8h idle / 12h total / ~1 update per 30s.
Implements UNUserNotificationCenterDelegate. willPresent chooses foreground presentation options. didReceive converts the response to a NotificationInteractionEvent. Handles UNTextInputNotificationResponse.userText.
Separate target. For E2E-encrypted apps: decrypt body and substitute plaintext. For rich push: fetch image URL and attach as UNNotificationAttachment. Triggered by mutable-content: 1.
| Capability | Module surface | Notes |
|---|---|---|
| Authorization (incl. provisional) | IosNotificationsClient | Provisional auth path for "deliver quietly until trusted". |
| Categories & actions | AppleCategoryRegistry | Text-input actions map to direct reply. |
| Attachments & rich push | Service extension | Fetched in 30s budget; original delivered on timeout. |
| Communication notifications (iOS 15+) | CommunicationNotificationBuilder | Sender avatar + Focus integration + CarPlay. |
| Focus & interruption levels | NotificationContent.interruptionLevel | Passive · Active · TimeSensitive · Critical. |
| Live Activities (iOS 16.1+) | LiveActivityController P2 | Push type liveactivity, separate auth. |
| Critical alerts | NotificationContent.interruptionLevel = Critical | Requires Apple entitlement; surfaced not enforced by the module. |
Web push is protocol-standardized (not vendor-proprietary) and end-to-end encrypted (AES128-GCM). It requires a Service Worker registration on the page side and a VAPID JWT on the server side. Targeted for Phase 2.
push events even when the page is closed, displays via registration.showNotification.Module pieces: WebPushProvider.kt on the server and a small service-worker.js template on the client. Web push is the most secure provider by default but the least feature-rich (no native action buttons with inline reply, limited styling).
The Cloudflare-side runtime. A Durable Object is the intake surface; a Worker hosts the fanout ServiceNode; D1 holds the operational store.
The DO calls NotificationsService.createNotificationInTransaction(txn, intent) inside the same SQLite transaction that persists the domain event. The call writes both the NotificationRecord and an outbox row atomically; the outbox relay publishes the bus kind asynchronously.
class ChatRoomDurableObject( private val db: DurableObjectDatabase, private val notifications: NotificationsService ) { suspend fun onMessageCreated(message: ChatMessage) { db.transaction { txn -> persistMessage(txn, message) message.mentions.forEach { userId -> notifications.createNotificationInTransaction( txn, NotificationIntent( tenantId = tenant, appId = "bestbuds", type = "chat.mention", target = NotificationTarget.User(userId), content = NotificationContent( title = "${message.author.name} mentioned you", body = message.text.take(100), threadId = message.roomId, person = NotificationPerson( id = message.author.id, displayName = message.author.name, avatarUrl = message.author.avatarUrl, ), ), route = NotificationRoute.GraphAction( type = "bestbuds.chat.open", payloadJson = """{"chatId":"${message.roomId}"}""", ), category = "messages", idempotencyKey = "mention:${message.id}:${userId}", correlationId = message.id, ) ) } } } }
A ServiceNode consumes notifications.fanout.requested, resolves target → endpoints, applies policy, and publishes one notifications.delivery.task per surviving endpoint. DeliveryPolicy is a pure function of (intent, endpoint, now) and is unit-testable without infrastructure.
class NotificationFanoutService( private val records: NotificationStore, private val endpoints: NotificationEndpointStore, private val policy: DeliveryPolicy, private val bus: ReaktorBus, ) : Service() { val fanout = PostHandler<FanoutRequest, FanoutResult>( ServiceEndpoint.pubSub("notifications.fanout.requested").operation ) { request -> val record = records.get(request.notificationId) ?: return@PostHandler FanoutResult.NotFound val targetEndpoints = resolveTarget(record.intent.target, record.tenantId, record.appId) val filtered = targetEndpoints.filter { endpoint -> endpoint.status == EndpointStatus.Active && policy.permits(record.intent, endpoint, Clock.System.now()) } filtered.forEach { endpoint -> bus.publish( kind = "notifications.delivery.task", payload = DeliveryTask( notificationId = record.id, endpointId = endpoint.id, attemptNumber = 1, deadline = Clock.System.now() + 24.hours, ), serializer = DeliveryTask.serializer(), correlationId = record.correlationId, causationId = request.messageId, ) } FanoutResult.Accepted(filtered.size) } }
JVM owns provider delivery because the Firebase Admin SDK and APNs HTTP/2 client are mature on JVM and not on JS. Workers consume notifications.delivery.task via reaktor-bus streaming pull, build the provider message, send through Arrow retry + circuit breaker, classify errors, and emit receipts.
class FirebaseDeliveryWorker( private val firebase: FirebaseMessaging, private val endpoints: NotificationEndpointStore, private val records: NotificationStore, private val classifier: ProviderErrorClassifier, private val bus: ReaktorBus, ) : Service() { private val retrySchedule = Schedule .exponential<Throwable>(base = 1.seconds) .jittered() .doWhile { err -> classifier.classify(err) is RetryDecision.Retryable } .maxN(10) private val breaker = CircuitBreaker( maxFailures = 5, resetTimeout = 30.seconds, openingStrategy = OpeningStrategy.Count, ) val deliver = PostHandler<DeliveryTask, DeliveryResult>( ServiceEndpoint.pubSub("notifications.delivery.task").operation ) { task -> val endpoint = endpoints.findById(task.endpointId) ?: return@PostHandler DeliveryResult.EndpointMissing val record = records.get(task.notificationId) ?: return@PostHandler DeliveryResult.RecordMissing val message = MessageBuilder.forEndpoint(endpoint, record.intent.content, record.intent.route) val outcome = retrySchedule.retry { breaker.protectOrThrow { firebase.send(message) DeliveryResult.Accepted(firebaseMessageId = message.messageId) } } outcome.fold( ifLeft = { err -> handleFailure(task, endpoint, err) }, ifRight = { accepted -> bus.publish( kind = "notifications.delivery.receipt", payload = ProviderReceipt( notificationId = task.notificationId, endpointId = task.endpointId, providerMessageId = accepted.firebaseMessageId, result = ProviderResult.Accepted, receivedAt = Clock.System.now(), ), serializer = ProviderReceipt.serializer(), ) accepted } ) } private suspend fun handleFailure(task: DeliveryTask, endpoint: NotificationEndpoint, err: Throwable): DeliveryResult = when (val decision = classifier.classify(err)) { is RetryDecision.InvalidateEndpoint -> { endpoints.markInvalid(task.endpointId, decision.reason) DeliveryResult.EndpointInvalid(decision.reason) } is RetryDecision.Terminal -> DeliveryResult.Failed(err) is RetryDecision.Retryable -> DeliveryResult.Failed(err) // retries exhausted } }
class ReceiptWorker( private val endpoints: NotificationEndpointStore, private val records: NotificationStore, ) : Service() { val handle = PostHandler<ProviderReceipt, Unit>( ServiceEndpoint.pubSub("notifications.delivery.receipt").operation ) { receipt -> records.recordReceipt(receipt) if (receipt.result is ProviderResult.EndpointInvalid) { endpoints.markInvalid(receipt.endpointId, receipt.result.reason) } } }
For Expo Push (if used as a provider), an additional worker polls Expo's tickets endpoint after the 15-minute propagation window and updates endpoint state.
ProviderErrorClassifier maps provider errors to a three-way RetryDecision: InvalidateEndpoint (dead token — remove from store), Retryable (transient — Arrow retries with backoff), Terminal (operator-fixable or unknown — surface to DLQ).
One implementation per provider, each tested against fixture error responses captured from real deployments.
| Provider | Error | Decision | Action |
|---|---|---|---|
| FCM | UNREGISTERED | Invalidate | Mark endpoint invalid; stop sending to this token. |
| FCM | INVALID_ARGUMENT | Invalidate | Malformed token or payload; mark invalid. |
| FCM | SENDER_ID_MISMATCH | Invalidate | Token belongs to a different app. |
| FCM | QUOTA_EXCEEDED | Retryable | Exponential backoff + jitter. |
| FCM | UNAVAILABLE / INTERNAL | Retryable | Transient FCM outage. |
| FCM | THIRD_PARTY_AUTH_ERROR | Terminal | Operator-fixable (APNs key config). |
| APNs | BadDeviceToken / Unregistered / DeviceTokenNotForTopic | Invalidate | Common: dev/prod env mismatch causes DeviceTokenNotForTopic. |
| APNs | TooManyRequests / ServiceUnavailable / Shutdown | Retryable | Backoff & reconnect. |
| * | IOException | Retryable | Network blip. |
Tap and action events are not strings. They become NotificationInteractionEvent on the client, and the GraphNotificationRouteDispatcher converts them to typed graph actions or route pushes.
class GraphNotificationRouteDispatcher( private val graph: Graph, private val actionRegistry: TactileActionRegistry, ) : NotificationRouteDispatcher { override suspend fun dispatch(event: NotificationInteractionEvent) { when (val route = event.route) { is NotificationRoute.OpenPath -> { val routeNode = graph.resolveRoute(route.path) ?: return graph.dispatch(Push(routeNode.edge(), routeNode.parsePayload(route.path))) } is NotificationRoute.GraphAction -> { val actionRef = ActionRef(route.type, payloadSchema = route.type) val payload = TactileValue.fromJson(route.payloadJson) actionRegistry.dispatch(actionRef, payload) } NotificationRoute.None -> Unit } } }
GraphAction(type = "bestbuds.chat.open", payloadJson = {"chatId":"..."}). The dispatcher resolves the action through TactileActionRegistry — the same registry the UI already uses for button taps. A notification tap is a first-class graph event. Deep-link strings are an internal detail of the path-based route only.
Notifications live in the graph as a node, not as a parallel system. Lifecycle hooks and DI scope flow naturally from BasicNode.
class NotificationsNode( graph: Graph, private val client: NotificationsClient, private val dispatcher: NotificationRouteDispatcher, ) : BasicNode(graph) { override suspend fun onAttach() { client.addReceivedListener { envelope -> // foreground presentation, badge update, analytics } client.addResponseListener { response -> dispatcher.dispatch(response.toInteractionEvent()) } } suspend fun register(userId: String?) { val permission = client.getPermissions() if (permission == NotificationPermissionStatus.Denied) return if (permission == NotificationPermissionStatus.Undetermined) { val granted = client.requestPermissions() if (granted == NotificationPermissionStatus.Denied) return } client.registerRemoteEndpoint(userId) } } fun Graph.NotificationsNode( client: NotificationsClient, dispatcher: NotificationRouteDispatcher, ): NotificationsNode { val node = NotificationsNode(this, client, dispatcher) attach(node) return node } // Installed in the app's root graph: rootGraph.NotificationsNode( client = platformNotificationsClient, dispatcher = GraphNotificationRouteDispatcher(rootGraph, actionRegistry), )
From the developer's perspective notifications are a graph capability. From the runtime's perspective they are a node with lifecycle hooks and DI scope.
Every intent, attempt, receipt, and interaction is traced end to end via reaktor-telemetry with a stable notification.correlationId spanning the entire flow.
| Attribute | Meaning |
|---|---|
notification.id | Record id. |
notification.type | Domain type (chat.mention, order.shipped). |
notification.tenant_id · notification.app_id | Multi-tenant context. |
notification.target_type | User · Installation · Segment · … |
notification.endpoint_id · notification.provider | Per-leg endpoint detail. |
notification.attempt · notification.status · notification.error_code | Delivery semantics. |
notification.route_type · notification.interaction_type | Routing & user response. |
notification.latency_ms | End-to-end latency for SLO tracking. |
correlationId; everything is queryable from Reaktor Desktop.
Bestbuds Ventures runs a multi-tenant delivery fleet. Tenants call a hosted REST API (or SDK) with createNotification calls; from tenant code the experience is identical to embedded mode.
notifications.{tenantId} binding)reaktor-auth tenant API keysThe tenant runs their own notifications cluster. Same module, same commonMain contracts, different deployment topology.
reaktor-bus deploymentMigration from embedded to hosted (or back) is a deployment-config change. Application code does not change.
ServiceNode (jsMain) with policy engine (quiet hours, frequency caps, category mutes).FirebasePushProvider + FirebaseDeliveryWorker with Arrow retry + circuit breaker.GraphNotificationRouteDispatcher + NotificationsNode.reaktor-bus wiring.PushProvider implementation).reaktor-email and reaktor-sms are separate modules.reaktor-tactile if it ships at all).Composes through standard Reaktor surfaces. No bespoke wiring. The status column reflects the dependency's own state today — most are partial or aspirational, which is the most important fact this whole document needs to communicate honestly. reaktor-notifications cannot ship faster than the slowest of its dependencies, so Phase 1 sequencing has to follow them.
| Module | Status today | How reaktor-notifications uses it |
|---|---|---|
| reaktor-core | Stable | Idempotency contract; persisted attempt & record abstractions; typed envelope helpers. |
| reaktor-graph | Stable | NotificationsNode is a graph node; lifecycle & DI scope flow naturally. Kernel facets (ServiceFacet, RouteFacet, ActionFacet) tag every concern. |
| reaktor-graph-port | Partial | ServiceNode handlers with ServiceEndpoint.pubSub; ConsumerPort / ProviderPort wiring. Port kernel exists; transport bindings vary by target. |
| reaktor-bus | Aspirational | Every async topic. Module assumes outbox, retry, DLQ, envelope with correlationId, W3C trace context. Blocks intake → fanout coordination. |
| reaktor-capability | Vocabulary only | CapabilityProfile for PushNotification / LocalNotification with tiers: Full (remote + local) · Basic (local only) · Fallback (suppressed). Strategy selector adapter layer still to build. |
| reaktor-db | Partial | D1 (Worker) and Postgres (Spring) repository adapters for the six tables; outbox primitive. Repos exist for some adapters; multi-target story incomplete. |
| reaktor-auth | Partial — see auth-analysis | Tenant + user identity on intent construction; permission gating on hosted API. Spring middleware partial (DefaultSecurityConfig permits all); JWKS endpoint on Workers missing. |
| reaktor-telemetry | Aspirational | Structured trace emission through the delivery pipeline; correlationId across kinds. Schema and collection layer undefined today. |
| reaktor-tactile | Partial | NotificationsService action refs available to generated UI via TactileActionRegistry. Action registry exists; registry-as-ContractId boundary still firming up. |
| reaktor-graphify | Aspirational | Notification subgraph appears in GRAPH_REPORT.md as its own Leiden community. |
| reaktor-shadow | Aspirational | Notification flows participate in recorded traces; replay covers the full intake-to-receipt chain. |
| reaktor-cloudflare | Partial | DO intake surface; D1 binding; CF Queues for the bus; per-tenant DB binding for hosted mode. |
| reaktor-devops | Aspirational | Deploys the module across Worker, Spring, and D1 in one command; bus kinds registered in the CF and GCP control plane. |
reaktor-bus (no outbox / DLQ today), reaktor-telemetry (no schema), and reaktor-capability (vocabulary only). Phase 1 should either land minimum-viable versions of these in parallel, or stub them with explicit "to be replaced" interfaces. Without that, this module ends up reinventing those kernels privately, which the design philosophy (commitment #2: "reuse the rest of Reaktor") explicitly forbids.
The honest picture: every concern in this document, except the empty NotificationAdapter class, is not yet implemented. The work below is roughly ordered to unblock the next concern after it lands.
| # | Work item | Source set | Status | Blocks |
|---|---|---|---|---|
| 1 | Domain types (Intent, Target, Content, Route, Endpoint, Record, DeliveryTask, ProviderReceipt, InteractionEvent) | commonMain | missing | everything |
| 2 | Service contracts (6 interfaces) | commonMain | missing | all platform impls |
| 3 | Policy: DeliveryPolicy, FrequencyCap, QuietHours (pure functions) | commonMain | missing | fanout |
| 4 | D1 schema migrations & D1Notification{Endpoint,Record}Store | jsMain | missing | intake, fanout |
| 5 | DurableObjectIntake with outbox row + idempotency | jsMain | missing | fanout |
| 6 | FanoutService consuming notifications.fanout.requested | jsMain | missing | delivery |
| 7 | FcmErrorClassifier & FirebasePushProvider | jvmMain | missing | delivery worker |
| 8 | FirebaseDeliveryWorker with Arrow Schedule + CircuitBreaker | jvmMain | missing | receipts |
| 9 | ReceiptWorker updating attempts and invalidating endpoints | jvmMain | missing | endpoint health |
| 10 | AndroidChannelRegistry, FcmTokenProvider, ReaktorFirebaseMessagingService | androidMain | missing | android receive |
| 11 | AndroidNotificationRenderer, ConversationShortcutManager, AndroidNotificationInteractionBridge | androidMain | missing | android tap routing |
| 12 | IosNotificationsClient, ApnsRegistrationProvider, AppleCategoryRegistry, IosNotificationInteractionBridge | iosMain | missing | ios receive + tap |
| 13 | CommunicationNotificationBuilder (iOS 15+ sender avatar + Focus) | iosMain | missing | chat-app parity |
| 14 | NotificationsNode + GraphNotificationRouteDispatcher | commonMain | missing | graph integration |
| 15 | Telemetry attribute set + Reaktor Desktop panels | commonMain + tooling | missing | operational sign-off |
| 16 | Postgres adapters for Spring-first deployments | jvmMain | missing | embedded mode |
| 17 | APNs direct provider, Web Push provider, Live Activities | jvmMain, jsMain, iosMain | Phase 2 | — |
| 18 | Campaign scheduler, segmentation, A/B testing | jvmMain, jsMain | Phase 2 | engagement-platform parity |
Land every type and interface in commonMain. Nothing else is unblocked without them. These are pure data classes — they should be reviewable independently of any platform work.
D1 schema, D1NotificationEndpointStore, D1NotificationStore, idempotency table, outbox relay, DurableObjectIntake. End state: a chat DO can call createNotificationInTransaction and observe a bus kind being published.
FcmErrorClassifier, FirebasePushProvider, FirebaseDeliveryWorker with Arrow retry + circuit breaker, ReceiptWorker. End state: a published delivery task produces a real FCM push and a persisted receipt.
Channel registry, token provider, FCM service subclass, renderer, interaction bridge. End state: a push from the JVM worker lands on a real Android device and a tap dispatches a typed graph action.
UNUserNotificationCenterDelegate impl, APNs registration, category registry, interaction bridge, communication notifications. End state: full parity with the Android client.
Wire reaktor-telemetry across every leg. Land Desktop panels. Cut Bestbuds chat mentions over to the new pipeline behind a feature flag.
chat.mention notifications + tap-deep-link-into-chat is exactly the workflow this module exists to make trivial, and it is the highest-value notification surface on the product today.