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:
+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