feat(artifacts): config-driven custom artifact kinds with schema validation

Users declare artifact kinds in [[artifacts]] (id + schema_path to a JSON-schema file + llm_emitted); ConfigArtifactKind registers them at startup via createWorkflowLoader(extraKinds). New JsonSchemaValidator validates any non-built-in kind generically against its declared deriveJsonSchema(), so an LLM-emitted custom kind is checked against its shape, never trusted. Removed the dead payloadSerializer from the ArtifactKind contract. Schema source is path-to-file (not inline TOML — the hand-rolled parser can't nest).
This commit is contained in:
2026-06-02 13:58:23 +04:00
parent b976a5c92a
commit d1c6774d05
14 changed files with 315 additions and 30 deletions
@@ -1,10 +1,7 @@
package com.correx.core.artifacts.kind
import kotlinx.serialization.KSerializer
interface ArtifactKind {
val id: String
val payloadSerializer: KSerializer<*>
fun deriveJsonSchema(): JsonSchema
val llmEmitted: Boolean
}
@@ -0,0 +1,16 @@
package com.correx.core.artifacts.kind
/**
* A user-declared artifact kind whose schema comes from configuration rather than
* a compile-time Kotlin type. The declared [schema] is authoritative: content is
* validated against it generically (see the artifact-payload validator), so an
* [llmEmitted] config kind is checked against its declared shape, never a
* hardcoded type check.
*/
data class ConfigArtifactKind(
override val id: String,
private val schema: JsonSchema,
override val llmEmitted: Boolean = false,
) : ArtifactKind {
override fun deriveJsonSchema(): JsonSchema = schema
}
@@ -1,10 +1,7 @@
package com.correx.core.artifacts.kind
import kotlinx.serialization.KSerializer
object FileWrittenKind : ArtifactKind {
override val id: String = "file_written"
override val payloadSerializer: KSerializer<*> = FileWrittenPayload.serializer()
override val llmEmitted: Boolean = false
override fun deriveJsonSchema(): JsonSchema = JsonSchema(
@@ -1,10 +1,7 @@
package com.correx.core.artifacts.kind
import kotlinx.serialization.KSerializer
object ProcessResultKind : ArtifactKind {
override val id: String = "process_result"
override val payloadSerializer: KSerializer<*> = ProcessResultArtifact.serializer()
override val llmEmitted: Boolean = false
override fun deriveJsonSchema(): JsonSchema = JsonSchema(
@@ -29,6 +29,21 @@ class ArtifactKindRegistryTest {
assertEquals(false, registry.get("process_result")!!.llmEmitted)
}
@Test
fun `config-declared kind registers and exposes its declared schema`() {
val schema = JsonSchema(
type = "object",
properties = mapOf("x" to JsonSchemaProperty(type = "string")),
required = listOf("x"),
)
registry.register(ConfigArtifactKind(id = "custom", schema = schema, llmEmitted = true))
val kind = registry.get("custom")
assertNotNull(kind)
assertEquals(true, kind!!.llmEmitted)
assertEquals(schema, kind.deriveJsonSchema())
}
@Test
fun `FileWrittenKind schema has required fields`() {
val schema = FileWrittenKind.deriveJsonSchema()
@@ -36,8 +36,21 @@ object ConfigLoader {
val sections = mutableMapOf<String, MutableMap<String, Any>>()
val providers = mutableListOf<MutableMap<String, Any>>()
val models = mutableListOf<MutableMap<String, Any>>()
val artifacts = mutableListOf<MutableMap<String, Any>>()
var currentProvider: MutableMap<String, Any>? = null
var currentModel: MutableMap<String, Any>? = null
var currentArtifact: MutableMap<String, Any>? = null
// Flush any open array-of-table entry into its list. Called whenever a new
// table header (array or section) starts, so the previous entry is committed.
fun flushTables() {
currentProvider?.let { providers.add(it) }
currentModel?.let { models.add(it) }
currentArtifact?.let { artifacts.add(it) }
currentProvider = null
currentModel = null
currentArtifact = null
}
for ((lineNum, line) in lines.withIndex()) {
val trimmed = line.trim()
@@ -47,29 +60,23 @@ object ConfigLoader {
// Skip empty lines and comments
}
trimmed == "[[providers]]" -> {
// Start a new provider entry
if (currentProvider != null) providers.add(currentProvider)
if (currentModel != null) { models.add(currentModel); currentModel = null }
flushTables()
currentProvider = mutableMapOf()
currentSection = ""
}
trimmed == "[[models]]" -> {
// Start a new model entry
if (currentModel != null) models.add(currentModel)
if (currentProvider != null) { providers.add(currentProvider); currentProvider = null }
flushTables()
currentModel = mutableMapOf()
currentSection = ""
}
trimmed == "[[artifacts]]" -> {
flushTables()
currentArtifact = mutableMapOf()
currentSection = ""
}
trimmed.startsWith("[") && !trimmed.startsWith("[[") && trimmed.endsWith("]") -> {
// Parse section headers like [server] or [tools.shell]
if (currentProvider != null) {
providers.add(currentProvider)
currentProvider = null
}
if (currentModel != null) {
models.add(currentModel)
currentModel = null
}
flushTables()
currentSection = trimmed.substring(1, trimmed.length - 1).trim()
sections.putIfAbsent(currentSection, mutableMapOf())
}
@@ -81,9 +88,14 @@ object ConfigLoader {
val valueStr = trimmed.substring(eqIndex + 1).trim()
val parsedValue = parseValue(valueStr, lineNum + 1)
// Locals so smart-casting works (the vars are captured by flushTables).
val provider = currentProvider
val model = currentModel
val artifact = currentArtifact
when {
currentProvider != null -> currentProvider[key] = parsedValue
currentModel != null -> currentModel[key] = parsedValue
provider != null -> provider[key] = parsedValue
model != null -> model[key] = parsedValue
artifact != null -> artifact[key] = parsedValue
currentSection.isNotEmpty() -> sections[currentSection]?.put(key, parsedValue)
}
}
@@ -92,10 +104,9 @@ object ConfigLoader {
}
// Don't forget the last array-of-table entry if file ends with one
if (currentProvider != null) providers.add(currentProvider)
if (currentModel != null) models.add(currentModel)
flushTables()
return buildConfig(sections, providers, models)
return buildConfig(sections, providers, models, artifacts)
}
private fun parseValue(valueStr: String, lineNum: Int): Any {
@@ -212,6 +223,7 @@ object ConfigLoader {
sections: Map<String, Map<String, Any>>,
providersList: List<Map<String, Any>> = emptyList(),
modelsList: List<Map<String, Any>> = emptyList(),
artifactsList: List<Map<String, Any>> = emptyList(),
): CorrexConfig {
val serverSection = sections["server"] ?: emptyMap()
val tuiSection = sections["tui"] ?: emptyMap()
@@ -386,6 +398,16 @@ object ConfigLoader {
port = asInt(modelsSection["port"], 10000),
)
val artifacts = artifactsList.mapNotNull { artifactMap ->
val id = asStringOrNull(artifactMap["id"]) ?: return@mapNotNull null
val schemaPath = asStringOrNull(artifactMap["schema_path"]) ?: return@mapNotNull null
ArtifactKindConfig(
id = id,
schemaPath = schemaPath,
llmEmitted = asBoolean(artifactMap["llm_emitted"], false),
)
}
return CorrexConfig(
server = server,
tui = tui,
@@ -395,6 +417,7 @@ object ConfigLoader {
router = router,
models = models,
modelsSettings = modelsSettings,
artifacts = artifacts,
)
}
@@ -12,6 +12,21 @@ data class CorrexConfig(
val router: RouterConfig = RouterConfig(),
val models: List<ModelConfig> = emptyList(),
val modelsSettings: ModelsSettings = ModelsSettings(),
val artifacts: List<ArtifactKindConfig> = emptyList(),
)
/**
* A user-declared artifact kind. [schemaPath] points to a JSON file holding the
* kind's JSON schema (object type, typed properties, required, additionalProperties).
* A path-to-file (not inline TOML) is used so schemas can nest arbitrarily and be
* parsed with a real JSON parser. [llmEmitted] kinds are validated against this
* schema, never trusted.
*/
@Serializable
data class ArtifactKindConfig(
val id: String,
val schemaPath: String,
val llmEmitted: Boolean = false,
)
@Serializable
@@ -41,6 +41,40 @@ class ConfigLoaderTest {
assertEquals("json", result.cli.defaultOutput)
}
@Test
fun `parseToml parses artifacts array-of-tables and separates them from models and sections`() {
val toml = """
[[models]]
id = "m1"
model_path = "/m1.gguf"
[[artifacts]]
id = "review_report"
schema_path = "schemas/review.json"
llm_emitted = true
[[artifacts]]
id = "plan"
schema_path = "schemas/plan.json"
[server]
port = 1234
""".trimIndent()
val parseTomlMethod = ConfigLoader::class.java.getDeclaredMethod("parseToml", String::class.java)
parseTomlMethod.isAccessible = true
val result = parseTomlMethod.invoke(ConfigLoader, toml) as CorrexConfig
assertEquals(1, result.models.size)
assertEquals(1234, result.server.port)
assertEquals(2, result.artifacts.size)
assertEquals("review_report", result.artifacts[0].id)
assertEquals("schemas/review.json", result.artifacts[0].schemaPath)
assertEquals(true, result.artifacts[0].llmEmitted)
assertEquals("plan", result.artifacts[1].id)
assertEquals(false, result.artifacts[1].llmEmitted)
}
@Test
fun `parseToml skips comments and empty lines`() {
val toml = """
@@ -42,9 +42,26 @@ class ArtifactPayloadValidator(
validateSlotAsFileWritten(slot.name, slot.kind.id, payloadStr, issues)
"process_result" ->
validateSlotAsProcessResult(slot.name, slot.kind.id, payloadStr, issues)
// Config-declared kinds: validate generically against the declared schema, so an
// LLM-emitted custom kind is checked against its shape rather than trusted.
else ->
validateSlotAgainstSchema(slot, payloadStr, issues)
}
}
private fun validateSlotAgainstSchema(
slot: TypedArtifactSlot,
payloadStr: String,
issues: MutableList<ValidationIssue>,
) {
val json = runCatching { Json.parseToJsonElement(payloadStr) }.getOrElse {
issues += decodeFailedIssue(slot.name, slot.kind.id, it.message)
return
}
JsonSchemaValidator.validate(slot.kind.deriveJsonSchema(), json)
.forEach { issues += semanticIssue(slot.name, it) }
}
private fun validateSlotAsFileWritten(
slotName: ArtifactId,
kindId: String,
@@ -0,0 +1,62 @@
package com.correx.core.validation.artifact
import com.correx.core.artifacts.kind.JsonSchema
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.longOrNull
/**
* Minimal, deterministic validator of a JSON value against a [JsonSchema]
* (the subset correx models: object type, typed properties, required keys,
* additionalProperties). Returns human-readable error strings; empty = valid.
*
* Used to validate config-declared artifact kinds against their declared schema,
* so an LLM-emitted custom kind is checked against its shape rather than trusted.
*/
object JsonSchemaValidator {
fun validate(schema: JsonSchema, value: JsonElement): List<String> =
if (schema.type == "object") {
validateObject(schema, value)
} else {
checkType(schema.type, value)?.let { listOf(it) } ?: emptyList()
}
private fun validateObject(schema: JsonSchema, value: JsonElement): List<String> {
val obj = value as? JsonObject ?: return listOf("expected object, got ${kindOf(value)}")
val errors = mutableListOf<String>()
schema.required.filterNot { it in obj }.forEach { errors += "missing required property '$it'" }
for ((key, element) in obj) {
val property = schema.properties[key]
if (property == null) {
if (!schema.additionalProperties) errors += "unknown property '$key'"
} else {
checkType(property.type, element)?.let { errors += "property '$key': $it" }
}
}
return errors
}
private fun checkType(type: String, value: JsonElement): String? {
val ok = when (type) {
"string" -> value is JsonPrimitive && value.isString
"integer" -> value is JsonPrimitive && !value.isString && value.longOrNull != null
"number" -> value is JsonPrimitive && !value.isString && value.doubleOrNull != null
"boolean" -> value is JsonPrimitive && !value.isString && value.booleanOrNull != null
"object" -> value is JsonObject
"array" -> value is JsonArray
else -> true // unknown declared type: don't enforce
}
return if (ok) null else "expected $type, got ${kindOf(value)}"
}
private fun kindOf(value: JsonElement): String = when (value) {
is JsonArray -> "array"
is JsonObject -> "object"
is JsonPrimitive -> if (value.isString) "string" else "primitive"
}
}
@@ -0,0 +1,56 @@
package com.correx.core.validation.artifact
import com.correx.core.artifacts.kind.JsonSchema
import com.correx.core.artifacts.kind.JsonSchemaProperty
import kotlinx.serialization.json.Json
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class JsonSchemaValidatorTest {
private val schema = JsonSchema(
type = "object",
properties = mapOf(
"summary" to JsonSchemaProperty(type = "string"),
"score" to JsonSchemaProperty(type = "integer"),
),
required = listOf("summary", "score"),
additionalProperties = false,
)
private fun errors(json: String) =
JsonSchemaValidator.validate(schema, Json.parseToJsonElement(json))
@Test
fun `valid object passes`() {
assertTrue(errors("""{"summary":"ok","score":3}""").isEmpty())
}
@Test
fun `missing required property is flagged`() {
assertTrue(errors("""{"summary":"ok"}""").any { it.contains("score") })
}
@Test
fun `wrong property type is flagged`() {
assertTrue(errors("""{"summary":"ok","score":"high"}""").any { it.contains("score") && it.contains("integer") })
}
@Test
fun `unknown property is flagged when additionalProperties is false`() {
assertTrue(errors("""{"summary":"ok","score":3,"extra":1}""").any { it.contains("extra") })
}
@Test
fun `unknown property is allowed when additionalProperties is true`() {
val open = schema.copy(additionalProperties = true)
val result = JsonSchemaValidator.validate(open, Json.parseToJsonElement("""{"summary":"ok","score":3,"extra":1}"""))
assertTrue(result.isEmpty())
}
@Test
fun `non-object payload against object schema is flagged`() {
assertEquals(1, errors("""["a","b"]""").size)
}
}