1use serde_json::Value;
31use std::fmt;
32
33const REGISTRY: &[(&str, &str)] = &[
37 (
38 "memory.write.v1",
39 include_str!("schemas/memory.write.v1.json"),
40 ),
41 (
42 "memory.read.v1",
43 include_str!("schemas/memory.read.v1.json"),
44 ),
45 ("boundary.v1", include_str!("schemas/boundary.v1.json")),
46 (
47 "agent_card.v1",
48 include_str!("schemas/agent_card.v1.json"),
49 ),
50 (
51 "agent_card_revocation.v1",
52 include_str!("schemas/agent_card_revocation.v1.json"),
53 ),
54];
55
56pub fn schema_json(suffix: &str) -> Option<&'static str> {
59 REGISTRY.iter().find(|(k, _)| *k == suffix).map(|(_, s)| *s)
60}
61
62pub fn registered_suffixes() -> Vec<&'static str> {
64 REGISTRY.iter().map(|(k, _)| *k).collect()
65}
66
67#[derive(Debug, Clone, PartialEq, Eq)]
69pub enum PredicateError {
70 MissingField { suffix: String, field: String },
72 TypeMismatch {
74 suffix: String,
75 field: String,
76 expected: String,
77 },
78 NotAnObject { suffix: String },
80 SchemaParse { suffix: String, detail: String },
82}
83
84impl fmt::Display for PredicateError {
85 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86 match self {
87 PredicateError::MissingField { suffix, field } => {
88 write!(f, "{suffix}: missing required field `{field}`")
89 }
90 PredicateError::TypeMismatch {
91 suffix,
92 field,
93 expected,
94 } => write!(
95 f,
96 "{suffix}: field `{field}` has the wrong type (expected {expected})"
97 ),
98 PredicateError::NotAnObject { suffix } => {
99 write!(f, "{suffix}: payload must be a JSON object")
100 }
101 PredicateError::SchemaParse { suffix, detail } => {
102 write!(f, "{suffix}: registered schema is invalid JSON: {detail}")
103 }
104 }
105 }
106}
107
108impl std::error::Error for PredicateError {}
109
110pub fn validate(suffix: &str, payload: Option<&Value>) -> Result<(), PredicateError> {
119 let Some(schema_str) = schema_json(suffix) else {
120 return Ok(());
121 };
122 let schema: Value =
123 serde_json::from_str(schema_str).map_err(|e| PredicateError::SchemaParse {
124 suffix: suffix.to_string(),
125 detail: e.to_string(),
126 })?;
127
128 let empty = Value::Object(serde_json::Map::new());
131 let value = payload.unwrap_or(&empty);
132 let map = value
133 .as_object()
134 .ok_or_else(|| PredicateError::NotAnObject {
135 suffix: suffix.to_string(),
136 })?;
137
138 if let Some(required) = schema.get("required").and_then(Value::as_array) {
139 for entry in required {
140 if let Some(name) = entry.as_str() {
141 if !map.contains_key(name) {
142 return Err(PredicateError::MissingField {
143 suffix: suffix.to_string(),
144 field: name.to_string(),
145 });
146 }
147 }
148 }
149 }
150
151 if let Some(props) = schema.get("properties").and_then(Value::as_object) {
152 for (field, subschema) in props {
153 let Some(actual) = map.get(field) else {
154 continue; };
156 let Some(type_decl) = subschema.get("type") else {
157 continue; };
159 if !type_matches(actual, type_decl) {
160 return Err(PredicateError::TypeMismatch {
161 suffix: suffix.to_string(),
162 field: field.to_string(),
163 expected: type_decl.to_string(),
164 });
165 }
166 }
167 }
168
169 Ok(())
170}
171
172fn type_matches(value: &Value, type_decl: &Value) -> bool {
175 match type_decl {
176 Value::String(t) => json_is(value, t),
177 Value::Array(types) => types
178 .iter()
179 .any(|t| t.as_str().is_some_and(|t| json_is(value, t))),
180 _ => true,
183 }
184}
185
186fn json_is(value: &Value, ty: &str) -> bool {
189 match ty {
190 "string" => value.is_string(),
191 "integer" => value.is_i64() || value.is_u64(),
192 "number" => value.is_number(),
193 "boolean" => value.is_boolean(),
194 "object" => value.is_object(),
195 "array" => value.is_array(),
196 "null" => value.is_null(),
197 _ => true,
199 }
200}
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205 use serde_json::json;
206
207 #[test]
208 fn registry_lists_the_three_seed_predicates() {
209 let suffixes = registered_suffixes();
210 assert!(suffixes.contains(&"memory.write.v1"));
211 assert!(suffixes.contains(&"memory.read.v1"));
212 assert!(suffixes.contains(&"boundary.v1"));
213 assert!(suffixes.contains(&"agent_card.v1"));
214 assert!(schema_json("memory.write.v1").is_some());
215 assert!(schema_json("nope.v1").is_none());
216 }
217
218 #[test]
219 fn embedded_schemas_parse() {
220 for s in registered_suffixes() {
221 let raw = schema_json(s).unwrap();
222 serde_json::from_str::<Value>(raw).expect("embedded schema must be valid JSON");
223 }
224 }
225
226 #[test]
227 fn unregistered_suffix_is_backward_compatible() {
228 assert!(validate("custom.kind.v1", None).is_ok());
230 assert!(validate("custom.kind.v1", Some(&json!({"anything": 1}))).is_ok());
231 }
232
233 #[test]
234 fn memory_write_valid_passes() {
235 let payload = json!({
236 "memory_id": "mem_abc",
237 "content_hash": "sha256:deadbeef",
238 "memory_type": "episodic",
239 "scope": "tenant://acme",
240 "activegraph_run_id": "run_1",
241 "supersedes": null
242 });
243 assert!(validate("memory.write.v1", Some(&payload)).is_ok());
244 }
245
246 #[test]
247 fn memory_write_missing_required_fails_closed() {
248 let payload = json!({
249 "memory_id": "mem_abc",
250 "memory_type": "episodic",
251 "scope": "tenant://acme"
252 }); let err = validate("memory.write.v1", Some(&payload)).unwrap_err();
254 assert_eq!(
255 err,
256 PredicateError::MissingField {
257 suffix: "memory.write.v1".into(),
258 field: "content_hash".into()
259 }
260 );
261 }
262
263 #[test]
264 fn memory_write_wrong_type_fails() {
265 let payload = json!({
266 "memory_id": "mem_abc",
267 "content_hash": 12345, "memory_type": "episodic",
269 "scope": "tenant://acme"
270 });
271 let err = validate("memory.write.v1", Some(&payload)).unwrap_err();
272 assert!(
273 matches!(err, PredicateError::TypeMismatch { field, .. } if field == "content_hash")
274 );
275 }
276
277 #[test]
278 fn memory_write_nullable_supersedes_accepts_string_and_null() {
279 let base = |sup: Value| {
280 json!({
281 "memory_id": "m", "content_hash": "h", "memory_type": "t", "scope": "s",
282 "supersedes": sup
283 })
284 };
285 assert!(validate("memory.write.v1", Some(&base(json!("mem_old")))).is_ok());
286 assert!(validate("memory.write.v1", Some(&base(Value::Null))).is_ok());
287 assert!(validate("memory.write.v1", Some(&base(json!(7)))).is_err());
289 }
290
291 #[test]
292 fn registered_predicate_requires_a_payload() {
293 let err = validate("memory.write.v1", None).unwrap_err();
294 assert!(matches!(err, PredicateError::MissingField { .. }));
295 }
296
297 #[test]
298 fn memory_read_valid_and_integer_enforced() {
299 let ok = json!({
300 "zmem_receipt_id": "act_1",
301 "trace_sha256": "abcd",
302 "query_hash": "qh",
303 "retrieval_mode": "semantic",
304 "memories_returned": 3
305 });
306 assert!(validate("memory.read.v1", Some(&ok)).is_ok());
307
308 let bad = json!({
309 "zmem_receipt_id": "act_1",
310 "trace_sha256": "abcd",
311 "query_hash": "qh",
312 "retrieval_mode": "semantic",
313 "memories_returned": "three" });
315 assert!(matches!(
316 validate("memory.read.v1", Some(&bad)).unwrap_err(),
317 PredicateError::TypeMismatch { field, .. } if field == "memories_returned"
318 ));
319 }
320
321 #[test]
322 fn memory_read_missing_required_fails() {
323 let payload = json!({
324 "zmem_receipt_id": "act_1",
325 "trace_sha256": "abcd",
326 "retrieval_mode": "semantic",
327 "memories_returned": 3
328 }); assert!(matches!(
330 validate("memory.read.v1", Some(&payload)).unwrap_err(),
331 PredicateError::MissingField { field, .. } if field == "query_hash"
332 ));
333 }
334
335 #[test]
336 fn boundary_structural_required_fields_enforced() {
337 let valid = json!({
341 "schema": "treeship.boundary.v1",
342 "subject_ref": "art_aabbccdd11223344",
343 "actor": {"uri": "agent://codex", "keyid": "key_aaaa1111"},
344 "checker": {"uri": "human://alice", "keyid": "key_bbbb2222"},
345 "decision": "allow",
346 "policy": {"digest": "sha256:p"},
347 "diet_root": "sha256:r",
348 "diet": [{"type": "memory_bundle", "digest": "sha256:d"}],
349 "committed_at": {"anchor": "merkle://zmem/checkpoint#4821", "ts": "2026-06-06T00:00:00Z"}
350 });
351 assert!(validate("boundary.v1", Some(&valid)).is_ok());
352
353 let mut wrong = valid.clone();
355 wrong.as_object_mut().unwrap()["committed_at"] = json!("not-an-object");
356 assert!(matches!(
357 validate("boundary.v1", Some(&wrong)).unwrap_err(),
358 PredicateError::TypeMismatch { field, .. } if field == "committed_at"
359 ));
360
361 let mut missing = valid.clone();
362 missing.as_object_mut().unwrap().remove("decision");
363 assert!(matches!(
364 validate("boundary.v1", Some(&missing)).unwrap_err(),
365 PredicateError::MissingField { field, .. } if field == "decision"
366 ));
367 }
368
369 #[test]
370 fn agent_card_valid_passes() {
371 let card = json!({
372 "schema": "agent_card.v1",
373 "agent": "agent://deployer",
374 "keyid": "key_9f8e7d6c",
375 "owner": "human://alice",
376 "version": "1.2.0",
377 "capabilities": {
378 "tools": ["file.read", "file.write", "db.*"],
379 "models": ["claude-sonnet-4"],
380 "can_delegate": true
381 },
382 "evidence_anchor": { "receipt_count": 1247, "merkle_root": "mroot_a0be" },
383 "supersedes": null
384 });
385 assert!(validate("agent_card.v1", Some(&card)).is_ok());
386 }
387
388 #[test]
389 fn agent_card_missing_keyid_fails_closed() {
390 let card = json!({
392 "schema": "agent_card.v1",
393 "agent": "agent://deployer",
394 "version": "1.0.0",
395 "capabilities": { "tools": ["file.read"] }
396 });
397 assert!(matches!(
398 validate("agent_card.v1", Some(&card)).unwrap_err(),
399 PredicateError::MissingField { field, .. } if field == "keyid"
400 ));
401 }
402
403 #[test]
404 fn agent_card_capabilities_must_be_an_object() {
405 let card = json!({
406 "schema": "agent_card.v1",
407 "agent": "agent://deployer",
408 "keyid": "key_1",
409 "version": "1.0.0",
410 "capabilities": ["file.read"] });
412 assert!(matches!(
413 validate("agent_card.v1", Some(&card)).unwrap_err(),
414 PredicateError::TypeMismatch { field, .. } if field == "capabilities"
415 ));
416 }
417
418 #[test]
419 fn agent_card_revocation_valid_passes() {
420 let rev = json!({
421 "schema": "agent_card_revocation.v1",
422 "card": "art_deadbeefdeadbeef",
423 "keyid": "key_1",
424 "reason": "key-rotation",
425 "revoked_at": "2026-06-23T00:00:00Z"
426 });
427 assert!(validate("agent_card_revocation.v1", Some(&rev)).is_ok());
428 }
429
430 #[test]
431 fn agent_card_revocation_requires_card_id() {
432 let rev = json!({
433 "schema": "agent_card_revocation.v1",
434 "revoked_at": "2026-06-23T00:00:00Z"
435 });
437 assert!(matches!(
438 validate("agent_card_revocation.v1", Some(&rev)).unwrap_err(),
439 PredicateError::MissingField { field, .. } if field == "card"
440 ));
441 }
442}