fix: harden event store, serialization, and grant engine; wire router chat

Event log integrity (sole source of truth):
- SqliteEventStore: serialize append/appendAll under a Mutex, enable WAL +
  busy_timeout + synchronous=NORMAL, roll back on any Throwable. Replaces the
  app-computed `SELECT MAX(seq)+1` read-modify-write that dropped events under
  concurrent appends (race was invisible to CI: only the in-memory store and
  single-threaded :memory: tests were ever exercised).
- Serialization: encodeDefaults + ignoreUnknownKeys, explicit @SerialName on all
  46 EventPayload subclasses and event-referenced enum constants, @Serializable on
  RiskLevel/RiskAction. Guards the append-only log against silent corruption from
  future class renames, field removals, or default-value changes.

Approval/grant security (Invariant: approvals cannot widen authority):
- Tier authorization is now a ceiling (request.tier.level <= max granted) instead
  of set membership; SESSION grants bind to a specific toolName instead of matching
  everything; handleCreateGrant rejects empty/over-tier/scopeless grants.

Router chat wiring (Phase 0-2): ChatInput round-trip, StartChatSession from IDLE,
router-panel layout, workflows behind Ctrl+W.

Tests: SqliteEventStore concurrency, serialization round-trip + discriminator
stability + unknown-key tolerance, adversarial grant scope/tier; updated existing
approval and reducer tests for the new grant semantics and StartChatSession effect.
This commit is contained in:
2026-05-28 22:39:15 +04:00
parent 57d2237ba0
commit cdee5f2245
43 changed files with 690 additions and 223 deletions
@@ -47,11 +47,19 @@ sealed class ClientMessage {
val permittedTiers: List<Tier>,
val reason: String,
val expiresAt: Instant? = null,
// Required for SESSION scope: binds this grant to a specific tool name.
val toolName: String? = null,
) : ClientMessage()
@Serializable
data class Ping(val timestamp: Long) : ClientMessage()
@Serializable
data class StartChatSession(
val sessionId: SessionId,
val text: String,
) : ClientMessage()
@Serializable
data class ChatInput(val sessionId: SessionId, val text: String, val mode: ChatMode) : ClientMessage()
}
@@ -12,14 +12,17 @@ import com.correx.apps.server.protocol.WorkflowDto
import com.correx.apps.server.protocol.StageToolDecl
import com.correx.apps.server.protocol.ToolDecl
import com.correx.core.approvals.GrantScope
import com.correx.core.approvals.Tier
import com.correx.core.events.events.ApprovalGrantCreatedEvent
import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.NewEvent
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.events.WorkflowStartedEvent
import com.correx.core.events.types.GrantId
import com.correx.core.events.stores.EventStore
import com.correx.core.events.types.EventId
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.inference.ProviderHealth
import com.correx.core.utils.TypeId
import io.ktor.server.websocket.DefaultWebSocketServerSession
@@ -140,11 +143,29 @@ class GlobalStreamHandler(private val module: ServerModule) {
when (msg) {
is ClientMessage.Ping -> Unit
is ClientMessage.StartSession -> handleStartSession(session, msg, sendFrame)
is ClientMessage.StartChatSession -> handleStartChatSession(session, msg, sendFrame)
is ClientMessage.CancelSession -> module.orchestrator.cancel(msg.sessionId)
is ClientMessage.ResumeSession -> session.send(encodeError("ResumeSession not supported"))
is ClientMessage.ApprovalResponse -> handleApprovalResponse(msg, sendFrame)
is ClientMessage.CreateGrant -> handleCreateGrant(msg, sendFrame)
is ClientMessage.ChatInput -> Unit // handler deferred to subsequent task
is ClientMessage.ChatInput -> {
runCatching {
module.routerFacade.onUserInput(msg.sessionId, msg.text, msg.mode)
}.onSuccess { response ->
sendFrame(ServerMessage.RouterResponseMessage(
sessionId = msg.sessionId,
content = response.content,
steeringEmitted = response.steeringEmitted,
))
}.onFailure {
log.error("routerFacade.onUserInput failed: {}", it.message)
sendFrame(ServerMessage.ProtocolError(
message = "Router error: ${it.message}",
sequence = null,
sessionSequence = null,
))
}
}
}
}
@@ -188,13 +209,31 @@ class GlobalStreamHandler(private val module: ServerModule) {
msg: ClientMessage.CreateGrant,
sendFrame: suspend (ServerMessage) -> Unit,
) {
// Validate: tiers must be non-empty and bounded to T2 (server-side ceiling).
// T3/T4 are destructive/escalated tiers that must never be auto-approved via grants.
if (msg.permittedTiers.isEmpty()) {
sendFrame(errorResponse("CreateGrant: permittedTiers must not be empty"))
return
}
val maxGrantableTier = Tier.T2
val overBroad = msg.permittedTiers.filter { it.level > maxGrantableTier.level }
if (overBroad.isNotEmpty()) {
sendFrame(errorResponse("CreateGrant: tiers ${overBroad.map { it.name }} exceed maximum grantable tier ${maxGrantableTier.name}"))
return
}
val scope = when (msg.scope) {
GrantScopeDto.SESSION -> {
if (msg.stageId != null) {
sendFrame(errorResponse("CreateGrant: SESSION scope must not include stageId"))
return
}
GrantScope.SESSION
// Require toolName — blanket session grants (no operation scope) are rejected.
val toolName = msg.toolName?.takeIf { it.isNotBlank() } ?: run {
sendFrame(errorResponse("CreateGrant: SESSION scope requires toolName to prevent blanket approval"))
return
}
GrantScope.SESSION(toolName)
}
GrantScopeDto.STAGE -> {
val sid = msg.stageId ?: run {
@@ -222,11 +261,66 @@ class GlobalStreamHandler(private val module: ServerModule) {
sessionId = msg.sessionId,
stageId = (scope as? GrantScope.STAGE)?.stageId,
projectId = null,
toolName = (scope as? GrantScope.SESSION)?.toolName,
),
)
module.eventStore.append(event)
}
private suspend fun handleStartChatSession(
session: DefaultWebSocketServerSession,
msg: ClientMessage.StartChatSession,
sendFrame: suspend (ServerMessage) -> Unit,
) {
val sessionId = msg.sessionId
log.info("starting chat session={}", sessionId.value)
// Emit WorkflowStartedEvent for the new session (no graph, no orchestrator).
val event = NewEvent(
metadata = EventMetadata(
eventId = EventId(UUID.randomUUID().toString()),
sessionId = sessionId,
timestamp = Clock.System.now(),
schemaVersion = 1,
causationId = null,
correlationId = null,
),
payload = WorkflowStartedEvent(
sessionId = sessionId,
workflowId = "chat",
startStageId = StageId("none"),
),
)
module.eventStore.append(event)
// Send SessionStarted so the TUI creates the session entry immediately.
val started = ServerMessage.SessionStarted(
sessionId = sessionId,
workflowId = "chat",
sequence = 0L,
sessionSequence = 0L,
)
session.send(Frame.Text(ProtocolSerializer.encodeServerMessage(started)))
// Call router and send response.
runCatching {
module.routerFacade.onUserInput(sessionId, msg.text)
}.onSuccess { response ->
sendFrame(ServerMessage.RouterResponseMessage(
sessionId = sessionId,
content = response.content,
steeringEmitted = response.steeringEmitted,
))
}.onFailure {
log.error("routerFacade.onUserInput failed: {}", it.message)
sendFrame(ServerMessage.ProtocolError(
message = "Router error: ${it.message}",
sequence = null,
sessionSequence = null,
))
}
}
private suspend fun handleStartSession(
session: DefaultWebSocketServerSession,
msg: ClientMessage.StartSession,
+1
View File
@@ -23,6 +23,7 @@ dependencies {
implementation project(':apps:server')
implementation project(':core:events')
implementation project(':core:approvals')
implementation project(':core:router')
implementation "io.ktor:ktor-client-core:$ktor_version"
implementation "io.ktor:ktor-client-cio:$ktor_version"
@@ -19,4 +19,5 @@ sealed class KeyEvent {
data class CharInput(val ch: Char) : KeyEvent()
object CursorLeft : KeyEvent()
object CursorRight : KeyEvent()
object ToggleWorkflows : KeyEvent()
}
@@ -1,8 +1,8 @@
package com.correx.apps.tui
import com.correx.apps.tui.components.approvalSurfaceWidget
import com.correx.apps.tui.components.diffViewerWidget
import com.correx.apps.tui.components.eventHistoryStripWidget
import com.correx.apps.tui.components.filteredSessions
import com.correx.apps.tui.components.inputBarWidget
import com.correx.apps.tui.components.routerPanelWidget
import com.correx.apps.tui.components.sessionListWidget
@@ -16,7 +16,6 @@ import com.correx.apps.tui.reducer.EffectDispatcher
import com.correx.apps.tui.reducer.RootReducer
import com.correx.apps.tui.state.DisplayState
import com.correx.apps.tui.state.TuiState
import com.correx.apps.tui.state.ToolDisplayStatus
import com.correx.apps.tui.state.displayState
import com.correx.apps.tui.state.selectedPendingApproval
import com.correx.apps.tui.ws.ConnectionEvent
@@ -38,9 +37,10 @@ import dev.tamboui.tui.event.KeyEvent as TambouiKeyEvent
private const val DEFAULT_PORT = 8080
private const val STATUS_HEIGHT = 1
private const val EVENT_STRIP_HEIGHT = 6
private const val DIFF_HEIGHT = 8
private const val WORKFLOW_HEIGHT = 6
private const val INPUT_HEIGHT = 4
private const val MAX_VISIBLE_SESSIONS = 7
private const val SESSION_BORDER_PADDING = 2
private val log = LoggerFactory.getLogger("com.correx.apps.tui.main")
@@ -130,51 +130,16 @@ private fun render(frame: Frame, state: TuiState) {
private fun renderIdleLayout(frame: Frame, state: TuiState) {
val area = frame.area()
val sessionCount = filteredSessions(state.sessions).size
val sessionListHeight = minOf(sessionCount, MAX_VISIBLE_SESSIONS) + SESSION_BORDER_PADDING
val hasWorkflows = state.sessions.workflows.isNotEmpty()
val vertRects = if (hasWorkflows) {
Layout.vertical()
.constraints(
Constraint.length(STATUS_HEIGHT),
Constraint.fill(),
Constraint.length(WORKFLOW_HEIGHT),
Constraint.length(INPUT_HEIGHT),
)
.split(area)
} else {
Layout.vertical()
.constraints(
Constraint.length(STATUS_HEIGHT),
Constraint.fill(),
Constraint.length(INPUT_HEIGHT),
)
.split(area)
}
frame.renderWidget(statusBarWidget(state), vertRects[0])
frame.renderWidget(sessionListWidget(state), vertRects[1])
if (hasWorkflows) {
frame.renderWidget(workflowListWidget(state), vertRects[2])
frame.renderWidget(inputBarWidget(state, DisplayState.IDLE), vertRects[3])
} else {
frame.renderWidget(inputBarWidget(state, DisplayState.IDLE), vertRects[2])
}
}
private fun renderInSessionLayout(frame: Frame, state: TuiState) {
val area = frame.area()
val selectedSession = state.sessions.sessions.firstOrNull { it.id == state.sessions.selectedId }
val hasDiff = selectedSession?.tools
?.lastOrNull { it.status == ToolDisplayStatus.COMPLETED }
?.diff != null
val showWorkflows = hasWorkflows && (state.sessions.workflowsVisible || state.sessions.sessions.isEmpty())
val constraints = mutableListOf(
Constraint.length(STATUS_HEIGHT),
Constraint.length(sessionListHeight),
)
if (state.eventStripVisible) {
constraints.add(Constraint.length(EVENT_STRIP_HEIGHT))
}
if (hasDiff) {
constraints.add(Constraint.length(DIFF_HEIGHT))
if (showWorkflows) {
constraints.add(Constraint.length(WORKFLOW_HEIGHT))
}
constraints.add(Constraint.fill())
constraints.add(Constraint.length(INPUT_HEIGHT))
@@ -185,32 +150,51 @@ private fun renderInSessionLayout(frame: Frame, state: TuiState) {
var rectIdx = 0
frame.renderWidget(statusBarWidget(state), vertRects[rectIdx++])
frame.renderWidget(sessionListWidget(state), vertRects[rectIdx++])
if (showWorkflows) {
frame.renderWidget(workflowListWidget(state), vertRects[rectIdx++])
}
frame.renderWidget(routerPanelWidget(state), vertRects[rectIdx++])
frame.renderWidget(inputBarWidget(state, DisplayState.IDLE), vertRects[rectIdx])
}
private fun renderInSessionLayout(frame: Frame, state: TuiState) {
val area = frame.area()
val sessionCount = filteredSessions(state.sessions).size
val sessionListHeight = minOf(sessionCount, MAX_VISIBLE_SESSIONS) + SESSION_BORDER_PADDING
val constraints = mutableListOf(
Constraint.length(STATUS_HEIGHT),
Constraint.length(sessionListHeight),
)
if (state.eventStripVisible) {
constraints.add(Constraint.length(EVENT_STRIP_HEIGHT))
}
constraints.add(Constraint.fill())
constraints.add(Constraint.length(INPUT_HEIGHT))
val vertRects = Layout.vertical()
.constraints(constraints)
.split(area)
var rectIdx = 0
frame.renderWidget(statusBarWidget(state), vertRects[rectIdx++])
frame.renderWidget(sessionListWidget(state), vertRects[rectIdx++])
if (state.eventStripVisible) {
frame.renderWidget(eventHistoryStripWidget(state), vertRects[rectIdx++])
}
if (hasDiff) {
frame.renderWidget(diffViewerWidget(state), vertRects[rectIdx++])
}
frame.renderWidget(routerPanelWidget(state), vertRects[rectIdx++])
frame.renderWidget(inputBarWidget(state, DisplayState.IN_SESSION), vertRects[rectIdx])
}
private fun renderApprovalLayout(frame: Frame, state: TuiState) {
val area = frame.area()
val selectedSession = state.sessions.sessions.firstOrNull { it.id == state.sessions.selectedId }
val hasDiff = selectedSession?.tools
?.lastOrNull { it.status == ToolDisplayStatus.COMPLETED }
?.diff != null
val constraints = mutableListOf(
Constraint.length(STATUS_HEIGHT),
)
if (state.eventStripVisible) {
constraints.add(Constraint.length(EVENT_STRIP_HEIGHT))
}
if (hasDiff) {
constraints.add(Constraint.length(DIFF_HEIGHT))
}
constraints.add(Constraint.fill())
constraints.add(Constraint.length(INPUT_HEIGHT))
@@ -223,9 +207,6 @@ private fun renderApprovalLayout(frame: Frame, state: TuiState) {
if (state.eventStripVisible) {
frame.renderWidget(eventHistoryStripWidget(state), vertRects[rectIdx++])
}
if (hasDiff) {
frame.renderWidget(diffViewerWidget(state), vertRects[rectIdx++])
}
frame.renderWidget(approvalSurfaceWidget(state), vertRects[rectIdx++])
frame.renderWidget(inputBarWidget(state, DisplayState.APPROVAL), vertRects[rectIdx])
}
@@ -1,82 +0,0 @@
package com.correx.apps.tui.components
import com.correx.apps.tui.state.ToolDisplayStatus
import com.correx.apps.tui.state.TuiState
import dev.tamboui.style.Style
import dev.tamboui.text.Line
import dev.tamboui.text.Span
import dev.tamboui.text.Text
import dev.tamboui.widgets.block.Block
import dev.tamboui.widgets.block.BorderType
import dev.tamboui.widgets.block.Borders
import dev.tamboui.widgets.block.Title
import dev.tamboui.widgets.paragraph.Paragraph
private const val DIFF_MAX_LINES = 8
private const val LINE_MAX_LENGTH = 100
private val dimStyle = Style.create().dim().gray()
private val greenStyle = Style.create().green()
private val redStyle = Style.create().red()
private val yellowStyle = Style.create().yellow()
fun diffViewerWidget(state: TuiState): Paragraph {
val selectedSession = state.sessions.sessions.firstOrNull { it.id == state.sessions.selectedId }
// Only show the diff for the most recently completed tool. If the last tool has no diff
// (e.g. a shell command), the viewer is hidden — stale diffs from earlier tools are noise.
val toolWithDiff = selectedSession?.tools
?.lastOrNull { it.status == ToolDisplayStatus.COMPLETED }
?.takeIf { it.diff != null }
val diffText = toolWithDiff?.diff ?: return Paragraph.builder().build()
val header = " ${toolWithDiff.name}"
val headerLine = Line.from(Span.styled(header, Style.create().cyan()))
val diffLines = diffText.lines()
val visible = diffLines.take(DIFF_MAX_LINES)
val overflow = diffLines.size - DIFF_MAX_LINES
val contentLines = buildList<Line> {
for (line in visible) {
val truncated = if (line.length > LINE_MAX_LENGTH) {
line.take(LINE_MAX_LENGTH - 3) + "..."
} else {
line
}
when {
truncated.startsWith("@@") -> add(
Line.from(Span.styled(truncated, yellowStyle)),
)
truncated.startsWith("+++") || truncated.startsWith("---") -> add(
Line.from(Span.styled(truncated, dimStyle)),
)
truncated.startsWith("+") -> add(
Line.from(Span.styled(truncated, greenStyle)),
)
truncated.startsWith("-") -> add(
Line.from(Span.styled(truncated, redStyle)),
)
else -> add(Line.from(Span.raw(truncated)))
}
}
if (overflow > 0) {
add(Line.from(Span.styled(" ($overflow more lines...)", dimStyle)))
}
}
val lines = listOf(headerLine) + contentLines
val block = Block.builder()
.title(Title.from(Span.styled("diff", Style.create().green())).centered())
.borders(Borders.ALL)
.borderType(BorderType.ROUNDED)
.build()
return Paragraph.builder().text(Text.from(lines)).block(block).build()
}
@@ -26,5 +26,6 @@ sealed interface Action {
data object CycleMode : Action
data object CursorLeft : Action
data object CursorRight : Action
data object ToggleWorkflows : Action
data object NoOp : Action
}
@@ -48,6 +48,7 @@ object KeyResolver {
KeyEvent.Tab -> Action.CycleMode
KeyEvent.Backspace -> Action.Backspace
KeyEvent.NewSession -> Action.OpenNewSessionPrompt
KeyEvent.ToggleWorkflows -> Action.ToggleWorkflows
is KeyEvent.CharInput -> Action.AppendChar(key.ch)
else -> null
}
@@ -23,6 +23,7 @@ fun mapKey(event: TambouiKeyEvent): KeyEvent? {
event.isChar('l') -> KeyEvent.ReturnToSessionList
event.isChar('e') -> KeyEvent.ToggleEventStrip
event.isChar('h') -> KeyEvent.ShowPendingApproval
event.isChar('w') -> KeyEvent.ToggleWorkflows
else -> null
}
}
@@ -157,6 +157,17 @@ object RootReducer {
) ProviderType.LOCAL else ProviderType.REMOTE,
)
}
msg is ServerMessage.RouterResponseMessage -> {
withBgReset.copy(
routerMessages = withBgReset.routerMessages + msg.content,
)
}
msg is ServerMessage.ToolCompleted && msg.diff != null -> {
withBgReset.copy(
routerMessages = withBgReset.routerMessages +
"--- diff from ${msg.toolName} ---",
)
}
else -> withBgReset
}
}
@@ -13,8 +13,8 @@ import com.correx.apps.tui.state.ToolDisplayStatus
import com.correx.apps.tui.state.ToolManifestEntry
import com.correx.apps.tui.state.TuiEventEntry
import com.correx.apps.tui.state.TuiToolRecord
import com.correx.core.events.types.ApprovalRequestId
import com.correx.core.events.types.SessionId
import com.correx.core.router.ChatMode
import org.slf4j.LoggerFactory
import java.time.Instant
import java.time.ZoneOffset
@@ -59,19 +59,30 @@ object SessionsReducer {
Effect.SendWs(ClientMessage.StartSession(workflowId = wf.workflowId, config = null)),
)
}
text.isNotBlank() -> {
val sessionId = SessionId(java.util.UUID.randomUUID().toString())
sessions to listOf(
Effect.SendWs(ClientMessage.StartSession(workflowId = text, config = null)),
Effect.SendWs(ClientMessage.StartChatSession(sessionId = sessionId, text = text)),
)
}
else -> sessions to emptyList()
}
}
displayState == DisplayState.IN_SESSION -> {
// Chat send is blocked until Epic 14 (router integration).
// History append is handled by RootReducer cross-field weave.
sessions to emptyList()
sessions to listOf(
Effect.SendWs(
ClientMessage.ChatInput(
sessionId = SessionId(sessions.selectedId!!),
text = inputText,
mode = ChatMode.CHAT,
),
),
)
}
else -> sessions to emptyList()
}
@@ -90,6 +101,10 @@ object SessionsReducer {
}
}
is Action.ToggleWorkflows -> sessions.copy(
workflowsVisible = !sessions.workflowsVisible,
) to emptyList()
is Action.ServerEventReceived -> applyServerMessage(sessions, action.message, clock)
else -> sessions to emptyList()
}
@@ -10,4 +10,6 @@ data class SessionsState(
val workflows: List<WorkflowDto> = emptyList(),
/** Index into [workflows] when focus is in the workflow picker; -1 = no workflow selected. */
val selectedWorkflowIndex: Int = -1,
/** When true, the workflow list is shown in IDLE layout even when sessions exist. */
val workflowsVisible: Boolean = false,
)
@@ -33,15 +33,16 @@ class RootReducerTest {
}
@Test
fun `SubmitInput in ROUTER mode emits StartSession effect and resets buffer`() {
fun `SubmitInput in ROUTER mode emits StartChatSession effect and resets buffer`() {
val initial = TuiState(inputMode = InputMode.ROUTER, inputBuffer = "my-workflow")
val (state, effects) = RootReducer.reduce(initial, Action.SubmitInput, fixedClock)
assertEquals(InputMode.ROUTER, state.inputMode)
assertEquals("", state.inputBuffer)
val wsEffects = effects.filterIsInstance<Effect.SendWs>()
assertEquals(1, wsEffects.size)
val msg = wsEffects[0].message as ClientMessage.StartSession
assertEquals("my-workflow", msg.workflowId)
val msg = wsEffects[0].message as ClientMessage.StartChatSession
assertEquals("my-workflow", msg.text)
assertTrue(msg.sessionId.value.isNotBlank())
}
@Test