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.
This commit is contained in:
2026-05-30 19:08:34 +04:00
parent 8e6a3e1470
commit 780a00229e
11 changed files with 158 additions and 4 deletions
@@ -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,
@@ -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,
@@ -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<String>,
val recommendedAction: String,
val rationale: List<String> = 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,
)
@@ -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))
}
}
@@ -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<String>(), 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
@@ -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,
@@ -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?,
@@ -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()
}
@@ -1,5 +1,8 @@
package com.correx.core.events.risk
import kotlinx.serialization.Serializable
@Serializable
data class RiskSummary(
val level: RiskLevel,
val signals: List<RiskSignal>,
@@ -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)
}
}
@@ -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(),
)
}