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:
+15
-7
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -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,
|
||||
|
||||
+1
-1
@@ -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
|
||||
}
|
||||
|
||||
+8
-7
@@ -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,
|
||||
}
|
||||
|
||||
+107
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user