awaken_contract/
config_validation.rs1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum UnknownFieldPolicy {
11 Reject,
12 Ignore,
13}
14
15pub const AGENT_SPEC_UNKNOWN_FIELD_POLICY: UnknownFieldPolicy = UnknownFieldPolicy::Reject;
17pub const AGENT_SPEC_PATCH_UNKNOWN_FIELD_POLICY: UnknownFieldPolicy = UnknownFieldPolicy::Reject;
18pub 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
58pub fn validate_agent_spec(value: Value) -> Result<AgentSpec, ConfigValidationError> {
62 serde_json::from_value(value).map_err(ConfigValidationError::AgentSpec)
63}
64
65pub fn validate_agent_spec_patch(value: Value) -> Result<AgentSpecPatch, ConfigValidationError> {
69 serde_json::from_value(value).map_err(ConfigValidationError::AgentSpecPatch)
70}
71
72pub 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
88pub 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
101pub 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}