Files
kami 2c459da009 feat(router): implement Epic 14 — core:router module
Implements the full conversational router facade: RouterState, RouterReducer,
RouterProjector, RouterRepository, RouterContextBuilder, RouterFacade, protocol
types, WebSocket wiring, infrastructure factory, and deterministic test suite.

Also fixes spec divergences found in post-implementation review:
- Add SteeringNote domain object to core:context (epic prerequisite)
- Rename RouterFacade.handleChat → onUserInput per spec interface contract
- Add in-memory ConcurrentHashMap conversation history to DefaultRouterFacade
- Make RouterRepository.getRouterState suspend
- Rename RouterConfig.keepLast → conversationKeepLast, fix defaults (6, 4096)
- Refactor InfrastructureModule.createRouterFacade to self-assemble internally
- Fix FileReadTool: allowedPaths was dead constructor param (@SuppressUnusedParameter);
  now stored as private val and enforced in validateRequest
- Disable koverVerify on modules tested via testing/ submodules or with
  hardware/integration dependencies (24 modules); build gate now passes clean
2026-05-21 15:06:20 +04:00

12 KiB

Epic 14 — Router (:core:router)

status: planned scope: resident conversational facade with inference, isolated L2 memory, and steering support goal: user can interact with the harness through a conversational interface that understands workflow state, responds using inference, and accepts steering input


prerequisites

before the router epic begins, two additions are required in :core:context:

task 0: SteeringNote domain object

problem: CompressionStrategy.SteeringNote exists — the compression system knows how to handle steering notes (never drop). but the domain object itself doesn't exist.

deliverables:

// core/context/src/main/kotlin/com/correx/core/context/steering/SteeringNote.kt
@Serializable
data class SteeringNote(
    val sessionId: SessionId,
    val content: String,
    val stageId: StageId? = null, // null = applies to current stage
    val createdAt: Instant = Clock.System.now(),
)
// core/events/.../SteeringNoteAddedEvent.kt
@Serializable
@SerialName("steering_note_added")
data class SteeringNoteAddedEvent(
    val sessionId: SessionId,
    val content: String,
    val stageId: StageId? = null,
) : EventPayload

files:

  • core/context/.../steering/SteeringNote.kt — new
  • core/events/.../SteeringNoteAddedEvent.kt — new
  • core/events/.../serialization/Serialization.kt — register SteeringNoteAddedEvent

acceptance criteria:

  • SteeringNote is @Serializable
  • SteeringNoteAddedEvent is registered in the serialization module
  • round-trip serialization test passes

task 1: RouterState and RouterReducer

purpose: router maintains its own L2 memory, isolated from execution context. it is rebuilt from events — same pattern as every other module.

deliverables:

// RouterState — what the router knows
@Serializable
data class RouterL2Entry(
    val stageId: StageId,
    val summary: String,         // human-readable stage outcome summary
    val outcome: StageOutcomeKind,
    val timestamp: Instant,
)

enum class StageOutcomeKind { SUCCESS, FAILURE, CANCELLED }

@Serializable
data class RouterState(
    val sessionId: SessionId,
    val workflowStatus: WorkflowStatus,  // IDLE, RUNNING, PAUSED, COMPLETED, FAILED
    val currentStageId: StageId?,
    val l2Memory: List<RouterL2Entry>,   // stage summaries, ordered oldest→newest
    val conversationHistory: List<RouterTurn>, // router ↔ user turns
)

@Serializable
data class RouterTurn(
    val role: TurnRole,
    val content: String,
    val timestamp: Instant,
)

enum class TurnRole { USER, ROUTER }
enum class WorkflowStatus { IDLE, RUNNING, PAUSED, COMPLETED, FAILED }
// RouterReducer — listens to domain events, builds RouterState
interface RouterReducer : Reducer<RouterState>

class DefaultRouterReducer : RouterReducer {
    override val initial: RouterState = RouterState(
        sessionId = SessionId(""),
        workflowStatus = WorkflowStatus.IDLE,
        currentStageId = null,
        l2Memory = emptyList(),
        conversationHistory = emptyList(),
    )

    override fun reduce(state: RouterState, event: StoredEvent): RouterState
}

reducer reacts to:

  • WorkflowStartedEvent → set status RUNNING, set sessionId
  • WorkflowCompletedEvent → set status COMPLETED
  • WorkflowFailedEvent → set status FAILED
  • OrchestrationPausedEvent → set status PAUSED
  • OrchestrationResumedEvent → set status RUNNING
  • StageCompletedEvent → append RouterL2Entry with outcome SUCCESS, update currentStageId
  • StageFailedEvent → append RouterL2Entry with outcome FAILURE
  • all other events → pass through unchanged

important: RouterReducer does NOT append conversation turns. conversation history is managed by RouterFacade directly — it is not event-sourced (router turns are ephemeral, not part of the domain event log).

files:

  • core/router/src/main/kotlin/com/correx/core/router/state/RouterState.kt
  • core/router/src/main/kotlin/com/correx/core/router/state/RouterReducer.kt
  • core/router/src/main/kotlin/com/correx/core/router/state/DefaultRouterReducer.kt

acceptance criteria:

  • WorkflowStartedEventRUNNING
  • StageCompletedEvent → L2 entry appended
  • StageFailedEvent → L2 entry with FAILURE outcome appended
  • WorkflowCompletedEventCOMPLETED
  • unrelated events pass through unchanged
  • deterministic tests: 8 minimum

task 2: RouterProjector and RouterRepository

purpose: standard projection pattern. router state is rebuildable from the event log.

deliverables:

class RouterProjector(
    private val reducer: RouterReducer,
) : Projection<RouterState> {
    override val initial: RouterState get() = reducer.initial
    override fun apply(state: RouterState, event: StoredEvent): RouterState =
        reducer.reduce(state, event)
}

interface RouterRepository {
    suspend fun getRouterState(sessionId: SessionId): RouterState
}

class DefaultRouterRepository(
    private val replayer: EventReplayer<RouterState>,
) : RouterRepository {
    override suspend fun getRouterState(sessionId: SessionId): RouterState =
        replayer.rebuild(sessionId)
}

files:

  • core/router/src/main/kotlin/com/correx/core/router/state/RouterProjector.kt
  • core/router/src/main/kotlin/com/correx/core/router/state/RouterRepository.kt
  • core/router/src/main/kotlin/com/correx/core/router/state/DefaultRouterRepository.kt

acceptance criteria:

  • replay from empty event log returns initial
  • replay after WorkflowStartedEvent + 2x StageCompletedEvent returns correct L2 memory
  • projection test: 4 minimum

task 3: RouterContextBuilder

purpose: builds a router-scoped ContextPack for inference. router never sees raw execution context — only its own L2 memory, workflow status, and conversation history.

deliverables:

interface RouterContextBuilder {
    fun build(state: RouterState, budget: TokenBudget): ContextPack
}

class DefaultRouterContextBuilder : RouterContextBuilder {
    override fun build(state: RouterState, budget: TokenBudget): ContextPack
}

pack contents (in priority order, highest first):

  1. L0 — system prompt for router role (non-authoritative, conversational assistant)
  2. L0 — current workflow status + current stage id
  3. L1 — recent conversation history (last N turns, configurable, default 6)
  4. L2 — stage summaries from l2Memory (oldest dropped first under budget pressure)

hard constraints:

  • router context pack MUST NOT contain raw events
  • router context pack MUST NOT contain artifact content
  • router context pack MUST NOT contain tool outputs
  • budget enforcement: if L2 entries overflow, drop oldest first — same eviction order as core context

files:

  • core/router/src/main/kotlin/com/correx/core/router/context/RouterContextBuilder.kt
  • core/router/src/main/kotlin/com/correx/core/router/context/DefaultRouterContextBuilder.kt

acceptance criteria:

  • pack never contains raw events or artifacts
  • L2 entries dropped oldest-first under budget pressure
  • L0 entries (status, system prompt) are never dropped
  • conversation history capped at configured keepLast
  • deterministic tests: 6 minimum

task 4: RouterFacade

purpose: single entry point for all router interactions. wires together state, context building, inference, and steering.

deliverables:

interface RouterFacade {
    suspend fun onUserInput(
        sessionId: SessionId,
        input: String,
        mode: InputMode,          // from existing TUI protocol
    ): RouterResponse
}

@Serializable
data class RouterResponse(
    val content: String,
    val steeringEmitted: Boolean, // true if a SteeringNote was also emitted
)

class DefaultRouterFacade(
    private val repository: RouterRepository,
    private val contextBuilder: RouterContextBuilder,
    private val inferenceRouter: InferenceRouter,
    private val eventStore: EventStore,
    private val config: RouterConfig,
) : RouterFacade {

    // in-memory conversation history — ephemeral, not event-sourced
    private val histories = ConcurrentHashMap<SessionId, MutableList<RouterTurn>>()

    override suspend fun onUserInput(
        sessionId: SessionId,
        input: String,
        mode: InputMode,
    ): RouterResponse
}

execution path:

  1. load RouterState from RouterRepository
  2. append user turn to in-memory history
  3. build ContextPack via RouterContextBuilder
  4. call InferenceRouter.route() → get provider
  5. call provider.infer() with router context pack
  6. parse response text
  7. append router turn to in-memory history
  8. if mode == InputMode.STEERING → emit SteeringNoteAddedEvent to event store
  9. return RouterResponse
@Serializable
data class RouterConfig(
    val conversationKeepLast: Int = 6,
    val tokenBudget: Int = 4096,
)

files:

  • core/router/src/main/kotlin/com/correx/core/router/RouterFacade.kt
  • core/router/src/main/kotlin/com/correx/core/router/DefaultRouterFacade.kt
  • core/router/src/main/kotlin/com/correx/core/router/RouterConfig.kt
  • core/router/src/main/kotlin/com/correx/core/router/RouterResponse.kt

acceptance criteria:

  • normal mode: inference called, response returned, no SteeringNoteAddedEvent emitted
  • steering mode: inference called, response returned, SteeringNoteAddedEvent emitted to event store
  • conversation history grows per turn, capped at conversationKeepLast * 2 (user + router turns)
  • RouterRepository is called once per onUserInput to get fresh state
  • mock inference provider used in tests — no live model calls
  • tests: 6 minimum

task 5: module wiring

purpose: wire :core:router into InfrastructureModule so it's available to the server.

deliverables:

// InfrastructureModule addition
fun createRouterFacade(
    eventStore: EventStore,
    inferenceRouter: InferenceRouter,
    config: RouterConfig = RouterConfig(),
): RouterFacade {
    val reducer = DefaultRouterReducer()
    val projector = RouterProjector(reducer)
    val replayer = DefaultEventReplayer(eventStore, projector)
    val repository = DefaultRouterRepository(replayer)
    val contextBuilder = DefaultRouterContextBuilder()
    return DefaultRouterFacade(repository, contextBuilder, inferenceRouter, eventStore, config)
}

server exposes router via existing websocket — ClientMessage variant for user input already exists. RouterResponse.content maps to an existing ServerMessage variant (check protocol layer before adding new ones).

files:

  • infrastructure/.../InfrastructureModule.kt — add createRouterFacade()
  • apps/server/.../CorrexServer.kt — wire RouterFacade, handle user input messages

acceptance criteria:

  • createRouterFacade() compiles and returns a usable RouterFacade
  • server routes user input messages through RouterFacade
  • steering mode input emits SteeringNoteAddedEvent visible in event stream
  • no domain types leak into protocol layer

module definition

// core/router/build.gradle.kts
dependencies {
    implementation(project(":core:events"))
    implementation(project(":core:context"))
    implementation(project(":core:inference"))
    implementation(project(":core:sessions"))
}

important: :core:router MUST NOT depend on :core:orchestration or :core:kernel. it reads state from events only — never from the orchestrator directly.


security constraints

  • router context pack must never contain raw EventPayload objects
  • router must never emit events that mutate workflow state (only SteeringNoteAddedEvent is permitted)
  • conversation history is in-memory and session-scoped — no cross-session leakage (ConcurrentHashMap<SessionId, ...>)
  • inference request to router must use the same cancellation semantics as orchestrator inference

what this epic does NOT include

explicitly deferred:

  • router L3 persistence (cross-session memory) — infrastructure concern, later epic
  • streaming inference responses — deferred
  • router-initiated workflow control (pause/cancel) — goes through server endpoints, not router
  • router system prompt configuration via harness.yaml — deferred to config epic
  • multiple concurrent router instances — resident single instance per server for now

ordering

task 0 (SteeringNote) → task 1 (RouterState/Reducer) → task 2 (Projector/Repository)
    → task 3 (ContextBuilder) → task 4 (RouterFacade) → task 5 (wiring)

task 0 touches :core:context and :core:events — complete and merge before starting task 1.