fix(validation): resolve F-007 artifact validator CAS-hash conflation

ArtifactPayloadValidator passed the logical slot name into the
content-hash-keyed CAS store, crashing every workflow with a typed
produces slot. Validator now reads payloads from a content map threaded
through ValidationContext (populated from the orchestrator's per-session
artifact cache across all stages) and no longer depends on ArtifactStore.

Also records the slot->CAS-hash mapping as a new ArtifactContentStoredEvent
(registered for serialization), emitted at both CAS write sites, so
artifact content is recoverable from CAS by hash after a cold start.
This commit is contained in:
2026-06-08 17:26:34 +04:00
parent 3f59faaa08
commit d518400b5f
7 changed files with 206 additions and 13 deletions
@@ -22,6 +22,7 @@ import com.correx.core.context.model.TokenBudget
import com.correx.core.context.model.EntryRole
import com.correx.core.events.events.ApprovalDecisionResolvedEvent
import com.correx.core.events.events.ApprovalRequestedEvent
import com.correx.core.events.events.ArtifactContentStoredEvent
import com.correx.core.events.events.ArtifactCreatedEvent
import com.correx.core.events.events.ContextTruncatedEvent
import com.correx.core.events.events.ToolCallAssessedEvent
@@ -418,11 +419,25 @@ abstract class SessionOrchestrator(
llmEmittedSlots.firstOrNull()?.let { slot ->
artifactContentCache["${sessionId.value}:${slot.name.value}"] =
inferenceResult.response.text
inferenceResult.responseArtifactId?.let { hash ->
emit(
sessionId,
ArtifactContentStoredEvent(
artifactId = slot.name,
contentHash = hash,
sessionId = sessionId,
stageId = stageId,
),
)
}
}
val validationCtx = ValidationContext(
graph = graph,
sessionState = session.state,
availableTools = effectives.registry?.all()?.map { it.name }?.toSet()
availableTools = effectives.registry?.all()?.map { it.name }?.toSet(),
producedArtifactContent = graph.stages.values.flatMap { it.produces }.mapNotNull { slot ->
artifactContentCache["${sessionId.value}:${slot.name.value}"]?.let { slot.name.value to it }
}.toMap(),
)
when (val outcome = mapValidationOutcome(sessionId, stageId, validationCtx)) {
is StageExecutionResult.Success -> {
@@ -702,8 +717,17 @@ abstract class SessionOrchestrator(
)
}
val jsonContent = Json.encodeToString(ProcessResultArtifact.serializer(), processResult)
artifactStore.put(jsonContent.toByteArray())
val storedHash = artifactStore.put(jsonContent.toByteArray())
artifactContentCache["${sessionId.value}:${slot.name.value}"] = jsonContent
emit(
sessionId,
ArtifactContentStoredEvent(
artifactId = slot.name,
contentHash = storedHash,
sessionId = sessionId,
stageId = stageId,
),
)
}
val sourceId = toolCall.id ?: invocationId.value
@@ -1073,7 +1097,7 @@ abstract class SessionOrchestrator(
),
)
InferenceResult.Success(response)
InferenceResult.Success(response, responseArtifactId)
} catch (e: TimeoutCancellationException) {
emit(
sessionId,
@@ -1456,7 +1480,7 @@ private fun buildDiffString(path: String, existingContent: String?, proposedCont
}
internal sealed interface InferenceResult {
data class Success(val response: InferenceResponse) : InferenceResult
data class Success(val response: InferenceResponse, val responseArtifactId: ArtifactId? = null) : InferenceResult
data class Failed(val reason: String) : InferenceResult
data object Cancelled : InferenceResult
}