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 (
46 "memory.quarantine-check.v1",
47 include_str!("schemas/memory.quarantine-check.v1.json"),
48 ),
49 ("blocked.v1", include_str!("schemas/blocked.v1.json")),
50 ("boundary.v1", include_str!("schemas/boundary.v1.json")),
51 ("agent_card.v1", include_str!("schemas/agent_card.v1.json")),
52 (
53 "agent_card_revocation.v1",
54 include_str!("schemas/agent_card_revocation.v1.json"),
55 ),
56 ("session.v1", include_str!("schemas/session.v1.json")),
57 ("agent_cert.v1", include_str!("schemas/agent_cert.v1.json")),
58 ("profile.v1", include_str!("schemas/profile.v1.json")),
59];
60
61pub fn schema_json(suffix: &str) -> Option<&'static str> {
64 REGISTRY.iter().find(|(k, _)| *k == suffix).map(|(_, s)| *s)
65}
66
67pub fn registered_suffixes() -> Vec<&'static str> {
69 REGISTRY.iter().map(|(k, _)| *k).collect()
70}
71
72#[derive(Debug, Clone, PartialEq, Eq)]
74pub enum PredicateError {
75 MissingField { suffix: String, field: String },
77 TypeMismatch {
79 suffix: String,
80 field: String,
81 expected: String,
82 },
83 NotAnObject { suffix: String },
85 NotInEnum {
89 suffix: String,
90 field: String,
91 allowed: String,
92 },
93 SchemaParse { suffix: String, detail: String },
95}
96
97impl fmt::Display for PredicateError {
98 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
99 match self {
100 PredicateError::MissingField { suffix, field } => {
101 write!(f, "{suffix}: missing required field `{field}`")
102 }
103 PredicateError::TypeMismatch {
104 suffix,
105 field,
106 expected,
107 } => write!(
108 f,
109 "{suffix}: field `{field}` has the wrong type (expected {expected})"
110 ),
111 PredicateError::NotAnObject { suffix } => {
112 write!(f, "{suffix}: payload must be a JSON object")
113 }
114 PredicateError::NotInEnum {
115 suffix,
116 field,
117 allowed,
118 } => write!(
119 f,
120 "{suffix}: field `{field}` has a value outside its allowed set ({allowed})"
121 ),
122 PredicateError::SchemaParse { suffix, detail } => {
123 write!(f, "{suffix}: registered schema is invalid JSON: {detail}")
124 }
125 }
126 }
127}
128
129impl std::error::Error for PredicateError {}
130
131pub fn validate(suffix: &str, payload: Option<&Value>) -> Result<(), PredicateError> {
140 let Some(schema_str) = schema_json(suffix) else {
141 return Ok(());
142 };
143 let schema: Value =
144 serde_json::from_str(schema_str).map_err(|e| PredicateError::SchemaParse {
145 suffix: suffix.to_string(),
146 detail: e.to_string(),
147 })?;
148
149 let empty = Value::Object(serde_json::Map::new());
152 let value = payload.unwrap_or(&empty);
153 let map = value
154 .as_object()
155 .ok_or_else(|| PredicateError::NotAnObject {
156 suffix: suffix.to_string(),
157 })?;
158
159 if let Some(required) = schema.get("required").and_then(Value::as_array) {
160 for entry in required {
161 if let Some(name) = entry.as_str() {
162 if !map.contains_key(name) {
163 return Err(PredicateError::MissingField {
164 suffix: suffix.to_string(),
165 field: name.to_string(),
166 });
167 }
168 }
169 }
170 }
171
172 if let Some(props) = schema.get("properties").and_then(Value::as_object) {
173 for (field, subschema) in props {
174 let Some(actual) = map.get(field) else {
175 continue; };
177
178 if let Some(type_decl) = subschema.get("type") {
180 if !type_matches(actual, type_decl) {
181 return Err(PredicateError::TypeMismatch {
182 suffix: suffix.to_string(),
183 field: field.to_string(),
184 expected: type_decl.to_string(),
185 });
186 }
187 }
188
189 if let Some(allowed) = subschema.get("enum").and_then(Value::as_array) {
195 if !allowed.iter().any(|a| a == actual) {
196 return Err(PredicateError::NotInEnum {
197 suffix: suffix.to_string(),
198 field: field.to_string(),
199 allowed: Value::Array(allowed.clone()).to_string(),
200 });
201 }
202 }
203 if let Some(constant) = subschema.get("const") {
204 if actual != constant {
205 return Err(PredicateError::NotInEnum {
206 suffix: suffix.to_string(),
207 field: field.to_string(),
208 allowed: constant.to_string(),
209 });
210 }
211 }
212 }
213 }
214
215 Ok(())
216}
217
218fn type_matches(value: &Value, type_decl: &Value) -> bool {
221 match type_decl {
222 Value::String(t) => json_is(value, t),
223 Value::Array(types) => types
224 .iter()
225 .any(|t| t.as_str().is_some_and(|t| json_is(value, t))),
226 _ => true,
229 }
230}
231
232fn json_is(value: &Value, ty: &str) -> bool {
235 match ty {
236 "string" => value.is_string(),
237 "integer" => value.is_i64() || value.is_u64(),
238 "number" => value.is_number(),
239 "boolean" => value.is_boolean(),
240 "object" => value.is_object(),
241 "array" => value.is_array(),
242 "null" => value.is_null(),
243 _ => true,
245 }
246}
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251 use serde_json::json;
252
253 #[test]
254 fn registry_lists_the_three_seed_predicates() {
255 let suffixes = registered_suffixes();
256 assert!(suffixes.contains(&"memory.write.v1"));
257 assert!(suffixes.contains(&"memory.read.v1"));
258 assert!(suffixes.contains(&"boundary.v1"));
259 assert!(suffixes.contains(&"agent_card.v1"));
260 assert!(schema_json("memory.write.v1").is_some());
261 assert!(schema_json("nope.v1").is_none());
262 }
263
264 #[test]
265 fn embedded_schemas_parse() {
266 for s in registered_suffixes() {
267 let raw = schema_json(s).unwrap();
268 serde_json::from_str::<Value>(raw).expect("embedded schema must be valid JSON");
269 }
270 }
271
272 #[test]
273 fn quarantine_check_valid_passes() {
274 let payload = json!({
275 "action_id": "aac_1f2e3d4c",
276 "provider": "system://zmem",
277 "chain_root": "u3v9xJ2kQm4Zr8pW1sTnA7bCdEfGhIjKlMnOpQrStUv",
278 "decision_seq": 1042,
279 "clean": true,
280 "quarantined_triggers": [],
281 "checked_at": "2026-07-17T19:00:00Z"
282 });
283 assert!(validate("memory.quarantine-check.v1", Some(&payload)).is_ok());
284 }
285
286 #[test]
287 fn quarantine_check_missing_verdict_fails_closed() {
288 let payload = json!({
289 "action_id": "aac_1f2e3d4c",
290 "chain_root": "u3v9xJ2kQm4Zr8pW1sTnA7bCdEfGhIjKlMnOpQrStUv",
291 "decision_seq": 1042
292 }); let err = validate("memory.quarantine-check.v1", Some(&payload)).unwrap_err();
294 assert_eq!(
295 err,
296 PredicateError::MissingField {
297 suffix: "memory.quarantine-check.v1".into(),
298 field: "clean".into()
299 }
300 );
301 }
302
303 #[test]
304 fn quarantine_check_stringly_typed_verdict_fails_closed() {
305 let payload = json!({
309 "action_id": "aac_1f2e3d4c",
310 "chain_root": "u3v9xJ2kQm4Zr8pW1sTnA7bCdEfGhIjKlMnOpQrStUv",
311 "decision_seq": 1042,
312 "clean": "true"
313 });
314 let err = validate("memory.quarantine-check.v1", Some(&payload)).unwrap_err();
315 assert_eq!(
316 err,
317 PredicateError::TypeMismatch {
318 suffix: "memory.quarantine-check.v1".into(),
319 field: "clean".into(),
320 expected: "\"boolean\"".into()
321 }
322 );
323 }
324
325 #[test]
326 fn quarantine_check_non_integer_seq_fails_closed() {
327 let payload = json!({
330 "action_id": "aac_1f2e3d4c",
331 "chain_root": "u3v9xJ2kQm4Zr8pW1sTnA7bCdEfGhIjKlMnOpQrStUv",
332 "decision_seq": "1042",
333 "clean": true
334 });
335 let err = validate("memory.quarantine-check.v1", Some(&payload)).unwrap_err();
336 assert_eq!(
337 err,
338 PredicateError::TypeMismatch {
339 suffix: "memory.quarantine-check.v1".into(),
340 field: "decision_seq".into(),
341 expected: "\"integer\"".into()
342 }
343 );
344 }
345
346 #[test]
347 fn blocked_valid_passes() {
348 let payload = json!({
349 "reason_class": "quarantine_triggered",
350 "refused_kind": "approval",
351 "approver": "human://alice",
352 "irreversibility": "one_way_consequential",
353 "description": "quarantine check reports DIRTY",
354 "quarantine_receipt": "art_deadbeef00112233"
355 });
356 assert!(validate("blocked.v1", Some(&payload)).is_ok());
357 }
358
359 #[test]
360 fn blocked_out_of_vocabulary_reason_fails_closed() {
361 let payload = json!({
365 "reason_class": "just_felt_like_it",
366 "refused_kind": "approval"
367 });
368 let err = validate("blocked.v1", Some(&payload)).unwrap_err();
369 assert!(
370 matches!(err, PredicateError::NotInEnum { ref field, .. } if field == "reason_class"),
371 "expected NotInEnum on reason_class, got {err:?}"
372 );
373 }
374
375 #[test]
376 fn blocked_missing_reason_fails_closed() {
377 let payload = json!({ "refused_kind": "approval" });
378 let err = validate("blocked.v1", Some(&payload)).unwrap_err();
379 assert_eq!(
380 err,
381 PredicateError::MissingField {
382 suffix: "blocked.v1".into(),
383 field: "reason_class".into()
384 }
385 );
386 }
387
388 #[test]
389 fn unregistered_suffix_is_backward_compatible() {
390 assert!(validate("custom.kind.v1", None).is_ok());
392 assert!(validate("custom.kind.v1", Some(&json!({"anything": 1}))).is_ok());
393 }
394
395 #[test]
396 fn agent_cert_valid_passes() {
397 let payload = json!({
398 "agent": "agent://deployer",
399 "subject_key_id": "key_abc123",
400 "subject_public_key": "vEQfSDqVCz4rtqbu5iuhpFuYrah6QALUSCGJYdOKeCY",
401 "issuer": "ship://ship_b49ff5f291a279c7",
402 "issued_at": "2026-07-06T12:00:00Z",
403 "valid_until": "2027-07-06T12:00:00Z",
404 "model": "claude-fable-5",
405 "description": null
406 });
407 assert!(validate("agent_cert.v1", Some(&payload)).is_ok());
408 }
409
410 #[test]
411 fn agent_cert_missing_subject_key_fails_closed() {
412 let payload = json!({
413 "agent": "agent://deployer",
414 "subject_key_id": "key_abc123",
415 "issuer": "ship://ship_x",
416 "issued_at": "2026-07-06T12:00:00Z",
417 "valid_until": "2027-07-06T12:00:00Z"
418 }); let err = validate("agent_cert.v1", Some(&payload)).unwrap_err();
420 assert_eq!(
421 err,
422 PredicateError::MissingField {
423 suffix: "agent_cert.v1".into(),
424 field: "subject_public_key".into()
425 }
426 );
427 }
428
429 #[test]
430 fn session_record_valid_passes() {
431 let payload = json!({
432 "session_id": "ssn_abc123",
433 "actor": "agent://hermes",
434 "headline": "Fixed keystore hostname-drift bug",
435 "outcome": "completed",
436 "started_at": "2026-07-06T14:00:00Z",
437 "closed_at": "2026-07-06T15:30:00Z",
438 "duration_ms": 5400000,
439 "harness": "claude-code",
440 "attestation_class": "runtime",
441 "action_count": 212,
442 "approval_count": 2,
443 "handoff_count": 0,
444 "event_count": 340,
445 "tools_exercised": ["Bash(git:*)", "Edit(*)"],
446 "receipt_digest": "sha256:deadbeef",
447 "receipt_merkle_root": "sha256:cafebabe",
448 "report_url": null
449 });
450 assert!(validate("session.v1", Some(&payload)).is_ok());
451 }
452
453 #[test]
454 fn session_record_out_of_enum_class_fails_closed() {
455 let payload = json!({
459 "session_id": "ssn_abc123",
460 "actor": "agent://hermes",
461 "outcome": "completed",
462 "started_at": "2026-07-06T14:00:00Z",
463 "closed_at": "2026-07-06T15:30:00Z",
464 "attestation_class": "super-trusted",
465 "receipt_digest": "sha256:deadbeef"
466 });
467 let err = validate("session.v1", Some(&payload)).unwrap_err();
468 assert!(
469 matches!(err, PredicateError::NotInEnum { ref field, .. } if field == "attestation_class"),
470 "expected NotInEnum for attestation_class, got {err:?}"
471 );
472 }
473
474 #[test]
475 fn session_record_out_of_enum_outcome_fails_closed() {
476 let payload = json!({
478 "session_id": "ssn_abc123",
479 "actor": "agent://hermes",
480 "outcome": "totally-shipped",
481 "started_at": "2026-07-06T14:00:00Z",
482 "closed_at": "2026-07-06T15:30:00Z",
483 "attestation_class": "self",
484 "receipt_digest": "sha256:deadbeef"
485 });
486 assert!(matches!(
487 validate("session.v1", Some(&payload)).unwrap_err(),
488 PredicateError::NotInEnum { .. }
489 ));
490 }
491
492 #[test]
493 fn session_record_missing_required_fails_closed() {
494 let payload = json!({
495 "session_id": "ssn_abc123",
496 "actor": "agent://hermes",
497 "outcome": "completed",
498 "started_at": "2026-07-06T14:00:00Z",
499 "closed_at": "2026-07-06T15:30:00Z",
500 "receipt_digest": "sha256:deadbeef"
501 }); let err = validate("session.v1", Some(&payload)).unwrap_err();
503 assert_eq!(
504 err,
505 PredicateError::MissingField {
506 suffix: "session.v1".into(),
507 field: "attestation_class".into()
508 }
509 );
510 }
511
512 #[test]
513 fn session_record_wrong_type_fails_closed() {
514 let payload = json!({
515 "session_id": "ssn_abc123",
516 "actor": "agent://hermes",
517 "outcome": "completed",
518 "started_at": "2026-07-06T14:00:00Z",
519 "closed_at": "2026-07-06T15:30:00Z",
520 "attestation_class": "runtime",
521 "receipt_digest": "sha256:deadbeef",
522 "tools_exercised": "Bash(git:*)"
523 }); let err = validate("session.v1", Some(&payload)).unwrap_err();
525 assert!(matches!(err, PredicateError::TypeMismatch { .. }));
526 }
527
528 #[test]
529 fn memory_write_valid_passes() {
530 let payload = json!({
531 "memory_id": "mem_abc",
532 "content_hash": "sha256:deadbeef",
533 "memory_type": "episodic",
534 "scope": "tenant://acme",
535 "activegraph_run_id": "run_1",
536 "supersedes": null
537 });
538 assert!(validate("memory.write.v1", Some(&payload)).is_ok());
539 }
540
541 #[test]
542 fn memory_write_missing_required_fails_closed() {
543 let payload = json!({
544 "memory_id": "mem_abc",
545 "memory_type": "episodic",
546 "scope": "tenant://acme"
547 }); let err = validate("memory.write.v1", Some(&payload)).unwrap_err();
549 assert_eq!(
550 err,
551 PredicateError::MissingField {
552 suffix: "memory.write.v1".into(),
553 field: "content_hash".into()
554 }
555 );
556 }
557
558 #[test]
559 fn memory_write_wrong_type_fails() {
560 let payload = json!({
561 "memory_id": "mem_abc",
562 "content_hash": 12345, "memory_type": "episodic",
564 "scope": "tenant://acme"
565 });
566 let err = validate("memory.write.v1", Some(&payload)).unwrap_err();
567 assert!(
568 matches!(err, PredicateError::TypeMismatch { field, .. } if field == "content_hash")
569 );
570 }
571
572 #[test]
573 fn memory_write_nullable_supersedes_accepts_string_and_null() {
574 let base = |sup: Value| {
575 json!({
576 "memory_id": "m", "content_hash": "h", "memory_type": "t", "scope": "s",
577 "supersedes": sup
578 })
579 };
580 assert!(validate("memory.write.v1", Some(&base(json!("mem_old")))).is_ok());
581 assert!(validate("memory.write.v1", Some(&base(Value::Null))).is_ok());
582 assert!(validate("memory.write.v1", Some(&base(json!(7)))).is_err());
584 }
585
586 #[test]
587 fn registered_predicate_requires_a_payload() {
588 let err = validate("memory.write.v1", None).unwrap_err();
589 assert!(matches!(err, PredicateError::MissingField { .. }));
590 }
591
592 #[test]
593 fn memory_read_valid_and_integer_enforced() {
594 let ok = json!({
595 "zmem_receipt_id": "act_1",
596 "trace_sha256": "abcd",
597 "query_hash": "qh",
598 "retrieval_mode": "semantic",
599 "memories_returned": 3
600 });
601 assert!(validate("memory.read.v1", Some(&ok)).is_ok());
602
603 let bad = json!({
604 "zmem_receipt_id": "act_1",
605 "trace_sha256": "abcd",
606 "query_hash": "qh",
607 "retrieval_mode": "semantic",
608 "memories_returned": "three" });
610 assert!(matches!(
611 validate("memory.read.v1", Some(&bad)).unwrap_err(),
612 PredicateError::TypeMismatch { field, .. } if field == "memories_returned"
613 ));
614 }
615
616 #[test]
617 fn memory_read_missing_required_fails() {
618 let payload = json!({
619 "zmem_receipt_id": "act_1",
620 "trace_sha256": "abcd",
621 "retrieval_mode": "semantic",
622 "memories_returned": 3
623 }); assert!(matches!(
625 validate("memory.read.v1", Some(&payload)).unwrap_err(),
626 PredicateError::MissingField { field, .. } if field == "query_hash"
627 ));
628 }
629
630 #[test]
631 fn boundary_structural_required_fields_enforced() {
632 let valid = json!({
636 "schema": "treeship.boundary.v1",
637 "subject_ref": "art_aabbccdd11223344",
638 "actor": {"uri": "agent://codex", "keyid": "key_aaaa1111"},
639 "checker": {"uri": "human://alice", "keyid": "key_bbbb2222"},
640 "decision": "allow",
641 "policy": {"digest": "sha256:p"},
642 "diet_root": "sha256:r",
643 "diet": [{"type": "memory_bundle", "digest": "sha256:d"}],
644 "committed_at": {"anchor": "merkle://zmem/checkpoint#4821", "ts": "2026-06-06T00:00:00Z"}
645 });
646 assert!(validate("boundary.v1", Some(&valid)).is_ok());
647
648 let mut wrong = valid.clone();
650 wrong.as_object_mut().unwrap()["committed_at"] = json!("not-an-object");
651 assert!(matches!(
652 validate("boundary.v1", Some(&wrong)).unwrap_err(),
653 PredicateError::TypeMismatch { field, .. } if field == "committed_at"
654 ));
655
656 let mut missing = valid.clone();
657 missing.as_object_mut().unwrap().remove("decision");
658 assert!(matches!(
659 validate("boundary.v1", Some(&missing)).unwrap_err(),
660 PredicateError::MissingField { field, .. } if field == "decision"
661 ));
662 }
663
664 #[test]
665 fn agent_card_valid_passes() {
666 let card = json!({
667 "schema": "agent_card.v1",
668 "agent": "agent://deployer",
669 "keyid": "key_9f8e7d6c",
670 "owner": "human://alice",
671 "version": "1.2.0",
672 "capabilities": {
673 "tools": ["file.read", "file.write", "db.*"],
674 "models": ["claude-sonnet-4"],
675 "can_delegate": true
676 },
677 "evidence_anchor": { "receipt_count": 1247, "merkle_root": "mroot_a0be" },
678 "supersedes": null
679 });
680 assert!(validate("agent_card.v1", Some(&card)).is_ok());
681 }
682
683 #[test]
684 fn agent_card_missing_keyid_fails_closed() {
685 let card = json!({
687 "schema": "agent_card.v1",
688 "agent": "agent://deployer",
689 "version": "1.0.0",
690 "capabilities": { "tools": ["file.read"] }
691 });
692 assert!(matches!(
693 validate("agent_card.v1", Some(&card)).unwrap_err(),
694 PredicateError::MissingField { field, .. } if field == "keyid"
695 ));
696 }
697
698 #[test]
699 fn agent_card_capabilities_must_be_an_object() {
700 let card = json!({
701 "schema": "agent_card.v1",
702 "agent": "agent://deployer",
703 "keyid": "key_1",
704 "version": "1.0.0",
705 "capabilities": ["file.read"] });
707 assert!(matches!(
708 validate("agent_card.v1", Some(&card)).unwrap_err(),
709 PredicateError::TypeMismatch { field, .. } if field == "capabilities"
710 ));
711 }
712
713 #[test]
714 fn agent_card_revocation_valid_passes() {
715 let rev = json!({
716 "schema": "agent_card_revocation.v1",
717 "card": "art_deadbeefdeadbeef",
718 "keyid": "key_1",
719 "reason": "key-rotation",
720 "revoked_at": "2026-06-23T00:00:00Z"
721 });
722 assert!(validate("agent_card_revocation.v1", Some(&rev)).is_ok());
723 }
724
725 #[test]
726 fn agent_card_revocation_requires_card_id() {
727 let rev = json!({
728 "schema": "agent_card_revocation.v1",
729 "revoked_at": "2026-06-23T00:00:00Z"
730 });
732 assert!(matches!(
733 validate("agent_card_revocation.v1", Some(&rev)).unwrap_err(),
734 PredicateError::MissingField { field, .. } if field == "card"
735 ));
736 }
737}