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