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
@@ -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,8 +85,14 @@ 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
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
}
@@ -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,
@@ -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,
@@ -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,
}
@@ -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
}
@@ -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
@@ -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
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -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
@@ -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,
}
@@ -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,
}
@@ -106,4 +106,6 @@ val eventModule = SerializersModule {
val eventJson = Json {
serializersModule = eventModule
encodeDefaults = true
ignoreUnknownKeys = true
}
@@ -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,
}
@@ -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<Pair<String, EventPayload>> = 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)
}
}
}
+1
View File
@@ -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"
}
@@ -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<SessionId, MutableSharedFlow<StoredEvent>>()
private val globalFlow = MutableSharedFlow<StoredEvent>(
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,6 +69,7 @@ class SqliteEventStore(
override suspend fun append(event: NewEvent): StoredEvent {
var stored: StoredEvent? = null
appendMutex.withLock {
artifactStore.flushBefore {
withContext(Dispatchers.IO) {
stored = connection.transaction {
@@ -81,6 +88,7 @@ class SqliteEventStore(
}
}
}
}
val result = checkNotNull(stored)
subscriptions[event.metadata.sessionId]?.tryEmit(result)
globalFlow.emit(result)
@@ -91,6 +99,7 @@ class SqliteEventStore(
if (events.isEmpty()) return emptyList()
val sessionId = events.first().metadata.sessionId
var stored: List<StoredEvent> = emptyList()
appendMutex.withLock {
artifactStore.flushBefore {
withContext(Dispatchers.IO) {
stored = connection.transaction {
@@ -111,6 +120,7 @@ class SqliteEventStore(
}
}
}
}
val flow = subscriptions[sessionId]
if (flow != null) stored.forEach { flow.tryEmit(it) }
stored.forEach { globalFlow.emit(it) }
@@ -1,7 +1,6 @@
package com.correx.infrastructure.persistence.util
import java.sql.Connection
import java.sql.SQLException
object JDBCHelper {
inline fun <T> Connection.transaction(block: () -> T): T {
@@ -13,7 +12,7 @@ object JDBCHelper {
commit()
return result
} catch (e: SQLException) {
} catch (e: Throwable) {
rollback()
throw e
} finally {
@@ -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")
}
}
}
@@ -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,
@@ -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)
}
}
@@ -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")
@@ -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")
@@ -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")
@@ -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")