From 641051b9ef5491bad318fccb2e67f5008cce2f2a Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 20 Jun 2026 21:16:34 +0000 Subject: [PATCH] =?UTF-8?q?feat(workflow):=20plan-derived=20diff=20manifes?= =?UTF-8?q?t=20(B=C2=A72)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PlanStage gains a `writes` field (planner-declared workspace-relative paths/globs); PlanDerivedManifest.deriveAllowedWrites + combine union it with the static baseline. ExecutionPlanCompiler wires the union into StageConfig.writeManifest, enforced by the existing ManifestContainmentRule via the same glob matcher (no parallel matcher). Co-Authored-By: Claude Opus 4.8 --- .../workflow/ExecutionPlanCompiler.kt | 10 +++ .../workflow/ExecutionPlanModel.kt | 6 ++ .../workflow/PlanDerivedManifest.kt | 48 ++++++++++++++ .../workflow/ExecutionPlanCompilerTest.kt | 32 +++++++++ .../workflow/PlanDerivedManifestTest.kt | 65 +++++++++++++++++++ 5 files changed, 161 insertions(+) create mode 100644 infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/PlanDerivedManifest.kt create mode 100644 infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/PlanDerivedManifestTest.kt 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 5991750b..7d0f33f9 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 @@ -34,10 +34,20 @@ class ExecutionPlanCompiler( val kindId = s.kind ?: s.produces val kind = registry.get(kindId) ?: throw WorkflowValidationException("Unknown artifact kind '$kindId' in stage '${s.id}'") + // Write-guard manifest = static per-stage globs UNION the plan-derived set (BACKLOG + // §B-§2). Plan stages carry no separate static `writes` glob list today, so the static + // baseline here is empty and the plan-derived paths form the manifest; the union is kept + // explicit so a future static source widens rather than replaces. Sorted for a + // deterministic, replay-stable manifest. + val writeManifest = PlanDerivedManifest.combine( + staticManifest = emptyList(), + planDerived = PlanDerivedManifest.deriveAllowedWrites(s), + ).sorted() StageId(s.id) to StageConfig( produces = listOf(TypedArtifactSlot(name = ArtifactId(s.produces), kind = kind)), needs = s.needs.map { ArtifactId(it) }.toSet(), allowedTools = s.tools.toSet(), + writeManifest = writeManifest, metadata = mapOf("promptInline" to s.prompt), ) } diff --git a/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/ExecutionPlanModel.kt b/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/ExecutionPlanModel.kt index 225d1dc7..fbeeb742 100644 --- a/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/ExecutionPlanModel.kt +++ b/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/ExecutionPlanModel.kt @@ -15,6 +15,12 @@ data class PlanStage( val kind: String? = null, val needs: List = emptyList(), val tools: List = emptyList(), + // Workspace-relative file paths/globs this stage declares it will write (BACKLOG §B-§2). + // Drives the plan-derived diff manifest: these are unioned with the static per-stage + // write globs to form the enforced write-guard manifest. Absent/empty = no plan-derived + // contribution (the static manifest, if any, still applies). The planner emits this under + // the JSON key `writes`. + val writes: List = emptyList(), ) data class PlanEdge( diff --git a/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/PlanDerivedManifest.kt b/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/PlanDerivedManifest.kt new file mode 100644 index 00000000..828b5c12 --- /dev/null +++ b/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/PlanDerivedManifest.kt @@ -0,0 +1,48 @@ +package com.correx.infrastructure.workflow + +/** + * Plan-derived diff manifest (BACKLOG §B-§2). + * + * The write-guard manifest enforced by the plane-2 `ManifestContainmentRule` was originally a + * STATIC per-stage `writes = [globs]` set (role-reliability §2). This object DERIVES the + * allowed-write set for a stage from the confirmed `impl_plan` artifact instead — the file + * paths/globs a [PlanStage] declares it produces/touches — so the guard tracks the actual locked + * plan rather than only hand-written globs. + * + * The derived set is not a replacement: it AUGMENTS the static baseline. [combine] unions the two, + * and the resulting set is fed verbatim into `StageConfig.writeManifest`, so plan-derived paths are + * matched with the SAME glob/containment semantics the static manifest already uses — no parallel + * matcher is introduced. + * + * ## Extraction rule + * Write targets are read from [PlanStage.writes]: the workspace-relative file paths/globs the + * plan-stage explicitly declares it will write. `produces`/`kind`/`needs` name artifact *slots* + * (logical outputs), not filesystem paths, so they are deliberately NOT treated as write targets. + * Derivation is deterministic and lenient: a stage with no [PlanStage.writes] yields an empty set, + * blank/whitespace entries are dropped, and duplicates collapse. + */ +object PlanDerivedManifest { + + /** + * The set of workspace-relative paths/globs [planStage] is permitted to write, derived from its + * declared [PlanStage.writes]. Lenient: a stage with no declared write targets yields the empty + * set (the static manifest, if any, still governs). Deterministic for a given input. + */ + fun deriveAllowedWrites(planStage: PlanStage): Set = + planStage.writes + .map { it.trim() } + .filter { it.isNotEmpty() } + .toSet() + + /** + * Unions the static per-stage write globs with the plan-derived set. A write is allowed if it + * matches EITHER source. Blank entries are dropped so the combined set carries only matchable + * globs. The static path is never narrowed — the result only ever widens the static baseline by + * the plan-derived paths. + */ + fun combine(staticManifest: Collection, planDerived: Set): Set = + (staticManifest + planDerived) + .map { it.trim() } + .filter { it.isNotEmpty() } + .toSet() +} 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 8e2163a9..6d2b24da 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 @@ -8,6 +8,7 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows import kotlin.test.assertEquals import kotlin.test.assertNotNull +import kotlin.test.assertTrue class ExecutionPlanCompilerTest { @@ -70,6 +71,37 @@ class ExecutionPlanCompilerTest { assertEquals(setOf("patch"), reviewStage.needs.map { it.value }.toSet()) } + @Test + fun `plan-declared writes are derived into the stage write manifest`() { + val plan = """ + { + "goal": "implement a feature", + "stages": [ + { + "id": "impl", + "prompt": "Implement it", + "produces": "patch", + "needs": [], + "tools": ["file_write"], + "writes": ["core/feature/**", "docs/feature.md"] + } + ], + "edges": [ + { "from": "impl", "to": "done", "condition": { "type": "always_true" } } + ] + } + """.trimIndent() + val graph = compiler.compile(plan, "manifest-workflow") + val stage = graph.stages.values.single() + assertEquals(setOf("core/feature/**", "docs/feature.md"), stage.writeManifest.toSet()) + } + + @Test + fun `a stage without declared writes has an empty write manifest`() { + val graph = compiler.compile(validPlan, "no-writes-workflow") + assertTrue(graph.stages.values.all { it.writeManifest.isEmpty() }) + } + @Test fun `edge referencing unknown from-stage throws WorkflowValidationException`() { val bad = validPlan.replace("\"from\": \"analyse\"", "\"from\": \"nonexistent\"") diff --git a/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/PlanDerivedManifestTest.kt b/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/PlanDerivedManifestTest.kt new file mode 100644 index 00000000..dd6afaa0 --- /dev/null +++ b/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/PlanDerivedManifestTest.kt @@ -0,0 +1,65 @@ +package com.correx.infrastructure.workflow + +import java.nio.file.FileSystems +import java.nio.file.Path +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class PlanDerivedManifestTest { + + @Test + fun `derives the declared write paths of a plan stage`() { + val stage = PlanStage(id = "impl", writes = listOf("core/a/B.kt", "docs/notes.md")) + assertEquals(setOf("core/a/B.kt", "docs/notes.md"), PlanDerivedManifest.deriveAllowedWrites(stage)) + } + + @Test + fun `a stage with no declared writes derives an empty set`() { + val stage = PlanStage(id = "analyse", produces = "patch", needs = listOf("x")) + assertTrue(PlanDerivedManifest.deriveAllowedWrites(stage).isEmpty()) + } + + @Test + fun `multiple files are all derived and blank entries dropped`() { + val stage = PlanStage(id = "impl", writes = listOf("a.kt", " ", "b.kt", "", "c.kt")) + assertEquals(setOf("a.kt", "b.kt", "c.kt"), PlanDerivedManifest.deriveAllowedWrites(stage)) + } + + @Test + fun `combine unions the static globs with the plan-derived set`() { + val combined = PlanDerivedManifest.combine( + staticManifest = listOf("core/**"), + planDerived = setOf("docs/x.md"), + ) + assertEquals(setOf("core/**", "docs/x.md"), combined) + } + + @Test + fun `combine drops blank entries and de-duplicates across sources`() { + val combined = PlanDerivedManifest.combine( + staticManifest = listOf("core/**", " ", "shared.kt"), + planDerived = setOf("shared.kt", "docs/x.md", ""), + ) + assertEquals(setOf("core/**", "shared.kt", "docs/x.md"), combined) + } + + @Test + fun `combined manifest matches via the same glob semantics the static manifest uses`() { + // The combined set is fed verbatim into StageConfig.writeManifest, which the plane-2 + // ManifestContainmentRule matches with FileSystems glob PathMatchers. Assert that a write + // allowed only by the plan-derived entry, one allowed only by the static glob, and one in + // neither resolve as expected under that exact matcher — no parallel matcher is introduced. + val combined = PlanDerivedManifest.combine( + staticManifest = listOf("core/**"), + planDerived = setOf("docs/x.md"), + ) + val matchers = combined.map { FileSystems.getDefault().getPathMatcher("glob:$it") } + fun allowed(rel: String) = matchers.any { it.matches(Path.of(rel)) } + + assertTrue(allowed("core/a/B.kt"), "static glob should allow core/a/B.kt") + assertTrue(allowed("docs/x.md"), "plan-derived path should allow docs/x.md") + assertFalse(allowed("apps/server/Main.kt"), "a path in neither source is denied") + } +}