From 1a7eb05945e67c023c8ea1d6df912cca0fee09da Mon Sep 17 00:00:00 2001 From: kami Date: Fri, 12 Jun 2026 13:42:13 +0400 Subject: [PATCH] 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. --- 1.txt | 1 + 2.txt | 1 + 3.txt | 1 + .../workflows/prompts/architect_freestyle.md | 4 ++ .../workflow/ExecutionPlanCompiler.kt | 28 ++++++++- .../workflow/ExecutionPlanCompilerTest.kt | 57 +++++++++++++++++++ 6 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 1.txt create mode 100644 2.txt create mode 100644 3.txt diff --git a/1.txt b/1.txt new file mode 100644 index 00000000..7999426c --- /dev/null +++ b/1.txt @@ -0,0 +1 @@ +1.txt \ No newline at end of file diff --git a/2.txt b/2.txt new file mode 100644 index 00000000..6757339c --- /dev/null +++ b/2.txt @@ -0,0 +1 @@ +2.txt \ No newline at end of file diff --git a/3.txt b/3.txt new file mode 100644 index 00000000..66c5144b --- /dev/null +++ b/3.txt @@ -0,0 +1 @@ +3.txt \ No newline at end of file diff --git a/examples/workflows/prompts/architect_freestyle.md b/examples/workflows/prompts/architect_freestyle.md index 66e88db6..2487e8e8 100644 --- a/examples/workflows/prompts/architect_freestyle.md +++ b/examples/workflows/prompts/architect_freestyle.md @@ -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 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. +- 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 reviewer stage: - `"type": "artifact_field_equals"`, `artifact_id`: reviewer's produces id, diff --git a/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/ExecutionPlanCompiler.kt b/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/ExecutionPlanCompiler.kt index 97e9d6b7..5991750b 100644 --- a/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/ExecutionPlanCompiler.kt +++ b/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/ExecutionPlanCompiler.kt @@ -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, transitions: Set, 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) } } diff --git a/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/ExecutionPlanCompilerTest.kt b/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/ExecutionPlanCompilerTest.kt index 0eb21285..8e2163a9 100644 --- a/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/ExecutionPlanCompilerTest.kt +++ b/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/ExecutionPlanCompilerTest.kt @@ -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 { 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": [] }"""