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 ("session.v1", include_str!("schemas/session.v1.json")),
55 ("agent_cert.v1", include_str!("schemas/agent_cert.v1.json")),
56 ("profile.v1", include_str!("schemas/profile.v1.json")),
57];
58
59pub fn schema_json(suffix: &str) -> Option<&'static str> {
62 REGISTRY.iter().find(|(k, _)| *k == suffix).map(|(_, s)| *s)
63}
64
65pub fn registered_suffixes() -> Vec<&'static str> {
67 REGISTRY.iter().map(|(k, _)| *k).collect()
68}
69
70#[derive(Debug, Clone, PartialEq, Eq)]
72pub enum PredicateError {
73 MissingField { suffix: String, field: String },
75 TypeMismatch {
77 suffix: String,
78 field: String,
79 expected: String,
80 },
81 NotAnObject { suffix: String },
83 NotInEnum {
87 suffix: String,
88 field: String,
89 allowed: String,
90 },
91 SchemaParse { suffix: String, detail: String },
93}
94
95impl fmt::Display for PredicateError {
96 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
97 match self {
98 PredicateError::MissingField { suffix, field } => {
99 write!(f, "{suffix}: missing required field `{field}`")
100 }
101 PredicateError::TypeMismatch {
102 suffix,
103 field,
104 expected,
105 } => write!(
106 f,
107 "{suffix}: field `{field}` has the wrong type (expected {expected})"
108 ),
109 PredicateError::NotAnObject { suffix } => {
110 write!(f, "{suffix}: payload must be a JSON object")
111 }
112 PredicateError::NotInEnum { suffix, field, allowed } => write!(
113 f,
114 "{suffix}: field `{field}` has a value outside its allowed set ({allowed})"
115 ),
116 PredicateError::SchemaParse { suffix, detail } => {
117 write!(f, "{suffix}: registered schema is invalid JSON: {detail}")
118 }
119 }
120 }
121}
122
123impl std::error::Error for PredicateError {}
124
125pub fn validate(suffix: &str, payload: Option<&Value>) -> Result<(), PredicateError> {
134 let Some(schema_str) = schema_json(suffix) else {
135 return Ok(());
136 };
137 let schema: Value =
138 serde_json::from_str(schema_str).map_err(|e| PredicateError::SchemaParse {
139 suffix: suffix.to_string(),
140 detail: e.to_string(),
141 })?;
142
143 let empty = Value::Object(serde_json::Map::new());
146 let value = payload.unwrap_or(&empty);
147 let map = value
148 .as_object()
149 .ok_or_else(|| PredicateError::NotAnObject {
150 suffix: suffix.to_string(),
151 })?;
152
153 if let Some(required) = schema.get("required").and_then(Value::as_array) {
154 for entry in required {
155 if let Some(name) = entry.as_str() {
156 if !map.contains_key(name) {
157 return Err(PredicateError::MissingField {
158 suffix: suffix.to_string(),
159 field: name.to_string(),
160 });
161 }
162 }
163 }
164 }
165
166 if let Some(props) = schema.get("properties").and_then(Value::as_object) {
167 for (field, subschema) in props {
168 let Some(actual) = map.get(field) else {
169 continue; };
171
172 if let Some(type_decl) = subschema.get("type") {
174 if !type_matches(actual, type_decl) {
175 return Err(PredicateError::TypeMismatch {
176 suffix: suffix.to_string(),
177 field: field.to_string(),
178 expected: type_decl.to_string(),
179 });
180 }
181 }
182
183 if let Some(allowed) = subschema.get("enum").and_then(Value::as_array) {
189 if !allowed.iter().any(|a| a == actual) {
190 return Err(PredicateError::NotInEnum {
191 suffix: suffix.to_string(),
192 field: field.to_string(),
193 allowed: Value::Array(allowed.clone()).to_string(),
194 });
195 }
196 }
197 if let Some(constant) = subschema.get("const") {
198 if actual != constant {
199 return Err(PredicateError::NotInEnum {
200 suffix: suffix.to_string(),
201 field: field.to_string(),
202 allowed: constant.to_string(),
203 });
204 }
205 }
206 }
207 }
208
209 Ok(())
210}
211
212fn type_matches(value: &Value, type_decl: &Value) -> bool {
215 match type_decl {
216 Value::String(t) => json_is(value, t),
217 Value::Array(types) => types
218 .iter()
219 .any(|t| t.as_str().is_some_and(|t| json_is(value, t))),
220 _ => true,
223 }
224}
225
226fn json_is(value: &Value, ty: &str) -> bool {
229 match ty {
230 "string" => value.is_string(),
231 "integer" => value.is_i64() || value.is_u64(),
232 "number" => value.is_number(),
233 "boolean" => value.is_boolean(),
234 "object" => value.is_object(),
235 "array" => value.is_array(),
236 "null" => value.is_null(),
237 _ => true,
239 }
240}
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245 use serde_json::json;
246
247 #[test]
248 fn registry_lists_the_three_seed_predicates() {
249 let suffixes = registered_suffixes();
250 assert!(suffixes.contains(&"memory.write.v1"));
251 assert!(suffixes.contains(&"memory.read.v1"));
252 assert!(suffixes.contains(&"boundary.v1"));
253 assert!(suffixes.contains(&"agent_card.v1"));
254 assert!(schema_json("memory.write.v1").is_some());
255 assert!(schema_json("nope.v1").is_none());
256 }
257
258 #[test]
259 fn embedded_schemas_parse() {
260 for s in registered_suffixes() {
261 let raw = schema_json(s).unwrap();
262 serde_json::from_str::<Value>(raw).expect("embedded schema must be valid JSON");
263 }
264 }
265
266 #[test]
267 fn unregistered_suffix_is_backward_compatible() {
268 assert!(validate("custom.kind.v1", None).is_ok());
270 assert!(validate("custom.kind.v1", Some(&json!({"anything": 1}))).is_ok());
271 }
272
273 #[test]
274 fn agent_cert_valid_passes() {
275 let payload = json!({
276 "agent": "agent://deployer",
277 "subject_key_id": "key_abc123",
278 "subject_public_key": "vEQfSDqVCz4rtqbu5iuhpFuYrah6QALUSCGJYdOKeCY",
279 "issuer": "ship://ship_b49ff5f291a279c7",
280 "issued_at": "2026-07-06T12:00:00Z",
281 "valid_until": "2027-07-06T12:00:00Z",
282 "model": "claude-fable-5",
283 "description": null
284 });
285 assert!(validate("agent_cert.v1", Some(&payload)).is_ok());
286 }
287
288 #[test]
289 fn agent_cert_missing_subject_key_fails_closed() {
290 let payload = json!({
291 "agent": "agent://deployer",
292 "subject_key_id": "key_abc123",
293 "issuer": "ship://ship_x",
294 "issued_at": "2026-07-06T12:00:00Z",
295 "valid_until": "2027-07-06T12:00:00Z"
296 }); let err = validate("agent_cert.v1", Some(&payload)).unwrap_err();
298 assert_eq!(
299 err,
300 PredicateError::MissingField {
301 suffix: "agent_cert.v1".into(),
302 field: "subject_public_key".into()
303 }
304 );
305 }
306
307 #[test]
308 fn session_record_valid_passes() {
309 let payload = json!({
310 "session_id": "ssn_abc123",
311 "actor": "agent://hermes",
312 "headline": "Fixed keystore hostname-drift bug",
313 "outcome": "completed",
314 "started_at": "2026-07-06T14:00:00Z",
315 "closed_at": "2026-07-06T15:30:00Z",
316 "duration_ms": 5400000,
317 "harness": "claude-code",
318 "attestation_class": "runtime",
319 "action_count": 212,
320 "approval_count": 2,
321 "handoff_count": 0,
322 "event_count": 340,
323 "tools_exercised": ["Bash(git:*)", "Edit(*)"],
324 "receipt_digest": "sha256:deadbeef",
325 "receipt_merkle_root": "sha256:cafebabe",
326 "report_url": null
327 });
328 assert!(validate("session.v1", Some(&payload)).is_ok());
329 }
330
331 #[test]
332 fn session_record_out_of_enum_class_fails_closed() {
333 let payload = json!({
337 "session_id": "ssn_abc123",
338 "actor": "agent://hermes",
339 "outcome": "completed",
340 "started_at": "2026-07-06T14:00:00Z",
341 "closed_at": "2026-07-06T15:30:00Z",
342 "attestation_class": "super-trusted",
343 "receipt_digest": "sha256:deadbeef"
344 });
345 let err = validate("session.v1", Some(&payload)).unwrap_err();
346 assert!(
347 matches!(err, PredicateError::NotInEnum { ref field, .. } if field == "attestation_class"),
348 "expected NotInEnum for attestation_class, got {err:?}"
349 );
350 }
351
352 #[test]
353 fn session_record_out_of_enum_outcome_fails_closed() {
354 let payload = json!({
356 "session_id": "ssn_abc123",
357 "actor": "agent://hermes",
358 "outcome": "totally-shipped",
359 "started_at": "2026-07-06T14:00:00Z",
360 "closed_at": "2026-07-06T15:30:00Z",
361 "attestation_class": "self",
362 "receipt_digest": "sha256:deadbeef"
363 });
364 assert!(matches!(
365 validate("session.v1", Some(&payload)).unwrap_err(),
366 PredicateError::NotInEnum { .. }
367 ));
368 }
369
370 #[test]
371 fn session_record_missing_required_fails_closed() {
372 let payload = json!({
373 "session_id": "ssn_abc123",
374 "actor": "agent://hermes",
375 "outcome": "completed",
376 "started_at": "2026-07-06T14:00:00Z",
377 "closed_at": "2026-07-06T15:30:00Z",
378 "receipt_digest": "sha256:deadbeef"
379 }); let err = validate("session.v1", Some(&payload)).unwrap_err();
381 assert_eq!(
382 err,
383 PredicateError::MissingField {
384 suffix: "session.v1".into(),
385 field: "attestation_class".into()
386 }
387 );
388 }
389
390 #[test]
391 fn session_record_wrong_type_fails_closed() {
392 let payload = json!({
393 "session_id": "ssn_abc123",
394 "actor": "agent://hermes",
395 "outcome": "completed",
396 "started_at": "2026-07-06T14:00:00Z",
397 "closed_at": "2026-07-06T15:30:00Z",
398 "attestation_class": "runtime",
399 "receipt_digest": "sha256:deadbeef",
400 "tools_exercised": "Bash(git:*)"
401 }); let err = validate("session.v1", Some(&payload)).unwrap_err();
403 assert!(matches!(err, PredicateError::TypeMismatch { .. }));
404 }
405
406 #[test]
407 fn memory_write_valid_passes() {
408 let payload = json!({
409 "memory_id": "mem_abc",
410 "content_hash": "sha256:deadbeef",
411 "memory_type": "episodic",
412 "scope": "tenant://acme",
413 "activegraph_run_id": "run_1",
414 "supersedes": null
415 });
416 assert!(validate("memory.write.v1", Some(&payload)).is_ok());
417 }
418
419 #[test]
420 fn memory_write_missing_required_fails_closed() {
421 let payload = json!({
422 "memory_id": "mem_abc",
423 "memory_type": "episodic",
424 "scope": "tenant://acme"
425 }); let err = validate("memory.write.v1", Some(&payload)).unwrap_err();
427 assert_eq!(
428 err,
429 PredicateError::MissingField {
430 suffix: "memory.write.v1".into(),
431 field: "content_hash".into()
432 }
433 );
434 }
435
436 #[test]
437 fn memory_write_wrong_type_fails() {
438 let payload = json!({
439 "memory_id": "mem_abc",
440 "content_hash": 12345, "memory_type": "episodic",
442 "scope": "tenant://acme"
443 });
444 let err = validate("memory.write.v1", Some(&payload)).unwrap_err();
445 assert!(
446 matches!(err, PredicateError::TypeMismatch { field, .. } if field == "content_hash")
447 );
448 }
449
450 #[test]
451 fn memory_write_nullable_supersedes_accepts_string_and_null() {
452 let base = |sup: Value| {
453 json!({
454 "memory_id": "m", "content_hash": "h", "memory_type": "t", "scope": "s",
455 "supersedes": sup
456 })
457 };
458 assert!(validate("memory.write.v1", Some(&base(json!("mem_old")))).is_ok());
459 assert!(validate("memory.write.v1", Some(&base(Value::Null))).is_ok());
460 assert!(validate("memory.write.v1", Some(&base(json!(7)))).is_err());
462 }
463
464 #[test]
465 fn registered_predicate_requires_a_payload() {
466 let err = validate("memory.write.v1", None).unwrap_err();
467 assert!(matches!(err, PredicateError::MissingField { .. }));
468 }
469
470 #[test]
471 fn memory_read_valid_and_integer_enforced() {
472 let ok = json!({
473 "zmem_receipt_id": "act_1",
474 "trace_sha256": "abcd",
475 "query_hash": "qh",
476 "retrieval_mode": "semantic",
477 "memories_returned": 3
478 });
479 assert!(validate("memory.read.v1", Some(&ok)).is_ok());
480
481 let bad = json!({
482 "zmem_receipt_id": "act_1",
483 "trace_sha256": "abcd",
484 "query_hash": "qh",
485 "retrieval_mode": "semantic",
486 "memories_returned": "three" });
488 assert!(matches!(
489 validate("memory.read.v1", Some(&bad)).unwrap_err(),
490 PredicateError::TypeMismatch { field, .. } if field == "memories_returned"
491 ));
492 }
493
494 #[test]
495 fn memory_read_missing_required_fails() {
496 let payload = json!({
497 "zmem_receipt_id": "act_1",
498 "trace_sha256": "abcd",
499 "retrieval_mode": "semantic",
500 "memories_returned": 3
501 }); assert!(matches!(
503 validate("memory.read.v1", Some(&payload)).unwrap_err(),
504 PredicateError::MissingField { field, .. } if field == "query_hash"
505 ));
506 }
507
508 #[test]
509 fn boundary_structural_required_fields_enforced() {
510 let valid = json!({
514 "schema": "treeship.boundary.v1",
515 "subject_ref": "art_aabbccdd11223344",
516 "actor": {"uri": "agent://codex", "keyid": "key_aaaa1111"},
517 "checker": {"uri": "human://alice", "keyid": "key_bbbb2222"},
518 "decision": "allow",
519 "policy": {"digest": "sha256:p"},
520 "diet_root": "sha256:r",
521 "diet": [{"type": "memory_bundle", "digest": "sha256:d"}],
522 "committed_at": {"anchor": "merkle://zmem/checkpoint#4821", "ts": "2026-06-06T00:00:00Z"}
523 });
524 assert!(validate("boundary.v1", Some(&valid)).is_ok());
525
526 let mut wrong = valid.clone();
528 wrong.as_object_mut().unwrap()["committed_at"] = json!("not-an-object");
529 assert!(matches!(
530 validate("boundary.v1", Some(&wrong)).unwrap_err(),
531 PredicateError::TypeMismatch { field, .. } if field == "committed_at"
532 ));
533
534 let mut missing = valid.clone();
535 missing.as_object_mut().unwrap().remove("decision");
536 assert!(matches!(
537 validate("boundary.v1", Some(&missing)).unwrap_err(),
538 PredicateError::MissingField { field, .. } if field == "decision"
539 ));
540 }
541
542 #[test]
543 fn agent_card_valid_passes() {
544 let card = json!({
545 "schema": "agent_card.v1",
546 "agent": "agent://deployer",
547 "keyid": "key_9f8e7d6c",
548 "owner": "human://alice",
549 "version": "1.2.0",
550 "capabilities": {
551 "tools": ["file.read", "file.write", "db.*"],
552 "models": ["claude-sonnet-4"],
553 "can_delegate": true
554 },
555 "evidence_anchor": { "receipt_count": 1247, "merkle_root": "mroot_a0be" },
556 "supersedes": null
557 });
558 assert!(validate("agent_card.v1", Some(&card)).is_ok());
559 }
560
561 #[test]
562 fn agent_card_missing_keyid_fails_closed() {
563 let card = json!({
565 "schema": "agent_card.v1",
566 "agent": "agent://deployer",
567 "version": "1.0.0",
568 "capabilities": { "tools": ["file.read"] }
569 });
570 assert!(matches!(
571 validate("agent_card.v1", Some(&card)).unwrap_err(),
572 PredicateError::MissingField { field, .. } if field == "keyid"
573 ));
574 }
575
576 #[test]
577 fn agent_card_capabilities_must_be_an_object() {
578 let card = json!({
579 "schema": "agent_card.v1",
580 "agent": "agent://deployer",
581 "keyid": "key_1",
582 "version": "1.0.0",
583 "capabilities": ["file.read"] });
585 assert!(matches!(
586 validate("agent_card.v1", Some(&card)).unwrap_err(),
587 PredicateError::TypeMismatch { field, .. } if field == "capabilities"
588 ));
589 }
590
591 #[test]
592 fn agent_card_revocation_valid_passes() {
593 let rev = json!({
594 "schema": "agent_card_revocation.v1",
595 "card": "art_deadbeefdeadbeef",
596 "keyid": "key_1",
597 "reason": "key-rotation",
598 "revoked_at": "2026-06-23T00:00:00Z"
599 });
600 assert!(validate("agent_card_revocation.v1", Some(&rev)).is_ok());
601 }
602
603 #[test]
604 fn agent_card_revocation_requires_card_id() {
605 let rev = json!({
606 "schema": "agent_card_revocation.v1",
607 "revoked_at": "2026-06-23T00:00:00Z"
608 });
610 assert!(matches!(
611 validate("agent_card_revocation.v1", Some(&rev)).unwrap_err(),
612 PredicateError::MissingField { field, .. } if field == "card"
613 ));
614 }
615}