1use crate::model::SUPPORTED_VERSIONS;
14use av_core::error::JCS_SAFE_MAX;
15use serde_json::Value;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum Mode {
20 Strict,
22 Compat,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct ValidationIssue {
29 pub path: String,
31 pub message: String,
33}
34
35impl std::fmt::Display for ValidationIssue {
36 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37 write!(f, "{}: {}", self.path, self.message)
38 }
39}
40
41fn parse_version(v: &str) -> Option<(u32, u32)> {
43 let rest = v.strip_prefix("ATIF-v")?;
44 let (maj, min) = rest.split_once('.')?;
45 Some((maj.parse().ok()?, min.parse().ok()?))
46}
47
48pub fn validate_trajectory(t: &crate::model::Trajectory, mode: Mode) -> Vec<ValidationIssue> {
50 match serde_json::to_value(t) {
51 Ok(v) => validate_value(&v, mode),
52 Err(e) => vec![ValidationIssue {
53 path: "trajectory".into(),
54 message: format!("serialization failed: {e}"),
55 }],
56 }
57}
58
59pub fn validate_bytes(bytes: &[u8], mode: Mode) -> Result<Vec<ValidationIssue>, String> {
78 refuse_duplicate_json_keys(bytes)?;
79 let value: serde_json::Value =
80 serde_json::from_slice(bytes).map_err(|error| format!("parse JSON: {error}"))?;
81 Ok(validate_value(&value, mode))
82}
83
84fn refuse_duplicate_json_keys(raw: &[u8]) -> Result<(), String> {
101 use serde::de::{Deserializer as _, MapAccess, SeqAccess, Visitor};
102 use std::fmt;
103
104 const DUP_KEY_SENTINEL: &str = "__av_atif_dup:";
105
106 struct NoDupKeys;
107 impl<'de> Visitor<'de> for NoDupKeys {
108 type Value = ();
109 fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
110 f.write_str("any JSON value without duplicate object keys")
111 }
112 fn visit_bool<E>(self, _: bool) -> Result<(), E> {
113 Ok(())
114 }
115 fn visit_i64<E>(self, _: i64) -> Result<(), E> {
116 Ok(())
117 }
118 fn visit_u64<E>(self, _: u64) -> Result<(), E> {
119 Ok(())
120 }
121 fn visit_f64<E>(self, _: f64) -> Result<(), E> {
122 Ok(())
123 }
124 fn visit_str<E>(self, _: &str) -> Result<(), E> {
125 Ok(())
126 }
127 fn visit_string<E>(self, _: String) -> Result<(), E> {
128 Ok(())
129 }
130 fn visit_none<E>(self) -> Result<(), E> {
131 Ok(())
132 }
133 fn visit_unit<E>(self) -> Result<(), E> {
134 Ok(())
135 }
136 fn visit_some<D: serde::de::Deserializer<'de>>(self, deser: D) -> Result<(), D::Error> {
137 deser.deserialize_any(NoDupKeys)
138 }
139 fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<(), A::Error> {
140 while seq.next_element::<NoDupWrap>()?.is_some() {}
141 Ok(())
142 }
143 fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<(), A::Error> {
144 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
145 while let Some(key) = map.next_key::<String>()? {
146 if !seen.insert(key.clone()) {
147 return Err(serde::de::Error::custom(format!(
148 "{DUP_KEY_SENTINEL}duplicate object key `{}` in ATIF",
149 key.escape_debug()
150 )));
151 }
152 let _: NoDupWrap = map.next_value()?;
153 }
154 Ok(())
155 }
156 }
157 struct NoDupWrap;
158 impl<'de> serde::Deserialize<'de> for NoDupWrap {
159 fn deserialize<D: serde::de::Deserializer<'de>>(deser: D) -> Result<Self, D::Error> {
160 deser.deserialize_any(NoDupKeys)?;
161 Ok(NoDupWrap)
162 }
163 }
164 let mut de = serde_json::Deserializer::from_slice(raw);
165 de.deserialize_any(NoDupKeys).map_err(|error| {
166 let text = error.to_string();
167 if let Some(rest) = text.strip_prefix(DUP_KEY_SENTINEL) {
175 format!("duplicate JSON key rejected: {rest}")
176 } else {
177 text
178 }
179 })
180}
181
182macro_rules! issue {
183 ($issues:expr, $path:expr, $($msg:tt)*) => {
184 if $issues.len() <= MAX_VALIDATION_ISSUES {
190 $issues.push(ValidationIssue { path: $path.to_string(), message: format!($($msg)*) })
191 }
192 };
193}
194
195const TRAJECTORY_FIELDS: &[&str] = &[
196 "schema_version",
197 "session_id",
198 "trajectory_id",
199 "agent",
200 "steps",
201 "notes",
202 "final_metrics",
203 "continued_trajectory_ref",
204 "subagent_trajectories",
205 "extra",
206];
207const AGENT_FIELDS: &[&str] = &["name", "version", "model_name", "tool_definitions", "extra"];
208const STEP_FIELDS: &[&str] = &[
209 "step_id",
210 "timestamp",
211 "source",
212 "message",
213 "reasoning_effort",
214 "reasoning_content",
215 "model_name",
216 "tool_calls",
217 "observation",
218 "metrics",
219 "is_copied_context",
220 "llm_call_count",
221 "extra",
222];
223const AGENT_ONLY_STEP_FIELDS: &[&str] = &[
224 "reasoning_content",
225 "reasoning_effort",
226 "model_name",
227 "tool_calls",
228 "metrics",
229];
230const TOOL_CALL_FIELDS: &[&str] = &["tool_call_id", "function_name", "arguments", "extra"];
231const OBS_RESULT_FIELDS: &[&str] = &["source_call_id", "content", "subagent_trajectory_ref", "extra"];
232const SUBAGENT_REF_FIELDS: &[&str] = &["trajectory_id", "session_id", "trajectory_path", "extra"];
233const METRICS_FIELDS: &[&str] = &[
234 "prompt_tokens",
235 "completion_tokens",
236 "cached_tokens",
237 "cost_usd",
238 "logprobs",
239 "completion_token_ids",
240 "prompt_token_ids",
241 "extra",
242];
243const FINAL_METRICS_FIELDS: &[&str] = &[
244 "total_prompt_tokens",
245 "total_completion_tokens",
246 "total_cached_tokens",
247 "total_cost_usd",
248 "total_steps",
249 "extra",
250];
251
252pub const MAX_VALIDATION_ISSUES: usize = 4096;
266
267const MAX_NESTED_DEPTH: usize = 128;
279
280pub const MAX_COST_USD: f64 = 9e9;
307
308pub(crate) const MAX_ID_LEN: usize = 128;
324
325pub(crate) const MAX_NOTES_LEN: usize = 8 * 1024;
330
331pub(crate) const MAX_REASONING_LEN: usize = 256 * 1024;
339
340fn is_visible_ascii(s: &str) -> bool {
344 s.bytes().all(|b| (0x21..=0x7e).contains(&b))
345}
346
347fn check_id_field(
354 obj: &serde_json::Map<String, Value>,
355 field: &str,
356 path: &str,
357 issues: &mut Vec<ValidationIssue>,
358) {
359 let Some(s) = obj.get(field).and_then(Value::as_str) else {
360 return;
361 };
362 if s.is_empty() {
363 issue!(issues, format!("{path}.{field}"), "must be non-empty");
364 return;
365 }
366 if s.len() > MAX_ID_LEN {
367 issue!(
370 issues,
371 format!("{path}.{field}"),
372 "id length {} exceeds {MAX_ID_LEN} bytes (SessionId parity); starts {:?}",
373 s.len(),
374 &s[..s.floor_char_boundary(24.min(s.len()))],
375 );
376 return;
377 }
378 if !is_visible_ascii(s) {
379 issue!(
382 issues,
383 format!("{path}.{field}"),
384 "id {s:?} contains bytes outside visible ASCII (0x21-0x7e); \
385 log-injection-safe class required for downstream tools that display it"
386 );
387 }
388}
389
390fn check_display_name_field(
402 obj: &serde_json::Map<String, Value>,
403 field: &str,
404 path: &str,
405 issues: &mut Vec<ValidationIssue>,
406) {
407 let Some(s) = obj.get(field).and_then(Value::as_str) else {
408 return;
409 };
410 if s.is_empty() {
414 return;
415 }
416 if s.len() > MAX_ID_LEN {
417 issue!(
418 issues,
419 format!("{path}.{field}"),
420 "display name length {} exceeds {MAX_ID_LEN} bytes; starts {:?}",
421 s.len(),
422 &s[..s.floor_char_boundary(24.min(s.len()))],
423 );
424 return;
425 }
426 if s.bytes().any(|b| b < 0x20 || b == 0x7F) {
430 issue!(
431 issues,
432 format!("{path}.{field}"),
433 "display name contains control bytes (0x00-0x1F or 0x7F); \
434 log-injection-safe class required for downstream tools that display it"
435 );
436 }
437}
438
439pub fn validate_value(root: &Value, mode: Mode) -> Vec<ValidationIssue> {
444 let mut issues = Vec::new();
445 let mut ancestors: std::collections::HashSet<String> = std::collections::HashSet::new();
453 validate_trajectory_obj(root, "trajectory", mode, &mut issues, true, 0, &mut ancestors);
454 if mode == Mode::Strict {
463 strict_null_and_extra_conformance(root, "", "trajectory", &mut issues, 0);
464 }
465 truncate_issues_with_marker(issues)
466}
467
468fn strict_null_and_extra_conformance(
474 value: &Value,
475 parent_key: &str,
476 path: &str,
477 issues: &mut Vec<ValidationIssue>,
478 depth: usize,
479) {
480 if depth > MAX_NESTED_DEPTH || issues.len() > MAX_VALIDATION_ISSUES {
481 return;
482 }
483 match value {
484 Value::Object(map) => {
485 for (key, child) in map {
486 let child_path = format!("{path}.{key}");
487 if key == "extra" {
488 let free_form = matches!(
492 parent_key,
493 "" | "agent" | "steps" | "subagent_trajectories" | "tool_calls"
494 );
495 if !free_form && !child.is_object() {
496 issue!(issues, child_path, "must be an object (schema type)");
497 }
498 continue;
500 }
501 if key == "tool_calls"
511 || key == "message"
512 || (key == "tool_definitions" && parent_key == "agent")
513 || (key == "content" && parent_key == "results")
514 {
515 if child.is_null() {
516 issue!(
517 issues,
518 child_path,
519 "explicit null is not schema-valid; omit the field instead"
520 );
521 }
522 continue;
523 }
524 if child.is_null() {
525 issue!(
526 issues,
527 child_path,
528 "explicit null is not schema-valid; omit the field instead"
529 );
530 continue;
531 }
532 strict_null_and_extra_conformance(child, key, &child_path, issues, depth + 1);
533 }
534 }
535 Value::Array(items) => {
536 for (index, child) in items.iter().enumerate() {
537 let child_path = format!("{path}[{index}]");
538 if child.is_null() {
539 issue!(
540 issues,
541 child_path,
542 "explicit null is not schema-valid inside this array"
543 );
544 continue;
545 }
546 strict_null_and_extra_conformance(child, parent_key, &child_path, issues, depth + 1);
547 }
548 }
549 _ => {}
550 }
551}
552
553fn truncate_issues_with_marker(mut issues: Vec<ValidationIssue>) -> Vec<ValidationIssue> {
559 if issues.len() > MAX_VALIDATION_ISSUES {
560 issues.truncate(MAX_VALIDATION_ISSUES);
561 issues.push(ValidationIssue {
562 path: "trajectory".into(),
563 message: format!(
564 "validator hit the {MAX_VALIDATION_ISSUES}-issue cap; additional issues suppressed"
565 ),
566 });
567 }
568 issues
569}
570
571fn check_unknown_fields(
572 obj: &serde_json::Map<String, Value>,
573 allowed: &[&str],
574 path: &str,
575 mode: Mode,
576 issues: &mut Vec<ValidationIssue>,
577) {
578 if mode == Mode::Strict {
579 for k in obj.keys() {
580 if !allowed.contains(&k.as_str()) {
581 issue!(issues, format!("{path}.{k}"), "unknown field (strict mode)");
582 }
583 }
584 }
585}
586
587#[allow(clippy::too_many_lines, clippy::too_many_arguments)]
588fn validate_trajectory_obj(
589 root: &Value,
590 path: &str,
591 mode: Mode,
592 issues: &mut Vec<ValidationIssue>,
593 top_level: bool,
594 depth: usize,
595 ancestors: &mut std::collections::HashSet<String>,
596) {
597 if depth > MAX_NESTED_DEPTH {
602 issue!(
603 issues,
604 path,
605 "trajectory nesting exceeds {MAX_NESTED_DEPTH}; refusing to recurse further"
606 );
607 return;
608 }
609 let Some(obj) = root.as_object() else {
610 issue!(issues, path, "trajectory must be a JSON object");
611 return;
612 };
613 check_unknown_fields(obj, TRAJECTORY_FIELDS, path, mode, issues);
614
615 let inserted_id: Option<String> = obj
625 .get("trajectory_id")
626 .and_then(Value::as_str)
627 .filter(|id| !id.is_empty())
628 .and_then(|id| {
629 if ancestors.insert(id.to_owned()) {
630 Some(id.to_owned())
631 } else {
632 issue!(
633 issues,
634 format!("{path}.trajectory_id"),
635 "trajectory_id {id:?} appears as one of its own ancestors (cycle)"
636 );
637 None
638 }
639 });
640
641 let version = match obj.get("schema_version").and_then(Value::as_str) {
643 Some(v) if SUPPORTED_VERSIONS.contains(&v) => parse_version(v),
644 Some(v) => {
645 issue!(
646 issues,
647 format!("{path}.schema_version"),
648 "unsupported version {v:?} (supported: v1.0-v1.7)"
649 );
650 None
651 }
652 None => {
653 issue!(
654 issues,
655 format!("{path}.schema_version"),
656 "required field is missing"
657 );
658 None
659 }
660 };
661 let ver = version.unwrap_or((1, 7));
662
663 if obj.contains_key("extra") && ver < (1, 1) {
665 issue!(
666 issues,
667 format!("{path}.extra"),
668 "field requires ATIF-v1.1+, file is v{}.{}",
669 ver.0,
670 ver.1
671 );
672 }
673 for f in ["trajectory_id", "subagent_trajectories"] {
674 if obj.contains_key(f) && ver < (1, 7) {
675 issue!(
676 issues,
677 format!("{path}.{f}"),
678 "field requires ATIF-v1.7+, file is v{}.{}",
679 ver.0,
680 ver.1
681 );
682 }
683 }
684 for f in ["session_id", "trajectory_id", "notes", "continued_trajectory_ref"] {
690 if obj.get(f).is_some_and(|v| !v.is_string() && !v.is_null()) {
691 issue!(issues, format!("{path}.{f}"), "must be a string");
692 }
693 }
694 for f in ["session_id", "trajectory_id", "continued_trajectory_ref"] {
700 check_id_field(obj, f, path, issues);
701 }
702 if let Some(s) = obj.get("notes").and_then(Value::as_str) {
707 if s.len() > MAX_NOTES_LEN {
708 issue!(
709 issues,
710 format!("{path}.notes"),
711 "notes length {} exceeds {MAX_NOTES_LEN} bytes",
712 s.len()
713 );
714 }
715 }
716 if top_level && ver < (1, 7) && obj.get("session_id").is_none_or(Value::is_null) {
720 issue!(
721 issues,
722 format!("{path}.session_id"),
723 "required field is missing (optional only since v1.7)"
724 );
725 }
726
727 match obj.get("agent") {
729 Some(Value::Object(agent)) => {
730 check_unknown_fields(agent, AGENT_FIELDS, &format!("{path}.agent"), mode, issues);
731 for req in ["name", "version"] {
732 match agent.get(req).and_then(Value::as_str) {
733 Some(s) if !s.is_empty() => {}
734 Some(_) => issue!(issues, format!("{path}.agent.{req}"), "must be non-empty"),
735 None => issue!(issues, format!("{path}.agent.{req}"), "required field is missing"),
736 }
737 }
738 if agent
747 .get("model_name")
748 .is_some_and(|v| !v.is_string() && !v.is_null())
749 {
750 issue!(issues, format!("{path}.agent.model_name"), "must be a string");
751 }
752 let agent_path = format!("{path}.agent");
761 for field in ["name", "version", "model_name"] {
762 check_display_name_field(agent, field, &agent_path, issues);
763 }
764 if agent.contains_key("tool_definitions") && ver < (1, 5) {
765 issue!(
766 issues,
767 format!("{path}.agent.tool_definitions"),
768 "field requires ATIF-v1.5+, file is v{}.{}",
769 ver.0,
770 ver.1
771 );
772 }
773 if let Some(defs) = agent.get("tool_definitions") {
779 match defs.as_array() {
780 Some(items) => {
781 if items.len() > 512 {
787 issue!(
788 issues,
789 format!("{path}.agent.tool_definitions"),
790 "must not contain more than 512 definitions (schema maxItems)"
791 );
792 }
793 for (index, item) in items.iter().enumerate() {
794 match item.as_object() {
795 Some(map) if map.len() > 128 => issue!(
796 issues,
797 format!("{path}.agent.tool_definitions[{index}]"),
798 "must not carry more than 128 properties (schema maxProperties)"
799 ),
800 Some(_) => {}
801 None => issue!(
802 issues,
803 format!("{path}.agent.tool_definitions[{index}]"),
804 "must be an object"
805 ),
806 }
807 }
808 }
809 None if !defs.is_null() => issue!(
810 issues,
811 format!("{path}.agent.tool_definitions"),
812 "must be an array of objects"
813 ),
814 None => {}
815 }
816 }
817 }
818 Some(_) => issue!(issues, format!("{path}.agent"), "must be an object"),
819 None => issue!(issues, format!("{path}.agent"), "required field is missing"),
820 }
821
822 let embedded_trajectory_ids: std::collections::HashSet<String> =
832 match obj.get("subagent_trajectories").and_then(Value::as_array) {
833 Some(subs) => subs
834 .iter()
835 .filter_map(|sub| {
836 sub.get("trajectory_id")
837 .and_then(Value::as_str)
838 .filter(|id| !id.is_empty())
839 .map(str::to_owned)
840 })
841 .collect(),
842 None => std::collections::HashSet::new(),
843 };
844
845 match obj.get("steps") {
847 Some(Value::Array(steps)) => {
848 if steps.is_empty() {
849 issue!(issues, format!("{path}.steps"), "must contain at least one step");
850 }
851 for (i, step) in steps.iter().enumerate() {
852 validate_step(
853 step,
854 &format!("{path}.steps.{i}"),
855 i,
856 ver,
857 mode,
858 issues,
859 &embedded_trajectory_ids,
860 );
861 }
862 }
863 Some(_) => issue!(issues, format!("{path}.steps"), "must be an array"),
864 None => issue!(issues, format!("{path}.steps"), "required field is missing"),
865 }
866
867 if let Some(fm) = obj.get("final_metrics") {
869 match fm.as_object() {
870 Some(m) => {
871 check_unknown_fields(
872 m,
873 FINAL_METRICS_FIELDS,
874 &format!("{path}.final_metrics"),
875 mode,
876 issues,
877 );
878 for f in [
879 "total_prompt_tokens",
880 "total_completion_tokens",
881 "total_cached_tokens",
882 "total_steps",
883 ] {
884 if let Some(v) = m.get(f) {
885 match v.as_u64() {
886 Some(n) if n <= JCS_SAFE_MAX => {}
894 Some(_) => issue!(
895 issues,
896 format!("{path}.final_metrics.{f}"),
897 "exceeds the JCS-safe integer bound (2^53)"
898 ),
899 None => issue!(
900 issues,
901 format!("{path}.final_metrics.{f}"),
902 "must be a non-negative integer"
903 ),
904 }
905 }
906 }
907 if let Some(v) = m.get("total_cost_usd") {
908 match v.as_f64() {
909 Some(n) if n.is_finite() && (0.0..=MAX_COST_USD).contains(&n) => {}
910 Some(_) => issue!(
911 issues,
912 format!("{path}.final_metrics.total_cost_usd"),
913 "must be a finite non-negative number ≤ {MAX_COST_USD:e}"
914 ),
915 None => issue!(
916 issues,
917 format!("{path}.final_metrics.total_cost_usd"),
918 "must be a number"
919 ),
920 }
921 }
922 }
923 None => issue!(issues, format!("{path}.final_metrics"), "must be an object"),
924 }
925 }
926
927 if matches!(mode, Mode::Strict) {
941 if let (Some(Value::Object(m)), Some(Value::Array(steps))) =
942 (obj.get("final_metrics"), obj.get("steps"))
943 {
944 if let Some(total_steps) = m.get("total_steps").and_then(Value::as_u64) {
945 if u128::from(total_steps) != u128::try_from(steps.len()).unwrap_or(u128::MAX) {
946 issue!(
947 issues,
948 format!("{path}.final_metrics.total_steps"),
949 "is {total_steps} but the trajectory carries {} steps",
950 steps.len()
951 );
952 }
953 }
954 }
955 }
956
957 match obj.get("subagent_trajectories") {
959 Some(Value::Array(subs)) => {
960 let mut trajectory_ids = std::collections::HashSet::new();
961 for (i, sub) in subs.iter().enumerate() {
962 let id = sub
963 .get("trajectory_id")
964 .and_then(Value::as_str)
965 .filter(|id| !id.is_empty());
966 match id {
967 Some(id) if trajectory_ids.insert(id.to_owned()) => {}
968 Some(id) => issue!(
969 issues,
970 format!("{path}.subagent_trajectories.{i}.trajectory_id"),
971 "duplicate embedded trajectory_id {id:?}"
972 ),
973 None => issue!(
974 issues,
975 format!("{path}.subagent_trajectories.{i}.trajectory_id"),
976 "required for embedded subagent trajectories"
977 ),
978 }
979 validate_trajectory_obj(
980 sub,
981 &format!("{path}.subagent_trajectories.{i}"),
982 mode,
983 issues,
984 false,
985 depth + 1,
986 ancestors,
987 );
988 }
989 }
990 Some(v) if !v.is_null() => issue!(
993 issues,
994 format!("{path}.subagent_trajectories"),
995 "must be an array"
996 ),
997 _ => {}
998 }
999
1000 if let Some(id) = inserted_id {
1004 ancestors.remove(&id);
1005 }
1006}
1007
1008fn validate_step(
1009 step: &Value,
1010 path: &str,
1011 index: usize,
1012 ver: (u32, u32),
1013 mode: Mode,
1014 issues: &mut Vec<ValidationIssue>,
1015 embedded_trajectory_ids: &std::collections::HashSet<String>,
1016) {
1017 let Some(obj) = step.as_object() else {
1018 issue!(issues, path, "step must be an object");
1019 return;
1020 };
1021 check_unknown_fields(obj, STEP_FIELDS, path, mode, issues);
1022
1023 let expected = index as u64 + 1;
1025 match obj.get("step_id").and_then(Value::as_u64) {
1026 Some(id) if id == expected => {}
1027 Some(id) => {
1028 issue!(
1029 issues,
1030 format!("{path}.step_id"),
1031 "expected {expected} (sequential from 1), got {id}"
1032 );
1033 }
1034 None => issue!(
1035 issues,
1036 format!("{path}.step_id"),
1037 "required field is missing or not an integer"
1038 ),
1039 }
1040
1041 if let Some(timestamp) = obj.get("timestamp") {
1043 match timestamp.as_str() {
1044 Some(ts) if is_iso8601(ts, mode == Mode::Strict) => {}
1045 Some(ts) => issue!(
1046 issues,
1047 format!("{path}.timestamp"),
1048 "invalid ISO-8601 timestamp {ts:?}"
1049 ),
1050 None => issue!(issues, format!("{path}.timestamp"), "must be a string"),
1051 }
1052 }
1053
1054 let source = obj.get("source").and_then(Value::as_str);
1056 match source {
1057 Some("system" | "user" | "agent") => {}
1058 Some(s) => issue!(
1059 issues,
1060 format!("{path}.source"),
1061 "invalid source {s:?} (system|user|agent)"
1062 ),
1063 None => issue!(issues, format!("{path}.source"), "required field is missing"),
1064 }
1065
1066 match obj.get("message") {
1067 Some(Value::String(_) | Value::Array(_)) => {}
1068 Some(_) => issue!(
1069 issues,
1070 format!("{path}.message"),
1071 "must be a string or content-part array"
1072 ),
1073 None => issue!(issues, format!("{path}.message"), "required field is missing"),
1074 }
1075
1076 if obj
1077 .get("reasoning_effort")
1078 .is_some_and(|value| !value.is_string() && !value.is_number())
1079 {
1080 issue!(
1081 issues,
1082 format!("{path}.reasoning_effort"),
1083 "must be a string or number"
1084 );
1085 }
1086
1087 if obj
1091 .get("model_name")
1092 .is_some_and(|v| !v.is_string() && !v.is_null())
1093 {
1094 issue!(issues, format!("{path}.model_name"), "must be a string");
1095 }
1096 check_display_name_field(obj, "model_name", path, issues);
1100
1101 if obj
1107 .get("is_copied_context")
1108 .is_some_and(|v| !v.is_boolean() && !v.is_null())
1109 {
1110 issue!(issues, format!("{path}.is_copied_context"), "must be a boolean");
1111 }
1112
1113 if let Some(src) = source {
1115 if src != "agent" {
1116 for f in AGENT_ONLY_STEP_FIELDS {
1117 if obj.contains_key(*f) {
1118 issue!(
1119 issues,
1120 format!("{path}.{f}"),
1121 "agent-only field present on {src} step"
1122 );
1123 }
1124 }
1125 if obj.contains_key("observation") {
1127 let ok = src == "system" && ver >= (1, 2);
1128 if !ok {
1129 issue!(
1130 issues,
1131 format!("{path}.observation"),
1132 "observation not allowed on {src} step (system steps require v1.2+)"
1133 );
1134 }
1135 }
1136 }
1137 }
1138
1139 if obj.contains_key("llm_call_count") && ver < (1, 7) {
1140 issue!(
1141 issues,
1142 format!("{path}.llm_call_count"),
1143 "field requires ATIF-v1.7+"
1144 );
1145 }
1146 if obj
1151 .get("llm_call_count")
1152 .is_some_and(|v| !v.is_u64() && !v.is_null())
1153 {
1154 issue!(
1155 issues,
1156 format!("{path}.llm_call_count"),
1157 "must be a non-negative integer"
1158 );
1159 }
1160 if obj
1165 .get("reasoning_content")
1166 .is_some_and(|v| !v.is_string() && !v.is_null())
1167 {
1168 issue!(issues, format!("{path}.reasoning_content"), "must be a string");
1169 }
1170 if let Some(s) = obj.get("reasoning_content").and_then(Value::as_str) {
1175 if s.len() > MAX_REASONING_LEN {
1176 issue!(
1177 issues,
1178 format!("{path}.reasoning_content"),
1179 "reasoning_content length {} exceeds {MAX_REASONING_LEN} bytes",
1180 s.len()
1181 );
1182 }
1183 }
1184 if source == Some("agent") && obj.get("llm_call_count").and_then(Value::as_u64) == Some(0) {
1185 for field in ["metrics", "reasoning_content"] {
1186 if obj.contains_key(field) {
1187 issue!(
1188 issues,
1189 format!("{path}.{field}"),
1190 "must be absent when llm_call_count is 0"
1191 );
1192 }
1193 }
1194 }
1195
1196 if ver < (1, 6) {
1198 if let Some(Value::Array(_)) = obj.get("message") {
1199 issue!(
1200 issues,
1201 format!("{path}.message"),
1202 "content-part arrays require ATIF-v1.6+"
1203 );
1204 }
1205 }
1206
1207 let mut call_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
1216 if let Some(tc) = obj.get("tool_calls") {
1217 match tc.as_array() {
1218 Some(calls) => {
1219 for (j, call) in calls.iter().enumerate() {
1220 let cpath = format!("{path}.tool_calls.{j}");
1221 let Some(c) = call.as_object() else {
1222 issue!(issues, cpath, "tool call must be an object");
1223 continue;
1224 };
1225 check_unknown_fields(c, TOOL_CALL_FIELDS, &cpath, mode, issues);
1226 if c.contains_key("extra") && ver < (1, 7) {
1227 issue!(issues, format!("{cpath}.extra"), "field requires ATIF-v1.7+");
1228 }
1229 match c.get("tool_call_id").and_then(Value::as_str) {
1230 Some(id) if !id.is_empty() => {
1231 if !call_ids.insert(id.to_owned()) {
1232 issue!(issues, format!("{cpath}.tool_call_id"), "duplicate id {id:?}");
1233 }
1234 }
1235 _ => issue!(
1236 issues,
1237 format!("{cpath}.tool_call_id"),
1238 "required field is missing"
1239 ),
1240 }
1241 check_id_field(c, "tool_call_id", &cpath, issues);
1248 check_id_field(c, "function_name", &cpath, issues);
1249 if c.get("function_name")
1250 .and_then(Value::as_str)
1251 .is_none_or(str::is_empty)
1252 {
1253 issue!(
1254 issues,
1255 format!("{cpath}.function_name"),
1256 "required field is missing"
1257 );
1258 }
1259 match c.get("arguments") {
1260 Some(Value::Object(_)) => {}
1261 Some(_) => issue!(issues, format!("{cpath}.arguments"), "must be an object"),
1262 None => issue!(issues, format!("{cpath}.arguments"), "required field is missing"),
1263 }
1264 }
1265 }
1266 None => issue!(issues, format!("{path}.tool_calls"), "must be an array"),
1267 }
1268 }
1269
1270 if let Some(observation) = obj.get("observation") {
1271 let opath = format!("{path}.observation");
1272 match observation.as_object() {
1273 Some(o) => {
1274 check_unknown_fields(o, &["results"], &opath, mode, issues);
1275 match o.get("results").and_then(Value::as_array) {
1276 Some(results) => {
1277 for (j, r) in results.iter().enumerate() {
1278 let rpath = format!("{opath}.results.{j}");
1279 let Some(res) = r.as_object() else {
1280 issue!(issues, rpath, "result must be an object");
1281 continue;
1282 };
1283 check_unknown_fields(res, OBS_RESULT_FIELDS, &rpath, mode, issues);
1284 if res.contains_key("extra") && ver < (1, 7) {
1285 issue!(issues, format!("{rpath}.extra"), "field requires ATIF-v1.7+");
1286 }
1287 if let Some(content) = res.get("content") {
1288 if !content.is_string() && !content.is_array() {
1289 issue!(
1290 issues,
1291 format!("{rpath}.content"),
1292 "must be a string or content-part array"
1293 );
1294 } else if ver < (1, 6) && content.is_array() {
1295 issue!(
1296 issues,
1297 format!("{rpath}.content"),
1298 "content-part arrays require ATIF-v1.6+"
1299 );
1300 }
1301 }
1302 if res
1306 .get("subagent_trajectory_ref")
1307 .is_some_and(|v| !v.is_array() && !v.is_null())
1308 {
1309 issue!(
1310 issues,
1311 format!("{rpath}.subagent_trajectory_ref"),
1312 "must be an array"
1313 );
1314 }
1315 if let Some(references) =
1316 res.get("subagent_trajectory_ref").and_then(Value::as_array)
1317 {
1318 if ver < (1, 7) {
1328 issue!(
1329 issues,
1330 format!("{rpath}.subagent_trajectory_ref"),
1331 "field requires ATIF-v1.7+, file is v{}.{}",
1332 ver.0,
1333 ver.1
1334 );
1335 }
1336 for (k, reference) in references.iter().enumerate() {
1337 let ref_path = format!("{rpath}.subagent_trajectory_ref.{k}");
1338 let Some(reference) = reference.as_object() else {
1339 issue!(issues, ref_path, "must be an object");
1340 continue;
1341 };
1342 check_unknown_fields(
1343 reference,
1344 SUBAGENT_REF_FIELDS,
1345 &ref_path,
1346 mode,
1347 issues,
1348 );
1349 for field in ["trajectory_id", "session_id", "trajectory_path"] {
1359 if reference
1360 .get(field)
1361 .is_some_and(|v| !v.is_string() && !v.is_null())
1362 {
1363 issue!(issues, format!("{ref_path}.{field}"), "must be a string");
1364 }
1365 }
1366 check_id_field(reference, "trajectory_id", &ref_path, issues);
1373 check_id_field(reference, "session_id", &ref_path, issues);
1374 let has_id = reference
1375 .get("trajectory_id")
1376 .and_then(Value::as_str)
1377 .is_some_and(|value| !value.is_empty());
1378 let has_path = reference
1379 .get("trajectory_path")
1380 .and_then(Value::as_str)
1381 .is_some_and(|value| !value.is_empty());
1382 if !has_id && !has_path {
1383 issue!(issues, ref_path, "must set trajectory_id or trajectory_path");
1384 }
1385 if reference
1392 .get("trajectory_path")
1393 .and_then(Value::as_str)
1394 .is_some_and(str::is_empty)
1395 {
1396 issue!(
1397 issues,
1398 ref_path,
1399 "trajectory_path must not be an empty string when present"
1400 );
1401 }
1402 if let Some(id) = reference
1414 .get("trajectory_id")
1415 .and_then(Value::as_str)
1416 .filter(|v| !v.is_empty())
1417 {
1418 if !embedded_trajectory_ids.contains(id) {
1419 issue!(
1420 issues,
1421 format!("{ref_path}.trajectory_id"),
1422 "references trajectory_id {id:?} that is not present in \
1423 the outer trajectory's `subagent_trajectories` array"
1424 );
1425 }
1426 }
1427 }
1428 }
1429 match res.get("source_call_id") {
1430 None | Some(Value::Null) => {}
1431 Some(Value::String(src_id)) => {
1432 if !call_ids.contains(src_id.as_str()) {
1433 issue!(
1434 issues,
1435 format!("{rpath}.source_call_id"),
1436 "references unknown tool_call_id {src_id:?} (must match a tool call in the same step)"
1437 );
1438 }
1439 }
1440 Some(_) => {
1445 issue!(issues, format!("{rpath}.source_call_id"), "must be a string")
1446 }
1447 }
1448 }
1449 }
1450 None => issue!(
1451 issues,
1452 format!("{opath}.results"),
1453 "required field is missing or not an array"
1454 ),
1455 }
1456 }
1457 None => issue!(issues, opath, "must be an object"),
1458 }
1459 }
1460
1461 if mode == Mode::Strict
1462 && ver >= (1, 7)
1463 && source == Some("agent")
1464 && obj.get("llm_call_count").and_then(Value::as_u64) != Some(0)
1465 {
1466 match obj.get("metrics").and_then(Value::as_object) {
1467 Some(metrics) => {
1468 for field in ["prompt_tokens", "completion_tokens", "cached_tokens"] {
1469 if !metrics.contains_key(field) {
1470 issue!(
1471 issues,
1472 format!("{path}.metrics.{field}"),
1473 "required for strict ATIF-v1.7 agent-step fidelity"
1474 );
1475 }
1476 }
1477 }
1478 None => issue!(
1479 issues,
1480 format!("{path}.metrics"),
1481 "required for strict ATIF-v1.7 agent-step fidelity"
1482 ),
1483 }
1484 }
1485
1486 if let Some(metrics) = obj.get("metrics") {
1488 let mpath = format!("{path}.metrics");
1489 match metrics.as_object() {
1490 Some(m) => {
1491 check_unknown_fields(m, METRICS_FIELDS, &mpath, mode, issues);
1492 for f in ["prompt_tokens", "completion_tokens", "cached_tokens"] {
1493 if let Some(v) = m.get(f) {
1494 match v.as_u64() {
1495 Some(n) if n <= JCS_SAFE_MAX => {}
1499 Some(_) => issue!(
1500 issues,
1501 format!("{mpath}.{f}"),
1502 "exceeds the JCS-safe integer bound (2^53)"
1503 ),
1504 None => issue!(issues, format!("{mpath}.{f}"), "must be a non-negative integer"),
1505 }
1506 }
1507 }
1508 if let Some(v) = m.get("cost_usd") {
1509 match v.as_f64() {
1510 Some(n) if n.is_finite() && (0.0..=MAX_COST_USD).contains(&n) => {}
1511 Some(_) => issue!(
1512 issues,
1513 format!("{mpath}.cost_usd"),
1514 "must be a finite non-negative number ≤ {MAX_COST_USD:e}"
1515 ),
1516 None => issue!(issues, format!("{mpath}.cost_usd"), "must be a number"),
1517 }
1518 }
1519 if m.contains_key("completion_token_ids") && ver < (1, 3) {
1520 issue!(
1521 issues,
1522 format!("{mpath}.completion_token_ids"),
1523 "field requires ATIF-v1.3+"
1524 );
1525 }
1526 if m.contains_key("prompt_token_ids") && ver < (1, 4) {
1527 issue!(
1528 issues,
1529 format!("{mpath}.prompt_token_ids"),
1530 "field requires ATIF-v1.4+"
1531 );
1532 }
1533 if let Mode::Strict = mode {
1542 if let Some(v) = m.get("logprobs") {
1545 match v.as_array() {
1546 Some(items) => {
1547 for (index, item) in items.iter().enumerate() {
1548 if !item.is_number() {
1549 issue!(
1550 issues,
1551 format!("{mpath}.logprobs[{index}]"),
1552 "must be a number"
1553 );
1554 }
1555 }
1556 }
1557 None => {
1558 issue!(issues, format!("{mpath}.logprobs"), "must be an array of numbers")
1559 }
1560 }
1561 }
1562 for token_id_field in ["completion_token_ids", "prompt_token_ids"] {
1568 if let Some(v) = m.get(token_id_field) {
1569 match v.as_array() {
1570 Some(items) => {
1571 for (index, item) in items.iter().enumerate() {
1572 if !item.is_u64() {
1573 issue!(
1574 issues,
1575 format!("{mpath}.{token_id_field}[{index}]"),
1576 "must be a non-negative integer"
1577 );
1578 }
1579 }
1580 }
1581 None => issue!(
1582 issues,
1583 format!("{mpath}.{token_id_field}"),
1584 "must be an array of non-negative integers"
1585 ),
1586 }
1587 }
1588 }
1589 }
1590 }
1591 None => issue!(issues, mpath, "must be an object"),
1592 }
1593 }
1594}
1595
1596fn is_iso8601(s: &str, require_offset: bool) -> bool {
1598 let b = s.as_bytes();
1599 if b.len() < 19 {
1600 return false;
1601 }
1602 let digits = |range: std::ops::Range<usize>| -> Option<u32> {
1603 let slice = b.get(range)?;
1604 let mut n: u32 = 0;
1605 for &c in slice {
1606 if !c.is_ascii_digit() {
1607 return None;
1608 }
1609 n = n.checked_mul(10)?.checked_add(u32::from(c - b'0'))?;
1610 }
1611 Some(n)
1612 };
1613 let sep = |i: usize, ch: u8| b.get(i) == Some(&ch);
1614 let (Some(year), Some(month), Some(day)) = (digits(0..4), digits(5..7), digits(8..10)) else {
1615 return false;
1616 };
1617 if !(sep(4, b'-') && sep(7, b'-') && (sep(10, b'T') || sep(10, b't'))) {
1618 return false;
1619 }
1620 let (Some(hour), Some(min), Some(sec)) = (digits(11..13), digits(14..16), digits(17..19)) else {
1621 return false;
1622 };
1623 if !(sep(13, b':') && sep(16, b':')) {
1624 return false;
1625 }
1626 if !(1..=12).contains(&month) || hour > 23 || min > 59 || sec > 60 {
1627 return false;
1628 }
1629 if sec == 60 && !(hour == 23 && min == 59) {
1634 return false;
1635 }
1636 let leap = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0;
1637 let dim = match month {
1638 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
1639 4 | 6 | 9 | 11 => 30,
1640 _ => {
1641 if leap {
1642 29
1643 } else {
1644 28
1645 }
1646 }
1647 };
1648 if day == 0 || day > dim {
1649 return false;
1650 }
1651 let mut i = 19;
1653 if b.get(i) == Some(&b'.') {
1654 i += 1;
1655 let start = i;
1656 while b.get(i).is_some_and(u8::is_ascii_digit) {
1657 i += 1;
1658 }
1659 if i == start {
1660 return false; }
1662 }
1663 match b.get(i) {
1664 None => !require_offset,
1671 Some(b'Z' | b'z') => i + 1 == b.len(),
1672 Some(b'+' | b'-') => {
1673 let (Some(oh), Some(om)) = (digits(i + 1..i + 3), digits(i + 4..i + 6)) else {
1674 return false;
1675 };
1676 sep(i + 3, b':') && oh <= 14 && om <= 59 && i + 6 == b.len()
1677 }
1678 _ => false,
1679 }
1680}
1681
1682#[cfg(test)]
1683mod tests {
1684 #![allow(
1685 clippy::unwrap_used,
1686 clippy::expect_used,
1687 clippy::panic,
1688 clippy::indexing_slicing
1689 )]
1690
1691 use super::*;
1692
1693 #[test]
1698 fn issue_collection_is_bounded_and_marks_truncation() {
1699 let mut map = serde_json::Map::new();
1703 map.insert("schema_version".into(), Value::String("1.7".into()));
1704 map.insert("session_id".into(), Value::String("s".into()));
1705 map.insert("agent".into(), serde_json::json!({"name": "a", "version": "1"}));
1706 map.insert(
1707 "steps".into(),
1708 serde_json::json!([{
1709 "step_id": 1,
1710 "source": "user",
1711 "message": "hi",
1712 }]),
1713 );
1714 for i in 0..(MAX_VALIDATION_ISSUES + 500) {
1715 map.insert(format!("unknown_{i}"), Value::Null);
1716 }
1717 let value = Value::Object(map);
1718 let issues = validate_value(&value, Mode::Strict);
1719 assert!(
1720 issues.len() <= MAX_VALIDATION_ISSUES + 1,
1721 "issue Vec is unbounded: {}",
1722 issues.len()
1723 );
1724 let last = issues.last().expect("at least one issue");
1726 assert!(
1727 last.message.contains("hit the ") && last.message.contains("issue cap"),
1728 "expected truncation marker, got: {:?}",
1729 last.message
1730 );
1731 }
1732
1733 #[test]
1734 fn iso8601_accepts_valid_forms() {
1735 for ts in [
1736 "2025-01-15T10:30:00Z",
1737 "2025-01-15T10:30:00.123Z",
1738 "2024-02-29T23:59:59Z",
1739 "2025-06-30T23:59:60Z",
1740 "2025-01-15T10:30:00+05:30",
1741 "2025-01-15T10:30:00.999999-08:00",
1742 ] {
1743 assert!(is_iso8601(ts, true), "strict should accept {ts}");
1744 assert!(is_iso8601(ts, false), "compat should accept {ts}");
1745 }
1746 assert!(is_iso8601("2025-01-15T10:30:00", false));
1751 assert!(!is_iso8601("2025-01-15T10:30:00", true));
1752 }
1753
1754 #[test]
1759 fn naive_step_timestamp_is_compat_valid_and_strict_invalid() {
1760 let value = serde_json::json!({
1761 "schema_version": "ATIF-v1.7",
1762 "session_id": "s",
1763 "agent": {"name": "a", "version": "1"},
1764 "steps": [{
1765 "step_id": 1,
1766 "source": "user",
1767 "message": "hi",
1768 "timestamp": "2025-01-15T10:30:00",
1769 }],
1770 });
1771 assert!(
1772 validate_value(&value, Mode::Compat).is_empty(),
1773 "compat must accept naive timestamps"
1774 );
1775 assert!(
1776 validate_value(&value, Mode::Strict)
1777 .iter()
1778 .any(|issue| issue.path.ends_with(".timestamp")),
1779 "strict must refuse naive timestamps"
1780 );
1781 }
1782
1783 #[test]
1789 fn strict_token_id_arrays_reject_non_u64_elements() {
1790 let doc = |field: &str, items: Value| {
1791 serde_json::json!({
1792 "schema_version": "ATIF-v1.7",
1793 "session_id": "s",
1794 "agent": {"name": "a", "version": "1"},
1795 "steps": [{
1796 "step_id": 1,
1797 "source": "agent",
1798 "message": "hi",
1799 "llm_call_count": 1,
1800 "metrics": {
1801 "prompt_tokens": 1,
1802 "completion_tokens": 1,
1803 "cached_tokens": 0,
1804 field: items,
1805 },
1806 }],
1807 })
1808 };
1809 for field in ["completion_token_ids", "prompt_token_ids"] {
1810 let good = doc(field, serde_json::json!([0, 1, 42]));
1813 assert!(
1814 validate_value(&good, Mode::Strict).is_empty(),
1815 "{field}: u64 elements must pass strict validation"
1816 );
1817 assert!(
1818 serde_json::from_value::<crate::model::Trajectory>(good).is_ok(),
1819 "{field}: u64 elements must deserialize"
1820 );
1821 for bad_items in [serde_json::json!([1, -5]), serde_json::json!([1, 2.5])] {
1824 let bad = doc(field, bad_items.clone());
1825 assert!(
1826 validate_value(&bad, Mode::Strict).iter().any(|issue| {
1827 issue.path.contains(field) && issue.message.contains("non-negative integer")
1828 }),
1829 "{field}: strict must flag {bad_items} as non-u64"
1830 );
1831 assert!(
1832 serde_json::from_value::<crate::model::Trajectory>(bad).is_err(),
1833 "{field}: typed model must refuse {bad_items} (verdict agreement)"
1834 );
1835 }
1836 }
1837 let logprobs = doc("logprobs", serde_json::json!([-0.25, 1.5]));
1840 assert!(
1841 validate_value(&logprobs, Mode::Strict).is_empty(),
1842 "logprobs must keep accepting arbitrary finite numbers"
1843 );
1844 assert!(
1845 validate_value(&doc("logprobs", serde_json::json!(["x"])), Mode::Strict)
1846 .iter()
1847 .any(|issue| issue.path.contains("logprobs") && issue.message.contains("number")),
1848 "logprobs must still flag non-numbers"
1849 );
1850 }
1851
1852 #[test]
1853 fn iso8601_rejects_invalid_forms() {
1854 for ts in [
1855 "",
1856 "2025-01-15 10:30:00Z",
1857 "2025-13-01T00:00:00Z",
1858 "2025-02-29T00:00:00Z",
1859 "2025-01-32T00:00:00Z",
1860 "2025-01-15T24:00:00Z",
1861 "2025-01-15T10:61:00Z",
1862 "2026-06-15T12:30:60Z",
1867 "2026-06-15T12:30:60.5Z",
1868 "2026-06-15T23:58:60Z",
1869 "2026-06-15T22:59:60Z",
1870 "2025-01-15T10:30:00.Z",
1871 "2025-01-15T10:30:00ZZ",
1872 "2025-01-15T10:30:00+5:30",
1873 "2025-01-15T10:30:00+15:00",
1878 "2025-01-15T10:30:00-15:00",
1879 "2025-01-15T10:30:00+14:60",
1880 "2025-01-15T10:30:00+1400",
1881 "2025-01-15T10:30:00+14:00Z",
1882 "not-a-date",
1883 ] {
1884 assert!(!is_iso8601(ts, true), "should reject {ts}");
1885 assert!(!is_iso8601(ts, false), "should reject {ts}");
1886 }
1887 for ts in [
1889 "2025-01-15T10:30:00+14:00",
1890 "2025-01-15T10:30:00-14:00",
1891 "2025-01-15T10:30:00+13:59",
1892 ] {
1893 assert!(is_iso8601(ts, true), "should accept {ts}");
1894 }
1895 }
1896
1897 #[test]
1907 fn version_gates_and_caps_refuse_exactly_at_their_boundaries() {
1908 let doc = |version: &str, step_extras: serde_json::Value| {
1909 let mut step = serde_json::json!({
1910 "step_id": 1,
1911 "source": "agent",
1912 "message": "hi",
1913 });
1914 if let (Some(obj), Some(extras)) = (step.as_object_mut(), step_extras.as_object()) {
1915 for (k, v) in extras {
1916 obj.insert(k.clone(), v.clone());
1917 }
1918 }
1919 serde_json::json!({
1920 "schema_version": format!("ATIF-v{version}"),
1921 "session_id": "s",
1922 "agent": {"name": "a", "version": "1"},
1923 "steps": [step],
1924 })
1925 };
1926 let has_issue = |v: &Value, needle: &str| {
1927 validate_value(v, Mode::Strict)
1928 .iter()
1929 .any(|i| i.path.contains(needle) || i.message.contains(needle))
1930 };
1931
1932 let m = serde_json::json!({"metrics": {"prompt_tokens": 1, "completion_tokens": 1, "cached_tokens": 0, "completion_token_ids": [1]}});
1934 assert!(
1935 has_issue(&doc("1.2", m.clone()), "completion_token_ids"),
1936 "v1.2 must refuse completion_token_ids"
1937 );
1938 assert!(
1939 !has_issue(&doc("1.3", m), "completion_token_ids"),
1940 "v1.3 must accept completion_token_ids"
1941 );
1942
1943 let m = serde_json::json!({"metrics": {"prompt_tokens": 1, "completion_tokens": 1, "cached_tokens": 0, "prompt_token_ids": [1]}});
1945 assert!(
1946 has_issue(&doc("1.3", m.clone()), "prompt_token_ids"),
1947 "v1.3 must refuse prompt_token_ids"
1948 );
1949 assert!(
1950 !has_issue(&doc("1.4", m), "prompt_token_ids"),
1951 "v1.4 must accept prompt_token_ids"
1952 );
1953
1954 let tc = |content: serde_json::Value| serde_json::json!({"observation": {"results": [{"content": content}]}});
1957 assert!(
1958 has_issue(
1959 &doc("1.5", tc(serde_json::json!([{"type": "text", "text": "x"}]))),
1960 "content-part arrays require"
1961 ),
1962 "v1.5 must refuse content-part arrays"
1963 );
1964 assert!(
1965 !has_issue(
1966 &doc("1.6", tc(serde_json::json!([{"type": "text", "text": "x"}]))),
1967 "content-part"
1968 ),
1969 "v1.6 must accept content-part arrays"
1970 );
1971 assert!(
1972 has_issue(
1973 &doc("1.7", tc(serde_json::json!(42))),
1974 "must be a string or content-part array"
1975 ),
1976 "numeric content must be refused at any version"
1977 );
1978
1979 let wrong_type_rows: Vec<(serde_json::Value, &str)> = vec![
1984 (serde_json::json!({"reasoning_effort": true}), "reasoning_effort"),
1985 (serde_json::json!({"model_name": 123}), "model_name"),
1986 (
1987 serde_json::json!({"is_copied_context": "yes"}),
1988 "is_copied_context",
1989 ),
1990 (serde_json::json!({"reasoning_content": 7}), "reasoning_content"),
1991 (
1992 serde_json::json!({"tool_calls": [{"tool_call_id": "c1", "function_name": "f", "arguments": {}, "extra": {"k": 1}}]}),
1993 "extra",
1994 ),
1995 (
1996 serde_json::json!({"observation": {"results": [{"content": "x", "subagent_trajectory_ref": [{"trajectory_id": 9}]}]}}),
1997 "must be a string",
1998 ),
1999 (
2000 serde_json::json!({"observation": {"results": [{"content": "x", "subagent_trajectory_ref": [{"trajectory_path": ""}]}]}}),
2001 "must set trajectory_id or trajectory_path",
2002 ),
2003 ];
2004 for (extras, needle) in &wrong_type_rows {
2005 assert!(
2006 has_issue(&doc("1.6", extras.clone()), needle),
2007 "expected issue containing {needle:?} for {extras}"
2008 );
2009 }
2010 let ok_rows: Vec<(serde_json::Value, &str)> = vec![
2013 (
2014 serde_json::json!({"reasoning_effort": "high"}),
2015 "reasoning_effort",
2016 ),
2017 (serde_json::json!({"model_name": "gpt-x"}), "model_name"),
2018 (
2019 serde_json::json!({"is_copied_context": true}),
2020 "is_copied_context",
2021 ),
2022 (
2023 serde_json::json!({"reasoning_content": "chain"}),
2024 "reasoning_content",
2025 ),
2026 (
2027 serde_json::json!({"observation": {"results": [{"content": "x", "subagent_trajectory_ref": [{"trajectory_id": "sub-1"}]}]}}),
2028 "must set trajectory_id",
2029 ),
2030 ];
2031 for (extras, needle) in &ok_rows {
2032 assert!(
2033 !has_issue(&doc("1.7", extras.clone()), needle),
2034 "well-typed {extras} must not trip {needle:?}"
2035 );
2036 }
2037 }
2038
2039 #[test]
2045 fn tool_definition_caps_bite_exactly_at_schema_bounds() {
2046 let doc_with_defs = |defs: serde_json::Value| {
2047 serde_json::json!({
2048 "schema_version": "ATIF-v1.7",
2049 "session_id": "s",
2050 "agent": {"name": "a", "version": "1", "tool_definitions": defs},
2051 "steps": [{"step_id": 1, "source": "user", "message": "hi"}],
2052 })
2053 };
2054 let has_issue = |v: &Value, needle: &str| {
2055 validate_value(v, Mode::Strict)
2056 .iter()
2057 .any(|i| i.message.contains(needle))
2058 };
2059 let def = serde_json::json!({"name": "t"});
2060 let at_max: Vec<_> = (0..512).map(|_| def.clone()).collect();
2061 assert!(
2062 !has_issue(&doc_with_defs(serde_json::json!(at_max)), "512 definitions"),
2063 "exactly 512 definitions must be schema-clean"
2064 );
2065 let past_max: Vec<_> = (0..513).map(|_| def.clone()).collect();
2066 assert!(
2067 has_issue(&doc_with_defs(serde_json::json!(past_max)), "512 definitions"),
2068 "513 definitions must be refused (schema maxItems)"
2069 );
2070
2071 let fat = |props: usize| -> serde_json::Value {
2072 let mut m = serde_json::Map::new();
2073 for i in 0..props {
2074 m.insert(format!("p{i}"), Value::from(1));
2075 }
2076 Value::Object(m)
2077 };
2078 assert!(
2079 !has_issue(&doc_with_defs(serde_json::json!([fat(128)])), "128 properties"),
2080 "exactly 128 properties must be schema-clean"
2081 );
2082 assert!(
2083 has_issue(&doc_with_defs(serde_json::json!([fat(129)])), "128 properties"),
2084 "129 properties must be refused (schema maxProperties)"
2085 );
2086 }
2087
2088 #[test]
2092 fn issue_truncation_marker_appears_only_past_the_cap() {
2093 let mk = |n: usize| -> Vec<ValidationIssue> {
2094 (0..n)
2095 .map(|i| ValidationIssue {
2096 path: format!("p{i}"),
2097 message: "m".into(),
2098 })
2099 .collect()
2100 };
2101 let at = truncate_issues_with_marker(mk(MAX_VALIDATION_ISSUES));
2102 assert_eq!(
2103 at.len(),
2104 MAX_VALIDATION_ISSUES,
2105 "exactly-at-cap must be untouched"
2106 );
2107 assert!(
2108 !at.last().unwrap().message.contains("issue cap"),
2109 "no synthetic marker at exactly the cap"
2110 );
2111 let past = truncate_issues_with_marker(mk(MAX_VALIDATION_ISSUES + 1));
2112 assert_eq!(past.len(), MAX_VALIDATION_ISSUES + 1, "cap + marker");
2113 assert!(
2114 past.last().unwrap().message.contains("issue cap"),
2115 "one past the cap must carry the truncation notice"
2116 );
2117 }
2118
2119 #[test]
2123 fn freeform_caps_bite_at_exact_byte_boundaries() {
2124 let doc = |extras: serde_json::Value| {
2125 let mut step = serde_json::json!({"step_id": 1, "source": "agent", "message": "hi"});
2126 if let (Some(o), Some(e)) = (step.as_object_mut(), extras.as_object()) {
2127 for (k, v) in e {
2128 o.insert(k.clone(), v.clone());
2129 }
2130 }
2131 serde_json::json!({
2132 "schema_version": "ATIF-v1.7",
2133 "session_id": "s",
2134 "agent": {"name": "a", "version": "1"},
2135 "steps": [step],
2136 })
2137 };
2138 let has_issue = |v: &Value, needle: &str| {
2139 validate_value(v, Mode::Strict)
2140 .iter()
2141 .any(|i| i.path.contains(needle))
2142 };
2143 let rc = |n: usize| serde_json::json!({"reasoning_content": "r".repeat(n)});
2144 assert!(
2145 !has_issue(&doc(rc(MAX_REASONING_LEN)), "reasoning_content"),
2146 "exactly 256 KiB must pass"
2147 );
2148 assert!(
2149 has_issue(&doc(rc(MAX_REASONING_LEN + 1)), "reasoning_content"),
2150 "256 KiB + 1 must be refused"
2151 );
2152 let mut root = doc(serde_json::json!({}));
2154 root.as_object_mut()
2155 .unwrap()
2156 .insert("notes".into(), Value::from("n".repeat(MAX_NOTES_LEN)));
2157 assert!(!has_issue(&root, "notes"), "exactly 8 KiB notes must pass");
2158 root.as_object_mut()
2159 .unwrap()
2160 .insert("notes".into(), Value::from("n".repeat(MAX_NOTES_LEN + 1)));
2161 assert!(has_issue(&root, "notes"), "8 KiB + 1 notes must be refused");
2162 }
2163
2164 #[test]
2165 fn version_parse() {
2166 assert_eq!(parse_version("ATIF-v1.7"), Some((1, 7)));
2167 assert_eq!(parse_version("ATIF-v1.0"), Some((1, 0)));
2168 assert_eq!(parse_version("v1.7"), None);
2169 assert_eq!(parse_version("ATIF-v1"), None);
2170 }
2171
2172 #[test]
2176 fn wrong_typed_optional_fields_are_flagged() {
2177 let value = serde_json::json!({
2178 "schema_version": "ATIF-v1.7",
2179 "session_id": 123,
2180 "trajectory_id": 42,
2181 "notes": 42,
2182 "continued_trajectory_ref": {},
2183 "subagent_trajectories": "nope",
2184 "agent": {"name": "a", "version": "1"},
2185 "steps": [{"step_id": 1, "source": "user", "message": "hi"}],
2186 });
2187 let issues = validate_value(&value, Mode::Strict);
2188 for f in ["session_id", "trajectory_id", "notes", "continued_trajectory_ref"] {
2189 assert!(
2190 issues
2191 .iter()
2192 .any(|i| i.path == format!("trajectory.{f}") && i.message == "must be a string"),
2193 "expected type issue for {f}: {issues:?}"
2194 );
2195 }
2196 assert!(
2197 issues
2198 .iter()
2199 .any(|i| i.path == "trajectory.subagent_trajectories" && i.message == "must be an array"),
2200 "expected array issue: {issues:?}"
2201 );
2202 assert!(serde_json::from_value::<crate::model::Trajectory>(value).is_err());
2204 }
2205
2206 #[test]
2216 fn id_shaped_fields_reject_control_bytes_and_oversize() {
2217 let value = serde_json::json!({
2219 "schema_version": "ATIF-v1.7",
2220 "session_id": "sess\r\nfake-log-line",
2221 "trajectory_id": "traj-ok",
2222 "agent": {"name": "a", "version": "1"},
2223 "steps": [{"step_id": 1, "source": "user", "message": "hi"}],
2224 });
2225 let issues = validate_value(&value, Mode::Strict);
2226 assert!(
2227 issues
2228 .iter()
2229 .any(|i| i.path == "trajectory.session_id" && i.message.contains("visible ASCII")),
2230 "control bytes in session_id must be flagged: {issues:?}"
2231 );
2232
2233 let oversize = "x".repeat(MAX_ID_LEN + 1);
2235 let value = serde_json::json!({
2236 "schema_version": "ATIF-v1.7",
2237 "session_id": "s",
2238 "trajectory_id": oversize,
2239 "agent": {"name": "a", "version": "1"},
2240 "steps": [{"step_id": 1, "source": "user", "message": "hi"}],
2241 });
2242 let issues = validate_value(&value, Mode::Strict);
2243 assert!(
2244 issues
2245 .iter()
2246 .any(|i| i.path == "trajectory.trajectory_id" && i.message.contains("exceeds")),
2247 "oversize trajectory_id must be flagged: {issues:?}"
2248 );
2249
2250 let value = serde_json::json!({
2252 "schema_version": "ATIF-v1.7",
2253 "session_id": "sess-\u{202E}rtl-legit",
2254 "agent": {"name": "a", "version": "1"},
2255 "steps": [{"step_id": 1, "source": "user", "message": "hi"}],
2256 });
2257 let issues = validate_value(&value, Mode::Strict);
2258 assert!(
2259 issues
2260 .iter()
2261 .any(|i| i.path == "trajectory.session_id" && i.message.contains("visible ASCII")),
2262 "RTL override in session_id must be flagged: {issues:?}"
2263 );
2264
2265 let value = serde_json::json!({
2269 "schema_version": "ATIF-v1.7",
2270 "session_id": "",
2271 "agent": {"name": "a", "version": "1"},
2272 "steps": [{"step_id": 1, "source": "user", "message": "hi"}],
2273 });
2274 let issues = validate_value(&value, Mode::Strict);
2275 assert!(
2276 issues
2277 .iter()
2278 .any(|i| i.path == "trajectory.session_id" && i.message == "must be non-empty"),
2279 "empty-string session_id must be flagged non-empty: {issues:?}"
2280 );
2281
2282 let at_cap = "x".repeat(MAX_ID_LEN);
2285 let value = serde_json::json!({
2286 "schema_version": "ATIF-v1.7",
2287 "session_id": at_cap,
2288 "agent": {"name": "a", "version": "1"},
2289 "steps": [{"step_id": 1, "source": "user", "message": "hi"}],
2290 });
2291 assert!(
2292 validate_value(&value, Mode::Strict).is_empty(),
2293 "id at MAX_ID_LEN (128) must remain legal (SessionId parity)"
2294 );
2295 }
2296
2297 #[test]
2301 fn notes_field_rejects_oversize() {
2302 let oversize = "n".repeat(MAX_NOTES_LEN + 1);
2303 let value = serde_json::json!({
2304 "schema_version": "ATIF-v1.7",
2305 "session_id": "s",
2306 "notes": oversize,
2307 "agent": {"name": "a", "version": "1"},
2308 "steps": [{"step_id": 1, "source": "user", "message": "hi"}],
2309 });
2310 let issues = validate_value(&value, Mode::Strict);
2311 assert!(
2312 issues
2313 .iter()
2314 .any(|i| i.path == "trajectory.notes" && i.message.contains("exceeds")),
2315 "oversize notes must be flagged: {issues:?}"
2316 );
2317
2318 let at_cap = "n".repeat(MAX_NOTES_LEN);
2320 let value = serde_json::json!({
2321 "schema_version": "ATIF-v1.7",
2322 "session_id": "s",
2323 "notes": at_cap,
2324 "agent": {"name": "a", "version": "1"},
2325 "steps": [{"step_id": 1, "source": "user", "message": "hi"}],
2326 });
2327 assert!(
2328 validate_value(&value, Mode::Strict).is_empty(),
2329 "notes at MAX_NOTES_LEN must remain legal"
2330 );
2331 }
2332
2333 #[test]
2342 fn agent_and_step_display_names_reject_control_bytes_and_oversize() {
2343 let value = serde_json::json!({
2345 "schema_version": "ATIF-v1.7",
2346 "session_id": "s",
2347 "agent": {"name": "agent\r\n2026-08-26 fake-log", "version": "1"},
2348 "steps": [{"step_id": 1, "source": "user", "message": "hi"}],
2349 });
2350 let issues = validate_value(&value, Mode::Strict);
2351 assert!(
2352 issues
2353 .iter()
2354 .any(|i| i.path == "trajectory.agent.name" && i.message.contains("control bytes")),
2355 "control bytes in agent.name must be flagged: {issues:?}"
2356 );
2357
2358 let value = serde_json::json!({
2360 "schema_version": "ATIF-v1.7",
2361 "session_id": "s",
2362 "agent": {
2363 "name": "My Agent v1",
2364 "version": "0.1.2-rc.1 (build 42)",
2365 "model_name": "Claude 3.5 Sonnet",
2366 },
2367 "steps": [{"step_id": 1, "source": "user", "message": "hi"}],
2368 });
2369 assert!(
2370 validate_value(&value, Mode::Strict).is_empty(),
2371 "spaces + display-name UTF-8 must remain legal"
2372 );
2373
2374 let oversize = "v".repeat(MAX_ID_LEN + 1);
2376 let value = serde_json::json!({
2377 "schema_version": "ATIF-v1.7",
2378 "session_id": "s",
2379 "agent": {"name": "a", "version": oversize},
2380 "steps": [{"step_id": 1, "source": "user", "message": "hi"}],
2381 });
2382 let issues = validate_value(&value, Mode::Strict);
2383 assert!(
2384 issues
2385 .iter()
2386 .any(|i| i.path == "trajectory.agent.version" && i.message.contains("exceeds")),
2387 "oversize agent.version must be flagged: {issues:?}"
2388 );
2389
2390 let value = serde_json::json!({
2392 "schema_version": "ATIF-v1.7",
2393 "session_id": "s",
2394 "agent": {"name": "a", "version": "1"},
2395 "steps": [{
2396 "step_id": 1,
2397 "source": "agent",
2398 "message": "hi",
2399 "model_name": "gpt-4\x00nul-injection",
2400 "metrics": {"prompt_tokens": 1, "completion_tokens": 1, "cached_tokens": 0},
2401 "llm_call_count": 1,
2402 }],
2403 });
2404 let issues = validate_value(&value, Mode::Strict);
2405 assert!(
2406 issues
2407 .iter()
2408 .any(|i| i.path == "trajectory.steps.0.model_name" && i.message.contains("control bytes")),
2409 "control bytes in step.model_name must be flagged: {issues:?}"
2410 );
2411 }
2412
2413 #[test]
2420 fn reasoning_content_field_rejects_oversize() {
2421 let oversize = "r".repeat(MAX_REASONING_LEN + 1);
2422 let value = serde_json::json!({
2423 "schema_version": "ATIF-v1.7",
2424 "session_id": "s",
2425 "agent": {"name": "a", "version": "1"},
2426 "steps": [{
2427 "step_id": 1,
2428 "source": "agent",
2429 "message": "hi",
2430 "reasoning_content": oversize,
2431 "metrics": {"prompt_tokens": 1, "completion_tokens": 1, "cached_tokens": 0},
2432 "llm_call_count": 1,
2433 }],
2434 });
2435 let issues = validate_value(&value, Mode::Strict);
2436 assert!(
2437 issues
2438 .iter()
2439 .any(|i| i.path == "trajectory.steps.0.reasoning_content" && i.message.contains("exceeds")),
2440 "oversize reasoning_content must be flagged: {issues:?}"
2441 );
2442
2443 let at_cap = "r".repeat(MAX_REASONING_LEN);
2445 let value = serde_json::json!({
2446 "schema_version": "ATIF-v1.7",
2447 "session_id": "s",
2448 "agent": {"name": "a", "version": "1"},
2449 "steps": [{
2450 "step_id": 1,
2451 "source": "agent",
2452 "message": "hi",
2453 "reasoning_content": at_cap,
2454 "metrics": {"prompt_tokens": 1, "completion_tokens": 1, "cached_tokens": 0},
2455 "llm_call_count": 1,
2456 }],
2457 });
2458 assert!(
2459 validate_value(&value, Mode::Strict).is_empty(),
2460 "reasoning_content at MAX_REASONING_LEN must remain legal"
2461 );
2462 }
2463
2464 #[test]
2469 fn tool_call_id_fields_reject_control_bytes_and_oversize() {
2470 let value = serde_json::json!({
2471 "schema_version": "ATIF-v1.7",
2472 "session_id": "s",
2473 "agent": {"name": "a", "version": "1"},
2474 "steps": [{
2475 "step_id": 1,
2476 "source": "agent",
2477 "message": "hi",
2478 "metrics": {"prompt_tokens": 1, "completion_tokens": 1, "cached_tokens": 0},
2479 "tool_calls": [{
2480 "tool_call_id": "call\r\nfake-log",
2481 "function_name": "get_time",
2482 "arguments": {},
2483 }],
2484 }],
2485 });
2486 let issues = validate_value(&value, Mode::Strict);
2487 assert!(
2488 issues
2489 .iter()
2490 .any(|i| i.path.ends_with(".tool_call_id") && i.message.contains("visible ASCII")),
2491 "control bytes in tool_call_id must be flagged: {issues:?}"
2492 );
2493
2494 let oversize = "x".repeat(MAX_ID_LEN + 1);
2495 let value = serde_json::json!({
2496 "schema_version": "ATIF-v1.7",
2497 "session_id": "s",
2498 "agent": {"name": "a", "version": "1"},
2499 "steps": [{
2500 "step_id": 1,
2501 "source": "agent",
2502 "message": "hi",
2503 "metrics": {"prompt_tokens": 1, "completion_tokens": 1, "cached_tokens": 0},
2504 "tool_calls": [{
2505 "tool_call_id": "call-1",
2506 "function_name": oversize,
2507 "arguments": {},
2508 }],
2509 }],
2510 });
2511 let issues = validate_value(&value, Mode::Strict);
2512 assert!(
2513 issues
2514 .iter()
2515 .any(|i| i.path.ends_with(".function_name") && i.message.contains("exceeds")),
2516 "oversize function_name must be flagged: {issues:?}"
2517 );
2518 }
2519
2520 #[test]
2526 fn wrong_typed_step_fields_are_flagged() {
2527 let value = serde_json::json!({
2528 "schema_version": "ATIF-v1.7",
2529 "session_id": "s",
2530 "agent": {"name": "a", "version": "1"},
2531 "steps": [{
2532 "step_id": 1,
2533 "source": "agent",
2534 "message": "hi",
2535 "llm_call_count": "1",
2536 "metrics": {"prompt_tokens": 1, "completion_tokens": 1, "cached_tokens": 0},
2537 "observation": {"results": [{
2538 "source_call_id": 123,
2539 "subagent_trajectory_ref": {"trajectory_id": "x"},
2540 }]},
2541 }],
2542 });
2543 let issues = validate_value(&value, Mode::Strict);
2544 let results_path = "trajectory.steps.0.observation.results.0";
2545 for (path, message) in [
2546 (
2547 "trajectory.steps.0.llm_call_count",
2548 "must be a non-negative integer",
2549 ),
2550 (&format!("{results_path}.source_call_id"), "must be a string"),
2551 (
2552 &format!("{results_path}.subagent_trajectory_ref"),
2553 "must be an array",
2554 ),
2555 ] {
2556 assert!(
2557 issues.iter().any(|i| i.path == path && i.message == message),
2558 "expected {message:?} at {path}: {issues:?}"
2559 );
2560 }
2561 let value = serde_json::json!({
2565 "schema_version": "ATIF-v1.7",
2566 "session_id": "s",
2567 "agent": {"name": "a", "version": "1"},
2568 "steps": [{
2569 "step_id": 1,
2570 "source": "agent",
2571 "message": "hi",
2572 "llm_call_count": null,
2573 "metrics": {"prompt_tokens": 1, "completion_tokens": 1, "cached_tokens": 0},
2574 "observation": {"results": [{
2575 "source_call_id": null,
2576 "subagent_trajectory_ref": null,
2577 }]},
2578 }],
2579 });
2580 let issues = validate_value(&value, Mode::Compat);
2581 assert!(issues.is_empty(), "compat must tolerate nulls: {issues:?}");
2582 let issues = validate_value(&value, Mode::Strict);
2583 assert_eq!(issues.len(), 3, "strict must reject the nulls: {issues:?}");
2584 }
2585
2586 #[test]
2592 fn zero_llm_call_count_forbids_metrics_and_reasoning() {
2593 let step_with = |llm_calls: u64| {
2594 serde_json::json!({
2595 "schema_version": "ATIF-v1.7",
2596 "session_id": "s",
2597 "agent": {"name": "a", "version": "1"},
2598 "steps": [{
2599 "step_id": 1,
2600 "source": "agent",
2601 "message": "hi",
2602 "llm_call_count": llm_calls,
2603 "reasoning_content": "thinking",
2604 "metrics": {"prompt_tokens": 1, "completion_tokens": 1, "cached_tokens": 0},
2605 }],
2606 })
2607 };
2608 let issues = validate_value(&step_with(0), Mode::Strict);
2609 for field in ["metrics", "reasoning_content"] {
2610 assert!(
2611 issues.iter().any(|i| {
2612 i.path == format!("trajectory.steps.0.{field}")
2613 && i.message.contains("llm_call_count is 0")
2614 }),
2615 "expected {field} flagged when llm_call_count is 0: {issues:?}"
2616 );
2617 }
2618 let issues = validate_value(&step_with(1), Mode::Strict);
2619 assert!(
2620 !issues.iter().any(|i| i.message.contains("llm_call_count is 0")),
2621 "nonzero llm_call_count must not trip the zero-call rule: {issues:?}"
2622 );
2623 }
2624
2625 #[test]
2628 fn null_optional_fields_remain_valid() {
2629 let value = serde_json::json!({
2630 "schema_version": "ATIF-v1.7",
2631 "session_id": null,
2632 "notes": null,
2633 "continued_trajectory_ref": null,
2634 "subagent_trajectories": null,
2635 "agent": {"name": "a", "version": "1"},
2636 "steps": [{"step_id": 1, "source": "user", "message": "hi"}],
2637 });
2638 let issues = validate_value(&value, Mode::Compat);
2642 assert!(issues.is_empty(), "compat must tolerate nulls: {issues:?}");
2643 let issues = validate_value(&value, Mode::Strict);
2644 assert_eq!(
2645 issues.len(),
2646 4,
2647 "strict must reject each explicit null: {issues:?}"
2648 );
2649 assert!(serde_json::from_value::<crate::model::Trajectory>(value).is_ok());
2650 }
2651
2652 #[test]
2658 fn validate_bytes_rejects_duplicate_top_level_key() {
2659 let bytes =
2660 br#"{"schema_version":"ATIF-v9.9","schema_version":"ATIF-v1.7","agent":{"name":"a","version":"1"},"steps":[{"step_id":1,"source":"user","message":"hi"}]}"#;
2661 let outcome = validate_bytes(bytes, Mode::Strict);
2662 assert!(
2663 matches!(&outcome, Err(reason) if reason.contains("schema_version") && reason.contains("duplicate")),
2664 "expected duplicate-key rejection at bytes level, got {outcome:?}"
2665 );
2666 }
2667
2668 #[test]
2669 fn validate_bytes_rejects_duplicate_key_inside_step() {
2670 let bytes = br#"{"schema_version":"ATIF-v1.7","agent":{"name":"a","version":"1"},"steps":[{"step_id":1,"source":"agent","message":"x","message":"y"}]}"#;
2671 let outcome = validate_bytes(bytes, Mode::Strict);
2672 assert!(
2673 matches!(&outcome, Err(reason) if reason.contains("message") && reason.contains("duplicate")),
2674 "expected duplicate-key rejection inside step, got {outcome:?}"
2675 );
2676 }
2677
2678 #[test]
2683 fn validate_bytes_flags_unknown_fields_that_typed_path_would_drop() {
2684 let bytes = br#"{"schema_version":"ATIF-v1.7","agent":{"name":"a","version":"1"},"steps":[{"step_id":1,"source":"user","message":"hi"}],"future_experimental_field":"planted"}"#;
2685 let issues = validate_bytes(bytes, Mode::Strict).expect("parses ok");
2686 assert!(
2687 issues
2688 .iter()
2689 .any(|i| i.message.contains("unknown field")
2690 || i.message.contains("future_experimental_field")),
2691 "unknown field must be flagged in Strict mode: {issues:?}"
2692 );
2693 }
2694
2695 #[test]
2701 fn dangling_subagent_trajectory_ref_is_flagged() {
2702 let value = serde_json::json!({
2705 "schema_version": "ATIF-v1.7",
2706 "session_id": "s",
2707 "agent": {"name": "a", "version": "1"},
2708 "steps": [{
2709 "step_id": 1,
2710 "source": "agent",
2711 "message": "delegating",
2712 "tool_calls": [{"tool_call_id": "c1", "function_name": "delegate", "arguments": {}}],
2713 "observation": {"results": [{
2714 "source_call_id": "c1",
2715 "subagent_trajectory_ref": [{"trajectory_id": "sub-does-not-exist"}]
2716 }]},
2717 }],
2718 "subagent_trajectories": [{
2719 "trajectory_id": "sub-actual",
2720 "agent": {"name": "sub-a", "version": "1"},
2721 "steps": [{"step_id": 1, "source": "agent", "message": "sub"}]
2722 }],
2723 });
2724 let issues = validate_value(&value, Mode::Strict);
2725 assert!(
2726 issues
2727 .iter()
2728 .any(|i| i.message.contains("sub-does-not-exist")
2729 && i.message.contains("subagent_trajectories")),
2730 "dangling trajectory_id must be flagged, got {issues:?}"
2731 );
2732
2733 let value = serde_json::json!({
2735 "schema_version": "ATIF-v1.7",
2736 "session_id": "s",
2737 "agent": {"name": "a", "version": "1"},
2738 "steps": [{
2739 "step_id": 1,
2740 "source": "agent",
2741 "message": "delegating",
2742 "tool_calls": [{"tool_call_id": "c1", "function_name": "delegate", "arguments": {}}],
2743 "observation": {"results": [{
2744 "source_call_id": "c1",
2745 "subagent_trajectory_ref": [{"trajectory_id": "sub-actual"}]
2746 }]},
2747 }],
2748 "subagent_trajectories": [{
2749 "trajectory_id": "sub-actual",
2750 "agent": {"name": "sub-a", "version": "1"},
2751 "steps": [{"step_id": 1, "source": "agent", "message": "sub"}]
2752 }],
2753 });
2754 let issues = validate_value(&value, Mode::Strict);
2755 assert!(
2756 !issues
2757 .iter()
2758 .any(|i| i.message.contains("not present in the outer")),
2759 "resolvable subagent_trajectory_ref must not be flagged: {issues:?}"
2760 );
2761
2762 let value = serde_json::json!({
2764 "schema_version": "ATIF-v1.7",
2765 "session_id": "s",
2766 "agent": {"name": "a", "version": "1"},
2767 "steps": [{
2768 "step_id": 1,
2769 "source": "agent",
2770 "message": "delegating",
2771 "tool_calls": [{"tool_call_id": "c1", "function_name": "delegate", "arguments": {}}],
2772 "observation": {"results": [{
2773 "source_call_id": "c1",
2774 "subagent_trajectory_ref": [{"trajectory_path": "external://s3/bucket/traj.json"}]
2775 }]},
2776 }],
2777 });
2778 let issues = validate_value(&value, Mode::Strict);
2779 assert!(
2780 !issues
2781 .iter()
2782 .any(|i| i.message.contains("not present in the outer")),
2783 "external trajectory_path refs must not be flagged as dangling: {issues:?}"
2784 );
2785 }
2786
2787 #[test]
2790 fn pre_v17_session_id_distinguishes_missing_from_wrong_type() {
2791 let base = |session_id: Option<Value>| {
2792 let mut map = serde_json::Map::new();
2793 map.insert("schema_version".into(), Value::String("ATIF-v1.0".into()));
2794 if let Some(v) = session_id {
2795 map.insert("session_id".into(), v);
2796 }
2797 map.insert("agent".into(), serde_json::json!({"name": "a", "version": "1"}));
2798 map.insert(
2799 "steps".into(),
2800 serde_json::json!([{"step_id": 1, "source": "user", "message": "hi"}]),
2801 );
2802 Value::Object(map)
2803 };
2804 let issues = validate_value(&base(None), Mode::Strict);
2805 assert!(
2806 issues.iter().any(|i| i.path == "trajectory.session_id"
2807 && i.message.contains("required field is missing")),
2808 "missing session_id must be reported: {issues:?}"
2809 );
2810 let issues = validate_value(&base(Some(serde_json::json!(123))), Mode::Strict);
2811 assert!(
2812 issues
2813 .iter()
2814 .any(|i| i.path == "trajectory.session_id" && i.message == "must be a string"),
2815 "wrong-typed session_id must be a type error: {issues:?}"
2816 );
2817 assert!(
2818 !issues.iter().any(|i| i.path == "trajectory.session_id"
2819 && i.message.contains("required field is missing")),
2820 "wrong type must not be reported as missing: {issues:?}"
2821 );
2822 }
2823}