1use thiserror::Error;
49
50use crate::event::{Event, EventEnvelope, SCHEMA_VERSION};
51use crate::id::{RunId, SequenceNumber};
52
53#[derive(Debug, Clone, PartialEq, Eq, Error)]
58pub enum ValidationError {
59 #[error("candidate run id {} does not match the log's run id {}", .found.as_uuid(), .expected.as_uuid())]
61 RunIdMismatch {
62 expected: RunId,
64 found: RunId,
66 },
67 #[error("candidate seq {found} is not the expected next position {expected}")]
69 NonContiguousSeq {
70 expected: SequenceNumber,
72 found: SequenceNumber,
74 },
75 #[error("candidate schema_version {version} is out of range 1..={max}")]
77 BadSchemaVersion {
78 version: u32,
80 max: u32,
82 },
83 #[error("a run log must start with RunStarted, candidate is {found}")]
85 ExpectedRunStarted {
86 found: &'static str,
88 },
89 #[error("a run head may only be the first event; the log already has history")]
92 DuplicateRunStarted,
93 #[error("no event may follow the terminal {terminal}")]
95 AfterTerminal {
96 terminal: &'static str,
98 },
99 #[error(
103 "the intent at seq {intent_seq} awaits its completion; candidate {found} cannot follow it"
104 )]
105 ExpectedCompletion {
106 intent_seq: SequenceNumber,
108 found: &'static str,
110 },
111 #[error("completion correlates to seq {found} but the pending intent is at seq {expected}")]
114 MiscorrelatedCompletion {
115 expected: SequenceNumber,
117 found: SequenceNumber,
119 },
120 #[error("candidate {found} is a completion with no pending intent to correlate to")]
123 UncorrelatedCompletion {
124 found: &'static str,
126 },
127 #[error("intent at envelope seq {envelope_seq} carries a mismatched inner seq {inner_seq}")]
130 IntentSeqMismatch {
131 envelope_seq: SequenceNumber,
133 inner_seq: SequenceNumber,
135 },
136}
137
138pub fn validate_next(
150 log: &[EventEnvelope],
151 candidate: &EventEnvelope,
152) -> Result<(), ValidationError> {
153 if candidate.schema_version == 0 || candidate.schema_version > SCHEMA_VERSION {
155 return Err(ValidationError::BadSchemaVersion {
156 version: candidate.schema_version,
157 max: SCHEMA_VERSION,
158 });
159 }
160 let expected_seq = log
161 .last()
162 .map_or(SequenceNumber::new(0), |last| last.seq.next());
163 if candidate.seq != expected_seq {
164 return Err(ValidationError::NonContiguousSeq {
165 expected: expected_seq,
166 found: candidate.seq,
167 });
168 }
169
170 let Some(last) = log.last() else {
171 return match &candidate.event {
176 Event::RunStarted { .. } | Event::GraphRunStarted { .. } => Ok(()),
177 other => Err(ValidationError::ExpectedRunStarted {
178 found: kind_name(other),
179 }),
180 };
181 };
182
183 if candidate.run_id != last.run_id {
185 return Err(ValidationError::RunIdMismatch {
186 expected: last.run_id,
187 found: candidate.run_id,
188 });
189 }
190
191 if matches!(
194 candidate.event,
195 Event::RunStarted { .. } | Event::GraphRunStarted { .. }
196 ) {
197 return Err(ValidationError::DuplicateRunStarted);
198 }
199
200 if matches!(
204 last.event,
205 Event::RunCompleted { .. } | Event::RunFailed { .. } | Event::RunAbandoned { .. }
206 ) {
207 return Err(ValidationError::AfterTerminal {
208 terminal: kind_name(&last.event),
209 });
210 }
211
212 match &last.event {
217 Event::ModelCallRequested {
218 seq: intent_seq, ..
219 } => check_completes(*intent_seq, &candidate.event, CompletionKind::Model),
220 Event::ToolCallRequested {
221 seq: intent_seq, ..
222 } => check_completes(*intent_seq, &candidate.event, CompletionKind::Tool),
223 _ => match &candidate.event {
226 Event::ModelCallCompleted { .. } | Event::ToolCallCompleted { .. } => {
227 Err(ValidationError::UncorrelatedCompletion {
228 found: kind_name(&candidate.event),
229 })
230 }
231 Event::ModelCallRequested { seq, .. } | Event::ToolCallRequested { seq, .. } => {
232 if *seq == candidate.seq {
235 Ok(())
236 } else {
237 Err(ValidationError::IntentSeqMismatch {
238 envelope_seq: candidate.seq,
239 inner_seq: *seq,
240 })
241 }
242 }
243 _ => Ok(()),
244 },
245 }
246}
247
248#[derive(Clone, Copy)]
250enum CompletionKind {
251 Model,
252 Tool,
253}
254
255fn check_completes(
258 intent_seq: SequenceNumber,
259 candidate: &Event,
260 awaited: CompletionKind,
261) -> Result<(), ValidationError> {
262 let found = kind_name(candidate);
263 match (awaited, candidate) {
264 (CompletionKind::Model, Event::ModelCallCompleted { seq, .. })
265 | (CompletionKind::Tool, Event::ToolCallCompleted { seq, .. }) => {
266 if *seq == intent_seq {
267 Ok(())
268 } else {
269 Err(ValidationError::MiscorrelatedCompletion {
270 expected: intent_seq,
271 found: *seq,
272 })
273 }
274 }
275 _ => Err(ValidationError::ExpectedCompletion { intent_seq, found }),
276 }
277}
278
279#[derive(Debug, Clone)]
287pub struct LogValidator {
288 log: Vec<EventEnvelope>,
289}
290
291impl LogValidator {
292 #[must_use]
294 pub fn new(log: Vec<EventEnvelope>) -> Self {
295 Self { log }
296 }
297
298 #[must_use]
300 pub fn next_seq(&self) -> SequenceNumber {
301 self.log
302 .last()
303 .map_or(SequenceNumber::new(0), |last| last.seq.next())
304 }
305
306 #[must_use]
308 pub fn log(&self) -> &[EventEnvelope] {
309 &self.log
310 }
311
312 pub fn validate(&self, candidate: &EventEnvelope) -> Result<(), ValidationError> {
318 validate_next(&self.log, candidate)
319 }
320
321 pub fn push(&mut self, candidate: EventEnvelope) -> Result<(), ValidationError> {
328 self.validate(&candidate)?;
329 self.log.push(candidate);
330 Ok(())
331 }
332}
333
334fn kind_name(event: &Event) -> &'static str {
337 match event {
338 Event::RunStarted { .. } => "RunStarted",
339 Event::ModelCallRequested { .. } => "ModelCallRequested",
340 Event::ModelCallCompleted { .. } => "ModelCallCompleted",
341 Event::ToolCallRequested { .. } => "ToolCallRequested",
342 Event::ToolCallCompleted { .. } => "ToolCallCompleted",
343 Event::NowObserved { .. } => "NowObserved",
344 Event::RandomObserved { .. } => "RandomObserved",
345 Event::Suspended { .. } => "Suspended",
346 Event::Resumed { .. } => "Resumed",
347 Event::BudgetExceeded { .. } => "BudgetExceeded",
348 Event::RunCompleted { .. } => "RunCompleted",
349 Event::RunFailed { .. } => "RunFailed",
350 Event::RunAbandoned { .. } => "RunAbandoned",
351 Event::GraphRunStarted { .. } => "GraphRunStarted",
352 Event::NodeEntered { .. } => "NodeEntered",
353 Event::NodeExited { .. } => "NodeExited",
354 Event::NodeSkipped { .. } => "NodeSkipped",
355 Event::BranchTaken { .. } => "BranchTaken",
356 Event::MapFannedOut { .. } => "MapFannedOut",
357 Event::MapIterationStarted { .. } => "MapIterationStarted",
358 Event::MapIterationJoined { .. } => "MapIterationJoined",
359 Event::FoldIterationStarted { .. } => "FoldIterationStarted",
360 Event::FoldIterationJoined { .. } => "FoldIterationJoined",
361 Event::FoldConverged { .. } => "FoldConverged",
362 }
363}
364
365#[cfg(test)]
366mod tests {
367 use super::*;
368 use crate::effect::Effect;
369 use crate::event::{Budget, BudgetKind, TokenUsage};
370 use time::OffsetDateTime;
371 use time::macros::datetime;
372 use uuid::Uuid;
373
374 fn run_a() -> RunId {
375 RunId::from_uuid(Uuid::parse_str("00000000-0000-4000-8000-00000000000a").unwrap())
376 }
377
378 fn run_b() -> RunId {
379 RunId::from_uuid(Uuid::parse_str("00000000-0000-4000-8000-00000000000b").unwrap())
380 }
381
382 fn ts() -> OffsetDateTime {
383 datetime!(2026-07-11 12:00:00 UTC)
384 }
385
386 fn env(seq: u64, event: Event) -> EventEnvelope {
388 EventEnvelope::new(run_a(), SequenceNumber::new(seq), ts(), event)
389 }
390
391 fn started() -> Event {
392 Event::RunStarted {
393 agent_def_hash: "sha256:agent".into(),
394 input: serde_json::json!({"topic": "otters"}),
395 labels: None,
396 }
397 }
398
399 fn model_intent(seq: u64) -> Event {
400 Event::ModelCallRequested {
401 seq: SequenceNumber::new(seq),
402 request_hash: "sha256:req".into(),
403 request_body: None,
404 }
405 }
406
407 fn model_done(seq: u64) -> Event {
408 Event::ModelCallCompleted {
409 seq: SequenceNumber::new(seq),
410 response: serde_json::json!({"text": "hi"}),
411 usage: TokenUsage {
412 input_tokens: 1,
413 output_tokens: 1,
414 },
415 }
416 }
417
418 fn tool_intent(seq: u64, effect: Effect) -> Event {
419 Event::ToolCallRequested {
420 seq: SequenceNumber::new(seq),
421 tool: "render".into(),
422 input: serde_json::json!({"src": "x"}),
423 effect,
424 idempotency_key: None,
425 performed_by: None,
426 }
427 }
428
429 fn tool_done(seq: u64) -> Event {
430 Event::ToolCallCompleted {
431 seq: SequenceNumber::new(seq),
432 output: serde_json::json!({"ok": true}),
433 deduplicated_from: None,
434 }
435 }
436
437 #[test]
440 fn a_legal_sequence_validates_event_by_event() {
441 let sequence = vec![
442 started(),
443 Event::NowObserved { now: ts() },
444 Event::RandomObserved { value: 7 },
445 Event::Suspended {
446 reason: "approval".into(),
447 input_schema: serde_json::json!({"type": "object"}),
448 },
449 Event::Resumed {
450 input: serde_json::json!({"approved": true}),
451 },
452 Event::BudgetExceeded {
453 budget: Budget {
454 kind: BudgetKind::Tokens,
455 limit: 100.0,
456 },
457 observed: 101.0,
458 },
459 Event::RunCompleted {
460 output: serde_json::json!({"done": true}),
461 },
462 ];
463 let mut validator = LogValidator::new(vec![]);
464 for (seq, event) in sequence.into_iter().enumerate() {
465 validator
466 .push(env(seq as u64, event))
467 .expect("each event is the legal next one");
468 }
469 }
470
471 #[test]
474 fn model_intent_then_correlated_completion_is_legal() {
475 let mut v = LogValidator::new(vec![]);
476 v.push(env(0, started())).unwrap();
477 v.push(env(1, model_intent(1))).unwrap();
478 v.push(env(2, model_done(1)))
479 .expect("the completion correlates to the intent at seq 1");
480 }
481
482 #[test]
486 fn write_intent_completion_is_well_formed() {
487 let mut v = LogValidator::new(vec![]);
488 v.push(env(0, started())).unwrap();
489 v.push(env(1, tool_intent(1, Effect::Write))).unwrap();
490 v.push(env(2, tool_done(1)))
491 .expect("a completion after a write intent is well formed");
492 }
493
494 fn graph_started() -> Event {
495 Event::GraphRunStarted {
496 graph_hash: "sha256:graph".into(),
497 input: serde_json::json!({"topic": "otters"}),
498 labels: None,
499 forked_from: None,
500 }
501 }
502
503 #[test]
507 fn graph_run_head_and_markers_validate() {
508 let mut v = LogValidator::new(vec![]);
509 v.push(env(0, graph_started()))
510 .expect("a graph run head opens a fresh log");
511 v.push(env(
512 1,
513 Event::NodeEntered {
514 node: "research".into(),
515 },
516 ))
517 .expect("a node marker is a legal free-standing event");
518 v.push(env(
519 2,
520 Event::BranchTaken {
521 node: "gate".into(),
522 case: "approved".into(),
523 },
524 ))
525 .expect("a branch marker is a legal free-standing event");
526 v.push(env(
527 3,
528 Event::NodeExited {
529 node: "research".into(),
530 },
531 ))
532 .expect("a node marker is a legal free-standing event");
533 }
534
535 #[test]
538 fn duplicate_graph_run_head_is_rejected() {
539 let log = vec![env(0, graph_started())];
540 let err = validate_next(&log, &env(1, graph_started())).unwrap_err();
541 assert_eq!(err, ValidationError::DuplicateRunStarted);
542 }
543
544 #[test]
547 fn graph_marker_after_intent_is_rejected() {
548 let log = vec![
549 env(0, graph_started()),
550 env(1, Event::NodeEntered { node: "n".into() }),
551 env(2, model_intent(2)),
552 ];
553 let err = validate_next(&log, &env(3, Event::NodeExited { node: "n".into() })).unwrap_err();
554 assert_eq!(
555 err,
556 ValidationError::ExpectedCompletion {
557 intent_seq: SequenceNumber::new(2),
558 found: "NodeExited",
559 }
560 );
561 }
562
563 #[test]
565 fn empty_log_rejects_non_run_started() {
566 let err = validate_next(&[], &env(0, Event::NowObserved { now: ts() })).unwrap_err();
567 assert_eq!(
568 err,
569 ValidationError::ExpectedRunStarted {
570 found: "NowObserved"
571 }
572 );
573 }
574
575 #[test]
577 fn duplicate_run_started_is_rejected() {
578 let log = vec![env(0, started())];
579 let err = validate_next(&log, &env(1, started())).unwrap_err();
580 assert_eq!(err, ValidationError::DuplicateRunStarted);
581 }
582
583 #[test]
586 fn non_contiguous_seq_is_rejected() {
587 let log = vec![env(0, started())];
588 let err = validate_next(&log, &env(5, Event::NowObserved { now: ts() })).unwrap_err();
589 assert_eq!(
590 err,
591 ValidationError::NonContiguousSeq {
592 expected: SequenceNumber::new(1),
593 found: SequenceNumber::new(5),
594 }
595 );
596 }
597
598 #[test]
600 fn wrong_run_id_is_rejected() {
601 let log = vec![env(0, started())];
602 let foreign = EventEnvelope::new(
603 run_b(),
604 SequenceNumber::new(1),
605 ts(),
606 Event::NowObserved { now: ts() },
607 );
608 let err = validate_next(&log, &foreign).unwrap_err();
609 assert_eq!(
610 err,
611 ValidationError::RunIdMismatch {
612 expected: run_a(),
613 found: run_b(),
614 }
615 );
616 }
617
618 #[test]
620 fn uncorrelated_completion_is_rejected() {
621 let log = vec![env(0, started())];
622 let err = validate_next(&log, &env(1, model_done(1))).unwrap_err();
623 assert_eq!(
624 err,
625 ValidationError::UncorrelatedCompletion {
626 found: "ModelCallCompleted"
627 }
628 );
629 }
630
631 #[test]
634 fn two_pending_intents_are_rejected() {
635 let log = vec![env(0, started()), env(1, model_intent(1))];
636 let err = validate_next(&log, &env(2, model_intent(2))).unwrap_err();
637 assert_eq!(
638 err,
639 ValidationError::ExpectedCompletion {
640 intent_seq: SequenceNumber::new(1),
641 found: "ModelCallRequested",
642 }
643 );
644 }
645
646 #[test]
649 fn bad_correlation_completion_is_rejected() {
650 let log = vec![env(0, started()), env(1, model_intent(1))];
651 let err = validate_next(&log, &env(2, model_done(9))).unwrap_err();
654 assert_eq!(
655 err,
656 ValidationError::MiscorrelatedCompletion {
657 expected: SequenceNumber::new(1),
658 found: SequenceNumber::new(9),
659 }
660 );
661 }
662
663 #[test]
666 fn context_event_after_intent_is_rejected() {
667 let log = vec![env(0, started()), env(1, model_intent(1))];
668 let err = validate_next(&log, &env(2, Event::NowObserved { now: ts() })).unwrap_err();
669 assert_eq!(
670 err,
671 ValidationError::ExpectedCompletion {
672 intent_seq: SequenceNumber::new(1),
673 found: "NowObserved",
674 }
675 );
676 }
677
678 #[test]
680 fn event_after_terminal_is_rejected() {
681 let log = vec![
682 env(0, started()),
683 env(
684 1,
685 Event::RunCompleted {
686 output: serde_json::json!({"done": true}),
687 },
688 ),
689 ];
690 let err = validate_next(&log, &env(2, Event::NowObserved { now: ts() })).unwrap_err();
691 assert_eq!(
692 err,
693 ValidationError::AfterTerminal {
694 terminal: "RunCompleted"
695 }
696 );
697 }
698
699 #[test]
703 fn event_after_abandoned_is_rejected() {
704 let log = vec![
705 env(0, started()),
706 env(
707 1,
708 Event::RunAbandoned {
709 reason: Some("husk is dead forever".into()),
710 unresolved_write: None,
711 },
712 ),
713 ];
714 let err = validate_next(&log, &env(2, Event::NowObserved { now: ts() })).unwrap_err();
715 assert_eq!(
716 err,
717 ValidationError::AfterTerminal {
718 terminal: "RunAbandoned"
719 }
720 );
721 }
722
723 #[test]
726 fn intent_inner_seq_must_match_envelope() {
727 let log = vec![env(0, started())];
728 let err = validate_next(&log, &env(1, model_intent(4))).unwrap_err();
730 assert_eq!(
731 err,
732 ValidationError::IntentSeqMismatch {
733 envelope_seq: SequenceNumber::new(1),
734 inner_seq: SequenceNumber::new(4),
735 }
736 );
737 }
738
739 #[test]
741 fn bad_schema_version_is_rejected() {
742 let mut e = env(0, started());
743 e.schema_version = 0;
744 assert_eq!(
745 validate_next(&[], &e).unwrap_err(),
746 ValidationError::BadSchemaVersion {
747 version: 0,
748 max: SCHEMA_VERSION,
749 }
750 );
751 let mut future = env(0, started());
752 future.schema_version = SCHEMA_VERSION + 1;
753 assert_eq!(
754 validate_next(&[], &future).unwrap_err(),
755 ValidationError::BadSchemaVersion {
756 version: SCHEMA_VERSION + 1,
757 max: SCHEMA_VERSION,
758 }
759 );
760 }
761}