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:
+27
-1
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+57
@@ -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": [] }"""
|
||||
|
||||
Reference in New Issue
Block a user