1use std::collections::{BTreeMap, BTreeSet, HashMap};
16
17use contextgraph_types::{FrameId, Representation, Verdict};
18
19use crate::event::{EventBody, RenderedFrame, SessionOutcome};
20use crate::journal::Journal;
21use crate::report::{CheckResult, TraceReport};
22
23pub const CHECK_SEQUENCE: &str = "sequence-integrity";
25pub const CHECK_TURN_LOOP: &str = "turn-loop-pairing";
26pub const CHECK_ASSEMBLY_BUDGET: &str = "assembly-budget-honesty";
27pub const CHECK_STALENESS: &str = "staleness-at-use";
28pub const CHECK_CITATION: &str = "citation-at-use";
29pub const CHECK_COMPOSITION: &str = "deterministic-composition";
30pub const CHECK_EFFECT_ONCE: &str = "effect-exactly-once";
31pub const CHECK_RESUME: &str = "resume-integrity";
32
33pub const ALL_CHECKS: &[&str] = &[
35 CHECK_SEQUENCE,
36 CHECK_TURN_LOOP,
37 CHECK_ASSEMBLY_BUDGET,
38 CHECK_STALENESS,
39 CHECK_CITATION,
40 CHECK_COMPOSITION,
41 CHECK_EFFECT_ONCE,
42 CHECK_RESUME,
43];
44
45pub fn run_oracles(journal: &Journal) -> TraceReport {
49 TraceReport {
50 target: journal.describe(),
51 checks: vec![
52 check_sequence_integrity(journal),
53 check_turn_loop_pairing(journal),
54 check_assembly_budget_honesty(journal),
55 check_staleness_at_use(journal),
56 check_citation_at_use(journal),
57 check_deterministic_composition(journal),
58 check_effect_exactly_once(journal),
59 check_resume_integrity(journal),
60 ],
61 }
62}
63
64pub fn check_sequence_integrity(journal: &Journal) -> CheckResult {
73 let mut violations = Vec::new();
74 let events = &journal.events;
75
76 let first = &events[0];
77 if first.seq != 1 {
78 violations.push(format!("first event has seq {}, not 1", first.seq));
79 }
80 if !matches!(first.body, EventBody::SessionStart { .. }) {
81 violations.push(format!(
82 "recording opens with `{}`, not `session_start`",
83 first.body.kind()
84 ));
85 }
86
87 let session = &first.session;
88 let mut open_turn: Option<u64> = None;
89 let mut highest_turn: u64 = 0;
90 let mut ended_at: Option<u64> = None;
91
92 for (index, event) in events.iter().enumerate() {
93 if index > 0 {
94 let previous = events[index - 1].seq;
95 if event.seq != previous + 1 {
96 violations.push(format!(
97 "seq {} follows seq {previous} — the sequence must be dense",
98 event.seq
99 ));
100 }
101 if matches!(event.body, EventBody::SessionStart { .. }) {
102 violations.push(format!("second `session_start` at seq {}", event.seq));
103 }
104 }
105 if let Some(end_seq) = ended_at {
106 violations.push(format!(
107 "`{}` at seq {} follows `session_end` at seq {end_seq}",
108 event.body.kind(),
109 event.seq
110 ));
111 }
112 if &event.session != session {
113 violations.push(format!(
114 "seq {} belongs to session '{}' but the journal records '{session}'",
115 event.seq, event.session
116 ));
117 }
118 if !contextgraph_types::is_protocol_timestamp(&event.at) {
119 violations.push(format!(
120 "seq {} timestamp '{}' is not an RFC 3339 UTC timestamp (§F4 profile)",
121 event.seq, event.at
122 ));
123 }
124
125 match &event.body {
126 EventBody::TurnStart => match (event.turn, open_turn) {
127 (None, _) => {
128 violations.push(format!("`turn_start` at seq {} names no turn", event.seq))
129 }
130 (Some(turn), Some(open)) => violations.push(format!(
131 "turn {turn} started at seq {} while turn {open} is still open",
132 event.seq
133 )),
134 (Some(turn), None) => {
135 if turn <= highest_turn {
136 violations.push(format!(
137 "turn {turn} started at seq {} but turn numbers must strictly increase (highest so far: {highest_turn})",
138 event.seq
139 ));
140 }
141 highest_turn = highest_turn.max(turn);
142 open_turn = Some(turn);
143 }
144 },
145 EventBody::TurnEnd => match (event.turn, open_turn) {
146 (Some(turn), Some(open)) if turn == open => open_turn = None,
147 (turn, open) => violations.push(format!(
148 "`turn_end` at seq {} names turn {turn:?} but the open turn is {open:?}",
149 event.seq
150 )),
151 },
152 EventBody::Resume { .. } => {
155 open_turn = None;
156 if event.turn.is_some() {
157 violations.push(format!(
158 "`resume` at seq {} carries a turn — resumes are session-level",
159 event.seq
160 ));
161 }
162 }
163 EventBody::SessionStart { .. } | EventBody::SessionEnd { .. } => {
164 if event.turn.is_some() {
165 violations.push(format!(
166 "`{}` at seq {} carries a turn — session lifecycle events are session-level",
167 event.body.kind(),
168 event.seq
169 ));
170 }
171 if let EventBody::SessionEnd { outcome } = &event.body {
172 if let Some(open) = open_turn
175 && *outcome == SessionOutcome::Completed
176 {
177 violations.push(format!(
178 "session completed at seq {} with turn {open} still open",
179 event.seq
180 ));
181 }
182 ended_at = Some(event.seq);
183 }
184 }
185 _ => {
186 if event.turn != open_turn {
189 violations.push(format!(
190 "`{}` at seq {} carries turn {:?} but the open turn is {:?}",
191 event.body.kind(),
192 event.seq,
193 event.turn,
194 open_turn
195 ));
196 }
197 }
198 }
199 }
200
201 CheckResult::from_violations(
202 CHECK_SEQUENCE,
203 violations,
204 format!(
205 "{} event(s), dense 1..={}, one session, timestamps well-formed, turn markers balanced",
206 events.len(),
207 events.last().map(|event| event.seq).unwrap_or(0)
208 ),
209 )
210}
211
212pub fn check_turn_loop_pairing(journal: &Journal) -> CheckResult {
224 let mut violations = Vec::new();
225 let mut pending: BTreeMap<String, u64> = BTreeMap::new();
227 let mut resolved: BTreeSet<String> = BTreeSet::new();
228 let mut executed: BTreeSet<String> = BTreeSet::new();
229 let mut ever_requested: BTreeSet<String> = BTreeSet::new();
230 let mut total_requested: usize = 0;
231
232 for event in &journal.events {
233 match &event.body {
234 EventBody::ModelResponse { tool_calls } => {
235 for call_id in tool_calls {
236 if !ever_requested.insert(call_id.clone()) {
237 violations.push(format!(
238 "call id `{call_id}` requested again at seq {} — call ids are unique per session",
239 event.seq
240 ));
241 continue;
242 }
243 total_requested += 1;
244 pending.insert(call_id.clone(), event.seq);
245 }
246 }
247 EventBody::ToolCall { call_id, tool } => {
248 if executed.contains(call_id) {
249 violations.push(format!(
250 "call `{call_id}` executed again at seq {} — one execution per request",
251 event.seq
252 ));
253 } else if resolved.contains(call_id) {
254 violations.push(format!(
255 "call `{call_id}` executed at seq {} after it was already resolved",
256 event.seq
257 ));
258 } else if !pending.contains_key(call_id) {
259 violations.push(format!(
260 "`{tool}` executed at seq {} under call id `{call_id}` which the model never requested (phantom execution)",
261 event.seq
262 ));
263 } else {
264 executed.insert(call_id.clone());
265 }
266 }
267 EventBody::ToolResult { call_id, .. } => {
268 if pending.remove(call_id).is_some() {
269 resolved.insert(call_id.clone());
270 } else if resolved.contains(call_id) {
271 violations.push(format!(
272 "call `{call_id}` resolved again at seq {} — exactly one result per call",
273 event.seq
274 ));
275 } else {
276 violations.push(format!(
277 "result at seq {} for call `{call_id}` which was never requested (orphan result)",
278 event.seq
279 ));
280 }
281 }
282 EventBody::PromptAssembled { .. } => {
283 if !pending.is_empty() {
284 let dangling: Vec<String> = pending
285 .iter()
286 .map(|(call_id, requested_at)| {
287 format!("`{call_id}` (requested at seq {requested_at})")
288 })
289 .collect();
290 violations.push(format!(
291 "prompt assembled at seq {} with {} unresolved tool call(s): {}",
292 event.seq,
293 dangling.len(),
294 dangling.join(", ")
295 ));
296 pending.clear();
297 }
298 }
299 EventBody::Resume { .. } => pending.clear(),
302 EventBody::SessionEnd { outcome }
303 if *outcome == SessionOutcome::Completed && !pending.is_empty() =>
304 {
305 let dangling: Vec<&str> = pending.keys().map(|call_id| call_id.as_str()).collect();
306 violations.push(format!(
307 "session completed at seq {} with unresolved tool call(s): {}",
308 event.seq,
309 dangling.join(", ")
310 ));
311 }
312 _ => {}
313 }
314 }
315
316 CheckResult::from_violations(
317 CHECK_TURN_LOOP,
318 violations,
319 format!(
320 "{total_requested} call(s) requested, each resolved exactly once before the next prompt"
321 ),
322 )
323}
324
325pub fn check_assembly_budget_honesty(journal: &Journal) -> CheckResult {
330 let mut violations = Vec::new();
331 let mut prompts: usize = 0;
332
333 for event in &journal.events {
334 let EventBody::PromptAssembled {
335 budget_tokens,
336 declared_total_tokens,
337 frames,
338 ..
339 } = &event.body
340 else {
341 continue;
342 };
343 prompts += 1;
344 let itemized: u64 = frames.iter().map(|frame| u64::from(frame.token_cost)).sum();
345 if itemized != *declared_total_tokens {
346 violations.push(format!(
347 "prompt at seq {}: itemized frame costs sum to {itemized} but the harness declared {declared_total_tokens} — the arithmetic drifted from the itemization",
348 event.seq
349 ));
350 }
351 if itemized > u64::from(*budget_tokens) {
352 violations.push(format!(
353 "prompt at seq {}: rendered frame costs sum to {itemized} against the announced budget of {budget_tokens} (§B1 at assembly)",
354 event.seq
355 ));
356 }
357 for rendered in frames {
358 if rendered.representation == Representation::Reference && rendered.token_cost > 0 {
359 violations.push(format!(
360 "prompt at seq {}: reference frame {} declares token_cost {} — a reference inlines nothing, so it costs 0",
361 event.seq,
362 frame_label(&rendered.frame),
363 rendered.token_cost
364 ));
365 }
366 }
367 }
368
369 CheckResult::from_violations(
370 CHECK_ASSEMBLY_BUDGET,
371 violations,
372 format!(
373 "{prompts} prompt(s) assembled; itemized costs match declared totals and fit their budgets"
374 ),
375 )
376}
377
378pub fn check_staleness_at_use(journal: &Journal) -> CheckResult {
392 let mut observations: usize = 0;
393 let mut rendered: usize = 0;
394 let mut latest: HashMap<FrameId, (&'static str, bool, u64)> = HashMap::new();
396 let mut violations = Vec::new();
397
398 for event in &journal.events {
399 match &event.body {
400 EventBody::VerifyObserved { frame, verdict } => {
401 observations += 1;
402 let dead = matches!(verdict, Verdict::Stale { .. } | Verdict::Gone);
403 latest.insert(frame.clone(), (verdict.status(), dead, event.seq));
404 }
405 EventBody::PromptAssembled { frames, .. } => {
406 for RenderedFrame { frame, .. } in frames {
407 rendered += 1;
408 if let Some((status, true, verified_at)) = latest.get(frame) {
409 violations.push(format!(
410 "frame {} rendered at seq {} was verified `{status}` at seq {verified_at} — the host MUST NOT keep serving the body it holds (§4 V2)",
411 frame_label(frame),
412 event.seq
413 ));
414 }
415 }
416 }
417 _ => {}
418 }
419 }
420
421 if observations == 0 {
422 return CheckResult::skip(
423 CHECK_STALENESS,
424 "no verify observations recorded — nothing to hold rendered frames against",
425 );
426 }
427 CheckResult::from_violations(
428 CHECK_STALENESS,
429 violations,
430 format!(
431 "{rendered} rendered frame(s) checked against {observations} verify observation(s); none cited dead evidence"
432 ),
433 )
434}
435
436pub fn check_citation_at_use(journal: &Journal) -> CheckResult {
441 let mut rendered: usize = 0;
442 let mut violations = Vec::new();
443
444 for event in &journal.events {
445 let EventBody::PromptAssembled { frames, .. } = &event.body else {
446 continue;
447 };
448 for frame in frames {
449 rendered += 1;
450 let labelled = frame
451 .citation_label
452 .as_deref()
453 .is_some_and(|label| !label.trim().is_empty());
454 if !labelled {
455 violations.push(format!(
456 "frame {} rendered at seq {} without a citation label (§F3 at the point of use)",
457 frame_label(&frame.frame),
458 event.seq
459 ));
460 }
461 }
462 }
463
464 CheckResult::from_violations(
465 CHECK_CITATION,
466 violations,
467 format!("{rendered} rendered frame(s), every one carrying a citation label"),
468 )
469}
470
471pub fn check_deterministic_composition(journal: &Journal) -> CheckResult {
482 let mut compositions: HashMap<String, (String, u64)> = HashMap::new();
486 let mut digested: usize = 0;
487 let mut violations = Vec::new();
488
489 for event in &journal.events {
490 let EventBody::PromptAssembled {
491 composition_digest: Some(digest),
492 frames,
493 ..
494 } = &event.body
495 else {
496 continue;
497 };
498 digested += 1;
499 let key = frame_set_key(frames);
500 match compositions.get(&key) {
501 None => {
502 compositions.insert(key, (digest.clone(), event.seq));
503 }
504 Some((first_digest, first_seq)) if first_digest != digest => {
505 violations.push(format!(
506 "the frame set rendered at seq {} is identical to seq {first_seq} but composed to a different digest — an unchanged set must render byte-identically (§1)",
507 event.seq
508 ));
509 }
510 Some(_) => {}
511 }
512 }
513
514 if digested == 0 {
515 return CheckResult::skip(
516 CHECK_COMPOSITION,
517 "no composition digests recorded — prefix stability not exercised by this journal",
518 );
519 }
520 CheckResult::from_violations(
521 CHECK_COMPOSITION,
522 violations,
523 format!("{digested} digest-carrying prompt(s); identical frame sets composed identically"),
524 )
525}
526
527pub fn check_effect_exactly_once(journal: &Journal) -> CheckResult {
533 let mut first_performed: BTreeMap<String, u64> = BTreeMap::new();
534 let mut resume_seqs: Vec<u64> = Vec::new();
535 let mut effects: usize = 0;
536 let mut violations = Vec::new();
537
538 for event in &journal.events {
539 match &event.body {
540 EventBody::Resume { .. } => resume_seqs.push(event.seq),
541 EventBody::SideEffect {
542 effect_id, kind, ..
543 } => {
544 effects += 1;
545 match first_performed.get(effect_id) {
546 None => {
547 first_performed.insert(effect_id.clone(), event.seq);
548 }
549 Some(first_seq) => {
550 let across_resume = resume_seqs.iter().find(|resume_seq| {
551 **resume_seq > *first_seq && **resume_seq < event.seq
552 });
553 let boundary = match across_resume {
554 Some(resume_seq) => {
555 format!(" — replayed across the resume at seq {resume_seq}")
556 }
557 None => " — duplicated within one live run".to_string(),
558 };
559 violations.push(format!(
560 "effect `{effect_id}` ({kind}) first performed at seq {first_seq} was performed again at seq {}{boundary}",
561 event.seq
562 ));
563 }
564 }
565 }
566 _ => {}
567 }
568 }
569
570 CheckResult::from_violations(
571 CHECK_EFFECT_ONCE,
572 violations,
573 format!("{effects} side effect(s), every effect id performed exactly once"),
574 )
575}
576
577pub fn check_resume_integrity(journal: &Journal) -> CheckResult {
583 let mut resumes: usize = 0;
584 let mut violations = Vec::new();
585
586 for (index, event) in journal.events.iter().enumerate() {
587 let EventBody::Resume { last_seq_seen } = &event.body else {
588 continue;
589 };
590 resumes += 1;
591 if index == 0 {
592 violations.push(format!(
593 "resume at seq {} with no prior recorded events — there is nothing to resume",
594 event.seq
595 ));
596 continue;
597 }
598 let recorded_through = journal.events[index - 1].seq;
599 if *last_seq_seen > recorded_through {
600 violations.push(format!(
601 "resume at seq {} claims to have recovered through seq {last_seq_seen} but the journal records only through seq {recorded_through} — a recovery of events that never happened",
602 event.seq
603 ));
604 } else if *last_seq_seen < recorded_through {
605 violations.push(format!(
606 "resume at seq {} recovered only through seq {last_seq_seen} of {recorded_through} recorded — {} recorded event(s) invisible to the resumed harness (quantified work loss)",
607 event.seq,
608 recorded_through - last_seq_seen
609 ));
610 }
611 }
612
613 if resumes == 0 {
614 return CheckResult::skip(
615 CHECK_RESUME,
616 "no resume recorded — durability not exercised by this journal",
617 );
618 }
619 CheckResult::from_violations(
620 CHECK_RESUME,
621 violations,
622 format!("{resumes} resume(s), each recovering exactly the recorded prefix"),
623 )
624}
625
626fn frame_label(frame: &FrameId) -> String {
628 format!("{}/{}", frame.provider_id, frame.frame_id)
629}
630
631fn frame_set_key(frames: &[RenderedFrame]) -> String {
633 frames
634 .iter()
635 .map(|rendered| {
636 format!(
637 "{}\u{1}{}\u{1}{}\u{1}{:?}",
638 rendered.frame.provider_id,
639 rendered.frame.frame_id,
640 rendered.frame.content_digest.as_deref().unwrap_or(""),
641 rendered.representation
642 )
643 })
644 .collect::<Vec<_>>()
645 .join("\u{2}")
646}
647
648#[cfg(test)]
649mod tests {
650 use super::*;
651 use crate::event::{ToolStatus, TraceEvent};
652
653 fn event(seq: u64, turn: Option<u64>, body: EventBody) -> TraceEvent {
654 TraceEvent {
655 seq,
656 at: "2026-07-23T09:00:00Z".into(),
657 session: "sess_1".into(),
658 turn,
659 body,
660 }
661 }
662
663 fn start() -> EventBody {
664 EventBody::SessionStart {
665 agent: "example-agent".into(),
666 harness: "stella/0.9".into(),
667 model: None,
668 trace_format: None,
669 }
670 }
671
672 fn rendered(digest: &str, label: Option<&str>) -> RenderedFrame {
673 RenderedFrame {
674 frame: FrameId::new("docs", "frm_1", Some(digest.into())),
675 representation: Representation::Full,
676 token_cost: 10,
677 citation_label: label.map(Into::into),
678 }
679 }
680
681 fn prompt(frames: Vec<RenderedFrame>) -> EventBody {
682 let total: u64 = frames.iter().map(|frame| u64::from(frame.token_cost)).sum();
683 EventBody::PromptAssembled {
684 budget_tokens: 4096,
685 declared_total_tokens: total,
686 composition_digest: None,
687 frames,
688 }
689 }
690
691 #[test]
692 fn dangling_calls_at_a_crash_are_expected_but_at_completion_are_a_defect() {
693 let crashed = Journal {
695 events: vec![
696 event(1, None, start()),
697 event(2, Some(1), EventBody::TurnStart),
698 event(
699 3,
700 Some(1),
701 EventBody::ModelResponse {
702 tool_calls: vec!["call_1".into()],
703 },
704 ),
705 ],
706 };
707 assert_eq!(
708 check_turn_loop_pairing(&crashed).status,
709 crate::report::CheckStatus::Pass
710 );
711
712 let mut events = crashed.events.clone();
714 events.push(event(4, Some(1), EventBody::TurnEnd));
715 events.push(event(
716 5,
717 None,
718 EventBody::SessionEnd {
719 outcome: SessionOutcome::Completed,
720 },
721 ));
722 let completed = Journal { events };
723 let result = check_turn_loop_pairing(&completed);
724 assert_eq!(result.status, crate::report::CheckStatus::Fail);
725 assert!(result.evidence.contains("call_1"), "{}", result.evidence);
726 }
727
728 #[test]
729 fn a_rejected_call_needs_no_execution_to_be_resolved() {
730 let journal = Journal {
733 events: vec![
734 event(1, None, start()),
735 event(2, Some(1), EventBody::TurnStart),
736 event(
737 3,
738 Some(1),
739 EventBody::ModelResponse {
740 tool_calls: vec!["call_1".into()],
741 },
742 ),
743 event(
744 4,
745 Some(1),
746 EventBody::ToolResult {
747 call_id: "call_1".into(),
748 status: ToolStatus::Rejected,
749 },
750 ),
751 event(5, Some(1), EventBody::TurnEnd),
752 event(
753 6,
754 None,
755 EventBody::SessionEnd {
756 outcome: SessionOutcome::Completed,
757 },
758 ),
759 ],
760 };
761 assert_eq!(
762 check_turn_loop_pairing(&journal).status,
763 crate::report::CheckStatus::Pass
764 );
765 }
766
767 #[test]
768 fn a_valid_verdict_after_a_stale_one_clears_the_identity_for_reuse() {
769 let frame = FrameId::new("docs", "frm_1", Some("sha256:aaaa".into()));
772 let journal = Journal {
773 events: vec![
774 event(1, None, start()),
775 event(
776 2,
777 None,
778 EventBody::VerifyObserved {
779 frame: frame.clone(),
780 verdict: Verdict::Stale {
781 replacement_digest: None,
782 },
783 },
784 ),
785 event(
786 3,
787 None,
788 EventBody::VerifyObserved {
789 frame,
790 verdict: Verdict::Valid,
791 },
792 ),
793 event(4, Some(1), EventBody::TurnStart),
794 event(
795 5,
796 Some(1),
797 prompt(vec![rendered("sha256:aaaa", Some("workspace.ts"))]),
798 ),
799 event(6, Some(1), EventBody::TurnEnd),
800 ],
801 };
802 assert_eq!(
803 check_staleness_at_use(&journal).status,
804 crate::report::CheckStatus::Pass
805 );
806 }
807
808 #[test]
809 fn an_honest_refresh_carries_a_new_digest_and_is_not_convicted() {
810 let stale_identity = FrameId::new("docs", "frm_1", Some("sha256:aaaa".into()));
813 let journal = Journal {
814 events: vec![
815 event(1, None, start()),
816 event(
817 2,
818 None,
819 EventBody::VerifyObserved {
820 frame: stale_identity,
821 verdict: Verdict::Stale {
822 replacement_digest: Some("sha256:bbbb".into()),
823 },
824 },
825 ),
826 event(3, Some(1), EventBody::TurnStart),
827 event(
828 4,
829 Some(1),
830 prompt(vec![rendered("sha256:bbbb", Some("workspace.ts"))]),
831 ),
832 event(5, Some(1), EventBody::TurnEnd),
833 ],
834 };
835 assert_eq!(
836 check_staleness_at_use(&journal).status,
837 crate::report::CheckStatus::Pass
838 );
839 }
840
841 #[test]
842 fn a_reference_frame_with_a_nonzero_cost_is_a_budget_lie() {
843 let mut reference = rendered("sha256:aaaa", Some("runbook"));
844 reference.representation = Representation::Reference;
845 reference.token_cost = 40;
846 let journal = Journal {
847 events: vec![
848 event(1, None, start()),
849 event(2, Some(1), EventBody::TurnStart),
850 event(3, Some(1), prompt(vec![reference])),
851 event(4, Some(1), EventBody::TurnEnd),
852 ],
853 };
854 let result = check_assembly_budget_honesty(&journal);
855 assert_eq!(result.status, crate::report::CheckStatus::Fail);
856 assert!(
857 result.evidence.contains("inlines nothing"),
858 "{}",
859 result.evidence
860 );
861 }
862
863 #[test]
864 fn work_loss_on_resume_is_quantified_not_just_flagged() {
865 let journal = Journal {
866 events: vec![
867 event(1, None, start()),
868 event(2, Some(1), EventBody::TurnStart),
869 event(3, Some(1), EventBody::TurnEnd),
870 event(4, None, EventBody::Resume { last_seq_seen: 2 }),
871 ],
872 };
873 let result = check_resume_integrity(&journal);
874 assert_eq!(result.status, crate::report::CheckStatus::Fail);
875 assert!(
876 result.evidence.contains("1 recorded event(s) invisible"),
877 "{}",
878 result.evidence
879 );
880 }
881
882 #[test]
883 fn oracles_that_a_journal_never_exercises_are_skipped_not_passed() {
884 let journal = Journal {
885 events: vec![
886 event(1, None, start()),
887 event(
888 2,
889 None,
890 EventBody::SessionEnd {
891 outcome: SessionOutcome::Completed,
892 },
893 ),
894 ],
895 };
896 let report = run_oracles(&journal);
897 assert!(report.passed());
898 let skipped: Vec<&str> = report
899 .checks
900 .iter()
901 .filter(|check| check.status == crate::report::CheckStatus::Skipped)
902 .map(|check| check.name.as_str())
903 .collect();
904 assert_eq!(
907 skipped,
908 vec![CHECK_STALENESS, CHECK_COMPOSITION, CHECK_RESUME]
909 );
910 }
911}