2c459da009
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
171 lines
13 KiB
Markdown
171 lines
13 KiB
Markdown
# 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<RouterState> backed by RouterReducer
|
|
- `core/router/src/main/kotlin/com/correx/core/router/RouterRepository.kt` — DefaultRouterRepository using EventReplayer<RouterState>
|
|
- `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-scoped** — `ConcurrentHashMap<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/`).
|