Skip to main content

contextgraph_trace/
oracle.rs

1//! The replay oracles — pure functions over a parsed [`Journal`] that hold a
2//! harness's recording to the loop invariants
3//! (`docs/sketches/host-trace.md` §"The oracles").
4//!
5//! Every oracle is deliberately independent: each walks the journal itself,
6//! so a check can be read, tested, and trusted in isolation, and a failure in
7//! one cannot mask a failure in another. Journals are small; clarity wins
8//! over a shared single pass.
9//!
10//! The suite is adversarial in the same way `contextgraph-conformance` is:
11//! the crate ships a golden journal that passes everything and one fixture
12//! per check that trips exactly that check (`tests/fixture_suite.rs`),
13//! proving each oracle catches the broken harness it exists for.
14
15use 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
23/// The stable check names, so reports and callers agree on identifiers.
24pub 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
33/// Every check this suite runs, in report order.
34pub 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
45/// Run every oracle over the journal, returning a typed report. Never
46/// panics: every defect becomes a failing check whose evidence names the
47/// exact `seq` numbers involved.
48pub 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
64/// `sequence-integrity` — the recording itself is trustworthy: `seq` is dense
65/// from 1, there is one session, timestamps are in the protocol profile
66/// (`SPEC.md` §F4), turn markers balance, and nothing follows `session_end`.
67///
68/// Every other oracle leans on this one: density is what makes "the journal
69/// is complete" checkable, and the turn discipline (a turn does not survive a
70/// crash — a `resume` implicitly closes it) is what lets the loop oracles
71/// distinguish a crash from a bug.
72pub 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            // A turn does not survive a crash: a resume implicitly closes
153            // any open turn. Session-level events carry no turn.
154            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                    // A deliberate completion closes its turn first; only an
173                    // abort may leave one open.
174                    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                // Inside a turn every event carries the open turn; between
187                // turns, none does.
188                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
212/// `turn-loop-pairing` — the tool loop's contract: every call the model
213/// requested is resolved exactly once *before the next prompt is assembled*,
214/// nothing is executed that the model never requested (phantom execution),
215/// and no result arrives for a call that was never made or was already
216/// resolved.
217///
218/// The crash carve-outs are deliberate: unresolved calls before a `resume`
219/// were orphaned by the crash (expected — the *replay* of their side effects
220/// is `effect-exactly-once`'s territory), and a journal that simply stops
221/// mid-turn records a crash, not a defect. Only a session that claims
222/// `completed` with dangling calls fails here.
223pub fn check_turn_loop_pairing(journal: &Journal) -> CheckResult {
224    let mut violations = Vec::new();
225    // Unresolved model-requested calls: call id → the seq that requested it.
226    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            // The crash orphaned whatever was in flight; resumed work starts
300            // a new turn with new calls.
301            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
325/// `assembly-budget-honesty` — §B1/§B3 held at the point of assembly, where
326/// the harness is the declaring party: the itemized frame costs must sum to
327/// the total the harness declared, the sum must fit the budget it announced,
328/// and a `reference` frame — which inlines nothing — must cost 0.
329pub 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
378/// `staleness-at-use` — the reuse rule (`docs/context-reuse.md` §4, V2) held
379/// at the point of use: a frame whose exact identity was last verified
380/// `stale` or `gone` must never be rendered again.
381///
382/// The identity is the full `(provider, frame, digest)` triple, so an honest
383/// refresh is invisible here: a re-queried frame carries the source's *new*
384/// digest and therefore a different identity. `stale` means the digest
385/// changed, so a same-identity render afterwards is either the host reusing
386/// the body it was told to drop or the provider contradicting itself — a
387/// defect either way. A later `valid` verdict for the identity clears it
388/// (verify-after-doubt is exactly how revalidation is supposed to work), and
389/// `unknown` does not convict: the host may have re-queried and been
390/// re-served the identical bytes, which the journal cannot distinguish.
391pub fn check_staleness_at_use(journal: &Journal) -> CheckResult {
392    let mut observations: usize = 0;
393    let mut rendered: usize = 0;
394    // Latest verdict per exact identity: (wire status, whether reuse is dead, seq).
395    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
436/// `citation-at-use` — §F3's "never a bare uuid", held where it actually
437/// matters: a frame rendered into a prompt must carry a non-empty citation
438/// label at that moment, not merely have carried one at the provider
439/// boundary.
440pub 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
471/// `deterministic-composition` — prefix stability
472/// (`docs/context-reuse.md` §1), finally checkable: two prompts rendering the
473/// identical frame set (same identities, same representations, same order)
474/// must compose to the identical `composition_digest`. A harness whose
475/// composition wobbles under an unchanged set is silently destroying the
476/// prompt-cache economics the canonical order exists to buy.
477///
478/// Skipped when the journal records no composition digests — the field is
479/// optional precisely so a recorder can adopt the vocabulary before wiring
480/// composed-prefix hashing.
481pub fn check_deterministic_composition(journal: &Journal) -> CheckResult {
482    // Frame-set key → (digest, seq first composed). The key covers identity
483    // + representation + order; declared cost is excluded deliberately —
484    // identity names the bytes, and §1 is about the rendered bytes.
485    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
527/// `effect-exactly-once` — the crash-replay double-side-effect bug, by
528/// construction: `effect_id` names an *intended-once* effect (a deliberate
529/// re-execution is a new id), so the same id performed twice is a defect —
530/// and the evidence says whether it was replayed across a `resume` boundary
531/// (the classic durability bug) or duplicated within one live run.
532pub 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
577/// `resume-integrity` — a `resume` must recover exactly what the journal
578/// records. `last_seq_seen` above the recorded prefix is a recovery of events
579/// that never happened (a corrupt recovery); below it is quantified work
580/// loss — the resumed harness is blind to events its own durable record
581/// holds, which is how a harness re-does work it already did.
582pub 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
626/// `provider/frame` — the human-readable name a violation cites a frame by.
627fn frame_label(frame: &FrameId) -> String {
628    format!("{}/{}", frame.provider_id, frame.frame_id)
629}
630
631/// A stable key for a rendered frame set: identity + representation + order.
632fn 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        // A journal that stops mid-call records a crash, not a bug.
694        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        // The same dangling call under a deliberate `completed` is the bug.
713        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        // Declining is a resolution, not an execution — a permission gate
731        // that answers `rejected` without a `tool_call` is a healthy loop.
732        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        // Verify-after-doubt is how revalidation is supposed to work: only
770        // the *latest* verdict convicts.
771        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        // After `stale`, the host re-queries and the new serve carries the
811        // source's new digest — a different identity, invisible here.
812        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        // Honesty about coverage: an unexercised guarantee is declared, not
905        // silently counted as upheld.
906        assert_eq!(
907            skipped,
908            vec![CHECK_STALENESS, CHECK_COMPOSITION, CHECK_RESUME]
909        );
910    }
911}