feat(workflow): plan-derived diff manifest (B§2)

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-20 21:16:34 +00:00
parent c52dbde4b4
commit 641051b9ef
5 changed files with 161 additions and 0 deletions
@@ -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),
)
}
@@ -15,6 +15,12 @@ data class PlanStage(
val kind: String? = null,
val needs: List<String> = emptyList(),
val tools: List<String> = 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<String> = emptyList(),
)
data class PlanEdge(
@@ -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<String> =
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<String>, planDerived: Set<String>): Set<String> =
(staticManifest + planDerived)
.map { it.trim() }
.filter { it.isNotEmpty() }
.toSet()
}
@@ -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\"")
@@ -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")
}
}