From 780a00229efc5149f61ae3b63d7f1e7c0b5dcb22 Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 30 May 2026 19:08:34 +0400 Subject: [PATCH] feat: surface risk rationale to operator in approval prompt Carry the full RiskSummary (level, signals, rationale) inline on ApprovalRequestedEvent instead of discarding it after assessment. The two server-side DTO builders (live ApprovalCoordinator path and replay DomainEventMapper path) now render real risk level, recommended action, signal factors, and the [code] rationale lines into the approval WebSocket message, with a consistent enum-name fallback when no assessment ran (tool path). RiskSummary/RiskSignal made @Serializable for event embedding; shared RiskSummary.toDto() lives next to RiskSummaryDto. --- .../server/approval/ApprovalCoordinator.kt | 7 ++- .../apps/server/bridge/DomainEventMapper.kt | 9 +++- .../com/correx/apps/server/protocol/Dtos.kt | 17 ++++++++ .../approval/ApprovalCoordinatorWiringTest.kt | 28 ++++++++++++ .../server/bridge/DomainEventMapperTest.kt | 43 ++++++++++++++++++- .../approvals/model/DomainApprovalRequest.kt | 2 + .../core/events/events/ApprovalEvents.kt | 2 + .../com/correx/core/events/risk/RiskSignal.kt | 15 +++++++ .../correx/core/events/risk/RiskSummary.kt | 3 ++ .../events/EventSerializationHardeningTest.kt | 31 +++++++++++++ .../orchestration/SessionOrchestrator.kt | 5 +++ 11 files changed, 158 insertions(+), 4 deletions(-) diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/approval/ApprovalCoordinator.kt b/apps/server/src/main/kotlin/com/correx/apps/server/approval/ApprovalCoordinator.kt index e305b5c7..2588408e 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/approval/ApprovalCoordinator.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/approval/ApprovalCoordinator.kt @@ -4,6 +4,7 @@ import com.correx.apps.server.protocol.ClientMessage import com.correx.apps.server.protocol.ProtocolSerializer import com.correx.apps.server.protocol.RiskSummaryDto import com.correx.apps.server.protocol.ServerMessage +import com.correx.apps.server.protocol.toDto import com.correx.core.approvals.ApprovalOutcome import com.correx.core.approvals.ApprovalStatus import com.correx.core.approvals.Tier @@ -11,6 +12,7 @@ import com.correx.core.approvals.model.ApprovalContext import com.correx.core.approvals.model.ApprovalScopeIdentity import com.correx.core.config.ApprovalConfig import com.correx.core.events.events.ApprovalRequestedEvent +import com.correx.core.events.risk.RiskAction import com.correx.core.events.types.ApprovalRequestId import com.correx.core.events.types.SessionId import com.correx.core.events.types.StageId @@ -60,10 +62,11 @@ open class ApprovalCoordinator( sessionId = event.sessionId, requestId = event.requestId, tier = event.tier, - riskSummary = RiskSummaryDto( + riskSummary = event.riskSummary?.toDto() ?: RiskSummaryDto( level = event.tier.name, factors = emptyList(), - recommendedAction = "Review and approve or reject", + recommendedAction = RiskAction.PROMPT_USER.name, + rationale = emptyList(), ), toolName = event.toolName, preview = event.preview, diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/bridge/DomainEventMapper.kt b/apps/server/src/main/kotlin/com/correx/apps/server/bridge/DomainEventMapper.kt index 528bd1a9..65766f38 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/bridge/DomainEventMapper.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/bridge/DomainEventMapper.kt @@ -3,6 +3,7 @@ package com.correx.apps.server.bridge import com.correx.apps.server.protocol.PauseReason import com.correx.apps.server.protocol.RiskSummaryDto import com.correx.apps.server.protocol.ServerMessage +import com.correx.apps.server.protocol.toDto import com.correx.core.artifactstore.ArtifactStore import com.correx.core.events.events.ApprovalRequestedEvent import com.correx.core.events.events.ArtifactCreatedEvent @@ -23,6 +24,7 @@ import com.correx.core.events.events.ToolInvocationRequestedEvent import com.correx.core.events.events.TransitionExecutedEvent import com.correx.core.events.events.WorkflowCompletedEvent import com.correx.core.events.events.WorkflowFailedEvent +import com.correx.core.events.risk.RiskAction import com.correx.core.events.types.ArtifactId import org.slf4j.LoggerFactory @@ -219,7 +221,12 @@ private fun mapApprovalRequested( sessionId = p.sessionId, requestId = p.requestId, tier = p.tier, - riskSummary = RiskSummaryDto("unknown", emptyList(), ""), + riskSummary = p.riskSummary?.toDto() ?: RiskSummaryDto( + level = p.tier.name, + factors = emptyList(), + recommendedAction = RiskAction.PROMPT_USER.name, + rationale = emptyList(), + ), toolName = p.toolName, preview = p.preview, sequence = seq, diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/protocol/Dtos.kt b/apps/server/src/main/kotlin/com/correx/apps/server/protocol/Dtos.kt index 4eb69a3d..e0022f84 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/protocol/Dtos.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/protocol/Dtos.kt @@ -1,5 +1,7 @@ package com.correx.apps.server.protocol +import com.correx.core.events.risk.RiskSignal +import com.correx.core.events.risk.RiskSummary import kotlinx.serialization.Serializable @Serializable @@ -7,6 +9,7 @@ data class RiskSummaryDto( val level: String, val factors: List, val recommendedAction: String, + val rationale: List = emptyList(), ) @Serializable @@ -75,3 +78,17 @@ data class WorkflowDto( val workflowId: String, val description: String, ) + +internal fun RiskSummary.toDto(): RiskSummaryDto = RiskSummaryDto( + level = level.name, + factors = signals.map { signal -> + when (signal) { + is RiskSignal.CycleWithoutExit -> "Cycle without exit: ${signal.cycleId}" + is RiskSignal.RepeatedFailure -> "Repeated failure: ${signal.reason} (${signal.count}×)" + is RiskSignal.ValidationErrors -> "Validation errors: ${signal.errorCount}" + is RiskSignal.InferenceTimeout -> "Inference timeout: ${signal.elapsedMs}ms" + } + }, + recommendedAction = recommendedAction.name, + rationale = rationale, +) diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/approval/ApprovalCoordinatorWiringTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/approval/ApprovalCoordinatorWiringTest.kt index 38ecb0dd..6ca3ce0b 100644 --- a/apps/server/src/test/kotlin/com/correx/apps/server/approval/ApprovalCoordinatorWiringTest.kt +++ b/apps/server/src/test/kotlin/com/correx/apps/server/approval/ApprovalCoordinatorWiringTest.kt @@ -12,6 +12,10 @@ 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.stores.EventStore +import com.correx.core.events.risk.RiskAction +import com.correx.core.events.risk.RiskLevel +import com.correx.core.events.risk.RiskSignal +import com.correx.core.events.risk.RiskSummary import com.correx.core.events.types.ApprovalRequestId import com.correx.core.events.types.EventId import com.correx.core.events.types.RiskSummaryId @@ -207,4 +211,28 @@ class ApprovalCoordinatorWiringTest { coord.handleResponse(msg, otherSessionId) assertNull(coord.lookupSession(requestId)) } + + @Test + fun `onApprovalRequested with non-null riskSummary registers session without error`(): Unit = runBlocking { + val gateway = RecordingGateway() + val coord = ApprovalCoordinator(gateway, ApprovalConfig(timeoutMs = 300_000L), scope) + val riskSummary = RiskSummary( + level = RiskLevel.HIGH, + signals = listOf(RiskSignal.ValidationErrors(errorCount = 2)), + recommendedAction = RiskAction.PROMPT_USER, + rationale = listOf("[ERR001] Schema mismatch"), + ) + val event = ApprovalRequestedEvent( + requestId = requestId, + tier = Tier.T3, + validationReportId = ValidationReportId("vr-1"), + riskSummaryId = null, + riskSummary = riskSummary, + sessionId = sessionId, + stageId = null, + projectId = null, + ) + coord.onApprovalRequested(event) + assertEquals(sessionId, coord.lookupSession(requestId)) + } } diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/bridge/DomainEventMapperTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/bridge/DomainEventMapperTest.kt index bf696c5f..e8773680 100644 --- a/apps/server/src/test/kotlin/com/correx/apps/server/bridge/DomainEventMapperTest.kt +++ b/apps/server/src/test/kotlin/com/correx/apps/server/bridge/DomainEventMapperTest.kt @@ -24,6 +24,10 @@ import com.correx.core.events.events.TransitionExecutedEvent import com.correx.core.events.events.WorkflowCompletedEvent import com.correx.core.events.events.WorkflowFailedEvent import com.correx.core.events.events.WorkflowStartedEvent +import com.correx.core.events.risk.RiskAction +import com.correx.core.events.risk.RiskLevel +import com.correx.core.events.risk.RiskSignal +import com.correx.core.events.risk.RiskSummary import com.correx.core.events.types.ApprovalRequestId import com.correx.core.events.types.ArtifactId import com.correx.core.events.types.EventId @@ -348,7 +352,7 @@ class DomainEventMapperTest { } @Test - fun `ApprovalRequestedEvent maps to ApprovalRequired`(): Unit = runTest { + fun `ApprovalRequestedEvent maps to ApprovalRequired with null riskSummary falls back to tier name`(): Unit = runTest { val requestId = ApprovalRequestId("req-1") val event = storedEvent( ApprovalRequestedEvent( @@ -356,6 +360,7 @@ class DomainEventMapperTest { tier = Tier.T3, validationReportId = ValidationReportId("vr-1"), riskSummaryId = null, + riskSummary = null, sessionId = sessionId, stageId = stageId, projectId = null, @@ -367,6 +372,42 @@ class DomainEventMapperTest { assertNull(result.toolName) assertNull(result.preview) assertEquals(event.sequence, result.sequence) + assertEquals("T3", result.riskSummary.level) + assertEquals(emptyList(), result.riskSummary.rationale) + } + + @Test + fun `ApprovalRequestedEvent maps to ApprovalRequired with riskSummary propagates rationale`(): Unit = runTest { + val requestId = ApprovalRequestId("req-2") + val riskSummary = RiskSummary( + level = RiskLevel.HIGH, + signals = listOf( + RiskSignal.ValidationErrors(errorCount = 3), + RiskSignal.RepeatedFailure(reason = "parse error", count = 2), + ), + recommendedAction = RiskAction.PROMPT_USER, + rationale = listOf("[ERR001] Schema mismatch", "[ERR002] Missing required field"), + ) + val event = storedEvent( + ApprovalRequestedEvent( + requestId = requestId, + tier = Tier.T3, + validationReportId = ValidationReportId("vr-2"), + riskSummaryId = null, + riskSummary = riskSummary, + sessionId = sessionId, + stageId = stageId, + projectId = null, + ), + ) + val result = domainEventToServerMessage(event, noopStore, sessionSequence = 0L) as ServerMessage.ApprovalRequired + assertEquals(requestId, result.requestId) + assertEquals("HIGH", result.riskSummary.level) + assertEquals("PROMPT_USER", result.riskSummary.recommendedAction) + assertEquals(listOf("[ERR001] Schema mismatch", "[ERR002] Missing required field"), result.riskSummary.rationale) + assertEquals(2, result.riskSummary.factors.size) + assertTrue(result.riskSummary.factors[0].contains("Validation errors")) + assertTrue(result.riskSummary.factors[1].contains("Repeated failure")) } @Test diff --git a/core/approvals/src/main/kotlin/com/correx/core/approvals/model/DomainApprovalRequest.kt b/core/approvals/src/main/kotlin/com/correx/core/approvals/model/DomainApprovalRequest.kt index 7d2485db..80f6ef3d 100644 --- a/core/approvals/src/main/kotlin/com/correx/core/approvals/model/DomainApprovalRequest.kt +++ b/core/approvals/src/main/kotlin/com/correx/core/approvals/model/DomainApprovalRequest.kt @@ -2,6 +2,7 @@ package com.correx.core.approvals.model import com.correx.core.approvals.Tier import com.correx.core.approvals.UserSteering +import com.correx.core.events.risk.RiskSummary import com.correx.core.events.types.ApprovalRequestId import com.correx.core.events.types.CausationId import com.correx.core.events.types.CorrelationId @@ -16,6 +17,7 @@ data class DomainApprovalRequest( val tier: Tier, val validationReportId: ValidationReportId, val riskSummaryId: RiskSummaryId?, + val riskSummary: RiskSummary? = null, val timestamp: Instant, val causationId: CausationId? = null, val correlationId: CorrelationId? = null, diff --git a/core/events/src/main/kotlin/com/correx/core/events/events/ApprovalEvents.kt b/core/events/src/main/kotlin/com/correx/core/events/events/ApprovalEvents.kt index 7243311c..c249634d 100644 --- a/core/events/src/main/kotlin/com/correx/core/events/events/ApprovalEvents.kt +++ b/core/events/src/main/kotlin/com/correx/core/events/events/ApprovalEvents.kt @@ -5,6 +5,7 @@ import com.correx.core.approvals.ApprovalStatus import com.correx.core.approvals.GrantScope import com.correx.core.approvals.Tier import com.correx.core.approvals.UserSteering +import com.correx.core.events.risk.RiskSummary import com.correx.core.events.types.ApprovalDecisionId import com.correx.core.events.types.ApprovalRequestId import com.correx.core.events.types.GrantId @@ -24,6 +25,7 @@ data class ApprovalRequestedEvent( val tier: Tier, val validationReportId: ValidationReportId, val riskSummaryId: RiskSummaryId?, + val riskSummary: RiskSummary? = null, val sessionId: SessionId, val stageId: StageId?, val projectId: ProjectId?, diff --git a/core/events/src/main/kotlin/com/correx/core/events/risk/RiskSignal.kt b/core/events/src/main/kotlin/com/correx/core/events/risk/RiskSignal.kt index d8d6b2e4..30997c4e 100644 --- a/core/events/src/main/kotlin/com/correx/core/events/risk/RiskSignal.kt +++ b/core/events/src/main/kotlin/com/correx/core/events/risk/RiskSignal.kt @@ -1,8 +1,23 @@ package com.correx.core.events.risk +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable sealed class RiskSignal { + @Serializable + @SerialName("CycleWithoutExit") data class CycleWithoutExit(val cycleId: String) : RiskSignal() + + @Serializable + @SerialName("RepeatedFailure") data class RepeatedFailure(val reason: String, val count: Int) : RiskSignal() + + @Serializable + @SerialName("ValidationErrors") data class ValidationErrors(val errorCount: Int) : RiskSignal() + + @Serializable + @SerialName("InferenceTimeout") data class InferenceTimeout(val elapsedMs: Long) : RiskSignal() } diff --git a/core/events/src/main/kotlin/com/correx/core/events/risk/RiskSummary.kt b/core/events/src/main/kotlin/com/correx/core/events/risk/RiskSummary.kt index 2af5e826..18d8fa7d 100644 --- a/core/events/src/main/kotlin/com/correx/core/events/risk/RiskSummary.kt +++ b/core/events/src/main/kotlin/com/correx/core/events/risk/RiskSummary.kt @@ -1,5 +1,8 @@ package com.correx.core.events.risk +import kotlinx.serialization.Serializable + +@Serializable data class RiskSummary( val level: RiskLevel, val signals: List, diff --git a/core/events/src/test/kotlin/com/correx/core/events/EventSerializationHardeningTest.kt b/core/events/src/test/kotlin/com/correx/core/events/EventSerializationHardeningTest.kt index 114eb3bd..cc9481b6 100644 --- a/core/events/src/test/kotlin/com/correx/core/events/EventSerializationHardeningTest.kt +++ b/core/events/src/test/kotlin/com/correx/core/events/EventSerializationHardeningTest.kt @@ -4,6 +4,7 @@ import com.correx.core.approvals.ApprovalOutcome import com.correx.core.approvals.ApprovalStatus import com.correx.core.approvals.Tier import com.correx.core.events.events.ApprovalDecisionResolvedEvent +import com.correx.core.events.events.ApprovalRequestedEvent import com.correx.core.events.events.EventPayload import com.correx.core.events.events.OrchestrationPausedEvent import com.correx.core.events.events.RiskAssessedEvent @@ -13,13 +14,17 @@ import com.correx.core.events.events.WorkflowCompletedEvent import com.correx.core.events.events.WorkflowStartedEvent import com.correx.core.events.risk.RiskAction import com.correx.core.events.risk.RiskLevel +import com.correx.core.events.risk.RiskSignal +import com.correx.core.events.risk.RiskSummary 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.ProjectId 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 com.correx.core.events.types.ValidationReportId import kotlinx.datetime.Instant import kotlinx.serialization.json.Json import kotlinx.serialization.json.jsonObject @@ -110,4 +115,30 @@ class EventSerializationHardeningTest { eventJson.decodeFromString(EventPayload.serializer(), withExtra) } } + + @Test + fun `ApprovalRequestedEvent with non-null riskSummary round-trips through EventPayload polymorphic serializer`() { + val riskSummary = RiskSummary( + level = RiskLevel.HIGH, + signals = listOf( + RiskSignal.ValidationErrors(errorCount = 3), + RiskSignal.RepeatedFailure(reason = "parse error", count = 2), + ), + recommendedAction = RiskAction.PROMPT_USER, + rationale = listOf("[ERR001] Schema mismatch", "[ERR002] Missing required field"), + ) + val event: EventPayload = ApprovalRequestedEvent( + requestId = ApprovalRequestId("req-rt-1"), + tier = Tier.T3, + validationReportId = ValidationReportId("vr-rt-1"), + riskSummaryId = null, + riskSummary = riskSummary, + sessionId = sessionId, + stageId = stageId, + projectId = ProjectId("proj-1"), + ) + val json = eventJson.encodeToString(EventPayload.serializer(), event) + val decoded = eventJson.decodeFromString(EventPayload.serializer(), json) + assertEquals(event, decoded) + } } diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt index 2a627785..774da8a5 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt @@ -894,6 +894,7 @@ abstract class SessionOrchestrator( tier = domainRequest.tier, validationReportId = domainRequest.validationReportId, riskSummaryId = domainRequest.riskSummaryId, + riskSummary = domainRequest.riskSummary, sessionId = sessionId, stageId = stageId, projectId = null, @@ -938,6 +939,9 @@ abstract class SessionOrchestrator( ), ) val riskSummaryId = RiskSummaryId(UUID.randomUUID().toString()) + // RiskAssessedEvent records the indexed level+action summary; ApprovalRequestedEvent carries the full + // authoritative RiskSummary (signals + rationale). Both derive from the same assessment value so + // they cannot diverge. emit( sessionId, RiskAssessedEvent(sessionId, stageId, riskSummaryId, riskSummary.level, riskSummary.recommendedAction), @@ -947,6 +951,7 @@ abstract class SessionOrchestrator( tier = riskSummary.level.toApprovalTier(), validationReportId = ValidationReportId(UUID.randomUUID().toString()), riskSummaryId = riskSummaryId, + riskSummary = riskSummary, timestamp = Clock.System.now(), ) }