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