epic-12: after epic audit and init commit

This commit is contained in:
2026-05-16 11:42:00 +04:00
commit c77277af0b
461 changed files with 28958 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
plugins {
id 'java-library'
id 'org.jetbrains.kotlin.jvm'
}
dependencies {
implementation project(':core:events')
implementation project(':core:approvals')
implementation project(':core:validation')
implementation project(':core:inference')
testImplementation "org.junit.jupiter:junit-jupiter"
testImplementation "org.jetbrains.kotlin:kotlin-test"
}
@@ -0,0 +1,48 @@
package com.correx.core.risk
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.inference.InferenceStatus
import com.correx.core.validation.model.ValidationSeverity
class DefaultRiskAssessor : RiskAssessor {
override fun assess(context: RiskContext): RiskSummary {
val (report, state, inferenceState) = context
val signals = mutableListOf<RiskSignal>()
val errorCount = report.sections.sumOf { s ->
s.issues.count { it.severity == ValidationSeverity.ERROR }
}
if (errorCount > 0) {
signals += RiskSignal.ValidationErrors(errorCount)
}
report.sections.flatMap { it.issues }
.firstOrNull { it.code.contains("CYCLE", ignoreCase = true) }
?.let { signals += RiskSignal.CycleWithoutExit(it.code) }
state.retryPolicy?.let { policy ->
if (state.retryCount >= policy.maxAttempts - 1) {
signals += RiskSignal.RepeatedFailure(
reason = state.failureReason ?: "unknown",
count = state.retryCount,
)
}
}
state.currentStageId?.let { stageId ->
inferenceState.records
.firstOrNull { it.stageId == stageId && it.status == InferenceStatus.TIMED_OUT }
?.let { signals += RiskSignal.InferenceTimeout(it.latencyMs ?: 0L) }
}
val level = signals.fold(RiskLevel.LOW) { acc, signal -> maxOf(acc, signal.toRiskLevel()) }
return RiskSummary(
level = level,
signals = signals,
recommendedAction = level.toRiskAction(),
)
}
}
@@ -0,0 +1,18 @@
package com.correx.core.risk
import com.correx.core.events.risk.RiskAction
import com.correx.core.events.risk.RiskLevel
import com.correx.core.events.risk.RiskSummary
/**
* No-op implementation of [RiskAssessor] used during deterministic replay.
* Replay reconstructs approval decisions from the event log (ApprovalGrantedEvent /
* ApprovalDeniedEvent) — live risk assessment must never run on the replay path.
*/
class NoOpRiskAssessor : RiskAssessor {
override fun assess(context: RiskContext): RiskSummary = RiskSummary(
level = RiskLevel.LOW,
signals = emptyList(),
recommendedAction = RiskAction.PROCEED,
)
}
@@ -0,0 +1,7 @@
package com.correx.core.risk
import com.correx.core.events.risk.RiskSummary
interface RiskAssessor {
fun assess(context: RiskContext): RiskSummary
}
@@ -0,0 +1,25 @@
package com.correx.core.risk
import com.correx.core.events.orchestration.OrchestrationState
import com.correx.core.inference.InferenceState
import com.correx.core.validation.model.ValidationReport
/**
* Immutable snapshot of all signals available for risk assessment at a given decision point.
*
* [validationReport] — issues raised by the validation pipeline for the current stage.
* [orchestrationState] — current orchestration state, including retry count, failure reason,
* and retry policy. Carries execution-level signals that previously had no path into risk
* evaluation (e.g. [OrchestrationState.retryCount], [OrchestrationState.retryPolicy]).
* [inferenceState] — inference history, used to detect timeouts for the current stage.
*
* Note: [StageOutcome] lives in core:kernel which depends on core:risk, so it cannot be
* embedded here directly. The relevant execution signals it carries (retry count, failure
* reason) are proxied through [orchestrationState], which is populated from config before
* the first assessment call.
*/
data class RiskContext(
val validationReport: ValidationReport,
val orchestrationState: OrchestrationState,
val inferenceState: InferenceState,
)
@@ -0,0 +1,27 @@
package com.correx.core.risk
import com.correx.core.approvals.Tier
import com.correx.core.events.risk.RiskAction
import com.correx.core.events.risk.RiskLevel
import com.correx.core.events.risk.RiskSignal
fun RiskLevel.toApprovalTier(): Tier = when (this) {
RiskLevel.LOW -> Tier.T1
RiskLevel.MEDIUM -> Tier.T2
RiskLevel.HIGH -> Tier.T3
RiskLevel.CRITICAL -> Tier.T4
}
internal fun RiskLevel.toRiskAction(): RiskAction = when (this) {
RiskLevel.LOW -> RiskAction.PROCEED
RiskLevel.MEDIUM -> RiskAction.PROMPT_USER
RiskLevel.HIGH -> RiskAction.PROMPT_USER
RiskLevel.CRITICAL -> RiskAction.BLOCK
}
internal fun RiskSignal.toRiskLevel(): RiskLevel = when (this) {
is RiskSignal.ValidationErrors -> RiskLevel.MEDIUM
is RiskSignal.CycleWithoutExit -> RiskLevel.MEDIUM
is RiskSignal.InferenceTimeout -> RiskLevel.MEDIUM
is RiskSignal.RepeatedFailure -> RiskLevel.HIGH
}
@@ -0,0 +1,201 @@
package com.correx.core.risk
import com.correx.core.events.execution.RetryPolicy
import com.correx.core.events.orchestration.OrchestrationState
import com.correx.core.events.orchestration.OrchestrationStatus
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.types.InferenceRequestId
import com.correx.core.events.types.ProviderId
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.inference.InferenceRecord
import com.correx.core.inference.InferenceState
import com.correx.core.inference.InferenceStatus
import com.correx.core.validation.model.ValidationIssue
import com.correx.core.validation.model.ValidationReport
import com.correx.core.validation.model.ValidationSection
import com.correx.core.validation.model.ValidationSeverity
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class DefaultRiskAssessorTest {
private val assessor = DefaultRiskAssessor()
private val sessionId = SessionId("s1")
private val stageId = StageId("stage-1")
private fun emptyReport() = ValidationReport(sections = emptyList())
private fun emptyInferenceState() = InferenceState()
private fun baseState() = OrchestrationState(currentStageId = stageId, status = OrchestrationStatus.RUNNING)
private fun ctx(
report: ValidationReport = emptyReport(),
state: OrchestrationState = baseState(),
inferenceState: InferenceState = emptyInferenceState(),
) = RiskContext(
validationReport = report,
orchestrationState = state,
inferenceState = inferenceState,
)
@Test
fun `no signals yields LOW risk and PROCEED`() {
val summary = assessor.assess(ctx())
assertEquals(RiskLevel.LOW, summary.level)
assertEquals(RiskAction.PROCEED, summary.recommendedAction)
assertTrue(summary.signals.isEmpty())
}
@Test
fun `validation errors produce ValidationErrors signal at MEDIUM`() {
val report = ValidationReport(
sections = listOf(
ValidationSection(
name = "schema",
issues = listOf(
ValidationIssue(code = "MISSING_FIELD", message = "field required",
severity = ValidationSeverity.ERROR),
ValidationIssue(code = "MISSING_FIELD", message = "other field",
severity = ValidationSeverity.ERROR),
),
),
),
)
val summary = assessor.assess(ctx(report = report))
assertEquals(RiskLevel.MEDIUM, summary.level)
assertEquals(RiskAction.PROMPT_USER, summary.recommendedAction)
val signal = summary.signals.filterIsInstance<RiskSignal.ValidationErrors>().single()
assertEquals(2, signal.errorCount)
}
@Test
fun `cycle issue in validation report produces CycleWithoutExit signal at MEDIUM`() {
val report = ValidationReport(
sections = listOf(
ValidationSection(
name = "semantic",
issues = listOf(
ValidationIssue(code = "CYCLE_WITHOUT_POLICY", message = "cycle detected",
severity = ValidationSeverity.WARNING),
),
),
),
)
val summary = assessor.assess(ctx(report = report))
assertEquals(RiskLevel.MEDIUM, summary.level)
val signal = summary.signals.filterIsInstance<RiskSignal.CycleWithoutExit>().single()
assertEquals("CYCLE_WITHOUT_POLICY", signal.cycleId)
}
@Test
fun `retryCount at maxAttempts minus one produces RepeatedFailure signal at HIGH`() {
val state = OrchestrationState(
currentStageId = stageId,
status = OrchestrationStatus.RUNNING,
retryCount = 2,
failureReason = "inference failed",
retryPolicy = RetryPolicy(maxAttempts = 3),
)
val summary = assessor.assess(ctx(state = state))
assertEquals(RiskLevel.HIGH, summary.level)
assertEquals(RiskAction.PROMPT_USER, summary.recommendedAction)
val signal = summary.signals.filterIsInstance<RiskSignal.RepeatedFailure>().single()
assertEquals(2, signal.count)
assertEquals("inference failed", signal.reason)
}
@Test
fun `no retry policy skips RepeatedFailure signal even at high retryCount`() {
val state = OrchestrationState(
currentStageId = stageId,
status = OrchestrationStatus.RUNNING,
retryCount = 99,
retryPolicy = null,
)
val summary = assessor.assess(ctx(state = state))
assertEquals(RiskLevel.LOW, summary.level)
assertTrue(summary.signals.filterIsInstance<RiskSignal.RepeatedFailure>().isEmpty())
}
@Test
fun `timed-out inference record for current stage produces InferenceTimeout signal at MEDIUM`() {
val inferenceState = InferenceState(
records = listOf(
InferenceRecord(
requestId = InferenceRequestId("r1"),
sessionId = sessionId,
stageId = stageId,
providerId = ProviderId("p1"),
status = InferenceStatus.TIMED_OUT,
latencyMs = 30_000L,
),
),
)
val summary = assessor.assess(ctx(inferenceState = inferenceState))
assertEquals(RiskLevel.MEDIUM, summary.level)
val signal = summary.signals.filterIsInstance<RiskSignal.InferenceTimeout>().single()
assertEquals(30_000L, signal.elapsedMs)
}
@Test
fun `timed-out inference for different stage does not produce signal`() {
val inferenceState = InferenceState(
records = listOf(
InferenceRecord(
requestId = InferenceRequestId("r1"),
sessionId = sessionId,
stageId = StageId("other-stage"),
providerId = ProviderId("p1"),
status = InferenceStatus.TIMED_OUT,
latencyMs = 30_000L,
),
),
)
val summary = assessor.assess(ctx(inferenceState = inferenceState))
assertEquals(RiskLevel.LOW, summary.level)
assertTrue(summary.signals.filterIsInstance<RiskSignal.InferenceTimeout>().isEmpty())
}
@Test
fun `multiple signals fold to highest risk level`() {
val report = ValidationReport(
sections = listOf(
ValidationSection(
name = "schema",
issues = listOf(
ValidationIssue(code = "MISSING_FIELD", message = "required",
severity = ValidationSeverity.ERROR),
),
),
),
)
val state = OrchestrationState(
currentStageId = stageId,
status = OrchestrationStatus.RUNNING,
retryCount = 2,
failureReason = "timeout",
retryPolicy = RetryPolicy(maxAttempts = 3),
)
val summary = assessor.assess(ctx(report = report, state = state))
assertEquals(RiskLevel.HIGH, summary.level)
assertEquals(2, summary.signals.size)
}
}