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