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
+1
View File
@@ -0,0 +1 @@
1.txt
+1
View File
@@ -0,0 +1 @@
2.txt
+1
View File
@@ -0,0 +1 @@
3.txt
@@ -70,6 +70,10 @@ Emit a JSON object that validates against the `execution_plan` schema:
- Chain sequential stages: stage N's edge goes `to` stage N+1, not to `"done"`. Only the - Chain sequential stages: stage N's edge goes `to` stage N+1, not to `"done"`. Only the
final stage's edge points to `"done"`. A plan where an intermediate stage jumps to final stage's edge points to `"done"`. A plan where an intermediate stage jumps to
`"done"` ends the whole workflow there and never runs the remaining stages. `"done"` ends the whole workflow there and never runs the remaining stages.
- An `artifact_field_equals` edge may only check a field that the producing stage's
`kind` schema declares. A verdict-checking stage must use a kind with a `verdict`
field (`review_report`) — a plan that checks a field its kind cannot emit fails to
compile.
- For a review loop (implementer ↔ reviewer): emit two conditional edges from the - For a review loop (implementer ↔ reviewer): emit two conditional edges from the
reviewer stage: reviewer stage:
- `"type": "artifact_field_equals"`, `artifact_id`: reviewer's produces id, - `"type": "artifact_field_equals"`, `artifact_id`: reviewer's produces id,
@@ -50,6 +50,7 @@ class ExecutionPlanCompiler(
if (e.to !in declared && e.to != TERMINAL) { if (e.to !in declared && e.to != TERMINAL) {
throw WorkflowValidationException("edge to unknown stage '${e.to}'") throw WorkflowValidationException("edge to unknown stage '${e.to}'")
} }
validateFieldCondition(plan, e)
TransitionEdge( TransitionEdge(
id = TransitionId("${e.from}->${e.to}"), id = TransitionId("${e.from}->${e.to}"),
from = StageId(e.from), from = StageId(e.from),
@@ -66,6 +67,32 @@ class ExecutionPlanCompiler(
}.toSet() }.toSet()
val start = StageId(plan.stages.first().id) 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 // 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 // failure: every stage wired straight to "done" instead of chained, which silently
// truncates the plan to its first stage. // truncates the plan to its first stage.
@@ -85,6 +112,5 @@ class ExecutionPlanCompiler(
"chain stages with edges instead of pointing every stage to 'done'", "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.ConfigArtifactKind
import com.correx.core.artifacts.kind.DefaultArtifactKindRegistry import com.correx.core.artifacts.kind.DefaultArtifactKindRegistry
import com.correx.core.artifacts.kind.JsonSchema 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.Test
import org.junit.jupiter.api.assertThrows import org.junit.jupiter.api.assertThrows
import kotlin.test.assertEquals import kotlin.test.assertEquals
@@ -174,6 +175,62 @@ class ExecutionPlanCompilerTest {
assertEquals("create_1", graph.start.value) 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 @Test
fun `empty stages throws WorkflowValidationException`() { fun `empty stages throws WorkflowValidationException`() {
val bad = """{ "goal": "x", "stages": [], "edges": [] }""" val bad = """{ "goal": "x", "stages": [], "edges": [] }"""