Skip to main content

vv_agent/runtime/state/
validation.rs

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}