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
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— newcore/events/.../SteeringNoteAddedEvent.kt— newcore/events/.../serialization/Serialization.kt— registerSteeringNoteAddedEvent
acceptance criteria:
SteeringNoteis@SerializableSteeringNoteAddedEventis 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 sessionIdWorkflowCompletedEvent→ set status COMPLETEDWorkflowFailedEvent→ set status FAILEDOrchestrationPausedEvent→ set status PAUSEDOrchestrationResumedEvent→ set status RUNNINGStageCompletedEvent→ appendRouterL2Entrywith outcome SUCCESS, update currentStageIdStageFailedEvent→ appendRouterL2Entrywith 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.ktcore/router/src/main/kotlin/com/correx/core/router/state/RouterReducer.ktcore/router/src/main/kotlin/com/correx/core/router/state/DefaultRouterReducer.kt
acceptance criteria:
WorkflowStartedEvent→RUNNINGStageCompletedEvent→ L2 entry appendedStageFailedEvent→ L2 entry with FAILURE outcome appendedWorkflowCompletedEvent→COMPLETED- 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.ktcore/router/src/main/kotlin/com/correx/core/router/state/RouterRepository.ktcore/router/src/main/kotlin/com/correx/core/router/state/DefaultRouterRepository.kt
acceptance criteria:
- replay from empty event log returns
initial - replay after
WorkflowStartedEvent+ 2xStageCompletedEventreturns 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):
- L0 — system prompt for router role (non-authoritative, conversational assistant)
- L0 — current workflow status + current stage id
- L1 — recent conversation history (last N turns, configurable, default 6)
- 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.ktcore/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:
- load
RouterStatefromRouterRepository - append user turn to in-memory history
- build
ContextPackviaRouterContextBuilder - call
InferenceRouter.route()→ get provider - call
provider.infer()with router context pack - parse response text
- append router turn to in-memory history
- if
mode == InputMode.STEERING→ emitSteeringNoteAddedEventto event store - 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.ktcore/router/src/main/kotlin/com/correx/core/router/DefaultRouterFacade.ktcore/router/src/main/kotlin/com/correx/core/router/RouterConfig.ktcore/router/src/main/kotlin/com/correx/core/router/RouterResponse.kt
acceptance criteria:
- normal mode: inference called, response returned, no
SteeringNoteAddedEventemitted - steering mode: inference called, response returned,
SteeringNoteAddedEventemitted to event store - conversation history grows per turn, capped at
conversationKeepLast * 2(user + router turns) RouterRepositoryis called once peronUserInputto 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— addcreateRouterFacade()apps/server/.../CorrexServer.kt— wireRouterFacade, handle user input messages
acceptance criteria:
createRouterFacade()compiles and returns a usableRouterFacade- server routes user input messages through
RouterFacade - steering mode input emits
SteeringNoteAddedEventvisible 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
EventPayloadobjects - router must never emit events that mutate workflow state (only
SteeringNoteAddedEventis 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.