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
@@ -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
}
}
@@ -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)
}
}
}