Skip to main content

av_atif/
writer.rs

1//! Trajectory construction and crash-safe persistence.
2
3use crate::model::{Agent, FinalMetrics, Metrics, Step, Trajectory, ATIF_VERSION};
4use std::path::Path;
5
6/// Errors from trajectory building / writing.
7#[derive(Debug, thiserror::Error)]
8#[non_exhaustive]
9pub enum WriterError {
10    /// Filesystem failure.
11    #[error("io error writing trajectory: {0}")]
12    Io(#[from] std::io::Error),
13    /// Serialization failure.
14    #[error("serialization error: {0}")]
15    Serde(#[from] serde_json::Error),
16    /// The built trajectory failed self-validation — never write invalid files.
17    #[error("built trajectory failed validation: {0:?}")]
18    Invalid(Vec<crate::validate::ValidationIssue>),
19}
20
21/// Incremental builder used by the harness's unsigned-workflow path.
22///
23/// Steps receive sequential ids automatically; aggregate metrics accumulate as
24/// steps are appended (checked arithmetic — a corrupt aggregate must fail
25/// loudly, not wrap).
26#[derive(Debug, Clone)]
27pub struct TrajectoryBuilder {
28    session_id: Option<String>,
29    trajectory_id: Option<String>,
30    agent: Agent,
31    steps: Vec<Step>,
32    total_prompt: u64,
33    total_completion: u64,
34    total_cached: u64,
35    total_cost: f64,
36}
37
38impl TrajectoryBuilder {
39    /// Start a trajectory for `agent`.
40    pub fn new(agent: Agent, session_id: Option<String>) -> Self {
41        Self {
42            session_id,
43            trajectory_id: Some(av_core::new_event_uid()),
44            agent,
45            steps: Vec::new(),
46            total_prompt: 0,
47            total_completion: 0,
48            total_cached: 0,
49            total_cost: 0.0,
50        }
51    }
52
53    /// Number of steps so far.
54    pub fn len(&self) -> usize {
55        self.steps.len()
56    }
57
58    /// True when no steps have been appended.
59    pub fn is_empty(&self) -> bool {
60        self.steps.is_empty()
61    }
62
63    /// Append a step. The builder overwrites `step_id` with the next
64    /// sequential id (callers cannot corrupt ordering) and accumulates
65    /// aggregate metrics.
66    pub fn push_step(&mut self, mut step: Step) -> Result<(), av_core::CoreError> {
67        step.step_id = self.steps.len() as u64 + 1;
68        // Strict-validator parity (same rationale as the
69        // cost cap below: the builder must never accept a step that
70        // makes the trajectory permanently un-writable at
71        // `write_atomic`, which strict-validates before persisting):
72        // agent steps couple `llm_call_count` and `metrics` —
73        // `llm_call_count == 0` forbids metrics/reasoning_content, any
74        // other agent step requires metrics with all three token
75        // fields present.
76        if step.source == crate::model::Source::Agent {
77            if step.llm_call_count == Some(0) {
78                if step.metrics.is_some() || step.reasoning_content.is_some() {
79                    return Err(av_core::CoreError::InvalidId(
80                        "agent step with llm_call_count 0 must not carry metrics or reasoning_content"
81                            .to_owned(),
82                    ));
83                }
84            } else {
85                let complete = step.metrics.as_ref().is_some_and(|m| {
86                    m.prompt_tokens.is_some() && m.completion_tokens.is_some() && m.cached_tokens.is_some()
87                });
88                if !complete {
89                    return Err(av_core::CoreError::InvalidId(
90                        "agent step requires metrics with prompt/completion/cached token counts \
91                         (strict ATIF-v1.7 agent-step fidelity)"
92                            .to_owned(),
93                    ));
94                }
95            }
96        }
97        // Compute the proposed totals into locals so an overflow on the
98        // second/third field cannot leave the builder with partially-updated
99        // totals ahead of a step that never got pushed.
100        let (next_prompt, next_completion, next_cached, next_cost) = if let Some(m) = &step.metrics {
101            let next_prompt = self
102                .total_prompt
103                .checked_add(m.prompt_tokens.unwrap_or(0))
104                .ok_or(av_core::CoreError::Overflow {
105                    context: "total_prompt_tokens",
106                })?;
107            let next_completion = self
108                .total_completion
109                .checked_add(m.completion_tokens.unwrap_or(0))
110                .ok_or(av_core::CoreError::Overflow {
111                    context: "total_completion_tokens",
112                })?;
113            let next_cached = self
114                .total_cached
115                .checked_add(m.cached_tokens.unwrap_or(0))
116                .ok_or(av_core::CoreError::Overflow {
117                    context: "total_cached_tokens",
118                })?;
119            // JCS-safe bound alongside the overflow checks: receipt
120            // bodies canonicalize via JCS, which refuses integers past
121            // 2^53 — a builder-accepted total above the bound produced
122            // a strict-valid trajectory the daemon's recovery then
123            // refused to fold (unrecoverable-by-construction evidence).
124            av_core::error::check_jcs_safe(next_prompt)?;
125            av_core::error::check_jcs_safe(next_completion)?;
126            av_core::error::check_jcs_safe(next_cached)?;
127            // Reject a non-finite or negative cost the same way tokens are
128            // rejected on overflow: without this the aggregate silently
129            // saturates to +∞ (or drifts negative), and a downstream Strict
130            // validator that trusts final_metrics.total_cost_usd signs
131            // corrupt evidence. serde_json does NOT refuse non-finite
132            // floats — it silently serializes them as `null` (empirically
133            // verified against the workspace serde_json), which would
134            // corrupt `total_cost_usd` in the persisted file. Reject them
135            // here, before they can reach serialization.
136            let step_cost = m.cost_usd.unwrap_or(0.0);
137            if !step_cost.is_finite() || step_cost < 0.0 {
138                return Err(av_core::CoreError::Overflow {
139                    context: "step_cost_usd",
140                });
141            }
142            // The validator caps step and total
143            // cost at MAX_COST_USD (9e9) and refuses to persist
144            // anything beyond. The prior writer accepted any finite
145            // non-negative step cost, so the reconciler's u64-micros ↔
146            // f64 USD round-trip could construct a step whose cost
147            // exceeded the cap — the trajectory then became
148            // permanently un-writable at `write_atomic` (evidence loss
149            // at export time). Refuse here so the builder never
150            // produces validator-invalid output.
151            if step_cost > crate::validate::MAX_COST_USD {
152                return Err(av_core::CoreError::Overflow {
153                    context: "step_cost_usd",
154                });
155            }
156            let next_cost = self.total_cost + step_cost;
157            if !next_cost.is_finite() {
158                return Err(av_core::CoreError::Overflow {
159                    context: "total_cost_usd",
160                });
161            }
162            if next_cost > crate::validate::MAX_COST_USD {
163                return Err(av_core::CoreError::Overflow {
164                    context: "total_cost_usd",
165                });
166            }
167            (next_prompt, next_completion, next_cached, next_cost)
168        } else {
169            (
170                self.total_prompt,
171                self.total_completion,
172                self.total_cached,
173                self.total_cost,
174            )
175        };
176        self.steps.push(step);
177        self.total_prompt = next_prompt;
178        self.total_completion = next_completion;
179        self.total_cached = next_cached;
180        self.total_cost = next_cost;
181        Ok(())
182    }
183
184    /// Finalize into a v1.7 trajectory with aggregate metrics.
185    pub fn finish(self) -> Trajectory {
186        let total_steps = self.steps.len() as u64;
187        Trajectory {
188            schema_version: ATIF_VERSION.to_owned(),
189            session_id: self.session_id,
190            trajectory_id: self.trajectory_id,
191            agent: self.agent,
192            steps: self.steps,
193            notes: None,
194            final_metrics: Some(FinalMetrics {
195                total_prompt_tokens: Some(self.total_prompt),
196                total_completion_tokens: Some(self.total_completion),
197                total_cached_tokens: Some(self.total_cached),
198                total_cost_usd: Some(self.total_cost),
199                total_steps: Some(total_steps),
200                extra: None,
201            }),
202            continued_trajectory_ref: None,
203            subagent_trajectories: None,
204            extra: None,
205        }
206    }
207}
208
209/// Convenience: build a metrics block with the three token counts the fidelity
210/// criterion (R17) requires on strict v1.7 agent steps with nonzero
211/// `llm_call_count` — in practice, every LLM-backed exported step.
212pub fn metrics(prompt: u64, completion: u64, cached: u64, cost_usd: f64) -> Metrics {
213    Metrics {
214        prompt_tokens: Some(prompt),
215        completion_tokens: Some(completion),
216        cached_tokens: Some(cached),
217        cost_usd: Some(cost_usd),
218        logprobs: None,
219        completion_token_ids: None,
220        prompt_token_ids: None,
221        extra: None,
222    }
223}
224
225/// Write a trajectory to `path` atomically: serialize → temp file in the same
226/// directory → fsync → rename. A crash can leave a stale `.tmp` file but never
227/// a torn trajectory (silent-error class D13.16). The trajectory is validated
228/// (strict) before any byte is written — this crate never produces an invalid
229/// file (success criterion R28).
230///
231/// A directory synchronization failure is returned even if rename already
232/// made the trajectory visible. Callers must retain the complete file for
233/// recovery or retry, and must not attest successful durable capture until the
234/// write succeeds. See `av_core::fsutil::write_atomic` for the shared contract.
235pub fn write_atomic(trajectory: &Trajectory, path: &Path) -> Result<(), WriterError> {
236    let issues = crate::validate::validate_trajectory(trajectory, crate::validate::Mode::Strict);
237    if !issues.is_empty() {
238        return Err(WriterError::Invalid(issues));
239    }
240    let json = serde_json::to_vec_pretty(trajectory)?;
241    // Delegate to the shared `av_core::fsutil::write_atomic` recipe so
242    // ATIF trajectories inherit the SAME tmp-naming discipline as every
243    // other spool artifact: `{stem}.{uuid}.tmp` — the exact suffix the
244    // shared `sweep_orphaned_tmp` boot-time reaper filters on. The
245    // prior `tempfile::NamedTempFile::new_in(dir)` recipe produced
246    // `.tmpXXXXXX` (dot-tmp *prefix*, no `.tmp` *suffix*), so a SIGKILL
247    // between `new_in` and `persist` stranded `.tmpXXXXXX` files that
248    // the sweep filter never matched — under a crash-loop the ATIF
249    // spool would accumulate them until inode-table exhaustion on
250    // ext4/xfs. Delegating also picks up the shared recipe's explicit
251    // 0o600 mode on Unix, its `create_new(true)` symlink-refusal
252    // guard, its `TempPathGuard` RAII unlink, and its
253    // `create_dir_all_synced` ancestor-fsync — one code path, one set
254    // of durability invariants.
255    av_core::fsutil::write_atomic(path, &json).map_err(WriterError::Io)?;
256    Ok(())
257}
258
259#[cfg(test)]
260mod tests {
261    #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
262
263    use super::*;
264    use crate::model::Source;
265
266    fn agent() -> Agent {
267        Agent {
268            name: "harness".into(),
269            version: "0.1.0".into(),
270            model_name: Some("claude-sonnet-4".into()),
271            tool_definitions: None,
272            extra: None,
273        }
274    }
275
276    fn step(source: Source) -> Step {
277        Step {
278            step_id: 999, // deliberately wrong; builder must overwrite
279            timestamp: Some(av_core::time::now_iso8601()),
280            source,
281            message: serde_json::json!("hello"),
282            reasoning_effort: None,
283            reasoning_content: None,
284            model_name: None,
285            tool_calls: None,
286            observation: None,
287            metrics: matches!(source, Source::Agent).then(|| metrics(100, 20, 60, 0.001)),
288            is_copied_context: None,
289            llm_call_count: None,
290            extra: None,
291        }
292    }
293
294    /// Pin the agent-step coupling guards individually (mutants
295    /// flipping the conjunctions previously survived): partially
296    /// populated metrics are refused, and `llm_call_count == 0` with
297    /// exactly one of metrics/reasoning_content present is refused.
298    #[test]
299    fn partial_metrics_and_zero_call_couplings_are_refused() {
300        // Each missing token field alone must refuse the step.
301        for missing in ["prompt", "completion", "cached"] {
302            let mut s = step(Source::Agent);
303            let mut m = metrics(100, 20, 60, 0.001);
304            match missing {
305                "prompt" => m.prompt_tokens = None,
306                "completion" => m.completion_tokens = None,
307                _ => m.cached_tokens = None,
308            }
309            s.metrics = Some(m);
310            let mut b = TrajectoryBuilder::new(agent(), None);
311            assert!(
312                b.push_step(s).is_err(),
313                "agent step missing {missing}_tokens must be refused"
314            );
315        }
316        // llm_call_count == 0 with metrics only.
317        let mut s = step(Source::Agent);
318        s.llm_call_count = Some(0);
319        let mut b = TrajectoryBuilder::new(agent(), None);
320        assert!(b.push_step(s).is_err(), "metrics with zero calls must be refused");
321        // llm_call_count == 0 with reasoning_content only.
322        let mut s = step(Source::Agent);
323        s.llm_call_count = Some(0);
324        s.metrics = None;
325        s.reasoning_content = Some("thinking".into());
326        let mut b = TrajectoryBuilder::new(agent(), None);
327        assert!(
328            b.push_step(s).is_err(),
329            "reasoning_content with zero calls must be refused"
330        );
331        // llm_call_count == 0 bare is accepted.
332        let mut s = step(Source::Agent);
333        s.llm_call_count = Some(0);
334        s.metrics = None;
335        let mut b = TrajectoryBuilder::new(agent(), None);
336        assert!(b.push_step(s).is_ok());
337    }
338
339    #[test]
340    fn builder_assigns_sequential_ids_and_aggregates() {
341        let mut b = TrajectoryBuilder::new(agent(), Some("sess-1".into()));
342        b.push_step(step(Source::User)).unwrap();
343        b.push_step(step(Source::Agent)).unwrap();
344        b.push_step(step(Source::Agent)).unwrap();
345        let t = b.finish();
346        assert_eq!(
347            t.steps.iter().map(|s| s.step_id).collect::<Vec<_>>(),
348            vec![1, 2, 3]
349        );
350        let fm = t.final_metrics.unwrap();
351        assert_eq!(fm.total_prompt_tokens, Some(200));
352        assert_eq!(fm.total_completion_tokens, Some(40));
353        assert_eq!(fm.total_cached_tokens, Some(120));
354        assert_eq!(fm.total_steps, Some(3));
355        assert!((fm.total_cost_usd.unwrap() - 0.002).abs() < 1e-12);
356    }
357
358    #[test]
359    fn aggregate_overflow_is_loud() {
360        let mut b = TrajectoryBuilder::new(agent(), None);
361        let mut s = step(Source::Agent);
362        s.metrics = Some(metrics(u64::MAX, 0, 0, 0.0));
363        // u64::MAX is already past the JCS-safe bound: the first push
364        // must refuse (receipt bodies canonicalize via JCS, which
365        // rejects integers above 2^53 — accepting the step here made
366        // the artifact unrecoverable-by-construction downstream).
367        assert!(
368            b.push_step(s).is_err(),
369            "JCS-unsafe totals must refuse at the builder"
370        );
371        // Exactly AT the JCS bound (2^53) is accepted; one more token
372        // then trips the same JCS gate LOUDLY instead of wrapping.
373        let mut s_ok = step(Source::Agent);
374        s_ok.metrics = Some(metrics(9_007_199_254_740_992, 0, 0, 0.0));
375        b.push_step(s_ok).unwrap();
376        let mut s2 = step(Source::Agent);
377        s2.metrics = Some(metrics(1, 0, 0, 0.0));
378        assert!(b.push_step(s2).is_err(), "overflow must not wrap silently");
379    }
380
381    /// Mutation-run hardening: the cost guard is
382    /// `!is_finite() || cost < 0.0` — flipping `||` to `&&` (which
383    /// disables the guard entirely, since no value is both non-finite
384    /// AND negative) survived the suite. Pin each arm independently:
385    /// NaN, +∞, and negative costs must all be refused, and a valid
386    /// cost on the same builder must still be accepted afterwards.
387    #[test]
388    fn non_finite_and_negative_costs_are_each_refused() {
389        for bad_cost in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -0.001] {
390            let mut b = TrajectoryBuilder::new(agent(), None);
391            let mut s = step(Source::Agent);
392            s.metrics = Some(metrics(1, 1, 0, bad_cost));
393            assert!(
394                b.push_step(s).is_err(),
395                "cost {bad_cost} must be refused before it can reach serialization",
396            );
397            // The refusal must not have partially updated the aggregates.
398            let mut ok = step(Source::Agent);
399            ok.metrics = Some(metrics(1, 1, 0, 0.5));
400            b.push_step(ok).unwrap();
401            let fm = b.finish().final_metrics.unwrap();
402            assert_eq!(fm.total_prompt_tokens, Some(1));
403            assert!((fm.total_cost_usd.unwrap() - 0.5).abs() < 1e-12);
404        }
405    }
406
407    #[test]
408    fn atomic_write_produces_valid_file() {
409        let dir = tempfile::tempdir().unwrap();
410        let path = dir.path().join("nested").join("trajectory.json");
411        let mut b = TrajectoryBuilder::new(agent(), Some("sess-2".into()));
412        b.push_step(step(Source::User)).unwrap();
413        b.push_step(step(Source::Agent)).unwrap();
414        write_atomic(&b.finish(), &path).unwrap();
415        let raw = std::fs::read_to_string(&path).unwrap();
416        let value: serde_json::Value = serde_json::from_str(&raw).unwrap();
417        let issues = crate::validate::validate_value(&value, crate::validate::Mode::Strict);
418        assert!(issues.is_empty(), "{issues:?}");
419        // No temp litter after success.
420        let litter: Vec<_> = std::fs::read_dir(path.parent().unwrap())
421            .unwrap()
422            .filter_map(Result::ok)
423            .filter(|e| e.path() != path)
424            .collect();
425        assert!(litter.is_empty(), "temp files left behind: {litter:?}");
426    }
427
428    #[test]
429    fn invalid_trajectory_never_written() {
430        let dir = tempfile::tempdir().unwrap();
431        let path = dir.path().join("bad.json");
432        let mut t = TrajectoryBuilder::new(agent(), None);
433        t.push_step(step(Source::Agent)).unwrap();
434        let mut t = t.finish();
435        // Corrupt: agent-only field on a user step.
436        if let Some(s) = t.steps.first_mut() {
437            s.source = Source::User;
438            s.reasoning_content = Some("should not be here".into());
439        }
440        let err = write_atomic(&t, &path);
441        assert!(matches!(err, Err(WriterError::Invalid(_))));
442        assert!(!path.exists(), "invalid file must not be created");
443    }
444
445    /// Vicious ordering bug: `push_step` used to mutate
446    /// `total_prompt` before checking `total_completion` for overflow, so an
447    /// overflow on a later field would leave the builder with totals ahead
448    /// of a step that was never pushed. Locks the atomic-commit invariant:
449    /// on any overflow, every accumulator + `steps` stays exactly as it was
450    /// before the call.
451    #[test]
452    fn push_step_overflow_leaves_builder_state_unchanged() {
453        let mut b = TrajectoryBuilder::new(agent(), None);
454        // First a legitimate step to establish nonzero totals.
455        b.push_step(step(Source::Agent)).unwrap();
456        let before_prompt = b.total_prompt;
457        let before_completion = b.total_completion;
458        let before_cached = b.total_cached;
459        let before_len = b.steps.len();
460
461        // Craft a step whose `completion_tokens` overflows the running total
462        // while `prompt_tokens` does not. Without the atomic-commit fix,
463        // total_prompt would be updated but the step would not be pushed,
464        // leaving total_prompt out of sync with sum(steps.prompt).
465        let mut hostile = step(Source::Agent);
466        hostile.metrics = Some(metrics(1, u64::MAX, 0, 0.0));
467        let err = b.push_step(hostile).expect_err("must refuse overflowing totals");
468        assert!(
469            matches!(err, av_core::CoreError::Overflow { .. }),
470            "wrong error variant: {err:?}",
471        );
472
473        assert_eq!(b.total_prompt, before_prompt, "total_prompt leaked");
474        assert_eq!(b.total_completion, before_completion, "total_completion leaked");
475        assert_eq!(b.total_cached, before_cached, "total_cached leaked");
476        assert_eq!(b.steps.len(), before_len, "steps len leaked");
477    }
478}