# 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:** ```kotlin // 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(), ) ``` ```kotlin // 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:** ```kotlin // 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, // stage summaries, ordered oldest→newest val conversationHistory: List, // 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 } ``` ```kotlin // RouterReducer — listens to domain events, builds RouterState interface RouterReducer : Reducer 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:** - `WorkflowStartedEvent` → `RUNNING` - `StageCompletedEvent` → L2 entry appended - `StageFailedEvent` → L2 entry with FAILURE outcome appended - `WorkflowCompletedEvent` → `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:** ```kotlin class RouterProjector( private val reducer: RouterReducer, ) : Projection { 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, ) : 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:** ```kotlin 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:** ```kotlin 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>() 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` ```kotlin @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:** ```kotlin // 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 ```kotlin // 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`) - 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.