Files
correx/docs/specs/2026-05-20-router/architecture.md
T
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

13 KiB

architecture

affected artifacts

new files

  • core/router/src/main/kotlin/com/correx/core/router/model/RouterState.kt — serializable state
  • core/router/src/main/kotlin/com/correx/core/router/model/RouterConfig.kt — conversation keep-last count, token budget
  • core/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 events
  • core/router/src/main/kotlin/com/correx/core/router/RouterProjector.kt — Projection backed by RouterReducer
  • core/router/src/main/kotlin/com/correx/core/router/RouterRepository.kt — DefaultRouterRepository using EventReplayer
  • core/router/src/main/kotlin/com/correx/core/router/RouterContextBuilder.kt — assembles budget-constrained ContextPack
  • core/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 — add SteeringNote data class and SteeringNoteAddedEvent
  • core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt — register SteeringNoteAddedEvent in eventModule polymorphic block

modified files (core/context)

  • core/context/src/main/kotlin/com/correx/core/context/model/ContextEntry.kt — or new file for SteeringNote domain type if not merged into events

modified files (apps/server)

  • apps/server/src/main/kotlin/com/correx/apps/server/protocol/ClientMessage.kt — add ChatInput data class, ChatMode enum
  • apps/server/src/main/kotlin/com/correx/apps/server/protocol/ServerMessage.kt — add RouterResponseMessage data class
  • apps/server/src/main/kotlin/com/correx/apps/server/protocol/Dtos.kt — potential new DTOs if needed
  • apps/server/src/main/kotlin/com/correx/apps/server/ws/SessionStreamHandler.kt — new when branch for ClientMessage.ChatInput
  • apps/server/src/main/kotlin/com/correx/apps/server/ServerModule.kt — add RouterFacade dependency

modified files (infrastructure)

  • infrastructure/src/main/kotlin/com/correx/infrastructure/InfrastructureModule.kt — add createRouterFacade() factory

build files

  • core/router/build.gradle — declare dependencies on :core:events, :core:context, :core:inference, :core:sessions
  • infrastructure/build.gradle — add implementation project(":core:router")
  • apps/server/build.gradle — add implementation 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

  • SteeringNote domain object — placed in :core:context model (or :core:events if treated purely as an event carrier)
  • SteeringNoteAddedEvent — placed in :core:events/events/ContextEvents.kt alongside other context-related events, implements EventPayload

apps:server — protocol and integration

  • Protocol types (ChatInput, RouterResponseMessage, ChatMode) — in existing protocol/ package
  • WebSocket dispatch — new when branch in SessionStreamHandler.handleClientMessage()
  • ServerModule extended with RouterFacade field

infrastructure — wiring

  • InfrastructureModule.createRouterFacade() — assembles dependencies (RouterRepository, RouterContextBuilder, InferenceRouter, EventStore, RouterConfig) and returns RouterFacade

boundaries

Router vs. Orchestrator / Kernel

  • :core:router does not depend on :core:orchestration or :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 InferenceRouter to select a provider, then calls InferenceProvider.infer() with a ContextPack
  • Inference cancellation must use the same InferenceCancellationToken semantics as the orchestrator
  • The router never modifies provider registration or routing strategy

Router ContextPack

  • RouterContextPack never contains raw EventPayload objects, 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.keepLast count

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

  • RouterFacade calls InferenceRouter.route(stageId, requiredCapabilities) to select an InferenceProvider
  • Then calls InferenceProvider.infer(InferenceRequest(contextPack = ..., ...)) with the router-assembled ContextPack
  • Uses the same GenerationConfig and cancellation semantics as orchestrator inference

WebSocket Protocol

  • Ingress: ClientMessage.ChatInput(sessionId, text, mode) — decoded via existing ProtocolSerializer.decodeClientMessage()
  • Egress: ServerMessage.RouterResponseMessage(content, steeringEmitted) — encoded via existing ProtocolSerializer.encodeServerMessage()
  • ChatMode enum drives routing: ChatMode.STEERING → emit SteeringNoteAddedEvent; ChatMode.CHAT (or equivalent) → inference-only path

Infrastructure wiring

InfrastructureModule.createRouterFacade() assembles:

  • RouterRepository (via DefaultEventReplayer<RouterState>(EventStore, RouterProjector(RouterReducer)))
  • RouterContextBuilder (with TokenBudget, 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

  1. RouterState is never persisted independently — always rebuilt from the event log via RouterProjector + RouterReducer
  2. RouterContextPack is clean — never contains raw EventPayload objects, artifact content, or tool outputs
  3. Router emits only SteeringNoteAddedEvent — it never emits events that mutate workflow state
  4. Conversation history is session-scopedConcurrentHashMap<SessionId, ...>, no cross-session leakage
  5. L0 entries are immutable under budget pressure — system prompt and workflow status are never dropped
  6. L2 entries are evicted oldest-first — stage summaries are dropped in insertion order when budget is exceeded
  7. Replay is environment-independent — no external service calls, no live LLM required for deterministic projection
  8. No upward dependency:core:router does not depend on :core:orchestration or :core:kernel; reads state from events only
  9. EventPayload registration is mandatory — every new EventPayload must be registered in eventModule
  10. Cancellation parity — inference requests from the router use the same InferenceCancellationToken semantics as orchestrator inference
  11. Policy layer is absolute — approvals cannot override policy denial; policy failure is terminal
  12. 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

  1. ChatInput.sessionId type — The spec asks whether ChatInput.sessionId should be a SessionId domain type (TypeId) or remain a String. Existing protocol types (ServerMessage, ClientMessage) already use domain types like SessionId, StageId. Consistency suggests SessionId is preferred, but this must be decided.

  2. ChatMode enum variants — The spec asks whether ChatMode should mirror the existing InputMode enum from apps:tui (variants ROUTER, NAVIGATE, FILTER, STEER) or use a simpler two-variant enum (CHAT, STEERING). This affects the server's routing logic and the protocol wire format.

  3. Task 0 merge timing — The spec asks whether SteeringNote and SteeringNoteAddedEvent (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.

  4. WebSocket handler extension point — The spec asks whether CorrexServer.kt (or more precisely SessionStreamHandler.kt) already has a WebSocket handler structure that can accommodate a ChatInput branch. Investigation of SessionStreamHandler.kt confirms the when block has an else catch-all at the end, so a new branch can be inserted before it. However, the exact location and naming convention should be confirmed.

  5. 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 in testing/deterministic/. The pattern in this project puts many domain tests in testing/ submodules (e.g., testing/projections/, testing/deterministic/).