Skip to main content

av_atif/
validate.rs

1//! ATIF validator, mirroring Harbor's trajectory-validator semantics:
2//! validates required fields, types and constraints, sequential step ids
3//! (starting from 1), tool-call references in observations, ISO-8601
4//! timestamps, agent-only field placement — and collects errors as it goes
5//! (not just the first), up to [`MAX_VALIDATION_ISSUES`], after which a
6//! truncation notice is appended.
7//!
8//! Two modes ([`Mode`]): `Strict` additionally rejects unknown fields outside
9//! the spec's `extra` escape hatches (outbound gate for files we produce);
10//! `Compat` tolerates unknown fields (inbound tolerance for files produced by
11//! newer or third-party writers).
12
13use crate::model::SUPPORTED_VERSIONS;
14use av_core::error::JCS_SAFE_MAX;
15use serde_json::Value;
16
17/// Validation mode.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum Mode {
20    /// Reject unknown fields (files we emit must be exactly spec-shaped).
21    Strict,
22    /// Tolerate unknown fields (accept newer/foreign writers).
23    Compat,
24}
25
26/// One validation problem, with a Harbor-style dotted path.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct ValidationIssue {
29    /// Dotted location, e.g. `trajectory.steps.0.step_id`.
30    pub path: String,
31    /// Human-readable message.
32    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
41/// Parse `"ATIF-vX.Y"` into `(X, Y)`.
42fn 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
48/// Validate a typed trajectory (serializes then defers to [`validate_value`]).
49pub 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
59/// Validate an ATIF trajectory from
60/// its RAW BYTES. This is the correct entry point for every path
61/// that receives operator/attacker-supplied ATIF content (CLI
62/// `avctl atif-validate`, reconciler recovery + promotion). Unlike
63/// `serde_json::from_slice::<Trajectory>()` + `validate_trajectory`,
64/// the bytes path:
65///
66///   1. Refuses duplicate JSON keys anywhere in the payload
67///      (parallel to `av_receipts::Receipt::from_json_slice`'s
68///      strict pre-scan), so the auditor cannot
69///      be shown a document whose "last wins" reading differs from
70///      the "first wins" reading.
71///   2. Runs `validate_value` on the parsed `serde_json::Value` — the
72///      untyped path exercises `check_unknown_fields`, which the
73///      typed `Trajectory` (no `deny_unknown_fields`) silently drops.
74///
75/// Returns `Err(reason)` on duplicate-key or parse failure; returns
76/// `Ok(issues)` on successful parse (issues may be empty).
77pub 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
84/// Refuse any JSON object with duplicate keys anywhere in the payload.
85/// Mirrors the internal check in `av-receipts::Receipt::from_json_slice`
86/// and `av-sandbox::refuse_duplicate_json_keys`; exposed here to give
87/// ATIF ingest a single strict-parse primitive without cross-crate
88/// coupling.
89///
90/// R65 F1: uses `DUP_KEY_SENTINEL` for stable classification, matching
91/// the discipline in `av_receipts::check_no_duplicate_keys`
92/// (`__av_dup:`) and `av_sandbox::rpc::reject_duplicate_keys`
93/// (`__av_sandbox_dup:`). Without a sentinel, operators inspecting
94/// FinalizeError chains have no stable substring to distinguish an
95/// ATIF duplicate-key rejection from an ordinary
96/// serde_json parse error ("expected value at line N column M"),
97/// making triage and metric-labelling noisier. The sentinel is
98/// stripped before returning so the caller's error text stays
99/// human-readable.
100fn 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        // Sentinel exists so classification is stable even after
168        // serde_json wraps the message with " at line N column M"
169        // trailer. Reformat with the shared "duplicate JSON key
170        // rejected: " prefix that `av_sandbox::rpc::reject_duplicate_keys`
171        // uses (rpc.rs:323) so operators grepping error logs see
172        // one canonical marker across both trust-boundary
173        // reject sites.
174        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        // Cap issue collection so a pathological
185        // trajectory cannot allocate millions of ValidationIssue
186        // structs. We allow ONE extra slot past MAX so
187        // `truncate_issues_with_marker` can detect the overflow
188        // and emit a synthetic truncation notice.
189        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
252/// Cap on the number of issues collected by `validate_value`.
253///
254/// A maliciously crafted trajectory sitting inside
255/// MAX_ATIF_BYTES (64 MiB) can legitimately trigger millions of
256/// validation issues. Each `ValidationIssue` allocates two owned
257/// Strings (path + message); five million entries → ~500 MiB
258/// Vec, hitting OOM before the caller ever renders a bounded
259/// error message (as once happened in the reconciler). Cap the collection
260/// so the memory bound is O(cap), independent of the input's
261/// pathological branching.
262///
263/// When the cap fires, a synthetic tail issue is emitted so
264/// downstream consumers know the truncation happened.
265pub const MAX_VALIDATION_ISSUES: usize = 4096;
266
267/// Mirror the `av_receipts::MAX_NESTED_DEPTH = 128`
268/// bound. ATIF's public [`validate_value`] accepts any
269/// programmatically-constructed `Value` at unbounded depth, and
270/// each recursion frame of [`validate_trajectory_obj`] allocates
271/// heavily (locals + `format!` on `path`), so stack overflow is
272/// reachable well before serde_json's default 128-frame parser
273/// ceiling would help. The reconciler's `from_slice::<Trajectory>`
274/// path is bounded by serde_json today, but any future call site
275/// that parses ATIF via `Deserializer::disable_recursion_limit()`
276/// (for shallow-but-huge files) silently reopens a crash. Cap
277/// here so no such caller can reopen it.
278const MAX_NESTED_DEPTH: usize = 128;
279
280/// Upper bound for USD-denominated cost fields
281/// (`total_cost_usd` in `final_metrics`, `cost_usd` per step). Aligned
282/// with the downstream recovery scaling in
283/// `av_harness::session::cost_from_atif_total`: recovery converts
284/// dollars → micros via `cost * USD_MICROS_PER_DOLLAR (1_000_000)`
285/// and refuses results exceeding `JCS_SAFE_MAX = 2^53`. Setting the
286/// strict-validator cap at `9e9` (nine billion USD) fits comfortably
287/// below `JCS_SAFE_MAX / USD_MICROS_PER_DOLLAR (~9.007e9)` and
288/// ensures every trajectory that passes strict validation can also
289/// pass recovery — before this alignment the validator accepted up
290/// to `1e12` (one trillion USD) while recovery capped at ~9e9, so
291/// hypothetical mid-range trajectories became unrecoverable with an
292/// explicit "recovered cost exceeds JCS-safe bounds" error.
293///
294/// `9e9` USD (~9× the whole LLM industry's 2026 annual spend) is
295/// still operationally absurd for any real agent run, so the cap
296/// remains a hostile-input guard, not a business-logic limit.
297///
298/// Historical context: strict mode's original floor at 0.0 was
299/// asymmetric — no ceiling — so a hostile trajectory carrying
300/// `total_cost_usd: 1.7e308` passed strict validation and flowed
301/// into promotion, dashboards, and receipt subject payloads. Not a
302/// signing hazard (receipt subject uses the ATIF file hash, not the
303/// cost fields) but a metrics-poisoning primitive: Prometheus
304/// histograms of av_session_cost_usd would blow up their bucketing;
305/// downstream billing exporters might saturate their accumulators.
306pub const MAX_COST_USD: f64 = 9e9;
307
308/// Upper bound for id-shaped string fields — matches
309/// [`av_core::ids::SessionId::parse`]'s 128-byte cap so a Trajectory
310/// whose `session_id` / `trajectory_id` / `tool_call_id` /
311/// `function_name` / `continued_trajectory_ref` passes strict
312/// validation is guaranteed constructible into a `SessionId`-like
313/// value downstream without further length-guarding.
314///
315/// The runtime is protected via `ensure_atif_provenance_from_bytes`
316/// (a hostile writer can't forge the sidecar MAC), but external
317/// consumers of [`validate_value`]/[`validate_bytes`] — `avctl
318/// atif-validate`, third-party promotion pipelines, dashboards
319/// ingesting foreign trajectories — get log-injection and
320/// preview-corruption primitives if raw `String` id fields aren't
321/// class-checked here. Same amplification-defense reasoning as
322/// `SessionId::parse` applies (echoing 128-byte values is safe).
323pub(crate) const MAX_ID_LEN: usize = 128;
324
325/// Upper bound for freeform text fields (`notes`). Freeform allows
326/// any UTF-8 (including control chars like `\n`), but a 10 MiB
327/// `notes` field would bloat receipts and dashboards — cap at 8 KiB
328/// which comfortably fits any legitimate annotation.
329pub(crate) const MAX_NOTES_LEN: usize = 8 * 1024;
330
331/// Upper bound for `step.reasoning_content` (CoT / thinking traces).
332/// Same bloat-prevention rationale as `notes` but with a larger cap:
333/// legitimate CoT dumps from long-context reasoning models can run
334/// tens of KB. 256 KiB is generous for real work while stopping a
335/// hostile writer from ballooning receipts / dashboards / audit
336/// spool via per-step reasoning fields (the bloat multiplies by
337/// step count, unlike `notes` which is trajectory-root).
338pub(crate) const MAX_REASONING_LEN: usize = 256 * 1024;
339
340/// True iff every byte of `s` is in the visible-ASCII range 0x21..=0x7E.
341/// Same predicate as [`av_core::ids::SessionId::parse`]. Header-safe
342/// / log-injection-safe / filesystem-safe / JCS-safe class.
343fn is_visible_ascii(s: &str) -> bool {
344    s.bytes().all(|b| (0x21..=0x7e).contains(&b))
345}
346
347/// Validate that `obj[field]`, if present and non-null, is an
348/// id-shaped string: non-empty, ≤ [`MAX_ID_LEN`] bytes, visible-ASCII
349/// only. The type check ("must be a string") is expected to already
350/// have been emitted upstream for the same field; this helper only
351/// runs when the value IS a string, so an `is_string()` guard prevents
352/// double-flagging a wrong-typed field.
353fn 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        // NEVER echo the full oversized value — same amplification
368        // defense as SessionId::parse's "starts …" fingerprint.
369        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        // Length is bounded (≤ MAX_ID_LEN by the check above), so
380        // echoing the value here is safe.
381        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
390/// Validate that `obj[field]`, if present and non-null, is a
391/// display-name-shaped string: non-empty, ≤ [`MAX_ID_LEN`] bytes,
392/// and free of control bytes (`0x00..=0x1F` and `0x7F`).
393///
394/// Unlike [`check_id_field`], this permits spaces and non-ASCII
395/// UTF-8 characters — model names ("Claude 3.5 Sonnet") and agent
396/// version strings ("0.1.2-rc.1 (build 42)") legitimately contain
397/// spaces and non-ASCII punctuation, but a control byte (CR/LF/BEL)
398/// is exclusively a log-injection primitive: every legitimate
399/// display value is renderable without one. Length cap prevents
400/// bloat downstream.
401fn 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    // Empty-value semantics differ per caller (some fields require
411    // non-empty and are checked upstream); this helper does not
412    // enforce non-empty on its own.
413    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    // Reject C0 controls (0x00-0x1F) and DEL (0x7F). CR/LF are log-
427    // injection; NUL, BEL, backspace and friends are terminal-poisoning
428    // primitives. Legitimate display names contain none of these.
429    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
439/// Validate a raw JSON value as an ATIF trajectory.
440///
441/// Returns at most [`MAX_VALIDATION_ISSUES`] issues plus a
442/// trailing synthetic entry noting truncation when the cap fires.
443pub fn validate_value(root: &Value, mode: Mode) -> Vec<ValidationIssue> {
444    let mut issues = Vec::new();
445    // Track the ancestor `trajectory_id` chain across
446    // recursion frames so a cycle A -> B -> A can be detected even
447    // when B is a great-grandchild of A. The prior per-frame
448    // HashSet only caught duplicates among direct siblings; a
449    // trajectory can claim to be a re-invocation of any ancestor
450    // and downstream analysis would treat the tree as a genuine
451    // recursive call.
452    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    // Strict mode additionally enforces the two schema semantics the
455    // field-wise walker's null-tolerant type checks (`!v.is_null()`
456    // escapes, mirroring serde `Option`) could not express, and whose
457    // absence let strict-valid documents fail the shipped schema:
458    //   1. explicit `null` is not schema-valid for any typed field
459    //      (JSON Schema types never include null here);
460    //   2. `extra` is pinned `{"type":"object"}` everywhere EXCEPT the
461    //      free-form root/agent/step/tool-call positions (`"extra": {}`).
462    if mode == Mode::Strict {
463        strict_null_and_extra_conformance(root, "", "trajectory", &mut issues, 0);
464    }
465    truncate_issues_with_marker(issues)
466}
467
468/// See the call site in [`validate_value`]: strict-mode schema-parity
469/// pass for explicit nulls and object-typed `extra` fields. `parent_key`
470/// is the property name of the containing object with array indices
471/// skipped ("" for the document root; nested subagent trajectory roots
472/// carry "subagent_trajectories", whose rules match the root's).
473fn 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                    // Free-form (`"extra": {}`) positions accept any
489                    // value, null included; every other `extra` is
490                    // schema-pinned `{"type":"object"}`.
491                    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                    // Interiors of `extra` are unconstrained either way.
499                    continue;
500                }
501                // Unconstrained interiors (bare `array` / string-or-array
502                // `oneOf` / object-only tool-definition items): the
503                // container value itself must not be null, but its
504                // contents are schema-free. `tool_definitions` interiors
505                // in particular routinely carry legitimate explicit
506                // nulls (OpenAI function schemas: `"default": null`,
507                // `"enum": [null, ...]`) and nested keys named `extra` —
508                // the shipped schema pins the items only as
509                // `{"type":"object","maxProperties":128}`.
510                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
553/// After a validation pass, cap the number of issues
554/// returned so downstream consumers never see a >>bounded Vec.
555/// The `issue!` macro allows ONE slot past MAX so this function
556/// can detect the overflow and swap in a synthetic truncation
557/// notice.
558fn 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    // Cap recursion depth. `subagent_trajectories`
598    // recurses; adversarial nesting could otherwise stack-overflow
599    // the validator. Return without adding the whole subtree's
600    // findings — one clear message beats a stack unwind.
601    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    // Ancestor-cycle detection. Insert this frame's
616    // trajectory_id (if any) into the shared ancestor set BEFORE
617    // recursing into `subagent_trajectories`. If the id is already
618    // in the set, an ancestor already claimed it — the tree encodes
619    // a false recursive self-call. On the way out, we remove the id
620    // so a subtree A -> B -> C at one branch does not also flag
621    // A -> D -> C at a sibling branch. `inserted_id` records
622    // whether we actually added an id (empty / missing ids are
623    // ignored so they don't accidentally clash).
624    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    // schema_version
642    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    // Version gates on root fields.
664    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    // Optional string fields: `and_then(Value::as_str)` elsewhere treats
685    // a wrong-typed value like an absent one, so e.g. `"notes": 42`
686    // passed Strict validation with zero issues yet fails typed
687    // deserialization. Flag the type mismatch explicitly (null stays
688    // legal — serde maps it to None).
689    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    // Id-shape byte-class + length checks for the trajectory-root id
695    // fields. Parity with `SessionId::parse` so downstream consumers
696    // (avctl atif-validate, promotion pipelines, dashboards) receive
697    // ids that are safe to log / display / use as filesystem stems.
698    // See `MAX_ID_LEN` for the full rationale.
699    for f in ["session_id", "trajectory_id", "continued_trajectory_ref"] {
700        check_id_field(obj, f, path, issues);
701    }
702    // `notes` is freeform text (any UTF-8, including \n) but must be
703    // bounded so a hostile trajectory can't bloat receipts or
704    // dashboards. Type check for `notes` is emitted above; length
705    // check only runs on the string branch.
706    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    // session_id optionality was relaxed in v1.7; older files must carry it.
717    // A present-but-wrong-typed value is a type error (flagged above), not
718    // a missing field.
719    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    // agent
728    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            // Optional-string type check parity with the
739            // trajectory-root optional strings above. `Trajectory` types
740            // `model_name` as `Option<String>`, so a wrong-typed value
741            // (`model_name: 123`) would fail typed deserialization but
742            // used to pass Strict — the schema at
743            // `schemas/atif-v1.7.schema.json` also declares it as
744            // `string`. Flag the type mismatch so the strict validator
745            // does not silently accept documents the schema refuses.
746            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            // Display-name byte-class + length checks for `agent.name`,
753            // `agent.version`, `agent.model_name`. Same log-injection
754            // primitive as the trajectory-root ids fixed above, but
755            // display names legitimately contain spaces and non-ASCII
756            // ("Claude 3.5 Sonnet", "0.1.2-rc.1 (build 42)") — the
757            // `check_display_name_field` variant permits UTF-8 while
758            // still refusing control bytes (log-injection) and
759            // enforcing MAX_ID_LEN.
760            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            // `Agent.tool_definitions` is
774            // `Option<Vec<Map>>` on the typed model — an array-of-object
775            // shape. STEP_FIELDS-style presence gating alone let a
776            // wrong-typed value pass the strict validator while
777            // deserialization then failed.
778            if let Some(defs) = agent.get("tool_definitions") {
779                match defs.as_array() {
780                    Some(items) => {
781                        // Schema: `maxItems: 512`, items `maxProperties:
782                        // 128`. Without these the strict validator
783                        // accepted documents the shipped schema rejects,
784                        // breaking the declared strict-valid ⇒
785                        // schema-valid invariant (golden.rs).
786                        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    // Pre-collect embedded
823    // `subagent_trajectories[*].trajectory_id` so we can cross-check
824    // step-level `subagent_trajectory_ref` entries against them.
825    // Historically the ref shape was only "must have trajectory_id
826    // or trajectory_path"; a producer could emit `trajectory_id:
827    // "does-not-exist"` and it would pass validation, giving
828    // unverifiable delegation provenance. Collect the id set here so
829    // `validate_step` (via observation.results processing) can flag
830    // dangling refs.
831    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    // steps
846    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    // final_metrics
868    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                            // JCS-safe bound: the daemon's recovery
887                            // path refuses counters above 2^53
888                            // (receipts canonicalize via JCS), so a
889                            // strict-valid trajectory carrying one
890                            // was unrecoverable-by-construction —
891                            // strict validity and daemon
892                            // recoverability must agree.
893                            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    // Strict-mode aggregate sanity: total_steps must equal the step
928    // count (both in-tree writers set it that way, and a summary that
929    // miscounts its own steps is machine-detectably contradictory).
930    // Token-total reconciliation against step sums was ATTEMPTED here
931    // and deliberately dropped: the daemon's close path builds
932    // final_metrics from the session's live usage fold, which is
933    // legitimately decoupled from per-step metrics blocks in BOTH
934    // directions (fold counts usage on steps that carry no metrics
935    // block; test-pinned close paths produce totals below step sums) —
936    // refusing either direction quarantined real sessions at close.
937    // Cross-checking token totals needs a semantic redesign of what
938    // final_metrics attests, not a validator arm; see the findings
939    // ledger.
940    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    // subagent trajectories recurse with the same rules.
958    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        // A non-array value previously slipped through (no else-arm) yet
991        // fails typed deserialization. Null stays legal (serde -> None).
992        Some(v) if !v.is_null() => issue!(
993            issues,
994            format!("{path}.subagent_trajectories"),
995            "must be an array"
996        ),
997        _ => {}
998    }
999
1000    // Remove this frame's id so sibling branches (a
1001    // different subtree at the same ancestor) can legitimately
1002    // reuse the id without triggering a false-positive cycle.
1003    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    // step_id: sequential from 1.
1024    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    // timestamp: optional ISO 8601.
1042    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    // source.
1055    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    // `Step.model_name` is `Option<String>` on the typed
1088    // model; a wrong-typed value (`"model_name": 123`) fails typed
1089    // deserialisation but used to pass Strict.
1090    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    // Display-name class check for per-step `model_name` — same
1097    // rationale as `agent.model_name`. Runs only on the string
1098    // branch; a wrong-typed value already has its own issue.
1099    check_display_name_field(obj, "model_name", path, issues);
1100
1101    // `Step.is_copied_context` is `Option<bool>`
1102    // on the typed model. It was only listed in STEP_FIELDS (so
1103    // check_unknown_fields let it through) but never type-checked, so
1104    // a wrong-typed value (`"is_copied_context": "yes"`) diverged the
1105    // CLI validator ("valid") from the typed deserializer ("invalid").
1106    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    // Agent-only fields.
1114    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            // observation: agent always; system since v1.2; never user.
1126            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    // Wrong-typed values must be flagged, not silently treated as absent:
1147    // `and_then(Value::as_u64)` below maps `"llm_call_count": "1"` to None,
1148    // which passed Strict validation while typed deserialization fails
1149    // (same class as the optional-string fields at the trajectory root).
1150    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    // Same wrong-type class as the `model_name` and
1161    // `is_copied_context` fixes: the typed model is
1162    // `Option<String>`, and a non-string here passed strict validation
1163    // while every typed consumer failed to load the document.
1164    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    // Bloat-prevention cap on per-step `reasoning_content` — same
1171    // freeform-text rationale as `notes` but with a larger cap
1172    // (`MAX_REASONING_LEN`) because legitimate CoT can run tens of KB
1173    // and the field is per-step (bloat multiplies by step count).
1174    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    // Multimodal content-part arrays require v1.6+.
1197    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    // tool_calls + observation cross-references.
1208    //
1209    // `HashSet<String>` instead of `Vec<String>` so duplicate detection
1210    // AND the observation source-id lookup below run in O(N + M)
1211    // instead of O(N² + N·M). A `trajectory.json` with 100k tool calls
1212    // and 100k observation results used to burn ~10¹⁰ string
1213    // comparisons in the CLI validator (used by CI, promotion, and
1214    // external auditors on untrusted files).
1215    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                    // Byte-class + length parity with SessionId — id
1242                    // fields inside tool calls must be safe for
1243                    // downstream logging / display too. Only runs if
1244                    // the field is a non-empty string (present +
1245                    // non-empty was checked above); a wrong-type or
1246                    // missing value already has its own issue.
1247                    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                            // Wrong-typed refs must be flagged, not silently
1303                            // treated as absent (`and_then(Value::as_array)`
1304                            // maps an object/string here to None).
1305                            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                                // Version gate parity with
1319                                // the sibling root-level
1320                                // `subagent_trajectories` (v1.7-only).
1321                                // Both were introduced in v1.7 per the
1322                                // shipped schema; without this check an
1323                                // ATIF-v1.0 file carrying the ref field
1324                                // inside observation.results[] slipped
1325                                // past both Strict and Compat modes
1326                                // silently.
1327                                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                                    // Each SubagentTrajectoryRef
1350                                    // typed member is `Option<String>` in
1351                                    // model.rs. Wrong-typed values would
1352                                    // fail typed deserialisation but used
1353                                    // to only surface as the misleading
1354                                    // "must set trajectory_id or
1355                                    // trajectory_path" — because the
1356                                    // has_id/has_path check maps a bad
1357                                    // type to None via `as_str`.
1358                                    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                                    // Byte-class + length parity with
1367                                    // SessionId for the id-shaped
1368                                    // fields inside subagent refs. A
1369                                    // wrong-type value already flagged
1370                                    // above; check_id_field skips
1371                                    // non-string values.
1372                                    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                                    // Schema parity: the shipped schema
1386                                    // pins minLength 1 on a PRESENT
1387                                    // trajectory_path; an empty string
1388                                    // riding beside a resolvable id
1389                                    // validated here but failed every
1390                                    // schema-based verifier.
1391                                    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 `trajectory_id` names an
1403                                    // embedded delegation, it MUST
1404                                    // resolve. A dangling id is
1405                                    // unverifiable delegation
1406                                    // provenance — a producer could
1407                                    // point downstream auditors at
1408                                    // "sub-999" that never existed.
1409                                    // `trajectory_path` remains
1410                                    // uncheckable at strict-validate
1411                                    // time (external ref) and is not
1412                                    // subject to this rule.
1413                                    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                                // Wrong-typed ids must be flagged, not silently
1441                                // skipped: `and_then(Value::as_str)` mapped
1442                                // `"source_call_id": 123` to None, bypassing
1443                                // the same-step linkage check entirely.
1444                                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    // metrics.
1487    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                            // Same JCS-safe bound as final_metrics: the
1496                            // daemon folds step counters into receipt
1497                            // bodies, which refuse integers past 2^53.
1498                            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                // Strict-mode type checks for
1534                // fields the wrong-type sweep above misses. The typed model
1535                // rejects a wrong type at deserialize (e.g. logprobs =
1536                // "x"), but validate_bytes/validate_value(Strict)
1537                // previously only presence-gated these — so the CLI
1538                // said "valid" while the harness reconciler's typed
1539                // path said "invalid", diverging the two verdicts on
1540                // the same file.
1541                if let Mode::Strict = mode {
1542                    // `logprobs` is `Vec<f64>` in the typed model: any JSON
1543                    // number is acceptable.
1544                    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                    // The token-id arrays are `Vec<u64>` in the typed model
1563                    // and `integer, minimum: 0` in the schema — `is_number`
1564                    // alone admitted negative/fractional elements here that
1565                    // the typed deserialize path rejects, re-opening the
1566                    // verdict split this block exists to close.
1567                    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
1596/// Minimal-but-strict ISO-8601 validation: `YYYY-MM-DDTHH:MM:SS[.fff...][Z|±HH:MM]`.
1597fn 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    // RFC 3339 (the schema's `format: date-time`) only admits the leap
1630    // second `:60` at end-of-day (`23:59:60`); `jsonschema` with format
1631    // assertions rejects `:60` at any other hour/minute. Accepting it
1632    // here would break the strict-valid ⇒ schema-valid invariant.
1633    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    // Fraction + timezone tail.
1652    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; // dot with no digits
1661        }
1662    }
1663    match b.get(i) {
1664        // Naive timestamps (no offset) are valid bare ISO-8601, so
1665        // Compat mode — the designated tolerance path for foreign
1666        // writers (Python's `datetime.isoformat()` emits exactly this
1667        // shape) — accepts them. The shipped schema pins
1668        // `format: date-time` (RFC 3339), which REQUIRES an offset, so
1669        // Strict mode refuses them (strict-valid ⇒ schema-valid).
1670        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    /// A maliciously crafted trajectory containing tens of
1694    /// thousands of unknown fields must NOT allocate an unbounded
1695    /// Vec<ValidationIssue>. The cap fires and a synthetic
1696    /// truncation marker signals the tail.
1697    #[test]
1698    fn issue_collection_is_bounded_and_marks_truncation() {
1699        // Build a trajectory-shaped Value with thousands of unknown
1700        // fields at the top level. Each triggers one issue in strict
1701        // mode. Well past the MAX_VALIDATION_ISSUES cap.
1702        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        // Last entry must be the synthetic truncation marker.
1725        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        // Naive (offset-less) timestamps: valid bare ISO-8601 (compat
1747        // tolerates foreign writers like Python's isoformat()) but NOT
1748        // RFC 3339 — the shipped schema's `format: date-time` rejects
1749        // them, so strict must too (strict ⇒ schema-valid).
1750        assert!(is_iso8601("2025-01-15T10:30:00", false));
1751        assert!(!is_iso8601("2025-01-15T10:30:00", true));
1752    }
1753
1754    /// End-to-end pin of the per-mode timestamp-offset requirement (a
1755    /// mutant swapping the Strict/Compat gate previously survived):
1756    /// the same naive-timestamp document is compat-valid and
1757    /// strict-invalid.
1758    #[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    /// Regression: strict mode used a bare `is_number` check for the
1784    /// token-id arrays, so `[-5]` or `[2.5]` passed validation while
1785    /// the typed model (`Vec<u64>`) and the shipped schema
1786    /// (`integer, minimum: 0`) both reject them — the exact
1787    /// CLI-vs-typed-path verdict split this block exists to close.
1788    #[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            // Valid: non-negative integers — strict verdict and typed
1811            // deserialize must both accept.
1812            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            // Invalid: negative and fractional elements — the typed
1822            // model refuses, so strict must too.
1823            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        // `logprobs` stays `Vec<f64>`: negative/fractional numbers are
1838        // legitimate there and must NOT be flagged.
1839        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            // Leap second anywhere but end-of-day: RFC 3339 (and the
1863            // shipped schema's format assertion) only allow `:60` at
1864            // `23:59:60`. `sec > 60` alone let `12:30:60` through,
1865            // breaking strict-valid ⇒ schema-valid.
1866            "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            // Round-20 mutation findings: the offset arm's bounds were
1874            // unpinned (`&&` → `||` mutants at is_iso8601's offset
1875            // check survived) — nothing refused an offset hour above
1876            // 14, offset minutes above 59, or a malformed separator.
1877            "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        // Boundary acceptance: ±14:00 is the real-world maximum offset.
1888        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    /// Round-20 mutation findings: the version-gate comparisons
1898    /// (`ver < (1, N)` → "field requires ATIF-v1.N+") and the
1899    /// tool_calls fan-out cap were unpinned — `<`/`>` mutants at each
1900    /// gate survived, so a refactor could silently stop enforcing the
1901    /// spec's compatibility matrix (accepting v1.6-gated fields in a
1902    /// v1.2 doc, or unbounded tool_calls maps). Table-driven pin: each
1903    /// entry is a minimal doc valid EXCEPT for the one gated defect,
1904    /// asserted to produce an issue naming the right path — plus the
1905    /// boundary version where the same doc must validate clean.
1906    #[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        // metrics.completion_token_ids requires v1.3+.
1933        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        // metrics.prompt_token_ids requires v1.4+.
1944        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        // observation.results[].content: content-part ARRAYS require
1955        // v1.6+; wrong TYPES are refused everywhere.
1956        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        // Round-24 second pass: the wrong-type refusal trios
1980        // (`!is_string() && !is_null()` shapes) and remaining version
1981        // gates were still unpinned — table-drive them with the same
1982        // doc builder. Each row: (step extras, issue needle).
1983        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        // And the acceptance side: correct types produce NO issue for
2011        // the same needles (guards the `&&`→`||` direction).
2012        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    /// Round-26 pass 3: the tool_definitions caps enforce the declared
2040    /// strict-valid ⇒ schema-valid invariant (schema maxItems: 512,
2041    /// items maxProperties: 128 — see golden.rs), but both boundaries
2042    /// had surviving `>` mutants. Pin exactly-at (clean) and one-past
2043    /// (refused) for each.
2044    #[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    /// Round-26: the truncation helper's `>` was mutable to `>=` —
2089    /// exactly MAX issues must pass through UNMARKED; MAX+1 must
2090    /// truncate and append the synthetic notice.
2091    #[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    /// Round-27: the freeform-cap magnitudes (notes 8 KiB,
2120    /// reasoning_content 256 KiB) had surviving `*` mutants in their
2121    /// const expressions — pin the exact byte boundaries.
2122    #[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        // notes is trajectory-root.
2153        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    /// Wrong-typed optional fields must be flagged: `and_then(Value::as_str)`
2173    /// used to skip them silently, so documents that fail typed
2174    /// deserialization passed Strict validation with zero issues.
2175    #[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        // Validator and serde must agree: typed deserialization also rejects.
2203        assert!(serde_json::from_value::<crate::model::Trajectory>(value).is_err());
2204    }
2205
2206    /// R45 Finding 2: id-shaped string fields must be visible-ASCII
2207    /// only and ≤ MAX_ID_LEN bytes, matching SessionId::parse. A
2208    /// hostile trajectory carrying `session_id: "sess\r\nfake-log"`
2209    /// or a 10 KiB trajectory_id would pass Strict before this fix
2210    /// and give external CLI/library consumers (avctl atif-validate,
2211    /// third-party promotion pipelines, dashboards) log-injection /
2212    /// preview-corruption primitives that SessionId was designed to
2213    /// eliminate. Runtime is protected via MAC verification but
2214    /// external consumers were not.
2215    #[test]
2216    fn id_shaped_fields_reject_control_bytes_and_oversize() {
2217        // Control byte (CR/LF) — log-injection primitive.
2218        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        // Oversize id (129 bytes = MAX_ID_LEN + 1).
2234        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        // Non-ASCII (RTL override) is refused.
2251        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        // Empty session_id (present but empty string): flagged distinctly
2266        // from "missing" — the type check passes, but the class check
2267        // rejects for being non-empty-required.
2268        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        // Valid id passes — MAX_ID_LEN (128) exact-length,
2283        // visible-ASCII stays legal.
2284        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    /// R45 Finding 2: `notes` is freeform text (any UTF-8) but must
2298    /// be length-bounded so a hostile writer can't bloat receipts
2299    /// and dashboards with a 10 MiB annotation field.
2300    #[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        // At-cap notes stay legal.
2319        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    /// R46 review-of-R45: `agent.name`, `agent.version`,
2334    /// `agent.model_name`, and `step.model_name` are display-name-
2335    /// shaped peers of the trajectory-root ids R45 hardened. Same
2336    /// log-injection primitive against downstream tools that render
2337    /// them on dashboards/logs, but they legitimately contain spaces
2338    /// and non-ASCII (e.g. "Claude 3.5 Sonnet"), so
2339    /// `check_display_name_field` refuses control bytes and enforces
2340    /// MAX_ID_LEN while permitting spaces + UTF-8.
2341    #[test]
2342    fn agent_and_step_display_names_reject_control_bytes_and_oversize() {
2343        // CR/LF in agent.name → log-injection primitive.
2344        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        // Spaces + non-ASCII UTF-8 stay legal.
2359        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        // Oversize agent.version fingerprint-echoes safely (bounded).
2375        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        // Per-step model_name control-byte refusal.
2391        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    /// R46 review-of-R45: `step.reasoning_content` is a freeform-
2414    /// text peer of `notes` — R45 capped `notes` but skipped this
2415    /// per-step field. Bloat multiplies by step count so a larger
2416    /// cap (MAX_REASONING_LEN = 256 KiB) applies but the same
2417    /// principle holds: a hostile writer must not bloat receipts /
2418    /// dashboards / audit spool via reasoning traces.
2419    #[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        // At-cap reasoning_content stays legal.
2444        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    /// R45 Finding 2: tool call id-shaped fields (`tool_call_id`,
2465    /// `function_name`) must apply the same visible-ASCII + length
2466    /// guards as top-level ids — they flow into audit records, logs,
2467    /// and dashboards the same way.
2468    #[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    /// Wrong-typed step-level optional fields must be flagged, not silently
2521    /// treated as absent: `llm_call_count`, `source_call_id`, and
2522    /// `subagent_trajectory_ref` all used `and_then(as_*)`, which mapped a
2523    /// wrong-typed value to `None` and passed Strict validation while typed
2524    /// deserialization fails.
2525    #[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        // Null means absent for the typed model and for compat mode;
2562        // strict mode rejects it for schema parity (strict ⇒
2563        // schema-valid).
2564        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    /// Mutation-run hardening: the `llm_call_count == 0` consistency
2587    /// rule (agent steps with zero LLM calls must not carry `metrics`
2588    /// or `reasoning_content`) had no test — flipping its `==` to `!=`
2589    /// survived. Pin both sides: 0 + metrics is flagged, nonzero +
2590    /// metrics is not.
2591    #[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    /// Explicit nulls deserialize to `None` for optional fields, so the
2626    /// type checks must not flag them.
2627    #[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        // Compat mode keeps the serde-Option tolerance (null ≡ absent);
2639        // strict mode now rejects explicit nulls because the shipped
2640        // schema does (strict ⇒ schema-valid, pinned by golden.rs).
2641        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    /// `validate_bytes` refuses duplicate JSON keys at
2653    /// any nesting level. Serde-typed parse alone would silently keep
2654    /// the last value, letting a hostile issuer sign under one
2655    /// interpretation while an auditor's raw-bytes reader sees the
2656    /// other.
2657    #[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    /// Partner to the duplicate-key test: even when the bytes are legit, the
2679    /// `validate_bytes` path also runs `validate_value` on the
2680    /// untyped form, exercising `check_unknown_fields` — a rejection
2681    /// class the typed `validate_trajectory` path silently drops.
2682    #[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    /// A `subagent_trajectory_ref` with a `trajectory_id`
2696    /// must resolve against the outer trajectory's embedded
2697    /// `subagent_trajectories` array. A dangling id gives
2698    /// unverifiable delegation provenance — the auditor is pointed at
2699    /// a subagent trajectory that doesn't exist in the document.
2700    #[test]
2701    fn dangling_subagent_trajectory_ref_is_flagged() {
2702        // Reference an embedded id that DOES NOT appear in
2703        // subagent_trajectories.
2704        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        // A resolvable ref passes.
2734        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        // trajectory_path (external ref) is not subject to the check.
2763        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    /// Pre-1.7 files require `session_id`; a present-but-wrong-typed value
2788    /// is a type error, not the misleading "required field is missing".
2789    #[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}