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
@@ -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(),
)
}