Skip to main content

awaken_contract/
config_validation.rs

1use serde::de::DeserializeOwned;
2use serde_json::Value;
3
4use crate::agent_spec_patch::AgentSpecPatch;
5use crate::config_record::{ConfigRecord, ConfigRecordError, ConfigRecordMerge};
6use crate::registry_spec::{AgentSpec, ModelBindingSpec, ProviderSpec};
7
8/// Unknown-field behavior for a serializable config surface.
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum UnknownFieldPolicy {
11    Reject,
12    Ignore,
13}
14
15/// `AgentSpec` and `AgentSpecPatch` reject unknown fields.
16pub const AGENT_SPEC_UNKNOWN_FIELD_POLICY: UnknownFieldPolicy = UnknownFieldPolicy::Reject;
17pub const AGENT_SPEC_PATCH_UNKNOWN_FIELD_POLICY: UnknownFieldPolicy = UnknownFieldPolicy::Reject;
18/// `ProviderSpec`'s serde implementation is intentionally lenient for
19/// read-time compatibility, but config write/validate surfaces reject unknown
20/// fields so operators do not persist silently ignored provider settings.
21pub const PROVIDER_SPEC_UNKNOWN_FIELD_POLICY: UnknownFieldPolicy = UnknownFieldPolicy::Reject;
22pub const MODEL_BINDING_SPEC_UNKNOWN_FIELD_POLICY: UnknownFieldPolicy = UnknownFieldPolicy::Reject;
23
24const PROVIDER_SPEC_FIELDS: &[&str] = &[
25    "id",
26    "adapter",
27    "api_key",
28    "base_url",
29    "timeout_secs",
30    "adapter_options",
31];
32const MODEL_BINDING_SPEC_FIELDS: &[&str] = &["id", "provider_id", "upstream_model"];
33
34#[derive(Debug, thiserror::Error)]
35pub enum ConfigValidationError {
36    #[error("invalid agent spec: {0}")]
37    AgentSpec(#[source] serde_json::Error),
38    #[error("invalid agent spec patch: {0}")]
39    AgentSpecPatch(#[source] serde_json::Error),
40    #[error("invalid provider spec: {0}")]
41    ProviderSpec(#[source] serde_json::Error),
42    #[error("invalid model binding spec: {0}")]
43    ModelBindingSpec(#[source] serde_json::Error),
44    #[error("invalid {surface}: unknown field '{field}'")]
45    UnknownField {
46        surface: &'static str,
47        field: String,
48    },
49    #[error("invalid {surface}: field '{field}' cannot be empty")]
50    EmptyField {
51        surface: &'static str,
52        field: &'static str,
53    },
54    #[error("invalid config record: {0}")]
55    ConfigRecord(#[from] ConfigRecordError),
56}
57
58/// Validate and decode an `AgentSpec`.
59///
60/// Unknown fields are rejected by `AgentSpec`'s serde definition.
61pub fn validate_agent_spec(value: Value) -> Result<AgentSpec, ConfigValidationError> {
62    serde_json::from_value(value).map_err(ConfigValidationError::AgentSpec)
63}
64
65/// Validate and decode an `AgentSpecPatch`.
66///
67/// Unknown fields are rejected by `AgentSpecPatch`'s serde definition.
68pub fn validate_agent_spec_patch(value: Value) -> Result<AgentSpecPatch, ConfigValidationError> {
69    serde_json::from_value(value).map_err(ConfigValidationError::AgentSpecPatch)
70}
71
72/// Validate and decode a `ProviderSpec` for config write surfaces.
73///
74/// Unknown fields are rejected here even though `ProviderSpec` deserialization
75/// remains lenient for read-time compatibility with future/older envelopes.
76/// Adapter support is intentionally not hard-coded in `awaken-contract`;
77/// runtime/server builders validate whether the linked provider backend
78/// supports a non-empty adapter string.
79pub fn validate_provider_spec(value: Value) -> Result<ProviderSpec, ConfigValidationError> {
80    reject_unknown_fields(&value, "provider spec", PROVIDER_SPEC_FIELDS)?;
81    let spec: ProviderSpec =
82        serde_json::from_value(value).map_err(ConfigValidationError::ProviderSpec)?;
83    reject_empty("provider spec", "id", &spec.id)?;
84    reject_empty("provider spec", "adapter", &spec.adapter)?;
85    Ok(spec)
86}
87
88/// Validate and decode a `ModelBindingSpec` for config write surfaces.
89pub fn validate_model_binding_spec(
90    value: Value,
91) -> Result<ModelBindingSpec, ConfigValidationError> {
92    reject_unknown_fields(&value, "model binding spec", MODEL_BINDING_SPEC_FIELDS)?;
93    let spec: ModelBindingSpec =
94        serde_json::from_value(value).map_err(ConfigValidationError::ModelBindingSpec)?;
95    reject_empty("model binding spec", "id", &spec.id)?;
96    reject_empty("model binding spec", "provider_id", &spec.provider_id)?;
97    reject_empty("model binding spec", "upstream_model", &spec.upstream_model)?;
98    Ok(spec)
99}
100
101/// Validate and decode a config record envelope, accepting legacy bare specs.
102/// `RecordMeta::user_overrides` must decode as the patch type for `T`.
103pub fn validate_config_record<T>(value: Value) -> Result<ConfigRecord<T>, ConfigValidationError>
104where
105    T: DeserializeOwned + ConfigRecordMerge,
106{
107    crate::config_record::validate_config_record(value).map_err(ConfigValidationError::ConfigRecord)
108}
109
110fn reject_unknown_fields(
111    value: &Value,
112    surface: &'static str,
113    allowed: &[&str],
114) -> Result<(), ConfigValidationError> {
115    let Some(object) = value.as_object() else {
116        return Ok(());
117    };
118    if let Some(field) = object
119        .keys()
120        .find(|field| !allowed.contains(&field.as_str()))
121    {
122        return Err(ConfigValidationError::UnknownField {
123            surface,
124            field: field.clone(),
125        });
126    }
127    Ok(())
128}
129
130fn reject_empty(
131    surface: &'static str,
132    field: &'static str,
133    value: &str,
134) -> Result<(), ConfigValidationError> {
135    if value.trim().is_empty() {
136        Err(ConfigValidationError::EmptyField { surface, field })
137    } else {
138        Ok(())
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use serde_json::json;
145
146    use super::*;
147
148    #[test]
149    fn validate_agent_spec_rejects_unknown_fields() {
150        let err = validate_agent_spec(json!({
151            "id": "a",
152            "model_id": "m",
153            "system_prompt": "s",
154            "model": "legacy"
155        }))
156        .expect_err("unknown field must be rejected");
157        assert!(err.to_string().contains("invalid agent spec"));
158    }
159
160    #[test]
161    fn validate_agent_spec_patch_rejects_unknown_fields() {
162        let err = validate_agent_spec_patch(json!({"bogus": true}))
163            .expect_err("unknown patch field must be rejected");
164        assert!(err.to_string().contains("invalid agent spec patch"));
165    }
166
167    #[test]
168    fn validate_config_record_accepts_legacy_bare_spec() {
169        let record = validate_config_record::<AgentSpec>(json!({
170            "id": "a",
171            "model_id": "m",
172            "system_prompt": "s"
173        }))
174        .expect("legacy bare spec must decode");
175        assert_eq!(record.spec.id, "a");
176    }
177
178    #[test]
179    fn validate_config_record_rejects_invalid_user_overrides() {
180        let err = validate_config_record::<AgentSpec>(json!({
181            "spec": {
182                "id": "a",
183                "model_id": "m",
184                "system_prompt": "s"
185            },
186            "meta": {
187                "source": {"kind": "builtin", "binary_version": "test"},
188                "user_overrides": {"unknown_patch_field": true}
189            }
190        }))
191        .expect_err("invalid overrides must fail validation");
192        assert!(err.to_string().contains("invalid config record"));
193    }
194
195    #[test]
196    fn validate_provider_spec_rejects_unknown_and_empty_fields() {
197        let err = validate_provider_spec(json!({
198            "id": "p",
199            "adapter": "openai",
200            "future_top_level": true
201        }))
202        .expect_err("unknown provider fields must be rejected on write surfaces");
203        assert!(err.to_string().contains("unknown field 'future_top_level'"));
204
205        let err = validate_provider_spec(json!({
206            "id": " ",
207            "adapter": "openai"
208        }))
209        .expect_err("empty provider id must be rejected");
210        assert!(err.to_string().contains("field 'id' cannot be empty"));
211
212        let err = validate_provider_spec(json!({
213            "id": "p",
214            "adapter": ""
215        }))
216        .expect_err("empty provider adapter must be rejected");
217        assert!(err.to_string().contains("field 'adapter' cannot be empty"));
218    }
219
220    #[test]
221    fn validate_model_binding_spec_rejects_unknown_and_empty_fields() {
222        let err = validate_model_binding_spec(json!({
223            "id": "m",
224            "provider_id": "p",
225            "upstream_model": "gpt-4",
226            "future_top_level": true
227        }))
228        .expect_err("unknown model fields must be rejected");
229        assert!(err.to_string().contains("unknown field 'future_top_level'"));
230
231        let err = validate_model_binding_spec(json!({
232            "id": "m",
233            "provider_id": " ",
234            "upstream_model": "gpt-4"
235        }))
236        .expect_err("empty provider_id must be rejected");
237        assert!(
238            err.to_string()
239                .contains("field 'provider_id' cannot be empty")
240        );
241    }
242}