1use std::collections::HashSet;
2
3use crate::budget::BudgetEnforcementBoundary;
4use crate::events::{ModelCallFailureOutcome, RunEventPayload};
5use crate::types::{AgentResult, AgentStatus, ModelCallStatus};
6
7use super::*;
8
9pub fn validate_checkpoint(checkpoint: &Checkpoint) -> CheckpointResult<()> {
10 if checkpoint.schema_version != CHECKPOINT_SCHEMA {
11 return Err(CheckpointError::new(
12 "checkpoint_schema_unsupported",
13 "checkpoint schema_version is unsupported",
14 ));
15 }
16 if checkpoint.run_definition_schema != RUN_DEFINITION_SCHEMA {
17 return Err(CheckpointError::new(
18 "checkpoint_definition_schema_unsupported",
19 "run_definition_schema is missing or unsupported",
20 ));
21 }
22 crate::checkpoint::validate_run_definition(&checkpoint.run_definition)?;
23 validate_sha256(&checkpoint.run_definition_digest, "run_definition_digest").map_err(
24 |error| CheckpointError::new("checkpoint_definition_digest_invalid", error.message()),
25 )?;
26 let digest = crate::checkpoint::run_definition_digest(&checkpoint.run_definition)?;
27 if digest != checkpoint.run_definition_digest {
28 return Err(CheckpointError::new(
29 "checkpoint_definition_mismatch",
30 "run_definition_digest does not match embedded run_definition",
31 ));
32 }
33 validate_checkpoint_key(&checkpoint.checkpoint_key)?;
34 for (value, field_name) in [
35 (&checkpoint.task_id, "task_id"),
36 (&checkpoint.root_run_id, "root_run_id"),
37 (&checkpoint.trace_id, "trace_id"),
38 ] {
39 if value.trim().is_empty() {
40 return Err(CheckpointError::new(
41 "checkpoint_value_invalid",
42 format!("{field_name} must be non-empty"),
43 ));
44 }
45 }
46 if checkpoint.resume_attempt == 0 || checkpoint.resume_attempt > MAX_WIRE_INTEGER {
47 return Err(CheckpointError::new(
48 "checkpoint_resume_attempt_invalid",
49 "resume_attempt must be positive and JSON-safe",
50 ));
51 }
52 if checkpoint.cycle_index > MAX_WIRE_INTEGER || checkpoint.revision > MAX_WIRE_INTEGER {
53 return Err(CheckpointError::new(
54 "checkpoint_integer_invalid",
55 "checkpoint integer is outside the JSON-safe range",
56 ));
57 }
58 let claim_values = [
59 checkpoint.claim_token.is_some(),
60 checkpoint.claimed_cycle.is_some(),
61 checkpoint.lease_expires_at_ms.is_some(),
62 ];
63 if claim_values.iter().any(|value| *value) && claim_values.iter().any(|value| !*value) {
64 return Err(CheckpointError::new(
65 "checkpoint_claim_invalid",
66 "claim fields must be all present or all null",
67 ));
68 }
69 if let Some(claim_token) = &checkpoint.claim_token {
70 if claim_token.trim().is_empty() {
71 return Err(CheckpointError::new(
72 "checkpoint_claim_invalid",
73 "claim_token must be non-empty",
74 ));
75 }
76 let claimed_cycle = checkpoint.claimed_cycle.expect("claim tuple checked");
77 let expected = checkpoint.cycle_index.checked_add(1).ok_or_else(|| {
78 CheckpointError::new("checkpoint_claim_invalid", "claimed cycle overflow")
79 })?;
80 if claimed_cycle != expected || claimed_cycle == 0 || claimed_cycle > MAX_WIRE_INTEGER {
81 return Err(CheckpointError::new(
82 "checkpoint_claim_invalid",
83 "claimed_cycle must equal cycle_index + 1",
84 ));
85 }
86 let lease = checkpoint.lease_expires_at_ms.expect("claim tuple checked");
87 if lease > MAX_WIRE_INTEGER {
88 return Err(CheckpointError::new(
89 "checkpoint_claim_invalid",
90 "lease expiry is outside the JSON-safe range",
91 ));
92 }
93 }
94 if checkpoint.terminal_result.is_some() && checkpoint.claim_token.is_some() {
95 return Err(CheckpointError::new(
96 "checkpoint_status_invalid",
97 "terminal checkpoint cannot have an active claim",
98 ));
99 }
100 if checkpoint.terminal_acknowledged && checkpoint.terminal_result.is_none() {
101 return Err(CheckpointError::new(
102 "checkpoint_status_invalid",
103 "terminal acknowledgement requires a terminal result",
104 ));
105 }
106 if checkpoint.terminal_result.is_none()
107 && !matches!(
108 checkpoint.status,
109 CheckpointStatus::Running | CheckpointStatus::ReconciliationRequired
110 )
111 {
112 return Err(CheckpointError::new(
113 "checkpoint_status_invalid",
114 "non-terminal checkpoint must be running or reconciliation_required",
115 ));
116 }
117 if checkpoint.terminal_result.is_some() && !checkpoint.status.is_terminal() {
118 return Err(CheckpointError::new(
119 "checkpoint_status_invalid",
120 "terminal_result requires a terminal checkpoint status",
121 ));
122 }
123 let call_ids = checkpoint
124 .model_calls
125 .iter()
126 .map(|record| record.call_id.as_str())
127 .collect::<HashSet<_>>();
128 if call_ids.len() != checkpoint.model_calls.len() {
129 return Err(CheckpointError::new(
130 "checkpoint_status_invalid",
131 "checkpoint model_calls contains duplicate call ids",
132 ));
133 }
134
135 let active_cycle = checkpoint.active_cycle()?;
136 for entry in checkpoint
137 .model_call_journal
138 .iter()
139 .chain(checkpoint.tool_journal.iter())
140 {
141 entry.validate()?;
142 if entry.cycle_index != active_cycle {
143 return Err(CheckpointError::new(
144 "checkpoint_journal_cycle_invalid",
145 "journal cycle_index must equal the active cycle",
146 ));
147 }
148 }
149 if checkpoint
150 .model_call_journal
151 .iter()
152 .any(|entry| entry.kind != OperationKind::Model)
153 || checkpoint
154 .tool_journal
155 .iter()
156 .any(|entry| entry.kind != OperationKind::Tool)
157 {
158 return Err(CheckpointError::new(
159 "checkpoint_journal_kind_invalid",
160 "journal arrays contain an entry of the wrong kind",
161 ));
162 }
163 for (namespace, entry) in &checkpoint.extension_state {
164 validate_extension_namespace(namespace)?;
165 entry.validate()?;
166 }
167 validate_extension_state_size(&checkpoint.extension_state, MAX_WIRE_INTEGER)?;
168 if let Some(cursor) = &checkpoint.event_cursor {
169 cursor.validate()?;
170 }
171 let mut event_ids = HashSet::new();
172 for entry in &checkpoint.event_outbox {
173 entry.verify_payload()?;
174 if !event_ids.insert(entry.event_id.as_str()) {
175 return Err(CheckpointError::new(
176 "event_identity_conflict",
177 "checkpoint event_outbox contains a duplicate event id",
178 ));
179 }
180 }
181 validate_model_journal_accounting(checkpoint)?;
182 for value in checkpoint.shared_state.values() {
183 validate_json(value, "shared_state")?;
184 }
185 if checkpoint.status == CheckpointStatus::ReconciliationRequired
186 && (!checkpoint.has_ambiguous_operation() || checkpoint.claim_token.is_some())
187 {
188 return Err(CheckpointError::new(
189 "checkpoint_status_invalid",
190 "reconciliation_required needs an ambiguous journal and no claim",
191 ));
192 }
193 if checkpoint.status == CheckpointStatus::Running
194 && checkpoint.has_ambiguous_operation()
195 && checkpoint.claim_token.is_none()
196 {
197 return Err(CheckpointError::new(
198 "checkpoint_status_invalid",
199 "running checkpoint with ambiguity needs an active recovery claim",
200 ));
201 }
202 if checkpoint.terminal_result.is_some()
203 && (!checkpoint.model_call_journal.is_empty() || !checkpoint.tool_journal.is_empty())
204 && !checkpoint.is_operator_abort_terminal()
205 {
206 return Err(CheckpointError::new(
207 "checkpoint_status_invalid",
208 "terminal checkpoint cannot retain active journals",
209 ));
210 }
211 if let Some(result) = &checkpoint.terminal_result {
212 validate_json(result, "terminal_result")?;
213 let result = AgentResult::from_dict(result).map_err(|error| {
214 CheckpointError::new(
215 "checkpoint_status_invalid",
216 format!("terminal_result is not the current AgentResult shape: {error}"),
217 )
218 })?;
219 if !agent_status_matches_checkpoint(result.status, checkpoint.status) {
220 return Err(CheckpointError::new(
221 "checkpoint_status_invalid",
222 "terminal result status must match checkpoint status",
223 ));
224 }
225 if result
226 .checkpoint_key
227 .as_deref()
228 .is_some_and(|key| key != checkpoint.checkpoint_key)
229 {
230 return Err(CheckpointError::new(
231 "checkpoint_status_invalid",
232 "terminal result checkpoint_key must match checkpoint",
233 ));
234 }
235 if result.token_usage.model_calls != checkpoint.model_calls {
236 return Err(CheckpointError::new(
237 "checkpoint_status_invalid",
238 "terminal result model-call ledger does not match checkpoint",
239 ));
240 }
241 }
242 Ok(())
243}
244
245pub fn validate_model_journal_accounting(checkpoint: &Checkpoint) -> CheckpointResult<()> {
246 for journal in &checkpoint.model_call_journal {
247 if journal.kind != OperationKind::Model {
248 return Err(CheckpointError::new(
249 "operation_kind_fields_invalid",
250 "model_call_journal contains a non-model entry",
251 ));
252 }
253 validate_model_journal_entry_accounting(checkpoint, journal)?;
254 }
255 Ok(())
256}
257
258#[derive(Debug, Clone, PartialEq, Eq)]
259struct ModelAccountingIdentity {
260 call_id: String,
261 operation_id: String,
262 attempt: u64,
263 operation: ModelCallOperation,
264 cycle_index: u64,
265 backend: String,
266 model: String,
267}
268
269fn validate_model_journal_entry_accounting(
270 checkpoint: &Checkpoint,
271 journal: &OperationJournalEntry,
272) -> CheckpointResult<()> {
273 journal.validate()?;
274 let identity = model_journal_identity(journal)?;
275 let record_candidates = checkpoint
276 .model_calls
277 .iter()
278 .filter(|record| {
279 record.call_id == identity.call_id
280 || (record.operation_id == identity.operation_id
281 && u64::from(record.attempt) == identity.attempt)
282 })
283 .collect::<Vec<_>>();
284
285 let mut event_candidates = Vec::new();
286 for (index, entry) in checkpoint.event_outbox.iter().enumerate() {
287 entry.verify_payload()?;
288 let event: RunEvent = serde_json::from_value(entry.event.clone()).map_err(|error| {
289 CheckpointError::new(
290 "checkpoint_event_outbox_invalid",
291 format!("checkpoint event payload is invalid: {error}"),
292 )
293 })?;
294 let Some(event_identity) = model_event_identity(&event) else {
295 continue;
296 };
297 if event_identity.call_id == identity.call_id
298 || (event_identity.operation_id == identity.operation_id
299 && event_identity.attempt == identity.attempt)
300 {
301 event_candidates.push((index, event, event_identity));
302 }
303 }
304 let started_events = event_candidates
305 .iter()
306 .filter(|(_, event, _)| matches!(event.payload(), RunEventPayload::ModelCallStarted { .. }))
307 .collect::<Vec<_>>();
308 let terminal_events = event_candidates
309 .iter()
310 .filter(|(_, event, _)| {
311 matches!(
312 event.payload(),
313 RunEventPayload::ModelCallCompleted { .. }
314 | RunEventPayload::ModelCallFailed { .. }
315 )
316 })
317 .collect::<Vec<_>>();
318
319 if record_candidates.len() > 1 || started_events.len() > 1 || terminal_events.len() > 1 {
320 return Err(model_accounting_error(
321 "model journal attempt has duplicate accounting evidence",
322 ));
323 }
324
325 match journal.state {
326 OperationState::Planned => {
327 require_model_evidence_counts(
328 &record_candidates,
329 &started_events,
330 &terminal_events,
331 (0, 0, 0),
332 )?;
333 return Ok(());
334 }
335 OperationState::Started => {
336 require_model_evidence_counts(
337 &record_candidates,
338 &started_events,
339 &terminal_events,
340 (0, 1, 0),
341 )?;
342 require_model_identity(&identity, &started_events[0].2)?;
343 return Ok(());
344 }
345 OperationState::Failed
346 if record_candidates.is_empty()
347 && started_events.is_empty()
348 && terminal_events.is_empty() =>
349 {
350 return Ok(());
351 }
352 OperationState::Succeeded | OperationState::Failed | OperationState::Ambiguous => {}
353 }
354
355 require_model_evidence_counts(
356 &record_candidates,
357 &started_events,
358 &terminal_events,
359 (1, 1, 1),
360 )?;
361 let record = record_candidates[0];
362 let started_event = started_events[0];
363 let terminal_event = terminal_events[0];
364 require_model_identity(&identity, &model_record_identity(record))?;
365 require_model_identity(&identity, &started_event.2)?;
366 require_model_identity(&identity, &terminal_event.2)?;
367
368 let status_matches = match journal.state {
369 OperationState::Succeeded => matches!(
370 record.status,
371 ModelCallStatus::Completed | ModelCallStatus::Ambiguous
372 ),
373 OperationState::Failed => matches!(
374 record.status,
375 ModelCallStatus::Failed | ModelCallStatus::Ambiguous
376 ),
377 OperationState::Ambiguous => record.status == ModelCallStatus::Ambiguous,
378 OperationState::Planned | OperationState::Started => false,
379 };
380 let event_type_matches = matches!(
381 (record.status, terminal_event.1.payload()),
382 (
383 ModelCallStatus::Completed,
384 RunEventPayload::ModelCallCompleted { .. }
385 ) | (
386 ModelCallStatus::Failed | ModelCallStatus::Ambiguous,
387 RunEventPayload::ModelCallFailed { .. }
388 )
389 );
390 if !status_matches || !event_type_matches {
391 return Err(model_accounting_error(
392 "model journal terminal state does not match its accounting evidence",
393 ));
394 }
395
396 let event_usage = match terminal_event.1.payload() {
397 RunEventPayload::ModelCallCompleted { usage, .. }
398 | RunEventPayload::ModelCallFailed { usage, .. } => usage,
399 _ => unreachable!("terminal event filtered above"),
400 };
401 if event_usage != &record.usage {
402 return Err(model_accounting_error(
403 "model terminal event usage does not match its ledger record",
404 ));
405 }
406 if let RunEventPayload::ModelCallFailed {
407 outcome,
408 error_code,
409 ..
410 } = terminal_event.1.payload()
411 {
412 let expected_outcome = if record.status == ModelCallStatus::Ambiguous {
413 ModelCallFailureOutcome::Ambiguous
414 } else {
415 ModelCallFailureOutcome::Definitive
416 };
417 if *outcome != expected_outcome || record.error_code.as_deref() != Some(error_code.as_str())
418 {
419 return Err(model_accounting_error(
420 "model failed event does not match its ledger record",
421 ));
422 }
423 }
424
425 validate_terminal_budget_event_order(checkpoint, terminal_event.0)?;
426 Ok(())
427}
428
429fn require_model_evidence_counts(
430 records: &[&ModelCallRecord],
431 started_events: &[&(usize, RunEvent, ModelAccountingIdentity)],
432 terminal_events: &[&(usize, RunEvent, ModelAccountingIdentity)],
433 expected: (usize, usize, usize),
434) -> CheckpointResult<()> {
435 if (records.len(), started_events.len(), terminal_events.len()) != expected {
436 return Err(model_accounting_error(
437 "model journal attempt is missing atomic accounting evidence",
438 ));
439 }
440 Ok(())
441}
442
443fn model_journal_identity(
444 journal: &OperationJournalEntry,
445) -> CheckpointResult<ModelAccountingIdentity> {
446 Ok(ModelAccountingIdentity {
447 call_id: journal.call_id.clone().ok_or_else(|| {
448 model_accounting_error("model journal call_id is missing from accounting identity")
449 })?,
450 operation_id: journal.operation_id.clone(),
451 attempt: journal.attempt,
452 operation: journal.model_operation.ok_or_else(|| {
453 model_accounting_error("model journal operation is missing from accounting identity")
454 })?,
455 cycle_index: journal.cycle_index,
456 backend: journal.backend.clone().ok_or_else(|| {
457 model_accounting_error("model journal backend is missing from accounting identity")
458 })?,
459 model: journal.model.clone().ok_or_else(|| {
460 model_accounting_error("model journal model is missing from accounting identity")
461 })?,
462 })
463}
464
465fn model_record_identity(record: &ModelCallRecord) -> ModelAccountingIdentity {
466 ModelAccountingIdentity {
467 call_id: record.call_id.clone(),
468 operation_id: record.operation_id.clone(),
469 attempt: u64::from(record.attempt),
470 operation: record.operation,
471 cycle_index: u64::from(record.cycle_index),
472 backend: record.backend.clone(),
473 model: record.model.clone(),
474 }
475}
476
477fn model_event_identity(event: &RunEvent) -> Option<ModelAccountingIdentity> {
478 let (call_id, operation_id, attempt, operation, backend, model) = match event.payload() {
479 RunEventPayload::ModelCallStarted {
480 call_id,
481 operation_id,
482 attempt,
483 operation,
484 backend,
485 model,
486 }
487 | RunEventPayload::ModelCallCompleted {
488 call_id,
489 operation_id,
490 attempt,
491 operation,
492 backend,
493 model,
494 ..
495 }
496 | RunEventPayload::ModelCallFailed {
497 call_id,
498 operation_id,
499 attempt,
500 operation,
501 backend,
502 model,
503 ..
504 } => (call_id, operation_id, attempt, operation, backend, model),
505 _ => return None,
506 };
507 Some(ModelAccountingIdentity {
508 call_id: call_id.clone(),
509 operation_id: operation_id.clone(),
510 attempt: u64::from(*attempt),
511 operation: *operation,
512 cycle_index: u64::from(event.cycle_index()?),
513 backend: backend.clone(),
514 model: model.clone(),
515 })
516}
517
518fn require_model_identity(
519 expected: &ModelAccountingIdentity,
520 observed: &ModelAccountingIdentity,
521) -> CheckpointResult<()> {
522 if expected != observed {
523 return Err(model_accounting_error(
524 "model journal, event, and ledger identities do not match",
525 ));
526 }
527 Ok(())
528}
529
530fn validate_terminal_budget_event_order(
531 checkpoint: &Checkpoint,
532 terminal_event_index: usize,
533) -> CheckpointResult<()> {
534 let budget_configured = checkpoint
535 .run_definition
536 .get("budget_limits")
537 .is_some_and(|value| !value.is_null());
538 let next_event = checkpoint
539 .event_outbox
540 .get(terminal_event_index + 1)
541 .map(|entry| {
542 serde_json::from_value::<RunEvent>(entry.event.clone()).map_err(|error| {
543 CheckpointError::new(
544 "checkpoint_event_outbox_invalid",
545 format!("checkpoint event payload is invalid: {error}"),
546 )
547 })
548 })
549 .transpose()?;
550 let next_is_model_budget = next_event.as_ref().is_some_and(|event| {
551 matches!(
552 event.payload(),
553 RunEventPayload::BudgetSnapshot {
554 enforcement_boundary: BudgetEnforcementBoundary::ModelCallComplete,
555 ..
556 } | RunEventPayload::BudgetExhausted {
557 enforcement_boundary: BudgetEnforcementBoundary::ModelCallComplete,
558 ..
559 }
560 )
561 });
562 if budget_configured != next_is_model_budget {
563 return Err(model_accounting_error(
564 "model terminal event must be followed immediately by its configured budget observation",
565 ));
566 }
567 if next_is_model_budget {
568 let duplicate_budget = checkpoint
569 .event_outbox
570 .get(terminal_event_index + 2)
571 .map(|entry| {
572 serde_json::from_value::<RunEvent>(entry.event.clone()).map_err(|error| {
573 CheckpointError::new(
574 "checkpoint_event_outbox_invalid",
575 format!("checkpoint event payload is invalid: {error}"),
576 )
577 })
578 })
579 .transpose()?
580 .as_ref()
581 .is_some_and(|event| {
582 matches!(
583 event.payload(),
584 RunEventPayload::BudgetSnapshot {
585 enforcement_boundary: BudgetEnforcementBoundary::ModelCallComplete,
586 ..
587 } | RunEventPayload::BudgetExhausted {
588 enforcement_boundary: BudgetEnforcementBoundary::ModelCallComplete,
589 ..
590 }
591 )
592 });
593 if duplicate_budget {
594 return Err(model_accounting_error(
595 "budget_exhausted must replace budget_snapshot for a model-call boundary",
596 ));
597 }
598 }
599 Ok(())
600}
601
602fn model_accounting_error(message: impl Into<String>) -> CheckpointError {
603 CheckpointError::new("checkpoint_status_invalid", message)
604}
605
606fn agent_status_matches_checkpoint(status: AgentStatus, checkpoint: CheckpointStatus) -> bool {
607 matches!(
608 (status, checkpoint),
609 (AgentStatus::Pending, CheckpointStatus::Pending)
610 | (AgentStatus::Running, CheckpointStatus::Running)
611 | (AgentStatus::WaitUser, CheckpointStatus::WaitUser)
612 | (AgentStatus::Completed, CheckpointStatus::Completed)
613 | (AgentStatus::Failed, CheckpointStatus::Failed)
614 | (AgentStatus::MaxCycles, CheckpointStatus::MaxCycles)
615 | (
616 AgentStatus::ReconciliationRequired,
617 CheckpointStatus::ReconciliationRequired
618 )
619 )
620}
621
622pub fn validate_extension_state_size(
623 extensions: &BTreeMap<String, ExtensionStateEntry>,
624 max_total: u64,
625) -> CheckpointResult<()> {
626 let mut total = 0_u64;
627 for (namespace, entry) in extensions {
628 let bytes = canonical_json_bytes(&entry.to_value(), "extension state entry")?;
629 if bytes.len() > MAX_EXTENSION_ENTRY_BYTES {
630 return Err(CheckpointError::new(
631 "checkpoint_extension_entry_too_large",
632 format!("extension {namespace} exceeds {MAX_EXTENSION_ENTRY_BYTES} bytes"),
633 ));
634 }
635 total = total.checked_add(bytes.len() as u64).ok_or_else(|| {
636 CheckpointError::new(
637 "checkpoint_extension_state_too_large",
638 "extension state byte count overflow",
639 )
640 })?;
641 }
642 if total > max_total {
643 return Err(CheckpointError::new(
644 "checkpoint_extension_state_too_large",
645 format!("extension state exceeds {max_total} bytes"),
646 ));
647 }
648 Ok(())
649}
650
651pub(super) fn validate_json(value: &Value, field_name: &str) -> CheckpointResult<()> {
652 crate::checkpoint::canonical_json_bytes(value, field_name).map(|_| ())
653}
654
655pub(super) fn required_string<'a>(
656 object: &'a Map<String, Value>,
657 field: &str,
658 code: &str,
659) -> CheckpointResult<&'a str> {
660 object
661 .get(field)
662 .and_then(Value::as_str)
663 .ok_or_else(|| CheckpointError::new(code, format!("{field} must be a string")))
664}
665
666pub(super) fn optional_string(
667 object: &Map<String, Value>,
668 field: &str,
669) -> CheckpointResult<Option<String>> {
670 match object.get(field) {
671 None | Some(Value::Null) => Ok(None),
672 Some(Value::String(value)) => Ok(Some(value.clone())),
673 Some(_) => Err(CheckpointError::new(
674 "operation_kind_fields_invalid",
675 format!("{field} must be a string or null"),
676 )),
677 }
678}
679
680pub(super) fn required_u64(
681 object: &Map<String, Value>,
682 field: &str,
683 code: &str,
684) -> CheckpointResult<u64> {
685 object
686 .get(field)
687 .and_then(Value::as_u64)
688 .ok_or_else(|| CheckpointError::new(code, format!("{field} must be a JSON-safe integer")))
689}