use super::config::SchemaDriftConfig;
use super::inspector::{AirlockViolation, RiskLevel, ViolationType};
use fd_core::ToolVersionId;
use jsonschema::JSONSchema;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use tracing::{debug, warn};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DriftKind {
MissingRequired,
TypeMismatch,
ConstraintViolation,
PayloadShape,
}
impl DriftKind {
pub fn as_str(&self) -> &'static str {
match self {
DriftKind::MissingRequired => "missing_required",
DriftKind::TypeMismatch => "type_mismatch",
DriftKind::ConstraintViolation => "constraint_violation",
DriftKind::PayloadShape => "payload_shape",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DriftDelta {
pub field: String,
pub kind: DriftKind,
pub message: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SchemaDriftDetails {
pub tool_version_id: String,
pub deltas: Vec<DriftDelta>,
}
pub struct SchemaDriftGuard {
schemas: RwLock<HashMap<ToolVersionId, Arc<JSONSchema>>>,
}
impl SchemaDriftGuard {
pub fn new<I>(pairs: I) -> Self
where
I: IntoIterator<Item = (ToolVersionId, serde_json::Value)>,
{
let mut schemas = HashMap::new();
for (id, value) in pairs {
match JSONSchema::compile(&value) {
Ok(compiled) => {
schemas.insert(id, Arc::new(compiled));
}
Err(err) => {
warn!(
tool_version_id = %id,
error = %err,
"skipping uncompilable input schema; payloads for this tool version will not be drift-checked",
);
}
}
}
Self {
schemas: RwLock::new(schemas),
}
}
pub fn empty() -> Self {
Self {
schemas: RwLock::new(HashMap::new()),
}
}
pub fn len(&self) -> usize {
self.read().len()
}
pub fn is_empty(&self) -> bool {
self.read().is_empty()
}
pub fn contains(&self, tool_version_id: &ToolVersionId) -> bool {
self.read().contains_key(tool_version_id)
}
pub fn upsert(&self, tool_version_id: ToolVersionId, schema: &serde_json::Value) -> bool {
match JSONSchema::compile(schema) {
Ok(compiled) => {
self.write().insert(tool_version_id, Arc::new(compiled));
debug!(tool_version_id = %tool_version_id, "schema-drift guard entry upserted");
true
}
Err(err) => {
warn!(
tool_version_id = %tool_version_id,
error = %err,
"schema-drift upsert: uncompilable input schema; this tool version will not be drift-checked",
);
false
}
}
}
fn read(&self) -> std::sync::RwLockReadGuard<'_, HashMap<ToolVersionId, Arc<JSONSchema>>> {
self.schemas.read().unwrap_or_else(|e| e.into_inner())
}
fn write(&self) -> std::sync::RwLockWriteGuard<'_, HashMap<ToolVersionId, Arc<JSONSchema>>> {
self.schemas.write().unwrap_or_else(|e| e.into_inner())
}
pub fn check(
&self,
tool_version_id: &ToolVersionId,
payload: &serde_json::Value,
config: &SchemaDriftConfig,
) -> Option<AirlockViolation> {
if !config.enabled {
return None;
}
let schema = match self.read().get(tool_version_id) {
Some(s) => Arc::clone(s),
None => {
if config.fail_closed_on_unregistered {
return Some(unregistered_violation(tool_version_id, config));
}
return None;
}
};
let validation = schema.validate(payload);
let errors = match validation {
Ok(()) => return None,
Err(errors) => errors,
};
let mut deltas: Vec<DriftDelta> = errors
.map(|err| {
let pointer = err.instance_path.to_string();
let field = if pointer.is_empty() {
"/".to_string()
} else {
pointer
};
let kind = classify_kind(&err.kind);
DriftDelta {
field,
kind,
message: err.to_string(),
}
})
.collect();
if deltas.is_empty() {
deltas.push(DriftDelta {
field: "/".to_string(),
kind: DriftKind::PayloadShape,
message: "payload failed schema validation".to_string(),
});
}
let risk_score = config.risk_score.min(100);
let details = SchemaDriftDetails {
tool_version_id: tool_version_id.to_string(),
deltas: deltas.clone(),
};
let serialized = serde_json::to_string(&details).unwrap_or_else(|_| {
format!(
"schema_drift: {} delta(s) for tool_version {}",
deltas.len(),
tool_version_id
)
});
debug!(
tool_version_id = %tool_version_id,
delta_count = deltas.len(),
"schema drift detected"
);
Some(AirlockViolation {
violation_type: ViolationType::SchemaDrift,
risk_score,
risk_level: RiskLevel::from_score(risk_score),
details: serialized,
trigger: format!("schema_drift:{}", deltas[0].kind.as_str()),
})
}
}
fn classify_kind(kind: &jsonschema::error::ValidationErrorKind) -> DriftKind {
use jsonschema::error::ValidationErrorKind;
match kind {
ValidationErrorKind::Required { .. } => DriftKind::MissingRequired,
ValidationErrorKind::Type { .. } => DriftKind::TypeMismatch,
_ => DriftKind::ConstraintViolation,
}
}
fn unregistered_violation(
tool_version_id: &ToolVersionId,
config: &SchemaDriftConfig,
) -> AirlockViolation {
let risk_score = config.risk_score.min(100);
let deltas = vec![DriftDelta {
field: "/".to_string(),
kind: DriftKind::PayloadShape,
message: "no registered schema for this tool version (fail-closed)".to_string(),
}];
let details = SchemaDriftDetails {
tool_version_id: tool_version_id.to_string(),
deltas,
};
let serialized = serde_json::to_string(&details)
.unwrap_or_else(|_| format!("schema_drift: unregistered tool_version {tool_version_id}"));
AirlockViolation {
violation_type: ViolationType::SchemaDrift,
risk_score,
risk_level: RiskLevel::from_score(risk_score),
details: serialized,
trigger: "schema_drift:unregistered".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn person_schema() -> serde_json::Value {
json!({
"type": "object",
"required": ["name", "age"],
"additionalProperties": false,
"properties": {
"name": { "type": "string" },
"age": { "type": "integer", "minimum": 0 }
}
})
}
fn config() -> SchemaDriftConfig {
SchemaDriftConfig {
enabled: true,
risk_score: 70,
fail_closed_on_unregistered: false,
}
}
#[test]
fn valid_payload_passes() {
let tv_id = ToolVersionId::new();
let guard = SchemaDriftGuard::new([(tv_id, person_schema())]);
let result = guard.check(&tv_id, &json!({"name": "Ada", "age": 36}), &config());
assert!(result.is_none());
}
#[test]
fn missing_required_field_is_caught() {
let tv_id = ToolVersionId::new();
let guard = SchemaDriftGuard::new([(tv_id, person_schema())]);
let violation = guard
.check(&tv_id, &json!({"name": "Ada"}), &config())
.expect("should detect missing required field");
assert_eq!(violation.violation_type, ViolationType::SchemaDrift);
let details: SchemaDriftDetails = serde_json::from_str(&violation.details).unwrap();
assert_eq!(details.tool_version_id, tv_id.to_string());
assert!(
details
.deltas
.iter()
.any(|d| d.kind == DriftKind::MissingRequired),
"expected MissingRequired delta, got {:?}",
details.deltas
);
}
#[test]
fn type_mismatch_is_caught() {
let tv_id = ToolVersionId::new();
let guard = SchemaDriftGuard::new([(tv_id, person_schema())]);
let violation = guard
.check(&tv_id, &json!({"name": "Ada", "age": "thirty"}), &config())
.expect("should detect type mismatch");
let details: SchemaDriftDetails = serde_json::from_str(&violation.details).unwrap();
assert!(
details
.deltas
.iter()
.any(|d| d.kind == DriftKind::TypeMismatch),
"expected TypeMismatch delta, got {:?}",
details.deltas
);
}
#[test]
fn unknown_field_is_caught_as_constraint() {
let tv_id = ToolVersionId::new();
let guard = SchemaDriftGuard::new([(tv_id, person_schema())]);
let violation = guard
.check(
&tv_id,
&json!({"name": "Ada", "age": 36, "ssn": "leaked"}),
&config(),
)
.expect("additionalProperties:false should reject unknown field");
let details: SchemaDriftDetails = serde_json::from_str(&violation.details).unwrap();
assert!(!details.deltas.is_empty());
}
#[test]
fn no_schema_for_tool_version_is_skipped() {
let known = ToolVersionId::new();
let unknown = ToolVersionId::new();
let guard = SchemaDriftGuard::new([(known, person_schema())]);
assert!(guard
.check(&unknown, &json!({"name": "Ada", "age": 36}), &config())
.is_none());
}
#[test]
fn disabled_config_short_circuits() {
let tv_id = ToolVersionId::new();
let guard = SchemaDriftGuard::new([(tv_id, person_schema())]);
let mut cfg = config();
cfg.enabled = false;
assert!(guard.check(&tv_id, &json!({"name": "Ada"}), &cfg).is_none());
}
#[test]
fn risk_score_is_clamped_to_100() {
let tv_id = ToolVersionId::new();
let guard = SchemaDriftGuard::new([(tv_id, person_schema())]);
let mut cfg = config();
cfg.risk_score = 200;
let violation = guard
.check(&tv_id, &json!({"name": "Ada"}), &cfg)
.expect("should violate");
assert_eq!(violation.risk_score, 100);
assert_eq!(violation.risk_level, RiskLevel::Critical);
}
#[test]
fn empty_guard_skips_all() {
let guard = SchemaDriftGuard::empty();
let tv_id = ToolVersionId::new();
assert!(guard.is_empty());
assert!(guard
.check(&tv_id, &json!({"anything": "goes"}), &config())
.is_none());
}
#[test]
fn invalid_schema_is_skipped_at_compile_time() {
let tv_id = ToolVersionId::new();
let bad_schema = json!({"type": 42});
let guard = SchemaDriftGuard::new([(tv_id, bad_schema)]);
assert!(guard.is_empty() || !guard.contains(&tv_id));
}
#[test]
fn upsert_registers_a_version_after_construction() {
let guard = SchemaDriftGuard::empty();
let tv_id = ToolVersionId::new();
assert!(!guard.contains(&tv_id));
assert!(guard
.check(&tv_id, &json!({"name": "Ada"}), &config())
.is_none());
assert!(guard.upsert(tv_id, &person_schema()));
assert!(guard.contains(&tv_id));
let violation = guard
.check(&tv_id, &json!({"name": "Ada"}), &config())
.expect("drift must be caught once the version is registered live");
assert_eq!(violation.violation_type, ViolationType::SchemaDrift);
assert!(guard
.check(&tv_id, &json!({"name": "Ada", "age": 36}), &config())
.is_none());
}
#[test]
fn upsert_replaces_an_existing_entry() {
let tv_id = ToolVersionId::new();
let guard = SchemaDriftGuard::new([(tv_id, person_schema())]);
let looser = json!({
"type": "object",
"required": ["name"],
"properties": { "name": { "type": "string" } }
});
assert!(guard.upsert(tv_id, &looser));
assert!(guard
.check(&tv_id, &json!({"name": "Ada"}), &config())
.is_none());
}
#[test]
fn fail_closed_blocks_an_unregistered_version() {
let guard = SchemaDriftGuard::empty();
let tv_id = ToolVersionId::new();
let mut cfg = config();
assert!(guard.check(&tv_id, &json!({"x": 1}), &cfg).is_none());
cfg.fail_closed_on_unregistered = true;
let violation = guard
.check(&tv_id, &json!({"x": 1}), &cfg)
.expect("fail-closed must block an unregistered tool version");
assert_eq!(violation.violation_type, ViolationType::SchemaDrift);
assert_eq!(violation.trigger, "schema_drift:unregistered");
assert!(guard.upsert(tv_id, &json!({"type": "object"})));
assert!(guard.check(&tv_id, &json!({"x": 1}), &cfg).is_none());
}
#[test]
fn upsert_rejects_an_uncompilable_schema() {
let guard = SchemaDriftGuard::empty();
let tv_id = ToolVersionId::new();
assert!(!guard.upsert(tv_id, &json!({"type": 42})));
assert!(!guard.contains(&tv_id));
}
}