feat(validation): plan-compile gate + semantic-review gate (staged verification #19/#20)

Plan-compile gate (#19): PlanCompilationCheck seam (null-on-replay),
runPlanCompileGate guards a produced execution_plan slot and compiles its
cached content via ExecutionPlanCompiler, emitting PlanCompileCheckedEvent.
On failure the architect gets a retryable hand-back and self-corrects;
exhaustion terminates the dead-end workflow instead of shipping an
uncompilable plan.

Semantic-review gate (#20): SemanticReviewer seam + StageConfig.semanticReview
opt-in (TOML semantic_review + freestyle plan JSON). runReviewGate reads the
FileWritten manifest (never blind context), asks a review-capable model for
PR-comment findings, and is advisory — blocks retryably only on FAIL plus a
correctness finding >=0.7 confidence, capped at 2 blocks so a stubborn stage
completes advisory-only rather than trapping. SemanticReviewerImpl talks to
the provider directly; any reviewer error degrades to PASS. REVIEW_MAX_TOKENS
is 8192: gemma is a reasoning model and 1024 truncated it mid-reasoning before
it reached the JSON verdict (empty content -> parse degraded to PASS).

Both gates live-QA'd on the real repo: plan-compile passed on a freestyle
web-UI scaffold; semantic review caught all 3 planted bugs in a maxOf() and
exercised the block->retry->cap safety valve end-to-end.
This commit is contained in:
2026-07-06 13:11:16 +04:00
parent 28e9e698a1
commit 9f12c87bb1
15 changed files with 547 additions and 2 deletions
@@ -2,6 +2,7 @@ package com.correx.core.events.events
import com.correx.core.events.types.ArtifactId
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@@ -34,3 +35,20 @@ data class ExecutionPlanRejectedEvent(
/** "compile" | "missing_content" | "operator" */
val source: String,
) : EventPayload
/**
* Environment observation (invariant #9): the outcome of compiling a stage's produced execution_plan
* as a post-stage gate. Recorded at the moment the architect stage completes so replay reads the
* recorded verdict and never re-compiles. A non-null [error] fails the producing stage retryably,
* feeding the compiler message back so the architect self-corrects via the retry-feedback loop
* (rather than parking the session in ACTIVE forever, as a post-planning compile failure did).
*/
@Serializable
@SerialName("PlanCompileChecked")
data class PlanCompileCheckedEvent(
val sessionId: SessionId,
val stageId: StageId,
val passed: Boolean,
/** Compiler error when [passed] is false; null on success. Kept bounded. */
val error: String? = null,
) : EventPayload
@@ -0,0 +1,54 @@
package com.correx.core.events.events
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** Overall verdict of a semantic (LLM) review over a stage's produced files. */
enum class ReviewVerdict { PASS, WARN, FAIL }
/** Severity of a single review finding, high→low. */
enum class ReviewSeverity { CRITICAL, MAJOR, MINOR, NIT }
/**
* One PR-comment-style finding from the semantic reviewer (Gate 3). A finding is a proposal, not a
* fact — [confidence] is the reviewer's own 0..1 self-estimate and [correctness] marks findings that
* claim the code is *wrong* (deterministically re-confirmable) vs. merely *suboptimal* (a smell /
* style / architecture opinion that can never block on its own).
*/
@Serializable
data class ReviewFinding(
val severity: ReviewSeverity,
/** Reviewer self-estimated confidence, 0.0..1.0. */
val confidence: Double,
/** Short category slug, e.g. "correctness", "smell", "architecture", "naming". */
val category: String,
/** Workspace-relative file (optionally :line) the finding anchors to. */
val target: String,
/** What is wrong / suboptimal. Kept bounded by the producer. */
val message: String,
/** Suggested fix, when the reviewer offers one. */
val suggestedFix: String? = null,
/** True = claims the code is incorrect (confirmable); false = suboptimal opinion (never blocks). */
val correctness: Boolean = false,
)
/**
* Environment observation (invariant #9): the semantic reviewer's findings over a stage's produced
* files, captured at the moment the stage completed. The reviewer is advisory — it reads the actual
* FileWritten manifest (never blind context) and its findings surface to the operator regardless of
* verdict. A [ReviewVerdict.FAIL] carrying a high-confidence correctness finding can block the stage
* retryably, but only until the review-block retry budget is spent, after which findings surface
* without blocking. Replay reads these recorded facts and never re-runs the reviewer.
*/
@Serializable
@SerialName("ReviewFindingsRaised")
data class ReviewFindingsRaisedEvent(
val sessionId: SessionId,
val stageId: StageId,
val verdict: ReviewVerdict,
val findings: List<ReviewFinding>,
/** True when this review's FAIL verdict blocked the stage (fed back for a fix); false = advisory. */
val blocked: Boolean = false,
) : EventPayload
@@ -33,6 +33,8 @@ import com.correx.core.events.events.PossibleContradictionFlaggedEvent
import com.correx.core.events.events.EgressHostsGrantedEvent
import com.correx.core.events.events.EventPayload
import com.correx.core.events.events.ExecutionPlanLockedEvent
import com.correx.core.events.events.PlanCompileCheckedEvent
import com.correx.core.events.events.ReviewFindingsRaisedEvent
import com.correx.core.events.events.ExecutionPlanRejectedEvent
import com.correx.core.events.events.HealthDegradedEvent
import com.correx.core.events.events.HealthRestoredEvent
@@ -164,6 +166,8 @@ val eventModule = SerializersModule {
subclass(ContextTruncatedEvent::class)
subclass(ExecutionPlanLockedEvent::class)
subclass(ExecutionPlanRejectedEvent::class)
subclass(PlanCompileCheckedEvent::class)
subclass(ReviewFindingsRaisedEvent::class)
subclass(JournalCompactedEvent::class)
subclass(HealthDegradedEvent::class)
subclass(HealthRestoredEvent::class)
@@ -0,0 +1,38 @@
package com.correx.core.events.serialization
import com.correx.core.events.events.EventPayload
import com.correx.core.events.events.PlanCompileCheckedEvent
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class PlanCompileCheckedEventSerializationTest {
@Test
fun `PlanCompileCheckedEvent round-trips as polymorphic EventPayload`() {
val sample: EventPayload = PlanCompileCheckedEvent(
sessionId = SessionId("s"),
stageId = StageId("architect"),
passed = false,
error = "edge from unknown stage 'ghost'",
)
val encoded = eventJson.encodeToString(EventPayload.serializer(), sample)
assertTrue(encoded.contains("\"type\":\"PlanCompileChecked\""), "SerialName must be present: $encoded")
val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded)
assertEquals(sample, decoded)
}
@Test
fun `PlanCompileCheckedEvent round-trips on success with null error`() {
val sample: EventPayload = PlanCompileCheckedEvent(
sessionId = SessionId("s"),
stageId = StageId("architect"),
passed = true,
)
val encoded = eventJson.encodeToString(EventPayload.serializer(), sample)
val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded)
assertEquals(sample, decoded)
}
}
@@ -0,0 +1,40 @@
package com.correx.core.events.serialization
import com.correx.core.events.events.EventPayload
import com.correx.core.events.events.ReviewFinding
import com.correx.core.events.events.ReviewFindingsRaisedEvent
import com.correx.core.events.events.ReviewSeverity
import com.correx.core.events.events.ReviewVerdict
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class ReviewFindingsRaisedEventSerializationTest {
@Test
fun `ReviewFindingsRaisedEvent round-trips as polymorphic EventPayload`() {
val sample: EventPayload = ReviewFindingsRaisedEvent(
sessionId = SessionId("s"),
stageId = StageId("implement"),
verdict = ReviewVerdict.FAIL,
findings = listOf(
ReviewFinding(
severity = ReviewSeverity.CRITICAL,
confidence = 0.9,
category = "correctness",
target = "src/App.tsx:42",
message = "off-by-one in the loop bound",
suggestedFix = "use < instead of <=",
correctness = true,
),
),
blocked = true,
)
val encoded = eventJson.encodeToString(EventPayload.serializer(), sample)
assertTrue(encoded.contains("\"type\":\"ReviewFindingsRaised\""), "SerialName must be present: $encoded")
val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded)
assertEquals(sample, decoded)
}
}
@@ -34,4 +34,13 @@ data class OrchestratorEngines(
// Evaluates Gate 2 contract assertions against a stage's produced files (design §1). Null = no
// contract gate; the deterministic funnel then rests on produces-presence + static analysis only.
val contractAssertionEvaluator: ContractAssertionEvaluator? = null,
// Compiles a stage's produced execution_plan as a post-stage gate (plan-compile gate). Null = no
// gate; a compile-invalid plan then falls through to the post-planning compiler, which historically
// parked the session in ACTIVE. Wired, it fails the architect stage retryably so the existing
// retry-feedback loop hands the compiler error back for self-correction.
val planCompilationCheck: PlanCompilationCheck? = null,
// Gate 3 semantic (LLM) reviewer, run last over a stage's static-clean produced files (design §
// Gate 3). Null = no semantic review; the funnel then rests on the deterministic gates only. A
// stage opts in via StageConfig.semanticReview.
val semanticReviewer: SemanticReviewer? = null,
)
@@ -0,0 +1,17 @@
package com.correx.core.kernel.orchestration
/**
* Seam for compiling a freestyle execution_plan as a post-stage gate. Like [ContractAssertionEvaluator]
* and [StaticAnalysisRunner], the real implementation (which wraps the infrastructure
* ExecutionPlanCompiler) is injected, so the deterministic core never depends on infrastructure and
* the gate stays unit-testable with a fake.
*
* [check] returns null when the plan compiles, or a bounded compiler-error string when it does not.
* The orchestrator records the verdict in a
* [com.correx.core.events.events.PlanCompileCheckedEvent] (invariant #9), and on failure fails the
* architect stage retryably so the existing retry-feedback loop hands the error back and the model
* self-corrects — replacing the post-planning compile failure that parked the session in ACTIVE.
*/
fun interface PlanCompilationCheck {
fun check(planJson: String, candidateId: String): String?
}
@@ -0,0 +1,35 @@
package com.correx.core.kernel.orchestration
import com.correx.core.events.events.ReviewFinding
import com.correx.core.events.events.ReviewVerdict
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import java.nio.file.Path
/** Result of a semantic review over a stage's produced files. */
data class ReviewOutcome(
val verdict: ReviewVerdict,
val findings: List<ReviewFinding>,
)
/**
* Seam for the Gate 3 semantic (LLM) reviewer. Like the other gate seams
* ([ContractAssertionEvaluator], [StaticAnalysisRunner], [PlanCompilationCheck]), the
* inference-and-filesystem-touching implementation lives in apps/infrastructure and is injected, so
* the deterministic core never runs inference or reads files itself and the gate stays
* unit-testable with a fake.
*
* The implementation reads the actual produced [files] from [workspaceRoot] (the FileWritten
* manifest — never blind context), reviews them against [objective], and returns PR-comment-style
* findings. Runs last, after the deterministic funnel (contract/static/execution) has passed, so its
* input is static-clean and its findings exclude anything the cheap gates already catch.
*/
fun interface SemanticReviewer {
suspend fun review(
sessionId: SessionId,
stageId: StageId,
workspaceRoot: Path,
files: List<String>,
objective: String,
): ReviewOutcome
}
@@ -35,6 +35,9 @@ import com.correx.core.events.events.ApprovalRequestedEvent
import com.correx.core.events.events.ArtifactContentStoredEvent
import com.correx.core.events.events.FileWrittenEvent
import com.correx.core.events.events.ContractGateEvaluatedEvent
import com.correx.core.events.events.PlanCompileCheckedEvent
import com.correx.core.events.events.ReviewFindingsRaisedEvent
import com.correx.core.events.events.ReviewVerdict
import com.correx.core.events.events.ContractAssertionResult
import com.correx.core.events.events.ArtifactCreatedEvent
import com.correx.core.events.events.ArtifactRepairAttemptedEvent
@@ -220,6 +223,13 @@ private const val MAX_CLARIFICATION_ROUNDS = 3
// Static-analysis output caps: the tail retained in the recorded event vs. the (larger) slice fed
// back to the model so it can fix the failure. Tails, because the error summary sits at the end.
private const val CONTRACT_EVIDENCE_CAP = 400
// Gate 3 semantic review: a FAIL blocks only on a correctness finding at/above this confidence, and
// only until this many prior blocks have occurred for the stage — after which findings surface
// without blocking, so a stuck reviewer can never trap a stage. Objective text fed to the reviewer
// is capped to keep the review prompt bounded.
private const val REVIEW_BLOCK_MIN_CONFIDENCE = 0.7
private const val REVIEW_BLOCK_RETRY_CAP = 2
private const val REVIEW_OBJECTIVE_CAP = 4_000
private const val STATIC_ANALYSIS_SUMMARY_CAP = 2_000
private const val STATIC_ANALYSIS_FEEDBACK_CAP = 6_000
@@ -258,6 +268,8 @@ abstract class SessionOrchestrator(
private val worldProbe: WorldProbe = engines.worldProbe
private val staticAnalysisRunner: StaticAnalysisRunner? = engines.staticAnalysisRunner
private val contractAssertionEvaluator: ContractAssertionEvaluator? = engines.contractAssertionEvaluator
private val planCompilationCheck: PlanCompilationCheck? = engines.planCompilationCheck
private val semanticReviewer: SemanticReviewer? = engines.semanticReviewer
private val workspaceToolRegistryProvider: WorkspaceToolRegistryProvider? = engines.workspaceToolRegistryProvider
private val inferenceRepository: InferenceRepository = repositories.inferenceRepository
internal val orchestrationRepository: OrchestrationRepository = repositories.orchestrationRepository
@@ -2093,8 +2105,10 @@ abstract class SessionOrchestrator(
{ groundBriefReferences(sessionId, stageId, stageConfig, effectives) },
{ checkBriefEcho(sessionId, stageId, stageConfig) },
{ runContractGate(sessionId, stageId, stageConfig, effectives) },
{ runPlanCompileGate(sessionId, stageId, stageConfig) },
{ runStaticAnalysis(sessionId, stageId, stageConfig, effectives) },
{ runExecutionGate(sessionId, stageId, stageConfig, effectives, profileCommands) },
{ runReviewGate(sessionId, stageId, stageConfig, effectives) },
)
for (gate in gates) {
val result = gate()
@@ -2163,6 +2177,41 @@ abstract class SessionOrchestrator(
)
}
/**
* Plan-compile gate: for a stage that produces an `execution_plan` artifact (the freestyle
* architect), compile its produced plan JSON as a post-stage gate. A compile failure fails the
* stage retryably with the compiler error fed back, so the existing retry-feedback loop hands it
* to the architect and the model self-corrects — rather than the post-planning compiler rejecting
* a broken plan with no re-plan path (which parked the session in ACTIVE forever). The verdict is
* recorded in a [PlanCompileCheckedEvent] (invariant #9) so replay reads it back and never
* re-compiles.
*/
private suspend fun runPlanCompileGate(
sessionId: SessionId,
stageId: StageId,
stageConfig: StageConfig,
): StageExecutionResult {
val check = planCompilationCheck ?: return StageExecutionResult.Success(emptyList())
val planSlot = stageConfig.produces.firstOrNull { it.kind.id == "execution_plan" }
?: return StageExecutionResult.Success(emptyList())
val json = artifactContentCache["${sessionId.value}:${planSlot.name.value}"]
?: return StageExecutionResult.Success(emptyList())
val error = check.check(json, "freestyle-${sessionId.value}")
emit(sessionId, PlanCompileCheckedEvent(sessionId, stageId, passed = error == null, error = error))
if (error == null) return StageExecutionResult.Success(emptyList())
log.warn(
"[Orchestrator] plan-compile gate failed session={} stage={}: {}",
sessionId.value, stageId.value, error,
)
return StageExecutionResult.Failure(
"stage ${stageId.value} produced an execution_plan that does not compile. " +
"Fix the plan before it can be locked:\n$error",
retryable = true,
)
}
/**
* The workspace-relative paths this stage actually wrote, projected from events (invariant #1):
* ToolInvocationRequested → this stage's invocation ids, FileWrittenEvent → the paths written
@@ -2284,6 +2333,78 @@ abstract class SessionOrchestrator(
)
}
/**
* Gate 3 — semantic (LLM) review (staged-verification § Gate 3). For a stage that opts in
* (`semanticReview`), the injected [SemanticReviewer] reads the files the stage actually wrote
* (the FileWritten manifest — never blind context) and reviews them against the stage objective,
* returning PR-comment-style findings. Runs last, so its input is static-clean (the deterministic
* funnel already passed) and its findings exclude anything the cheap gates catch.
*
* The reviewer is advisory: every finding is recorded in a [ReviewFindingsRaisedEvent] and
* surfaces to the operator regardless of verdict. A [ReviewVerdict.FAIL] blocks the stage
* retryably ONLY when it carries a high-confidence *correctness* finding and the review-block
* budget ([REVIEW_BLOCK_RETRY_CAP]) is not yet spent — so a stuck reviewer can never trap the
* stage; after the budget, findings surface without blocking. Suboptimal/opinion findings never
* block. Recorded as an environment observation (invariant #9); replay never re-runs the reviewer.
*/
private suspend fun runReviewGate(
sessionId: SessionId,
stageId: StageId,
stageConfig: StageConfig,
effectives: RunEffectives,
): StageExecutionResult {
if (!stageConfig.semanticReview) return StageExecutionResult.Success(emptyList())
val reviewer = semanticReviewer ?: return StageExecutionResult.Success(emptyList())
val workspaceRoot = effectives.policy?.workspaceRoot ?: return StageExecutionResult.Success(emptyList())
val files = stageWrittenPaths(sessionId, stageId)
if (files.isEmpty()) return StageExecutionResult.Success(emptyList())
val objective = stageConfig.needs
.mapNotNull { artifactContentCache["${sessionId.value}:${it.value}"] }
.joinToString("\n\n")
.ifBlank { "Stage '${stageId.value}' produced the files below; review them for defects." }
.take(REVIEW_OBJECTIVE_CAP)
val outcome = reviewer.review(sessionId, stageId, workspaceRoot, files, objective)
// A FAIL blocks only on a high-confidence correctness finding, and only while the review-block
// budget remains — count prior review blocks for this stage from the log (replay-safe).
val priorBlocks = eventStore.read(sessionId)
.mapNotNull { it.payload as? ReviewFindingsRaisedEvent }
.count { it.stageId == stageId && it.blocked }
val blockingFinding = outcome.findings.firstOrNull {
it.correctness && it.confidence >= REVIEW_BLOCK_MIN_CONFIDENCE
}
val shouldBlock = outcome.verdict == ReviewVerdict.FAIL &&
blockingFinding != null &&
priorBlocks < REVIEW_BLOCK_RETRY_CAP
emit(sessionId, ReviewFindingsRaisedEvent(sessionId, stageId, outcome.verdict, outcome.findings, blocked = shouldBlock))
if (!shouldBlock) {
if (outcome.findings.isNotEmpty()) {
log.info(
"[Orchestrator] semantic review advisory session={} stage={} verdict={} findings={}",
sessionId.value, stageId.value, outcome.verdict, outcome.findings.size,
)
}
return StageExecutionResult.Success(emptyList())
}
log.warn(
"[Orchestrator] semantic review blocked session={} stage={} finding={}",
sessionId.value, stageId.value, blockingFinding?.target,
)
val detail = outcome.findings
.filter { it.correctness }
.joinToString("\n") { "- [${it.severity}] ${it.target}: ${it.message}" +
(it.suggestedFix?.let { fix -> "\n fix: $fix" } ?: "") }
return StageExecutionResult.Failure(
"stage ${stageId.value} failed semantic review — fix these correctness issues:\n$detail",
retryable = true,
)
}
private suspend fun emitProcessResultEvents(
sessionId: SessionId,
stageId: StageId,
@@ -38,5 +38,10 @@ data class StageConfig(
// FAILS the gate — catching missing files, which the runtime write-manifest alone (it only
// inspects files that WERE written) cannot. Derived from the concrete entries of PlanStage.writes.
val expectedFiles: List<String> = emptyList(),
// Opt-in to the Gate 3 semantic (LLM) reviewer for this stage (staged-verification § Gate 3). When
// true and a reviewer is wired, the reviewer reads this stage's produced files after the
// deterministic funnel passes and raises advisory PR-comment findings; a high-confidence
// correctness FAIL can block retryably until the review-block budget is spent. Default off.
val semanticReview: Boolean = false,
val metadata: Map<String, String> = emptyMap(),
)