reaktor-graph Refinement Plan

Sharpening the graph framework toward an expressive, layered, React/Actor-grade runtime.

Premise. reaktor-graph is the most mature module in Reaktor and powers BestBuds in production. The skeleton — a sealed Node hierarchy over a typed port-graph with lifecycle, DI, navigation, and services — is sound. What the BestBuds codebase reveals is that everyday feature work spends most of its lines on ceremony: re-declaring RouteBindings, chaining .apply { exposePort(...) }, hand-rolling MutableStateFlow fields, and overriding activateGraphForRoute just to keep a tab selector in sync.

Goal of this document. Identify the precise places where the framework is under-abstracted (not over-abstracted) and propose a layered set of refinements that pull these patterns into the framework — without flattening into a one-shot mega-DSL. We want a runtime where the 80% case is one line and the 20% case is still wide open.

65
Kotlin files in reaktor-graph
6
Concrete node types today
~3:1
Ceremony : business logic
12
Proposed primitives
Table of Contents
1. Principles 2. Snapshot — What Already Works 3. Diagnosis — Where Expressiveness Leaks 4. Mental Model — Five Layers 5. Layer 0 — Atomic Primitives 6. Layer 1 — Port Shapes 7. Layer 2 — Reactive Nodes (Hooks & Behaviors) 8. Layer 3 — Routing & Container Idioms 9. Layer 4 — Module Composition & Supervision 10. End-to-end Walkthrough — ChatGraph Re-imagined 11. Migration — Additive Path 12. Non-Goals 13. Open Questions

1. Principles

Every refinement in this document is judged against these four constraints. When they conflict, the order is the tiebreaker: an expressive primitive beats a simple one; a simple primitive beats a clever one; a clever one-shot API beats nothing only when it is also expressive.

01

Expressiveness >> Simplicity >>>> Bloated one-shot APIs

Primitives must compose. A @Screen("/chat/:id") annotation that owns state, navigation, and IO is fast to type and impossible to bend. We prefer five thin orthogonal primitives that can be combined.

02

React & Actor models as inspiration

Two field-proven mental models: functions of state with declarative effects (React), and autonomous units exchanging messages with supervision (Akka/Erlang). Reaktor already has both ingredients half-formed; this plan finishes them.

03

Atomic design, layered abstractions

Primitive → molecule → organism → template → system. Every layer is usable on its own. You can drop down a layer for power or up a layer for ergonomics — without rewriting.

04

Easy for 80% — possible for 20%

A vanilla CRUD screen should be one DSL block. A custom render pipeline with shared mailbox routing should still be expressible, even if it takes more lines. Never force developers into the DSL.

What this rules out. Annotation processors that own the entire feature ("@Feature" macros), reflection-based dependency injection at runtime, and any DSL that hides the underlying Graph/Node/Port identities. The DSL is sugar on top of types we can still call directly.

2. Snapshot — What Already Works

Before proposing changes, lock in what we keep. The runtime has earned the following load-bearing decisions:

DecisionWhere it livesWhy we keep it
Graph as the runtime scope (DI + lifecycle + coroutines + navigation + ports in one object) core/Graph.kt One place to attach, one place to tear down. Parent-child nesting cleanly models tabs/containers.
Typed Port graph (ProviderPort<T>, ConsumerPort<T>, indexed by Type + Key) reaktor-graph-port/port/* Allows multiple ports of the same type per node; underpins autoWire().
Sealed lifecycle state machine with cascading transitions capabilities/LifecycleCapability.kt Predictable, debuggable. Actors hook in cleanly (Restoring/Attaching → loop start).
Cross-graph navigation that bubbles to the container owner Graph.handleCrossGraphForward The right separation between in-tab and across-tab navigation; back-stacks stay layered.
ActorNode<M> with mailbox + supervision + persistent ObjectStore core/node/ActorNode.kt Akka-grade primitive: sequential receive, ask with timeouts, restart/escalate directives, stable UUIDs by name.
ServiceNode — HTTP handlers as provider ports service/Service.kt One model spans client and server. Swap an implementation without touching consumers.
Visitor + Selector + Traverser triplet for graph traversal visitor/*, portgraph/visitor/* Decouples policy (DFS/BFS) from concern (introspection/serialization).

The skeleton holds. Everything below adds tissue; nothing replaces a bone.

3. Diagnosis — Where Expressiveness Leaks

Findings from a sweep of bestbuds/modules/app and bestbuds/modules/engine, cross-referenced with the framework source.

4. Mental Model — Five Layers

The refinements organize into five concentric layers. Each one is small and orthogonal; each can be adopted independently. Lower layers know nothing about higher ones.

┌───────────────────────────────────────────────────────────────────────┐ │ L4 System graphs ↔ graphs, supervision, deep links │ │ ┌─────────────────────────────────────────────────────────────────┐ │ │ │ L3 Templates route tables, containers, modules │ │ │ │ ┌───────────────────────────────────────────────────────────┐ │ │ │ │ │ L2 Organisms hook-style nodes, behavior actors, │ │ │ │ │ │ reducer controllers │ │ │ │ │ │ ┌─────────────────────────────────────────────────────┐ │ │ │ │ │ │ │ L1 Port Shapes StatePort, StreamPort, │ │ │ │ │ │ │ │ RequestPort, EventPort, │ │ │ │ │ │ │ │ EffectPort │ │ │ │ │ │ │ │ ┌───────────────────────────────────────────────┐ │ │ │ │ │ │ │ │ │ L0 Atoms Type, Key, Port, Message, │ │ │ │ │ │ │ │ │ │ Behavior, Lifecycle │ │ │ │ │ │ │ │ │ └───────────────────────────────────────────────┘ │ │ │ │ │ │ │ └─────────────────────────────────────────────────────┘ │ │ │ │ │ └───────────────────────────────────────────────────────────┘ │ │ │ └─────────────────────────────────────────────────────────────────┘ │ └───────────────────────────────────────────────────────────────────────┘
How to read this. A screen author lives at L2-L3. A subsystem author lives at L1-L2. A framework author lives at L0-L1. The point is that none of them should have to step outside their layer for the common case — but all paths down are open.

⦿ 5. Layer 0 — Atomic Primitives

These are the cells the higher layers are built from. Each one is independently testable and ~50–200 LOC.

L0.1
Strengthen Type to carry full KType
Fix D7. Generic-aware type identity at the port boundary.

Today Type stores a string and an optional KClass. Two ports of List<String> and List<Int> collide. Switch to kotlin.reflect.typeOf<T>() (multiplatform since 1.6) and use structural equality.

Before
inline fun<reified T> Type() = create(T::class)
// Type<List<String>>() ≡ Type<List<Int>>()
After
inline fun<reified T> Type() = Type(typeOf<T>())

data class Type(val kType: KType) {
    val raw: KClass<*> get() = kType.classifier as KClass<*>
}
// Type<List<String>>() ≠ Type<List<Int>>() ✓

Side effect: autoWire can now compute structural compatibility (e.g. wire List<Message> consumer to MutableList<Message> provider if you opt in to variance).

L0.2
Lift connect() out of the Edge constructor
Fix D11. Transactional, two-phase wiring.

Edges should be immutable values. A separate Wiring object accumulates pending connections, validates the lot, then commits atomically — firing port-lifecycle events only after the commit succeeds.

class Wiring(private val graph: Graph) {
    private val pending = mutableListOf<Pair<ConsumerPort<*>, ProviderPort<*>>>()
    fun<T> add(c: ConsumerPort<T>, p: ProviderPort<T>): Wiring = apply { pending += c to p }
    fun commit(): Result<List<Edge>> { /* validate all, then apply */ }
}

// usage
graph.wire {
    chatActor.operations to operationsInteractor
    chatActor.timeline   to timelineInteractor
}.commit().getOrThrow()

This unlocks dry-run validation, partial rollback, and structured error reporting — a prerequisite for the Layer-4 module-merging story.

L0.3
Promote Lifecycle hooks to suspend
Fix D5. Async setup/teardown without leaking runBlocking.

Today onTransition is synchronous, which forces nodes that need async setup to schedule it on coroutineScope and lose ordering. Add a parallel suspend fun onTransitionAsync(prev, next) hook driven through graph.coroutineScope so the next transition waits.

interface LifecycleAware {
    fun onTransition(prev: Lifecycle, next: Lifecycle) {}        // sync, optional
    suspend fun onTransitionAsync(prev: Lifecycle, next: Lifecycle) {} // async, optional
}

Graph then awaits all onTransitionAsync calls before advancing to the next state.

L0.4
Introduce a Behavior type for actor receive logic
Inspiration: Akka Behaviors (Typed). Replaces the unbounded sealed-class × when pattern.

An ActorNode's logic is "given a message, do something, and possibly become a new behavior." Today this is implicit in the receive(msg) method, with sealed-class dispatch and mutable fields. Make it explicit:

sealed interface Behavior<M : Any> {
    object Same : Behavior<Nothing>
    object Stopped : Behavior<Nothing>
    class Receive<M : Any>(val fn: suspend ActorContext<M>.(M) -> Behavior<M>) : Behavior<M>
    class Setup<M : Any>(val fn: suspend ActorContext<M>.() -> Behavior<M>) : Behavior<M>
}

val chatActor = behavior<ChatMsg> {
    receive { msg -> when(msg) {
        is ChatMsg.Open  -> { ops.load(msg.id); become(open(msg.id)) }
        is ChatMsg.Close -> Behavior.Stopped
        else             -> Behavior.Same
    }}
}

The existing ActorNode stays; behavior {} compiles to an ActorNode whose receive just dispatches to the current Behavior. Existing subclasses keep working untouched.

6. Layer 1 — Port Shapes

Today every port is a value provider. Consumers call into the value directly. This works for repositories and interactors, but everywhere else the team rebuilds Flow/Channel/Deferred plumbing on the call site. Five port shapes cover >95% of real usage.

ShapeUnderlyingConsumer readsUse case
ValuePort<T> Direct reference (current behavior) port.impl Repositories, services, singletons.
StatePort<S> StateFlow<S> port.value, port.flow Replaces hand-rolled StateFlow in interactors (D4).
StreamPort<E> Flow<E> or SharedFlow<E> port.collect { } Event buses, telemetry feeds, presence ticks (D12).
RequestPort<In,Out> suspend (In) -> Out with retry/timeout policy port(input) Service calls, single-shot RPCs; replaces ad-hoc handler classes.
MailboxPort<M> ActorRef<M> port.send(msg), port.ask { } How sibling nodes talk to an actor; replaces by consumes<MyActor>() (D3).

Port-shape declaration DSL

The by provides<T>()/by consumes<T>() delegates stay for the ValuePort case; new delegates layer in next to them.

class ChatSessionInteractor(graph: Graph) : BasicNode(graph) {

    // L1 — declarative state, single line
    val chatState   by state<Data<Chat>>(Data.Loading)
    val messages    by state<List<ChatMessage>>(emptyList())
    val presence    by state<Map<String, ChatPresence>>(emptyMap())

    // L1 — declarative downstream event channel
    val typingEvents by stream<TypingEvent>()

    // L1 — declarative request endpoint
    val sendMessage  by request<DraftMessage, SentMessage>(retry = backoff(3))

    // L1 — declarative inbound mailbox; auto-wired by actor key
    val chatActor   by mailbox(chatActorKey)
}
Why this matters for D2 and D4. The interactor above is its own provider and its own consumer. The by state / by stream delegates register provider ports automatically, indexed under the property name. No more provides<ChatSessionInteractor>(this) + exposePort chain — the node's surface is its property list, period.

7. Layer 2 — Reactive Nodes (Hooks & Behaviors)

With Layer-1 port shapes in place we can express two of the most useful organism patterns: React-style hook nodes and Akka-style behavior actors. Both compile down to existing concrete Node subclasses; both are entirely optional.

7.1 Hook nodes — React-flavored controllers

React's reducer pattern (useReducer + useEffect + useEvent) cleanly separates what state we have from what events change it and what side effects happen. Today every interactor reimplements this poorly. Wrap it once:

val chatController = graph.controller<ChatState, ChatEvent>(
    initial = ChatState.Loading,
    label = "chat",
) {

    // reducer: events ➜ next state
    reduce { state, event -> when(event) {
        is ChatEvent.Loaded   -> ChatState.Ready(event.chat, event.messages)
        is ChatEvent.Typed    -> (state as? ChatState.Ready)?.addMessage(event.msg) ?: state
        is ChatEvent.Disposed -> ChatState.Loading
    }}

    // effect: state transitions trigger IO
    effect(on = { it.chatId }) { state ->
        if (state is ChatState.Loading)
            sendMessage(DraftMessage(...))   // RequestPort call
    }

    // derived selector: re-derive only on dependency change
    val unreadCount = derived { state -> state.messages.count { !it.read } }

    // lifecycle: bound to node Attaching/Saving
    onEnter { chatActor.send(ChatMsg.Open(payload.chatId)) }
    onLeave { chatActor.send(ChatMsg.Close) }
}

What we get:

The result is a ControllerNode<State> with a StatePort<State> already exposed — readable from Compose, from sibling nodes, and from tests, without any new glue.

7.2 Behavior actors — Akka Typed-flavored mailboxes

Inspired by L0.4, this is the high-level surface for the same primitive:

val chatActor = graph.actor<ChatMsg>(key = chatActorKey) {

    setup {
        // preStart equivalent — runs before first message
        operations.connect()
    }

    receive<ChatMsg.Open> { msg ->
        chatState.update { ChatState.Loading }
        val chat = sendMessage(DraftMessage(...))
        chatState.update { ChatState.Ready(chat) }
    }

    receive<ChatMsg.Close> {
        Behavior.Stopped
    }

    supervise {
        on<NetworkError> { ActorDirective.Restart }
        default            { ActorDirective.Escalate }
    }

    teardown { operations.disconnect() }
}

Three wins:

7.3 exposed { } — one place declares the node's public surface

The current exposePort() chain (D2) is the worst offender for ceremony. Replace with a node-local exposed { } block, evaluated once at attach time, that registers ports into the parent graph's DI scope:

Before — BestBuds.kt:404-411
Node(::ConfigRepository).apply { exposePort(configRepository) }
Node(::StickerRepository).apply { exposePort(stickerRepository) }
Node(::SessionHydrationInteractor).apply {
    exposePort(sessionHydrationInteractor)
}
After
class ConfigRepository(g: Graph) : BasicNode(g) {
    val repo by state<Config>(Config.Default)
    exposed { +repo }   // one line, declarative
}

// parent graph:
Node(::ConfigRepository)
Node(::StickerRepository)
Node(::SessionHydrationInteractor)

8. Layer 3 — Routing & Container Idioms

8.1 Declarative route tables (D1, D9)

A route table co-locates pattern, payload type, screen, and guards. RouteBinding subclasses disappear — their only job was to carry edge references, which the table holds globally.

val routes = routes {

    route<ChatPayload>("/chats/{id}") {
        screen = ::ChatScreen
        controller = ::ChatController
        guard { auth.user.value != null }
        onEnter { actor.send(ChatMsg.Open(payload.chatId)) }
        onLeave { actor.send(ChatMsg.Close) }

        navigatesTo("/profile/{userId}")
        navigatesTo("/groups/{groupId}")
    }

    route<FriendProfilePayload>("/profile/{userId}") {
        screen = ::FriendProfileScreen
    }

    deeplink("bestbuds://chat/{id}") to "/chats/{id}"
}

graph.install(routes)

The routes DSL is just data — it compiles down to RouteNode instances plus a registry. The runtime can answer questions previously impossible:

8.2 Typed result returns (refines D8 for navigation)

Current back(result: R) erases R to Any. Make the return type part of the route declaration:

route<PickStickerPayload, PickedSticker>("/picker/sticker") {
    screen = ::StickerPickerScreen
}

// caller:
val picked: PickedSticker = routeBinding.pushForResult(stickerPickerEdge, ...)

// callee:
routeBinding.back(PickedSticker(id))   // type-checked at compile time

8.3 Container with reactive selection (D6)

Today every ContainerNode subclass mirrors the active child into its own MutableStateFlow. Push that into the base class:

abstract class ContainerNode(graph: Graph, ...) : Node(graph) {
    val selectedKey: StateFlow<String>     // driven by activateGraphForRoute
    val activeGraph: StateFlow<Graph?>     // derived
    fun select(key: String): Boolean       // reverse: programmatic switch
}

BottomNavigationContainer drops its 70-line glue and becomes:

class BottomNavigationContainer(g: Graph, p: String, c: Map<String, ChildGraph>)
    : ContainerNode(g, p, c.values.map { it.graph }) {
    val controller by provides<Controller>(Controller(selectedKey))
}

8.4 Modules — reusable subgraph contracts (D10)

The engine module's WorkbenchGraph re-exports thirty properties from a child shell graph. The framework should know about this pattern. A module is a subgraph plus a public contract:

val messagingModule = module {
    provides<MessagingApi>()
    provides<PresenceTracker>()
    consumes<AuthSession>()
    build { graph ->
        ServiceNode(graph, MessagingServiceClient(Servers.MessagingServer))
        Node(::PresenceTrackerNode)
    }
}

// inside a parent graph:
use(messagingModule)   // one line — wires consumes/provides automatically

Two things to note: (1) the contract is checked at commit time using the L0.2 wiring machinery — missing AuthSession → compile-time-quality error; (2) modules nest: a module can use(otherModule).

9. Layer 4 — Module Composition & Supervision

9.1 Supervision trees rooted in the graph hierarchy

actorSupervisorStrategy is currently a flat per-graph function. Promote it to a tree:

RootGraph ├── supervises: AlwaysRestart │ ├── ChatGraph │ ├── chatActor (mailbox) │ │ ├── on NetworkError → Restart (max 3 in 1m → Escalate) │ │ └── on default → Escalate │ └── messagingActor │ └── on default → inherit ChatGraph strategy │ └── ProfileGraph └── on default → Resume

Concretely: every ActorNode can declare its own supervise { } block (already in Layer 2.2). When a directive is Escalate, the failure walks up the graph parent chain until handled. This mirrors Erlang's supervisor hierarchies and gives operators a single tunable.

9.2 Cross-graph mesh — explicit named edges

The current cross-graph forward works but is invisible: NavigationEdge.isCrossGraph is a derived boolean and the bubble-up is unwritten. Make cross-graph edges a first-class concept:

val crossEdge: CrossGraphEdge<ChatPayload> = chatGraph.routes.chatRoute crosses rootGraph.routes.chatRoute

This gives us a registry the workbench/devtools can render and the runtime can validate at startup. Today, a typo in a target graph silently fails at runtime with a Logger.w.

9.3 Visitor with mutation — framework-level transforms

The visitor pattern is read-only. Add a MutatingVisitor that returns a GraphPatch instead of side-effecting. A devtools "rewire" command, a hot-reload swap, or a feature-flag-driven node swap all become structured operations that the wiring machinery (L0.2) can apply atomically.

10. End-to-end Walkthrough — ChatGraph Re-imagined

What does a real screen look like once all five layers exist? Here is BestBuds' ChatGraph (which today spans ~80 lines plus three separate RouteBinding classes plus a manually wired actor).

Today — verbatim shape (collapsed for space)
// BestBuds.kt:128-179
class ChatGraph(parent: Graph?) : Graph(parent, label = "Chat") {
    val chatRoute: RouteNode<ChatPayload, ChatBinding>
    val friendProfileRoute: RouteNode<FriendProfilePayload, FriendProfileBinding>
    val groupProfileRoute: RouteNode<GroupProfilePayload, GroupProfileBinding>

    init {
        Node(::MessageRepository)
        Node(::ChatSessionOperationsInteractor)
        Node(::ChatSessionStateInteractor)
        Node(::ChatTimelineInteractor)
        Node(::ChatAnalyticsInteractor)
        Node(::ChatSocketInteractor)
        Node(::ChatTypingInteractor)
        Node(::ChatActor)

        friendProfileRoute = Route("/profile/{userId}") { FriendProfileBinding() }
        groupProfileRoute  = Route("/groups/{groupId}") { GroupProfileBinding() }
        chatRoute = Route("/chats/{id}") {
            ChatBinding(
                friendProfileEdge = it.edge(friendProfileRoute),
                groupProfileEdge  = it.edge(groupProfileRoute),
            )
        }
        Node(::ChatScreen)
        Node(::FriendProfileScreen)
        Node(::GroupProfileScreen)

        autoWire()
        addRoot(chatRoute, Payload())
    }
}

// + ChatBinding (5 lines), FriendProfileBinding (3), GroupProfileBinding (3)
// + ChatActor with 6 manual `by consumes<T>()` declarations
// + ChatSessionStateInteractor with 6 manual MutableStateFlow fields
After refinement — same shape, ~40% fewer lines, more readable
val chatModule = module(label = "Chat") {

    // 1. Sub-nodes are normal — Layer 2 conventions remove boilerplate inside each.
    +MessageRepository()
    +ChatSessionOperationsInteractor()
    +ChatSessionStateInteractor()      // uses `by state` from L1
    +ChatTimelineInteractor()
    +ChatAnalyticsInteractor()
    +ChatSocketInteractor()
    +ChatTypingInteractor()

    // 2. The actor is a behavior block — no subclass, no consumes ceremony.
    actor<ChatMsg>(chatActorKey) {
        setup { operations.connect() }
        receive<ChatMsg.Open>  { msg -> state.update { Loading }; ops.load(msg.id) }
        receive<ChatMsg.Close> { Behavior.Stopped }
        supervise { on<NetworkError> { Restart } }
    }

    // 3. Routes are declarative; navigation targets are listed inline.
    routes {
        route<ChatPayload>("/chats/{id}") {
            screen = ::ChatScreen
            controller = ::ChatController
            onEnter { actor.send(ChatMsg.Open(payload.chatId)) }
            onLeave { actor.send(ChatMsg.Close) }
            navigatesTo("/profile/{userId}")
            navigatesTo("/groups/{groupId}")
        }
        route<FriendProfilePayload>("/profile/{userId}") { screen = ::FriendProfileScreen }
        route<GroupProfilePayload>("/groups/{groupId}")   { screen = ::GroupProfileScreen }
    }
}

// somewhere up in App.kt
use(chatModule)

What disappeared:

  • Three RouteBinding subclasses — gone. Route →route navigation is a declarative navigatesTo.
  • The hand-wired ChatBinding(friendProfileEdge = ..., groupProfileEdge = ...) constructor — gone. The route table knows.
  • Six by consumes<T>() declarations on ChatActor — gone. The actor is a behavior block scoped inside chatModule, so all sibling nodes are in scope.
  • Six MutableStateFlow declarations on the state interactor — gone. by state<T>(init).
  • LaunchedEffect/DisposableEffect in the Compose screen for open/close — gone. The route's onEnter/onLeave is the single source of truth.

What is still possible at lower layers:

  • A custom ContainerNode subclass with custom UI semantics? Still allowed — it just inherits the new selectedKey StateFlow for free.
  • A raw ProviderPort<T> with custom wiring policy? Still allowed — the by state/by stream delegates compile to the same primitive.
  • A bespoke ActorNode<M> subclass with custom receive? Still allowed — actor { receive<Open> { } } is just sugar.

11. Migration — Additive Path

Every layer is additive: nothing in this plan deletes or replaces an existing API. The migration story is one of opt-in, screen by screen.

PhaseScopeRiskVerification
P0 — L0 primitives Strengthen Type with KType; add Wiring alongside connect(); add onTransitionAsync. Low — old paths untouched. All existing tests pass; add specific KType-equality tests.
P1 — Port shapes Add state/stream/request/mailbox delegates next to provides/consumes. Low — new properties, no replacement. Convert one interactor (e.g. ChatSessionStateInteractor) and diff lines.
P2 — exposed { } Add the block; legacy exposePort stays callable. Low Convert BestBuds.kt:389-411; check that DI lookups still resolve.
P3 — Hook controllers Introduce controller<S, E> { reduce; effect; ... } as a thin wrapper around ControllerNode. Medium — touches UI integration. Migrate ChatScreen; visually verify navigation, state, lifecycle still work.
P4 — Behavior actors Introduce actor<M>(key) { receive; supervise; ... }. Medium — new code path through ActorNode. Migrate ChatActor; ensure ask/send/supervision still pass ActorNodeIntegrationTest.
P5 — Route table routes { route<P>(...) { } } registers RouteNodes under the covers. Medium Migrate ChatGraph; verify cross-graph forward, deeplinks, guards.
P6 — Modules & supervision trees Add module / use / hierarchical supervision. Highest — touches engine module's nested-graph delegation. Migrate WorkbenchGraph; verify all 30 re-exposed services still resolve.
P7 — Deprecate After two release cycles where new modules use the DSL, mark legacy RouteBinding subclass pattern and inline exposePort as deprecated. Low — soft-deprecation only. Run the workbench inspector to detect remaining legacy patterns.
Verification doctrine. Each phase ships behind a "old path still works" guarantee. A single screen migrated end-to-end — ChatGraph is the canonical pilot — is the gate for the next phase. No phase ships without removing >100 lines from the BestBuds codebase.

12. Non-Goals

No annotation processors. @Screen, @Provides, etc. would force a compiler plugin into the multiplatform build for every consumer. Reified generics + property delegates cover the same surface in pure source.
No runtime reflection-based DI. Koin already does the DI; we don't add a second mechanism. The new exposed { } block is sugar over Koin registration, not a replacement.
No new top-level node type. The sealed Node hierarchy is small and load-bearing. Hooks/behavior actors are builders that emit existing concrete types.
No removal of imperative APIs. Every imperative API stays callable. The DSL is opt-in. A developer who prefers RouteNode(graph, pattern) { Binding } can keep writing it forever.
No replacement of Compose integration. The Compose layer reads StateFlows; it doesn't care whether the StateFlow came from a hand-rolled MutableStateFlow or a StatePort.

? 13. Open Questions

  1. How far does structural KType matching go? Exact equality is safe but rigid; subtype matching (List<String> accepts ArrayList<String>) is intuitive but requires careful variance handling. Start with exact, add covariance behind an opt-in.
  2. Does RequestPort subsume ServiceNode? The handlers in service/* already are request/response. We could deprecate the GetHandler/PostHandler hierarchy in favor of a single RequestPort with HTTP-method metadata. Worth a prototype.
  3. How do hook controllers interact with JsExport? Reified-generic property delegates don't always round-trip through @JsExport. May need a parallel controllerJs(...) factory that avoids reified types at the JS boundary.
  4. Persistence model for StatePort. ActorNode already has ObjectStore-backed state. Should by state<S>() support an persisted = true flag that automatically rehydrates from the store on Restoring?
  5. Cross-graph mesh: validation timing. Layer-4 cross-graph edges are validated at startup. But subgraphs can be added/removed at runtime (tab creation). The wiring needs to support incremental validation without re-walking the whole world.
  6. Where does reaktor-react fit? The plan is largely Compose-centric, but a sibling module exists for React. Hook controllers in particular need to translate cleanly to React hooks. Should the controller DSL emit both a Compose and a React adapter?
Refinement plan v1 — targets reaktor-graph + reaktor-graph-port. Status: Proposal — awaiting pilot on ChatGraph.