fix(workflow): reject field-condition edges the producing kind cannot satisfy

Live repro: architect emitted a verify stage with kind=analysis and a
verdict-equals edge. The kind schema doubles as the LLM response format,
so 'verdict' was never emitted and the workflow failed at its final hop
at runtime. The compiler now requires artifact_field_equals fields to be
declared by the producing stage's kind schema, failing the plan at lock
time with a pointer at review_report. Architect prompt rule added;
reachability check extracted alongside the new validation.
This commit is contained in:
2026-06-12 13:42:13 +04:00
parent a455762dd5
commit 1a7eb05945
6 changed files with 91 additions and 1 deletions
@@ -50,6 +50,7 @@ class ExecutionPlanCompiler(
if (e.to !in declared && e.to != TERMINAL) {
throw WorkflowValidationException("edge to unknown stage '${e.to}'")
}
validateFieldCondition(plan, e)
TransitionEdge(
id = TransitionId("${e.from}->${e.to}"),
from = StageId(e.from),
@@ -66,6 +67,32 @@ class ExecutionPlanCompiler(
}.toSet()
val start = StageId(plan.stages.first().id)
validateReachability(declared, transitions, start)
return WorkflowGraph(id = workflowId, stages = stageMap, transitions = transitions, start = start)
}
/**
* A field-equals edge can only ever fire if the producing stage's kind schema declares
* that field — the kind's schema is also the LLM response format, so an undeclared field
* (live repro: 'verdict' checked on an 'analysis' artifact) is never emitted and the
* condition fails the whole workflow at runtime instead of the plan failing at lock time.
*/
private fun validateFieldCondition(plan: ExecutionPlanModel, e: PlanEdge) {
if (e.condition.type != "artifact_field_equals") return
val field = e.condition.field ?: return
val producing = plan.stages.firstOrNull { it.produces == e.condition.artifactId } ?: return
val kindId = producing.kind ?: producing.produces
val schema = registry.get(kindId)?.deriveJsonSchema() ?: return
if (!schema.properties.containsKey(field)) {
throw WorkflowValidationException(
"edge '${e.from}->${e.to}' checks field '$field' of artifact '${e.condition.artifactId}', " +
"but its kind '$kindId' does not declare that field — use a kind whose schema has it " +
"(e.g. review_report for verdict)",
)
}
}
private fun validateReachability(declared: Set<String>, transitions: Set<TransitionEdge>, start: StageId) {
// A declared stage with no path from start can never run — typical LLM topology
// failure: every stage wired straight to "done" instead of chained, which silently
// truncates the plan to its first stage.
@@ -85,6 +112,5 @@ class ExecutionPlanCompiler(
"chain stages with edges instead of pointing every stage to 'done'",
)
}
return WorkflowGraph(id = workflowId, stages = stageMap, transitions = transitions, start = start)
}
}
@@ -3,6 +3,7 @@ package com.correx.infrastructure.workflow
import com.correx.core.artifacts.kind.ConfigArtifactKind
import com.correx.core.artifacts.kind.DefaultArtifactKindRegistry
import com.correx.core.artifacts.kind.JsonSchema
import com.correx.core.artifacts.kind.JsonSchemaProperty
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import kotlin.test.assertEquals
@@ -174,6 +175,62 @@ class ExecutionPlanCompilerTest {
assertEquals("create_1", graph.start.value)
}
@Test
fun `field condition on a kind without that field throws WorkflowValidationException`() {
// Live repro (2026-06-12): a reviewer stage with kind=analysis and a verdict-equals
// edge — analysis never emits 'verdict', so the workflow failed at runtime.
val plan = """
{
"goal": "verify",
"stages": [
{ "id": "verify", "prompt": "verify", "produces": "verify_out", "kind": "patch" }
],
"edges": [
{
"from": "verify", "to": "done",
"condition": { "type": "artifact_field_equals", "artifact_id": "verify_out", "field": "verdict", "value": "approved", "operator": "eq" }
}
]
}
""".trimIndent()
val ex = assertThrows<WorkflowValidationException> { compiler.compile(plan, "bad-workflow") }
assertEquals(true, ex.message?.contains("verdict"))
}
@Test
fun `field condition on a kind declaring the field compiles`() {
val verdictRegistry = DefaultArtifactKindRegistry().also {
it.register(
ConfigArtifactKind(
id = "review_report",
schema = JsonSchema(
type = "object",
properties = mapOf("verdict" to JsonSchemaProperty(type = "string")),
required = listOf("verdict"),
additionalProperties = true,
),
llmEmitted = true,
),
)
}
val plan = """
{
"goal": "verify",
"stages": [
{ "id": "verify", "prompt": "verify", "produces": "verify_out", "kind": "review_report" }
],
"edges": [
{
"from": "verify", "to": "done",
"condition": { "type": "artifact_field_equals", "artifact_id": "verify_out", "field": "verdict", "value": "approved", "operator": "eq" }
}
]
}
""".trimIndent()
val graph = ExecutionPlanCompiler(verdictRegistry).compile(plan, "verdict-workflow")
assertEquals(1, graph.stages.size)
}
@Test
fun `empty stages throws WorkflowValidationException`() {
val bad = """{ "goal": "x", "stages": [], "edges": [] }"""