feat(validation): artifact-emission repair ladder + empty-schema fix

Deterministic repair pipeline (extraction, classification, policy, gated
LLM-repair rung) with ArtifactRepairAttempted/Failed events. Also fix
JsonSchemaValidator: a schema with no declared properties describes 'any
object' — unknown-property enforcement now only applies against a declared
shape (empty-properties schemas were rejecting every real artifact).
This commit is contained in:
2026-07-05 18:04:36 +04:00
parent 24f3b843bb
commit f90f2ab39d
11 changed files with 462 additions and 1 deletions
@@ -0,0 +1,44 @@
package com.correx.core.kernel.orchestration
import com.correx.core.validation.artifact.ArtifactFailure
/**
* What to do when the artifact-emission repair ladder (deterministic + one LLM rung) still can't
* produce a schema-valid artifact. Each maps onto machinery the orchestrator already has.
* See docs/specs/2026-07-04-artifact-emission-pipeline.md §3.4.
*/
sealed interface ArtifactPolicyDecision {
data object RetryProducer : ArtifactPolicyDecision // → retryStageOrFail (retryable failure)
data object RegenerateArtifactOnly : ArtifactPolicyDecision // → re-run the tools-less emission nudge
data object Abort : ArtifactPolicyDecision // → non-retryable failure
data object EscalateHuman : ArtifactPolicyDecision // → approval gate / OrchestrationPaused
}
/**
* Pure default policy. `attemptsSoFar` is the repair attempts already spent this stage.
* Mapping (spec §3.4): UNSAFE→Abort; CONTRADICTORY→RetryProducer; MISSING_INFO→RegenerateArtifactOnly
* first, then RetryProducer; FORMATTING/SCHEMA→RetryProducer (deterministic + LLM rung already tried);
* exhausted→EscalateHuman when an approver is attached, else Abort.
*/
fun decide(
failure: ArtifactFailure,
attemptsSoFar: Int,
maxAttempts: Int,
hasApprover: Boolean,
): ArtifactPolicyDecision {
if (failure == ArtifactFailure.UNSAFE) return ArtifactPolicyDecision.Abort
val base = when (failure) {
ArtifactFailure.MISSING_INFO -> if (attemptsSoFar == 0) {
ArtifactPolicyDecision.RegenerateArtifactOnly
} else {
ArtifactPolicyDecision.RetryProducer
}
else -> ArtifactPolicyDecision.RetryProducer // FORMATTING, SCHEMA, CONTRADICTORY
}
// Exhaustion guard: a retry-flavoured decision with no budget left escalates (with an approver) or aborts.
return if (attemptsSoFar >= maxAttempts) {
if (hasApprover) ArtifactPolicyDecision.EscalateHuman else ArtifactPolicyDecision.Abort
} else {
base
}
}
@@ -0,0 +1,34 @@
package com.correx.core.kernel.orchestration
import com.correx.core.validation.artifact.ArtifactFailure
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class ArtifactPolicyDecisionTest {
private fun decideFresh(f: ArtifactFailure) = decide(f, attemptsSoFar = 0, maxAttempts = Int.MAX_VALUE, hasApprover = false)
@Test
fun `unsafe always aborts, even with budget and approver`() {
assertEquals(ArtifactPolicyDecision.Abort, decide(ArtifactFailure.UNSAFE, 0, 5, hasApprover = true))
}
@Test
fun `schema and formatting retry the producer`() {
assertEquals(ArtifactPolicyDecision.RetryProducer, decideFresh(ArtifactFailure.SCHEMA))
assertEquals(ArtifactPolicyDecision.RetryProducer, decideFresh(ArtifactFailure.FORMATTING))
assertEquals(ArtifactPolicyDecision.RetryProducer, decideFresh(ArtifactFailure.CONTRADICTORY))
}
@Test
fun `missing info regenerates artifact-only on first attempt then retries producer`() {
assertEquals(ArtifactPolicyDecision.RegenerateArtifactOnly, decide(ArtifactFailure.MISSING_INFO, 0, 5, false))
assertEquals(ArtifactPolicyDecision.RetryProducer, decide(ArtifactFailure.MISSING_INFO, 1, 5, false))
}
@Test
fun `exhausted budget escalates with approver, aborts without`() {
assertEquals(ArtifactPolicyDecision.EscalateHuman, decide(ArtifactFailure.SCHEMA, 3, 3, hasApprover = true))
assertEquals(ArtifactPolicyDecision.Abort, decide(ArtifactFailure.SCHEMA, 3, 3, hasApprover = false))
}
}