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 "verification.packet.v1",
70 include_str!("schemas/verification.packet.v1.json"),
71 ),
72 (
73 "verification.recompute.v1",
74 include_str!("schemas/verification.recompute.v1.json"),
75 ),
76 ("evaluation.v1", include_str!("schemas/evaluation.v1.json")),
77 ("coverage.v1", include_str!("schemas/coverage.v1.json")),
78 ("halt.v1", include_str!("schemas/halt.v1.json")),
79 ("judgement.v1", include_str!("schemas/judgement.v1.json")),
80];
81
82pub fn schema_json(suffix: &str) -> Option<&'static str> {
85 REGISTRY.iter().find(|(k, _)| *k == suffix).map(|(_, s)| *s)
86}
87
88pub fn registered_suffixes() -> Vec<&'static str> {
90 REGISTRY.iter().map(|(k, _)| *k).collect()
91}
92
93#[derive(Debug, Clone, PartialEq, Eq)]
95pub enum PredicateError {
96 MissingField { suffix: String, field: String },
98 TypeMismatch {
100 suffix: String,
101 field: String,
102 expected: String,
103 },
104 NotAnObject { suffix: String },
106 NotInEnum {
110 suffix: String,
111 field: String,
112 allowed: String,
113 },
114 SchemaParse { suffix: String, detail: String },
116 InvalidPayload { suffix: String, detail: String },
120}
121
122impl fmt::Display for PredicateError {
123 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
124 match self {
125 PredicateError::MissingField { suffix, field } => {
126 write!(f, "{suffix}: missing required field `{field}`")
127 }
128 PredicateError::TypeMismatch {
129 suffix,
130 field,
131 expected,
132 } => write!(
133 f,
134 "{suffix}: field `{field}` has the wrong type (expected {expected})"
135 ),
136 PredicateError::NotAnObject { suffix } => {
137 write!(f, "{suffix}: payload must be a JSON object")
138 }
139 PredicateError::NotInEnum {
140 suffix,
141 field,
142 allowed,
143 } => write!(
144 f,
145 "{suffix}: field `{field}` has a value outside its allowed set ({allowed})"
146 ),
147 PredicateError::SchemaParse { suffix, detail } => {
148 write!(f, "{suffix}: registered schema is invalid JSON: {detail}")
149 }
150 PredicateError::InvalidPayload { suffix, detail } => {
151 write!(f, "{suffix}: invalid payload: {detail}")
152 }
153 }
154 }
155}
156
157impl std::error::Error for PredicateError {}
158
159pub fn validate(suffix: &str, payload: Option<&Value>) -> Result<(), PredicateError> {
168 let Some(schema_str) = schema_json(suffix) else {
169 return Ok(());
170 };
171 let schema: Value =
172 serde_json::from_str(schema_str).map_err(|e| PredicateError::SchemaParse {
173 suffix: suffix.to_string(),
174 detail: e.to_string(),
175 })?;
176
177 let empty = Value::Object(serde_json::Map::new());
180 let value = payload.unwrap_or(&empty);
181 let map = value
182 .as_object()
183 .ok_or_else(|| PredicateError::NotAnObject {
184 suffix: suffix.to_string(),
185 })?;
186
187 walk_object(suffix, "", &schema, map)?;
195
196 if suffix == "workflow.v1" {
201 use crate::verify::workflow_conformance::WorkflowDeclaration;
202 let declaration: WorkflowDeclaration =
203 serde_json::from_value(value.clone()).map_err(|e| PredicateError::InvalidPayload {
204 suffix: suffix.to_string(),
205 detail: e.to_string(),
206 })?;
207 declaration
208 .validate()
209 .map_err(|errors| PredicateError::InvalidPayload {
210 suffix: suffix.to_string(),
211 detail: errors
212 .iter()
213 .map(ToString::to_string)
214 .collect::<Vec<_>>()
215 .join("; "),
216 })?;
217 }
218
219 Ok(())
220}
221
222fn walk_object(
225 suffix: &str,
226 path: &str,
227 schema: &Value,
228 map: &serde_json::Map<String, Value>,
229) -> Result<(), PredicateError> {
230 let at = |field: &str| -> String {
231 if path.is_empty() {
232 field.to_string()
233 } else {
234 format!("{path}.{field}")
235 }
236 };
237 if let Some(required) = schema.get("required").and_then(Value::as_array) {
238 for entry in required {
239 if let Some(name) = entry.as_str() {
240 if !map.contains_key(name) {
241 return Err(PredicateError::MissingField {
242 suffix: suffix.to_string(),
243 field: at(name),
244 });
245 }
246 }
247 }
248 }
249 if let Some(props) = schema.get("properties").and_then(Value::as_object) {
250 for (field, subschema) in props {
251 let Some(actual) = map.get(field) else {
252 continue; };
254 walk_value(suffix, &at(field), subschema, actual)?;
255 }
256 }
257 if let Some(extra) = schema.get("additionalProperties") {
258 if extra.is_object() {
259 if let Some(declared) = schema.get("properties").and_then(Value::as_object) {
260 for (field, actual) in map {
261 if !declared.contains_key(field) {
262 walk_value(suffix, &at(field), extra, actual)?;
263 }
264 }
265 } else {
266 for (field, actual) in map {
267 walk_value(suffix, &at(field), extra, actual)?;
268 }
269 }
270 }
271 }
272 Ok(())
273}
274
275fn walk_value(
278 suffix: &str,
279 path: &str,
280 subschema: &Value,
281 actual: &Value,
282) -> Result<(), PredicateError> {
283 if let Some(type_decl) = subschema.get("type") {
284 if !type_matches(actual, type_decl) {
285 return Err(PredicateError::TypeMismatch {
286 suffix: suffix.to_string(),
287 field: path.to_string(),
288 expected: type_decl.to_string(),
289 });
290 }
291 }
292 if let Some(allowed) = subschema.get("enum").and_then(Value::as_array) {
296 if !allowed.iter().any(|a| a == actual) {
297 return Err(PredicateError::NotInEnum {
298 suffix: suffix.to_string(),
299 field: path.to_string(),
300 allowed: Value::Array(allowed.clone()).to_string(),
301 });
302 }
303 }
304 if let Some(constant) = subschema.get("const") {
305 if actual != constant {
306 return Err(PredicateError::NotInEnum {
307 suffix: suffix.to_string(),
308 field: path.to_string(),
309 allowed: constant.to_string(),
310 });
311 }
312 }
313 if let Some(n) = actual.as_f64() {
314 if let Some(min) = subschema.get("minimum").and_then(Value::as_f64) {
315 if n < min {
316 return Err(PredicateError::InvalidPayload {
317 suffix: suffix.to_string(),
318 detail: format!("{path} is {n}, below the minimum {min}"),
319 });
320 }
321 }
322 if let Some(max) = subschema.get("maximum").and_then(Value::as_f64) {
323 if n > max {
324 return Err(PredicateError::InvalidPayload {
325 suffix: suffix.to_string(),
326 detail: format!("{path} is {n}, above the maximum {max}"),
327 });
328 }
329 }
330 }
331 if let (Some(s), Some(pat)) = (
332 actual.as_str(),
333 subschema.get("pattern").and_then(Value::as_str),
334 ) {
335 if !pattern_matches(pat, s) {
336 return Err(PredicateError::InvalidPayload {
337 suffix: suffix.to_string(),
338 detail: format!("{path} does not match {pat}"),
339 });
340 }
341 }
342 match actual {
343 Value::Object(inner) => walk_object(suffix, path, subschema, inner)?,
344 Value::Array(items) => {
345 if let Some(item_schema) = subschema.get("items") {
346 for (i, item) in items.iter().enumerate() {
347 walk_value(suffix, &format!("{path}[{i}]"), item_schema, item)?;
348 }
349 }
350 }
351 _ => {}
352 }
353 Ok(())
354}
355
356fn pattern_matches(pat: &str, s: &str) -> bool {
361 let Some(body) = pat.strip_prefix('^').and_then(|p| p.strip_suffix('$')) else {
362 return true;
363 };
364 let Some(open) = body.find('[') else {
365 return true;
366 };
367 let literal = &body[..open];
368 let Some(close) = body[open..].find(']') else {
369 return true;
370 };
371 let class = &body[open + 1..open + close];
372 let rest = &body[open + close + 1..];
373 let Some(count) = rest
374 .strip_prefix('{')
375 .and_then(|r| r.strip_suffix('}'))
376 .and_then(|n| n.parse::<usize>().ok())
377 else {
378 return true;
379 };
380 let Some(tail) = s.strip_prefix(literal) else {
381 return false;
382 };
383 if tail.chars().count() != count {
384 return false;
385 }
386 let in_class = |c: char| -> bool {
387 let cs: Vec<char> = class.chars().collect();
388 let mut i = 0;
389 while i < cs.len() {
390 if i + 2 < cs.len() && cs[i + 1] == '-' {
391 if c >= cs[i] && c <= cs[i + 2] {
392 return true;
393 }
394 i += 3;
395 } else {
396 if c == cs[i] {
397 return true;
398 }
399 i += 1;
400 }
401 }
402 false
403 };
404 tail.chars().all(in_class)
405}
406
407fn type_matches(value: &Value, type_decl: &Value) -> bool {
410 match type_decl {
411 Value::String(t) => json_is(value, t),
412 Value::Array(types) => types
413 .iter()
414 .any(|t| t.as_str().is_some_and(|t| json_is(value, t))),
415 _ => true,
418 }
419}
420
421fn json_is(value: &Value, ty: &str) -> bool {
424 match ty {
425 "string" => value.is_string(),
426 "integer" => value.is_i64() || value.is_u64(),
427 "number" => value.is_number(),
428 "boolean" => value.is_boolean(),
429 "object" => value.is_object(),
430 "array" => value.is_array(),
431 "null" => value.is_null(),
432 _ => true,
434 }
435}
436
437#[cfg(test)]
438mod tests {
439 use super::*;
440 use serde_json::json;
441
442 #[test]
443 fn registry_lists_the_three_seed_predicates() {
444 let suffixes = registered_suffixes();
445 assert!(suffixes.contains(&"memory.write.v1"));
446 assert!(suffixes.contains(&"memory.read.v1"));
447 assert!(suffixes.contains(&"boundary.v1"));
448 assert!(suffixes.contains(&"agent_card.v1"));
449 assert!(schema_json("memory.write.v1").is_some());
450 assert!(schema_json("nope.v1").is_none());
451 }
452
453 #[test]
454 fn embedded_schemas_parse() {
455 for s in registered_suffixes() {
456 let raw = schema_json(s).unwrap();
457 serde_json::from_str::<Value>(raw).expect("embedded schema must be valid JSON");
458 }
459 }
460
461 #[test]
462 fn workflow_declaration_runs_full_typed_validation_before_signing() {
463 let valid: Value = serde_json::from_str(include_str!(
464 "../../tests/fixtures/workflow-conformance/declaration.json"
465 ))
466 .expect("golden workflow declaration parses");
467 assert!(validate("workflow.v1", Some(&valid)).is_ok());
468
469 let mut unknown_field = valid.clone();
470 unknown_field["nodes"][0]["retry_policy"] = json!({ "max": 99 });
471 assert!(matches!(
472 validate("workflow.v1", Some(&unknown_field)),
473 Err(PredicateError::InvalidPayload { .. })
474 ));
475
476 let mut missing_allowed_tools = valid.clone();
477 missing_allowed_tools["nodes"][0]
478 .as_object_mut()
479 .expect("workflow node is an object")
480 .remove("allowed_tools");
481 let error = validate("workflow.v1", Some(&missing_allowed_tools))
482 .expect_err("schema-required nested fields must be refused before signing");
483 assert!(matches!(
486 error,
487 PredicateError::InvalidPayload { .. } | PredicateError::MissingField { .. }
488 ));
489 assert!(error.to_string().contains("allowed_tools"));
490
491 let mut unbounded_cycle = valid;
492 unbounded_cycle["edges"]
493 .as_array_mut()
494 .expect("edges is an array")
495 .push(json!({ "from": "finish", "to": "inspect", "when": "always" }));
496 let error = validate("workflow.v1", Some(&unbounded_cycle))
497 .expect_err("an undeclared workflow cycle must be refused before signing");
498 assert!(matches!(error, PredicateError::InvalidPayload { .. }));
499 assert!(error.to_string().contains("bounded loop"));
500 }
501
502 #[test]
503 fn quarantine_check_valid_passes() {
504 let payload = json!({
505 "action_id": "aac_1f2e3d4c",
506 "provider": "system://zmem",
507 "chain_root": "u3v9xJ2kQm4Zr8pW1sTnA7bCdEfGhIjKlMnOpQrStUv",
508 "decision_seq": 1042,
509 "clean": true,
510 "quarantined_triggers": [],
511 "checked_at": "2026-07-17T19:00:00Z"
512 });
513 assert!(validate("memory.quarantine-check.v1", Some(&payload)).is_ok());
514 }
515
516 #[test]
517 fn quarantine_check_missing_verdict_fails_closed() {
518 let payload = json!({
519 "action_id": "aac_1f2e3d4c",
520 "chain_root": "u3v9xJ2kQm4Zr8pW1sTnA7bCdEfGhIjKlMnOpQrStUv",
521 "decision_seq": 1042
522 }); let err = validate("memory.quarantine-check.v1", Some(&payload)).unwrap_err();
524 assert_eq!(
525 err,
526 PredicateError::MissingField {
527 suffix: "memory.quarantine-check.v1".into(),
528 field: "clean".into()
529 }
530 );
531 }
532
533 #[test]
534 fn quarantine_check_stringly_typed_verdict_fails_closed() {
535 let payload = json!({
539 "action_id": "aac_1f2e3d4c",
540 "chain_root": "u3v9xJ2kQm4Zr8pW1sTnA7bCdEfGhIjKlMnOpQrStUv",
541 "decision_seq": 1042,
542 "clean": "true"
543 });
544 let err = validate("memory.quarantine-check.v1", Some(&payload)).unwrap_err();
545 assert_eq!(
546 err,
547 PredicateError::TypeMismatch {
548 suffix: "memory.quarantine-check.v1".into(),
549 field: "clean".into(),
550 expected: "\"boolean\"".into()
551 }
552 );
553 }
554
555 #[test]
556 fn quarantine_check_non_integer_seq_fails_closed() {
557 let payload = json!({
560 "action_id": "aac_1f2e3d4c",
561 "chain_root": "u3v9xJ2kQm4Zr8pW1sTnA7bCdEfGhIjKlMnOpQrStUv",
562 "decision_seq": "1042",
563 "clean": true
564 });
565 let err = validate("memory.quarantine-check.v1", Some(&payload)).unwrap_err();
566 assert_eq!(
567 err,
568 PredicateError::TypeMismatch {
569 suffix: "memory.quarantine-check.v1".into(),
570 field: "decision_seq".into(),
571 expected: "\"integer\"".into()
572 }
573 );
574 }
575
576 fn reason_authorization_payload() -> Value {
580 json!({
581 "schema": "zerker.reason.authorization.v1",
582 "status": "authorized",
583 "request_digest": format!("sha256:{}", "1".repeat(64)),
584 "mission": {
585 "id": "mission_release_140",
586 "digest": format!("sha256:{}", "2".repeat(64))
587 },
588 "action": {
589 "id": "action_deploy_140",
590 "digest": format!("sha256:{}", "3".repeat(64)),
591 "tool": "deploy_release",
592 "arguments": {"environment": "production"},
593 "effects": []
594 },
595 "reasoning": {
596 "schema": "zerker.reason.result.v2",
597 "status": "proved",
598 "query": {"predicate": "authorized", "arguments": ["action_deploy_140"]},
599 "program_digest": format!("sha256:{}", "4".repeat(64)),
600 "ontology": {},
601 "authority": {"classes": ["human-authorized"], "default_admit": ["human-authorized"]},
602 "proof": {"root": "authorized(action_deploy_140)"},
603 "disproof": null,
604 "conflict": null,
605 "missing": [],
606 "assumptions": [],
607 "metrics": {"facts": 3, "rules": 1}
608 },
609 "issues": []
610 })
611 }
612
613 #[test]
614 fn reason_authorization_valid_shape_passes() {
615 assert!(validate(
616 "reason.authorization.v1",
617 Some(&reason_authorization_payload())
618 )
619 .is_ok());
620 }
621
622 #[test]
623 fn reason_authorization_nested_required_is_enforced() {
624 let mut p = reason_authorization_payload();
626 p["action"].as_object_mut().unwrap().remove("id");
627 assert_eq!(
628 validate("reason.authorization.v1", Some(&p)),
629 Err(PredicateError::MissingField {
630 suffix: "reason.authorization.v1".into(),
631 field: "action.id".into()
632 })
633 );
634 let mut p = reason_authorization_payload();
635 p["reasoning"]["status"] = json!("vibes");
636 assert!(matches!(
637 validate("reason.authorization.v1", Some(&p)),
638 Err(PredicateError::NotInEnum { field, .. }) if field == "reasoning.status"
639 ));
640 }
641
642 #[test]
643 fn reason_authorization_missing_request_digest_fails_closed() {
644 let mut payload = reason_authorization_payload();
645 payload.as_object_mut().unwrap().remove("request_digest");
646 let err = validate("reason.authorization.v1", Some(&payload)).unwrap_err();
647 assert_eq!(
648 err,
649 PredicateError::MissingField {
650 suffix: "reason.authorization.v1".into(),
651 field: "request_digest".into()
652 }
653 );
654 }
655
656 #[test]
657 fn reason_authorization_out_of_vocabulary_status_fails_closed() {
658 let mut payload = reason_authorization_payload();
659 payload["status"] = json!("probably_safe");
660 let err = validate("reason.authorization.v1", Some(&payload)).unwrap_err();
661 assert!(
662 matches!(&err, PredicateError::NotInEnum { field, .. } if field == "status"),
663 "expected NotInEnum on status, got {err:?}"
664 );
665 }
666
667 #[test]
668 fn reason_authorization_wrong_action_shape_fails_closed() {
669 let payload = json!({
670 "schema": "zerker.reason.authorization.v1",
671 "status": "denied",
672 "request_digest": format!("sha256:{}", "1".repeat(64)),
673 "mission": {},
674 "action": "action_deploy_140",
675 "reasoning": {},
676 "issues": []
677 });
678 let err = validate("reason.authorization.v1", Some(&payload)).unwrap_err();
679 assert_eq!(
680 err,
681 PredicateError::TypeMismatch {
682 suffix: "reason.authorization.v1".into(),
683 field: "action".into(),
684 expected: "\"object\"".into()
685 }
686 );
687 }
688
689 #[test]
690 fn blocked_valid_passes() {
691 let payload = json!({
692 "reason_class": "quarantine_triggered",
693 "refused_kind": "approval",
694 "approver": "human://alice",
695 "irreversibility": "one_way_consequential",
696 "description": "quarantine check reports DIRTY",
697 "quarantine_receipt": "art_deadbeef00112233"
698 });
699 assert!(validate("blocked.v1", Some(&payload)).is_ok());
700 }
701
702 #[test]
703 fn blocked_out_of_vocabulary_reason_fails_closed() {
704 let payload = json!({
708 "reason_class": "just_felt_like_it",
709 "refused_kind": "approval"
710 });
711 let err = validate("blocked.v1", Some(&payload)).unwrap_err();
712 assert!(
713 matches!(err, PredicateError::NotInEnum { ref field, .. } if field == "reason_class"),
714 "expected NotInEnum on reason_class, got {err:?}"
715 );
716 }
717
718 #[test]
719 fn blocked_missing_reason_fails_closed() {
720 let payload = json!({ "refused_kind": "approval" });
721 let err = validate("blocked.v1", Some(&payload)).unwrap_err();
722 assert_eq!(
723 err,
724 PredicateError::MissingField {
725 suffix: "blocked.v1".into(),
726 field: "reason_class".into()
727 }
728 );
729 }
730
731 #[test]
732 fn unregistered_suffix_is_backward_compatible() {
733 assert!(validate("custom.kind.v1", None).is_ok());
735 assert!(validate("custom.kind.v1", Some(&json!({"anything": 1}))).is_ok());
736 }
737
738 #[test]
739 fn agent_cert_valid_passes() {
740 let payload = json!({
741 "agent": "agent://deployer",
742 "subject_key_id": "key_abc123",
743 "subject_public_key": "vEQfSDqVCz4rtqbu5iuhpFuYrah6QALUSCGJYdOKeCY",
744 "issuer": "ship://ship_b49ff5f291a279c7",
745 "issued_at": "2026-07-06T12:00:00Z",
746 "valid_until": "2027-07-06T12:00:00Z",
747 "model": "claude-fable-5",
748 "description": null
749 });
750 assert!(validate("agent_cert.v1", Some(&payload)).is_ok());
751 }
752
753 #[test]
754 fn agent_cert_missing_subject_key_fails_closed() {
755 let payload = json!({
756 "agent": "agent://deployer",
757 "subject_key_id": "key_abc123",
758 "issuer": "ship://ship_x",
759 "issued_at": "2026-07-06T12:00:00Z",
760 "valid_until": "2027-07-06T12:00:00Z"
761 }); let err = validate("agent_cert.v1", Some(&payload)).unwrap_err();
763 assert_eq!(
764 err,
765 PredicateError::MissingField {
766 suffix: "agent_cert.v1".into(),
767 field: "subject_public_key".into()
768 }
769 );
770 }
771
772 #[test]
773 fn session_record_valid_passes() {
774 let payload = json!({
775 "session_id": "ssn_abc123",
776 "actor": "agent://hermes",
777 "headline": "Fixed keystore hostname-drift bug",
778 "outcome": "completed",
779 "started_at": "2026-07-06T14:00:00Z",
780 "closed_at": "2026-07-06T15:30:00Z",
781 "duration_ms": 5400000,
782 "harness": "claude-code",
783 "attestation_class": "runtime",
784 "action_count": 212,
785 "approval_count": 2,
786 "handoff_count": 0,
787 "event_count": 340,
788 "tools_exercised": ["Bash(git:*)", "Edit(*)"],
789 "receipt_digest": "sha256:deadbeef",
790 "receipt_merkle_root": "sha256:cafebabe",
791 "report_url": null
792 });
793 assert!(validate("session.v1", Some(&payload)).is_ok());
794 }
795
796 #[test]
797 fn session_record_out_of_enum_class_fails_closed() {
798 let payload = json!({
802 "session_id": "ssn_abc123",
803 "actor": "agent://hermes",
804 "outcome": "completed",
805 "started_at": "2026-07-06T14:00:00Z",
806 "closed_at": "2026-07-06T15:30:00Z",
807 "attestation_class": "super-trusted",
808 "receipt_digest": "sha256:deadbeef"
809 });
810 let err = validate("session.v1", Some(&payload)).unwrap_err();
811 assert!(
812 matches!(err, PredicateError::NotInEnum { ref field, .. } if field == "attestation_class"),
813 "expected NotInEnum for attestation_class, got {err:?}"
814 );
815 }
816
817 #[test]
818 fn session_record_out_of_enum_outcome_fails_closed() {
819 let payload = json!({
821 "session_id": "ssn_abc123",
822 "actor": "agent://hermes",
823 "outcome": "totally-shipped",
824 "started_at": "2026-07-06T14:00:00Z",
825 "closed_at": "2026-07-06T15:30:00Z",
826 "attestation_class": "self",
827 "receipt_digest": "sha256:deadbeef"
828 });
829 assert!(matches!(
830 validate("session.v1", Some(&payload)).unwrap_err(),
831 PredicateError::NotInEnum { .. }
832 ));
833 }
834
835 #[test]
836 fn session_record_missing_required_fails_closed() {
837 let payload = json!({
838 "session_id": "ssn_abc123",
839 "actor": "agent://hermes",
840 "outcome": "completed",
841 "started_at": "2026-07-06T14:00:00Z",
842 "closed_at": "2026-07-06T15:30:00Z",
843 "receipt_digest": "sha256:deadbeef"
844 }); let err = validate("session.v1", Some(&payload)).unwrap_err();
846 assert_eq!(
847 err,
848 PredicateError::MissingField {
849 suffix: "session.v1".into(),
850 field: "attestation_class".into()
851 }
852 );
853 }
854
855 #[test]
856 fn session_record_wrong_type_fails_closed() {
857 let payload = json!({
858 "session_id": "ssn_abc123",
859 "actor": "agent://hermes",
860 "outcome": "completed",
861 "started_at": "2026-07-06T14:00:00Z",
862 "closed_at": "2026-07-06T15:30:00Z",
863 "attestation_class": "runtime",
864 "receipt_digest": "sha256:deadbeef",
865 "tools_exercised": "Bash(git:*)"
866 }); let err = validate("session.v1", Some(&payload)).unwrap_err();
868 assert!(matches!(err, PredicateError::TypeMismatch { .. }));
869 }
870
871 #[test]
872 fn memory_write_valid_passes() {
873 let payload = json!({
874 "memory_id": "mem_abc",
875 "content_hash": "sha256:deadbeef",
876 "memory_type": "episodic",
877 "scope": "tenant://acme",
878 "activegraph_run_id": "run_1",
879 "supersedes": null
880 });
881 assert!(validate("memory.write.v1", Some(&payload)).is_ok());
882 }
883
884 #[test]
885 fn memory_write_missing_required_fails_closed() {
886 let payload = json!({
887 "memory_id": "mem_abc",
888 "memory_type": "episodic",
889 "scope": "tenant://acme"
890 }); let err = validate("memory.write.v1", Some(&payload)).unwrap_err();
892 assert_eq!(
893 err,
894 PredicateError::MissingField {
895 suffix: "memory.write.v1".into(),
896 field: "content_hash".into()
897 }
898 );
899 }
900
901 #[test]
902 fn memory_write_wrong_type_fails() {
903 let payload = json!({
904 "memory_id": "mem_abc",
905 "content_hash": 12345, "memory_type": "episodic",
907 "scope": "tenant://acme"
908 });
909 let err = validate("memory.write.v1", Some(&payload)).unwrap_err();
910 assert!(
911 matches!(err, PredicateError::TypeMismatch { field, .. } if field == "content_hash")
912 );
913 }
914
915 #[test]
916 fn memory_write_nullable_supersedes_accepts_string_and_null() {
917 let base = |sup: Value| {
918 json!({
919 "memory_id": "m", "content_hash": "h", "memory_type": "t", "scope": "s",
920 "supersedes": sup
921 })
922 };
923 assert!(validate("memory.write.v1", Some(&base(json!("mem_old")))).is_ok());
924 assert!(validate("memory.write.v1", Some(&base(Value::Null))).is_ok());
925 assert!(validate("memory.write.v1", Some(&base(json!(7)))).is_err());
927 }
928
929 #[test]
930 fn registered_predicate_requires_a_payload() {
931 let err = validate("memory.write.v1", None).unwrap_err();
932 assert!(matches!(err, PredicateError::MissingField { .. }));
933 }
934
935 #[test]
936 fn memory_read_valid_and_integer_enforced() {
937 let ok = json!({
938 "zmem_receipt_id": "act_1",
939 "trace_sha256": "abcd",
940 "query_hash": "qh",
941 "retrieval_mode": "semantic",
942 "memories_returned": 3
943 });
944 assert!(validate("memory.read.v1", Some(&ok)).is_ok());
945
946 let bad = json!({
947 "zmem_receipt_id": "act_1",
948 "trace_sha256": "abcd",
949 "query_hash": "qh",
950 "retrieval_mode": "semantic",
951 "memories_returned": "three" });
953 assert!(matches!(
954 validate("memory.read.v1", Some(&bad)).unwrap_err(),
955 PredicateError::TypeMismatch { field, .. } if field == "memories_returned"
956 ));
957 }
958
959 #[test]
960 fn memory_read_missing_required_fails() {
961 let payload = json!({
962 "zmem_receipt_id": "act_1",
963 "trace_sha256": "abcd",
964 "retrieval_mode": "semantic",
965 "memories_returned": 3
966 }); assert!(matches!(
968 validate("memory.read.v1", Some(&payload)).unwrap_err(),
969 PredicateError::MissingField { field, .. } if field == "query_hash"
970 ));
971 }
972
973 #[test]
974 fn boundary_structural_required_fields_enforced() {
975 let valid = json!({
979 "schema": "treeship.boundary.v1",
980 "subject_ref": "art_aabbccdd11223344",
981 "actor": {"uri": "agent://codex", "keyid": "key_aaaa1111"},
982 "checker": {"uri": "human://alice", "keyid": "key_bbbb2222"},
983 "decision": "allow",
984 "policy": {"digest": "sha256:p"},
985 "diet_root": "sha256:r",
986 "diet": [{"type": "memory_bundle", "digest": "sha256:d"}],
987 "committed_at": {"anchor": "merkle://zmem/checkpoint#4821", "ts": "2026-06-06T00:00:00Z"}
988 });
989 assert!(validate("boundary.v1", Some(&valid)).is_ok());
990
991 let mut wrong = valid.clone();
993 wrong.as_object_mut().unwrap()["committed_at"] = json!("not-an-object");
994 assert!(matches!(
995 validate("boundary.v1", Some(&wrong)).unwrap_err(),
996 PredicateError::TypeMismatch { field, .. } if field == "committed_at"
997 ));
998
999 let mut missing = valid.clone();
1000 missing.as_object_mut().unwrap().remove("decision");
1001 assert!(matches!(
1002 validate("boundary.v1", Some(&missing)).unwrap_err(),
1003 PredicateError::MissingField { field, .. } if field == "decision"
1004 ));
1005 }
1006
1007 #[test]
1008 fn agent_card_valid_passes() {
1009 let card = json!({
1010 "schema": "agent_card.v1",
1011 "agent": "agent://deployer",
1012 "keyid": "key_9f8e7d6c",
1013 "owner": "human://alice",
1014 "version": "1.2.0",
1015 "capabilities": {
1016 "tools": ["file.read", "file.write", "db.*"],
1017 "models": ["claude-sonnet-4"],
1018 "can_delegate": true
1019 },
1020 "evidence_anchor": { "receipt_count": 1247, "merkle_root": "mroot_a0be" },
1021 "supersedes": null
1022 });
1023 assert!(validate("agent_card.v1", Some(&card)).is_ok());
1024 }
1025
1026 #[test]
1027 fn agent_card_missing_keyid_fails_closed() {
1028 let card = json!({
1030 "schema": "agent_card.v1",
1031 "agent": "agent://deployer",
1032 "version": "1.0.0",
1033 "capabilities": { "tools": ["file.read"] }
1034 });
1035 assert!(matches!(
1036 validate("agent_card.v1", Some(&card)).unwrap_err(),
1037 PredicateError::MissingField { field, .. } if field == "keyid"
1038 ));
1039 }
1040
1041 #[test]
1042 fn agent_card_capabilities_must_be_an_object() {
1043 let card = json!({
1044 "schema": "agent_card.v1",
1045 "agent": "agent://deployer",
1046 "keyid": "key_1",
1047 "version": "1.0.0",
1048 "capabilities": ["file.read"] });
1050 assert!(matches!(
1051 validate("agent_card.v1", Some(&card)).unwrap_err(),
1052 PredicateError::TypeMismatch { field, .. } if field == "capabilities"
1053 ));
1054 }
1055
1056 #[test]
1057 fn agent_card_revocation_valid_passes() {
1058 let rev = json!({
1059 "schema": "agent_card_revocation.v1",
1060 "card": "art_deadbeefdeadbeef",
1061 "keyid": "key_1",
1062 "reason": "key-rotation",
1063 "revoked_at": "2026-06-23T00:00:00Z"
1064 });
1065 assert!(validate("agent_card_revocation.v1", Some(&rev)).is_ok());
1066 }
1067
1068 #[test]
1069 fn agent_card_revocation_requires_card_id() {
1070 let rev = json!({
1071 "schema": "agent_card_revocation.v1",
1072 "revoked_at": "2026-06-23T00:00:00Z"
1073 });
1075 assert!(matches!(
1076 validate("agent_card_revocation.v1", Some(&rev)).unwrap_err(),
1077 PredicateError::MissingField { field, .. } if field == "card"
1078 ));
1079 }
1080
1081 fn packet_payload() -> serde_json::Value {
1082 json!({
1083 "schema": "verification.packet.v1",
1084 "packet_id": "pkt_000042",
1085 "stream_id": "tap-7/req-9f2a",
1086 "sequence": 42,
1087 "prev_packet_id": "pkt_000041",
1088 "model_digest": "sha256:aa11",
1089 "input_digest": "sha256:bb22",
1090 "output_digest": "sha256:cc33",
1091 "reproducibility": "bit_exact",
1092 "produced_at": "2026-09-17T15:00:00Z"
1093 })
1094 }
1095
1096 #[test]
1097 fn verification_packet_valid_passes() {
1098 assert!(validate("verification.packet.v1", Some(&packet_payload())).is_ok());
1099 }
1100
1101 #[test]
1102 fn verification_packet_missing_output_digest_fails_closed() {
1103 let mut p = packet_payload();
1104 p.as_object_mut().unwrap().remove("output_digest");
1105 assert_eq!(
1106 validate("verification.packet.v1", Some(&p)),
1107 Err(PredicateError::MissingField {
1108 suffix: "verification.packet.v1".into(),
1109 field: "output_digest".into(),
1110 })
1111 );
1112 }
1113
1114 #[test]
1115 fn verification_packet_stringly_typed_sequence_fails_closed() {
1116 let mut p = packet_payload();
1117 p["sequence"] = json!("42");
1118 assert!(matches!(
1119 validate("verification.packet.v1", Some(&p)),
1120 Err(PredicateError::TypeMismatch { field, .. }) if field == "sequence"
1121 ));
1122 }
1123
1124 #[test]
1125 fn verification_packet_out_of_vocabulary_reproducibility_fails_closed() {
1126 let mut p = packet_payload();
1127 p["reproducibility"] = json!("probably");
1128 assert!(matches!(
1129 validate("verification.packet.v1", Some(&p)),
1130 Err(PredicateError::NotInEnum { field, .. }) if field == "reproducibility"
1131 ));
1132 }
1133
1134 #[test]
1135 fn verification_packet_wrong_schema_const_fails_closed() {
1136 let mut p = packet_payload();
1137 p["schema"] = json!("verification.recompute.v1");
1138 assert!(matches!(
1139 validate("verification.packet.v1", Some(&p)),
1140 Err(PredicateError::NotInEnum { field, .. }) if field == "schema"
1141 ));
1142 }
1143
1144 fn recompute_payload() -> serde_json::Value {
1145 json!({
1146 "schema": "verification.recompute.v1",
1147 "packet": "art_0123456789abcdef0123456789abcdef",
1148 "packet_id": "pkt_000042",
1149 "method": "difr",
1150 "verdict": "match",
1151 "distance": 0.0012,
1152 "threshold": 0.01,
1153 "recomputed_at": "2026-09-17T15:05:00Z"
1154 })
1155 }
1156
1157 #[test]
1158 fn verification_recompute_valid_passes() {
1159 assert!(validate("verification.recompute.v1", Some(&recompute_payload())).is_ok());
1160 }
1161
1162 #[test]
1163 fn verification_recompute_out_of_vocabulary_verdict_fails_closed() {
1164 let mut p = recompute_payload();
1165 p["verdict"] = json!("mostly");
1166 assert!(matches!(
1167 validate("verification.recompute.v1", Some(&p)),
1168 Err(PredicateError::NotInEnum { field, .. }) if field == "verdict"
1169 ));
1170 }
1171
1172 #[test]
1173 fn verification_recompute_missing_packet_fails_closed() {
1174 let mut p = recompute_payload();
1175 p.as_object_mut().unwrap().remove("packet");
1176 assert_eq!(
1177 validate("verification.recompute.v1", Some(&p)),
1178 Err(PredicateError::MissingField {
1179 suffix: "verification.recompute.v1".into(),
1180 field: "packet".into(),
1181 })
1182 );
1183 }
1184
1185 fn evaluation_payload() -> serde_json::Value {
1186 json!({
1187 "schema": "evaluation.v1",
1188 "subject_kind": "model",
1189 "subject_digest": "sha256:aa11",
1190 "suite_id": "sandbox-escape-v3",
1191 "suite_digest": "sha256:bb22",
1192 "result_digest": "sha256:cc33",
1193 "verdict": "pass",
1194 "score": 0.02,
1195 "threshold": 0.05,
1196 "capability": "sandbox-escape",
1197 "evaluated_at": "2026-09-17T18:00:00Z"
1198 })
1199 }
1200
1201 #[test]
1202 fn evaluation_valid_passes() {
1203 assert!(validate("evaluation.v1", Some(&evaluation_payload())).is_ok());
1204 }
1205
1206 #[test]
1207 fn evaluation_out_of_vocabulary_verdict_fails_closed() {
1208 let mut p = evaluation_payload();
1209 p["verdict"] = json!("mostly");
1210 assert!(matches!(
1211 validate("evaluation.v1", Some(&p)),
1212 Err(PredicateError::NotInEnum { field, .. }) if field == "verdict"
1213 ));
1214 }
1215
1216 #[test]
1217 fn evaluation_out_of_vocabulary_subject_kind_fails_closed() {
1218 let mut p = evaluation_payload();
1219 p["subject_kind"] = json!("vibes");
1220 assert!(matches!(
1221 validate("evaluation.v1", Some(&p)),
1222 Err(PredicateError::NotInEnum { field, .. }) if field == "subject_kind"
1223 ));
1224 }
1225
1226 #[test]
1227 fn evaluation_missing_suite_digest_fails_closed() {
1228 let mut p = evaluation_payload();
1229 p.as_object_mut().unwrap().remove("suite_digest");
1230 assert_eq!(
1231 validate("evaluation.v1", Some(&p)),
1232 Err(PredicateError::MissingField {
1233 suffix: "evaluation.v1".into(),
1234 field: "suite_digest".into(),
1235 })
1236 );
1237 }
1238
1239 #[test]
1240 fn evaluation_stringly_typed_score_fails_closed() {
1241 let mut p = evaluation_payload();
1242 p["score"] = json!("0.02");
1243 assert!(matches!(
1244 validate("evaluation.v1", Some(&p)),
1245 Err(PredicateError::TypeMismatch { field, .. }) if field == "score"
1246 ));
1247 }
1248 fn coverage_payload() -> serde_json::Value {
1249 json!({
1250 "schema": "coverage.v1",
1251 "session_id": "ssn_0011223344556677",
1252 "actor": "agent://claude-code",
1253 "declared_level": "high",
1254 "harnesses": [{
1255 "harness_id": "claude-code",
1256 "status": "instrumented",
1257 "coverage": "high",
1258 "connection_modes": ["native-hook", "mcp", "git-reconcile"],
1259 "known_gaps": ["Built-in tools the user invokes outside hooks rely on git-reconcile."]
1260 }],
1261 "observed": {
1262 "events": 14,
1263 "event_types": {"session.started": 1, "agent.called_tool": 12, "session.closed": 1},
1264 "hosts": ["host_aa"],
1265 "agent_instances": 1,
1266 "event_log_skipped": 0
1267 },
1268 "gaps": ["Built-in tools the user invokes outside hooks rely on git-reconcile."],
1269 "closed_at": "2026-09-18T20:00:00Z"
1270 })
1271 }
1272
1273 #[test]
1274 fn coverage_valid_passes() {
1275 assert!(validate("coverage.v1", Some(&coverage_payload())).is_ok());
1276 }
1277
1278 #[test]
1279 fn coverage_requires_observed() {
1280 let mut p = coverage_payload();
1281 p.as_object_mut().unwrap().remove("observed");
1282 assert_eq!(
1283 validate("coverage.v1", Some(&p)),
1284 Err(PredicateError::MissingField {
1285 suffix: "coverage.v1".into(),
1286 field: "observed".into(),
1287 })
1288 );
1289 }
1290
1291 #[test]
1292 fn coverage_rejects_unknown_level() {
1293 let mut p = coverage_payload();
1294 p["declared_level"] = json!("total");
1295 assert!(validate("coverage.v1", Some(&p)).is_err());
1296 }
1297
1298 #[test]
1299 fn coverage_none_level_with_no_harnesses_is_valid() {
1300 let mut p = coverage_payload();
1301 p["declared_level"] = json!("none");
1302 p["harnesses"] = json!([]);
1303 assert!(validate("coverage.v1", Some(&p)).is_ok());
1304 }
1305
1306 #[test]
1307 fn halt_valid_passes() {
1308 let p = json!({"schema":"halt.v1","action":"halt","actor":"agent://claude-code","reason":"off-task network calls","issued_at":"2026-09-18T10:00:00Z"});
1309 assert!(validate("halt.v1", Some(&p)).is_ok());
1310 let l = json!({"schema":"halt.v1","action":"lift","actor":"agent://claude-code","halt":"art_0123","issued_at":"2026-09-18T11:00:00Z"});
1311 assert!(validate("halt.v1", Some(&l)).is_ok());
1312 }
1313
1314 #[test]
1315 fn halt_out_of_vocabulary_action_fails_closed() {
1316 let p = json!({"schema":"halt.v1","action":"pause","actor":"agent://x","issued_at":"2026-09-18T10:00:00Z"});
1317 assert!(matches!(
1318 validate("halt.v1", Some(&p)),
1319 Err(PredicateError::NotInEnum { field, .. }) if field == "action"
1320 ));
1321 }
1322
1323 #[test]
1324 fn halt_missing_actor_fails_closed() {
1325 let p = json!({"schema":"halt.v1","action":"halt","issued_at":"2026-09-18T10:00:00Z"});
1326 assert_eq!(
1327 validate("halt.v1", Some(&p)),
1328 Err(PredicateError::MissingField {
1329 suffix: "halt.v1".into(),
1330 field: "actor".into()
1331 })
1332 );
1333 }
1334 fn judgement_payload() -> serde_json::Value {
1335 json!({
1336 "schema": "judgement.v1",
1337 "judge": {"model": "jev-1.13.0", "provider": "typesafe", "kind": "decision-model", "replayable": false},
1338 "state_digest": format!("sha256:{}", "ab".repeat(32)),
1339 "questions_digest": format!("sha256:{}", "cd".repeat(32)),
1340 "question": {"key": "destructive", "type": "noul",
1341 "instructions": "Does this command delete or overwrite files outside the workspace?"},
1342 "answer": {"noul": 0.93},
1343 "threshold": {"value": 0.85, "applies_to": "noul", "set_by": "card:agent://claude-code"},
1344 "outcome": "refused",
1345 "effect": "deny",
1346 "latency_ms": 140,
1347 "judged_at": "2026-09-22T20:00:00Z"
1348 })
1349 }
1350
1351 #[test]
1352 fn judgement_valid_passes() {
1353 assert!(validate("judgement.v1", Some(&judgement_payload())).is_ok());
1354 }
1355
1356 #[test]
1357 fn judgement_rejects_unknown_outcome() {
1358 let mut p = judgement_payload();
1359 p["outcome"] = json!("shrugged");
1360 assert!(validate("judgement.v1", Some(&p)).is_err());
1361 }
1362
1363 #[test]
1364 fn judgement_rejects_unknown_question_type() {
1365 let mut p = judgement_payload();
1366 p["question"]["type"] = json!("essay");
1367 assert!(validate("judgement.v1", Some(&p)).is_err());
1368 }
1369
1370 #[test]
1371 fn judgement_requires_judge_model() {
1372 let mut p = judgement_payload();
1373 p["judge"] = json!({"provider": "typesafe"});
1374 assert!(validate("judgement.v1", Some(&p)).is_err());
1375 }
1376
1377 #[test]
1378 fn judgement_rejects_probability_out_of_range() {
1379 let mut p = judgement_payload();
1380 p["answer"]["noul"] = json!(1.4);
1381 assert!(validate("judgement.v1", Some(&p)).is_err());
1382 }
1383}