From cdee5f2245eb30d3f6b9e57cd806491812c06492 Mon Sep 17 00:00:00 2001 From: kami Date: Thu, 28 May 2026 22:39:15 +0400 Subject: [PATCH] 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. --- .../apps/server/protocol/ClientMessage.kt | 8 + .../apps/server/ws/GlobalStreamHandler.kt | 98 +++++++++- apps/tui/build.gradle | 1 + .../kotlin/com/correx/apps/tui/KeyEvent.kt | 1 + .../main/kotlin/com/correx/apps/tui/TuiApp.kt | 97 ++++------ .../correx/apps/tui/components/DiffViewer.kt | 82 --------- .../com/correx/apps/tui/input/Action.kt | 1 + .../com/correx/apps/tui/input/KeyResolver.kt | 1 + .../correx/apps/tui/input/TambouiKeyMapper.kt | 1 + .../correx/apps/tui/reducer/RootReducer.kt | 11 ++ .../apps/tui/reducer/SessionsReducer.kt | 25 ++- .../correx/apps/tui/state/SessionsState.kt | 2 + .../apps/tui/reducer/RootReducerTest.kt | 7 +- .../approvals/domain/DefaultApprovalEngine.kt | 22 ++- .../approvals/DefaultApprovalReducerTest.kt | 6 +- .../DefaultApprovalRepositoryTest.kt | 2 +- .../correx/core/approvals/ApprovalOutcome.kt | 7 +- .../com/correx/core/approvals/GrantScope.kt | 5 +- .../kotlin/com/correx/core/approvals/Tier.kt | 11 +- .../core/events/events/ApprovalEvents.kt | 8 + .../core/events/events/ArtifactEvents.kt | 8 + .../core/events/events/ContextEvents.kt | 8 + .../core/events/events/InferenceEvents.kt | 7 + .../core/events/events/OrchestrationEvents.kt | 7 + .../core/events/events/RiskAssessedEvent.kt | 2 + .../core/events/events/SessionEvents.kt | 6 + .../correx/core/events/events/StageEvents.kt | 5 + .../correx/core/events/events/ToolEvents.kt | 7 + .../com/correx/core/events/risk/RiskAction.kt | 10 +- .../com/correx/core/events/risk/RiskLevel.kt | 11 +- .../events/serialization/Serialization.kt | 2 + .../events/types/ArtifactRelationshipType.kt | 15 +- .../events/EventSerializationHardeningTest.kt | 107 +++++++++++ infrastructure/persistence/build.gradle | 1 + .../persistence/SqliteEventStore.kt | 72 ++++---- .../persistence/util/JDBCHelper.kt | 3 +- .../SqliteEventStoreConcurrencyTest.kt | 53 ++++++ .../kotlin/ApprovalEngineEdgeCasesTest.kt | 4 +- .../src/test/kotlin/GrantSecurityTest.kt | 171 ++++++++++++++++++ .../src/test/kotlin/GrantSemanticsTest.kt | 12 +- .../test/kotlin/NoExecutionCouplingTest.kt | 2 +- .../src/test/kotlin/TierImmutabilityTest.kt | 2 +- .../src/test/kotlin/ApprovalReplayTest.kt | 2 +- 43 files changed, 690 insertions(+), 223 deletions(-) delete mode 100644 apps/tui/src/main/kotlin/com/correx/apps/tui/components/DiffViewer.kt create mode 100644 core/events/src/test/kotlin/com/correx/core/events/EventSerializationHardeningTest.kt create mode 100644 infrastructure/persistence/src/test/kotlin/com/correx/infrastructure/persistence/SqliteEventStoreConcurrencyTest.kt create mode 100644 testing/approvals/src/test/kotlin/GrantSecurityTest.kt diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ClientMessage.kt b/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ClientMessage.kt index 6414af98..ebf959b6 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ClientMessage.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ClientMessage.kt @@ -47,11 +47,19 @@ sealed class ClientMessage { val permittedTiers: List, 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() } diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/ws/GlobalStreamHandler.kt b/apps/server/src/main/kotlin/com/correx/apps/server/ws/GlobalStreamHandler.kt index 45fc24d1..df733034 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/ws/GlobalStreamHandler.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/ws/GlobalStreamHandler.kt @@ -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, diff --git a/apps/tui/build.gradle b/apps/tui/build.gradle index e546d4e6..a9485a52 100644 --- a/apps/tui/build.gradle +++ b/apps/tui/build.gradle @@ -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" diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/KeyEvent.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/KeyEvent.kt index 27eee423..af75f160 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/KeyEvent.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/KeyEvent.kt @@ -19,4 +19,5 @@ sealed class KeyEvent { data class CharInput(val ch: Char) : KeyEvent() object CursorLeft : KeyEvent() object CursorRight : KeyEvent() + object ToggleWorkflows : KeyEvent() } diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/TuiApp.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/TuiApp.kt index 88c62e1a..230d0e8f 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/TuiApp.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/TuiApp.kt @@ -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]) } diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/DiffViewer.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/DiffViewer.kt deleted file mode 100644 index faa9faec..00000000 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/DiffViewer.kt +++ /dev/null @@ -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 { - 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() -} diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/input/Action.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/input/Action.kt index aa11f8ed..af23d3a6 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/input/Action.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/input/Action.kt @@ -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 } diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/input/KeyResolver.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/input/KeyResolver.kt index 912f1aff..a8f13159 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/input/KeyResolver.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/input/KeyResolver.kt @@ -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 } diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/input/TambouiKeyMapper.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/input/TambouiKeyMapper.kt index 47b1fdef..d375ec4b 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/input/TambouiKeyMapper.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/input/TambouiKeyMapper.kt @@ -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 } } diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/RootReducer.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/RootReducer.kt index ae876b18..115a0498 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/RootReducer.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/RootReducer.kt @@ -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 } } diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/SessionsReducer.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/SessionsReducer.kt index 4dcc4ac0..c35257c6 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/SessionsReducer.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/SessionsReducer.kt @@ -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() } diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/state/SessionsState.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/state/SessionsState.kt index fb355710..1a4b0642 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/state/SessionsState.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/state/SessionsState.kt @@ -10,4 +10,6 @@ data class SessionsState( val workflows: List = 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, ) diff --git a/apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/RootReducerTest.kt b/apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/RootReducerTest.kt index a26783c1..fdadce31 100644 --- a/apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/RootReducerTest.kt +++ b/apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/RootReducerTest.kt @@ -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() 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 diff --git a/core/approvals/src/main/kotlin/com/correx/core/approvals/domain/DefaultApprovalEngine.kt b/core/approvals/src/main/kotlin/com/correx/core/approvals/domain/DefaultApprovalEngine.kt index c6c75459..d62e1124 100644 --- a/core/approvals/src/main/kotlin/com/correx/core/approvals/domain/DefaultApprovalEngine.kt +++ b/core/approvals/src/main/kotlin/com/correx/core/approvals/domain/DefaultApprovalEngine.kt @@ -26,8 +26,10 @@ class DefaultApprovalEngine : ApprovalEngine { val matchingGrant = grants.firstOrNull { grant -> !isExpired(grant, now) - && scopeMatches(grant.scope, context) - && request.tier in grant.permittedTiers + && scopeMatches(grant.scope, context, request.toolName) + // Ceiling semantics: grant authorizes up to its highest tier, not an exact set. + && grant.permittedTiers.isNotEmpty() + && request.tier.level <= grant.permittedTiers.maxOf { it.level } } if (matchingGrant != null) { @@ -83,9 +85,15 @@ class DefaultApprovalEngine : ApprovalEngine { private fun isExpired(grant: ApprovalGrant, now: Instant): Boolean = grant.expiresAt != null && grant.expiresAt <= now - private fun scopeMatches(scope: GrantScope, context: ApprovalContext): Boolean = when (scope) { - is GrantScope.SESSION -> true - is GrantScope.STAGE -> context.identity.stageId == scope.stageId - is GrantScope.PROJECT -> context.identity.projectId == scope.projectId - } + private fun scopeMatches(scope: GrantScope, context: ApprovalContext, requestToolName: String?): Boolean = + when (scope) { + // SESSION grants are scoped to a specific tool name. + // A null toolName on either side means the binding is absent; treat as no-match + // to prevent a legacy/malformed grant from becoming a blanket approval. + is GrantScope.SESSION -> scope.toolName != null + && requestToolName != null + && scope.toolName == requestToolName + is GrantScope.STAGE -> context.identity.stageId == scope.stageId + is GrantScope.PROJECT -> context.identity.projectId == scope.projectId + } } diff --git a/core/approvals/src/test/kotlin/com/correx/core/approvals/DefaultApprovalReducerTest.kt b/core/approvals/src/test/kotlin/com/correx/core/approvals/DefaultApprovalReducerTest.kt index 8a8ac110..0940f14d 100644 --- a/core/approvals/src/test/kotlin/com/correx/core/approvals/DefaultApprovalReducerTest.kt +++ b/core/approvals/src/test/kotlin/com/correx/core/approvals/DefaultApprovalReducerTest.kt @@ -68,7 +68,7 @@ class DefaultApprovalReducerTest { val grantId = GrantId("grant-1") val payload = ApprovalGrantCreatedEvent( grantId = grantId, - scope = GrantScope.SESSION, + scope = GrantScope.SESSION(), permittedTiers = setOf(Tier.T1, Tier.T2), reason = "user approved", expiresAt = null, @@ -81,7 +81,7 @@ class DefaultApprovalReducerTest { assertTrue(state.grants.containsKey(grantId)) assertEquals(grantId, state.grants[grantId]?.id) - assertEquals(GrantScope.SESSION, state.grants[grantId]?.scope) + assertEquals(GrantScope.SESSION(), state.grants[grantId]?.scope) } @Test @@ -90,7 +90,7 @@ class DefaultApprovalReducerTest { val addPayload = ApprovalGrantCreatedEvent( grantId = grantId, - scope = GrantScope.SESSION, + scope = GrantScope.SESSION(), permittedTiers = setOf(Tier.T1), reason = "granted", expiresAt = null, diff --git a/core/approvals/src/test/kotlin/com/correx/core/approvals/DefaultApprovalRepositoryTest.kt b/core/approvals/src/test/kotlin/com/correx/core/approvals/DefaultApprovalRepositoryTest.kt index 525677e5..97498ab2 100644 --- a/core/approvals/src/test/kotlin/com/correx/core/approvals/DefaultApprovalRepositoryTest.kt +++ b/core/approvals/src/test/kotlin/com/correx/core/approvals/DefaultApprovalRepositoryTest.kt @@ -76,7 +76,7 @@ class DefaultApprovalRepositoryTest { metadata = metadata(sessionId, "event-1"), payload = ApprovalGrantCreatedEvent( grantId = grantId, - scope = GrantScope.SESSION, + scope = GrantScope.SESSION(), permittedTiers = setOf(Tier.T1, Tier.T2), reason = "approved", expiresAt = null, diff --git a/core/events/src/main/kotlin/com/correx/core/approvals/ApprovalOutcome.kt b/core/events/src/main/kotlin/com/correx/core/approvals/ApprovalOutcome.kt index fd9b3cf0..85eeb82e 100644 --- a/core/events/src/main/kotlin/com/correx/core/approvals/ApprovalOutcome.kt +++ b/core/events/src/main/kotlin/com/correx/core/approvals/ApprovalOutcome.kt @@ -1,10 +1,11 @@ package com.correx.core.approvals +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable enum class ApprovalOutcome { - APPROVED, - REJECTED, - AUTO_APPROVED, + @SerialName("APPROVED") APPROVED, + @SerialName("REJECTED") REJECTED, + @SerialName("AUTO_APPROVED") AUTO_APPROVED, } diff --git a/core/events/src/main/kotlin/com/correx/core/approvals/GrantScope.kt b/core/events/src/main/kotlin/com/correx/core/approvals/GrantScope.kt index 840e5e8b..93430a18 100644 --- a/core/events/src/main/kotlin/com/correx/core/approvals/GrantScope.kt +++ b/core/events/src/main/kotlin/com/correx/core/approvals/GrantScope.kt @@ -6,7 +6,10 @@ import kotlinx.serialization.Serializable @Serializable sealed interface GrantScope { - @Serializable data object SESSION : GrantScope + // toolName binds this grant to a specific operation kind (e.g. "shell", "write_file"). + // A null toolName is rejected at grant-creation time; it is kept nullable here only + // for backward-compatible deserialization of legacy events. + @Serializable data class SESSION(val toolName: String? = null) : GrantScope @Serializable data class STAGE(val stageId: StageId) : GrantScope @Serializable data class PROJECT(val projectId: ProjectId) : GrantScope } diff --git a/core/events/src/main/kotlin/com/correx/core/approvals/Tier.kt b/core/events/src/main/kotlin/com/correx/core/approvals/Tier.kt index 7af1ce2e..50574a8b 100644 --- a/core/events/src/main/kotlin/com/correx/core/approvals/Tier.kt +++ b/core/events/src/main/kotlin/com/correx/core/approvals/Tier.kt @@ -1,15 +1,16 @@ package com.correx.core.approvals +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Suppress("MagicNumber") @Serializable enum class Tier(val level: Int) { - T0(0), - T1(1), - T2(2), - T3(3), - T4(4) + @SerialName("T0") T0(0), + @SerialName("T1") T1(1), + @SerialName("T2") T2(2), + @SerialName("T3") T3(3), + @SerialName("T4") T4(4) } fun Tier.isAtLeast(other: Tier): Boolean = this.level >= other.level diff --git a/core/events/src/main/kotlin/com/correx/core/events/events/ApprovalEvents.kt b/core/events/src/main/kotlin/com/correx/core/events/events/ApprovalEvents.kt index 2138ea4a..7243311c 100644 --- a/core/events/src/main/kotlin/com/correx/core/events/events/ApprovalEvents.kt +++ b/core/events/src/main/kotlin/com/correx/core/events/events/ApprovalEvents.kt @@ -14,9 +14,11 @@ import com.correx.core.events.types.SessionId import com.correx.core.events.types.StageId import com.correx.core.events.types.ValidationReportId import kotlinx.datetime.Instant +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable +@SerialName("ApprovalRequested") data class ApprovalRequestedEvent( val requestId: ApprovalRequestId, val tier: Tier, @@ -31,6 +33,7 @@ data class ApprovalRequestedEvent( ) : EventPayload @Serializable +@SerialName("ApprovalDecisionResolved") data class ApprovalDecisionResolvedEvent( val decisionId: ApprovalDecisionId, val requestId: ApprovalRequestId, @@ -43,6 +46,7 @@ data class ApprovalDecisionResolvedEvent( ) : EventPayload @Serializable +@SerialName("ApprovalGrantCreated") data class ApprovalGrantCreatedEvent( val grantId: GrantId, val scope: GrantScope, @@ -52,9 +56,13 @@ data class ApprovalGrantCreatedEvent( val sessionId: SessionId, val stageId: StageId?, val projectId: ProjectId?, + // Operation identity — must match DomainApprovalRequest.toolName for SESSION grants. + // Defaulted null for backward-compatible deserialization; new events always carry a value. + @SerialName("toolName") val toolName: String? = null, ) : EventPayload @Serializable +@SerialName("ApprovalGrantExpired") data class ApprovalGrantExpiredEvent( val grantId: GrantId, ) : EventPayload diff --git a/core/events/src/main/kotlin/com/correx/core/events/events/ArtifactEvents.kt b/core/events/src/main/kotlin/com/correx/core/events/events/ArtifactEvents.kt index cc152e76..8f6a6714 100644 --- a/core/events/src/main/kotlin/com/correx/core/events/events/ArtifactEvents.kt +++ b/core/events/src/main/kotlin/com/correx/core/events/events/ArtifactEvents.kt @@ -4,9 +4,11 @@ import com.correx.core.events.types.ArtifactId import com.correx.core.events.types.ArtifactRelationshipType import com.correx.core.events.types.SessionId import com.correx.core.events.types.StageId +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable +@SerialName("ArtifactValidating") data class ArtifactValidatingEvent( val artifactId: ArtifactId, val sessionId: SessionId, @@ -14,6 +16,7 @@ data class ArtifactValidatingEvent( ) : EventPayload @Serializable +@SerialName("ArtifactValidated") data class ArtifactValidatedEvent( val artifactId: ArtifactId, val sessionId: SessionId, @@ -21,6 +24,7 @@ data class ArtifactValidatedEvent( ) : EventPayload @Serializable +@SerialName("ArtifactSuperseded") data class ArtifactSupersededEvent( val artifactId: ArtifactId, val supersededById: ArtifactId, @@ -29,6 +33,7 @@ data class ArtifactSupersededEvent( ) : EventPayload @Serializable +@SerialName("ArtifactRelationshipAdded") data class ArtifactRelationshipAddedEvent( val sourceId: ArtifactId, val targetId: ArtifactId, @@ -37,6 +42,7 @@ data class ArtifactRelationshipAddedEvent( ) : EventPayload @Serializable +@SerialName("ArtifactRejected") data class ArtifactRejectedEvent( val artifactId: ArtifactId, val sessionId: SessionId, @@ -45,6 +51,7 @@ data class ArtifactRejectedEvent( ) : EventPayload @Serializable +@SerialName("ArtifactCreated") data class ArtifactCreatedEvent( val artifactId: ArtifactId, val sessionId: SessionId, @@ -53,6 +60,7 @@ data class ArtifactCreatedEvent( ) : EventPayload @Serializable +@SerialName("ArtifactArchived") data class ArtifactArchivedEvent( val artifactId: ArtifactId, val sessionId: SessionId, diff --git a/core/events/src/main/kotlin/com/correx/core/events/events/ContextEvents.kt b/core/events/src/main/kotlin/com/correx/core/events/events/ContextEvents.kt index b64c7592..2e09dcf8 100644 --- a/core/events/src/main/kotlin/com/correx/core/events/events/ContextEvents.kt +++ b/core/events/src/main/kotlin/com/correx/core/events/events/ContextEvents.kt @@ -3,9 +3,11 @@ package com.correx.core.events.events import com.correx.core.events.types.ContextPackId import com.correx.core.events.types.SessionId import com.correx.core.events.types.StageId +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable +@SerialName("LayerTruncated") data class LayerTruncatedEvent( val contextPackId: ContextPackId, val layer: String, @@ -14,12 +16,14 @@ data class LayerTruncatedEvent( ) : EventPayload @Serializable +@SerialName("ContextBuildingStarted") data class ContextBuildingStartedEvent( val sessionId: SessionId, val stageId: StageId, ) : EventPayload @Serializable +@SerialName("ContextBuildingFailed") data class ContextBuildingFailedEvent( val sessionId: SessionId, val stageId: StageId, @@ -28,12 +32,14 @@ data class ContextBuildingFailedEvent( // Emitted during replay when a session ended with buildingInProgress=true and no completion/failure event followed. @Serializable +@SerialName("ContextBuildingInterrupted") data class ContextBuildingInterruptedEvent( val sessionId: SessionId, val stageId: StageId, ) : EventPayload @Serializable +@SerialName("CompressionApplied") data class CompressionAppliedEvent( val contextPackId: ContextPackId, val layer: String, @@ -42,6 +48,7 @@ data class CompressionAppliedEvent( ) : EventPayload @Serializable +@SerialName("ContextPackBuilt") data class ContextPackBuiltEvent( val contextPackId: ContextPackId, val sessionId: SessionId, @@ -51,6 +58,7 @@ data class ContextPackBuiltEvent( ) : EventPayload @Serializable +@SerialName("SteeringNoteAdded") data class SteeringNoteAddedEvent( val sessionId: SessionId, val content: String, diff --git a/core/events/src/main/kotlin/com/correx/core/events/events/InferenceEvents.kt b/core/events/src/main/kotlin/com/correx/core/events/events/InferenceEvents.kt index 262e9830..bb4685f5 100644 --- a/core/events/src/main/kotlin/com/correx/core/events/events/InferenceEvents.kt +++ b/core/events/src/main/kotlin/com/correx/core/events/events/InferenceEvents.kt @@ -6,10 +6,12 @@ import com.correx.core.events.types.ProviderId import com.correx.core.events.types.SessionId import com.correx.core.events.types.StageId import com.correx.core.inference.TokenUsage +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable +@SerialName("InferenceStarted") data class InferenceStartedEvent( val requestId: InferenceRequestId, val sessionId: SessionId, @@ -19,6 +21,7 @@ data class InferenceStartedEvent( ) : EventPayload @Serializable +@SerialName("InferenceCompleted") data class InferenceCompletedEvent( val requestId: InferenceRequestId, val sessionId: SessionId, @@ -30,6 +33,7 @@ data class InferenceCompletedEvent( ) : EventPayload @Serializable +@SerialName("InferenceFailed") data class InferenceFailedEvent( val requestId: InferenceRequestId, val sessionId: SessionId, @@ -39,6 +43,7 @@ data class InferenceFailedEvent( ) : EventPayload @Serializable +@SerialName("InferenceTimeout") data class InferenceTimeoutEvent( val requestId: InferenceRequestId, val sessionId: SessionId, @@ -48,6 +53,7 @@ data class InferenceTimeoutEvent( ) : EventPayload @Serializable +@SerialName("ModelLoaded") data class ModelLoadedEvent( val modelId: String, val providerId: ProviderId, @@ -55,6 +61,7 @@ data class ModelLoadedEvent( ) : EventPayload @Serializable +@SerialName("ModelUnloaded") data class ModelUnloadedEvent( val modelId: String, val providerId: ProviderId, diff --git a/core/events/src/main/kotlin/com/correx/core/events/events/OrchestrationEvents.kt b/core/events/src/main/kotlin/com/correx/core/events/events/OrchestrationEvents.kt index 64958469..e6f80021 100644 --- a/core/events/src/main/kotlin/com/correx/core/events/events/OrchestrationEvents.kt +++ b/core/events/src/main/kotlin/com/correx/core/events/events/OrchestrationEvents.kt @@ -3,9 +3,11 @@ package com.correx.core.events.events import com.correx.core.events.execution.RetryPolicy import com.correx.core.events.types.SessionId import com.correx.core.events.types.StageId +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable +@SerialName("WorkflowStarted") data class WorkflowStartedEvent( val sessionId: SessionId, val workflowId: String, @@ -14,6 +16,7 @@ data class WorkflowStartedEvent( ) : EventPayload @Serializable +@SerialName("WorkflowCompleted") data class WorkflowCompletedEvent( val sessionId: SessionId, val terminalStageId: StageId, @@ -21,6 +24,7 @@ data class WorkflowCompletedEvent( ) : EventPayload @Serializable +@SerialName("WorkflowFailed") data class WorkflowFailedEvent( val sessionId: SessionId, val stageId: StageId, @@ -29,6 +33,7 @@ data class WorkflowFailedEvent( ) : EventPayload @Serializable +@SerialName("OrchestrationPaused") data class OrchestrationPausedEvent( val sessionId: SessionId, val stageId: StageId, @@ -36,12 +41,14 @@ data class OrchestrationPausedEvent( ) : EventPayload @Serializable +@SerialName("OrchestrationResumed") data class OrchestrationResumedEvent( val sessionId: SessionId, val stageId: StageId, ) : EventPayload @Serializable +@SerialName("RetryAttempted") data class RetryAttemptedEvent( val sessionId: SessionId, val stageId: StageId, diff --git a/core/events/src/main/kotlin/com/correx/core/events/events/RiskAssessedEvent.kt b/core/events/src/main/kotlin/com/correx/core/events/events/RiskAssessedEvent.kt index 485a9f3c..4c82e0a2 100644 --- a/core/events/src/main/kotlin/com/correx/core/events/events/RiskAssessedEvent.kt +++ b/core/events/src/main/kotlin/com/correx/core/events/events/RiskAssessedEvent.kt @@ -5,9 +5,11 @@ import com.correx.core.events.risk.RiskLevel import com.correx.core.events.types.RiskSummaryId import com.correx.core.events.types.SessionId import com.correx.core.events.types.StageId +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable +@SerialName("RiskAssessed") data class RiskAssessedEvent( val sessionId: SessionId, val stageId: StageId, diff --git a/core/events/src/main/kotlin/com/correx/core/events/events/SessionEvents.kt b/core/events/src/main/kotlin/com/correx/core/events/events/SessionEvents.kt index eabec9e6..8f253dc3 100644 --- a/core/events/src/main/kotlin/com/correx/core/events/events/SessionEvents.kt +++ b/core/events/src/main/kotlin/com/correx/core/events/events/SessionEvents.kt @@ -1,32 +1,38 @@ package com.correx.core.events.events import com.correx.core.events.types.SessionId +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable +@SerialName("SessionStarted") data class SessionStartedEvent( val sessionId: SessionId, val initialContextId: String? = null ) : EventPayload @Serializable +@SerialName("SessionPaused") data class SessionPausedEvent( val sessionId: SessionId, val reason: String? = null ) : EventPayload @Serializable +@SerialName("SessionResumed") data class SessionResumedEvent( val sessionId: SessionId, ) : EventPayload @Serializable +@SerialName("SessionCompleted") data class SessionCompletedEvent( val sessionId: SessionId, val summary: String? = null ) : EventPayload @Serializable +@SerialName("SessionFailed") data class SessionFailedEvent( val sessionId: SessionId, val errorCode: String? = null, diff --git a/core/events/src/main/kotlin/com/correx/core/events/events/StageEvents.kt b/core/events/src/main/kotlin/com/correx/core/events/events/StageEvents.kt index 3d49bfab..68931f00 100644 --- a/core/events/src/main/kotlin/com/correx/core/events/events/StageEvents.kt +++ b/core/events/src/main/kotlin/com/correx/core/events/events/StageEvents.kt @@ -3,9 +3,11 @@ package com.correx.core.events.events import com.correx.core.events.types.SessionId import com.correx.core.events.types.StageId import com.correx.core.events.types.TransitionId +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable +@SerialName("StageStarted") data class StageStartedEvent( val sessionId: SessionId, val stageId: StageId, @@ -13,6 +15,7 @@ data class StageStartedEvent( ) : EventPayload @Serializable +@SerialName("StageCompleted") data class StageCompletedEvent( val sessionId: SessionId, val stageId: StageId, @@ -20,6 +23,7 @@ data class StageCompletedEvent( ) : EventPayload @Serializable +@SerialName("StageFailed") data class StageFailedEvent( val sessionId: SessionId, val stageId: StageId, @@ -28,6 +32,7 @@ data class StageFailedEvent( ) : EventPayload @Serializable +@SerialName("TransitionExecuted") data class TransitionExecutedEvent( val sessionId: SessionId, val from: StageId, diff --git a/core/events/src/main/kotlin/com/correx/core/events/events/ToolEvents.kt b/core/events/src/main/kotlin/com/correx/core/events/events/ToolEvents.kt index 910b4cbf..b6a22f1f 100644 --- a/core/events/src/main/kotlin/com/correx/core/events/events/ToolEvents.kt +++ b/core/events/src/main/kotlin/com/correx/core/events/events/ToolEvents.kt @@ -4,9 +4,11 @@ import com.correx.core.approvals.Tier import com.correx.core.events.types.SessionId import com.correx.core.events.types.StageId import com.correx.core.events.types.ToolInvocationId +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable +@SerialName("ToolExecutionCompleted") data class ToolExecutionCompletedEvent( val invocationId: ToolInvocationId, val sessionId: SessionId, @@ -15,6 +17,7 @@ data class ToolExecutionCompletedEvent( ) : EventPayload @Serializable +@SerialName("ToolExecutionFailed") data class ToolExecutionFailedEvent( val invocationId: ToolInvocationId, val sessionId: SessionId, @@ -23,6 +26,7 @@ data class ToolExecutionFailedEvent( ) : EventPayload @Serializable +@SerialName("ToolExecutionRejected") data class ToolExecutionRejectedEvent( val invocationId: ToolInvocationId, val sessionId: SessionId, @@ -32,6 +36,7 @@ data class ToolExecutionRejectedEvent( ) : EventPayload @Serializable +@SerialName("ToolExecutionStarted") data class ToolExecutionStartedEvent( val invocationId: ToolInvocationId, val sessionId: SessionId, @@ -39,6 +44,7 @@ data class ToolExecutionStartedEvent( ) : EventPayload @Serializable +@SerialName("ToolInvocationRequested") data class ToolInvocationRequestedEvent( val invocationId: ToolInvocationId, val sessionId: SessionId, @@ -49,6 +55,7 @@ data class ToolInvocationRequestedEvent( ) : EventPayload @Serializable +@SerialName("ToolInvoked") data class ToolInvokedEvent( val toolId: String, ) : EventPayload diff --git a/core/events/src/main/kotlin/com/correx/core/events/risk/RiskAction.kt b/core/events/src/main/kotlin/com/correx/core/events/risk/RiskAction.kt index 32365889..99b87c74 100644 --- a/core/events/src/main/kotlin/com/correx/core/events/risk/RiskAction.kt +++ b/core/events/src/main/kotlin/com/correx/core/events/risk/RiskAction.kt @@ -1,3 +1,11 @@ package com.correx.core.events.risk -enum class RiskAction { PROCEED, PROMPT_USER, BLOCK } +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +enum class RiskAction { + @SerialName("PROCEED") PROCEED, + @SerialName("PROMPT_USER") PROMPT_USER, + @SerialName("BLOCK") BLOCK, +} diff --git a/core/events/src/main/kotlin/com/correx/core/events/risk/RiskLevel.kt b/core/events/src/main/kotlin/com/correx/core/events/risk/RiskLevel.kt index bcbccb54..a8539dbe 100644 --- a/core/events/src/main/kotlin/com/correx/core/events/risk/RiskLevel.kt +++ b/core/events/src/main/kotlin/com/correx/core/events/risk/RiskLevel.kt @@ -1,3 +1,12 @@ package com.correx.core.events.risk -enum class RiskLevel { LOW, MEDIUM, HIGH, CRITICAL } +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +enum class RiskLevel { + @SerialName("LOW") LOW, + @SerialName("MEDIUM") MEDIUM, + @SerialName("HIGH") HIGH, + @SerialName("CRITICAL") CRITICAL, +} diff --git a/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt b/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt index 65549ce8..c64e1082 100644 --- a/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt +++ b/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt @@ -106,4 +106,6 @@ val eventModule = SerializersModule { val eventJson = Json { serializersModule = eventModule + encodeDefaults = true + ignoreUnknownKeys = true } diff --git a/core/events/src/main/kotlin/com/correx/core/events/types/ArtifactRelationshipType.kt b/core/events/src/main/kotlin/com/correx/core/events/types/ArtifactRelationshipType.kt index 43b9de55..6a0ef01d 100644 --- a/core/events/src/main/kotlin/com/correx/core/events/types/ArtifactRelationshipType.kt +++ b/core/events/src/main/kotlin/com/correx/core/events/types/ArtifactRelationshipType.kt @@ -1,14 +1,15 @@ package com.correx.core.events.types +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable enum class ArtifactRelationshipType { - PARENT, - CHILD, - SUPERSEDES, - DERIVED_FROM, - VALIDATED_BY, - APPROVED_BY, - GENERATED_FROM + @SerialName("PARENT") PARENT, + @SerialName("CHILD") CHILD, + @SerialName("SUPERSEDES") SUPERSEDES, + @SerialName("DERIVED_FROM") DERIVED_FROM, + @SerialName("VALIDATED_BY") VALIDATED_BY, + @SerialName("APPROVED_BY") APPROVED_BY, + @SerialName("GENERATED_FROM") GENERATED_FROM, } diff --git a/core/events/src/test/kotlin/com/correx/core/events/EventSerializationHardeningTest.kt b/core/events/src/test/kotlin/com/correx/core/events/EventSerializationHardeningTest.kt new file mode 100644 index 00000000..3253dcf3 --- /dev/null +++ b/core/events/src/test/kotlin/com/correx/core/events/EventSerializationHardeningTest.kt @@ -0,0 +1,107 @@ +package com.correx.core.events + +import com.correx.core.approvals.ApprovalOutcome +import com.correx.core.approvals.ApprovalStatus +import com.correx.core.approvals.GrantScope +import com.correx.core.approvals.Tier +import com.correx.core.events.events.ApprovalDecisionResolvedEvent +import com.correx.core.events.events.ContextPackBuiltEvent +import com.correx.core.events.events.EventPayload +import com.correx.core.events.events.OrchestrationPausedEvent +import com.correx.core.events.events.RiskAssessedEvent +import com.correx.core.events.events.SessionStartedEvent +import com.correx.core.events.events.ToolExecutionFailedEvent +import com.correx.core.events.risk.RiskAction +import com.correx.core.events.risk.RiskLevel +import com.correx.core.events.serialization.eventJson +import com.correx.core.events.types.ApprovalDecisionId +import com.correx.core.events.types.ApprovalRequestId +import com.correx.core.events.types.ContextPackId +import com.correx.core.events.types.RiskSummaryId +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import com.correx.core.events.types.ToolInvocationId +import kotlinx.datetime.Instant +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertDoesNotThrow +import org.junit.jupiter.api.Test + +class EventSerializationHardeningTest { + + private val sessionId = SessionId("s-1") + private val stageId = StageId("st-1") + private val ts = Instant.parse("2026-01-01T00:00:00Z") + + private val payloads: List> = listOf( + "SessionStarted" to SessionStartedEvent(sessionId = sessionId), + "ToolExecutionFailed" to ToolExecutionFailedEvent( + invocationId = ToolInvocationId("inv-1"), + sessionId = sessionId, + toolName = "echo", + reason = "boom" + ), + "ApprovalDecisionResolved" to ApprovalDecisionResolvedEvent( + decisionId = ApprovalDecisionId("d-1"), + requestId = ApprovalRequestId("r-1"), + outcome = ApprovalOutcome.APPROVED, + status = ApprovalStatus.COMPLETED, + tier = Tier.T2, + resolutionTimestamp = ts, + reason = null + ), + "ContextPackBuilt" to ContextPackBuiltEvent( + contextPackId = ContextPackId("cp-1"), + sessionId = sessionId, + stageId = stageId, + budgetUsed = 100, + budgetLimit = 4096 + ), + "OrchestrationPaused" to OrchestrationPausedEvent( + sessionId = sessionId, + stageId = stageId, + reason = "APPROVAL_PENDING" + ), + "RiskAssessed" to RiskAssessedEvent( + sessionId = sessionId, + stageId = stageId, + riskSummaryId = RiskSummaryId("rs-1"), + level = RiskLevel.MEDIUM, + action = RiskAction.PROCEED + ) + ) + + @Test + fun `discriminator field equals pinned SerialName for all representative events`() { + for ((expectedType, payload) in payloads) { + val json = eventJson.encodeToString(EventPayload.serializer(), payload) + val element = Json.parseToJsonElement(json) + val actualType = element.jsonObject["type"]?.toString()?.removeSurrounding("\"") + assertEquals( + expectedType, + actualType, + "Expected discriminator 'type'=\"$expectedType\" for ${payload::class.simpleName}" + ) + } + } + + @Test + fun `all representative events round-trip through EventPayload polymorphic serializer`() { + for ((_, payload) in payloads) { + val json = eventJson.encodeToString(EventPayload.serializer(), payload) + val decoded = eventJson.decodeFromString(EventPayload.serializer(), json) + assertEquals(payload, decoded, "Round-trip failed for ${payload::class.simpleName}") + } + } + + @Test + fun `unknown fields in JSON are tolerated (ignoreUnknownKeys=true)`() { + val event = SessionStartedEvent(sessionId = sessionId) + val json = eventJson.encodeToString(EventPayload.serializer(), event) + val withExtra = json.replace("{", "{\"bogusField\":123,") + assertDoesNotThrow { + eventJson.decodeFromString(EventPayload.serializer(), withExtra) + } + } +} diff --git a/infrastructure/persistence/build.gradle b/infrastructure/persistence/build.gradle index 056ac60b..14e1144c 100644 --- a/infrastructure/persistence/build.gradle +++ b/infrastructure/persistence/build.gradle @@ -12,6 +12,7 @@ dependencies { implementation(project(":core:artifacts-store")) implementation "org.xerial:sqlite-jdbc" testImplementation(testFixtures(project(":testing:contracts"))) + testImplementation(project(":testing:fixtures")) testImplementation "org.junit.jupiter:junit-jupiter" } diff --git a/infrastructure/persistence/src/main/kotlin/com/correx/infrastructure/persistence/SqliteEventStore.kt b/infrastructure/persistence/src/main/kotlin/com/correx/infrastructure/persistence/SqliteEventStore.kt index 616e1625..9a9167cf 100644 --- a/infrastructure/persistence/src/main/kotlin/com/correx/infrastructure/persistence/SqliteEventStore.kt +++ b/infrastructure/persistence/src/main/kotlin/com/correx/infrastructure/persistence/SqliteEventStore.kt @@ -17,6 +17,8 @@ import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.datetime.Instant import java.sql.Connection @@ -28,6 +30,7 @@ class SqliteEventStore( private val jsonSerializer: JsonEventSerializer = JsonEventSerializer(eventJson), private val artifactStore: ArtifactStore, ) : EventStore { + private val appendMutex = Mutex() private val subscriptions = ConcurrentHashMap>() private val globalFlow = MutableSharedFlow( replay = 0, @@ -37,6 +40,9 @@ class SqliteEventStore( init { connection.createStatement().use { stmt -> + stmt.execute("PRAGMA journal_mode=WAL;") + stmt.execute("PRAGMA busy_timeout=5000;") + stmt.execute("PRAGMA synchronous=NORMAL;") stmt.execute( """ CREATE TABLE IF NOT EXISTS events ( @@ -63,21 +69,23 @@ class SqliteEventStore( override suspend fun append(event: NewEvent): StoredEvent { var stored: StoredEvent? = null - artifactStore.flushBefore { - withContext(Dispatchers.IO) { - stored = connection.transaction { - val existing = findByEventId(event.metadata.eventId) - check(existing == null) { "duplicate event_id: ${event.metadata.eventId}" } - val globalSeqVal = nextGlobalSequence() - val sessionSeqVal = nextSessionSequence(event.metadata.sessionId) - val s = StoredEvent( - metadata = event.metadata, - sequence = globalSeqVal, - sessionSequence = sessionSeqVal, - payload = event.payload, - ) - insert(s) - s + appendMutex.withLock { + artifactStore.flushBefore { + withContext(Dispatchers.IO) { + stored = connection.transaction { + val existing = findByEventId(event.metadata.eventId) + check(existing == null) { "duplicate event_id: ${event.metadata.eventId}" } + val globalSeqVal = nextGlobalSequence() + val sessionSeqVal = nextSessionSequence(event.metadata.sessionId) + val s = StoredEvent( + metadata = event.metadata, + sequence = globalSeqVal, + sessionSequence = sessionSeqVal, + payload = event.payload, + ) + insert(s) + s + } } } } @@ -91,22 +99,24 @@ class SqliteEventStore( if (events.isEmpty()) return emptyList() val sessionId = events.first().metadata.sessionId var stored: List = emptyList() - artifactStore.flushBefore { - withContext(Dispatchers.IO) { - stored = connection.transaction { - events.map { event -> - val existing = findByEventId(event.metadata.eventId) - check(existing == null) { "duplicate event_id: ${event.metadata.eventId}" } - val globalSeqVal = nextGlobalSequence() - val sessionSeqVal = nextSessionSequence(sessionId) - val s = StoredEvent( - metadata = event.metadata, - sequence = globalSeqVal, - sessionSequence = sessionSeqVal, - payload = event.payload, - ) - insert(s) - s + appendMutex.withLock { + artifactStore.flushBefore { + withContext(Dispatchers.IO) { + stored = connection.transaction { + events.map { event -> + val existing = findByEventId(event.metadata.eventId) + check(existing == null) { "duplicate event_id: ${event.metadata.eventId}" } + val globalSeqVal = nextGlobalSequence() + val sessionSeqVal = nextSessionSequence(sessionId) + val s = StoredEvent( + metadata = event.metadata, + sequence = globalSeqVal, + sessionSequence = sessionSeqVal, + payload = event.payload, + ) + insert(s) + s + } } } } diff --git a/infrastructure/persistence/src/main/kotlin/com/correx/infrastructure/persistence/util/JDBCHelper.kt b/infrastructure/persistence/src/main/kotlin/com/correx/infrastructure/persistence/util/JDBCHelper.kt index 192c6ef8..0d71e4af 100644 --- a/infrastructure/persistence/src/main/kotlin/com/correx/infrastructure/persistence/util/JDBCHelper.kt +++ b/infrastructure/persistence/src/main/kotlin/com/correx/infrastructure/persistence/util/JDBCHelper.kt @@ -1,7 +1,6 @@ package com.correx.infrastructure.persistence.util import java.sql.Connection -import java.sql.SQLException object JDBCHelper { inline fun Connection.transaction(block: () -> T): T { @@ -13,7 +12,7 @@ object JDBCHelper { commit() return result - } catch (e: SQLException) { + } catch (e: Throwable) { rollback() throw e } finally { diff --git a/infrastructure/persistence/src/test/kotlin/com/correx/infrastructure/persistence/SqliteEventStoreConcurrencyTest.kt b/infrastructure/persistence/src/test/kotlin/com/correx/infrastructure/persistence/SqliteEventStoreConcurrencyTest.kt new file mode 100644 index 00000000..faf92ab0 --- /dev/null +++ b/infrastructure/persistence/src/test/kotlin/com/correx/infrastructure/persistence/SqliteEventStoreConcurrencyTest.kt @@ -0,0 +1,53 @@ +package com.correx.infrastructure.persistence + +import com.correx.core.events.types.EventId +import com.correx.core.events.types.SessionId +import com.correx.testing.contracts.fixtures.artifactstore.NoopArtifactStore +import com.correx.testing.fixtures.EventFixtures +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Path +import java.sql.DriverManager + +class SqliteEventStoreConcurrencyTest { + + @Test + fun `concurrent appends to same session produce no dropped or duplicate sequence numbers`( + @TempDir tempDir: Path, + ): Unit = runBlocking { + val dbFile = tempDir.resolve("events.db").toAbsolutePath().toString() + val conn = DriverManager.getConnection("jdbc:sqlite:$dbFile") + val store = SqliteEventStore(conn, artifactStore = NoopArtifactStore()) + + val sessionId = SessionId("concurrent-session") + val n = 50 + + (1..n) + .map { i -> + async(Dispatchers.IO) { + store.append(EventFixtures.newEvent(EventId("e-$i"), sessionId)) + } + } + .awaitAll() + + val events = store.read(sessionId) + + assertEquals(n, events.size, "Expected exactly $n events but got ${events.size}") + + val sessionSeqs = events.map { it.sessionSequence }.sorted() + assertEquals((1L..n.toLong()).toList(), sessionSeqs, "Session sequences must be exactly 1..$n with no gaps or duplicates") + + val globalSeqs = events.map { it.sequence } + assertEquals(globalSeqs.size, globalSeqs.toSet().size, "Global sequence values must all be unique") + + globalSeqs.forEach { seq -> + assertTrue(seq >= 1L, "Global sequence $seq must be >= 1") + } + } +} diff --git a/testing/approvals/src/test/kotlin/ApprovalEngineEdgeCasesTest.kt b/testing/approvals/src/test/kotlin/ApprovalEngineEdgeCasesTest.kt index 80a22c5a..6812ccec 100644 --- a/testing/approvals/src/test/kotlin/ApprovalEngineEdgeCasesTest.kt +++ b/testing/approvals/src/test/kotlin/ApprovalEngineEdgeCasesTest.kt @@ -29,7 +29,7 @@ class ApprovalEngineEdgeCasesTest { fun `grants from different scopes apply only to matching identity`() { val sessionGrant = ApprovalGrant( id = GrantId("g1"), - scope = GrantScope.SESSION, + scope = GrantScope.SESSION(), permittedTiers = setOf(Tier.T3), reason = "", timestamp = Instant.parse("2026-01-01T00:00:00Z") @@ -59,7 +59,7 @@ class ApprovalEngineEdgeCasesTest { val past = Instant.parse("2025-12-31T23:59:59Z") val expiredGrant = ApprovalGrant( id = GrantId("g1"), - scope = GrantScope.SESSION, + scope = GrantScope.SESSION(), permittedTiers = setOf(Tier.T2), reason = "", timestamp = past, diff --git a/testing/approvals/src/test/kotlin/GrantSecurityTest.kt b/testing/approvals/src/test/kotlin/GrantSecurityTest.kt new file mode 100644 index 00000000..bd830864 --- /dev/null +++ b/testing/approvals/src/test/kotlin/GrantSecurityTest.kt @@ -0,0 +1,171 @@ +import com.correx.core.approvals.ApprovalOutcome +import com.correx.core.approvals.ApprovalStatus +import com.correx.core.approvals.GrantScope +import com.correx.core.approvals.Tier +import com.correx.core.approvals.domain.DefaultApprovalEngine +import com.correx.core.approvals.model.ApprovalContext +import com.correx.core.approvals.model.ApprovalGrant +import com.correx.core.approvals.model.ApprovalScopeIdentity +import com.correx.core.events.types.GrantId +import com.correx.core.events.types.SessionId +import com.correx.core.sessions.ApprovalMode +import com.correx.testing.fixtures.request +import kotlinx.datetime.Instant +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +class GrantSecurityTest { + + private val engine = DefaultApprovalEngine() + + private val now = Instant.parse("2026-01-01T00:00:01Z") + private val grantTime = Instant.parse("2026-01-01T00:00:00Z") + + private fun denyCtx() = ApprovalContext( + ApprovalScopeIdentity(SessionId("s1"), null, null), + ApprovalMode.DENY + ) + + // ─── 1. Operation isolation ─────────────────────────────────────────────── + + @Test + fun `SESSION grant for shell does not auto-approve write_file request`() { + val req = request("r1", Tier.T2).copy(toolName = "write_file") + val grant = ApprovalGrant( + id = GrantId("g1"), + scope = GrantScope.SESSION(toolName = "shell"), + permittedTiers = setOf(Tier.T2), + reason = "test", + timestamp = grantTime + ) + + val decision = engine.evaluate(req, denyCtx(), listOf(grant), now) + + assertEquals(ApprovalStatus.PENDING, decision.state) + assertNull(decision.outcome) + } + + @Test + fun `SESSION grant for write_file does not auto-approve shell request`() { + val req = request("r1", Tier.T2).copy(toolName = "shell") + val grant = ApprovalGrant( + id = GrantId("g1"), + scope = GrantScope.SESSION(toolName = "write_file"), + permittedTiers = setOf(Tier.T2), + reason = "test", + timestamp = grantTime + ) + + val decision = engine.evaluate(req, denyCtx(), listOf(grant), now) + + assertEquals(ApprovalStatus.PENDING, decision.state) + assertNull(decision.outcome) + } + + // ─── 2. Tier ceiling ───────────────────────────────────────────────────── + + @Test + fun `grant with max tier T2 does not auto-approve T3 request even with matching toolName`() { + val req = request("r1", Tier.T3).copy(toolName = "shell") + val grant = ApprovalGrant( + id = GrantId("g1"), + scope = GrantScope.SESSION(toolName = "shell"), + permittedTiers = setOf(Tier.T2), + reason = "test", + timestamp = grantTime + ) + + val decision = engine.evaluate(req, denyCtx(), listOf(grant), now) + + assertEquals(ApprovalStatus.PENDING, decision.state) + assertNull(decision.outcome) + } + + @Test + fun `grant with max tier T2 auto-approves T2 request with matching toolName (positive control)`() { + val req = request("r1", Tier.T2).copy(toolName = "shell") + val grant = ApprovalGrant( + id = GrantId("g1"), + scope = GrantScope.SESSION(toolName = "shell"), + permittedTiers = setOf(Tier.T2), + reason = "test", + timestamp = grantTime + ) + + val decision = engine.evaluate(req, denyCtx(), listOf(grant), now) + + assertEquals(ApprovalOutcome.AUTO_APPROVED, decision.outcome) + assertEquals(ApprovalStatus.COMPLETED, decision.state) + } + + @Test + fun `grant with max tier T2 auto-approves T1 request with matching toolName (ceiling covers lower tiers)`() { + val req = request("r1", Tier.T1).copy(toolName = "shell") + val grant = ApprovalGrant( + id = GrantId("g1"), + scope = GrantScope.SESSION(toolName = "shell"), + permittedTiers = setOf(Tier.T2), + reason = "test", + timestamp = grantTime + ) + + val decision = engine.evaluate(req, denyCtx(), listOf(grant), now) + + assertEquals(ApprovalOutcome.AUTO_APPROVED, decision.outcome) + assertEquals(ApprovalStatus.COMPLETED, decision.state) + } + + @Test + fun `grant with max tier T2 auto-approves T0 request with matching toolName (ceiling covers lowest tier)`() { + val req = request("r1", Tier.T0).copy(toolName = "shell") + val grant = ApprovalGrant( + id = GrantId("g1"), + scope = GrantScope.SESSION(toolName = "shell"), + permittedTiers = setOf(Tier.T2), + reason = "test", + timestamp = grantTime + ) + + val decision = engine.evaluate(req, denyCtx(), listOf(grant), now) + + assertEquals(ApprovalOutcome.AUTO_APPROVED, decision.outcome) + assertEquals(ApprovalStatus.COMPLETED, decision.state) + } + + // ─── 3. No blanket grant ────────────────────────────────────────────────── + + @Test + fun `SESSION grant with null toolName does not match any request (default-deny preserved)`() { + val req = request("r1", Tier.T2).copy(toolName = "shell") + val grant = ApprovalGrant( + id = GrantId("g1"), + scope = GrantScope.SESSION(toolName = null), + permittedTiers = setOf(Tier.T2), + reason = "test", + timestamp = grantTime + ) + + val decision = engine.evaluate(req, denyCtx(), listOf(grant), now) + + assertEquals(ApprovalStatus.PENDING, decision.state) + assertNull(decision.outcome) + } + + @Test + fun `SESSION grant with null toolName does not match request with null toolName`() { + val req = request("r1", Tier.T2).copy(toolName = null) + val grant = ApprovalGrant( + id = GrantId("g1"), + scope = GrantScope.SESSION(toolName = null), + permittedTiers = setOf(Tier.T2), + reason = "test", + timestamp = grantTime + ) + + val decision = engine.evaluate(req, denyCtx(), listOf(grant), now) + + assertEquals(ApprovalStatus.PENDING, decision.state) + assertNull(decision.outcome) + } +} diff --git a/testing/approvals/src/test/kotlin/GrantSemanticsTest.kt b/testing/approvals/src/test/kotlin/GrantSemanticsTest.kt index c1be52f6..54ecc4b9 100644 --- a/testing/approvals/src/test/kotlin/GrantSemanticsTest.kt +++ b/testing/approvals/src/test/kotlin/GrantSemanticsTest.kt @@ -21,10 +21,10 @@ class GrantSemanticsTest { @Test fun `grant permits exact tier - auto_approved`() { - val req = request("r1", Tier.T2) + val req = request("r1", Tier.T2).copy(toolName = "shell") val grant = ApprovalGrant( id = GrantId("g1"), - scope = GrantScope.SESSION, + scope = GrantScope.SESSION(toolName = "shell"), permittedTiers = setOf(Tier.T2), reason = "test", timestamp = Instant.parse("2026-01-01T00:00:00Z") @@ -45,7 +45,7 @@ class GrantSemanticsTest { val req = request("r1", Tier.T3) val grant = ApprovalGrant( id = GrantId("g1"), - scope = GrantScope.SESSION, + scope = GrantScope.SESSION(), permittedTiers = setOf(Tier.T2), reason = "test", timestamp = Instant.parse("2026-01-01T00:00:00Z") @@ -63,18 +63,18 @@ class GrantSemanticsTest { @Test fun `multiple grants - any match is enough`() { - val req = request("r1", Tier.T4) + val req = request("r1", Tier.T4).copy(toolName = "write_file") val grants = listOf( ApprovalGrant( GrantId("g1"), - GrantScope.SESSION, + GrantScope.SESSION(toolName = "shell"), setOf(Tier.T3), "", Instant.parse("2026-01-01T00:00:00Z") ), ApprovalGrant( GrantId("g2"), - GrantScope.SESSION, + GrantScope.SESSION(toolName = "write_file"), setOf(Tier.T4), "", Instant.parse("2026-01-01T00:00:00Z") diff --git a/testing/approvals/src/test/kotlin/NoExecutionCouplingTest.kt b/testing/approvals/src/test/kotlin/NoExecutionCouplingTest.kt index c71f3c81..78b6e04e 100644 --- a/testing/approvals/src/test/kotlin/NoExecutionCouplingTest.kt +++ b/testing/approvals/src/test/kotlin/NoExecutionCouplingTest.kt @@ -21,7 +21,7 @@ class NoExecutionCouplingTest { val grants = mutableListOf( ApprovalGrant( GrantId("g1"), - GrantScope.SESSION, + GrantScope.SESSION(), setOf(Tier.T2), "", Instant.parse("2026-01-01T00:00:00Z") diff --git a/testing/approvals/src/test/kotlin/TierImmutabilityTest.kt b/testing/approvals/src/test/kotlin/TierImmutabilityTest.kt index b70881e7..ba123fcb 100644 --- a/testing/approvals/src/test/kotlin/TierImmutabilityTest.kt +++ b/testing/approvals/src/test/kotlin/TierImmutabilityTest.kt @@ -38,7 +38,7 @@ class TierImmutabilityTest { val req = request("r1", tier) val grant = ApprovalGrant( id = GrantId("g1"), - scope = GrantScope.SESSION, + scope = GrantScope.SESSION(), permittedTiers = setOf(tier), reason = "test", timestamp = Instant.parse("2026-01-01T00:00:00Z") diff --git a/testing/replay/src/test/kotlin/ApprovalReplayTest.kt b/testing/replay/src/test/kotlin/ApprovalReplayTest.kt index c8107b9b..38301320 100644 --- a/testing/replay/src/test/kotlin/ApprovalReplayTest.kt +++ b/testing/replay/src/test/kotlin/ApprovalReplayTest.kt @@ -26,7 +26,7 @@ class ApprovalReplayTest { val grants = listOf( ApprovalGrant( GrantId("g1"), - GrantScope.SESSION, + GrantScope.SESSION(), setOf(Tier.T2), "reason", Instant.parse("2026-01-01T00:00:00Z")