Reaktor DocsPlatform servicesCodex

Platform services - Codex

Codex Reaktor Notifications Design

A full-stack design for turning today's thin reaktor-notification adapter placeholder into a graph-native notification runtime: permissions, endpoint registration, push delivery, local rendering, policy, receipts, typed graph routing, and optional hosted delivery.

Use whenDesigning or implementing the Reaktor notification module and BestBuds notification migration.
Current stateFramework module is a placeholder with one common adapter class.
Route/docs/codex-reaktor-notifications-design

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.

SurfaceCurrent implementationStatusDesign 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.
Current-state verdict: this is effectively a greenfield module with one important existing constraint: follow Reaktor's adapter, feature-slot, graph-node, service-handler, and source-set patterns instead of introducing a standalone notification SDK inside product code.

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.

Where Reaktor goes beyond Expo: Expo stops at a capable delivery primitive. Reaktor should own the typed notification workflow, user preference policy, graph routing, tenant isolation, observability, and deployment shape.

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 themeNotification design consequenceConcrete 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

PlaneNotification responsibility
Capability / environmentPlatform adapters expose permission state, token registration, local scheduling, channel/category registration, and receive/response listeners.
Graph / actionNotification taps become OpenPath or GraphAction events dispatched through the app graph and action registry.
Transport / messagingIntent, fanout, delivery, receipt, and interaction events move through typed bus kinds with correlation and causation ids.
Data / persistenceEndpoint registry, attempts, receipts, idempotency, and interactions are operational stores, not product business tables.
UI / interactionAndroid channels, iOS categories, direct replies, foreground presentation, and local notifications stay platform-native.
Intelligence / toolingWorkbench 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
SurfacePurposeOwner
Feature.NotificationsGlobal slot for platform/client notification adapter, matching Auth, File, Database, and PubSub patterns.commonMain
NotificationsClientClient-side permission, token, local schedule, receive, and response contract.commonMain with platform implementations
NotificationsServiceServer/intake API for endpoint registration, preferences, notification creation, reads, and interaction recording.commonMain
NotificationsNodeGraph integration point that installs listeners, registers endpoints after auth, and dispatches interactions.reaktor-graph integration
PushProviderProvider-neutral delivery abstraction with provider-specific batching, rate limiting, and error classification.jvmMain, jsMain
DeliveryPolicyPure policy function for category mutes, quiet hours, and frequency caps.commonMain
NotificationFacetGraph metadata for notification categories, required permissions, route targets, provider needs, and policy coverage.commonMain
NotificationCapabilityRuntime 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.

01IntentProduct emits typed notification intent with idempotency key.
02IntakeD1/Postgres record and outbox row are written atomically.
03FanoutTarget is expanded to active endpoints and policy is applied.
04DeliveryProvider worker sends, retries, and emits receipt events.
05InteractionClient tap or action becomes a graph route/action.
Bus kindProducerConsumerPayload
notifications.intent.acceptedIntakeTelemetry, optional dashboardsAccepted record metadata
notifications.fanout.requestedOutbox relayFanoutServiceFanoutRequest
notifications.delivery.taskFanoutServiceDeliveryWorkerDeliveryTask
notifications.delivery.receiptDeliveryWorker or Expo receipt pollerReceiptWorkerProviderReceipt
notifications.endpoint.invalidatedReceiptWorkerAnalytics, endpoint cleanupEndpoint id and reason
notifications.interaction.recordedClient/APIAnalytics, conversion funnelsNotificationInteractionEvent

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.

TablePurposeRetentionIndexes
notification_endpointsInstallation/provider token registry plus preferences and status.Until unregister or invalidation TTL.User lookup, status lookup, unique installation/provider.
notification_recordsOne row per accepted notification intent.30 to 180 days by tenant policy.Tenant/app/created, correlation id.
notification_attemptsEach endpoint delivery attempt, provider id, and error.Shorter retention, often 14 to 30 days.Notification id, endpoint id.
notification_receiptsAccepted, failed, delivered/opened/dismissed where available.Analytics window.Notification id, endpoint id.
notification_idempotencyMaps idempotency keys to notification ids.Expires on workflow TTL.Primary key.
notification_interactionsTap/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)
);
Format policy: use generated FlexCoders for rich internal workflow payloads, endpoint snapshots, graph/workbench documents, and cache-heavy notification inbox records. Keep JSON for provider APIs, tiny public payloads, webhook compatibility, and debugging surfaces where self-description matters more than hot-path speed.

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.

ProviderPhaseUse caseImportant behavior
FCMPhase 1Android delivery and initial iOS delivery through Firebase.Data and notification messages, token refresh, Admin SDK error classification, endpoint invalidation.
APNs directPhase 2iOS-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 PushBridgeTenants already using Expo/React Native or hosted mode with Expo push tokens.Ticket persistence, 15-minute receipt polling, batch limits, access-token support.
Web PushLaterBrowser 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
        }
    }
}
Reaktor differentiator: this connects notification interactions directly to the same action registry, graph lifecycle, route parsing, telemetry, and replay model used by the rest of the app.

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 tenantId and appId; 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-auth tenant 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 attributeWhy it matters
notification.correlation_idJoins original domain event, intake, delivery, and client interaction.
notification.providerSeparates FCM/APNs/Expo/Web Push behavior.
notification.delivery.latency_msTracks slow provider paths and queue backlog.
notification.error_codeDrives retry, endpoint invalidation, and operator alerts.
notification.policy.suppressedShows quiet-hours, mute, and frequency-cap effects.
notification.interaction_typeConverts 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

PhaseScopeExit criteria
0. Align moduleDecide 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 foundationAndroid/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 deliveryD1/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 depthAndroid 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 webAPNs 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 integrationNotification 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 layerScheduled campaigns, event-triggered campaigns, simple segments, A/B variants, dashboard panels.Basic retention campaign can be run without buying an external engagement platform.
Important sequencing: do not build campaign tooling before Phase 2 delivery and receipts are solid. A campaign engine without reliable endpoint hygiene and delivery telemetry will create product noise and operational debt.

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.

Generated May 26 2026 Target reaktorWeb public docs Module :reaktor-notification