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.
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.
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.StructuralSelector union ActiveSelector, RouteSelector filter { arc -> ... }, StructuralSelector restrictTo<Graph>(). v1 had six selectors but no way to derive new views without writing new objects.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.SuspendTraversal alongside the synchronous one without forcing every pass to be suspend.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.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.IntrospectionExcluded attribute, arc-level filtering, and a documented cycle policy so visualizers do not infinite-recurse.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.WorkflowDependency as the only domain kind; v2 adds ModuleProvides, ModuleConsumes, and CrossGraphEdge for the refinement plan's L3-L4 work.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
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.Sequence<VisitArc>; arcs carry a typed VisitKind.
G2 — No visit context
VisitContext with depth, path, graphPath, routePath, nearest<T>().
G3 — No traversal control
DepthFirstTraverser walks everything. There is no way for a visitor to say "stop here" or "skip my children" beyond throwing.VisitDecision with Continue / SkipChildren / Stop.
G4 — No typed results
Visitor returns nothing. HierarchyVisitor exposes a lateinit rootMap that throws if you forget to traverse before reading.Visitor<R> with fun result(): R.
G5 — Visitors quietly mutate
autoWire)WireVisitor.finish() would inherit the same problem — partial wiring on listener failure.MutatingVisitor returns GraphPatch; patches commit through Wiring (refinement plan L0.2).
G6 — Port type matching is string equality
autoWire)localProviders[consumer.type] compares Type.type: String. Loses generic information; cannot cross language boundaries to TS or JS bridges.PortMatcher strategy with KType and ContractId variants.
G7 — No async pass support
runBlocking into onTransition.SuspendTraversal in parallel with Traversal.
G8 — No reactive view
StateFlow; a v1 ActiveSelector snapshots it once. Analytics, browser URL sync, and devtools all need to re-run when it changes.ReactiveSelector.arcs(graph): Flow<VisitArc> with installation-bound subscription.
G9 — No selector composition
union, intersect, filter, restrictTo<T>() combinators.
G10 — Passes don't share results
DeepLinkInstallPass reindexes routes that RouteIndexPass already produced. Every compose-time call duplicates work.PassContext with typed results; passes declare dependsOn and read via ctx[RouteIndexPass].
G11 — Self-reference can infinite-recurse
IntrospectionExcluded attribute + arc-level VisitFilter.
G12 — Diagnostics are too thin
success / messages / error is enough for "did it compile" but not for "which port failed and why."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.
ChatGraphwires message repositories, chat actors, session interactors, socket/typing/timeline logic, chat list, chat detail, and friend/group profile routes.EventGraphcreates a route in the parent graph and passes it into private/public child graphs, so navigation analysis must preserve cross-graph route ownership.FriendGraphreceiveschatGraph.chatRoute, which means route reachability is not a pure tree problem.FeatureFlags.EVENTS_TABcontrols 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.
WorkbenchGraphcomposes a/workbenchcontainer over many pane graphs while also exposing cross-cutting command/data/subsystem services.ReaktorGraphDocumentBuilderalready 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
VisitContext needs both graphPath and routePath; VisitArc.kind must distinguish containment, route attachment, navigation target, active child graph, and cross-graph edges.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.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.IntrospectionExcluded) and a documented cycle policy.5. Design principles
| Principle | Meaning | Consequence |
|---|---|---|
| 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.
thing that can be visited
typed relation
graph view
order & mode
logic & result
packaged capability
Two formal companions, not part of the spine but always present:
mutation as data
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 }
}
VisitContext and VisitArc.kind.
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()
}
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
}
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.
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.
| Selector | Use case | v2 status |
|---|---|---|
StructuralSelector | Export, route indexing, validation, recursive compile. | Kept. |
RouteSelector | Route index, deeplinks, sitemap, auth coverage. | Kept. |
NavigationSelector | Route reachability, dead-route detection. | Kept + emits CrossGraphEdge arcs. |
PortConnectivitySelector | Wiring, capability analysis. | Kept. |
ActiveSelector | URL sync, analytics, focused back handling. | Has a reactive companion now (§14). |
WorkflowSelector | Task 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))
}
}
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
| Code | Severity | Meaning |
|---|---|---|
wiring.missing | Error | Consumer port has no matching provider. |
wiring.ambiguous | Error | More than one provider matches an unnamed consumer. |
wiring.crossgraph | Info | Consumer matched a provider from a parent graph's DI scope. |
route.deadend | Warn | Route has no attached node or no incoming edges. |
route.duplicate | Error | Two routes share the same pattern in the same graph. |
module.contract.unsatisfied | Error | A module declares consumes<T> but no upstream module provides it. |
introspection.cycle | Fatal | A self-referential visitor hit a cycle without an IntrospectionExcluded mark. |
pass.failed | Fatal | A pass threw before completing. |
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.
| Pass | Purpose | Mutates | v2 status |
|---|---|---|---|
WirePass | Connect unmatched consumers to providers. | via GraphPatch | Refactored to emit a patch instead of side-effecting. |
RequireFullyWiredPass | Validate that every consumer has an edge. | No | Kept; emits Findings. |
RouteIndexPass | Build a path-pattern → route map. | No | Kept; result reused by deeplinks/sitemap. |
GraphJsonPass | Recursive JSON dump. | No | Kept. |
GraphDocumentPass | Engine's first-class graph document. | No | Sharpened: deterministic ordering, stable IDs. |
DeepLinkInstallPass | Wire URL changes to NavCommand. | via install | Now depends on RouteIndexPass. |
AnalyticsInstallPass | Emit screen_view on active route change. | via install | Now a ReactivePass. |
AuthCoveragePass | Report which routes are guarded. | No | Kept. |
WorkflowPlanPass | Compile a task DAG from workflow edges. | No | Uses Topological traversal. |
ModuleContractPass | Validate that module consumes are satisfied. | No | New. |
CrossGraphReachabilityPass | Detect orphan target graphs in cross-graph edges. | No | New. |
SourceMapPass | Collect source-code locations for nodes/ports. | No | New; 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 { ... }
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() }
}
}
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 { ... }
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() }
}
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)
}
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
| Area | Acceptance check | Why 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
| Step | Change | Compatibility |
|---|---|---|
| 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
- 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
dependsOnorder, but conflicts on the same edge should at least surface as apatch.conflictfinding. - Reactive pass back-pressure. A
ReactivePasssubscribed tobackStack.entriesmay be slower than emissions. Should we buffer, conflate, or drop? Recommendation:conflate()by default with an opt-inbuffer(n). - 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.outgoingcall; document as "best-effort consistency under quiescent graph state." - PassContext type erasure.
ctx[PassKey<RouteIndex>]uses unchecked casts internally. Is there a cleaner type-safe encoding? Likely needs Kotlin contracts. - Variance opt-in scope.
VarianceAwareMatcheris currently per-WirePass. Should variance be a per-port attribute (PortAttr.AllowCovariant) instead, so a single graph can mix strict and permissive? - Cycle policy for topological traversal. A workflow graph with cycles must surface them, but
WorkflowPlanPassas written returns a partial plan. Should it fail-fast or emit a finding and continue? - Source-map storage location.
SourceMapPassreads aSourceMapStore. Where does the store live — inreaktor-io, inreaktor-compileroutput, or as an Engine-only resource? - Module boundary semantics during lifecycle. Are module contracts validated at
Compilephase only, or re-validated on eachAttachingtransition? Suspect compile-only is enough but worth a runtime spike. - 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:
SelectorreturnsVisitArc(already in v1).- Mutations become
GraphPatchvalues, applied transactionally. - Passes form a DAG with shared results via
PassContext. - Selectors compose; reactive selectors are first-class for live installs.
- Polyglot wiring is delegated to
PortMatcherso the same passes work across language bridges. - 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.