reaktor-notifications · detailed design · authored by Claude

The full-stack notification runtime, from domain intent to graph action

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.

Gradle path · :reaktor-notification (singular) Canonical name · reaktor-notifications (plural, per kernel canon) Status · Brainstorming → Phase 1 Source spec · Reaktor / §22 Targets · android · ios · jvm · jsMain · webMain Doc revision · 2026-05-26
naming The gradle folder is 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.
Code today
~10 LOC
Files today
1 .kt
Phase 1 footprint
~6 KLOC est.
Source sets
common · android · ios · js · jvm
Bus kinds
6 (fanout · delivery · receipt · …)
Reuses
bus · db · auth · capability · graph

00Where we are today

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.

What ships today

reaktor-notification/ ├── README.md # "Stability: Brainstorming" ├── build.gradle.kts # droid {} darwin {} web {} server {} useKoin() ├── reaktor_notification.podspec └── src/ └── commonMain/kotlin/dev/shibasis/reaktor/notification/ └── NotificationAdapter.kt # ~7 LOC

NotificationAdapter.kt (entire current source)

package dev.shibasis.reaktor.notification

import dev.shibasis.reaktor.core.framework.Adapter

abstract class NotificationAdapter<Controller>(
    controller: Controller
): Adapter<Controller>(controller) {

}
audit The module has the build configuration of a multiplatform leaf module but zero implementation. No domain model, no platform code, no server pipeline, no graph integration. The README explicitly labels this "Stability: Brainstorming — intentionally thin. Exists as a shared seam for future platform implementations."

What is not built yet

Domain model

No NotificationIntent, NotificationTarget, NotificationContent, NotificationRoute, NotificationEndpoint, NotificationRecord, DeliveryTask, ProviderReceipt, or NotificationInteractionEvent.

Service contracts

No NotificationsService, NotificationOrchestrator, NotificationEndpointStore, NotificationStore, PushProvider, or NotificationRouteDispatcher interfaces.

Android client

No FirebaseMessagingService subclass, no channel registry, no MessagingStyle renderer, no conversation/shortcut bridge, no token provider.

iOS client

No UNUserNotificationCenterDelegate implementation, no APNs registration, no category registry, no communication-notification builder, no Live Activity controller.

Server (jsMain)

No Durable Object intake, no D1 schema, no FanoutService, no WebPushProvider, no outbox relay wiring.

Server (jvmMain)

No FCM Admin worker, no APNs-direct worker, no receipt worker, no Postgres adapters, no Arrow retry/circuit-breaker integration, no error classifiers.

Graph integration

No NotificationsNode, no GraphNotificationRouteDispatcher, no wiring to TactileActionRegistry.

Telemetry

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.

01Why notifications are hard

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.

1 · Platform fragmentation

Channels vs categories. importance vs interruptionLevel. MessagingStyle vs communication notifications. POST_NOTIFICATIONS vs requestAuthorization. Web push and desktop add further variants.

2 · Provider diversity

FCM, APNs direct, Web Push (VAPID + service workers), Expo Push. Each has its own auth model, error taxonomy, rate limits, and quota.

3 · Token lifecycle

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.

4 · Delivery semantics

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.

5 · Interaction routing

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.

6 · User preferences

Per-category mute, quiet hours, time-zone awareness, priority overrides. Preferences must sync across the user's devices.

7 · Targeting & segmentation

"Send to users who completed onboarding, live in NA, and haven't logged in for three days" is table-stakes for engagement.

8 · Scheduling & orchestration

Campaigns are sequences with conditions. Event-triggered notifications fire on user actions. Retention reminders fire on absence. Each needs a different execution model.

9 · Analytics

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.

02Inspiration: expo-notifications & Expo Push Service

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.

expo-notifications — the library

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.

Expo Push Service — the delivery primitive

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).

two-phase Tickets prove "Expo queued it"; receipts prove "FCM/APNs accepted it". Production code must poll receipts and prune dead ExpoPushTokens, or quota silently leaks on every send.

Where Expo stops

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.

Expo provides

  • Unified client API for permissions, tokens, local, categories, channels, handlers
  • Single POST endpoint that fans to FCM and APNs
  • Tickets + receipts protocol
  • Rate-limited multitenant delivery (~600 msgs/sec baseline)
  • APNs cert management on behalf of the developer

reaktor-notifications adds

  • Typed NotificationIntent + NotificationRoute (not opaque strings)
  • D1 / Postgres operational store (records · attempts · receipts · interactions · endpoints · idempotency)
  • Outbox-coordinated intake from Durable Objects
  • Bus-coordinated fanout & per-endpoint delivery with retry + circuit breaker
  • Per-category mute, quiet hours, frequency caps (Phase 1)
  • Segmentation, A/B test, campaigns (Phase 2)
  • Direct integration with reaktor-graph: a tap is a typed graph action
  • Hosted (Reaktor Cloud) and embedded deployment shapes from one codebase

03Positioning & design philosophy

reaktor-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.

Six design commitments

1 · Typed workflow, not push send

The caller (chat DO, payment worker, moderation agent) decides a domain event deserves a notification. The module owns the workflow from intent → delivery → interaction.

2 · Reuse the rest of Reaktor

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.

3 · Platform depth is first-class

Channels, categories, MessagingStyle, communication notifications, Live Activities — all exposed as typed building blocks rather than hidden behind a lowest-common-denominator API.

4 · Typed routing into the graph

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.

5 · Production resilience from day one

Outbox, idempotency, retry with circuit breaker, DLQ with replay — all via reaktor-bus. No "we'll add that later" for the parts that matter.

6 · Two deployment shapes

Embedded (run your own delivery cluster) and hosted (Reaktor Cloud multi-tenant). Same commonMain contracts. Migration is a deployment-config change.

04Seven non-negotiables

One KMM module

commonMain, androidMain, iosMain, jsMain, jvmMain. No sub-packages beyond standard source sets.

D1 for edge · Postgres for Spring

D1 is the operational store for Cloudflare deployments; Postgres for Spring-first. Supabase is business truth; notification truth is separate.

DO intake · Bus spine

Durable Objects are the intake surface on Cloudflare. reaktor-bus (PubSub or CF Queue) is the async spine.

JVM owns provider delivery

Firebase Admin SDK and APNs direct run on JVM workers. Worker (jsMain) talks to JVM via bus, not by re-implementing the SDKs.

Arrow + coroutines for resilience

No bespoke retry DSL, no custom circuit breaker, no parallel effect system. Schedule.exponential().jittered() and CircuitBreaker from Arrow Resilience compose with coroutines naturally.

Typed routes, not strings

NotificationRoute is a sealed class with OpenPath and GraphAction cases. Deep-link string parsing is forbidden at the route surface.

Envelope carries correlation

Every bus envelope carries correlationId and causationId so a notification flow is traceable across kinds.

05The six runtime planes & four graphs

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.

PlaneWhat it ownsNotifications' 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.

Four graphs (kernel canon · Example 9)

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.

IntakeGraph Spring · Worker · DO

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.

BusGraph Cloudflare Queues · GCP Pub/Sub

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.

DeliveryGraph JVM Spring · Worker

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.

ClientGraph Android · iOS · Web

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.
key alignment Each graph maps cleanly onto one or two planes: Intake is Graph/action + Transport, Bus is Transport, Delivery is Transport + Capability, Client is Capability + Graph/action + UI. There is no graph that crosses every plane — that's the property that lets the four pieces be deployed, tested, and reasoned about independently.

Facets in play

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.

FacetCarried byConsumed by
ServiceFacetNotificationsService, NotificationOrchestratorRouting layer, Reaktor Desktop service inspector.
QueueFacetEvery bus kind: notifications.fanout.requested, .delivery.task, .delivery.receipt, …reaktor-bus, DLQ replay tool, telemetry.
RouteFacetNotificationRoute.OpenPathNavigation runtime, deep-link validator, graphify route community.
ActionFacetNotificationRoute.GraphActionTactileActionRegistry, action codegen, AI copilot suggestions.
DeploymentFacetEach sender node (FCM, APNs, Web Push)reaktor-devops deployment planner — picks JVM Spring vs Worker per provider.
TelemetryFacetEvery leg of the pipelinereaktor-telemetry, Reaktor Desktop dashboards.
CapabilityProfilePushNotification, LocalNotificationStrategy selector — falls back from remote → local → suppressed.

06The notification as a typed workflow

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.

IntakeGraph · DO
NotificationRequestService
Idempotency check · persist NotificationRecord · outbox publish to NotificationRequestedQueue
BusGraph → Worker
FanoutService
Target → endpoints · apply policy · publish per-endpoint FanoutQueue tasks
DeliveryGraph · JVM
Fcm / Apns / WebPush Sender
Build provider message · send · retry · circuit-break · RetryClassifier
BusGraph → ReceiptQueue
ReceiptWorker
Update attempts · invalidate dead endpoints · emit telemetry · DLQ on terminal
ClientGraph · Device
NotificationActionRouter
Render · listener fires · OpenRouteAction / MarkReadAction → graph dispatch

End-to-end flow

1DO caller builds NotificationIntent from a domain event (e.g. chat.mention)
2DO createNotificationInTransaction writes NotificationRecord + outbox row atomically (idempotency-keyed)
3BUS outbox relay publishes notifications.fanout.requested
4BUS fanout worker loads record · resolves NotificationTarget.User → endpoints · applies DeliveryPolicy (quiet hours · category mute · frequency cap)
5BUS publishes one notifications.delivery.task per surviving endpoint
6JVM FirebaseDeliveryWorker consumes task · constructs provider message · sends through Arrow retry + circuit breaker
7BUS publishes notifications.delivery.receipt (Accepted or EndpointInvalid)
8JVM receipt worker writes receipt row · marks endpoint invalid on permanent failure
9DEV FCM / APNs delivers push · platform service builds NotificationEnvelope
10DEV foreground listener or system display · user taps
11DEV interaction bridge emits NotificationInteractionEventGraphNotificationRouteDispatchergraph.dispatch(...)
12DEV graph executes typed action exactly as if a button were pressed inside the app
invariant The outbox row sits in the same SQLite transaction as the 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.

07Module layout

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.

reaktor-notification/ ├── commonMain/ │ ├── domain/ │ │ ├── NotificationIntent.kt │ │ ├── NotificationTarget.kt │ │ ├── NotificationContent.kt │ │ ├── NotificationRoute.kt │ │ ├── NotificationEndpoint.kt │ │ ├── NotificationRecord.kt │ │ ├── DeliveryTask.kt │ │ ├── ProviderReceipt.kt │ │ └── NotificationInteractionEvent.kt │ ├── service/ │ │ ├── NotificationsService.kt │ │ ├── NotificationOrchestrator.kt │ │ ├── NotificationEndpointStore.kt │ │ ├── NotificationStore.kt │ │ └── NotificationRouteDispatcher.kt │ ├── policy/ │ │ ├── DeliveryPolicy.kt │ │ ├── FrequencyCap.kt │ │ └── QuietHours.kt │ └── provider/ │ ├── PushProvider.kt │ └── ProviderErrorClassifier.kt ├── androidMain/ │ ├── AndroidNotificationsClient.kt │ ├── FcmTokenProvider.kt │ ├── AndroidChannelRegistry.kt │ ├── AndroidNotificationRenderer.kt │ ├── ConversationShortcutManager.kt │ ├── AndroidNotificationInteractionBridge.kt │ └── ReaktorFirebaseMessagingService.kt ├── iosMain/ │ ├── IosNotificationsClient.kt │ ├── ApnsRegistrationProvider.kt │ ├── AppleCategoryRegistry.kt │ ├── IosNotificationInteractionBridge.kt │ ├── CommunicationNotificationBuilder.kt │ └── LiveActivityController.kt # Phase 2 ├── jsMain/ │ ├── DurableObjectIntake.kt │ ├── D1NotificationEndpointStore.kt │ ├── D1NotificationStore.kt │ ├── FanoutService.kt │ └── WebPushProvider.kt # Phase 2 └── jvmMain/ ├── FirebasePushProvider.kt ├── ApnsDirectPushProvider.kt # Phase 2 ├── FirebaseDeliveryWorker.kt ├── PostgresNotificationEndpointStore.kt ├── PostgresNotificationStore.kt ├── ReceiptWorker.kt └── CampaignScheduler.kt # Phase 2

08Domain model (commonMain)

Semantic, provider-neutral. No FCM types. No APNs types. No platform types. All nine types live in commonMain/domain/.

NotificationIntent

What the caller wants delivered: tenant · app · type · target · content · route · priority · category · scheduledAt · idempotencyKey · correlationId.

NotificationTarget

Sealed: User · Users · Installation · Installations · Segment (P2) · Topic (P2).

NotificationContent

title · subtitle (iOS) · body · imageUrl · categoryId · threadId · person · badge · sound · interruptionLevel · data.

NotificationRoute

Sealed: OpenPath(path) · GraphAction(type, payloadJson) · None.

NotificationEndpoint

id · tenant · app · user · installation · platform · provider · token · environment · locale · timezone · deviceInfo · status · preferences · timestamps.

NotificationRecord

The persisted intent: tenant · type · target · content · route · status · createdAt · sentAt · completedAt.

DeliveryTask

The unit of work for the JVM worker: notificationId · endpointId · attemptNumber · deadline.

ProviderReceipt

Outcome of a delivery attempt: notificationId · endpointId · providerMessageId · result · receivedAt.

NotificationInteractionEvent

What the device emits when the user taps or actions a notification: notificationId · endpointId · actionId · route · occurredAt.

Reference shapes

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 }

09Service contracts

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)
}

10Operational data store

D1 (Cloudflare) or Postgres (Spring). Six tables in Phase 1; campaign tables land in Phase 2 if scheduled.

TablePurposeKey indexes
notification_endpointsTokens keyed by tenant + app + installation + provider.(tenant,app,user) · (tenant,status)
notification_recordsEach accepted NotificationIntent as a persisted record.(tenant,app,created_at DESC) · (correlation_id)
notification_attemptsOne row per per-endpoint delivery attempt.(notification_id)
notification_receiptsOutcomes: Accepted · DeliveredToDevice · Opened · Dismissed · Failed.(notification_id)
notification_idempotencyDedup table on idempotencyKey with TTL.PK only
notification_interactionsTap and action-button events from devices.(notification_id)

Schema (D1)

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

11Android source set

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.

AndroidNotificationsClient

Implements the common NotificationsClient interface. Wires permissions (POST_NOTIFICATIONS), token acquisition, listener registration, and local-notification scheduling onto Android primitives.

FcmTokenProvider

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.

AndroidChannelRegistry

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.

AndroidNotificationRenderer

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.

ConversationShortcutManager

Maintains long-lived ShortcutInfo entries for active chats. Enables bubbles and conversation-priority on Android 11+. Pushes via ShortcutManager.

AndroidNotificationInteractionBridge

Receives tap and action intents. Extracts notificationId, endpointId, actionId, route metadata; for direct reply, extracts RemoteInput text and attaches it to the event.

ReaktorFirebaseMessagingService

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.

Platform primitives covered

CapabilityModule surfaceNotes
Channels & channel groupsAndroidChannelRegistryLocked behaviors respected; only name & description editable post-creation.
MessagingStyleAndroidNotificationRendererMulti-message threading + self-message attribution.
Conversations (API 30+)ConversationShortcutManagerUnlocks priority conversations, bubbles, top-of-shade placement.
Direct replyAndroidNotificationInteractionBridgeRemoteInput bound to a category's reply action.
Group summariesAndroidNotificationRendererExplicit cross-channel aggregation when needed.
POST_NOTIFICATIONS permissionAndroidNotificationsClientRequested in context (post-onboarding), not on first launch.
Importance vs priority bridgingAndroidNotificationRendererNotificationCompat bridges both worlds.
Foreground-service notificationsOut of scope; owned by the specific service.

12iOS source set

Wraps UNUserNotificationCenter and APNs registration. Communication-notification & Live-Activity helpers expose iOS-specific features without leaking UNMutableNotificationContent into common code.

IosNotificationsClient

Implements the common interface against UNUserNotificationCenter: authorization, pending/delivered queries, dismissal, badge, schedule.

ApnsRegistrationProvider

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.

AppleCategoryRegistry

Registers UNNotificationCategory entries at launch via setNotificationCategories. Re-registration is cheap and idempotent; categories key off app version.

CommunicationNotificationBuilder

Handles INPerson + INSendMessageIntent donation + threadIdentifier + intent image. Engaged whenever content.person is non-null. Required for sender-avatar rendering, Focus filters, CarPlay read-aloud.

LiveActivityController P2

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.

IosNotificationInteractionBridge

Implements UNUserNotificationCenterDelegate. willPresent chooses foreground presentation options. didReceive converts the response to a NotificationInteractionEvent. Handles UNTextInputNotificationResponse.userText.

Service Extension opt-in

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.

Platform primitives covered

CapabilityModule surfaceNotes
Authorization (incl. provisional)IosNotificationsClientProvisional auth path for "deliver quietly until trusted".
Categories & actionsAppleCategoryRegistryText-input actions map to direct reply.
Attachments & rich pushService extensionFetched in 30s budget; original delivered on timeout.
Communication notifications (iOS 15+)CommunicationNotificationBuilderSender avatar + Focus integration + CarPlay.
Focus & interruption levelsNotificationContent.interruptionLevelPassive · Active · TimeSensitive · Critical.
Live Activities (iOS 16.1+)LiveActivityController P2Push type liveactivity, separate auth.
Critical alertsNotificationContent.interruptionLevel = CriticalRequires Apple entitlement; surfaced not enforced by the module.

13Web push source set P2

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.

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).

14jsMain: intake & fanout

The Cloudflare-side runtime. A Durable Object is the intake surface; a Worker hosts the fanout ServiceNode; D1 holds the operational store.

Intake from a Durable Object

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,
          )
        )
      }
    }
  }
}

Fanout worker

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)
  }
}

15jvmMain: delivery workers

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
    }
}

Receipt worker

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.

16Retry classification

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.

ProviderErrorDecisionAction
FCMUNREGISTEREDInvalidateMark endpoint invalid; stop sending to this token.
FCMINVALID_ARGUMENTInvalidateMalformed token or payload; mark invalid.
FCMSENDER_ID_MISMATCHInvalidateToken belongs to a different app.
FCMQUOTA_EXCEEDEDRetryableExponential backoff + jitter.
FCMUNAVAILABLE / INTERNALRetryableTransient FCM outage.
FCMTHIRD_PARTY_AUTH_ERRORTerminalOperator-fixable (APNs key config).
APNsBadDeviceToken / Unregistered / DeviceTokenNotForTopicInvalidateCommon: dev/prod env mismatch causes DeviceTokenNotForTopic.
APNsTooManyRequests / ServiceUnavailable / ShutdownRetryableBackoff & reconnect.
*IOExceptionRetryableNetwork blip.

17Typed routing into the graph

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
    }
  }
}
differentiator A tap on "Alice mentioned you" fires 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.

18NotificationsNode: graph integration

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.

19Observability

Every intent, attempt, receipt, and interaction is traced end to end via reaktor-telemetry with a stable notification.correlationId spanning the entire flow.

Standard telemetry attributes

AttributeMeaning
notification.idRecord id.
notification.typeDomain type (chat.mention, order.shipped).
notification.tenant_id · notification.app_idMulti-tenant context.
notification.target_typeUser · Installation · Segment · …
notification.endpoint_id · notification.providerPer-leg endpoint detail.
notification.attempt · notification.status · notification.error_codeDelivery semantics.
notification.route_type · notification.interaction_typeRouting & user response.
notification.latency_msEnd-to-end latency for SLO tracking.

Reaktor Desktop dashboard panels

closes the gap This is what closes the "works in demos, opaque in production" gap that afflicts most notification systems. Every leg emits a structured span with the same correlationId; everything is queryable from Reaktor Desktop.

20Hosted vs embedded

Hosted (Reaktor Cloud)

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.

  • Per-tenant D1 databases (notifications.{tenantId} binding)
  • Per-tenant FCM service accounts, encrypted at rest
  • Per-tenant APNs keys, encrypted at rest
  • Multi-tenant JVM delivery fleet with per-tenant rate limits
  • Per-tenant analytics dashboard surfaced through Reaktor Desktop
  • Auth via reaktor-auth tenant API keys

Embedded (self-hosted)

The tenant runs their own notifications cluster. Same module, same commonMain contracts, different deployment topology.

  • D1 or Postgres operational store
  • Their own FCM & APNs credentials
  • Their own JVM workers
  • Their own reaktor-bus deployment

Migration from embedded to hosted (or back) is a deployment-config change. Application code does not change.

21Phase 1 / Phase 2 roadmap

Phase 1 — v0.1 target: Q3 2026

  • Single KMM module with all five source sets populated.
  • Android + iOS client: permissions, channels, conversation shortcuts, token acquisition, local notifications, receive + respond, typed interaction bridge.
  • D1-backed endpoint store, record store, idempotency table, interactions table, receipts table, attempts table.
  • Durable Object intake (jsMain) with outbox pattern.
  • Fanout ServiceNode (jsMain) with policy engine (quiet hours, frequency caps, category mutes).
  • JVM FirebasePushProvider + FirebaseDeliveryWorker with Arrow retry + circuit breaker.
  • FCM error classifier.
  • Receipt worker for FCM.
  • GraphNotificationRouteDispatcher + NotificationsNode.
  • Six bus kinds declared; reaktor-bus wiring.
  • Endpoint invalidation on permanent failure.
  • Standard telemetry.

Phase 2 — v0.2 target: Q4 2026 — Q1 2027

  • APNs direct (Live Activities, critical alerts, push-to-talk, push types FCM does not expose).
  • Web Push provider (VAPID + service worker template + AES-GCM encryption).
  • Live Activities controller (ActivityKit wrapper).
  • Campaign engine: scheduled + event-triggered campaigns with A/B variants.
  • Segmentation queries over the endpoint store (attribute + event filters).
  • Frequency caps + quiet hours promoted from policy to campaign-level config.
  • Expo Push provider (optional, as an interchangeable PushProvider implementation).

Explicitly out of scope

  • Visual journey builder (a Reaktor Desktop feature, not a module concern).
  • Propensity scoring & best-time-to-send ML.
  • Multi-channel orchestration: reaktor-email and reaktor-sms are separate modules.
  • In-app inbox UI (lives in reaktor-tactile if it ships at all).
  • Foreground-service notifications — owned by the specific service (media player, call UI).

22Integration with the rest of Reaktor

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.

ModuleStatus todayHow reaktor-notifications uses it
reaktor-coreStableIdempotency contract; persisted attempt & record abstractions; typed envelope helpers.
reaktor-graphStableNotificationsNode is a graph node; lifecycle & DI scope flow naturally. Kernel facets (ServiceFacet, RouteFacet, ActionFacet) tag every concern.
reaktor-graph-portPartialServiceNode handlers with ServiceEndpoint.pubSub; ConsumerPort / ProviderPort wiring. Port kernel exists; transport bindings vary by target.
reaktor-busAspirationalEvery async topic. Module assumes outbox, retry, DLQ, envelope with correlationId, W3C trace context. Blocks intake → fanout coordination.
reaktor-capabilityVocabulary onlyCapabilityProfile for PushNotification / LocalNotification with tiers: Full (remote + local) · Basic (local only) · Fallback (suppressed). Strategy selector adapter layer still to build.
reaktor-dbPartialD1 (Worker) and Postgres (Spring) repository adapters for the six tables; outbox primitive. Repos exist for some adapters; multi-target story incomplete.
reaktor-authPartial — see auth-analysisTenant + user identity on intent construction; permission gating on hosted API. Spring middleware partial (DefaultSecurityConfig permits all); JWKS endpoint on Workers missing.
reaktor-telemetryAspirationalStructured trace emission through the delivery pipeline; correlationId across kinds. Schema and collection layer undefined today.
reaktor-tactilePartialNotificationsService action refs available to generated UI via TactileActionRegistry. Action registry exists; registry-as-ContractId boundary still firming up.
reaktor-graphifyAspirationalNotification subgraph appears in GRAPH_REPORT.md as its own Leiden community.
reaktor-shadowAspirationalNotification flows participate in recorded traces; replay covers the full intake-to-receipt chain.
reaktor-cloudflarePartialDO intake surface; D1 binding; CF Queues for the bus; per-tenant DB binding for hosted mode.
reaktor-devopsAspirationalDeploys the module across Worker, Spring, and D1 in one command; bus kinds registered in the CF and GCP control plane.
honest take Three dependencies are blocking a real Phase 1 ship: 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.

23Gap analysis & next steps

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 itemSource setStatusBlocks
1Domain types (Intent, Target, Content, Route, Endpoint, Record, DeliveryTask, ProviderReceipt, InteractionEvent)commonMainmissingeverything
2Service contracts (6 interfaces)commonMainmissingall platform impls
3Policy: DeliveryPolicy, FrequencyCap, QuietHours (pure functions)commonMainmissingfanout
4D1 schema migrations & D1Notification{Endpoint,Record}StorejsMainmissingintake, fanout
5DurableObjectIntake with outbox row + idempotencyjsMainmissingfanout
6FanoutService consuming notifications.fanout.requestedjsMainmissingdelivery
7FcmErrorClassifier & FirebasePushProviderjvmMainmissingdelivery worker
8FirebaseDeliveryWorker with Arrow Schedule + CircuitBreakerjvmMainmissingreceipts
9ReceiptWorker updating attempts and invalidating endpointsjvmMainmissingendpoint health
10AndroidChannelRegistry, FcmTokenProvider, ReaktorFirebaseMessagingServiceandroidMainmissingandroid receive
11AndroidNotificationRenderer, ConversationShortcutManager, AndroidNotificationInteractionBridgeandroidMainmissingandroid tap routing
12IosNotificationsClient, ApnsRegistrationProvider, AppleCategoryRegistry, IosNotificationInteractionBridgeiosMainmissingios receive + tap
13CommunicationNotificationBuilder (iOS 15+ sender avatar + Focus)iosMainmissingchat-app parity
14NotificationsNode + GraphNotificationRouteDispatchercommonMainmissinggraph integration
15Telemetry attribute set + Reaktor Desktop panelscommonMain + toolingmissingoperational sign-off
16Postgres adapters for Spring-first deploymentsjvmMainmissingembedded mode
17APNs direct provider, Web Push provider, Live ActivitiesjvmMain, jsMain, iosMainPhase 2
18Campaign scheduler, segmentation, A/B testingjvmMain, jsMainPhase 2engagement-platform parity

Critical-path recommendation

Week 1–2 · Domain & contracts

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.

Week 3–4 · Edge intake on D1

D1 schema, D1NotificationEndpointStore, D1NotificationStore, idempotency table, outbox relay, DurableObjectIntake. End state: a chat DO can call createNotificationInTransaction and observe a bus kind being published.

Week 5–7 · JVM delivery

FcmErrorClassifier, FirebasePushProvider, FirebaseDeliveryWorker with Arrow retry + circuit breaker, ReceiptWorker. End state: a published delivery task produces a real FCM push and a persisted receipt.

Week 8–10 · Android client

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.

Week 11–13 · iOS client

UNUserNotificationCenterDelegate impl, APNs registration, category registry, interaction bridge, communication notifications. End state: full parity with the Android client.

Week 14 · Telemetry & cutover

Wire reaktor-telemetry across every leg. Land Desktop panels. Cut Bestbuds chat mentions over to the new pipeline behind a feature flag.

first user Bestbuds is the obvious first consumer. 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.