1. Where We Are Today
The actual Reaktor framework module is named :reaktor-notification in Gradle. The strategic document and
product language often call the product surface reaktor-notifications; this design treats that plural name
as the product capability, while preserving the current module name unless the team chooses a rename before publication.
| Surface | Current implementation | Status | Design implication |
|---|---|---|---|
| Framework module | reaktor-notification/build.gradle.kts configures common, Android, Darwin, web, and server targets; it currently depends on :reaktor-ui and applies Koin through Dependeasy. |
Skeleton | Good KMP target shape exists; dependencies should become core, graph, IO, and provider-specific source-set dependencies. |
| Common source | NotificationAdapter<Controller> extends the core Adapter pattern and is empty. |
Placeholder | Keep the adapter bridge, but add domain models, client contracts, service contracts, and a Feature.Notifications slot. |
| README | Explicitly says the module is in brainstorming state and exists as a shared seam for future platform implementations. | Honest | The design can be implemented without compatibility debt because no public behavior has shipped. |
| BestBuds Cloudflare worker | Product-specific NotificationService has only a health route returning NotImplemented. |
Stub | This should migrate to Reaktor intake once the framework owns endpoint registration and notification creation. |
| BestBuds Android/iOS | Android has an empty FirebaseMessagingService; iOS has delegate logging for foreground/tap and FCM token refresh. |
Platform probes | These are useful proof points, but should become Reaktor platform clients instead of product-local plumbing. |
2. What To Take From Expo Notifications
Expo is useful as a product-shape reference because it separates the client library from the push relay service. The client requests permissions, gets a project-scoped push token, receives and responds to notifications, and can schedule local notifications. The service accepts server requests, forwards them to FCM/APNs, and reports tickets and receipts.
Client API shape
Match the ergonomics: permission state, request permission, token acquisition, token rollover listener, received listener, response listener, local scheduling, categories/actions, and channel/category registration.
Push relay shape
Keep a relay interface like Expo Push Service, but make it one provider among several. Reaktor should support direct FCM/APNs for platform depth and an optional Expo provider for React Native/Expo tenants or early hosted mode.
Receipts are mandatory
Expo's ticket/receipt flow is the right operating model: accepted by relay is not delivered. Reaktor should persist provider attempts and receipts, invalidate dead endpoints, and expose retry/DLQ state.
Limits become contracts
Expo documents concrete limits such as 100 notifications per request, 1000 receipts per request, and 600 notifications per second per project. Reaktor should express provider limits as typed rate-limit policies.
3. Requirements And Boundaries
Must have
Endpoint registration, token refresh, local notifications, foreground handling, tap/action handling, FCM delivery, durable attempts, receipts, endpoint invalidation, typed graph routes, and telemetry.
Should have
Android channels/conversation support, iOS categories, quiet hours, category mutes, frequency caps, Cloudflare D1 stores, hosted and embedded deployment, and Expo provider compatibility.
Later
APNs direct for Live Activities and critical iOS features, Web Push, campaign sequences, segmentation, A/B testing, in-app inbox, and multi-channel orchestration through separate modules.
Non-goals
- No visual journey builder in the first release.
- No attempt to hide platform affordances behind a lowest-common-denominator API.
- No foreground-service notification ownership; those belong to media, call, navigation, and long-running work modules.
- No product-specific notification worker in BestBuds once the Reaktor service exists.
4. Alignment With Existing Reaktor Docs
The existing Reaktor docs frame the framework as a composition kernel: graph structure is the runtime source of truth, modules contribute typed capabilities and contracts, visitors interpret the graph, commands mutate it safely, and auth/security policy is part of the runtime context. Notifications should follow that model directly.
| Existing doc theme | Notification design consequence | Concrete artifact |
|---|---|---|
| Graph kernel | Notifications are not an external SDK bolted onto products. They are graph-visible capabilities, facets, ports, and routes. | NotificationCapability, NotificationFacet, NotificationRouteFacet, NotificationsNode |
| Visitor system | Validation, documentation, endpoint health, policy coverage, and workbench export should be graph passes with deterministic diagnostics. | NotificationCoveragePass, NotificationPolicyPass, NotificationFlowDocumentPass |
| Actor model | Fanout, delivery, receipt polling, retry state, and campaign sequences are serialized workflows with one owner and ordered mutation. | NotificationActorKey, DeliveryActor, Durable Object adapter for edge deployments |
| Auth kernel | Every API receives an AuthContext; delivery workers use service principals rather than fake users. |
AuthRequirement on endpoint registration, intake, preference update, and receipt replay |
| Security plan | Encrypted conversation notifications must not create a plaintext server-side bypass around MLS-protected content. | Opaque route ids, minimized provider payloads, optional iOS service extension display decrypt |
| FlexBuffer runtime | Internal rich payloads can use generated FlexCoders and accessors; tiny public/provider payloads can stay JSON. | Format policy on bus envelopes, records, graph documents, and provider translators |
Runtime planes
| Plane | Notification responsibility |
|---|---|
| Capability / environment | Platform adapters expose permission state, token registration, local scheduling, channel/category registration, and receive/response listeners. |
| Graph / action | Notification taps become OpenPath or GraphAction events dispatched through the app graph and action registry. |
| Transport / messaging | Intent, fanout, delivery, receipt, and interaction events move through typed bus kinds with correlation and causation ids. |
| Data / persistence | Endpoint registry, attempts, receipts, idempotency, and interactions are operational stores, not product business tables. |
| UI / interaction | Android channels, iOS categories, direct replies, foreground presentation, and local notifications stay platform-native. |
| Intelligence / tooling | Workbench panels inspect notification topology, policy gaps, DLQ depth, endpoint hygiene, and conversion analytics through graph documents. |
Module contribution contract
reaktor-notification should contribute facets for notification intents, endpoints, policy, providers,
categories, routes, and interactions. These facets make routes and nodes inspectable without pushing notification
semantics into graph core.
Command surface
Workbench and agents should mutate notification configuration through commands such as
RegisterNotificationCategory, UpdateNotificationPolicy, ReplayReceipt, and
InvalidateEndpoint, with validations returning findings before patches are applied.
5. Target Architecture
The key design choice is to model notifications as a typed workflow rather than as a push-send helper. A product
service creates a NotificationIntent. Reaktor persists it, fans it out to endpoints, applies policy, delivers
through a provider, records receipts, and turns client interactions into graph actions.
Product service or graph node
|
| NotificationIntent
v
NotificationsService intake
|-- idempotency check
|-- persist NotificationRecord
|-- publish notifications.fanout.requested
v
FanoutService
|-- resolve target to endpoints
|-- apply quiet hours, category mutes, frequency caps
|-- publish notifications.delivery.task
v
DeliveryWorker
|-- select provider: FCM, APNS_DIRECT, WEB_PUSH, EXPO
|-- retry/circuit-breaker
|-- publish notifications.delivery.receipt
v
ReceiptWorker
|-- update attempts and receipts
|-- invalidate dead endpoints
v
Client NotificationsNode
|-- received listener
|-- response listener
|-- NotificationInteractionEvent -> route or graph action| Surface | Purpose | Owner |
|---|---|---|
Feature.Notifications | Global slot for platform/client notification adapter, matching Auth, File, Database, and PubSub patterns. | commonMain |
NotificationsClient | Client-side permission, token, local schedule, receive, and response contract. | commonMain with platform implementations |
NotificationsService | Server/intake API for endpoint registration, preferences, notification creation, reads, and interaction recording. | commonMain |
NotificationsNode | Graph integration point that installs listeners, registers endpoints after auth, and dispatches interactions. | reaktor-graph integration |
PushProvider | Provider-neutral delivery abstraction with provider-specific batching, rate limiting, and error classification. | jvmMain, jsMain |
DeliveryPolicy | Pure policy function for category mutes, quiet hours, and frequency caps. | commonMain |
NotificationFacet | Graph metadata for notification categories, required permissions, route targets, provider needs, and policy coverage. | commonMain |
NotificationCapability | Runtime capability descriptor for unavailable, local-only, remote push, direct reply, rich media, and critical alert support. | commonMain with platform detection |
6. Common Domain Model
Domain classes stay semantic and provider-neutral. FCM, APNs, Web Push, and Expo-specific fields belong in provider translators, not in the product-facing intent.
@Serializable
data class NotificationIntent(
val tenantId: String,
val appId: String,
val type: String,
val target: NotificationTarget,
val content: NotificationContent,
val route: NotificationRoute,
val priority: NotificationPriority = NotificationPriority.Normal,
val category: String? = null,
val scheduledAt: Instant? = null,
val idempotencyKey: String? = null,
val correlationId: String? = null,
)
@Serializable
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
}
@Serializable
data class NotificationContent(
val title: String,
val subtitle: String? = null,
val body: String,
val imageUrl: String? = null,
val categoryId: String? = null,
val threadId: String? = null,
val person: NotificationPerson? = null,
val badge: Int? = null,
val sound: NotificationSound = NotificationSound.Default,
val interruptionLevel: InterruptionLevel = InterruptionLevel.Active,
val data: Map<String, String> = emptyMap(),
)
@Serializable
sealed class NotificationRoute {
data class OpenPath(val path: String) : NotificationRoute()
data class GraphAction(val type: String, val payloadJson: String) : NotificationRoute()
data object None : NotificationRoute()
}
7. Typed Workflow And Bus Kinds
Every asynchronous hop should be explicit, idempotent, and observable. The notification pipeline uses Reaktor service handlers and bus envelopes rather than ad hoc queues.
| Bus kind | Producer | Consumer | Payload |
|---|---|---|---|
notifications.intent.accepted | Intake | Telemetry, optional dashboards | Accepted record metadata |
notifications.fanout.requested | Outbox relay | FanoutService | FanoutRequest |
notifications.delivery.task | FanoutService | DeliveryWorker | DeliveryTask |
notifications.delivery.receipt | DeliveryWorker or Expo receipt poller | ReceiptWorker | ProviderReceipt |
notifications.endpoint.invalidated | ReceiptWorker | Analytics, endpoint cleanup | Endpoint id and reason |
notifications.interaction.recorded | Client/API | Analytics, conversion funnels | NotificationInteractionEvent |
8. Operational Data Store
Notification truth is operational truth, not product business truth. D1 is the default for Cloudflare edge deployments; Postgres is the default for Spring-first deployments.
| Table | Purpose | Retention | Indexes |
|---|---|---|---|
notification_endpoints | Installation/provider token registry plus preferences and status. | Until unregister or invalidation TTL. | User lookup, status lookup, unique installation/provider. |
notification_records | One row per accepted notification intent. | 30 to 180 days by tenant policy. | Tenant/app/created, correlation id. |
notification_attempts | Each endpoint delivery attempt, provider id, and error. | Shorter retention, often 14 to 30 days. | Notification id, endpoint id. |
notification_receipts | Accepted, failed, delivered/opened/dismissed where available. | Analytics window. | Notification id, endpoint id. |
notification_idempotency | Maps idempotency keys to notification ids. | Expires on workflow TTL. | Primary key. |
notification_interactions | Tap/action/open records used for graph routing and conversion analytics. | Analytics window. | 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)
);
9. Module Layout And Source Sets
Keep the module as one KMP package. Platform-specific implementation belongs in source sets; orchestration contracts stay in commonMain.
reaktor-notification/
src/commonMain/kotlin/dev/shibasis/reaktor/notification/
domain/ NotificationIntent, Target, Content, Route, Endpoint
client/ NotificationsClient, Permission, Listeners
service/ NotificationsService, Orchestrator, Stores
policy/ DeliveryPolicy, QuietHours, FrequencyCap
provider/ PushProvider, ProviderErrorClassifier
graph/ NotificationsNode, GraphNotificationRouteDispatcher
src/androidMain/
AndroidNotificationsClient, FcmTokenProvider, AndroidChannelRegistry
AndroidNotificationRenderer, ReaktorFirebaseMessagingService
src/iosMain/
IosNotificationsClient, ApnsRegistrationProvider, AppleCategoryRegistry
IosNotificationInteractionBridge, CommunicationNotificationBuilder
src/jsMain/
DurableObjectIntake, D1NotificationStore, FanoutService, WebPushProvider
src/jvmMain/
FirebasePushProvider, ApnsDirectPushProvider, FirebaseDeliveryWorker
PostgresNotificationStore, ReceiptWorker
First API cut
var Feature.Notifications by CreateSlot<NotificationsClient>()
interface NotificationsClient {
suspend fun getPermissions(): NotificationPermissionStatus
suspend fun requestPermissions(options: NotificationPermissionOptions): NotificationPermissionStatus
suspend fun getDeviceToken(): DevicePushToken?
suspend fun registerRemoteEndpoint(userId: String?): RegisterEndpointResult
suspend fun scheduleLocal(request: LocalNotificationRequest): LocalNotificationId
fun addReceivedListener(listener: suspend (NotificationEnvelope) -> Unit): ListenerHandle
fun addResponseListener(listener: suspend (NotificationResponseEvent) -> Unit): ListenerHandle
}
enum class NotificationCapabilityLevel {
Unavailable,
LocalOnly,
RemotePush,
RemotePushWithActions,
RemotePushWithRichMedia,
}
10. Provider Strategy
Provider support should be explicit. The product-facing intent is stable, while providers translate it into FCM, APNs, Web Push, or Expo-specific requests and classify errors into common retry decisions.
| Provider | Phase | Use case | Important behavior |
|---|---|---|---|
| FCM | Phase 1 | Android delivery and initial iOS delivery through Firebase. | Data and notification messages, token refresh, Admin SDK error classification, endpoint invalidation. |
| APNs direct | Phase 2 | iOS-specific features not fully covered by FCM: Live Activities, critical alerts, direct APNs headers. | HTTP/2 provider auth, push type, collapse id, expiration, environment separation. |
| Expo Push | Bridge | Tenants already using Expo/React Native or hosted mode with Expo push tokens. | Ticket persistence, 15-minute receipt polling, batch limits, access-token support. |
| Web Push | Later | Browser and PWA notifications. | VAPID keys, service workers, encrypted payloads, subscription rotation. |
sealed class RetryDecision {
data class Retryable(val delay: Duration? = null) : RetryDecision()
data class InvalidateEndpoint(val reason: String) : RetryDecision()
data object Terminal : RetryDecision()
}
interface ProviderErrorClassifier {
fun classify(error: Throwable): RetryDecision
}
11. Typed Routing Into The Graph
A notification tap is a graph event, not a deep-link string. The route is serialized into the provider payload and restored into NotificationInteractionEvent when the app receives a tap, action button, direct reply, or foreground response.
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 -> graph.dispatchPath(route.path)
is NotificationRoute.GraphAction -> {
val action = ActionRef(route.type, payloadSchema = route.type)
actionRegistry.dispatch(action, TactileValue.fromJson(route.payloadJson))
}
NotificationRoute.None -> Unit
}
}
}
12. Policy Engine
Policy runs during fanout before provider delivery. It is a pure function of intent, endpoint, and time so it can be unit tested without Cloudflare, Firebase, APNs, or a device.
Category mutes
Per-category settings such as messages, campaigns, social updates, product alerts, and marketing.
Quiet hours
Endpoint time-zone aware suppression, with priority overrides for urgent or time-sensitive intents.
Frequency caps
Tenant and category caps over a rolling window, backed by the attempts/receipts store.
interface DeliveryPolicy {
fun permits(intent: NotificationIntent, endpoint: NotificationEndpoint, now: Instant): PolicyDecision
}
sealed class PolicyDecision {
data object Send : PolicyDecision()
data class Suppress(val reason: String) : PolicyDecision()
data class DelayUntil(val at: Instant, val reason: String) : PolicyDecision()
}
13. Security And Privacy
- Tenant isolation: all records and endpoints carry
tenantIdandappId; hosted mode should use per-tenant D1 bindings or strict tenant partitioning. - Credential isolation: FCM service accounts, APNs keys, Expo access tokens, and VAPID keys are tenant secrets, never product payload fields.
- Payload minimization: provider payloads should carry display text plus opaque ids. Sensitive content should be fetched after graph route resolution or decrypted by an iOS service extension where needed.
- Auth integration: hosted APIs require
reaktor-authtenant API keys or service credentials. Product-local embedded mode still verifies user and app context before registering endpoints. - Service principals: intake, fanout, delivery, receipt polling, and DLQ replay run as service principals with explicit scopes, not as synthetic users.
- E2EE boundary: for MLS-backed conversations, provider payloads should carry opaque conversation/message ids and safe preview policy. The server should not gain a plaintext decrypt path; rich display can be delegated to a platform service extension when the product requires it.
- Replay safety: idempotency keys are required for domain-triggered sends such as chat mentions and payment events.
14. Observability
Notifications should never be a black box. Every accepted intent, fanout decision, provider attempt, receipt, endpoint invalidation, and interaction should emit telemetry with shared correlation ids.
| Metric or span attribute | Why it matters |
|---|---|
notification.correlation_id | Joins original domain event, intake, delivery, and client interaction. |
notification.provider | Separates FCM/APNs/Expo/Web Push behavior. |
notification.delivery.latency_ms | Tracks slow provider paths and queue backlog. |
notification.error_code | Drives retry, endpoint invalidation, and operator alerts. |
notification.policy.suppressed | Shows quiet-hours, mute, and frequency-cap effects. |
notification.interaction_type | Converts delivery into product analytics: tap, direct reply, dismiss, action. |
Workbench views
Topology
Show which graph routes declare notification entry points, categories, required permissions, and provider capabilities.
Operations
Track endpoint health, fanout backlog, retry counts, provider circuit state, DLQ depth, and invalidation reasons.
Product impact
Join interactions to graph actions so delivery, open, dismiss, direct reply, and conversion can be inspected from the same trace.
15. Implementation Roadmap
| Phase | Scope | Exit criteria |
|---|---|---|
| 0. Align module | Decide naming, add Feature.Notifications, add common domain/client/service contracts, facets, capability descriptors, validators, and README. | Module compiles across current targets with no product dependency. |
| 1. Client foundation | Android/iOS permission checks, token acquisition, token refresh listeners, local notifications, response listeners, endpoint registration contract. | BestBuds can register endpoints and route a local notification tap through the graph. |
| 2. FCM delivery | D1/Postgres stores, AuthContext-aware intake service, actor-backed fanout, JVM Firebase provider, attempts, receipts, FCM classifier, endpoint invalidation. | Cloudflare intake can send a notification to a real Android device through FCM with persisted receipt state. |
| 3. Platform depth | Android channels/conversations/direct reply, iOS categories/actions, rich media, foreground behavior, optional Expo provider. | Chat mention notification renders natively and tap/direct-reply interactions enter the app graph. |
| 4. APNs and web | APNs direct provider, Web Push provider, service extension helpers, Live Activity controller behind feature flag. | Provider selection works per endpoint and per notification capability. |
| 5. Workbench integration | Notification graph passes, policy diagnostics, endpoint health panels, DLQ replay commands, and trace-to-action navigation. | Operators can inspect why a notification was sent, suppressed, retried, invalidated, or converted. |
| 6. Engagement layer | Scheduled campaigns, event-triggered campaigns, simple segments, A/B variants, dashboard panels. | Basic retention campaign can be run without buying an external engagement platform. |
16. References Used
This design was produced from the local Reaktor strategic assessment document, the current repository implementation, the existing reaktorWeb docs, and current official documentation for Expo, Firebase, Android, and Apple notification primitives.
- Graph Kernel Analysis
- Reaktor Graph Visitors Design
- Reaktor Actor Model
- Reaktor Implementation Plan
- Auth Architecture and Redesign
- Security Implementation Plan
- FlexBuffer Binary Serialization
- React x Compose Blueprint
- Expo push notifications setup
- Send notifications with the Expo Push Service
- Expo Notifications SDK
- Expo notification types and behaviors
- Firebase Cloud Messaging message types
- Firebase Cloud Messaging error codes
- Android notification runtime permission
- Android NotificationChannel API reference
- Apple UNUserNotificationCenter
- Apple APNs provider API