epic-12: after epic audit and init commit
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
plugins {
|
||||
id 'java-library'
|
||||
id 'org.jetbrains.kotlin.jvm'
|
||||
id 'org.jetbrains.kotlin.plugin.serialization'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":core:events"))
|
||||
implementation(project(":core:sessions"))
|
||||
implementation(project(":core:transitions"))
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package com.correx.core.validation.approval
|
||||
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.validation.model.ValidationReport
|
||||
|
||||
data class ApprovalRequest(
|
||||
val sessionId: SessionId?,
|
||||
val riskSummary: ValidationRiskStats,
|
||||
val validationReport: ValidationReport
|
||||
)
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package com.correx.core.validation.approval
|
||||
|
||||
import com.correx.core.validation.model.ValidationReport
|
||||
import com.correx.core.validation.model.ValidationSeverity
|
||||
|
||||
class ApprovalTrigger {
|
||||
|
||||
/**
|
||||
* Evaluates whether a validation report requires human approval.
|
||||
*
|
||||
* All approvals produced here are implicitly **Tier.T2 (REVERSIBLE)** — validation decisions
|
||||
* affect workflow progression but not external state. This tier will be made explicit on
|
||||
* [ApprovalRequest] once the risk model (Epic 12, task 4) is wired in.
|
||||
*/
|
||||
fun evaluate(report: ValidationReport): ApprovalRequest? {
|
||||
|
||||
val errors = report.sections
|
||||
.flatMap { it.issues }
|
||||
.count { it.severity == ValidationSeverity.ERROR }
|
||||
|
||||
val warnings = report.sections
|
||||
.flatMap { it.issues }
|
||||
.count { it.severity == ValidationSeverity.WARNING }
|
||||
|
||||
val hasCyclePolicyIssue = report.sections
|
||||
.flatMap { it.issues }
|
||||
.any { it.code == "CYCLE_POLICY_MISSING" }
|
||||
|
||||
val risk = ValidationRiskStats(
|
||||
errorCount = errors,
|
||||
warningCount = warnings,
|
||||
hasCyclePoliciesMissing = hasCyclePolicyIssue
|
||||
)
|
||||
|
||||
// decision boundary (still NOT execution)
|
||||
if (errors > 0 || hasCyclePolicyIssue) {
|
||||
return ApprovalRequest(
|
||||
sessionId = null,
|
||||
riskSummary = risk,
|
||||
validationReport = report
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.correx.core.validation.approval
|
||||
|
||||
data class RiskSummary(
|
||||
val errorCount: Int,
|
||||
val warningCount: Int,
|
||||
val hasCyclePoliciesMissing: Boolean
|
||||
)
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package com.correx.core.validation.approval
|
||||
|
||||
data class ValidationRiskStats(
|
||||
val errorCount: Int,
|
||||
val warningCount: Int,
|
||||
val hasCyclePoliciesMissing: Boolean,
|
||||
)
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.correx.core.validation.graph
|
||||
|
||||
import com.correx.core.validation.model.ValidationContext
|
||||
import com.correx.core.validation.model.ValidationIssue
|
||||
import com.correx.core.validation.model.ValidationLocation
|
||||
import com.correx.core.validation.model.ValidationSection
|
||||
import com.correx.core.validation.model.ValidationSeverity
|
||||
import com.correx.core.validation.pipeline.Validator
|
||||
|
||||
class GraphValidator : Validator {
|
||||
|
||||
override fun validate(context: ValidationContext): ValidationSection {
|
||||
|
||||
val graph = context.graph
|
||||
val issues = mutableListOf<ValidationIssue>()
|
||||
|
||||
// 1. start node exists (redundant safety check)
|
||||
if (graph.start !in graph.stages) {
|
||||
issues += ValidationIssue(
|
||||
code = "GRAPH_START_INVALID",
|
||||
message = "Start stage does not exist in graph",
|
||||
severity = ValidationSeverity.ERROR
|
||||
)
|
||||
}
|
||||
|
||||
// 2. dangling transitions
|
||||
graph.transitions.forEach { edge ->
|
||||
if (edge.from !in graph.stages || edge.to !in graph.stages) {
|
||||
issues += ValidationIssue(
|
||||
code = "GRAPH_DANGLING_TRANSITION",
|
||||
message = "Transition references missing stage",
|
||||
severity = ValidationSeverity.ERROR,
|
||||
location = ValidationLocation.Graph(
|
||||
transitionId = edge.id
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. cycles (ONLY attach, no interpretation)
|
||||
context.detectedCycles.forEach { cycle ->
|
||||
issues += ValidationIssue(
|
||||
code = "GRAPH_CYCLE_DETECTED",
|
||||
message = "Cycle detected: ${cycle.nodes}",
|
||||
severity = ValidationSeverity.INFO
|
||||
)
|
||||
}
|
||||
|
||||
return ValidationSection(
|
||||
name = "graph",
|
||||
issues = issues
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.correx.core.validation.model
|
||||
|
||||
import com.correx.core.sessions.SessionState
|
||||
import com.correx.core.transitions.analysis.DetectedCycle
|
||||
import com.correx.core.transitions.graph.WorkflowGraph
|
||||
import com.correx.core.transitions.policy.CyclePolicyBinding
|
||||
|
||||
data class ValidationContext(
|
||||
val graph: WorkflowGraph,
|
||||
val detectedCycles: List<DetectedCycle> = emptyList(),
|
||||
val cyclePolicies: Set<CyclePolicyBinding> = emptySet(),
|
||||
val sessionState: SessionState? = null,
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.correx.core.validation.model
|
||||
|
||||
data class ValidationIssue(
|
||||
val code: String,
|
||||
val message: String,
|
||||
val severity: ValidationSeverity,
|
||||
val location: ValidationLocation? = null)
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package com.correx.core.validation.model
|
||||
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.events.types.TransitionId
|
||||
|
||||
sealed interface ValidationLocation {
|
||||
data class Graph(
|
||||
val stageId: StageId? = null,
|
||||
val transitionId: TransitionId? = null
|
||||
) : ValidationLocation
|
||||
|
||||
data class Session(
|
||||
val sessionId: SessionId
|
||||
) : ValidationLocation
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.correx.core.validation.model
|
||||
|
||||
data class ValidationReport(
|
||||
val sections: List<ValidationSection>
|
||||
) {
|
||||
fun hasErrors(): Boolean =
|
||||
sections.any { section ->
|
||||
section.issues.any { it.severity == ValidationSeverity.ERROR }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.correx.core.validation.model
|
||||
|
||||
data class ValidationSection(
|
||||
val name: String,
|
||||
val issues: List<ValidationIssue> = emptyList(),
|
||||
val metadata: Map<String, String> = emptyMap())
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.correx.core.validation.model
|
||||
|
||||
enum class ValidationSeverity {
|
||||
INFO,
|
||||
WARNING,
|
||||
ERROR
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package com.correx.core.validation.pipeline
|
||||
|
||||
import com.correx.core.validation.approval.ApprovalRequest
|
||||
import com.correx.core.validation.model.ValidationReport
|
||||
|
||||
sealed interface ValidationOutcome {
|
||||
data class Passed(val report: ValidationReport) : ValidationOutcome
|
||||
/**
|
||||
* @param retryable set by the validator based on failure type; orchestrator must read this
|
||||
* and never override it.
|
||||
*/
|
||||
data class Rejected(val report: ValidationReport, val retryable: Boolean) : ValidationOutcome
|
||||
data class NeedsApproval(val request: ApprovalRequest) : ValidationOutcome
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package com.correx.core.validation.pipeline
|
||||
|
||||
import com.correx.core.validation.approval.ApprovalTrigger
|
||||
import com.correx.core.validation.model.ValidationContext
|
||||
import com.correx.core.validation.model.ValidationReport
|
||||
import com.correx.core.validation.model.ValidationSection
|
||||
import com.correx.core.validation.model.ValidationSeverity
|
||||
|
||||
class ValidationPipeline(
|
||||
private val validators: List<Validator>,
|
||||
private val approvalTrigger: ApprovalTrigger? = null
|
||||
) {
|
||||
|
||||
fun validate(context: ValidationContext): ValidationOutcome {
|
||||
val sections = mutableListOf<ValidationSection>()
|
||||
for (validator in validators) {
|
||||
val section = validator.validate(context)
|
||||
sections += section
|
||||
if (section.issues.any { it.severity == ValidationSeverity.ERROR }) {
|
||||
// Structural validation errors are not retryable — the graph must be fixed.
|
||||
return ValidationOutcome.Rejected(ValidationReport(sections), retryable = false)
|
||||
}
|
||||
}
|
||||
val report = ValidationReport(sections)
|
||||
val approvalRequest = approvalTrigger?.evaluate(report)
|
||||
return if (approvalRequest != null) {
|
||||
ValidationOutcome.NeedsApproval(approvalRequest)
|
||||
} else {
|
||||
ValidationOutcome.Passed(report)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.correx.core.validation.pipeline
|
||||
|
||||
import com.correx.core.validation.model.ValidationContext
|
||||
import com.correx.core.validation.model.ValidationSection
|
||||
|
||||
fun interface Validator {
|
||||
fun validate(context: ValidationContext): ValidationSection
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.correx.core.validation.semantic
|
||||
|
||||
import com.correx.core.validation.model.ValidationContext
|
||||
import com.correx.core.validation.model.ValidationIssue
|
||||
|
||||
interface SemanticRule {
|
||||
fun validate(context: ValidationContext): List<ValidationIssue>
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package com.correx.core.validation.semantic
|
||||
|
||||
import com.correx.core.validation.model.ValidationContext
|
||||
import com.correx.core.validation.model.ValidationSection
|
||||
import com.correx.core.validation.pipeline.Validator
|
||||
|
||||
class SemanticValidator(
|
||||
private val rules: List<SemanticRule>
|
||||
) : Validator {
|
||||
|
||||
override fun validate(context: ValidationContext): ValidationSection {
|
||||
|
||||
val issues = rules.flatMap { it.validate(context) }
|
||||
|
||||
return ValidationSection(
|
||||
name = "semantic",
|
||||
issues = issues
|
||||
)
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package com.correx.core.validation.semantic.rules
|
||||
|
||||
import com.correx.core.transitions.policy.CycleSignature
|
||||
import com.correx.core.transitions.policy.CycleSignatureFactory
|
||||
import com.correx.core.validation.model.ValidationContext
|
||||
import com.correx.core.validation.model.ValidationIssue
|
||||
import com.correx.core.validation.model.ValidationSeverity
|
||||
import com.correx.core.validation.semantic.SemanticRule
|
||||
|
||||
class CyclePolicyBindingRule(
|
||||
private val requirePolicyForCycles: Boolean
|
||||
) : SemanticRule {
|
||||
|
||||
override fun validate(context: ValidationContext): List<ValidationIssue> {
|
||||
|
||||
if (!requirePolicyForCycles) return emptyList()
|
||||
|
||||
val knownSignatures = context.detectedCycles
|
||||
.map { CycleSignatureFactory.from(it.nodes, it.edges) }
|
||||
.toSet()
|
||||
|
||||
val bound = context.cyclePolicies
|
||||
.map { it.cycle }
|
||||
.toSet()
|
||||
|
||||
val unbound = knownSignatures - bound
|
||||
|
||||
return unbound.map { signature ->
|
||||
ValidationIssue(
|
||||
code = "CYCLE_POLICY_MISSING",
|
||||
message = "Cycle has no policy binding: $signature",
|
||||
severity = ValidationSeverity.WARNING,
|
||||
location = null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
package com.correx.core.validation.session
|
||||
|
||||
import com.correx.core.sessions.SessionStatus
|
||||
import com.correx.core.validation.model.ValidationContext
|
||||
import com.correx.core.validation.model.ValidationIssue
|
||||
import com.correx.core.validation.model.ValidationSection
|
||||
import com.correx.core.validation.model.ValidationSeverity
|
||||
import com.correx.core.validation.pipeline.Validator
|
||||
|
||||
class SessionValidator : Validator {
|
||||
override fun validate(context: ValidationContext): ValidationSection {
|
||||
|
||||
val state = context.sessionState ?: return ValidationSection(
|
||||
name = "session",
|
||||
issues = emptyList()
|
||||
)
|
||||
|
||||
val issues = mutableListOf<ValidationIssue>()
|
||||
|
||||
val createdAt = state.createdAt
|
||||
val updatedAt = state.updatedAt
|
||||
|
||||
// 1. temporal consistency
|
||||
if (createdAt != null && updatedAt != null) {
|
||||
if (createdAt > updatedAt) {
|
||||
issues += ValidationIssue(
|
||||
code = "SESSION_TIME_INCONSISTENT",
|
||||
message = "createdAt is after updatedAt",
|
||||
severity = ValidationSeverity.ERROR
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. invalid transitions sanity
|
||||
if (state.invalidTransitions < 0) {
|
||||
issues += ValidationIssue(
|
||||
code = "SESSION_INVALID_TRANSITIONS_NEGATIVE",
|
||||
message = "invalidTransitions cannot be negative",
|
||||
severity = ValidationSeverity.ERROR
|
||||
)
|
||||
}
|
||||
|
||||
// 3. status sanity (light semantic check only)
|
||||
if (state.status == SessionStatus.FAILED &&
|
||||
state.invalidTransitions == 0
|
||||
) {
|
||||
|
||||
issues += ValidationIssue(
|
||||
code = "SESSION_SUSPICIOUS_FAILURE_STATE",
|
||||
message = "FAILED session with zero invalid transitions",
|
||||
severity = ValidationSeverity.WARNING
|
||||
)
|
||||
}
|
||||
|
||||
return ValidationSection(
|
||||
name = "session",
|
||||
issues = issues
|
||||
)
|
||||
}
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
package com.correx.core.validation.transition
|
||||
|
||||
import com.correx.core.transitions.graph.TransitionEdge
|
||||
import com.correx.core.transitions.resolution.TransitionOrdering
|
||||
import com.correx.core.validation.model.ValidationContext
|
||||
import com.correx.core.validation.model.ValidationIssue
|
||||
import com.correx.core.validation.model.ValidationLocation
|
||||
import com.correx.core.validation.model.ValidationSection
|
||||
import com.correx.core.validation.model.ValidationSeverity
|
||||
import com.correx.core.validation.pipeline.Validator
|
||||
|
||||
class TransitionValidator(
|
||||
private val ordering: Comparator<TransitionEdge> = TransitionOrdering.comparator
|
||||
) : Validator {
|
||||
|
||||
override fun validate(context: ValidationContext): ValidationSection {
|
||||
|
||||
val graph = context.graph
|
||||
val issues = mutableListOf<ValidationIssue>()
|
||||
|
||||
// group transitions by source node
|
||||
val grouped = graph.transitions.groupBy { it.from }
|
||||
|
||||
// 1. endpoint integrity
|
||||
graph.transitions.forEach { edge ->
|
||||
if (edge.from !in graph.stages) {
|
||||
issues += ValidationIssue(
|
||||
code = "TRANSITION_INVALID_FROM",
|
||||
message = "Transition references missing 'from' stage",
|
||||
severity = ValidationSeverity.ERROR,
|
||||
location = ValidationLocation.Graph(
|
||||
transitionId = edge.id
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (edge.to !in graph.stages) {
|
||||
issues += ValidationIssue(
|
||||
code = "TRANSITION_INVALID_TO",
|
||||
message = "Transition references missing 'to' stage",
|
||||
severity = ValidationSeverity.ERROR,
|
||||
location = ValidationLocation.Graph(
|
||||
transitionId = edge.id
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. deterministic ordering check per node
|
||||
grouped.forEach { (from, edges) ->
|
||||
|
||||
val sorted = edges.sortedWith(ordering)
|
||||
|
||||
// detect duplicate ordering signatures (ambiguity risk)
|
||||
val signatures = sorted.map { it.to }.toSet()
|
||||
|
||||
if (signatures.size != sorted.size) {
|
||||
issues += ValidationIssue(
|
||||
code = "TRANSITION_NON_DETERMINISTIC_ORDER",
|
||||
message = "Non-deterministic ordering detected for node $from",
|
||||
severity = ValidationSeverity.WARNING,
|
||||
location = ValidationLocation.Graph(stageId = from)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return ValidationSection(
|
||||
name = "transition",
|
||||
issues = issues
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user