feat(validation): staged-verification funnel — contract + execution gates

Gate 2 (contract) and Gate 4 (execution) of the 4-gate staged-verification
design, plus the two gate-quality fixes found in live QA.

- Gate 2: KindContractTable (kind→assertions registry, universal floor,
  additive project overrides) + KindInference (path→kind, pure/replay-safe)
  + ContractAssertion model; ContractGateEvaluatedEvent (registered);
  ContractAssertionEvaluator seam + FileSystemContractEvaluator (FS/TEXT,
  COMPILER skipped); runContractGate in runPostStageGates — a stage that
  produces file_written artifacts must have every declared+written file
  exist/non-empty/parse, else retryable hand-back with per-assertion feedback.
- Option A: ExecutionPlanCompiler puts concrete (non-glob) plan `writes` into
  StageConfig.expectedFiles, so a declared-but-unwritten file fails file_exists.
- Gate 4: BuildExpectation enum (none|module|project|tests) + plan field +
  closed-set compiler validation; runExecutionGate resolves the alias against
  the bound project profile commands and runs it as a deterministic gate.
- Live-QA fixes: react_entry kind so main.tsx/index.tsx aren't required to
  export a default component (was an unwinnable false-positive); JSONC
  tolerance (allowComments/allowTrailingComma) so tsconfig.json passes valid_json.

Seams are null on the replay harness (no-op) — recorded events are truth (#9).
This commit is contained in:
2026-07-06 01:25:51 +04:00
parent 33ea44d8cc
commit ff1a0ffdad
18 changed files with 935 additions and 5 deletions
@@ -38,6 +38,7 @@ import com.correx.core.kernel.orchestration.OrchestrationProjector
import com.correx.core.kernel.orchestration.OrchestrationRepository
import com.correx.core.kernel.orchestration.OrchestratorEngines
import com.correx.core.kernel.orchestration.ProcessStaticAnalysisRunner
import com.correx.core.kernel.orchestration.FileSystemContractEvaluator
import com.correx.core.kernel.orchestration.OrchestratorRepositories
import com.correx.core.kernel.orchestration.WorkspaceContext
import com.correx.core.kernel.orchestration.WorkspaceTools
@@ -324,6 +325,7 @@ fun main() {
workspacePolicy = workspacePolicy,
workspaceToolRegistryProvider = wsToolRegistryProvider,
staticAnalysisRunner = ProcessStaticAnalysisRunner(),
contractAssertionEvaluator = FileSystemContractEvaluator(),
)
val decisionJournalRepository = InfrastructureModule.createDecisionJournalRepository(eventStore)
val defaultOrchestrationConfig = OrchestrationConfig(
@@ -0,0 +1,53 @@
package com.correx.core.artifacts.kind
/**
* Which mechanism evaluates an assertion. The *initial* choice is the cheapest that works;
* `AST`-tagged assertions ship as `TEXT` heuristics and are promoted to a real structured parse
* only when the benchmark projection shows the heuristic is too noisy (design §3.4). The id is
* stable across a promotion; only the evaluator strengthens.
*/
enum class AssertionEvaluator {
/** Filesystem stat/read — existence, non-empty. No parse. */
FS,
/** Regex / JSON parse — line-oriented, cheap. */
TEXT,
/** Read out of a compiler/linter run the stage already invokes (authoritative, whole-project). */
COMPILER,
/** Structured parse. Starts life as a `TEXT` heuristic; promoted on demand. */
AST,
}
/**
* The contract layer an assertion came from. Priority increases downward. Monotonicity is scoped
* to the LLM-authored layers: [DERIVED], [FRAMEWORK] and [PLANNER] may only strengthen the
* contract (they are concatenated, never edited). [HUMAN] is the single trusted layer with
* weakening authority (design §3.1). Enforced structurally — the planner emits only its own
* layer and has no syntax for removal — so weakening is inexpressible, not detected-and-rejected.
*/
enum class AssertionLayer {
DERIVED,
FRAMEWORK,
PLANNER,
HUMAN,
}
/**
* One deterministic (or, for [AssertionEvaluator.AST], deterministic-once-promoted) requirement on
* a stage's produced file. Instantiated from the [KindContractTable] against a concrete path.
*
* A contract assertion is Gate 2 (design §1): "did the stage accomplish its objective?" — answered
* by filesystem/AST inspection over what the stage declared it produces, never by an LLM. The
* result of evaluating each assertion is recorded in a
* [com.correx.core.events.events.ContractGateEvaluatedEvent] so hand-back feedback is
* failing-test-name granular.
*/
data class ContractAssertion(
val id: String,
val target: String,
val evaluator: AssertionEvaluator,
val layer: AssertionLayer = AssertionLayer.DERIVED,
val args: Map<String, String> = emptyMap(),
)
@@ -0,0 +1,150 @@
package com.correx.core.artifacts.kind
/**
* The checked-in `kind → assertions` registry (design doc 2026-07-06-kind-assertion-table.md).
*
* Most contracts are *derived*: a stage's `produces[].kind` maps to a fixed assertion set here, and
* the planner writes nothing. An unrecognized kind never yields an empty contract — it degrades to
* the [UNIVERSAL_FLOOR] (`file_exists · file_nonempty · parses`), so e.g. an empty `Sessions.tsx`
* fails regardless of table coverage.
*
* A project may supply an **additive** override ([projectOverrides]) — add assertions to a kind,
* never remove the checked-in ones (monotonic, one level up). Merge keeps the checked-in set and
* appends project entries not already present by id.
*
* This is deliberately data, not behaviour: it is environment-independent and must replay
* identically. Evaluation lives behind the `ContractAssertionEvaluator` seam in the kernel/
* infrastructure; this table only says *what* to check, never *how*.
*/
object KindContractTable {
/** A single assertion template — a kind's requirement before it is bound to a concrete path. */
private data class Template(val id: String, val evaluator: AssertionEvaluator, val args: Map<String, String> = emptyMap())
private val FLOOR = listOf(
Template("file_exists", AssertionEvaluator.FS),
Template("file_nonempty", AssertionEvaluator.FS),
Template("parses", AssertionEvaluator.COMPILER),
)
// TS / React ---------------------------------------------------------------------------------
private val TS_REACT = mapOf(
"react_hook" to FLOOR + listOf(
Template("imports_resolve", AssertionEvaluator.COMPILER),
Template("exports_hook", AssertionEvaluator.AST),
),
"react_component" to FLOOR + listOf(
Template("imports_resolve", AssertionEvaluator.COMPILER),
Template("exports_default_component", AssertionEvaluator.AST),
),
"react_view" to FLOOR + listOf(
Template("imports_resolve", AssertionEvaluator.COMPILER),
Template("exports_default_component", AssertionEvaluator.AST),
),
// App entry point (main.tsx / index.tsx): mounts the tree, exports nothing. The honest bar
// is parse + imports resolve; a default-export assertion here false-fails a correct file.
"react_entry" to FLOOR + listOf(
Template("imports_resolve", AssertionEvaluator.COMPILER),
),
"ts_module" to FLOOR + listOf(
Template("imports_resolve", AssertionEvaluator.COMPILER),
),
// pure type module: may legitimately export only types, which erase at runtime. `parses`
// (in the floor) is the honest bar; an export assertion here would false-fail.
"ts_types" to FLOOR,
"api_client" to FLOOR + listOf(
Template("imports_resolve", AssertionEvaluator.COMPILER),
Template("exports_symbol", AssertionEvaluator.COMPILER),
),
"html_entry" to listOf(
Template("file_exists", AssertionEvaluator.FS),
Template("file_nonempty", AssertionEvaluator.FS),
Template("contains", AssertionEvaluator.TEXT, mapOf("pattern" to "id=\"root\"")),
Template("contains", AssertionEvaluator.TEXT, mapOf("pattern" to "<script")),
),
"package_json" to listOf(
Template("file_exists", AssertionEvaluator.FS),
Template("valid_json", AssertionEvaluator.TEXT),
Template("json_has_key", AssertionEvaluator.TEXT, mapOf("key" to "name")),
Template("script_defined", AssertionEvaluator.TEXT, mapOf("name" to "build")),
),
"tsconfig" to listOf(
Template("file_exists", AssertionEvaluator.FS),
Template("valid_json", AssertionEvaluator.TEXT),
),
"vite_config" to FLOOR,
"css_entry" to listOf(
Template("file_exists", AssertionEvaluator.FS),
Template("file_nonempty", AssertionEvaluator.FS),
),
"router_config" to FLOOR + listOf(
Template("imports_resolve", AssertionEvaluator.COMPILER),
),
)
// Kotlin (correx itself) ---------------------------------------------------------------------
private val KOTLIN = mapOf(
// Promotes the CLAUDE.md SILENT FAILURE TRAP into a deterministic gate: every EventPayload
// subclass must be registered in Serialization.kt or deserialization fails silently at runtime.
"kotlin_event" to FLOOR + listOf(
Template("implements_interface", AssertionEvaluator.COMPILER, mapOf("interface" to "EventPayload")),
Template("registered_in_serialization", AssertionEvaluator.AST),
),
"kotlin_reducer" to FLOOR + listOf(
Template("implements_interface", AssertionEvaluator.COMPILER, mapOf("interface" to "Reducer")),
),
"kotlin_projector" to FLOOR + listOf(
Template("implements_interface", AssertionEvaluator.COMPILER, mapOf("interface" to "Projection")),
),
"kotlin_repository" to FLOOR,
"kotlin_service" to FLOOR,
"kotlin_test" to FLOOR + listOf(
Template("has_test", AssertionEvaluator.TEXT),
),
"gradle_module" to listOf(
Template("file_exists", AssertionEvaluator.FS),
Template("valid_json", AssertionEvaluator.TEXT),
),
)
// Generic / cross-stack ----------------------------------------------------------------------
private val GENERIC = mapOf(
"config" to FLOOR,
"json_config" to listOf(
Template("file_exists", AssertionEvaluator.FS),
Template("valid_json", AssertionEvaluator.TEXT),
),
"migration" to listOf(
Template("file_exists", AssertionEvaluator.FS),
Template("migration_present", AssertionEvaluator.TEXT),
),
"toolchain" to listOf(
Template("toolchain_installed", AssertionEvaluator.FS),
),
"docs" to listOf(
Template("file_exists", AssertionEvaluator.FS),
Template("file_nonempty", AssertionEvaluator.FS),
),
)
private val CHECKED_IN: Map<String, List<Template>> = TS_REACT + KOTLIN + GENERIC
/** Project-supplied additive overrides, keyed by kind id. Set at wiring time; empty by default. */
@Volatile
var projectOverrides: Map<String, List<ContractAssertion>> = emptyMap()
/**
* The derived-layer contract for a produced file of [kindId] at [path]. Falls back to the
* universal floor for an unknown kind, then appends any project override assertions not already
* present by id (additive/monotonic).
*/
fun assertionsFor(kindId: String, path: String): List<ContractAssertion> {
val base = (CHECKED_IN[kindId] ?: FLOOR).map {
ContractAssertion(it.id, path, it.evaluator, AssertionLayer.DERIVED, it.args)
}
val overrides = projectOverrides[kindId].orEmpty()
.filter { ov -> base.none { it.id == ov.id } }
.map { it.copy(target = path) }
return base + overrides
}
}
@@ -0,0 +1,64 @@
package com.correx.core.artifacts.kind
/**
* Infers a [KindContractTable] key from a written file's workspace-relative path (Gate 2, Option B:
* runtime-manifest-driven — the plan declares a generic `file_written` slot, so the semantic kind is
* recovered from the path at gate time).
*
* Pure and deterministic over the path string — no filesystem access — so it is replay-safe. An
* unrecognized path returns `null`; the caller then falls back to the universal floor, so an
* unknown file is still checked for existence + non-emptiness + parse, never skipped.
*
* This is heuristic by nature. Option A (planner declares `{path, kind}` targets) supersedes it for
* stages that opt in; until then this recovers the common cases for the stacks correx exercises.
*/
object KindInference {
fun kindFor(path: String): String? {
val name = path.substringAfterLast('/')
val lower = name.lowercase()
return when {
lower == "package.json" -> "package_json"
lower.startsWith("tsconfig") && lower.endsWith(".json") -> "tsconfig"
lower.startsWith("vite.config.") -> "vite_config"
lower.endsWith(".html") -> "html_entry"
lower.endsWith(".css") || lower.endsWith(".scss") -> "css_entry"
lower.endsWith(".gradle.kts") || lower == "build.gradle" -> "gradle_module"
name.endsWith(".tsx") || name.endsWith(".ts") -> tsKind(path, name)
name.endsWith(".kt") -> kotlinKind(name)
lower.endsWith(".json") -> "json_config"
lower.endsWith(".md") -> "docs"
else -> null
}
}
private fun tsKind(path: String, name: String): String {
val stem = name.substringBeforeLast('.')
return when {
// App entry points (src/main.tsx, index.tsx) mount the tree via createRoot/render and
// legitimately export nothing — requiring a default component (react_view) makes the
// stage unwinnable for a correct file. Lenient contract: exists · non-empty · imports.
name.endsWith(".tsx") && (stem == "main" || stem == "index") -> "react_entry"
stem.startsWith("use") && stem.length > 3 && stem[3].isUpperCase() -> "react_hook"
"/types/" in path || stem == "types" -> "ts_types"
"/views/" in path || stem.endsWith("View") -> "react_view"
"router" in stem.lowercase() || "routes" in stem.lowercase() -> "router_config"
name.endsWith(".tsx") && stem.isNotEmpty() && stem[0].isUpperCase() -> "react_component"
name.endsWith(".tsx") -> "react_view"
else -> "ts_module"
}
}
private fun kotlinKind(name: String): String {
val stem = name.removeSuffix(".kt")
return when {
stem.endsWith("Event") || stem.endsWith("Events") -> "kotlin_event"
stem.endsWith("Reducer") -> "kotlin_reducer"
stem.endsWith("Projector") || stem.endsWith("Projection") -> "kotlin_projector"
stem.endsWith("Repository") -> "kotlin_repository"
stem.endsWith("Test") || stem.endsWith("Tests") -> "kotlin_test"
stem.endsWith("Service") || stem.endsWith("Facade") -> "kotlin_service"
else -> "kotlin_service"
}
}
}
@@ -0,0 +1,78 @@
package com.correx.core.artifacts.kind
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class KindContractTableTest {
@AfterTest
fun reset() {
KindContractTable.projectOverrides = emptyMap()
}
@Test
fun `infers common TS and Kotlin kinds from path`() {
assertEquals("react_hook", KindInference.kindFor("frontend/src/hooks/useWebSocket.ts"))
assertEquals("react_view", KindInference.kindFor("frontend/src/views/Sessions.tsx"))
assertEquals("react_component", KindInference.kindFor("frontend/src/components/Navbar.tsx"))
assertEquals("ts_types", KindInference.kindFor("frontend/src/types/index.ts"))
assertEquals("package_json", KindInference.kindFor("frontend/package.json"))
assertEquals("tsconfig", KindInference.kindFor("frontend/tsconfig.json"))
assertEquals("html_entry", KindInference.kindFor("frontend/index.html"))
assertEquals("kotlin_event", KindInference.kindFor("core/events/FooEvent.kt"))
assertEquals("kotlin_reducer", KindInference.kindFor("core/foo/BarReducer.kt"))
}
@Test
fun `unknown kind falls back to universal floor, never empty`() {
val assertions = KindContractTable.assertionsFor(KindInference.kindFor("weird.xyz") ?: "", "weird.xyz")
assertEquals(listOf("file_exists", "file_nonempty", "parses"), assertions.map { it.id })
}
@Test
fun `app entry files are react_entry, not react_view`() {
// Regression: main.tsx / index.tsx mount the tree and export nothing. Classifying them as
// react_view (which requires exports_default_component) made a correct file unwinnable.
assertEquals("react_entry", KindInference.kindFor("frontend/src/main.tsx"))
assertEquals("react_entry", KindInference.kindFor("frontend/src/index.tsx"))
// A non-entry .tsx entry point name in .ts form is unaffected.
assertEquals("ts_types", KindInference.kindFor("frontend/src/types/index.ts"))
}
@Test
fun `react_entry contract does not require a default component export`() {
val ids = KindContractTable.assertionsFor("react_entry", "frontend/src/main.tsx").map { it.id }
assertTrue("file_exists" in ids && "file_nonempty" in ids)
assertTrue("exports_default_component" !in ids, "entry file must not require a default export")
}
@Test
fun `react_hook contract includes floor plus exports_hook`() {
val ids = KindContractTable.assertionsFor("react_hook", "useWebSocket.ts").map { it.id }
assertTrue("file_exists" in ids && "file_nonempty" in ids)
assertTrue("exports_hook" in ids)
}
@Test
fun `kotlin_event promotes the serialization-registration trap into an assertion`() {
val ids = KindContractTable.assertionsFor("kotlin_event", "FooEvent.kt").map { it.id }
assertTrue("registered_in_serialization" in ids)
}
@Test
fun `project override is additive and cannot remove checked-in assertions`() {
KindContractTable.projectOverrides = mapOf(
"css_entry" to listOf(
ContractAssertion("contains", "", AssertionEvaluator.TEXT, args = mapOf("pattern" to "@tailwind")),
// duplicate of a checked-in id — must not double up
ContractAssertion("file_exists", "", AssertionEvaluator.FS),
),
)
val ids = KindContractTable.assertionsFor("css_entry", "src/index.css").map { it.id }
assertEquals(1, ids.count { it == "file_exists" }, "checked-in file_exists must not be duplicated")
assertTrue("contains" in ids, "override assertion must be appended")
assertTrue("file_nonempty" in ids, "checked-in assertions remain")
}
}
@@ -0,0 +1,42 @@
package com.correx.core.events.events
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** The outcome of evaluating one contract assertion against a stage's produced file. */
@Serializable
data class ContractAssertionResult(
val assertionId: String,
/** Workspace-relative path the assertion was evaluated against. */
val target: String,
/** Contract layer the assertion came from: DERIVED / FRAMEWORK / PLANNER / HUMAN. */
val layer: String,
/** Evaluator that produced the verdict: FS / TEXT / COMPILER / AST. */
val evaluator: String,
val passed: Boolean,
/** Concrete reason the assertion failed (or a confirmation when it passed). Kept bounded. */
val evidence: String,
)
/**
* Environment observation (invariant #9): the Gate 2 contract assertions evaluated against a
* stage's produced files — each with the layer it came from, the evaluator, its verdict and
* evidence — captured at the moment the stage completed. Replay reads these recorded facts and
* never re-inspects the filesystem.
*
* Gate 2 (design §1) answers "did the stage accomplish its objective?" deterministically, over the
* files the stage declared it `produces`. A failed assertion fails the producing stage retryably,
* with the failing assertion ids + evidence fed back verbatim — so hand-back feedback is
* failing-test-name granular, not a vague "review said fix it".
*/
@Serializable
@SerialName("ContractGateEvaluated")
data class ContractGateEvaluatedEvent(
val sessionId: SessionId,
val stageId: StageId,
val results: List<ContractAssertionResult>,
) : EventPayload {
val passed: Boolean get() = results.all { it.passed }
}
@@ -15,6 +15,7 @@ import com.correx.core.events.events.ArtifactValidatingEvent
import com.correx.core.events.events.BriefEchoMismatchEvent
import com.correx.core.events.events.BriefGroundingCheckedEvent
import com.correx.core.events.events.StaticAnalysisCompletedEvent
import com.correx.core.events.events.ContractGateEvaluatedEvent
import com.correx.core.events.events.PlanLintCompletedEvent
import com.correx.core.events.events.ChatSessionStartedEvent
import com.correx.core.events.events.SessionWorkingTaskEvent
@@ -148,6 +149,7 @@ val eventModule = SerializersModule {
subclass(BriefGroundingCheckedEvent::class)
subclass(BriefEchoMismatchEvent::class)
subclass(StaticAnalysisCompletedEvent::class)
subclass(ContractGateEvaluatedEvent::class)
subclass(PlanLintCompletedEvent::class)
subclass(RiskAssessedEvent::class)
subclass(ChatSessionStartedEvent::class)
@@ -0,0 +1,30 @@
package com.correx.core.kernel.orchestration
import com.correx.core.artifacts.kind.ContractAssertion
import java.nio.file.Path
/**
* Verdict for one [ContractAssertion]. [skipped] marks an assertion this evaluator does not handle
* — currently the `COMPILER` ones, which are covered by the static-analysis command gate (a real
* `tsc`/`kotlinc` run), not re-implemented here. A skipped assertion is recorded but never fails a
* stage.
*/
data class ContractAssertionVerdict(
val passed: Boolean,
val evidence: String,
val skipped: Boolean = false,
)
/**
* Seam for evaluating a Gate 2 contract assertion against files in the workspace (design §1). Like
* [StaticAnalysisRunner], the filesystem-touching implementation lives in infrastructure and is
* injected, so the deterministic core never reads files itself and the gate stays unit-testable with
* a fake.
*
* The orchestrator records every verdict in a
* [com.correx.core.events.events.ContractGateEvaluatedEvent] (invariant #9 environment observation)
* — replay reads the recorded facts and never re-inspects the filesystem.
*/
fun interface ContractAssertionEvaluator {
suspend fun evaluate(workspaceRoot: Path, assertion: ContractAssertion): ContractAssertionVerdict
}
@@ -0,0 +1,115 @@
package com.correx.core.kernel.orchestration
import com.correx.core.artifacts.kind.AssertionEvaluator
import com.correx.core.artifacts.kind.ContractAssertion
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonObject
import java.nio.file.Files
import java.nio.file.Path
/**
* Filesystem/text [ContractAssertionEvaluator] for Gate 2 (design §1). Reads the assertion's target
* file under the workspace root and returns a verdict with concrete evidence.
*
* Handles the `FS` and `TEXT` evaluators outright, and the `AST` ones as text heuristics (they ship
* heuristic and get promoted to a real parse on demand, design §3.4). `COMPILER` assertions are
* marked [ContractAssertionVerdict.skipped] — a real `tsc`/`kotlinc` run (the static-analysis command
* gate) is the authoritative check for those, so re-implementing them here would only add a weaker
* duplicate.
*/
class FileSystemContractEvaluator : ContractAssertionEvaluator {
// allowComments/allowTrailingComma make tsconfig.json (JSONC — comments + trailing commas are
// legal and tsc/Vite parse them) pass valid_json instead of false-failing a valid config file.
private val json = Json {
ignoreUnknownKeys = true
isLenient = true
allowComments = true
allowTrailingComma = true
}
override suspend fun evaluate(workspaceRoot: Path, assertion: ContractAssertion): ContractAssertionVerdict =
withContext(Dispatchers.IO) {
if (assertion.evaluator == AssertionEvaluator.COMPILER) {
return@withContext skip("covered by static-analysis command gate")
}
val path = workspaceRoot.resolve(assertion.target)
val exists = Files.exists(path) && Files.isRegularFile(path)
val dirExists = Files.isDirectory(workspaceRoot.resolve(assertion.target))
when (assertion.id) {
"file_exists" -> verdict(exists, if (exists) "exists" else "file does not exist")
"file_nonempty" -> nonEmpty(path, exists)
"toolchain_installed" -> verdict(dirExists, if (dirExists) "present" else "toolchain dir absent")
"valid_json" -> validJson(path, exists)
"json_has_key" -> jsonKey(path, exists, assertion.args["key"].orEmpty())
"script_defined" -> scriptDefined(path, exists, assertion.args["name"].orEmpty())
"contains" -> contains(path, exists, assertion.args["pattern"].orEmpty())
"has_test" -> textMatch(path, exists, "@Test", "no @Test found")
"migration_present" -> migrationPresent(assertion.target, exists)
"exports_hook" -> exportsHook(path, exists)
"exports_default_component" -> textMatch(path, exists, "export default", "no default export")
"registered_in_serialization" -> skip("cross-file check — deferred to compiler/Option A")
else -> skip("no evaluator for '${assertion.id}'")
}
}
private fun readOrNull(path: Path, exists: Boolean): String? =
if (!exists) null else runCatching { Files.readString(path) }.getOrNull()
private fun nonEmpty(path: Path, exists: Boolean): ContractAssertionVerdict {
val text = readOrNull(path, exists) ?: return verdict(false, "file does not exist")
return verdict(text.isNotBlank(), if (text.isNotBlank()) "non-empty" else "file is empty")
}
private fun validJson(path: Path, exists: Boolean): ContractAssertionVerdict {
val text = readOrNull(path, exists) ?: return verdict(false, "file does not exist")
val ok = runCatching { json.parseToJsonElement(text) }.isSuccess
return verdict(ok, if (ok) "valid json" else "not parseable as json")
}
private fun jsonObj(path: Path, exists: Boolean): JsonObject? =
readOrNull(path, exists)?.let { runCatching { json.parseToJsonElement(it).jsonObject }.getOrNull() }
private fun jsonKey(path: Path, exists: Boolean, key: String): ContractAssertionVerdict {
val obj = jsonObj(path, exists) ?: return verdict(false, "not a json object")
return verdict(obj.containsKey(key), if (obj.containsKey(key)) "key '$key' present" else "missing key '$key'")
}
private fun scriptDefined(path: Path, exists: Boolean, name: String): ContractAssertionVerdict {
val scripts = jsonObj(path, exists)?.get("scripts")?.let { runCatching { it.jsonObject }.getOrNull() }
?: return verdict(false, "no scripts block")
return verdict(scripts.containsKey(name), if (scripts.containsKey(name)) "script '$name' defined" else "missing script '$name'")
}
private fun contains(path: Path, exists: Boolean, pattern: String): ContractAssertionVerdict {
val text = readOrNull(path, exists) ?: return verdict(false, "file does not exist")
return verdict(text.contains(pattern), if (text.contains(pattern)) "contains '$pattern'" else "missing '$pattern'")
}
private fun textMatch(path: Path, exists: Boolean, needle: String, failMsg: String): ContractAssertionVerdict {
val text = readOrNull(path, exists) ?: return verdict(false, "file does not exist")
return verdict(text.contains(needle), if (text.contains(needle)) "found '$needle'" else failMsg)
}
private fun exportsHook(path: Path, exists: Boolean): ContractAssertionVerdict {
val text = readOrNull(path, exists) ?: return verdict(false, "file does not exist")
val ok = HOOK_EXPORT.containsMatchIn(text)
return verdict(ok, if (ok) "exports a use* function" else "no exported use* hook found")
}
private fun migrationPresent(target: String, exists: Boolean): ContractAssertionVerdict {
val name = target.substringAfterLast('/')
val ok = exists && name.firstOrNull()?.isDigit() == true
return verdict(ok, if (ok) "sequential migration file present" else "no sequential-id migration file")
}
private fun verdict(passed: Boolean, evidence: String) = ContractAssertionVerdict(passed, evidence)
private fun skip(reason: String) = ContractAssertionVerdict(passed = true, evidence = reason, skipped = true)
private companion object {
val HOOK_EXPORT = Regex("""export\s+(?:default\s+)?(?:async\s+)?(?:function|const)\s+use[A-Z]""")
}
}
@@ -31,4 +31,7 @@ data class OrchestratorEngines(
// Runs operator-configured static-analysis commands as a harness step (role-reliability §5).
// Null = no static-first step; a stage declaring `static_analysis` then no-ops with a warning.
val staticAnalysisRunner: StaticAnalysisRunner? = null,
// Evaluates Gate 2 contract assertions against a stage's produced files (design §1). Null = no
// contract gate; the deterministic funnel then rests on produces-presence + static analysis only.
val contractAssertionEvaluator: ContractAssertionEvaluator? = null,
)
@@ -183,6 +183,7 @@ class ReplayOrchestrator(
responseFormat: ResponseFormat,
effectives: RunEffectives,
withTools: Boolean,
forceWriteOnly: Boolean,
): InferenceResult = when (strategy) {
is ReplayStrategy.SkipInference -> {
// bypass router entirely — use recorded artifact
@@ -213,6 +214,7 @@ class ReplayOrchestrator(
}
else -> super.runInference(
sessionId, stageId, contextPack, stageConfig, timeoutMs, responseFormat, effectives, withTools,
forceWriteOnly,
)
}
@@ -14,6 +14,8 @@ import com.correx.core.approvals.model.ApprovalScopeIdentity
import com.correx.core.approvals.model.DomainApprovalRequest
import com.correx.core.artifacts.ArtifactState
import com.correx.core.artifacts.kind.ArtifactKindRegistry
import com.correx.core.artifacts.kind.KindContractTable
import com.correx.core.artifacts.kind.KindInference
import com.correx.core.artifacts.kind.JsonSchema
import com.correx.core.artifacts.kind.FileWrittenArtifact
import com.correx.core.artifacts.kind.ProcessResultArtifact
@@ -32,6 +34,8 @@ import com.correx.core.events.events.RefinementIterationEvent
import com.correx.core.events.events.ApprovalRequestedEvent
import com.correx.core.events.events.ArtifactContentStoredEvent
import com.correx.core.events.events.FileWrittenEvent
import com.correx.core.events.events.ContractGateEvaluatedEvent
import com.correx.core.events.events.ContractAssertionResult
import com.correx.core.events.events.ArtifactCreatedEvent
import com.correx.core.events.events.ArtifactRepairAttemptedEvent
import com.correx.core.events.events.ArtifactRepairFailedEvent
@@ -165,6 +169,13 @@ import java.util.concurrent.atomic.*
import kotlin.coroutines.cancellation.CancellationException
private const val MAX_TOOL_ROUNDS = 10
// Consecutive read-only tool rounds (no file_write/file_edit) that still owe a file_written
// artifact before we force the write nudge. A model that keeps calling read tools every round
// trips neither the prose nudge nor the stage_complete nudge, so without this it silently burns
// every round reading and never writes (2026-07-05: define_types read-looped 40 turns → failed).
private const val READ_LOOP_NUDGE_THRESHOLD = 3
private val WRITE_TOOL_NAMES = setOf("file_write", "file_edit")
private const val MAX_FEEDBACK_ISSUES = 3
private const val STAGE_COMPLETE_TOOL = "stage_complete"
private const val EMIT_ARTIFACT_TOOL = "emit_artifact"
@@ -208,6 +219,7 @@ private const val MAX_CLARIFICATION_ROUNDS = 3
// Static-analysis output caps: the tail retained in the recorded event vs. the (larger) slice fed
// back to the model so it can fix the failure. Tails, because the error summary sits at the end.
private const val CONTRACT_EVIDENCE_CAP = 400
private const val STATIC_ANALYSIS_SUMMARY_CAP = 2_000
private const val STATIC_ANALYSIS_FEEDBACK_CAP = 6_000
@@ -245,6 +257,7 @@ abstract class SessionOrchestrator(
private val workspacePolicy: WorkspacePolicy? = engines.workspacePolicy
private val worldProbe: WorldProbe = engines.worldProbe
private val staticAnalysisRunner: StaticAnalysisRunner? = engines.staticAnalysisRunner
private val contractAssertionEvaluator: ContractAssertionEvaluator? = engines.contractAssertionEvaluator
private val workspaceToolRegistryProvider: WorkspaceToolRegistryProvider? = engines.workspaceToolRegistryProvider
private val inferenceRepository: InferenceRepository = repositories.inferenceRepository
internal val orchestrationRepository: OrchestrationRepository = repositories.orchestrationRepository
@@ -462,8 +475,12 @@ abstract class SessionOrchestrator(
} ?: emptyList()
val projectProfileEntries = session.state.boundProjectProfile
?.let { listOf(buildProjectProfileEntry(it)) } ?: emptyList()
val agentInstructionsEntries = session.state.boundAgentInstructions
?.let { listOf(buildAgentInstructionsEntry(it)) } ?: emptyList()
// AGENTS.md injection is disabled for now. The workspace-root AGENTS.md (the DOX framework)
// carries a mandatory "Read Before Editing" walk — "read the whole AGENTS.md chain, do not
// rely on memory, re-read before editing" — which drives a small model into an endless
// read-loop that never writes (2026-07-05 root cause). Reincorporating DOX properly (scoped
// to the stage's subproject, not the repo root; not read-mandating) is deferred.
val agentInstructionsEntries = emptyList<ContextEntry>()
val retryFeedbackEntries = buildRetryFeedbackEntry(sessionEvents, stageId)
?.let { listOf(it) } ?: emptyList()
val vocabularyEntries = artifactKindRegistry
@@ -517,6 +534,7 @@ abstract class SessionOrchestrator(
sessionId, stageId, currentContext, stageConfig, config.stageTimeoutMs, responseFormat, effectives,
)
var toolRounds = 0
var consecutiveReadOnlyRounds = 0
// Set when the model produces its artifact via the emit_artifact tool instead of a final
// JSON message; overrides the post-loop capture of the (then-empty) assistant text.
var llmArtifactOverride: String? = null
@@ -548,7 +566,7 @@ abstract class SessionOrchestrator(
// Append a corrective tool-result and re-run inference (bounded by MAX_TOOL_ROUNDS).
// Returns the new result rather than mutating inferenceResult, to preserve smart casts.
suspend fun pushBack(nudge: String): InferenceResult {
suspend fun pushBack(nudge: String, forceWriteOnly: Boolean = false): InferenceResult {
accumulatedEntries = accumulatedEntries + ContextEntry(
id = ContextEntryId(UUID.randomUUID().toString()),
layer = ContextLayer.L2,
@@ -569,6 +587,7 @@ abstract class SessionOrchestrator(
toolRounds++
return runInference(
sessionId, stageId, currentContext, stageConfig, config.stageTimeoutMs, responseFormat, effectives,
forceWriteOnly = forceWriteOnly,
)
}
@@ -576,6 +595,10 @@ abstract class SessionOrchestrator(
"file_edit or file_write tool to write the change to the file before finishing — do " +
"not output the file contents as a message."
val readLoopNudge = "STOP reading. You have read enough files and this stage has produced " +
"nothing yet. You MUST now call the file_write tool to create the required file(s) for " +
"this stage. Do not call file_read or list_dir again before you have written a file."
while (
inferenceResult is InferenceResult.Success &&
toolRounds < MAX_TOOL_ROUNDS &&
@@ -640,6 +663,23 @@ abstract class SessionOrchestrator(
// loop as tool-result context so the model can see the error and adapt (bounded by
// MAX_TOOL_ROUNDS). Only FATAL: failures (handled above) abort the stage.
accumulatedEntries = accumulatedEntries + toolEntries
// Read-loop breaker: this round called only read-only tools yet the stage still owes a
// file_written artifact. Left alone the model keeps reading until MAX_TOOL_ROUNDS and
// never writes (F-018 nudges only cover a prose turn or a premature stage_complete, not
// an endless read-loop). After READ_LOOP_NUDGE_THRESHOLD such rounds, force the write
// nudge; a real write resets the counter so multi-file stages are unaffected.
if (owesFileWrite() &&
inferenceResult.response.toolCalls.none { it.function.name in WRITE_TOOL_NAMES }
) {
consecutiveReadOnlyRounds++
if (consecutiveReadOnlyRounds >= READ_LOOP_NUDGE_THRESHOLD) {
consecutiveReadOnlyRounds = 0
inferenceResult = pushBack(readLoopNudge, forceWriteOnly = true)
continue
}
} else {
consecutiveReadOnlyRounds = 0
}
currentContext = contextPackBuilder.build(
id = ContextPackId(UUID.randomUUID().toString()),
sessionId = sessionId,
@@ -752,7 +792,10 @@ abstract class SessionOrchestrator(
is StageExecutionResult.Success -> {
emitToolArtifacts(sessionId, stageId, stageConfig)
emitLlmArtifacts(sessionId, stageId, stageConfig)
runPostStageGates(sessionId, stageId, stageConfig, effectives)
runPostStageGates(
sessionId, stageId, stageConfig, effectives,
session.state.boundProjectProfile?.commands ?: emptyMap(),
)
}
is StageExecutionResult.Failure -> outcome
@@ -1617,13 +1660,19 @@ abstract class SessionOrchestrator(
* Zero-score hits carry no ranking signal (a noop embedder scores everything 0.0), so injecting
* them just feeds the agent arbitrary files and invites hallucinated paths. Drop them; when nothing
* useful survives, fall back to the deterministic repo map so the stage still has real grounding.
*
* Markdown/docs hits are gated exactly like the repo-map floor (buildRepoMapEntries): with the
* embedder live, semantic similarity happily ranks churny kernel docs (epics, ADRs, QA plans)
* above real source for a code task, re-poisoning the context the floor's gate was added to fix
* (2026-07-05 root cause). They only survive when the stage prompt explicitly asks about docs.
*/
private suspend fun repoEntriesOrMapFloor(
sessionId: SessionId,
hits: List<RepoKnowledgeHit>,
stagePrompt: String,
): List<ContextEntry> {
val useful = hits.filter { it.score > 0f }
val docsWanted = DOCS_REQUEST_REGEX.containsMatchIn(stagePrompt)
val useful = hits.filter { it.score > 0f && (docsWanted || !isDocPath(it.path)) }
return if (useful.isEmpty()) {
buildRepoMapEntries(sessionId, stagePrompt)
} else {
@@ -2033,6 +2082,7 @@ abstract class SessionOrchestrator(
stageId: StageId,
stageConfig: StageConfig,
effectives: RunEffectives,
profileCommands: Map<String, String>,
): StageExecutionResult {
val produced = verifyProduces(sessionId, stageId, stageConfig)
if (produced is StageExecutionResult.Failure) return produced
@@ -2042,7 +2092,9 @@ abstract class SessionOrchestrator(
val gates: List<suspend () -> StageExecutionResult> = listOf(
{ groundBriefReferences(sessionId, stageId, stageConfig, effectives) },
{ checkBriefEcho(sessionId, stageId, stageConfig) },
{ runContractGate(sessionId, stageId, stageConfig, effectives) },
{ runStaticAnalysis(sessionId, stageId, stageConfig, effectives) },
{ runExecutionGate(sessionId, stageId, stageConfig, effectives, profileCommands) },
)
for (gate in gates) {
val result = gate()
@@ -2051,6 +2103,83 @@ abstract class SessionOrchestrator(
return StageExecutionResult.Success(emptyList())
}
/**
* Gate 2 — contract (design 2026-07-06-staged-verification-and-review.md §1). For a stage that
* produces file_written artifacts, project the files it actually wrote (Option B: runtime
* manifest), infer each file's kind from its path, derive the [KindContractTable] assertions
* (universal floor for unknown kinds), and evaluate the FS/TEXT ones deterministically. Every
* verdict is recorded in a [ContractGateEvaluatedEvent] (invariant #9), so replay reads it back
* and never re-inspects the filesystem.
*
* A failed assertion fails the stage retryably with the failing assertion ids + evidence fed
* back verbatim — failing-test-name granular hand-back, so the implementer fixes exactly the
* empty/missing/malformed file rather than read-looping. COMPILER assertions are recorded as
* skipped here; the static-analysis command gate is their authoritative check.
*/
private suspend fun runContractGate(
sessionId: SessionId,
stageId: StageId,
stageConfig: StageConfig,
effectives: RunEffectives,
): StageExecutionResult {
val evaluator = contractAssertionEvaluator ?: return StageExecutionResult.Success(emptyList())
val workspaceRoot = effectives.policy?.workspaceRoot ?: return StageExecutionResult.Success(emptyList())
if (stageConfig.produces.none { it.kind.id == "file_written" }) return StageExecutionResult.Success(emptyList())
// Option A B: the files the stage actually wrote (runtime manifest) unioned with the
// concrete files the plan declared it would produce (expectedFiles). A declared file that
// was never written survives here and fails file_exists — that is the missing-file catch.
val paths = (stageWrittenPaths(sessionId, stageId) + stageConfig.expectedFiles).distinct()
if (paths.isEmpty()) return StageExecutionResult.Success(emptyList())
val assertions = paths.flatMap { path ->
KindContractTable.assertionsFor(KindInference.kindFor(path) ?: "", path)
}
val results = assertions.map { a ->
val v = contractAssertionEvaluator.evaluate(workspaceRoot, a)
ContractAssertionResult(
assertionId = a.id,
target = a.target,
layer = a.layer.name,
evaluator = a.evaluator.name,
passed = v.passed,
evidence = v.evidence.takeLast(CONTRACT_EVIDENCE_CAP),
)
}
emit(sessionId, ContractGateEvaluatedEvent(sessionId, stageId, results))
val failures = results.filterNot { it.passed }
if (failures.isEmpty()) return StageExecutionResult.Success(emptyList())
log.warn(
"[Orchestrator] contract gate failed session={} stage={} assertions={}",
sessionId.value, stageId.value,
failures.joinToString(", ") { "${it.assertionId}(${it.target})" },
)
val detail = failures.joinToString("\n") { "- ${it.assertionId} on ${it.target}: ${it.evidence}" }
return StageExecutionResult.Failure(
"stage ${stageId.value} did not satisfy its file contract. Fix these before review:\n$detail",
retryable = true,
)
}
/**
* The workspace-relative paths this stage actually wrote, projected from events (invariant #1):
* ToolInvocationRequested → this stage's invocation ids, FileWrittenEvent → the paths written
* under them. Pure projection, replay-safe.
*/
private fun stageWrittenPaths(sessionId: SessionId, stageId: StageId): List<String> {
val events = eventStore.read(sessionId)
val invocationIds = events.mapNotNull { it.payload as? ToolInvocationRequestedEvent }
.filter { it.stageId == stageId }
.map { it.invocationId }
.toSet()
return events.mapNotNull { it.payload as? FileWrittenEvent }
.filter { it.invocationId in invocationIds && it.postImageHash != null }
.map { it.path }
.distinct()
}
/**
* Static-first reviewer gate (role-reliability §5): for a stage that declares `static_analysis`
* commands, run them (compiler / detekt / formatters) against its just-produced output in the
@@ -2108,6 +2237,53 @@ abstract class SessionOrchestrator(
)
}
/**
* Gate 4 — execution (staged-verification §1/§2). A stage's [StageConfig.buildExpectation]
* (none/module/project/tests) resolves to the bound project profile's typecheck/build/test
* command and runs it as a deterministic build gate. NONE runs nothing (early stages where the
* project isn't yet runnable). A profile missing the alias skips with a warning — the vocabulary
* degrades safely rather than failing when the operator hasn't configured commands. A non-clean
* run fails the stage retryably with the build output fed back (recorded as a
* [StaticAnalysisCompletedEvent], invariant #9, so replay never re-runs it).
*/
private suspend fun runExecutionGate(
sessionId: SessionId,
stageId: StageId,
stageConfig: StageConfig,
effectives: RunEffectives,
profileCommands: Map<String, String>,
): StageExecutionResult {
val alias = stageConfig.buildExpectation.commandAlias ?: return StageExecutionResult.Success(emptyList())
val runner = staticAnalysisRunner
val workspaceRoot = effectives.policy?.workspaceRoot
if (runner == null || workspaceRoot == null) return StageExecutionResult.Success(emptyList())
val command = profileCommands[alias]
if (command.isNullOrBlank()) {
log.warn(
"[Orchestrator] stage {} declares build_expectation={} but project profile has no '{}' " +
"command — skipping execution gate",
stageId.value, stageConfig.buildExpectation, alias,
)
return StageExecutionResult.Success(emptyList())
}
val run = runner.run(workspaceRoot, command)
val finding = StaticAnalysisFinding(command, run.exitCode, run.output.takeLast(STATIC_ANALYSIS_SUMMARY_CAP))
emit(sessionId, StaticAnalysisCompletedEvent(sessionId, stageId, listOf(finding)))
if (finding.clean) return StageExecutionResult.Success(emptyList())
log.warn(
"[Orchestrator] execution gate failed session={} stage={} expectation={} command={}",
sessionId.value, stageId.value, stageConfig.buildExpectation, command,
)
return StageExecutionResult.Failure(
"stage ${stageId.value} did not pass its ${stageConfig.buildExpectation} build gate. " +
"Fix these before proceeding:\n\n$ $command (exit ${run.exitCode})\n" +
run.output.takeLast(STATIC_ANALYSIS_FEEDBACK_CAP),
retryable = true,
)
}
private suspend fun emitProcessResultEvents(
sessionId: SessionId,
stageId: StageId,
@@ -2185,6 +2361,11 @@ abstract class SessionOrchestrator(
// into the artifact text; a tools-less call yields clean JSON deterministically (and lets the
// JSON grammar constraint re-apply, since grammar+tools is rejected by llama.cpp).
withTools: Boolean = true,
// When true, offer ONLY write tools (+ stage_complete / emit_artifact) — read tools are
// stripped. Used to break a read-loop: a model that owes a file_written artifact but keeps
// calling read tools ignores a textual nudge (its output all goes to reasoning_content), so
// we structurally remove the option to read and leave writing as the only move.
forceWriteOnly: Boolean = false,
): InferenceResult {
// A stage that grants tools needs a tool-calling model, so request ToolCalling on top of any
// declared capabilities — the capability-aware strategy then ranks eligible providers by their
@@ -2213,6 +2394,11 @@ abstract class SessionOrchestrator(
// ponytail: filter write tools while read-before-write block is active; restored once a read completes
!isReadOnlyMode(sessionId) || ToolCapability.FILE_WRITE !in tool.requiredCapabilities
}
.filter { tool ->
// Read-loop break: keep only write tools (drop file_read/list_dir/shell) so
// the model cannot keep reading and must write or complete.
!forceWriteOnly || ToolCapability.FILE_WRITE in tool.requiredCapabilities
}
.map { tool ->
ToolDefinition(
function = ToolFunction(
@@ -0,0 +1,104 @@
package com.correx.core.kernel.orchestration
import com.correx.core.artifacts.kind.AssertionEvaluator
import com.correx.core.artifacts.kind.ContractAssertion
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.nio.file.Files
import java.nio.file.Path
import kotlin.io.path.createDirectories
import kotlin.io.path.writeText
class FileSystemContractEvaluatorTest {
private val evaluator = FileSystemContractEvaluator()
private fun workspace(build: (Path) -> Unit): Path {
val root = Files.createTempDirectory("contract-gate")
build(root)
return root
}
private fun assertion(id: String, target: String, ev: AssertionEvaluator, args: Map<String, String> = emptyMap()) =
ContractAssertion(id, target, ev, args = args)
@Test
fun `empty produced file fails file_nonempty`() = runBlocking {
val root = workspace { r ->
r.resolve("src/views").createDirectories()
r.resolve("src/views/Sessions.tsx").writeText("")
}
val v = evaluator.evaluate(root, assertion("file_nonempty", "src/views/Sessions.tsx", AssertionEvaluator.FS))
assertFalse(v.passed, "empty Sessions.tsx must fail file_nonempty")
}
@Test
fun `missing file fails file_exists`() = runBlocking {
val root = workspace { }
val v = evaluator.evaluate(root, assertion("file_exists", "src/App.tsx", AssertionEvaluator.FS))
assertFalse(v.passed, "missing App.tsx must fail file_exists")
}
@Test
fun `hook file without a use-export fails exports_hook`() = runBlocking {
val root = workspace { r ->
r.resolve("src/hooks").createDirectories()
r.resolve("src/hooks/useWebSocket.ts").writeText("export const connect = () => {}")
}
val v = evaluator.evaluate(root, assertion("exports_hook", "src/hooks/useWebSocket.ts", AssertionEvaluator.AST))
assertFalse(v.passed)
}
@Test
fun `real hook export passes`() = runBlocking {
val root = workspace { r ->
r.resolve("src/hooks").createDirectories()
r.resolve("src/hooks/useWebSocket.ts").writeText("export const useWebSocket = () => {}")
}
val v = evaluator.evaluate(root, assertion("exports_hook", "src/hooks/useWebSocket.ts", AssertionEvaluator.AST))
assertTrue(v.passed)
}
@Test
fun `package_json missing build script fails script_defined`() = runBlocking {
val root = workspace { r ->
r.resolve("package.json").writeText("""{"name":"x","scripts":{"dev":"vite"}}""")
}
val v = evaluator.evaluate(
root,
assertion("script_defined", "package.json", AssertionEvaluator.TEXT, mapOf("name" to "build")),
)
assertFalse(v.passed)
}
@Test
fun `tsconfig with comments and trailing commas passes valid_json (JSONC)`() = runBlocking {
// Regression: tsconfig.json is JSONC — tsc/Vite accept comments + trailing commas. A strict
// valid_json false-failed a legitimate config and wasted a retry.
val root = workspace { r ->
r.resolve("tsconfig.json").writeText(
"""
{
// compiler options for the app
"compilerOptions": {
"strict": true,
"jsx": "react-jsx",
},
}
""".trimIndent(),
)
}
val v = evaluator.evaluate(root, assertion("valid_json", "tsconfig.json", AssertionEvaluator.TEXT))
assertTrue(v.passed, "tsconfig JSONC must pass valid_json")
}
@Test
fun `compiler assertions are skipped, not failed`() = runBlocking {
val root = workspace { }
val v = evaluator.evaluate(root, assertion("parses", "whatever.ts", AssertionEvaluator.COMPILER))
assertTrue(v.skipped)
assertTrue(v.passed, "a skipped assertion must not fail the stage")
}
}
@@ -0,0 +1,32 @@
package com.correx.core.transitions.graph
/**
* Per-stage execution-gate scope (staged-verification design §2) — the closed set a planner picks
* from to say how far the project should be runnable after this stage. Resolves at runtime to a
* concrete command drawn from the bound project profile's command aliases, so the command itself
* stays stack-specific and operator-owned while the *scope* is a fixed vocabulary the planner can't
* invent.
*
* [commandAlias] names the `[project.commands]` alias to run for this scope (null = no gate):
* NONE → nothing (early stages where the project is not yet runnable), MODULE → typecheck,
* PROJECT → build, TESTS → test. A stage whose profile lacks the alias skips the gate with a warning
* rather than failing — the vocabulary degrades safely when the operator hasn't configured commands.
*/
enum class BuildExpectation(val commandAlias: String?) {
NONE(null),
MODULE("typecheck"),
PROJECT("build"),
TESTS("test"),
;
companion object {
/** Parses a plan's `build_expectation` string; null for an unrecognized value so the compiler rejects it. */
fun fromPlan(raw: String?): BuildExpectation? = when (raw?.trim()?.lowercase()) {
null, "", "none" -> NONE
"module" -> MODULE
"project" -> PROJECT
"tests", "test" -> TESTS
else -> null
}
}
}
@@ -28,5 +28,15 @@ data class StageConfig(
// A non-clean command fails the stage retryably with its output fed back, so only
// static-clean output reaches the downstream reviewer. Empty = no static-first step.
val staticAnalysis: List<String> = emptyList(),
// Execution-gate scope for this stage (staged-verification §2). NONE = no build gate (default,
// e.g. early scaffold/types stages); MODULE/PROJECT/TESTS resolve to the bound project profile's
// typecheck/build/test command and run it as a deterministic Gate 4 after the stage produces.
val buildExpectation: BuildExpectation = BuildExpectation.NONE,
// Concrete (non-glob) workspace-relative paths this stage declares it will produce (Gate 2
// Option A, staged-verification §3.2). The contract gate checks each declared file exists and
// satisfies its inferred-kind contract, so a file the plan promised but the stage never wrote
// FAILS the gate — catching missing files, which the runtime write-manifest alone (it only
// inspects files that WERE written) cannot. Derived from the concrete entries of PlanStage.writes.
val expectedFiles: List<String> = emptyList(),
val metadata: Map<String, String> = emptyMap(),
)
@@ -5,6 +5,7 @@ import com.correx.core.artifacts.kind.TypedArtifactSlot
import com.correx.core.events.types.ArtifactId
import com.correx.core.events.types.StageId
import com.correx.core.events.types.TransitionId
import com.correx.core.transitions.graph.BuildExpectation
import com.correx.core.transitions.graph.StageConfig
import com.correx.core.transitions.graph.TransitionEdge
import com.correx.core.transitions.graph.WorkflowGraph
@@ -58,11 +59,21 @@ class ExecutionPlanCompiler(
staticManifest = emptyList(),
planDerived = PlanDerivedManifest.deriveAllowedWrites(s),
).sorted()
val buildExpectation = BuildExpectation.fromPlan(s.buildExpectation)
?: throw WorkflowValidationException(
"stage '${s.id}' has invalid build_expectation '${s.buildExpectation}' — " +
"use one of none|module|project|tests",
)
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,
// Option A: the concrete (non-glob) subset of the declared writes are expected files
// the contract gate will require to exist. Globs (src/**) are write-permission scopes,
// not existence guarantees, so they are excluded here.
expectedFiles = s.writes.map { it.trim() }.filter { it.isNotEmpty() && !isGlob(it) },
buildExpectation = buildExpectation,
tokenBudget = DEFAULT_STAGE_TOKEN_BUDGET,
metadata = mapOf("promptInline" to s.prompt),
)
@@ -134,6 +145,8 @@ class ExecutionPlanCompiler(
}
}
private fun isGlob(path: String): Boolean = path.any { it == '*' || it == '?' || it == '[' }
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
@@ -21,6 +21,10 @@ data class PlanStage(
// contribution (the static manifest, if any, still applies). The planner emits this under
// the JSON key `writes`.
val writes: List<String> = emptyList(),
// Execution-gate scope (staged-verification §2): none|module|project|tests. Absent = none. The
// planner emits this under the JSON key `build_expectation`; it resolves at runtime to the
// project profile's typecheck/build/test command run as a deterministic Gate 4.
@param:JsonProperty("build_expectation") val buildExpectation: String? = null,
)
data class PlanEdge(
@@ -322,4 +322,44 @@ class ExecutionPlanCompilerTest {
val graph = compiler.compile(plan, "unvalidated-workflow")
assertEquals(2, graph.stages.size)
}
@Test
fun `Option A - concrete declared writes become expectedFiles, globs excluded`() {
val plan = """
{
"goal": "x", "stages": [
{ "id": "impl", "prompt": "p", "produces": "patch", "tools": ["file_write"],
"writes": ["src/App.tsx", "src/hooks/**", "package.json"] }
],
"edges": [ { "from": "impl", "to": "done", "condition": { "type": "always_true" } } ]
}
""".trimIndent()
val stage = compiler.compile(plan, "wf").stages.values.single()
assertEquals(setOf("src/App.tsx", "package.json"), stage.expectedFiles.toSet())
}
@Test
fun `build_expectation is parsed into the closed-set enum`() {
val plan = validPlan.replace(
"\"tools\": [\"ShellTool\"]",
"\"tools\": [\"ShellTool\"], \"build_expectation\": \"project\"",
)
val stage = compiler.compile(plan, "wf").stages[compiler.compile(plan, "wf").start]!!
assertEquals(com.correx.core.transitions.graph.BuildExpectation.PROJECT, stage.buildExpectation)
}
@Test
fun `absent build_expectation defaults to NONE`() {
val stage = compiler.compile(validPlan, "wf").stages.values.first()
assertEquals(com.correx.core.transitions.graph.BuildExpectation.NONE, stage.buildExpectation)
}
@Test
fun `invalid build_expectation is rejected at compile time`() {
val bad = validPlan.replace(
"\"tools\": [\"ShellTool\"]",
"\"tools\": [\"ShellTool\"], \"build_expectation\": \"deploy\"",
)
assertThrows<WorkflowValidationException> { compiler.compile(bad, "wf") }
}
}