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:
+17
@@ -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,
|
||||
|
||||
+62
@@ -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"
|
||||
}
|
||||
}
|
||||
+56
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user