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
13 KiB
architecture
affected artifacts
new files
core/router/src/main/kotlin/com/correx/core/router/model/RouterState.kt— serializable statecore/router/src/main/kotlin/com/correx/core/router/model/RouterConfig.kt— conversation keep-last count, token budgetcore/router/src/main/kotlin/com/correx/core/router/model/RouterResponse.kt— output type (content, steeringEmitted)core/router/src/main/kotlin/com/correx/core/router/RouterReducer.kt— reduces RouterState from Router-specific eventscore/router/src/main/kotlin/com/correx/core/router/RouterProjector.kt— Projection backed by RouterReducercore/router/src/main/kotlin/com/correx/core/router/RouterRepository.kt— DefaultRouterRepository using EventReplayercore/router/src/main/kotlin/com/correx/core/router/RouterContextBuilder.kt— assembles budget-constrained ContextPackcore/router/src/main/kotlin/com/correx/core/router/RouterFacade.kt— orchestrates state loading, context building, inference routing, steering emission
modified files (core/events)
core/events/src/main/kotlin/com/correx/core/events/events/ContextEvents.kt— addSteeringNotedata class andSteeringNoteAddedEventcore/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt— registerSteeringNoteAddedEventineventModulepolymorphic block
modified files (core/context)
core/context/src/main/kotlin/com/correx/core/context/model/ContextEntry.kt— or new file forSteeringNotedomain type if not merged into events
modified files (apps/server)
apps/server/src/main/kotlin/com/correx/apps/server/protocol/ClientMessage.kt— addChatInputdata class,ChatModeenumapps/server/src/main/kotlin/com/correx/apps/server/protocol/ServerMessage.kt— addRouterResponseMessagedata classapps/server/src/main/kotlin/com/correx/apps/server/protocol/Dtos.kt— potential new DTOs if neededapps/server/src/main/kotlin/com/correx/apps/server/ws/SessionStreamHandler.kt— newwhenbranch forClientMessage.ChatInputapps/server/src/main/kotlin/com/correx/apps/server/ServerModule.kt— addRouterFacadedependency
modified files (infrastructure)
infrastructure/src/main/kotlin/com/correx/infrastructure/InfrastructureModule.kt— addcreateRouterFacade()factory
build files
core/router/build.gradle— declare dependencies on:core:events,:core:context,:core:inference,:core:sessionsinfrastructure/build.gradle— addimplementation project(":core:router")apps/server/build.gradle— addimplementation project(":core:router")
test files (deterministic)
testing/deterministic/—RouterReducerTest,RouterProjectorTest,RouterContextBuilderTest,RouterFacadeTest
component placement
:core:router — router domain layer
The module owns all router-specific concerns, structured per the core architecture pattern:
core/router/src/main/kotlin/com/correx/core/router/
model/
RouterState.kt — @Serializable data class, empty defaults
RouterConfig.kt — conversation keep-last count, token budget
RouterResponse.kt — output type for facade response
RouterReducer.kt — reduces RouterState from events
RouterProjector.kt — Projection<RouterState>
RouterRepository.kt — DefaultRouterRepository(EventReplayer<RouterState>)
RouterContextBuilder.kt — assembles ContextPack from L2 memory + workflow status + conversation
RouterFacade.kt — orchestrates: state load → context build → inference route → steering emit
Conversation history storage — owned by RouterFacade, implemented as ConcurrentHashMap<SessionId, List<ChatTurn>> (or equivalent). Session-scoped, no cross-session leakage.
:core:events / :core:context — shared domain primitives
SteeringNotedomain object — placed in:core:contextmodel (or:core:eventsif treated purely as an event carrier)SteeringNoteAddedEvent— placed in:core:events/events/ContextEvents.ktalongside other context-related events, implementsEventPayload
apps:server — protocol and integration
- Protocol types (
ChatInput,RouterResponseMessage,ChatMode) — in existingprotocol/package - WebSocket dispatch — new
whenbranch inSessionStreamHandler.handleClientMessage() ServerModuleextended withRouterFacadefield
infrastructure — wiring
InfrastructureModule.createRouterFacade()— assembles dependencies (RouterRepository, RouterContextBuilder, InferenceRouter, EventStore, RouterConfig) and returns RouterFacade
boundaries
Router vs. Orchestrator / Kernel
:core:routerdoes not depend on:core:orchestrationor:core:kernel- The router reads workflow status exclusively from events, never from orchestrator state
- The router emits only
SteeringNoteAddedEvent— never workflow-mutating events - The orchestrator and router are parallel consumers/producers on the same event log
Router vs. Event Store
- The router writes to EventStore only for
SteeringNoteAddedEvent - The router reads from EventStore only via EventReplayer for projection
- The router never bypasses the event log — no direct state persistence
Router vs. Inference
- The router uses
InferenceRouterto select a provider, then callsInferenceProvider.infer()with aContextPack - Inference cancellation must use the same
InferenceCancellationTokensemantics as the orchestrator - The router never modifies provider registration or routing strategy
Router ContextPack
RouterContextPacknever contains rawEventPayloadobjects, artifact content, or tool outputs- L0 entries (system prompt, workflow status) are never dropped under budget pressure
- L2 entries (stage summaries from L2 memory) are evicted oldest-first under budget pressure
Conversation History
- Session-scoped (
ConcurrentHashMap<SessionId, ...>) — no cross-session data leakage - Bounded by
RouterConfig.keepLastcount
integration points
Serialization — eventModule registration
Every new EventPayload must be registered in core/events/.../serialization/Serialization.kt inside the eventModule polymorphic block. SteeringNoteAddedEvent must be added as subclass(SteeringNoteAddedEvent::class). Missing registration causes silent runtime deserialization failure.
EventStore — dual access
The router reads events via EventReplayer (for projection) and writes events via EventStore.append() (for SteeringNoteAddedEvent). Both access paths target the same SQLite-backed store (SqliteEventStore in infrastructure).
InferenceRouting
RouterFacadecallsInferenceRouter.route(stageId, requiredCapabilities)to select anInferenceProvider- Then calls
InferenceProvider.infer(InferenceRequest(contextPack = ..., ...))with the router-assembledContextPack - Uses the same
GenerationConfigand cancellation semantics as orchestrator inference
WebSocket Protocol
- Ingress:
ClientMessage.ChatInput(sessionId, text, mode)— decoded via existingProtocolSerializer.decodeClientMessage() - Egress:
ServerMessage.RouterResponseMessage(content, steeringEmitted)— encoded via existingProtocolSerializer.encodeServerMessage() ChatModeenum drives routing:ChatMode.STEERING→ emitSteeringNoteAddedEvent;ChatMode.CHAT(or equivalent) → inference-only path
Infrastructure wiring
InfrastructureModule.createRouterFacade() assembles:
RouterRepository(viaDefaultEventReplayer<RouterState>(EventStore, RouterProjector(RouterReducer)))RouterContextBuilder(withTokenBudget,ContextPack,CompressionStrategy)InferenceRouter(existing from infrastructure wiring)RouterConfig(from config or defaults)EventStore(existing from infrastructure wiring)
ServerModule extension
ServerModule data class gains a routerFacade: RouterFacade field. SessionStreamHandler gains access to module.routerFacade for ChatInput dispatch.
architectural invariants
- RouterState is never persisted independently — always rebuilt from the event log via
RouterProjector+RouterReducer - RouterContextPack is clean — never contains raw
EventPayloadobjects, artifact content, or tool outputs - Router emits only SteeringNoteAddedEvent — it never emits events that mutate workflow state
- Conversation history is session-scoped —
ConcurrentHashMap<SessionId, ...>, no cross-session leakage - L0 entries are immutable under budget pressure — system prompt and workflow status are never dropped
- L2 entries are evicted oldest-first — stage summaries are dropped in insertion order when budget is exceeded
- Replay is environment-independent — no external service calls, no live LLM required for deterministic projection
- No upward dependency —
:core:routerdoes not depend on:core:orchestrationor:core:kernel; reads state from events only - EventPayload registration is mandatory — every new
EventPayloadmust be registered ineventModule - Cancellation parity — inference requests from the router use the same
InferenceCancellationTokensemantics as orchestrator inference - Policy layer is absolute — approvals cannot override policy denial; policy failure is terminal
- LLM outputs are untrusted until validated — any inference output from the router is a proposal; cannot affect state until validated
coupling risks
SteeringNoteAddedEvent registration
If SteeringNoteAddedEvent is not registered in eventModule, deserialization of stored events will silently fail at runtime. Tests may still pass because they may not exercise the full serialization round-trip.
Server WebSocket handler extension
Adding a new when branch to SessionStreamHandler.handleClientMessage() requires careful placement. The existing else branch (line 81-85) catches unexpected message types and sends a ProtocolError. The ChatInput branch must be added before the else catch-all, or the else branch must be refined.
ServerModule circular wiring
ServerModule currently depends on DefaultSessionOrchestrator (from :core:kernel). Adding RouterFacade to ServerModule is safe because RouterFacade is in :core:router which does not depend on :core:kernel. However, InfrastructureModule.createRouterFacade() must receive dependencies from the existing component graph without creating circular dependencies.
EventStore dual-role
The router reads (via EventReplayer) and writes (via EventStore.append) to the same store. If the store's write path is slow or blocks, the router's steering emission could delay. This is acceptable per spec but should be monitored.
ContextPack boundary crossing
If RouterContextBuilder accidentally includes raw EventPayload objects or tool outputs in the ContextPack, it would violate invariant #2 and could leak implementation details to the LLM provider. The builder must strictly filter to L0/L1/L2 entries only.
unresolved structural decisions
-
ChatInput.sessionId type — The spec asks whether
ChatInput.sessionIdshould be aSessionIddomain type (TypeId) or remain aString. Existing protocol types (ServerMessage,ClientMessage) already use domain types likeSessionId,StageId. Consistency suggestsSessionIdis preferred, but this must be decided. -
ChatMode enum variants — The spec asks whether
ChatModeshould mirror the existingInputModeenum fromapps:tui(variantsROUTER, NAVIGATE, FILTER, STEER) or use a simpler two-variant enum (CHAT, STEERING). This affects the server's routing logic and the protocol wire format. -
Task 0 merge timing — The spec asks whether
SteeringNoteandSteeringNoteAddedEvent(Task 0) should be merged before Task 1 starts, or if a single PR is acceptable. The epic states "complete and merge before starting task 1," but the actual merge strategy for this epic is unresolved. -
WebSocket handler extension point — The spec asks whether
CorrexServer.kt(or more preciselySessionStreamHandler.kt) already has a WebSocket handler structure that can accommodate aChatInputbranch. Investigation ofSessionStreamHandler.ktconfirms thewhenblock has anelsecatch-all at the end, so a new branch can be inserted before it. However, the exact location and naming convention should be confirmed. -
RouterFacade test placement — The spec says "deterministic tests for reducer, projector, context builder, and facade." It is unresolved whether these tests live in
core/router/src/test/or intesting/deterministic/. The pattern in this project puts many domain tests intesting/submodules (e.g.,testing/projections/,testing/deterministic/).