feat(kernel): brief echo-back gate (C-A1)

- BriefEcho model + BriefEchoMismatchEvent (registered + serialization test)
- BriefEchoExtractor: parses the planner's brief_echo block and the original
  brief's referenced-files/symbols/acceptance-criteria dimensions
- BriefEchoComparator: conservative diff — a dropped acceptance criterion or an
  invented file path halts; dropping a file/symbol alone does not
- checkBriefEcho gate chained into the post-stage flow after groundBriefReferences;
  opt-in via stageConfig.metadata["briefEcho"], fails the stage (retryable) on
  divergence with re-grounding feedback
- prompt/workflow activation (planner.md emitting brief_echo; role_pipeline
  metadata) left as live-QA follow-up

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-20 10:42:49 +00:00
parent 0e251d6083
commit 1df7af5b85
8 changed files with 388 additions and 1 deletions
@@ -0,0 +1,38 @@
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
/**
* A planner's structured restatement of the brief it was handed: the files it must touch, the
* symbols it must reach, and the acceptance criteria it must satisfy. The harness diffs this echo
* against the original brief before any plan is generated, so a divergence halts at the cheapest
* point in the pipeline. Also reused as the diff input shape for both sides of the comparison.
*/
@Serializable
data class BriefEcho(
val referencedFiles: Set<String> = emptySet(),
val symbols: Set<String> = emptySet(),
val acceptanceCriteria: Set<String> = emptySet(),
)
/**
* A stage echoed the brief but the echo diverged from the original, so the plan is rejected
* before generation ("halt before plan generation"). Recorded so replay explains the halt.
*
* - [droppedFiles] / [droppedSymbols] / [droppedAcceptanceCriteria]: present in the original brief,
* missing from the echo — the planner failed to carry them forward.
* - [inventedFiles]: file paths in the echo that are not in the original brief — hallucinated paths.
*/
@Serializable
@SerialName("BriefEchoMismatch")
data class BriefEchoMismatchEvent(
val sessionId: SessionId,
val stageId: StageId,
val droppedFiles: Set<String>,
val droppedSymbols: Set<String>,
val droppedAcceptanceCriteria: Set<String>,
val inventedFiles: Set<String>,
) : EventPayload
@@ -8,6 +8,7 @@ import com.correx.core.events.events.ArtifactContentStoredEvent
import com.correx.core.events.events.ArtifactCreatedEvent import com.correx.core.events.events.ArtifactCreatedEvent
import com.correx.core.events.events.ArtifactValidatedEvent import com.correx.core.events.events.ArtifactValidatedEvent
import com.correx.core.events.events.ArtifactValidatingEvent 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.BriefGroundingCheckedEvent
import com.correx.core.events.events.ChatSessionStartedEvent import com.correx.core.events.events.ChatSessionStartedEvent
import com.correx.core.events.events.ClarificationAnsweredEvent import com.correx.core.events.events.ClarificationAnsweredEvent
@@ -109,6 +110,7 @@ val eventModule = SerializersModule {
subclass(WorkspaceStateObservedEvent::class) subclass(WorkspaceStateObservedEvent::class)
subclass(RepoKnowledgeRetrievedEvent::class) subclass(RepoKnowledgeRetrievedEvent::class)
subclass(BriefGroundingCheckedEvent::class) subclass(BriefGroundingCheckedEvent::class)
subclass(BriefEchoMismatchEvent::class)
subclass(RiskAssessedEvent::class) subclass(RiskAssessedEvent::class)
subclass(ChatSessionStartedEvent::class) subclass(ChatSessionStartedEvent::class)
subclass(ChatTurnEvent::class) subclass(ChatTurnEvent::class)
@@ -0,0 +1,28 @@
package com.correx.core.events.serialization
import com.correx.core.events.events.BriefEchoMismatchEvent
import com.correx.core.events.events.EventPayload
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class BriefEchoEventSerializationTest {
@Test
fun `BriefEchoMismatchEvent round-trips as polymorphic EventPayload`() {
val sample: EventPayload = BriefEchoMismatchEvent(
sessionId = SessionId("s"),
stageId = StageId("plan"),
droppedFiles = setOf("core/kernel/Foo.kt"),
droppedSymbols = setOf("Foo"),
droppedAcceptanceCriteria = setOf("must compile"),
inventedFiles = setOf("core/kernel/Bar.kt"),
)
val encoded = eventJson.encodeToString(EventPayload.serializer(), sample)
assertTrue(encoded.contains("\"type\":\"BriefEchoMismatch\""), "SerialName must be present: $encoded")
val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded)
assertEquals(sample, decoded)
}
}
@@ -0,0 +1,59 @@
package com.correx.core.kernel.orchestration
import com.correx.core.events.events.BriefEcho
/**
* The divergence between an original brief and a planner's echo of it.
*
* - [droppedFiles] / [droppedSymbols] / [droppedAcceptanceCriteria]: present in the original brief,
* missing from the echo — the planner failed to carry them forward.
* - [inventedFiles]: file paths in the echo not in the original — hallucinated paths.
*
* [hasMismatch] is conservative: see [BriefEchoComparator.compare].
*/
internal data class BriefEchoDiff(
val droppedFiles: Set<String>,
val droppedSymbols: Set<String>,
val droppedAcceptanceCriteria: Set<String>,
val inventedFiles: Set<String>,
) {
/**
* A mismatch that should halt the stage. Only dropping a *required acceptance criterion* or
* *inventing a file path* qualifies: those are correctness-bearing. Merely dropping a file or
* symbol the plan may legitimately not echo is tolerated and does NOT halt.
*/
val hasMismatch: Boolean
get() = droppedAcceptanceCriteria.isNotEmpty() || inventedFiles.isNotEmpty()
}
/**
* Diffs a planner's [BriefEcho] against the original brief's. Pure and deterministic.
*
* Diff semantics:
* - `dropped* = original.X - echo.X` (the planner failed to carry something forward).
* - `inventedFiles = echo.referencedFiles - original.referencedFiles` (hallucinated paths).
*
* Comparison is case-insensitive and trimmed for acceptance criteria and symbols (free-text the
* model may rephrase in casing/whitespace), and exact for file paths (a path is a path).
*/
internal object BriefEchoComparator {
fun compare(original: BriefEcho, echo: BriefEcho): BriefEchoDiff {
val droppedFiles = original.referencedFiles - echo.referencedFiles
val inventedFiles = echo.referencedFiles - original.referencedFiles
return BriefEchoDiff(
droppedFiles = droppedFiles,
droppedSymbols = normalizedDrop(original.symbols, echo.symbols),
droppedAcceptanceCriteria = normalizedDrop(original.acceptanceCriteria, echo.acceptanceCriteria),
inventedFiles = inventedFiles,
)
}
/** Original entries whose normalized form is absent from the echo, returned in original form. */
private fun normalizedDrop(original: Set<String>, echo: Set<String>): Set<String> {
val echoKeys = echo.mapTo(HashSet()) { it.normalize() }
return original.filterTo(LinkedHashSet()) { it.normalize() !in echoKeys }
}
private fun String.normalize(): String = trim().lowercase()
}
@@ -0,0 +1,61 @@
package com.correx.core.kernel.orchestration
import com.correx.core.events.events.BriefEcho
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
/**
* Extracts the structured [BriefEcho] from two artifact shapes the echo gate compares:
*
* - [fromEcho]: a planner's produced artifact, which restates the brief under a `brief_echo` key:
* `{"brief_echo": {"referenced_files": [...], "symbols": [...], "acceptance_criteria": [...]}}`.
* - [fromOriginalBrief]: the original (analyst) brief artifact, whose three dimensions are pulled
* best-effort — referenced files via [BriefReferenceExtractor], plus optional `acceptance_criteria`
* and `symbols` arrays.
*
* Both are deterministic and tolerant of missing keys, parsing with kotlinx-serialization Json
* like [BriefReferenceExtractor].
*/
internal object BriefEchoExtractor {
private const val BRIEF_ECHO_KEY = "brief_echo"
private const val REFERENCED_FILES_KEY = "referenced_files"
private const val SYMBOLS_KEY = "symbols"
private const val ACCEPTANCE_CRITERIA_KEY = "acceptance_criteria"
/** Returns the `brief_echo` block as a [BriefEcho], or null if the artifact has no such key. */
fun fromEcho(artifactJson: String): BriefEcho? {
val root = artifactJson.parseObjectOrNull() ?: return null
val echo = root[BRIEF_ECHO_KEY] as? JsonObject ?: return null
return BriefEcho(
referencedFiles = echo.stringSet(REFERENCED_FILES_KEY),
symbols = echo.stringSet(SYMBOLS_KEY),
acceptanceCriteria = echo.stringSet(ACCEPTANCE_CRITERIA_KEY),
)
}
/** The same three dimensions pulled from the original brief artifact, best-effort. */
fun fromOriginalBrief(artifactJson: String): BriefEcho {
val root = artifactJson.parseObjectOrNull()
return BriefEcho(
referencedFiles = BriefReferenceExtractor.fileReferences(artifactJson).toSet(),
symbols = root?.stringSet(SYMBOLS_KEY) ?: emptySet(),
acceptanceCriteria = root?.stringSet(ACCEPTANCE_CRITERIA_KEY) ?: emptySet(),
)
}
private fun String.parseObjectOrNull(): JsonObject? =
runCatching { Json.parseToJsonElement(this) }.getOrNull() as? JsonObject
/** Non-blank string leaves of the named array, in order; empty when the key is absent/non-array. */
private fun JsonObject.stringSet(key: String): Set<String> {
val array = this[key] as? JsonArray ?: return emptySet()
return array.mapNotNullTo(LinkedHashSet()) { it.stringOrNull()?.takeIf(String::isNotBlank) }
}
private fun JsonElement.stringOrNull(): String? =
(this as? JsonPrimitive)?.takeIf { it.isString }?.content
}
@@ -28,6 +28,7 @@ import com.correx.core.events.events.RefinementIterationEvent
import com.correx.core.events.events.ApprovalRequestedEvent import com.correx.core.events.events.ApprovalRequestedEvent
import com.correx.core.events.events.ArtifactContentStoredEvent import com.correx.core.events.events.ArtifactContentStoredEvent
import com.correx.core.events.events.ArtifactCreatedEvent import com.correx.core.events.events.ArtifactCreatedEvent
import com.correx.core.events.events.BriefEchoMismatchEvent
import com.correx.core.events.events.BriefGroundingCheckedEvent import com.correx.core.events.events.BriefGroundingCheckedEvent
import com.correx.core.events.events.ContextTruncatedEvent import com.correx.core.events.events.ContextTruncatedEvent
import com.correx.core.events.events.ClarificationAnswer import com.correx.core.events.events.ClarificationAnswer
@@ -550,7 +551,11 @@ abstract class SessionOrchestrator(
emitLlmArtifacts(sessionId, stageId, stageConfig) emitLlmArtifacts(sessionId, stageId, stageConfig)
when (val produced = verifyProduces(sessionId, stageId, stageConfig)) { when (val produced = verifyProduces(sessionId, stageId, stageConfig)) {
is StageExecutionResult.Success -> is StageExecutionResult.Success ->
groundBriefReferences(sessionId, stageId, stageConfig, effectives) when (val grounded = groundBriefReferences(sessionId, stageId, stageConfig, effectives)) {
is StageExecutionResult.Success ->
checkBriefEcho(sessionId, stageId, stageConfig)
is StageExecutionResult.Failure -> grounded
}
is StageExecutionResult.Failure -> produced is StageExecutionResult.Failure -> produced
} }
} }
@@ -1399,6 +1404,70 @@ abstract class SessionOrchestrator(
} }
} }
/**
* Brief echo-back gate (BACKLOG §C-A1): for a stage that opted in (`briefEcho`), the planner must
* restate the brief as a structured `brief_echo` block before any plan is generated. The echo is
* diffed against the original brief; on divergence a [BriefEchoMismatchEvent] is emitted and the
* stage fails (retryable), so the plan is never accepted — the cheapest failure point in the
* pipeline. Mismatch is conservative ([BriefEchoComparator]): a dropped acceptance criterion or an
* invented file path halts; merely dropping a file/symbol does not.
*/
private suspend fun checkBriefEcho(
sessionId: SessionId,
stageId: StageId,
stageConfig: StageConfig,
): StageExecutionResult {
if (stageConfig.metadata["briefEcho"] != "true") return StageExecutionResult.Success(emptyList())
val sourceSlot = stageConfig.metadata["briefEchoSource"] ?: "brief"
val original = artifactContentCache["${sessionId.value}:$sourceSlot"]
?: return StageExecutionResult.Success(emptyList())
val produced = stageConfig.produces
.filter { it.kind.llmEmitted }
.firstNotNullOfOrNull { artifactContentCache["${sessionId.value}:${it.name.value}"] }
?: return StageExecutionResult.Success(emptyList())
val echo = BriefEchoExtractor.fromEcho(produced)
?: return StageExecutionResult.Failure(
"stage ${stageId.value} must restate the brief as a brief_echo block before planning",
retryable = true,
)
val diff = BriefEchoComparator.compare(BriefEchoExtractor.fromOriginalBrief(original), echo)
if (!diff.hasMismatch) return StageExecutionResult.Success(emptyList())
emit(
sessionId,
BriefEchoMismatchEvent(
sessionId = sessionId,
stageId = stageId,
droppedFiles = diff.droppedFiles,
droppedSymbols = diff.droppedSymbols,
droppedAcceptanceCriteria = diff.droppedAcceptanceCriteria,
inventedFiles = diff.inventedFiles,
),
)
log.warn(
"[Orchestrator] brief echo diverged session={} stage={} droppedCriteria={} invented={}",
sessionId.value, stageId.value,
diff.droppedAcceptanceCriteria.joinToString(", "),
diff.inventedFiles.joinToString(", "),
)
return StageExecutionResult.Failure(
buildString {
append("stage ${stageId.value} brief_echo diverged from the brief.")
if (diff.droppedAcceptanceCriteria.isNotEmpty()) {
append(" Restate these acceptance criteria: ${diff.droppedAcceptanceCriteria.joinToString(", ")}.")
}
if (diff.inventedFiles.isNotEmpty()) {
append(" These files are not in the brief — do not invent paths: " +
"${diff.inventedFiles.joinToString(", ")}.")
}
},
retryable = true,
)
}
private suspend fun emitProcessResultEvents( private suspend fun emitProcessResultEvents(
sessionId: SessionId, sessionId: SessionId,
stageId: StageId, stageId: StageId,
@@ -0,0 +1,68 @@
package com.correx.core.kernel.orchestration
import com.correx.core.events.events.BriefEcho
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class BriefEchoComparatorTest {
@Test
fun `identical echo has no mismatch`() {
val brief = BriefEcho(
referencedFiles = setOf("core/kernel/Foo.kt"),
symbols = setOf("Foo"),
acceptanceCriteria = setOf("must compile"),
)
val diff = BriefEchoComparator.compare(brief, brief)
assertFalse(diff.hasMismatch)
assertTrue(diff.droppedFiles.isEmpty())
assertTrue(diff.inventedFiles.isEmpty())
}
@Test
fun `dropped acceptance criterion is a mismatch`() {
val original = BriefEcho(acceptanceCriteria = setOf("must compile", "must pass tests"))
val echo = BriefEcho(acceptanceCriteria = setOf("must compile"))
val diff = BriefEchoComparator.compare(original, echo)
assertTrue(diff.hasMismatch)
assertEquals(setOf("must pass tests"), diff.droppedAcceptanceCriteria)
}
@Test
fun `invented file is a mismatch`() {
val original = BriefEcho(referencedFiles = setOf("core/kernel/Foo.kt"))
val echo = BriefEcho(referencedFiles = setOf("core/kernel/Foo.kt", "core/kernel/Bar.kt"))
val diff = BriefEchoComparator.compare(original, echo)
assertTrue(diff.hasMismatch)
assertEquals(setOf("core/kernel/Bar.kt"), diff.inventedFiles)
}
@Test
fun `dropped file only is not a mismatch`() {
val original = BriefEcho(referencedFiles = setOf("core/kernel/Foo.kt", "core/kernel/Bar.kt"))
val echo = BriefEcho(referencedFiles = setOf("core/kernel/Foo.kt"))
val diff = BriefEchoComparator.compare(original, echo)
assertFalse(diff.hasMismatch)
assertEquals(setOf("core/kernel/Bar.kt"), diff.droppedFiles)
}
@Test
fun `acceptance criterion match is case-insensitive and trimmed`() {
val original = BriefEcho(acceptanceCriteria = setOf("Must Compile"))
val echo = BriefEcho(acceptanceCriteria = setOf(" must compile "))
val diff = BriefEchoComparator.compare(original, echo)
assertFalse(diff.hasMismatch)
assertTrue(diff.droppedAcceptanceCriteria.isEmpty())
}
@Test
fun `dropped symbol is not a mismatch but is reported`() {
val original = BriefEcho(symbols = setOf("Foo", "Bar"))
val echo = BriefEcho(symbols = setOf("foo"))
val diff = BriefEchoComparator.compare(original, echo)
assertFalse(diff.hasMismatch)
assertEquals(setOf("Bar"), diff.droppedSymbols)
}
}
@@ -0,0 +1,62 @@
package com.correx.core.kernel.orchestration
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class BriefEchoExtractorTest {
@Test
fun `parses a well-formed brief_echo block`() {
val json = """
{
"brief_echo": {
"referenced_files": ["core/kernel/Foo.kt", "core/kernel/Bar.kt"],
"symbols": ["Foo"],
"acceptance_criteria": ["must compile", "must pass tests"]
}
}
""".trimIndent()
val echo = BriefEchoExtractor.fromEcho(json)!!
assertEquals(setOf("core/kernel/Foo.kt", "core/kernel/Bar.kt"), echo.referencedFiles)
assertEquals(setOf("Foo"), echo.symbols)
assertEquals(setOf("must compile", "must pass tests"), echo.acceptanceCriteria)
}
@Test
fun `returns null when brief_echo key is absent`() {
assertNull(BriefEchoExtractor.fromEcho("""{"summary": "do the thing"}"""))
}
@Test
fun `tolerates missing dimensions in brief_echo`() {
val echo = BriefEchoExtractor.fromEcho("""{"brief_echo": {"referenced_files": ["a/b/C.kt"]}}""")!!
assertEquals(setOf("a/b/C.kt"), echo.referencedFiles)
assertTrue(echo.symbols.isEmpty())
assertTrue(echo.acceptanceCriteria.isEmpty())
}
@Test
fun `original-brief extraction pulls referenced files and acceptance criteria`() {
val json = """
{
"summary": "touch core/kernel/Foo.kt to satisfy the gate",
"acceptance_criteria": ["must compile", "must pass tests"],
"symbols": ["Foo"]
}
""".trimIndent()
val original = BriefEchoExtractor.fromOriginalBrief(json)
assertTrue(original.referencedFiles.contains("core/kernel/Foo.kt"))
assertEquals(setOf("must compile", "must pass tests"), original.acceptanceCriteria)
assertEquals(setOf("Foo"), original.symbols)
}
@Test
fun `original-brief extraction tolerates missing acceptance_criteria and symbols`() {
val original = BriefEchoExtractor.fromOriginalBrief("""{"affected": ["core/kernel/Foo.kt"]}""")
assertTrue(original.referencedFiles.contains("core/kernel/Foo.kt"))
assertTrue(original.acceptanceCriteria.isEmpty())
assertTrue(original.symbols.isEmpty())
}
}