Design document · v2 · refined

Reaktor Graph Visitors

A refined visitor architecture for Reaktor Graph: read-only by default, mutations as explicit patches, selectors that compose, passes that form a DAG, and visitors that can run sync, suspend, or reactively. Pressure-tested against the BestBuds product graph, the Reaktor Engine workbench graph, and the polyglot wiring story in graph-kernel-analysis.

Design sentence (refined). A visitor is a graph pass over a selected graph-view. Selectors define the view, traversals define order and effects, visitors produce typed results, patches express mutations, and passes package the whole thing into a reusable, diagnosable capability.
Graph stays small Visitors bolt on behavior Selectors return arcs Passes are reusable capabilities + Mutations are patches + Passes compose into a DAG + Reactive selectors + Polyglot ContractId

2. What changed in v2

v1 nailed the core insight — selectors should return arcs, not raw neighbors — and laid out the six-type model. v2 takes that further by closing seven specific gaps that emerged when cross-referencing the design against the refinement plan, the actor model doc, the kernel analysis, and real autoWire()/toJsonElement() code in the runtime.

newMutations are explicit patches. WirePass in v1 quietly mutated the graph inside visitor.finish(). v2 separates Visitor<R> (read-only) from MutatingVisitor, which returns a GraphPatch applied transactionally via the refinement plan's Wiring machinery.
newSelectors compose. StructuralSelector union ActiveSelector, RouteSelector filter { arc -> ... }, StructuralSelector restrictTo<Graph>(). v1 had six selectors but no way to derive new views without writing new objects.
newPasses are a DAG with shared PassContext. v1's GraphCompiler just ran passes sequentially. v2 lets one pass declare a dependency on another (DeepLinkInstallPass depends on RouteIndexPass) and read its result, eliminating duplicate traversals.
newSuspend-capable traversals. Service health checks, cache warmup, and Engine source-map collection are IO-bound. v2 adds SuspendTraversal alongside the synchronous one without forcing every pass to be suspend.
newReactive selectors and live installs. ActiveSelector changes over time; an analytics install wants to re-fire when the back-stack moves. v2 introduces ReactiveSelector emitting a Flow<VisitArc> and binds it to GraphInstallation lifetimes automatically.
newPolyglot wiring via ContractId. The kernel says ports match by contract identity, not by local Kotlin Type. v2 routes WirePass through a pluggable PortMatcher so a TS port can match a Kotlin port across the bridge.
newSelf-reference safety. The Engine workbench inspects graphs that may include the inspecting graph itself. v2 adds an IntrospectionExcluded attribute, arc-level filtering, and a documented cycle policy so visualizers do not infinite-recurse.
v2Diagnostics are structured. PassReport in v1 carried success/messages/error. v2 adds severity (Info/Warn/Error/Fatal), per-element findings (Finding(element, message, hint)), and a deterministic ordering contract.
v2VisitKind grows to cover module contracts and cross-graph edges. v1 left WorkflowDependency as the only domain kind; v2 adds ModuleProvides, ModuleConsumes, and CrossGraphEdge for the refinement plan's L3-L4 work.
keptSix-type model. Visitable, VisitArc, Selector, Traversal, Visitor, GraphPass. Still the spine.
keptApp pressure test. BestBuds + Engine are still the two graphs every selector and pass must justify itself against.

3. Diagnosis

The current Reaktor visitor code (reaktor-graph/src/commonMain/kotlin/dev/shibasis/reaktor/graph/visitor/Visitor.kt and reaktor-graph-port/src/commonMain/kotlin/dev/shibasis/reaktor/portgraph/visitor/Visitor.kt) is small and useful, but limited to neighbor-walking with untyped results and ad-hoc ExitScope closures. The following twelve gaps are the precise reason a redesign is worth the cost.

G1 — Neighbors lose relation meaning

portgraph/visitor/Visitor.kt:13
A selector returns List<Visitable>. A traversal landing on a Graph cannot tell whether the next node is a child graph, a sibling node, a port, or a route attachment.
Fix. Selectors return Sequence<VisitArc>; arcs carry a typed VisitKind.

G2 — No visit context

visitor/Visitor.kt:64
Visitors only see the current node. Depth, parent, root, path, graph chain, and route chain are recomputed by every visitor that needs them.
Fix. VisitContext with depth, path, graphPath, routePath, nearest<T>().

G3 — No traversal control

portgraph/visitor/Visitor.kt:60
DepthFirstTraverser walks everything. There is no way for a visitor to say "stop here" or "skip my children" beyond throwing.
Fix. VisitDecision with Continue / SkipChildren / Stop.

G4 — No typed results

visitor/Visitor.kt:64
Visitor returns nothing. HierarchyVisitor exposes a lateinit rootMap that throws if you forget to traverse before reading.
Fix. Visitor<R> with fun result(): R.

G5 — Visitors quietly mutate

Graph.kt:211 (autoWire)
The graph runtime already mutates state from outside a transaction. A future WireVisitor.finish() would inherit the same problem — partial wiring on listener failure.
Fix. MutatingVisitor returns GraphPatch; patches commit through Wiring (refinement plan L0.2).

G6 — Port type matching is string equality

Graph.kt:217 (autoWire)
localProviders[consumer.type] compares Type.type: String. Loses generic information; cannot cross language boundaries to TS or JS bridges.
Fix. PortMatcher strategy with KType and ContractId variants.

G7 — No async pass support

N/A (missing)
Engine source-map collection, service health probes, and cache warmup are IO. A synchronous traversal cannot accommodate these without smuggling runBlocking into onTransition.
Fix. SuspendTraversal in parallel with Traversal.

G8 — No reactive view

backStack, ActiveSelector
The active route is a StateFlow; a v1 ActiveSelector snapshots it once. Analytics, browser URL sync, and devtools all need to re-run when it changes.
Fix. ReactiveSelector.arcs(graph): Flow<VisitArc> with installation-bound subscription.

G9 — No selector composition

N/A
A visitor over routes that are currently active requires writing a third selector. Doubles for routes-in-this-graph, ports-on-a-controller, etc.
Fix. union, intersect, filter, restrictTo<T>() combinators.

G10 — Passes don't share results

v1 GraphCompiler
DeepLinkInstallPass reindexes routes that RouteIndexPass already produced. Every compose-time call duplicates work.
Fix. PassContext with typed results; passes declare dependsOn and read via ctx[RouteIndexPass].

G11 — Self-reference can infinite-recurse

Engine graph viewer (planned)
The workbench visualizer is itself a Reaktor graph. Naive recursive serialization would crawl the visualizer's own viewer node.
Fix. IntrospectionExcluded attribute + arc-level VisitFilter.

G12 — Diagnostics are too thin

v1 PassReport
success / messages / error is enough for "did it compile" but not for "which port failed and why."
Fix. Finding(severity, element, message, hint, code) with deterministic ordering.

4. App pressure test: BestBuds and Engine

The visitor design is judged against two live Reaktor apps. bestbuds/modules/app is the product graph (real routing, services, state, feature flags). bestbuds/modules/engine is the workbench/control-plane graph that inspects Reaktor apps — including itself.

BestBuds app graph

BestBuds is a nested product graph rooted at BestBuds. It installs service nodes for messaging, social, and stickers; exposes repositories and interactors; then composes route graphs for chat, campaign, discover, events, friends, profile, palette, and dev tools under /home.

  • ChatGraph wires message repositories, chat actors, session interactors, socket/typing/timeline logic, chat list, chat detail, and friend/group profile routes.
  • EventGraph creates a route in the parent graph and passes it into private/public child graphs, so navigation analysis must preserve cross-graph route ownership.
  • FriendGraph receives chatGraph.chatRoute, which means route reachability is not a pure tree problem.
  • FeatureFlags.EVENTS_TAB controls visible navigation, so active/navigation visitors need runtime state without losing the full structural graph.
  • navigateToStart() documents a current cross-graph limitation: some flows still dispatch from the root because edges cannot always reach back across graph boundaries.

Reaktor Engine graph

Engine exposes apps through ReaktorApps: the real BestBuds graph and a WorkbenchGraph for the Engine itself. The workbench has API/service nodes, shell state, command queues, graph catalog, and pane subgraphs for graph, devtools, UI, database, auth, AI, testing, deploy, insights, and agent workflows.

  • WorkbenchGraph composes a /workbench container over many pane graphs while also exposing cross-cutting command/data/subsystem services.
  • ReaktorGraphDocumentBuilder already manually walks graphs to emit nodes plus containment, navigation, and data edges — the strongest first migration target.
  • Engine renders graphs as Reaktor Flow diagrams. Without the v2 introspection-exclusion mechanism, visualizing the Engine itself recurses forever.
  • Engine requires stable IDs, deterministic ordering, and read-only snapshot passes because it diffs graph documents across sessions.

Consequences for the design

Route traversal must be graph-aware. A route edge may connect siblings, parent-owned routes, or container child graphs. VisitContext needs both graphPath and routePath; VisitArc.kind must distinguish containment, route attachment, navigation target, active child graph, and cross-graph edges.
Export is not just debugging. Engine's graph document is a product API. GraphDocumentPass is a standard pass with stable IDs, predictable edge categories, and compatibility with the current reaktorGraphDocument(appId) entry point. It must be deterministic to support diffs.
Wiring needs reports, not only side effects. BestBuds calls autoWire() repeatedly across subgraphs. A pass-based replacement must explain connected, missing, ambiguous, and inherited/provider-from-parent ports so product regressions are diagnosable — and must operate transactionally so a partial failure does not leave a half-wired graph.
Runtime passes and snapshot passes must be separate. BestBuds needs installs for deep links, analytics, auth guards, and active route syncing. Engine needs pure exports and validation. Mixing those concerns would make the workbench mutate graphs while trying to inspect them.
Self-inspection must not loop. The Engine workbench inspects graphs that may include the Engine itself. Visitors must respect an opt-out (IntrospectionExcluded) and a documented cycle policy.

5. Design principles

PrincipleMeaningConsequence
Graph is runtime structure The graph owns nodes, ports, edges, routes, containers, lifecycle, navigation, and dependency scope. Do not add core concepts for analytics, auth, docs, WorkManager, deep links, or workflow engines.
Selectors are graph views The same structure can be seen as a structural tree, a route graph, a port graph, an active route chain, or a workflow DAG. A visitor can be reused over multiple views, and a selector can support many visitors. Views compose with union, filter, restrictTo.
Visitors are passes A visitor does one operation: collect, validate, wire, install, compile, export, or inspect. Graph capabilities become composable: graph.compile { +WirePass(); +RouteIndexPass() }.
Arcs matter Relationships carry meaning; neighbors alone are insufficient. Selector returns VisitArc, not raw Visitable.
Read-only by default A Visitor<R> never modifies the graph it walks. Mutations are surfaced as GraphPatch values for the caller to apply. Workbench/devtools cannot accidentally mutate the graph they are inspecting. Test fixtures can re-run any visitor any number of times.
Deterministic order A Selector over a fixed graph state must yield arcs in a stable order. A Traversal must visit them in a stable order. Engine document diffs are meaningful. Snapshot tests do not flake. AI context windows are repeatable.
Result typed Visitors must return concrete values like RouteIndex, WiringReport, JsonElement, or WorkflowPlan. No global mutation or ad hoc side channels for common operations.
Suspend-capable Some passes (health checks, source maps, cache warmup) are IO-bound and must await. A parallel SuspendTraversal and SuspendVisitor<R> exist next to the sync ones; sync stays the default.
Reactive when asked Some views change over time (active route, lifecycle, dependency scope content). ReactiveSelector emits a Flow<VisitArc>; GraphInstallation binds the subscription to lifetime.
Polyglot port identity Ports identified by ContractId across language bridges, not by local Kotlin Type only. PortMatcher is pluggable; the same WirePass handles Kotlin↔Kotlin, Kotlin↔TS, and JS bridges.

6. Minimal model

v1's six types are kept; v2 adds two formal companions for mutations and async.

Visitable
thing that can be visited
VisitArc
typed relation
Selector
graph view
Traversal
order & mode
Visitor<R>
logic & result
GraphPass<R>
packaged capability

Two formal companions, not part of the spine but always present:

GraphPatch
mutation as data
PassContext
shared pass results
interface Visitable

data class VisitArc(
    val from: Visitable?,
    val to: Visitable,
    val kind: VisitKind,
    val label: String = "",
)

fun interface Selector {
    fun outgoing(ctx: VisitContext, current: Visitable): Sequence<VisitArc>
}

interface Traversal {
    fun <R> traverse(root: Visitable, selector: Selector, visitor: Visitor<R>): R
}

interface Visitor<R> {
    fun enter(ctx: VisitContext): VisitEnter = VisitEnter()
    fun finish() {}
    fun result(): R
}

interface GraphPass<R> {
    val name: String
    val selector: Selector
    val traversal: Traversal
    val dependsOn: List<PassKey<*>> get() = emptyList()
    fun visitor(ctx: PassContext): Visitor<R>
}

7. VisitArc & VisitKind

The strongest refinement v1 introduced: selectors return arcs, not neighbors. v2 keeps the model and grows VisitKind to cover module contracts, cross-graph edges, and lifecycle transitions surfaced by reactive selectors.

enum class VisitKind {
    Root,

    // Structure
    GraphNode,
    ContainerRoute,
    ContainerChildGraph,
    ActiveChildGraph,

    // Ports
    ProviderPort,
    ConsumerPort,
    PortEdge,

    // Routing / navigation
    RouteAttachment,
    NavigationTarget,
    BackStackEntry,
    ActiveRoute,
    CrossGraphEdge,                 // NEW v2 — explicit cross-graph nav

    // Services / workflow
    ServiceHandler,
    ServiceEndpoint,
    RepositoryState,
    ActorMailbox,
    FeatureGate,
    WorkflowDependency,

    // Module contracts (refinement plan L3.4)            NEW v2
    ModuleProvides,
    ModuleConsumes,
    ModuleBoundary,

    // Lifecycle (reactive selectors only)               NEW v2
    LifecycleTransition,

    // Fallback
    Derived,
}

VisitArc stays a four-field data class. v2 adds two optional projections:

data class VisitArc(
    val from: Visitable?,
    val to: Visitable,
    val kind: VisitKind,
    val label: String = "",
    val attributes: Attributes = Attributes.Empty,     // NEW v2 — arc-level metadata
)

// Helpers for filtering / matching
fun VisitArc.matches(kind: VisitKind) = this.kind == kind
fun VisitArc.crossesGraph(): Boolean =
    from is Node && to is Node && from.graph !== to.graph

8. VisitContext

Visitors should not need to recompute parent, route, graph, path, or traversal state. The traversal provides this context; nothing about it changes in v2 except a small extension for module boundaries.

data class VisitContext(
    val root: Visitable,
    val current: Visitable,
    val parent: Visitable?,
    val incoming: VisitArc?,
    val depth: Int,
    val path: List<VisitArc>,
    val traversal: Traversal,
    val selector: Selector,
    val passContext: PassContext,        // NEW v2 — access to upstream pass results
) {
    inline fun <reified T : Visitable> nearest(): T? =
        path.asReversed().asSequence()
            .flatMap { sequenceOf(it.to, it.from).filterNotNull() }
            .filterIsInstance<T>()
            .firstOrNull()

    val graph: Graph?
        get() = when (current) {
            is Graph -> current
            is Node -> current.graph
            else -> nearest<Graph>()
        }

    val graphPath: List<Graph>
        get() = path.asSequence()
            .flatMap { sequenceOf(it.from, it.to).filterNotNull() }
            .filterIsInstance<Graph>().distinct().toList()

    val route: RouteNode<*, *>?
        get() = current as? RouteNode<*, *> ?: nearest<RouteNode<*, *>>()

    val routePath: List<RouteNode<*, *>>
        get() = path.asSequence()
            .flatMap { sequenceOf(it.from, it.to).filterNotNull() }
            .filterIsInstance<RouteNode<*, *>>().distinct().toList()

    // NEW v2 — module boundary helpers
    val module: ModuleId? get() = nearest<Graph>()?.attributes[ModuleAttr.Id]
    val crossedModuleBoundary: Boolean
        get() = path.any { it.kind == VisitKind.ModuleBoundary }
}
Rule. Passes should not manually crawl global graph state unless their job is explicitly global indexing. Most logic should derive from VisitContext and VisitArc.kind.
BestBuds rule. graphPath and routePath are not optional polish. The app has parent-owned routes handed into child graphs and child graphs mounted under containers. Without path-aware context, a visitor cannot distinguish "this screen belongs under /home/Chat" from "this route is reachable from the root sentinel."

9. Visitor API

The visitor supports typed results, early stop, child skipping, exit hooks, and (new) a suspend variant. ReaktorVisitor<R> is unchanged from v1 except for the suspend twin.

sealed interface VisitDecision
data object Continue : VisitDecision
data object SkipChildren : VisitDecision
data object Stop : VisitDecision

data class VisitEnter(
    val decision: VisitDecision = Continue,
    val onExit: () -> Unit = {},
)

interface Visitor<R> {
    fun enter(ctx: VisitContext): VisitEnter = VisitEnter()
    fun finish() {}
    fun result(): R
}

// NEW v2 — suspend twin
interface SuspendVisitor<R> {
    suspend fun enter(ctx: VisitContext): VisitEnter = VisitEnter()
    suspend fun finish() {}
    suspend fun result(): R
}

Typed Reaktor visitor adapter

open class ReaktorVisitor<R> : Visitor<R> {
    override fun enter(ctx: VisitContext): VisitEnter =
        when (val current = ctx.current) {
            is Graph -> visitGraph(ctx, current)
            is ContainerNode -> visitContainer(ctx, current)
            is RouteNode<*, *> -> visitRoute(ctx, current)
            is ControllerNode<*> -> visitController(ctx, current)
            is ActorNode<*> -> visitActor(ctx, current)         // NEW v2
            is ServiceNode -> visitService(ctx, current)         // NEW v2
            is BasicNode -> visitBasic(ctx, current)
            is Node -> visitNode(ctx, current)
            is ConsumerPort<*> -> visitConsumerPort(ctx, current)
            is ProviderPort<*> -> visitProviderPort(ctx, current)
            is Edge<*> -> visitEdge(ctx, current)
            else -> VisitEnter()
        }

    open fun visitGraph(ctx: VisitContext, graph: Graph): VisitEnter = VisitEnter()
    open fun visitContainer(ctx: VisitContext, container: ContainerNode): VisitEnter = VisitEnter()
    open fun visitRoute(ctx: VisitContext, route: RouteNode<*, *>): VisitEnter = VisitEnter()
    open fun visitController(ctx: VisitContext, node: ControllerNode<*>): VisitEnter = VisitEnter()
    open fun visitActor(ctx: VisitContext, node: ActorNode<*>): VisitEnter = VisitEnter()
    open fun visitService(ctx: VisitContext, node: ServiceNode): VisitEnter = VisitEnter()
    open fun visitBasic(ctx: VisitContext, node: BasicNode): VisitEnter = VisitEnter()
    open fun visitNode(ctx: VisitContext, node: Node): VisitEnter = VisitEnter()
    open fun visitConsumerPort(ctx: VisitContext, port: ConsumerPort<*>): VisitEnter = VisitEnter()
    open fun visitProviderPort(ctx: VisitContext, port: ProviderPort<*>): VisitEnter = VisitEnter()
    open fun visitEdge(ctx: VisitContext, edge: Edge<*>): VisitEnter = VisitEnter()
}
Why visitActor and visitService are separate hooks. These are the two node kinds whose semantics differ enough from "just a Node" that every Engine inspector and every deploy pass needs to special-case them. Splitting hooks at the adapter level keeps individual visitors readable.

10. Mutations & GraphPatch

The single most important change in v2. A Visitor<R> is contractually read-only. Anything that mutates the graph — wiring ports, attaching nodes, installing listeners — emits a GraphPatch instead, which the caller commits atomically.

sealed interface GraphPatch {
    val description: String

    data class ConnectPort(
        val consumer: ConsumerPort<*>,
        val provider: ProviderPort<*>,
        override val description: String = "connect ${consumer.key.key} ↔ ${provider.key.key}",
    ) : GraphPatch

    data class AttachNode(
        val graph: Graph,
        val node: Node,
        override val description: String = "attach ${node.label}",
    ) : GraphPatch

    data class SetAttribute<T : Any>(
        val target: Attributed,
        val key: AttributeKey<T>,
        val value: T,
        override val description: String = "set ${key.name}",
    ) : GraphPatch

    data class Composite(
        val patches: List<GraphPatch>,
        override val description: String = "${patches.size} changes",
    ) : GraphPatch
}

interface MutatingVisitor<R> : Visitor<Pair<R, GraphPatch?>> {
    /** Read-only result plus an optional patch describing every mutation
     *  the visitor would perform. Returning `null` means no mutations. */
}

The commit machinery

GraphPatch commits through the refinement plan's Wiring machinery: validate the entire patch, then apply atomically. If a step fails, no port is mutated. Listener exceptions are collected and reported per-step, not raised mid-commit.

class GraphPatchResult(
    val applied: List<GraphPatch>,
    val rejected: List<Finding>,
) {
    val success: Boolean get() = rejected.isEmpty()
}

fun Graph.apply(patch: GraphPatch, dryRun: Boolean = false): GraphPatchResult { ... }

// Convenience over MutatingVisitor result
fun <R> Graph.runMutating(pass: GraphPass<Pair<R, GraphPatch?>>): Pair<R, GraphPatchResult> {
    val (result, patch) = run(pass)
    val applied = patch?.let { apply(it) } ?: GraphPatchResult(emptyList(), emptyList())
    return result to applied
}
Migration consequence. v1's WireVisitor.finish() mutates the graph as a side effect. v2's WirePass emits a GraphPatch.Composite and exposes the wiring report alongside it. The legacy Graph.autoWire() function continues to exist as a thin wrapper that runs the pass and applies the patch.

11. Traversals

Depth-first is the default. Breadth-first supports search/indexing. Topological is needed for workflow compilation. v2 also adds a documented determinism contract and a suspend variant.

Determinism contract. A Traversal over a fixed graph state must visit elements in a stable, reproducible order. Selector implementations must yield arcs in a deterministic order (typically declaration order in the nodes/ports collections). Engine document diffs, snapshot tests, and AI context windows depend on this.

11.1 Depth-first

object DepthFirst : Traversal {
    override fun <R> traverse(
        root: Visitable, selector: Selector, visitor: Visitor<R>,
    ): R {
        val visited = mutableSetOf<Visitable>()
        var stopped = false

        fun go(
            current: Visitable,
            parent: Visitable?,
            incoming: VisitArc?,
            path: List<VisitArc>,
        ) {
            if (stopped || !visited.add(current)) return

            val ctx = VisitContext(
                root = root, current = current, parent = parent,
                incoming = incoming, depth = path.size, path = path,
                traversal = this, selector = selector,
                passContext = PassContext.NoOp,
            )

            val enter = visitor.enter(ctx)
            when (enter.decision) {
                Stop -> { stopped = true; enter.onExit(); return }
                SkipChildren -> { enter.onExit(); return }
                Continue -> Unit
            }

            selector.outgoing(ctx, current).forEach { arc ->
                if (stopped) return@forEach
                go(arc.to, current, arc, path + arc)
            }
            enter.onExit()
        }

        go(root, null, VisitArc(null, root, VisitKind.Root), emptyList())
        visitor.finish()
        return visitor.result()
    }
}

11.2 Topological

For workflow planning, deploy ordering, and dependency analysis. Selectors used with topological traversal must yield a DAG; cycles are reported as Findings, not exceptions.

object Topological : Traversal {
    override fun <R> traverse(
        root: Visitable, selector: Selector, visitor: Visitor<R>,
    ): R {
        val sorted = topologicalSort(root, selector) // returns Result with cycle diagnostics
        sorted.getOrThrow().forEach { element -> /* enter element */ }
        visitor.finish()
        return visitor.result()
    }
}

11.3 Suspend

interface SuspendTraversal {
    suspend fun <R> traverse(
        root: Visitable, selector: Selector, visitor: SuspendVisitor<R>,
    ): R
}

object SuspendDepthFirst : SuspendTraversal {
    override suspend fun <R> traverse(...) { /* analogous to DepthFirst, awaiting each enter */ }
}

Graph helpers

fun <R> Graph.visit(
    selector: Selector = GraphSelectors.Structural,
    traversal: Traversal = DepthFirst,
    visitor: Visitor<R>,
): R = traversal.traverse(this, selector, visitor)

suspend fun <R> Graph.visitAsync(
    selector: Selector = GraphSelectors.Structural,
    traversal: SuspendTraversal = SuspendDepthFirst,
    visitor: SuspendVisitor<R>,
): R = traversal.traverse(this, selector, visitor)

12. Standard selectors

Selectors are the reusable "views" over the same Reaktor graph. The graph itself does not change; only the relationship view changes. The six v1 selectors stay; refer to v1 for the full bodies. v2 sharpens three of them and adds two new ones.

SelectorUse casev2 status
StructuralSelectorExport, route indexing, validation, recursive compile.Kept.
RouteSelectorRoute index, deeplinks, sitemap, auth coverage.Kept.
NavigationSelectorRoute reachability, dead-route detection.Kept + emits CrossGraphEdge arcs.
PortConnectivitySelectorWiring, capability analysis.Kept.
ActiveSelectorURL sync, analytics, focused back handling.Has a reactive companion now (§14).
WorkflowSelectorTask DAG extraction.Kept.
ModuleContractSelector Module boundary & contract inspection.New in v2.
SupervisionSelector Actor supervision tree for deploy/diagnostics.New in v2.

12.1 ModuleContractSelector

Walks a graph as a tree of modules (refinement plan L3.4). Each Graph with a ModuleAttr.Id attribute is a module boundary; its provides/consumes are exposed as ModuleProvides/ModuleConsumes arcs.

object ModuleContractSelector : Selector {
    override fun outgoing(ctx: VisitContext, current: Visitable): Sequence<VisitArc> = sequence {
        when (current) {
            is Graph -> {
                val moduleId = current.attributes[ModuleAttr.Id]
                if (moduleId != null) {
                    current.attributes[ModuleAttr.Provides]?.forEach { type ->
                        yield(VisitArc(current, ModuleProvide(moduleId, type), VisitKind.ModuleProvides))
                    }
                    current.attributes[ModuleAttr.Consumes]?.forEach { type ->
                        yield(VisitArc(current, ModuleConsume(moduleId, type), VisitKind.ModuleConsumes))
                    }
                }
                current.nodes.filterIsInstance<ContainerNode>().forEach { container ->
                    container.graphs.forEach { child ->
                        yield(VisitArc(current, child, VisitKind.ModuleBoundary))
                    }
                }
            }
        }
    }
}

12.2 SupervisionSelector

Walks the actor supervision tree (refinement plan §9.1). Used by deploy diagnostics and devtools fault visualization.

object SupervisionSelector : Selector {
    override fun outgoing(ctx: VisitContext, current: Visitable): Sequence<VisitArc> = sequence {
        when (current) {
            is Graph -> current.nodes.filterIsInstance<ActorNode<*>>().forEach { actor ->
                yield(VisitArc(current, actor, VisitKind.ActorMailbox))
            }
            is ActorNode<*> -> current.supervisedChildren().forEach { child ->
                yield(VisitArc(current, child, VisitKind.ActorMailbox))
            }
        }
    }
}

13. Selector combinators

A view of a view is also a view. v2 introduces a small set of combinators so the existing selectors compose without subclassing.

// Union — yield arcs from both selectors, in order.
infix fun Selector.union(other: Selector): Selector = Selector { ctx, current ->
    sequenceOf(this, other).flatMap { it.outgoing(ctx, current) }
}

// Intersect — yield arcs present in both (matched by from/to/kind).
infix fun Selector.intersect(other: Selector): Selector = Selector { ctx, current ->
    val a = outgoing(ctx, current).toList()
    val b = other.outgoing(ctx, current).toSet()
    a.asSequence().filter { it in b }
}

// Filter — yield only matching arcs.
fun Selector.filter(predicate: (VisitArc) -> Boolean): Selector = Selector { ctx, current ->
    outgoing(ctx, current).filter(predicate)
}

// Restrict — only yield arcs where `to` is a specific Visitable type.
inline fun <reified T : Visitable> Selector.restrictTo(): Selector =
    filter { it.to is T }

// Mapping — rewrite arc kinds (useful for adapters).
fun Selector.relabel(transform: (VisitArc) -> VisitArc): Selector = Selector { ctx, current ->
    outgoing(ctx, current).map(transform)
}

// Stop at boundary — do not traverse past arcs of a given kind.
fun Selector.stopAt(kind: VisitKind): Selector = Selector { ctx, current ->
    outgoing(ctx, current).takeWhile { it.kind != kind }
}

Example: active routes + module boundaries

val activeWithModules = ActiveSelector union ModuleContractSelector
val withinThisModule = StructuralSelector.stopAt(VisitKind.ModuleBoundary)
val portsOnlyOnControllers = PortConnectivitySelector
    .filter { it.from is ControllerNode<*> }
    .restrictTo<ConsumerPort<*>>()

14. Reactive selectors & live installations

Some views change over time. The current active route is a StateFlow; an analytics install needs to re-fire every time the back-stack moves. A reactive selector emits a Flow<VisitArc> describing arc changes; a ReactivePass subscribes to it for the lifetime of the resulting GraphInstallation.

interface ReactiveSelector {
    /** Emits arcs over time. Each emission is a single change — addition or removal. */
    fun arcs(graph: Graph): Flow<VisitArc>
}

// Built-in: ActiveSelector as a reactive selector.
object ReactiveActiveSelector : ReactiveSelector {
    override fun arcs(graph: Graph): Flow<VisitArc> =
        graph.backStack.entries
            .map { it.lastOrNull()?.edge?.end }
            .distinctUntilChanged()
            .filterNotNull()
            .map { route -> VisitArc(graph, route, VisitKind.ActiveRoute) }
}

// Built-in: LifecycleSelector
object ReactiveLifecycleSelector : ReactiveSelector {
    override fun arcs(graph: Graph): Flow<VisitArc> =
        graph.lifecycle
            .map { state -> VisitArc(graph, LifecycleEvent(state), VisitKind.LifecycleTransition) }
}

// A pass that returns an installation tied to a reactive selector subscription.
abstract class ReactivePass : GraphPass<GraphInstallation> {
    abstract val reactiveSelector: ReactiveSelector
    abstract suspend fun onArc(arc: VisitArc, graph: Graph)

    override fun visitor(ctx: PassContext): Visitor<GraphInstallation> =
        object : ReaktorVisitor<GraphInstallation>() {
            override fun result(): GraphInstallation {
                val graph = ctx.graph
                val job = graph.coroutineScope.launch {
                    reactiveSelector.arcs(graph).collect { onArc(it, graph) }
                }
                return GraphInstallation { job.cancel() }
            }
        }
}

Example: analytics install

class AnalyticsInstallPass(private val analytics: Analytics) : ReactivePass() {
    override val name = "analytics.install"
    override val selector = StructuralSelector
    override val traversal = DepthFirst
    override val reactiveSelector = ReactiveActiveSelector

    override suspend fun onArc(arc: VisitArc, graph: Graph) {
        val route = arc.to as? RouteNode<*, *> ?: return
        val screen = route.attributes[AnalyticsAttr.Screen] ?: return
        analytics.logEvent("screen_view", mapOf("screen" to screen))
    }
}
Why a separate type. A ReactiveSelector is not a Selector. The plain Selector is a pure function over a fixed graph state and is required to be deterministic. Reactive selectors emit over time and are explicitly side-effecting in their subscription — they have their own contract and lifecycle.

15. Passes & PassContext

v1 introduced GraphPass<R> and a sequential GraphCompiler. v2 elevates this to a DAG: a pass can declare dependencies on other passes and read their results through a typed PassContext. The compiler does the topological sort.

class PassKey<R>(val name: String)

interface PassContext {
    val graph: Graph
    operator fun <R> get(key: PassKey<R>): R
    fun <R> getOrNull(key: PassKey<R>): R?

    companion object {
        val NoOp: PassContext = NoOpPassContext
    }
}

interface GraphPass<R> {
    val name: String
    val key: PassKey<R> get() = PassKey(name)
    val selector: Selector
    val traversal: Traversal
    val dependsOn: List<PassKey<*>> get() = emptyList()

    fun visitor(ctx: PassContext): Visitor<R>
}

Example: deeplinks depend on routes

object PassKeys {
    val RouteIndex = PassKey<RouteIndex>("routes.index")
    val Wiring = PassKey<WiringReport>("ports.wire")
    val GraphDocument = PassKey<GraphDocument>("engine.graphDocument")
}

class DeepLinkInstallPass : GraphPass<GraphInstallation> {
    override val name = "navigation.deepLinks"
    override val key = PassKey<GraphInstallation>(name)
    override val selector = StructuralSelector            // unused, RouteIndex carries data
    override val traversal = DepthFirst
    override val dependsOn = listOf(PassKeys.RouteIndex)  // NEW v2

    override fun visitor(ctx: PassContext): Visitor<GraphInstallation> {
        val index = ctx[PassKeys.RouteIndex]              // read upstream result
        return object : ReaktorVisitor<GraphInstallation>() {
            override fun result(): GraphInstallation {
                val bridge = WebNavigationBridgeV2(ctx.graph, index)
                bridge.start()
                return GraphInstallation { bridge.destroy() }
            }
        }
    }
}

Compile pipeline

class GraphCompiler(private val graph: Graph) {
    private val passes = mutableListOf<GraphPass<*>>()

    operator fun GraphPass<*>.unaryPlus() { passes += this }

    fun run(): GraphCompileReport {
        val sorted = topologicalSortPasses(passes)        // by `dependsOn`
        val results = mutableMapOf<PassKey<*>, Any?>()
        val findings = mutableListOf<Finding>()
        val ctx = LivePassContext(graph, results)

        for (pass in sorted) {
            runCatching {
                val visitor = pass.visitor(ctx)
                val r = pass.traversal.traverse(graph, pass.selector, visitor)
                @Suppress("UNCHECKED_CAST")
                results[pass.key as PassKey<Any>] = r as Any
            }.onFailure { error ->
                findings += Finding(
                    severity = Severity.Fatal,
                    element = graph,
                    code = "pass.failed",
                    message = "Pass '${pass.name}' threw ${error::class.simpleName}: ${error.message}",
                    cause = error,
                )
            }
        }
        return GraphCompileReport(results, findings)
    }
}

fun Graph.compile(configure: GraphCompiler.() -> Unit): GraphCompileReport =
    GraphCompiler(this).apply(configure).run()

Runtime installations

Unchanged from v1: passes that install observers/interceptors return a GraphInstallation. The compile report aggregates them so the caller can close all installs together.

fun interface GraphInstallation : AutoCloseable

class CompositeInstallation(
    private val handles: List<GraphInstallation>,
) : GraphInstallation {
    override fun close() { handles.asReversed().forEach { it.close() } }
}

val GraphCompileReport.installations: GraphInstallation
    get() = CompositeInstallation(
        results.values.filterIsInstance<GraphInstallation>()
    )

16. Diagnostics

v1's PassReport had success/messages/error. v2 promotes findings to first-class structured values with severity, element, code, and hint — matching WiringReport's precision.

enum class Severity { Info, Warn, Error, Fatal }

data class Finding(
    val severity: Severity,
    val element: Visitable?,
    val code: String,                  // stable identifier — e.g. "wiring.ambiguous"
    val message: String,               // human-readable, includes element labels
    val hint: String? = null,          // suggested fix
    val cause: Throwable? = null,
) : Comparable<Finding> {
    override fun compareTo(other: Finding): Int =
        compareValuesBy(this, other, { it.severity }, { it.code }, { it.message })
}

data class GraphCompileReport(
    val results: Map<PassKey<*>, Any?>,
    val findings: List<Finding>,
) {
    val errors get() = findings.filter { it.severity >= Severity.Error }
    val success: Boolean get() = errors.isEmpty()

    inline fun <reified R> get(key: PassKey<R>): R? {
        @Suppress("UNCHECKED_CAST")
        return results[key] as? R
    }
}

Standard codes

CodeSeverityMeaning
wiring.missingErrorConsumer port has no matching provider.
wiring.ambiguousErrorMore than one provider matches an unnamed consumer.
wiring.crossgraphInfoConsumer matched a provider from a parent graph's DI scope.
route.deadendWarnRoute has no attached node or no incoming edges.
route.duplicateErrorTwo routes share the same pattern in the same graph.
module.contract.unsatisfiedErrorA module declares consumes<T> but no upstream module provides it.
introspection.cycleFatalA self-referential visitor hit a cycle without an IntrospectionExcluded mark.
pass.failedFatalA pass threw before completing.
Why codes are stable strings. Engine workbench filters and groups findings by code. Renaming a code breaks the workbench. New codes are additive; old codes are immutable.

17. Standard passes

v1 specified eight standard passes. v2 keeps them, sharpens the wiring & document passes, and adds three new ones for module contracts, cross-graph reachability, and source-map collection.

PassPurposeMutatesv2 status
WirePassConnect unmatched consumers to providers.via GraphPatchRefactored to emit a patch instead of side-effecting.
RequireFullyWiredPassValidate that every consumer has an edge.NoKept; emits Findings.
RouteIndexPassBuild a path-pattern → route map.NoKept; result reused by deeplinks/sitemap.
GraphJsonPassRecursive JSON dump.NoKept.
GraphDocumentPassEngine's first-class graph document.NoSharpened: deterministic ordering, stable IDs.
DeepLinkInstallPassWire URL changes to NavCommand.via installNow depends on RouteIndexPass.
AnalyticsInstallPassEmit screen_view on active route change.via installNow a ReactivePass.
AuthCoveragePassReport which routes are guarded.NoKept.
WorkflowPlanPassCompile a task DAG from workflow edges.NoUses Topological traversal.
ModuleContractPass Validate that module consumes are satisfied.NoNew.
CrossGraphReachabilityPass Detect orphan target graphs in cross-graph edges.NoNew.
SourceMapPass Collect source-code locations for nodes/ports.NoNew; suspend pass.

17.1 WirePass (refactored)

class WirePass(
    private val matcher: PortMatcher = PortMatcher.Default,
) : GraphPass<Pair<WiringReport, GraphPatch?>> {
    override val name = "ports.wire"
    override val selector = StructuralSelector
    override val traversal = DepthFirst

    override fun visitor(ctx: PassContext) = object : ReaktorVisitor<Pair<WiringReport, GraphPatch?>>() {
        private val providers = mutableListOf<ProviderPort<*>>()
        private val consumers = mutableListOf<ConsumerPort<*>>()
        private val patches = mutableListOf<GraphPatch>()
        private val findings = mutableListOf<Finding>()

        override fun visitProviderPort(c: VisitContext, p: ProviderPort<*>): VisitEnter {
            providers += p; return VisitEnter()
        }
        override fun visitConsumerPort(c: VisitContext, p: ConsumerPort<*>): VisitEnter {
            if (!p.isConnected()) consumers += p; return VisitEnter()
        }

        override fun finish() {
            consumers.forEach { consumer ->
                val matches = providers.filter { matcher.matches(consumer, it) }
                when {
                    matches.isEmpty() -> findings += Finding(
                        Severity.Error, consumer, "wiring.missing",
                        "No provider for '${consumer.key.key}' (${consumer.type})",
                        hint = "Expose a port of type ${consumer.type} or add @Provides annotation.",
                    )
                    matches.size > 1 && consumer.key.key.isEmpty() -> findings += Finding(
                        Severity.Error, consumer, "wiring.ambiguous",
                        "Multiple providers match '${consumer.type}'",
                        hint = "Add a key or qualifier to disambiguate.",
                    )
                    else -> patches += GraphPatch.ConnectPort(consumer, matches.first())
                }
            }
        }

        override fun result() = WiringReport(
            connected = emptyList(), // populated after patch applies
            missing = consumers.filter { c -> findings.any { it.element == c && it.code == "wiring.missing" } },
            ambiguous = consumers.filter { c -> findings.any { it.element == c && it.code == "wiring.ambiguous" } },
            findings = findings,
        ) to if (patches.isEmpty()) null else GraphPatch.Composite(patches)
    }
}

17.2 GraphDocumentPass (sharpened)

The Engine-first pass. Replaces the manual ReaktorGraphDocumentBuilder while preserving the public document shape: app id, label, visible nodes, containment edges, navigation edges, and data edges. v2 makes determinism explicit: nodes are emitted in declaration order; edges follow the order in which arcs are seen.

class GraphDocumentPass(
    private val appId: String,
    private val appLabel: String,
) : GraphPass<GraphDocument> {
    override val name = "engine.graphDocument"
    override val key = PassKeys.GraphDocument
    override val selector = StructuralSelector
        .filter { arc -> arc.from?.attributes?.get(VisitorAttr.IntrospectionExcluded) != true }
    override val traversal = DepthFirst

    override fun visitor(ctx: PassContext): Visitor<GraphDocument> =
        GraphDocumentVisitor(appId, appLabel)
}

17.3 ModuleContractPass

data class ModuleContractReport(
    val satisfied: List<ModuleConsume>,
    val unsatisfied: List<ModuleConsume>,
) { val success: Boolean get() = unsatisfied.isEmpty() }

class ModuleContractPass : GraphPass<ModuleContractReport> {
    override val name = "module.contracts"
    override val selector = ModuleContractSelector
    override val traversal = DepthFirst

    override fun visitor(ctx: PassContext) = object : Visitor<ModuleContractReport> {
        private val provides = mutableSetOf<ModuleProvide>()
        private val consumes = mutableListOf<ModuleConsume>()

        override fun enter(c: VisitContext): VisitEnter {
            when (val v = c.current) {
                is ModuleProvide -> provides += v
                is ModuleConsume -> consumes += v
            }
            return VisitEnter()
        }

        override fun result(): ModuleContractReport {
            val satisfied = consumes.filter { c -> provides.any { p -> p.type == c.type } }
            val unsatisfied = consumes - satisfied.toSet()
            return ModuleContractReport(satisfied, unsatisfied)
        }
    }
}

17.4 CrossGraphReachabilityPass

Validates that every cross-graph navigation edge has a reachable target. Catches a class of failures that today surface as Logger.w("Cross-graph navigation failed...") at runtime.

class CrossGraphReachabilityPass : GraphPass<List<Finding>> {
    override val name = "navigation.crossGraph"
    override val selector = NavigationSelector.filter { it.kind == VisitKind.CrossGraphEdge }
    override val traversal = DepthFirst

    override fun visitor(ctx: PassContext) = object : ReaktorVisitor<List<Finding>>() {
        private val findings = mutableListOf<Finding>()

        override fun visitEdge(c: VisitContext, edge: Edge<*>): VisitEnter {
            val nav = edge as? NavigationEdge<*> ?: return VisitEnter()
            val target = nav.end.graph
            val container = ctx.graph.findContainerForGraph(target)
            if (container == null) findings += Finding(
                Severity.Error, nav, "navigation.unreachable",
                "Cross-graph edge to '${target.label}' has no container in the root graph.",
                hint = "Mount target graph under a ContainerNode or remove the edge.",
            )
            return VisitEnter()
        }

        override fun result() = findings
    }
}

17.5 SourceMapPass

Collects source-code locations for every node and port, so Engine inspectors and AI context can jump-to-definition. This pass is suspend because it reads the build's source-map artifact from disk or R2.

class SourceMapPass(
    private val sourceMaps: SourceMapStore,
) : SuspendGraphPass<SourceMap> {
    override val name = "engine.sourceMap"
    override val selector = StructuralSelector
    override val traversal = SuspendDepthFirst

    override suspend fun visitor(ctx: PassContext) = object : SuspendVisitor<SourceMap> {
        private val entries = mutableListOf<SourceMapEntry>()

        override suspend fun enter(c: VisitContext): VisitEnter {
            val v = c.current
            sourceMaps.lookup(v)?.let { entries += it }
            return VisitEnter()
        }

        override suspend fun result() = SourceMap(entries)
    }
}

18. Visitor composition

You often want two visitors to run over the same traversal — an export visitor and a validation visitor, say. v2 adds a tiny adapter so a single traversal can drive multiple visitors and return a tuple.

class TupleVisitor2<A, B>(
    private val a: Visitor<A>,
    private val b: Visitor<B>,
) : Visitor<Pair<A, B>> {
    override fun enter(ctx: VisitContext): VisitEnter {
        val ea = a.enter(ctx); val eb = b.enter(ctx)
        val decision = when {
            ea.decision == Stop || eb.decision == Stop -> Stop
            ea.decision == SkipChildren && eb.decision == SkipChildren -> SkipChildren
            else -> Continue
        }
        return VisitEnter(decision) { ea.onExit(); eb.onExit() }
    }
    override fun finish() { a.finish(); b.finish() }
    override fun result(): Pair<A, B> = a.result() to b.result()
}

fun <A, B> Visitor<A>.with(other: Visitor<B>): Visitor<Pair<A, B>> = TupleVisitor2(this, other)

Example: export and validate in one walk

val (doc, findings) = graph.visit(
    selector = StructuralSelector,
    visitor = GraphDocumentVisitor(appId, appLabel) with ValidationVisitor(),
)

For more than two visitors, TupleVisitor3 / TupleVisitor4 follow the same pattern. Beyond that, the ergonomics favor running multiple passes through the compiler; the compiler already deduplicates work by caching pass results.

19. Polyglot wiring with ContractId

The kernel analysis treats ContractId as the polyglot port identity — Kotlin and TypeScript ports match by a shared schema name, not by local Kotlin Type. The visitor system delegates this through a PortMatcher strategy so the same WirePass works across language bridges.

fun interface PortMatcher {
    fun matches(consumer: ConsumerPort<*>, provider: ProviderPort<*>): Boolean

    companion object {
        val Default: PortMatcher = ChainedMatcher(
            ByContractId,            // try contract first
            ByKType,                 // then full KType (refinement plan L0.1)
            ByLocalType,             // legacy fallback — string equality
        )
    }
}

object ByContractId : PortMatcher {
    override fun matches(c: ConsumerPort<*>, p: ProviderPort<*>): Boolean {
        val cId = c.attributes[ContractAttr.Id] ?: return false
        val pId = p.attributes[ContractAttr.Id] ?: return false
        return cId == pId && (c.key.key.isEmpty() || c.key == p.key)
    }
}

object ByKType : PortMatcher {
    override fun matches(c: ConsumerPort<*>, p: ProviderPort<*>): Boolean =
        c.type.kType == p.type.kType && (c.key.key.isEmpty() || c.key == p.key)
}

object ByLocalType : PortMatcher {
    override fun matches(c: ConsumerPort<*>, p: ProviderPort<*>): Boolean =
        c.type.type == p.type.type && (c.key.key.isEmpty() || c.key == p.key)
}

Variance opt-in

Strict KType equality is too rigid in some real cases: a consumer of List<Message> should be wirable to a provider of MutableList<Message>. VarianceAwareMatcher implements this behind an opt-in flag.

class VarianceAwareMatcher(
    private val allowCovariantList: Boolean = true,
    private val allowContravariantConsumer: Boolean = false,
) : PortMatcher { ... }
Acceptance. When ContractId resolution is enabled, two ports declaring ContractAttr.Id = "MessagingApi/v1" must wire regardless of whether they are MessagingApi on the Kotlin side and a TS interface of the same shape on the React side.

20. Lifecycle integration

Passes are not always run by the developer; some run during graph lifecycle transitions. The compile-time bundle BestBudsRuntimePasses wants to fire DeepLinkInstallPass only when the graph reaches Attaching and tear it down on Saving. v2 ties passes to lifecycle transitions explicitly.

enum class PassPhase {
    Compile,    // before any lifecycle transition — pure analysis
    Attaching,  // after graph reaches Attaching
    Active,     // graph is running; passes may be reactive
    Saving,     // graph is being saved; pass returns persistable result
    Destroying, // graph is tearing down; passes flush
}

interface PassSchedule {
    val phase: PassPhase
    fun shouldRun(graph: Graph): Boolean = true
}

// Default schedule: compile-time analysis.
val CompilePhase = object : PassSchedule {
    override val phase = PassPhase.Compile
}

interface ScheduledGraphPass<R> : GraphPass<R> {
    val schedule: PassSchedule get() = CompilePhase
}

Hooking into Graph.lifecycle

fun Graph.installRuntimePasses(passes: List<ScheduledGraphPass<*>>): GraphInstallation {
    val handles = mutableListOf<GraphInstallation>()
    val job = coroutineScope.launch {
        lifecycle.collect { state ->
            passes.filter { it.schedule.phase.matches(state) && it.schedule.shouldRun(this@installRuntimePasses) }
                .forEach { pass ->
                    val result = run(pass)
                    if (result is GraphInstallation) handles += result
                }
        }
    }
    return GraphInstallation {
        job.cancel()
        handles.asReversed().forEach { it.close() }
    }
}
Rule. A PassPhase.Compile pass must be side-effect-free on the live graph. It may produce a GraphPatch, but the patch is not applied until the caller opts in. Lifecycle-phased passes (Attaching/Saving) may install GraphInstallations but should still avoid mutation.

21. Introspection & cycles

The Engine workbench inspects graphs. The workbench itself is a Reaktor graph. Without explicit cycle handling, visualizing the Engine recurses into the visualizer's own viewer node forever. v2 makes the policy explicit at three levels.

21.1 Level 1: visited-set (existing)

Every traversal keeps a visited set; revisiting an already-visited Visitable is a no-op. This handles structural cycles in the same graph.

21.2 Level 2: arc-level IntrospectionExcluded

Mark a node, port, or graph as not to be introspected by visualization passes. The Engine's graph-viewer node is the canonical example.

object VisitorAttr {
    val IntrospectionExcluded = AttributeKey<Boolean>("visitor.introspectionExcluded")
}

// in Engine setup:
val workbenchViewer = Node(::ReaktorGraphDocumentNode)
    .attr(VisitorAttr.IntrospectionExcluded, true)

// in StructuralSelector usage:
val visualizerSelector = StructuralSelector.filter { arc ->
    arc.from?.attributes?.get(VisitorAttr.IntrospectionExcluded) != true
}

21.3 Level 3: explicit cycle policy

For visitors that must traverse cyclic structures (workflow planning over a graph that may have cycles), expose the policy:

enum class CyclePolicy {
    Stop,          // default — visited-set prevents re-entry
    ReportAsError, // surface a Finding when a cycle is detected
    Allow,         // re-enter (caller responsible for termination)
}

class CycleAwareDepthFirst(
    private val policy: CyclePolicy = CyclePolicy.Stop,
) : Traversal { ... }
Engine rule. All passes intended for the workbench visualizer must use a selector that filters IntrospectionExcluded. GraphDocumentPass does this by default. The Engine integration tests check that visualizing the Engine itself terminates.

22. Incremental visitors

The workbench is a long-lived inspector. When a node is attached or an edge is connected, the workbench should update its view incrementally, not re-traverse the whole graph. v2 adds an opt-in incremental visitor protocol on top of graph events.

interface IncrementalVisitor<R> : Visitor<R> {
    fun onAttach(ctx: VisitContext, node: Node) {}
    fun onDetach(ctx: VisitContext, node: Node) {}
    fun onConnect(ctx: VisitContext, edge: Edge<*>) {}
    fun onDisconnect(ctx: VisitContext, edge: Edge<*>) {}
}

fun <R> Graph.installIncremental(
    pass: GraphPass<R>,
    visitor: IncrementalVisitor<R>,
): GraphInstallation {
    visit(pass.selector, pass.traversal, visitor)  // initial pass
    val job = coroutineScope.launch {
        graphEvents.collect { event ->
            val ctx = visitContextFor(event)
            when (event) {
                is GraphEvent.NodeAttached -> visitor.onAttach(ctx, event.node)
                is GraphEvent.NodeDetached -> visitor.onDetach(ctx, event.node)
                is GraphEvent.EdgeConnected -> visitor.onConnect(ctx, event.edge)
                is GraphEvent.EdgeDisconnected -> visitor.onDisconnect(ctx, event.edge)
            }
        }
    }
    return GraphInstallation { job.cancel() }
}
Where this lands. The Engine workbench's tree panel and inspector panel today re-call toJsonElement() on every render. With an incremental visitor and the existing PortEvent stream from PortCapability, the panels become live without a polling loop.

23. Attributes & inheritance

Visitors need metadata, but the graph core should not know analytics, auth, documentation, workflow policy, or deep-link policy. The typed attribute bag from v1 stays; v2 adds documented inheritance rules.

class AttributeKey<T : Any>(val name: String, val inheritable: Boolean = false)

class Attributes private constructor(
    private val values: MutableMap<AttributeKey<*>, Any>,
) {
    operator fun <T : Any> set(key: AttributeKey<T>, value: T) { values[key] = value }

    @Suppress("UNCHECKED_CAST")
    operator fun <T : Any> get(key: AttributeKey<T>): T? = values[key] as? T

    fun has(key: AttributeKey<*>): Boolean = key in values

    companion object { val Empty = Attributes(mutableMapOf()) }
}

interface Attributed { val attributes: Attributes }

fun <T : Any, A> A.attr(key: AttributeKey<T>, value: T): A where A : Attributed {
    attributes[key] = value; return this
}

Inheritance rules

If AttributeKey.inheritable = true, a missing attribute on a node falls back to the owning graph, then its parent graph, then root. Non-inheritable keys never look up the chain.

fun <T : Any> Visitable.resolve(key: AttributeKey<T>): T? {
    if (this is Attributed) attributes[key]?.let { return it }
    if (!key.inheritable) return null
    return when (this) {
        is Node -> graph.resolve(key)
        is Graph -> parentGraph?.resolve(key)
        else -> null
    }
}

// Example: AuthAttr.RequiresUser is inheritable — set once on /home, applies to all children.
object AuthAttr {
    val RequiresUser = AttributeKey<Boolean>("auth.requiresUser", inheritable = true)
}
Rule. Inheritance is opt-in per key. Most attributes (route patterns, analytics names) are scoped to one element and stay non-inheritable. Cross-cutting policies (auth, feature gates, environment) are good inheritance candidates.

24. Testing affordances

The whole point of pushing capabilities into passes is that they become testable in isolation. v2 ships a small test toolkit so a pass author writes one fixture, not three.

// 1. Build a fake graph quickly.
fun testGraph(label: String = "test", build: Graph.() -> Unit): Graph =
    Graph(label = label, dependencyAdapter = NoOpDependencyAdapter, builder = build)

// 2. Assert which arcs a selector emits, in order.
fun Selector.assertEmits(
    from: Visitable,
    expected: List<Pair<VisitKind, Visitable>>,
) {
    val actual = outgoing(VisitContext.empty(from), from).toList()
    assertEquals(expected.size, actual.size, "arc count")
    expected.zip(actual).forEach { (e, a) ->
        assertEquals(e.first, a.kind)
        assertSame(e.second, a.to)
    }
}

// 3. Run a pass against a built graph.
fun <R> GraphPass<R>.runAgainst(graph: Graph): R = graph.run(this)

// 4. Assert findings.
fun GraphCompileReport.assertFinding(code: String, severity: Severity = Severity.Error) {
    val match = findings.firstOrNull { it.code == code && it.severity == severity }
    assertNotNull(match, "expected finding $code at $severity, got: $findings")
}

Example test

@Test
fun `wire pass reports ambiguous unnamed consumer`() {
    val g = testGraph {
        Node { object : BasicNode(it) {
            val a by provides<String>("a", "hello")
            val b by provides<String>("b", "world")
        }}
        Node { object : BasicNode(it) {
            val consumer by consumes<String>()    // no key — ambiguous
        }}
    }

    val report = g.compile { +WirePass() }
    report.assertFinding("wiring.ambiguous", Severity.Error)
}

25. Concrete examples

25.1 Compile BestBuds end-to-end

val report = BestBuds.compile {
    +WirePass()                          // emits patch
    +RequireFullyWiredPass()
    +RouteIndexPass()
    +CrossGraphReachabilityPass()
    +AuthCoveragePass(defaultRequiresUser = true) {
        public("/")
        public("/onboarding")
    }
    +DeepLinkInstallPass()
    +AnalyticsInstallPass(analytics)
}

if (!report.success) {
    report.errors.forEach { Logger.e("compile.failed", it) }
    return
}

val installation = report.installations
// later, at app shutdown:
installation.close()

25.2 Engine document export, deterministically

fun reaktorGraphDocument(app: ReaktorAppDefinition): JsonElement {
    val report = app.graph().compile {
        +GraphDocumentPass(appId = app.id, appLabel = app.name)
        +SourceMapPass(sourceMaps)
    }
    val doc = report[PassKeys.GraphDocument] ?: error("document pass did not run")
    return Json.encodeToJsonElement(GraphDocument.serializer(), doc)
}

25.3 Workbench live tree panel

val installation = workbenchGraph.installIncremental(
    pass = GraphDocumentPass(appId = "engine", appLabel = "Reaktor Engine"),
    visitor = TreePanelVisitor(treePanelState),
)

// TreePanelVisitor implements IncrementalVisitor and updates a StateFlow
// that Compose collects. No polling.

25.4 Polyglot wire across the React bridge

val matcher = ChainedMatcher(ByContractId, ByKType, ByLocalType)
val report = appGraph.compile {
    +WirePass(matcher = matcher)
}
// Kotlin ProviderPort<MessagingApi> with ContractAttr.Id = "MessagingApi/v1"
// wires to TS ConsumerPort with the same ContractId.

26. Acceptance tests

AreaAcceptance checkWhy it matters
BestBuds routes RouteIndexPass finds /, /onboarding, /home, /chats, /chats/{id}, /friends, /profiles/user, and event routes when the flag is on. Confirms nested route graphs and feature-gated containers are represented.
BestBuds navigation NavigationSelector reports cross-graph edges from friends to chat, onboarding to home, and event tabs to create-event flows. Validates the app's real route topology.
BestBuds wiring WirePass + RequireFullyWiredPass produce a deterministic report; the resulting patch applies atomically; legacy autoWire() still works as a wrapper. Makes wiring failures inspectable before runtime traffic.
Cross-graph reachability CrossGraphReachabilityPass emits a navigation.unreachable finding for any cross-graph edge whose target graph is not mounted under a container. Catches today's silent Logger.w failure.
Engine export GraphDocumentPass emits the same edge categories as the current builder; running twice produces identical IDs, node order, and edge order. Required for workbench diffs, screenshots, AI context, deploy diagnostics.
Engine self-inspection Running GraphDocumentPass on WorkbenchGraph terminates without recursing through the workbench's own graph-viewer node. Validates the introspection-exclusion mechanism end-to-end.
Polyglot wiring A Kotlin port with ContractAttr.Id = "MessagingApi/v1" wires to a TS port with the same id, regardless of local Kotlin Type. Confirms the kernel's polyglot port-identity rule is honored by the visitor system.
Pass DAG DeepLinkInstallPass declares dependsOn = [RouteIndex]; the compiler topologically sorts; running DeepLinkInstallPass alone reuses a cached RouteIndex when present. Validates pass composition.
Reactive install AnalyticsInstallPass fires screen_view for every distinct active route during a recorded navigation sequence; closing the installation cancels the subscription. Validates reactive selector lifecycle.
Incremental visitor The Engine tree panel updates within one frame when a node is attached to a child graph; no full re-traversal. Validates the incremental protocol.

27. Migration plan

StepChangeCompatibility
0 Freeze the current Engine graph document contract; add snapshot tests for ReaktorApps.bestBuds and ReaktorApps.engine. Prevents visitor migration from breaking the visible workbench renderer.
1 Add VisitArc, VisitKind (v2 expanded), VisitContext, VisitDecision, VisitEnter, Attributes, PassKey, PassContext. Additive.
2 Add new arc-based Selector.outgoing(ctx, current); provide adapter from legacy neighbors() selectors. Old neighbor selectors continue to work via wrapper.
3 Add result-producing Traversal with determinism contract, plus SuspendTraversal. Keep old Traverser wrappers temporarily.
4 Add Visitor<R>, SuspendVisitor<R>, ReaktorVisitor<R>. Existing visitors migrate one by one; old Visitor stays as an alias.
5 Add GraphPatch sealed hierarchy + Graph.apply(patch) via the refinement plan's Wiring. New API only; existing mutations untouched.
6 Implement WirePass emitting a patch; legacy Graph.autoWire() calls run(WirePass()).second.apply(). Behavior preserved for all current callers.
7 Implement GraphDocumentPass with introspection-exclusion filter; route reaktorGraphDocument(appId) through it. Behavior preserved; snapshot tests verify byte-for-byte parity.
8 Implement RouteIndexPass, DeepLinkInstallPass, AuthCoveragePass; route WebNavigationBridge.buildRouteIndex() through it. Bridge constructor wraps the new pass internally.
9 Add ReactivePass + ReactiveSelector; migrate AnalyticsInstallPass. New analytics path; legacy listeners stay.
10 Add Graph.compile { ... } with DAG sorting and PassContext; document the determinism contract. Used by both BestBuds and Engine; old manual calls stay as wrappers.
11 Implement ModuleContractPass and CrossGraphReachabilityPass; integrate with refinement plan L3 module work. Gated on refinement plan P6; standalone CrossGraphReachabilityPass can land independently.
12 Add IncrementalVisitor + graph.installIncremental(...); migrate Engine tree/inspector panels. Existing polling code stays; opt-in per panel.
13 Add product bundles: BestBudsRuntimePasses for installs, EngineSnapshotPasses for export/diagnostics. Keeps runtime mutation separate from read-only workbench snapshots.

28. Suggested files to create

dev.shibasis.reaktor.portgraph.visitor/
  Visitable.kt
  VisitArc.kt
  VisitContext.kt
  Selector.kt              (+ combinators)
  ReactiveSelector.kt      [new]
  Traversal.kt             (DepthFirst, BreadthFirst, Topological)
  SuspendTraversal.kt      [new]
  Visitor.kt
  SuspendVisitor.kt        [new]
  Attributes.kt            (+ inheritance)

dev.shibasis.reaktor.graph.visitor/
  GraphSelectors.kt        (+ ModuleContractSelector, SupervisionSelector)
  ReaktorVisitor.kt        (+ Actor/Service hooks)
  GraphPass.kt             (+ PassContext, dependsOn)
  GraphCompiler.kt         (+ DAG sort)
  GraphInstallation.kt
  GraphPatch.kt            [new]
  PortMatcher.kt           [new]
  Finding.kt               [new]
  IncrementalVisitor.kt    [new]
  ReactivePass.kt          [new]
  ScheduledGraphPass.kt    [new]
  IntrospectionAttr.kt     [new]

dev.shibasis.reaktor.graph.passes/
  WirePass.kt              (now emits GraphPatch)
  RequireFullyWiredPass.kt
  RouteIndexPass.kt
  GraphJsonPass.kt
  GraphDocumentPass.kt
  DeepLinkInstallPass.kt
  AnalyticsInstallPass.kt  (now ReactivePass)
  AuthCoveragePass.kt
  WorkflowPlanPass.kt
  ModuleContractPass.kt    [new]
  CrossGraphReachabilityPass.kt [new]
  SourceMapPass.kt         [new]

dev.shibasis.reaktor.graph.testing/
  TestGraph.kt             [new]
  SelectorAssertions.kt    [new]
  PassAssertions.kt        [new]

ai.bestbuds.app.graph/
  BestBudsRuntimePasses.kt

ai.bestbuds.reaktor/
  EngineSnapshotPasses.kt

29. Open questions

  1. GraphPatch concurrency. If two passes in the same compile both emit patches that touch the same port, what's the conflict resolution? Right now the compiler applies in dependsOn order, but conflicts on the same edge should at least surface as a patch.conflict finding.
  2. Reactive pass back-pressure. A ReactivePass subscribed to backStack.entries may be slower than emissions. Should we buffer, conflate, or drop? Recommendation: conflate() by default with an opt-in buffer(n).
  3. Determinism under concurrent attach. If a graph mutates while a traversal is in progress, ordering breaks. Should traversal take a snapshot? Or require the caller to suspend mutations? Recommendation: snapshot the node collection at the start of each Selector.outgoing call; document as "best-effort consistency under quiescent graph state."
  4. PassContext type erasure. ctx[PassKey<RouteIndex>] uses unchecked casts internally. Is there a cleaner type-safe encoding? Likely needs Kotlin contracts.
  5. Variance opt-in scope. VarianceAwareMatcher is currently per-WirePass. Should variance be a per-port attribute (PortAttr.AllowCovariant) instead, so a single graph can mix strict and permissive?
  6. Cycle policy for topological traversal. A workflow graph with cycles must surface them, but WorkflowPlanPass as written returns a partial plan. Should it fail-fast or emit a finding and continue?
  7. Source-map storage location. SourceMapPass reads a SourceMapStore. Where does the store live — in reaktor-io, in reaktor-compiler output, or as an Engine-only resource?
  8. Module boundary semantics during lifecycle. Are module contracts validated at Compile phase only, or re-validated on each Attaching transition? Suspect compile-only is enough but worth a runtime spike.
  9. How do reactive selectors interact with introspection-exclusion? If the workbench installs a reactive pass over its own active route, should excluded nodes still emit transitions? Recommendation: yes — exclusion is for visualizers; analytics doesn't care.

30. Appendix: compatibility adapters

To keep migration safe, support the old neighbor selector style as a wrapper.

fun interface NeighborSelector {
    fun neighbors(visitable: Visitable): List<Visitable>
}

fun NeighborSelector.asSelector(kind: VisitKind = VisitKind.Derived): Selector =
    Selector { _, current ->
        neighbors(current).asSequence().map { next ->
            VisitArc(from = current, to = next, kind = kind)
        }
    }

Existing function wrappers

fun Graph.autoWire() {
    val (_, patch) = run(WirePass())
    if (patch != null) apply(patch)
}

fun Graph.requireFullyWired() {
    val report = compile { +RequireFullyWiredPass() }
    require(report.success) { report.errors.joinToString("\n") { it.message } }
}

fun Graph.toJsonElement(): JsonElement = run(GraphJsonPass())

Final recommendation

Build the visitor system around this invariant:

Graph = runtime structure. Selector = graph view. Traversal = order. Visitor = read-only logic. Patch = mutation as data. Pass = reusable, diagnosable capability that fits into a DAG.

The crucial implementation shifts compared to v1 are small but powerful:

  1. Selector returns VisitArc (already in v1).
  2. Mutations become GraphPatch values, applied transactionally.
  3. Passes form a DAG with shared results via PassContext.
  4. Selectors compose; reactive selectors are first-class for live installs.
  5. Polyglot wiring is delegated to PortMatcher so the same passes work across language bridges.
  6. The Engine workbench can inspect itself without recursing forever.

Each of these is additive over v1 and over the existing runtime, so the migration plan above can land one step at a time without breaking any currently shipping app.