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:
@@ -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,
|
||||
)
|
||||
|
||||
+28
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+5
@@ -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(),
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user