1use std::collections::HashMap;
57
58use serde::Serialize;
59
60use crate::runtime::kernel::wire::ConfigDefaults;
61use crate::runtime::kernel::wire::effect::{EffectOutcome, EffectSuccess, ProviderOutcome};
62use crate::runtime::kernel::wire::record::{
63 KernelRecord, NormalizedPayload, RecordError, verify_record_chain,
64};
65use crate::runtime::kernel::wire::restore::{RestoredOperation, restore_operation};
66use crate::runtime::kernel::wire::transaction::InMemoryRecordIndex;
67
68pub const UNATTRIBUTED_SEGMENT: &str = "(unattributed)";
71
72const DEFERRED: &[&str] = &[
75 "c4.parent_chain: parent links are not journaled; an orphan spawn cannot resolve (no \
76 outstanding effect), which C3's re-plan enforces structurally",
77 "c4.launch_token_ledger: the durable LaunchToken ledger lives in checkpoints, so reuse \
78 across different TaskLaunch payloads is a batch-2 (checkpoint input) check; the \
79 journal-direct shadow — (task_id, attempt_id) pair uniqueness — is checked here",
80];
81
82#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
84pub struct RuleReport {
85 pub rule: String,
87 pub verdict: Verdict,
88 pub detail: String,
90}
91
92#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
93#[serde(rename_all = "snake_case")]
94pub enum Verdict {
95 Pass,
96 Fail,
97 Degraded,
99}
100
101#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
103pub struct DegradedHop {
104 pub ordinal: usize,
106 pub step_seq: Option<u64>,
107 pub reason: String,
109}
110
111#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
113pub struct SegmentReport {
114 pub operation_id: String,
115 pub hops: usize,
116 pub degraded_hops: Vec<DegradedHop>,
117 pub rules: Vec<RuleReport>,
118}
119
120#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
121pub struct ValidationReport {
122 pub segments: Vec<SegmentReport>,
123 pub unparseable_records: usize,
125 #[serde(default)]
128 pub cross_checks: Vec<RuleReport>,
129 #[serde(default)]
132 pub unparseable_events: usize,
133 #[serde(default)]
137 pub session_events: Option<usize>,
138 pub deferred: Vec<String>,
140}
141
142impl ValidationReport {
143 pub fn has_violations(&self) -> bool {
144 self.segments
145 .iter()
146 .flat_map(|segment| segment.rules.iter())
147 .chain(self.cross_checks.iter())
148 .any(|rule| rule.verdict == Verdict::Fail)
149 }
150
151 pub fn exit_code(&self) -> i32 {
156 if self.has_violations() {
157 1
158 } else if self.unparseable_records > 0
159 || self.unparseable_events > 0
160 || self.segments.is_empty()
161 || matches!(self.session_events, Some(0))
162 {
163 2
164 } else {
165 0
166 }
167 }
168}
169
170enum Hop {
173 Complete(KernelRecord),
174 Degraded(DegradedRecord),
175}
176
177struct DegradedRecord {
178 ordinal: usize,
179 operation_id: Option<String>,
180 input_id: Option<String>,
181 step_seq: Option<u64>,
182 previous_record_digest: Option<String>,
183 record_digest: Option<String>,
184 reason: String,
185 integrity_failure: bool,
186}
187
188impl DegradedRecord {
189 fn marking(&self) -> DegradedHop {
190 DegradedHop {
191 ordinal: self.ordinal,
192 step_seq: self.step_seq,
193 reason: self.reason.clone(),
194 }
195 }
196}
197
198impl Hop {
199 fn operation_id(&self) -> Option<&str> {
200 match self {
201 Self::Complete(record) => Some(record.operation_id().as_str()),
202 Self::Degraded(degraded) => degraded.operation_id.as_deref(),
203 }
204 }
205
206 fn step_seq(&self) -> Option<u64> {
207 match self {
208 Self::Complete(record) => Some(record.step_seq().get()),
209 Self::Degraded(degraded) => degraded.step_seq,
210 }
211 }
212}
213
214pub fn validate_journal<B: AsRef<[u8]>>(blobs: &[B]) -> ValidationReport {
218 validate_with_session_log(blobs, &[] as &[Vec<Vec<u8>>])
219}
220
221pub fn validate_with_session_log<J, S>(journal_blobs: &[J], session_streams: &[Vec<S>]) -> ValidationReport
226where
227 J: AsRef<[u8]>,
228 S: AsRef<[u8]>,
229{
230 let (outcomes, unparseable_records) = validate_journal_plane(journal_blobs);
231
232 let mut streams: Vec<SessionStream> = Vec::with_capacity(session_streams.len());
233 for stream_blobs in session_streams {
234 let mut events = Vec::with_capacity(stream_blobs.len());
235 let mut unparseable_events = 0;
236 for blob in stream_blobs {
237 match classify_session_event(blob.as_ref()) {
238 Some(event) => events.push(event),
239 None => unparseable_events += 1,
240 }
241 }
242 streams.push(SessionStream {
243 events,
244 unparseable_events,
245 });
246 }
247
248 let session_plane_provided = !session_streams.is_empty();
249 let session_events = session_plane_provided
250 .then(|| streams.iter().map(|stream| stream.events.len()).sum());
251 let unparseable_events = streams
252 .iter()
253 .map(|stream| stream.unparseable_events)
254 .sum();
255
256 let cross_checks = if session_plane_provided {
258 let mut checks = check_c6(&streams, &outcomes);
259 checks.push(check_c8(&streams, &outcomes));
260 checks
261 } else {
262 Vec::new()
263 };
264
265 let deferred: Vec<String> = DEFERRED.iter().map(|line| (*line).to_string()).collect();
266
267 ValidationReport {
268 segments: outcomes.into_iter().map(|outcome| outcome.report).collect(),
269 unparseable_records,
270 cross_checks,
271 unparseable_events,
272 session_events,
273 deferred,
274 }
275}
276
277fn validate_journal_plane<B: AsRef<[u8]>>(blobs: &[B]) -> (Vec<SegmentOutcome>, usize) {
279 let mut hops: Vec<Hop> = Vec::with_capacity(blobs.len());
280 let mut unparseable_records = 0;
281 for (ordinal, blob) in blobs.iter().enumerate() {
282 match classify(ordinal, blob.as_ref()) {
283 Some(hop) => hops.push(hop),
284 None => unparseable_records += 1,
285 }
286 }
287
288 let mut segments: HashMap<String, Vec<Hop>> = HashMap::new();
289 for hop in hops {
290 let key = hop
291 .operation_id()
292 .map(str::to_string)
293 .unwrap_or_else(|| UNATTRIBUTED_SEGMENT.to_string());
294 segments.entry(key).or_default().push(hop);
295 }
296
297 let mut keys: Vec<String> = segments.keys().cloned().collect();
298 keys.sort();
299 let reports = keys
300 .iter()
301 .map(|key| validate_segment(key, segments.remove(key).unwrap_or_default()))
302 .collect();
303 (reports, unparseable_records)
304}
305
306pub struct SessionStream {
313 pub events: Vec<EvidenceEvent>,
314 pub unparseable_events: usize,
315}
316
317#[derive(Debug, Clone, PartialEq, Eq)]
320pub enum EvidenceEvent {
321 RunStarted { route_id: Option<String> },
324 ProviderAttempt {
326 effect_id: Option<String>,
327 request_fingerprint: Option<String>,
328 route_id: Option<String>,
329 status: Option<String>,
330 },
331 PromptMeasured {
334 effect_id: Option<String>,
335 request_fingerprint: Option<String>,
336 },
337 LlmCompleted {
340 effect_id: Option<String>,
341 invocation_id: Option<String>,
342 },
343 Other,
345}
346
347fn classify_session_event(bytes: &[u8]) -> Option<EvidenceEvent> {
350 let value: serde_json::Value = serde_json::from_slice(bytes).ok()?;
351 let object = value.as_object()?;
352 let string = |key: &str| {
353 object
354 .get(key)
355 .and_then(serde_json::Value::as_str)
356 .map(str::to_string)
357 };
358 let kind = string("kind");
359 let event = match kind.as_deref() {
360 Some("run_started") => EvidenceEvent::RunStarted {
361 route_id: route_id_of(&value),
362 },
363 Some("provider_attempt") => EvidenceEvent::ProviderAttempt {
364 effect_id: string("effect_id"),
365 request_fingerprint: string("request_fingerprint"),
366 route_id: route_id_of(&value),
367 status: string("status"),
368 },
369 Some("prompt_measured") => EvidenceEvent::PromptMeasured {
370 effect_id: string("effect_id"),
371 request_fingerprint: object
374 .get("measurement")
375 .and_then(|measurement| {
376 measurement
377 .get("requestFingerprint")
378 .or_else(|| measurement.get("request_fingerprint"))
379 })
380 .and_then(serde_json::Value::as_str)
381 .map(str::to_string),
382 },
383 Some("llm_completed") => EvidenceEvent::LlmCompleted {
384 effect_id: string("effect_id"),
385 invocation_id: string("invocation_id"),
386 },
387 _ => EvidenceEvent::Other,
388 };
389 Some(event)
390}
391
392fn route_id_of(event: &serde_json::Value) -> Option<String> {
394 let route = event.get("route")?;
395 route
396 .get("routeId")
397 .or_else(|| route.get("route_id"))
398 .and_then(serde_json::Value::as_str)
399 .map(str::to_string)
400}
401
402fn check_c6(streams: &[SessionStream], outcomes: &[SegmentOutcome]) -> Vec<RuleReport> {
431 let segments: HashMap<&str, &SegmentOutcome> = outcomes
432 .iter()
433 .filter(|outcome| outcome.report.operation_id != UNATTRIBUTED_SEGMENT)
434 .map(|outcome| (outcome.report.operation_id.as_str(), outcome))
435 .collect();
436
437 let mut correspondence = ClauseAccumulator::default();
438 let mut fingerprints = ClauseAccumulator::default();
439 let mut routes = ClauseAccumulator::default();
440 let mut total_attempts = 0usize;
441
442 for (index, stream) in streams.iter().enumerate() {
443 let measured: std::collections::HashSet<&str> = stream
444 .events
445 .iter()
446 .filter_map(|event| match event {
447 EvidenceEvent::PromptMeasured {
448 request_fingerprint,
449 ..
450 } => request_fingerprint.as_deref(),
451 _ => None,
452 })
453 .collect();
454
455 let mut baseline: Option<&str> = None;
458 let mut last_pinned: Option<&str> = None;
459
460 for event in &stream.events {
461 match event {
462 EvidenceEvent::RunStarted { route_id } => {
463 if let Some(new_route) = route_id.as_deref() {
464 if let Some(previous) = last_pinned
465 && previous != new_route
466 {
467 routes.degraded(format!(
468 "stream #{index}: run resumed on route {new_route} (was \
469 {previous}) — a cross-resume change, degraded per Q3"
470 ));
471 }
472 baseline = Some(new_route);
473 last_pinned = Some(new_route);
474 } else {
475 baseline = None;
478 }
479 }
480 EvidenceEvent::ProviderAttempt {
481 effect_id,
482 request_fingerprint,
483 route_id,
484 ..
485 } => {
486 total_attempts += 1;
487 let label = effect_id.as_deref().unwrap_or("(no effect_id)");
488
489 match effect_id.as_deref() {
491 None => correspondence.violation(format!(
492 "stream #{index}: provider_attempt without effect_id — the writers \
493 mint it from the kernel effect, so a keyless attempt is forged \
494 evidence"
495 )),
496 Some(effect) => match parse_effect_step(effect) {
497 None => correspondence.violation(format!(
498 "stream #{index}: attempt names {effect}, which is not in the \
499 `operation:step:N:effect:M` vocabulary"
500 )),
501 Some((operation, step)) => match segments.get(operation) {
502 None => correspondence.degraded(format!(
503 "stream #{index}: attempt names {effect}, but operation \
504 {operation} has no journal segment (the journal may cover \
505 a subset of the session)"
506 )),
507 Some(outcome) => match &outcome.effects {
508 None => correspondence.degraded(format!(
509 "stream #{index}: segment {operation} could not be \
510 re-planned, so {effect}'s publication is unverifiable"
511 )),
512 Some(effects) if effects.published.contains(effect) => {
513 correspondence.checked += 1;
514 }
515 Some(effects) if step > effects.max_step => {
516 correspondence.degraded(format!(
517 "stream #{index}: attempt names {effect} at step \
518 {step}, past the journal's tip (step {}) — a \
519 prefix cannot disprove it",
520 effects.max_step
521 ));
522 }
523 Some(_) => correspondence.violation(format!(
524 "stream #{index}: attempt names {effect}, but the \
525 deterministic re-plan of {operation} never published \
526 it — the attempt is unmoored from the journal"
527 )),
528 },
529 },
530 },
531 }
532
533 match request_fingerprint.as_deref() {
535 None => fingerprints.violation(format!(
536 "stream #{index}: provider_attempt {label} without \
537 request_fingerprint — the writers require it (G2)"
538 )),
539 Some(fingerprint) if measured.contains(fingerprint) => {
540 fingerprints.checked += 1;
541 }
542 Some(fingerprint) => fingerprints.violation(format!(
543 "stream #{index}: attempt {label} carries fingerprint \
544 {fingerprint}, but no prompt_measured in this session carries it"
545 )),
546 }
547
548 match (route_id.as_deref(), baseline) {
550 (Some(route), Some(pinned)) if route != pinned => routes.violation(
551 format!(
552 "stream #{index}: attempt {label} ran on route {route} inside \
553 a run pinned to {pinned} — an in-run route change is a \
554 violation (Q3)"
555 ),
556 ),
557 (Some(_), Some(_)) => routes.checked += 1,
558 _ => routes.unverifiable += 1,
560 }
561 }
562 _ => {}
563 }
564 }
565 }
566
567 vec![
568 correspondence.report(
569 "C6.1",
570 total_attempts,
571 "attempt↔journal effect correspondence",
572 ),
573 fingerprints.report("C6.2", total_attempts, "attempt fingerprint↔prompt_measured join"),
574 routes.report("C6.3", total_attempts, "in-run route stability"),
575 ]
576}
577
578#[derive(Default)]
581struct ClauseAccumulator {
582 checked: usize,
583 unverifiable: usize,
584 violations: Vec<String>,
585 degraded_notes: Vec<String>,
586}
587
588impl ClauseAccumulator {
589 fn violation(&mut self, detail: String) {
590 self.violations.push(detail);
591 }
592
593 fn degraded(&mut self, note: String) {
594 self.degraded_notes.push(note);
595 }
596
597 fn report(self, rule: &str, total_attempts: usize, what: &str) -> RuleReport {
598 let rule = rule.to_string();
599 if !self.violations.is_empty() {
600 return RuleReport {
601 rule,
602 verdict: Verdict::Fail,
603 detail: self.violations.join("; "),
604 };
605 }
606 if total_attempts == 0 {
607 return RuleReport {
608 rule,
609 verdict: Verdict::Degraded,
610 detail: format!(
611 "no provider_attempt events in any stream — a pre-0.2.63 log carries none \
612 (C7); {what} unchecked"
613 ),
614 };
615 }
616 if !self.degraded_notes.is_empty() || self.unverifiable > 0 {
617 let mut detail = self.degraded_notes.join("; ");
618 if self.unverifiable > 0 {
619 if !detail.is_empty() {
620 detail.push_str("; ");
621 }
622 detail.push_str(&format!(
623 "{} attempt(s) unverifiable (no pinned run route)",
624 self.unverifiable
625 ));
626 }
627 return RuleReport {
628 rule,
629 verdict: Verdict::Degraded,
630 detail: format!(
631 "{} attempt(s) verified for {what}; {detail}",
632 self.checked
633 ),
634 };
635 }
636 RuleReport {
637 rule,
638 verdict: Verdict::Pass,
639 detail: format!(
640 "{} attempt(s) verified — {what} holds across every stream",
641 self.checked
642 ),
643 }
644 }
645}
646
647fn check_c8(streams: &[SessionStream], outcomes: &[SegmentOutcome]) -> RuleReport {
681 let rule = "C8".to_string();
682 let segments: HashMap<&str, &SegmentOutcome> = outcomes
683 .iter()
684 .filter(|outcome| outcome.report.operation_id != UNATTRIBUTED_SEGMENT)
685 .map(|outcome| (outcome.report.operation_id.as_str(), outcome))
686 .collect();
687
688 let mut llm_completed_events = 0usize;
689 let mut checked = 0usize;
690 let mut trivial = 0usize;
691 let mut unverifiable = 0usize;
692 let mut violations: Vec<String> = Vec::new();
693 let mut degraded_notes: Vec<String> = Vec::new();
694
695 for (index, stream) in streams.iter().enumerate() {
696 for event in &stream.events {
697 let EvidenceEvent::LlmCompleted {
698 effect_id,
699 invocation_id,
700 } = event
701 else {
702 continue;
703 };
704 llm_completed_events += 1;
705 let (Some(head), Some(selected)) = (invocation_id.as_deref(), effect_id.as_deref())
706 else {
707 unverifiable += 1;
710 continue;
711 };
712 if head == selected {
713 trivial += 1;
714 continue;
715 }
716
717 let (Some((head_op, head_step)), Some((selected_op, selected_step))) =
718 (parse_effect_step(head), parse_effect_step(selected))
719 else {
720 violations.push(format!(
721 "stream #{index}: llm_completed claims invocation {head} → {selected}, but \
722 one of the pair is not in the `operation:step:N:effect:M` vocabulary"
723 ));
724 continue;
725 };
726 if head_op != selected_op {
727 violations.push(format!(
728 "stream #{index}: llm_completed claims invocation {head} → {selected} — an \
729 invocation chain cannot cross operations"
730 ));
731 continue;
732 }
733 if selected_step <= head_step {
734 violations.push(format!(
735 "stream #{index}: llm_completed claims invocation {head} → {selected}, but \
736 the selected effect does not follow the chain head"
737 ));
738 continue;
739 }
740 let Some(outcome) = segments.get(head_op) else {
741 degraded_notes.push(format!(
742 "stream #{index}: operation {head_op} has no journal segment (the journal \
743 may cover a subset of the session)"
744 ));
745 continue;
746 };
747 let Some(effects) = &outcome.effects else {
748 degraded_notes.push(format!(
749 "stream #{index}: segment {head_op} could not be re-planned, so the \
750 invocation {head} → {selected} is unverifiable"
751 ));
752 continue;
753 };
754
755 match effects.resolutions.get(head) {
757 Some(ResolutionFact::Completed) | Some(ResolutionFact::Other) => {
758 violations.push(format!(
759 "stream #{index}: llm_completed claims invocation {head} → {selected}, \
760 but {head} resolved to completion — a completed effect closes its \
761 invocation; nothing chains from it"
762 ));
763 continue;
764 }
765 Some(ResolutionFact::Overflow) | Some(ResolutionFact::Failed) => {}
766 None if effects.published.contains(head) => degraded_notes.push(format!(
767 "stream #{index}: chain head {head} is published but its resolution has \
768 not landed in the journal (the planes are not synchronised)"
769 )),
770 None if head_step > effects.max_step => degraded_notes.push(format!(
771 "stream #{index}: chain head {head} claims step {head_step}, past the \
772 journal's tip (step {})",
773 effects.max_step
774 )),
775 None => {
776 violations.push(format!(
777 "stream #{index}: llm_completed claims invocation head {head}, but the \
778 deterministic re-plan of {head_op} never published it"
779 ));
780 continue;
781 }
782 }
783
784 if effects.resolutions.contains_key(selected) {
786 checked += 1;
787 } else if effects.published.contains(selected) || selected_step > effects.max_step {
788 degraded_notes.push(format!(
789 "stream #{index}: selected effect {selected} is not resolved in the \
790 journal (the planes are not synchronised)"
791 ));
792 } else {
793 violations.push(format!(
794 "stream #{index}: llm_completed selects {selected}, but the deterministic \
795 re-plan of {selected_op} never published it"
796 ));
797 }
798 }
799 }
800
801 if !violations.is_empty() {
802 return RuleReport {
803 rule,
804 verdict: Verdict::Fail,
805 detail: violations.join("; "),
806 };
807 }
808 if llm_completed_events == 0 {
809 return RuleReport {
810 rule,
811 verdict: Verdict::Degraded,
812 detail: "no llm_completed events in any stream — invocation adjacency unchecked"
813 .to_string(),
814 };
815 }
816 if !degraded_notes.is_empty() || unverifiable > 0 {
817 let mut detail = degraded_notes.join("; ");
818 if unverifiable > 0 {
819 if !detail.is_empty() {
820 detail.push_str("; ");
821 }
822 detail.push_str(&format!(
823 "{unverifiable} llm_completed event(s) without invocation_id/effect_id \
824 (pre-0.2.63 fields — C7)"
825 ));
826 }
827 if !detail.is_empty() {
828 return RuleReport {
829 rule,
830 verdict: Verdict::Degraded,
831 detail: format!(
832 "{checked} retried invocation(s) verified, {trivial} first-try chain(s) \
833 closed; {detail}"
834 ),
835 };
836 }
837 }
838 RuleReport {
839 rule,
840 verdict: Verdict::Pass,
841 detail: format!(
842 "{checked} retried invocation(s) verified end-to-end, {trivial} first-try chain(s) \
843 closed"
844 ),
845 }
846}
847
848fn classify(ordinal: usize, bytes: &[u8]) -> Option<Hop> {
852 let error = match KernelRecord::from_record_bytes(bytes) {
853 Ok(record) => return Some(Hop::Complete(record)),
854 Err(error) => error,
855 };
856 let value: serde_json::Value = serde_json::from_slice(bytes).ok()?;
857 let object = value.as_object()?;
858 let string = |key: &str| {
859 object
860 .get(key)
861 .and_then(serde_json::Value::as_str)
862 .map(str::to_string)
863 };
864 let step_seq = object.get("step_seq").and_then(|value| {
867 value
868 .as_u64()
869 .or_else(|| value.as_str().and_then(|text| text.parse().ok()))
870 });
871 let degraded = DegradedRecord {
872 ordinal,
873 operation_id: string("operation_id"),
874 input_id: string("input_id"),
875 step_seq,
876 previous_record_digest: string("previous_record_digest"),
877 record_digest: string("record_digest"),
878 reason: format!("{}: {}", error.code().as_str(), error.message()),
879 integrity_failure: matches!(error, RecordError::DigestMismatch(_)),
880 };
881 if degraded.operation_id.is_some() || degraded.step_seq.is_some() {
884 Some(Hop::Degraded(degraded))
885 } else {
886 None
887 }
888}
889
890fn validate_segment(operation_id: &str, mut hops: Vec<Hop>) -> SegmentOutcome {
891 hops.sort_by_key(|hop| {
894 (
895 hop.step_seq().unwrap_or(u64::MAX),
896 match hop {
897 Hop::Complete(_) => 0usize,
898 Hop::Degraded(degraded) => degraded.ordinal,
899 },
900 )
901 });
902
903 let degraded_hops: Vec<DegradedHop> = hops
904 .iter()
905 .filter_map(|hop| match hop {
906 Hop::Degraded(degraded) => Some(degraded.marking()),
907 Hop::Complete(_) => None,
908 })
909 .collect();
910 let hop_count = hops.len();
911
912 let c1 = check_c1(&hops);
913 let replan = replan_segment(&hops, &c1);
914 let c2 = check_c2(&hops);
915 let c3 = render_c3(&replan);
916 let c4 = check_c4(&hops, operation_id);
917
918 let effects = match &replan {
921 Replan::Restored(restored) => {
922 let mut published: std::collections::HashSet<String> = std::collections::HashSet::new();
923 let mut resolutions: HashMap<String, ResolutionFact> = HashMap::new();
924 for hop in &hops {
925 if let Some((effect_id, fact)) = resolution_of(hop) {
926 published.insert(effect_id.clone());
927 resolutions.insert(effect_id, fact);
928 }
929 }
930 published.extend(
931 restored
932 .transaction
933 .pending_effects()
934 .map(|effect| effect.effect_id.as_str().to_string()),
935 );
936 let max_step = hops.iter().filter_map(|hop| hop.step_seq()).max().unwrap_or(0);
937 Some(SegmentEffects {
938 max_step,
939 published,
940 resolutions,
941 })
942 }
943 _ => None,
944 };
945
946 SegmentOutcome {
947 report: SegmentReport {
948 operation_id: operation_id.to_string(),
949 hops: hop_count,
950 degraded_hops,
951 rules: vec![c1, c2, c3, c4],
952 },
953 effects,
954 }
955}
956
957struct SegmentOutcome {
959 report: SegmentReport,
960 effects: Option<SegmentEffects>,
962}
963
964struct SegmentEffects {
965 max_step: u64,
968 published: std::collections::HashSet<String>,
970 resolutions: HashMap<String, ResolutionFact>,
972}
973
974#[derive(Debug, Clone, Copy, PartialEq, Eq)]
977enum ResolutionFact {
978 Completed,
980 Overflow,
983 Failed,
987 Other,
989}
990
991fn resolution_of(hop: &Hop) -> Option<(String, ResolutionFact)> {
994 let Hop::Complete(record) = hop else { return None };
995 let input = record.normalized_input().ok()?;
996 let NormalizedPayload::ResolveEffect(resolve) = &input.input else {
997 return None;
998 };
999 let fact = match &resolve.outcome {
1000 EffectOutcome::Failed(_) => ResolutionFact::Failed,
1001 EffectOutcome::Succeeded(success) => match &success.result {
1002 EffectSuccess::Provider(provider) => match &provider.outcome {
1003 ProviderOutcome::Completed(_) => ResolutionFact::Completed,
1004 ProviderOutcome::ContextOverflow(_) => ResolutionFact::Overflow,
1005 },
1006 _ => ResolutionFact::Other,
1007 },
1008 };
1009 Some((resolve.effect_id.as_str().to_string(), fact))
1010}
1011
1012fn check_c1(hops: &[Hop]) -> RuleReport {
1014 let rule = "C1".to_string();
1015 if hops.is_empty() {
1016 return RuleReport {
1017 rule,
1018 verdict: Verdict::Degraded,
1019 detail: "no records in this segment".to_string(),
1020 };
1021 }
1022 let all_complete = hops.iter().all(|hop| matches!(hop, Hop::Complete(_)));
1023 if all_complete {
1024 let records: Vec<KernelRecord> = hops
1025 .iter()
1026 .filter_map(|hop| match hop {
1027 Hop::Complete(record) => Some(record.clone()),
1028 Hop::Degraded(_) => None,
1029 })
1030 .collect();
1031 return match verify_record_chain(&records) {
1032 Ok(genesis_digest) => RuleReport {
1033 rule,
1034 verdict: Verdict::Pass,
1035 detail: format!(
1036 "{} record(s), genesis {genesis_digest}, every link verified",
1037 records.len()
1038 ),
1039 },
1040 Err(error) => RuleReport {
1041 rule,
1042 verdict: Verdict::Fail,
1043 detail: format!("{}: {}", error.code().as_str(), error.message()),
1044 },
1045 };
1046 }
1047
1048 let mut broken: Vec<String> = hops
1051 .iter()
1052 .filter_map(|hop| match hop {
1053 Hop::Degraded(record) if record.integrity_failure => Some(record.reason.clone()),
1054 _ => None,
1055 })
1056 .collect();
1057 let mut unverifiable_links = 0usize;
1058 let mut previous: Option<(&Hop, Option<&KernelRecord>)> = None;
1059 for hop in hops {
1060 let step = hop.step_seq();
1061 let (prev_digest, _) = digests_of(hop);
1062 if let Some((previous_hop, previous_complete)) = previous {
1063 let previous_step = previous_hop.step_seq();
1064 let previous_digest = digests_of(previous_hop).1;
1065 match (prev_digest, previous_digest) {
1066 (Some(expected), Some(actual)) if expected != actual => broken.push(format!(
1067 "hop at step {} expects head {expected}, but its predecessor's digest is \
1068 {actual}",
1069 step.map_or("?".to_string(), |seq| seq.to_string()),
1070 )),
1071 (None, _) => unverifiable_links += 1,
1072 (_, None) => unverifiable_links += 1,
1073 _ => {}
1074 }
1075 match (step, previous_step) {
1076 (Some(step), Some(previous_step)) if step != previous_step + 1 => broken.push(
1077 format!("hop is step {step}, but its predecessor is step {previous_step}"),
1078 ),
1079 (Some(_), Some(_)) => {}
1080 _ => unverifiable_links += 1,
1081 }
1082 if let (Hop::Complete(record), Some(previous_record)) = (hop, previous_complete)
1085 && let Err(error) = record.verify_follows(Some(previous_record))
1086 {
1087 broken.push(format!("{}: {}", error.code().as_str(), error.message()));
1088 }
1089 } else if let Hop::Complete(record) = hop
1090 && let Err(error) = record.verify_follows(None)
1091 {
1092 broken.push(format!("{}: {}", error.code().as_str(), error.message()));
1093 }
1094 previous = Some((
1095 hop,
1096 match hop {
1097 Hop::Complete(record) => Some(record),
1098 Hop::Degraded(_) => None,
1099 },
1100 ));
1101 }
1102
1103 if !broken.is_empty() {
1104 return RuleReport {
1105 rule,
1106 verdict: Verdict::Fail,
1107 detail: broken.join("; "),
1108 };
1109 }
1110 RuleReport {
1111 rule,
1112 verdict: Verdict::Degraded,
1113 detail: format!(
1114 "partial chain: every surviving link verified, {unverifiable_links} link(s) \
1115 unverifiable across degraded hop(s)"
1116 ),
1117 }
1118}
1119
1120fn check_c2(hops: &[Hop]) -> RuleReport {
1122 let rule = "C2".to_string();
1123 let mut by_input: HashMap<&str, &str> = HashMap::new();
1124 let mut conflicts: Vec<String> = Vec::new();
1125 let mut retries = 0usize;
1126 let mut unverifiable = 0usize;
1127 for hop in hops {
1128 let (input_id, record_digest) = match hop {
1129 Hop::Complete(record) => (
1130 Some(record.input_id().as_str()),
1131 Some(record.record_digest().as_str()),
1132 ),
1133 Hop::Degraded(degraded) => (
1134 degraded.input_id.as_deref(),
1135 degraded.record_digest.as_deref(),
1136 ),
1137 };
1138 let Some(input_id) = input_id else { continue };
1139 let Some(digest) = record_digest else {
1140 unverifiable += 1;
1141 continue;
1142 };
1143 match by_input.get(input_id) {
1144 Some(existing) if *existing != digest => conflicts.push(format!(
1145 "input {input_id} produced two different records ({existing} and {digest}); a \
1146 retry must reach the same record"
1147 )),
1148 Some(_) => retries += 1,
1149 None => {
1150 by_input.insert(input_id, digest);
1151 }
1152 }
1153 }
1154 if !conflicts.is_empty() {
1155 return RuleReport {
1156 rule,
1157 verdict: Verdict::Fail,
1158 detail: conflicts.join("; "),
1159 };
1160 }
1161 if unverifiable > 0 {
1162 return RuleReport {
1163 rule,
1164 verdict: Verdict::Degraded,
1165 detail: format!(
1166 "{} unique input(s), {retries} idempotent retry hit(s); {unverifiable} degraded \
1167 hop(s) could not be compared",
1168 by_input.len(),
1169 ),
1170 };
1171 }
1172 RuleReport {
1173 rule,
1174 verdict: Verdict::Pass,
1175 detail: format!(
1176 "{} unique input(s), {retries} idempotent retry hit(s), no divergent duplicates",
1177 by_input.len(),
1178 ),
1179 }
1180}
1181
1182enum Replan {
1187 Unavailable(&'static str),
1189 Failed(String),
1191 Restored(RestoredOperation),
1192}
1193
1194fn replan_segment(hops: &[Hop], c1: &RuleReport) -> Replan {
1195 if hops.iter().any(|hop| matches!(hop, Hop::Degraded(_))) {
1196 return Replan::Unavailable(
1197 "re-plan requires complete records; this segment has degraded hops",
1198 );
1199 }
1200 if c1.verdict == Verdict::Fail {
1201 return Replan::Unavailable(
1202 "C1 failed; a re-plan over a broken chain would only re-report that break",
1203 );
1204 }
1205 let records: Vec<KernelRecord> = hops
1206 .iter()
1207 .filter_map(|hop| match hop {
1208 Hop::Complete(record) => Some(record.clone()),
1209 Hop::Degraded(_) => None,
1210 })
1211 .collect();
1212 if records.is_empty() {
1213 return Replan::Unavailable("no records in this segment");
1214 }
1215 match restore_operation(
1216 None,
1217 &records,
1218 ConfigDefaults::default(),
1219 InMemoryRecordIndex::from_records(&records),
1220 ) {
1221 Ok(restored) => Replan::Restored(restored),
1222 Err(fault) => Replan::Failed(format!("{}: {}", fault.code.as_str(), fault.message)),
1223 }
1224}
1225
1226fn render_c3(replan: &Replan) -> RuleReport {
1227 let rule = "C3".to_string();
1228 match replan {
1229 Replan::Unavailable(reason) => RuleReport {
1230 rule,
1231 verdict: Verdict::Degraded,
1232 detail: (*reason).to_string(),
1233 },
1234 Replan::Failed(fault) => RuleReport {
1235 rule,
1236 verdict: Verdict::Fail,
1237 detail: fault.clone(),
1238 },
1239 Replan::Restored(restored) => RuleReport {
1240 rule,
1241 verdict: Verdict::Pass,
1242 detail: format!(
1243 "re-planned {} record(s) from genesis; every durable record digest reproduced",
1244 restored.cost.records_before_checkpoint
1245 ),
1246 },
1247 }
1248}
1249
1250fn check_c4(hops: &[Hop], operation_id: &str) -> RuleReport {
1252 let rule = "C4".to_string();
1253 struct LaunchFact {
1254 task_id: String,
1255 attempt_id: String,
1256 step_seq: u64,
1257 effect_id: String,
1258 }
1259
1260 let mut launches: Vec<LaunchFact> = Vec::new();
1261 let mut unreadable_inputs = 0usize;
1262 for hop in hops {
1263 let Hop::Complete(record) = hop else { continue };
1264 let input = match record.normalized_input() {
1265 Ok(input) => input,
1266 Err(_) => {
1267 unreadable_inputs += 1;
1268 continue;
1269 }
1270 };
1271 let NormalizedPayload::ResolveEffect(resolve) = &input.input else {
1272 continue;
1273 };
1274 let EffectOutcome::Succeeded(success) = &resolve.outcome else {
1275 continue;
1276 };
1277 let EffectSuccess::TasksSpawned(spawned) = &success.result else {
1278 continue;
1279 };
1280 for attempt in &spawned.attempts {
1281 launches.push(LaunchFact {
1282 task_id: attempt.task_id.as_str().to_string(),
1283 attempt_id: attempt.attempt_id.as_str().to_string(),
1284 step_seq: record.step_seq().get(),
1285 effect_id: resolve.effect_id.as_str().to_string(),
1286 });
1287 }
1288 }
1289
1290 let mut violations: Vec<String> = Vec::new();
1291 let mut seen: HashMap<(&str, &str), u64> = HashMap::new();
1292 for fact in &launches {
1293 let pair = (fact.task_id.as_str(), fact.attempt_id.as_str());
1294 if let Some(first_step) = seen.insert(pair, fact.step_seq) {
1295 violations.push(format!(
1296 "task {} attempt {} launched at steps {first_step} and {}; the launch token is \
1297 derived from that pair, so a repeated pair is a reused LaunchToken",
1298 fact.task_id, fact.attempt_id, fact.step_seq,
1299 ));
1300 }
1301 match parse_effect_step(&fact.effect_id) {
1302 Some((effect_operation, effect_step)) => {
1303 if effect_operation != operation_id {
1304 violations.push(format!(
1305 "task {} launch at step {} resolves effect {} of another operation — \
1306 causation cannot cross operations",
1307 fact.task_id, fact.step_seq, fact.effect_id,
1308 ));
1309 } else if effect_step >= fact.step_seq {
1310 violations.push(format!(
1311 "task {} launch resolved at step {} names an effect published at step \
1312 {effect_step} — the resolution precedes the publication",
1313 fact.task_id, fact.step_seq,
1314 ));
1315 }
1316 }
1317 None => violations.push(format!(
1318 "task {} launch at step {} names effect {}, which is not in the \
1319 `operation:step:N:effect:M` vocabulary",
1320 fact.task_id, fact.step_seq, fact.effect_id,
1321 )),
1322 }
1323 }
1324
1325 if !violations.is_empty() {
1326 return RuleReport {
1327 rule,
1328 verdict: Verdict::Fail,
1329 detail: violations.join("; "),
1330 };
1331 }
1332 let degraded_hops = hops
1333 .iter()
1334 .filter(|hop| matches!(hop, Hop::Degraded(_)))
1335 .count();
1336 if degraded_hops > 0 || unreadable_inputs > 0 {
1337 return RuleReport {
1338 rule,
1339 verdict: Verdict::Degraded,
1340 detail: format!(
1341 "{} launch(es) checked; {degraded_hops} degraded hop(s) and \
1342 {unreadable_inputs} unreadable input(s) could hide further launches",
1343 launches.len(),
1344 ),
1345 };
1346 }
1347 RuleReport {
1348 rule,
1349 verdict: Verdict::Pass,
1350 detail: format!(
1351 "{} launch(es), every (task_id, attempt_id) pair unique, every spawn resolution \
1352 names an earlier step of this operation",
1353 launches.len(),
1354 ),
1355 }
1356}
1357
1358fn parse_effect_step(effect_id: &str) -> Option<(&str, u64)> {
1361 let (before_effect, _) = effect_id.rsplit_once(":effect:")?;
1362 let (operation, step) = before_effect.rsplit_once(":step:")?;
1363 Some((operation, step.parse().ok()?))
1364}
1365
1366fn digests_of(hop: &Hop) -> (Option<&str>, Option<&str>) {
1367 match hop {
1368 Hop::Complete(record) => (
1369 record
1370 .previous_record_digest()
1371 .map(|digest| digest.as_str()),
1372 Some(record.record_digest().as_str()),
1373 ),
1374 Hop::Degraded(degraded) => (
1375 degraded.previous_record_digest.as_deref(),
1376 degraded.record_digest.as_deref(),
1377 ),
1378 }
1379}
1380
1381#[cfg(test)]
1386mod tests {
1387 use serde_json::json;
1388
1389 use super::*;
1390 use crate::runtime::kernel::wire::config::{
1391 ConfigDefaults, ExecutionPolicy, HostEffectSupport, OperationConfig,
1392 };
1393 use crate::runtime::kernel::wire::driver::CanonicalOperationDriver;
1394 use crate::runtime::kernel::wire::effect::{
1395 EffectKindTag, EffectSucceeded, ProviderCompleted, ProviderContextOverflow,
1396 ProviderMessage, ProviderOutcome, ProviderSuccess, TaskLaunchOutcome, TaskLaunchStarted,
1397 TaskLaunchStatus, TasksSpawnedSuccess, ToolCall,
1398 };
1399 use crate::runtime::kernel::wire::envelope::{
1400 ConfigureOperation, KernelInput, ResolveEffect, StartOperation, WireEnvelope,
1401 };
1402 use crate::runtime::kernel::wire::record::{KernelRecord, NormalizedInput};
1403 use crate::runtime::kernel::wire::root::{
1404 InitialContext, LogicalAgentSpec, LogicalMessage, LogicalTask, MessageRole,
1405 RootAgentEntry, RootEntry, RootWorkflowEntry, WorkflowNode, WorkflowSpec,
1406 };
1407 use crate::runtime::kernel::wire::scalar::{
1408 AttemptId, BoundedJson, CallId, EffectId, InputId, NodeId, OperationId, TaskId, WireU64,
1409 };
1410 use crate::runtime::kernel::wire::transaction::{InMemoryRecordIndex, KernelTransaction};
1411
1412 fn operation(id: &str) -> OperationId {
1417 OperationId::new(id).unwrap()
1418 }
1419
1420 fn envelope(op: &OperationId, id: &str, at: u64, input: KernelInput) -> WireEnvelope {
1421 WireEnvelope::new(
1422 op.clone(),
1423 InputId::new(id).unwrap(),
1424 WireU64::new(at),
1425 input,
1426 )
1427 }
1428
1429 fn configure_envelope(op: &OperationId) -> WireEnvelope {
1430 envelope(
1431 op,
1432 "in-configure",
1433 1_700_000_000_000,
1434 KernelInput::ConfigureOperation(ConfigureOperation {
1435 config: OperationConfig {
1436 execution_policy: Some(ExecutionPolicy {
1437 max_turns: Some(12),
1438 ..ExecutionPolicy::default()
1439 }),
1440 host_effect_support: HostEffectSupport::new([
1441 EffectKindTag::CallProvider,
1442 EffectKindTag::SpawnTasks,
1443 ]),
1444 ..OperationConfig::default()
1445 },
1446 }),
1447 )
1448 }
1449
1450 fn agent_start_envelope(op: &OperationId) -> WireEnvelope {
1451 envelope(
1452 op,
1453 "in-start",
1454 1_700_000_001_000,
1455 KernelInput::StartOperation(StartOperation {
1456 entry: RootEntry::Agent(RootAgentEntry {
1457 task: LogicalTask::new("write the brief"),
1458 run_spec: Some(LogicalAgentSpec::new("write the brief")),
1459 }),
1460 initial_context: InitialContext::default(),
1461 }),
1462 )
1463 }
1464
1465 fn agent_start_with_history_envelope(op: &OperationId, messages: usize) -> WireEnvelope {
1469 envelope(
1470 op,
1471 "in-start",
1472 1_700_000_001_000,
1473 KernelInput::StartOperation(StartOperation {
1474 entry: RootEntry::Agent(RootAgentEntry {
1475 task: LogicalTask::new("write the brief"),
1476 run_spec: Some(LogicalAgentSpec::new("write the brief")),
1477 }),
1478 initial_context: InitialContext {
1479 messages: (0..messages)
1480 .map(|index| LogicalMessage {
1481 role: if index % 2 == 0 {
1482 MessageRole::User
1483 } else {
1484 MessageRole::Assistant
1485 },
1486 content: format!(
1487 "turn {index}: a long enough body that compaction has \
1488 something to reclaim when the prompt stops fitting"
1489 ),
1490 tokens: Some(64),
1491 tool_call_id: None,
1492 })
1493 .collect(),
1494 ..InitialContext::default()
1495 },
1496 }),
1497 )
1498 }
1499
1500 fn workflow_start_envelope(op: &OperationId) -> WireEnvelope {
1501 envelope(
1502 op,
1503 "in-start",
1504 1_700_000_001_000,
1505 KernelInput::StartOperation(StartOperation {
1506 entry: RootEntry::Workflow(RootWorkflowEntry {
1507 spec: WorkflowSpec {
1508 name: "brief".to_string(),
1509 nodes: vec![
1510 WorkflowNode {
1511 node_id: NodeId::new("collect").unwrap(),
1512 task: LogicalTask::new("collect the sources"),
1513 depends_on: vec![],
1514 run_spec: Some(LogicalAgentSpec::new("collect the sources")),
1515 },
1516 WorkflowNode {
1517 node_id: NodeId::new("write").unwrap(),
1518 task: LogicalTask::new("write the brief"),
1519 depends_on: vec![NodeId::new("collect").unwrap()],
1520 run_spec: Some(LogicalAgentSpec::new("write the brief")),
1521 },
1522 ],
1523 },
1524 }),
1525 initial_context: InitialContext::default(),
1526 }),
1527 )
1528 }
1529
1530 fn resolve_overflow_envelope(op: &OperationId, effect_step: u64) -> WireEnvelope {
1531 envelope(
1532 op,
1533 "in-resolve",
1534 1_700_000_002_000,
1535 KernelInput::ResolveEffect(ResolveEffect {
1536 effect_id: EffectId::new(format!("{op}:step:{effect_step}:effect:0")).unwrap(),
1537 outcome: EffectOutcome::Succeeded(EffectSucceeded {
1538 result: EffectSuccess::Provider(ProviderSuccess {
1539 outcome: ProviderOutcome::ContextOverflow(
1540 ProviderContextOverflow::default(),
1541 ),
1542 }),
1543 }),
1544 }),
1545 )
1546 }
1547
1548 fn resolve_completed_envelope(
1551 op: &OperationId,
1552 id: &str,
1553 at: u64,
1554 effect_step: u64,
1555 with_tool_call: bool,
1556 ) -> WireEnvelope {
1557 envelope(
1558 op,
1559 id,
1560 at,
1561 KernelInput::ResolveEffect(ResolveEffect {
1562 effect_id: EffectId::new(format!("{op}:step:{effect_step}:effect:0")).unwrap(),
1563 outcome: EffectOutcome::Succeeded(EffectSucceeded {
1564 result: EffectSuccess::Provider(ProviderSuccess {
1565 outcome: ProviderOutcome::Completed(ProviderCompleted {
1566 message: ProviderMessage {
1567 role: MessageRole::Assistant,
1568 content: "done".to_string(),
1569 tool_calls: if with_tool_call {
1570 vec![ToolCall {
1571 call_id: CallId::new("call-1").unwrap(),
1572 name: "read_file".to_string(),
1573 arguments: BoundedJson::new(json!({})).unwrap(),
1574 }]
1575 } else {
1576 Vec::new()
1577 },
1578 tool_call_id: None,
1579 tokens: None,
1580 },
1581 observed_input_tokens: None,
1582 observed_output_tokens: None,
1583 stop_reason: None,
1584 }),
1585 }),
1586 }),
1587 }),
1588 )
1589 }
1590
1591 fn resolve_spawn_envelope(
1592 op: &OperationId,
1593 id: &str,
1594 at: u64,
1595 effect_id: &str,
1596 tasks: &[(&str, &str)],
1597 ) -> WireEnvelope {
1598 envelope(
1599 op,
1600 id,
1601 at,
1602 KernelInput::ResolveEffect(ResolveEffect {
1603 effect_id: EffectId::new(effect_id).unwrap(),
1604 outcome: EffectOutcome::Succeeded(EffectSucceeded {
1605 result: EffectSuccess::TasksSpawned(TasksSpawnedSuccess {
1606 attempts: tasks
1607 .iter()
1608 .map(|(task, attempt)| TaskLaunchOutcome {
1609 task_id: TaskId::new(*task).unwrap(),
1610 attempt_id: AttemptId::new(*attempt).unwrap(),
1611 outcome: TaskLaunchStatus::Started(TaskLaunchStarted {}),
1612 })
1613 .collect(),
1614 }),
1615 }),
1616 }),
1617 )
1618 }
1619
1620 fn live_chain(envelopes: &[WireEnvelope]) -> Vec<KernelRecord> {
1627 let mut tx = KernelTransaction::new(ConfigDefaults::default(), InMemoryRecordIndex::new());
1628 let mut driver = CanonicalOperationDriver::new();
1629 let mut journal = Vec::new();
1630 for envelope in envelopes {
1631 let preparation = tx.prepare(envelope, |context| driver.plan(context));
1632 let token = preparation
1633 .token()
1634 .unwrap_or_else(|| {
1635 panic!("expected a prepared step, got {:?}", preparation.fault())
1636 })
1637 .clone();
1638 let head = preparation.record().unwrap().record_digest().clone();
1639 let committed = tx.commit(&token, &head).expect("commit must succeed");
1640 journal.push(committed.record.clone());
1641 driver
1642 .note_committed(committed.step_seq)
1643 .expect("the driver folds the step it planned");
1644 }
1645 journal
1646 }
1647
1648 fn hand_chain(envelopes: &[WireEnvelope]) -> Vec<KernelRecord> {
1652 let mut records: Vec<KernelRecord> = Vec::new();
1653 for (index, envelope) in envelopes.iter().enumerate() {
1654 let input = NormalizedInput::normalize(envelope, &ConfigDefaults::default())
1655 .expect("the envelope normalises");
1656 let step = json!({ "planned": format!("step-{index}"), "effects": [] });
1657 let record =
1658 KernelRecord::chain(records.last(), &input, &step).expect("the record chains");
1659 records.push(record);
1660 }
1661 records
1662 }
1663
1664 fn blobs(records: &[KernelRecord]) -> Vec<Vec<u8>> {
1665 records
1666 .iter()
1667 .map(|record| record.record_bytes().into_vec())
1668 .collect()
1669 }
1670
1671 fn rule<'a>(report: &'a ValidationReport, segment: usize, id: &str) -> &'a RuleReport {
1672 report.segments[segment]
1673 .rules
1674 .iter()
1675 .find(|rule| rule.rule == id)
1676 .unwrap_or_else(|| panic!("segment {segment} has no {id} verdict"))
1677 }
1678
1679 fn cross<'a>(report: &'a ValidationReport, id: &str) -> &'a RuleReport {
1680 report
1681 .cross_checks
1682 .iter()
1683 .find(|rule| rule.rule == id)
1684 .unwrap_or_else(|| panic!("the report has no {id} cross-check"))
1685 }
1686
1687 #[test]
1692 fn a_green_agent_chain_passes_every_rule() {
1693 let op = operation("op-green-agent");
1694 let chain = live_chain(&[
1695 configure_envelope(&op),
1696 agent_start_envelope(&op),
1697 resolve_overflow_envelope(&op, 1),
1698 ]);
1699 let report = validate_journal(&blobs(&chain));
1700 assert_eq!(report.segments.len(), 1);
1701 for id in ["C1", "C2", "C3", "C4"] {
1702 assert_eq!(
1703 rule(&report, 0, id).verdict,
1704 Verdict::Pass,
1705 "{id}: {}",
1706 rule(&report, 0, id).detail
1707 );
1708 }
1709 assert!(
1710 rule(&report, 0, "C3")
1711 .detail
1712 .contains("every durable record digest reproduced"),
1713 "C3 proves the re-plan: {}",
1714 rule(&report, 0, "C3").detail
1715 );
1716 assert_eq!(report.exit_code(), 0);
1717 assert_eq!(report.unparseable_records, 0);
1718 }
1719
1720 #[test]
1721 fn a_green_workflow_chain_passes_c4_with_real_launches() {
1722 let op = operation("op-green-workflow");
1723 let chain = live_chain(&[
1724 configure_envelope(&op),
1725 workflow_start_envelope(&op),
1726 resolve_spawn_envelope(
1727 &op,
1728 "in-ack-1",
1729 1_700_000_002_000,
1730 "op-green-workflow:step:1:effect:0",
1731 &[("wf-node0", "wf-node0:attempt:1")],
1732 ),
1733 ]);
1734 let report = validate_journal(&blobs(&chain));
1735 assert_eq!(report.segments.len(), 1);
1736 for id in ["C1", "C2", "C3", "C4"] {
1737 assert_eq!(
1738 rule(&report, 0, id).verdict,
1739 Verdict::Pass,
1740 "{id}: {}",
1741 rule(&report, 0, id).detail
1742 );
1743 }
1744 assert!(
1745 rule(&report, 0, "C4").detail.contains("1 launch(es)"),
1746 "{}",
1747 rule(&report, 0, "C4").detail
1748 );
1749 assert_eq!(report.deferred.len(), 2, "batch-1 scope limits are named");
1750 assert_eq!(report.exit_code(), 0);
1751 }
1752
1753 #[test]
1754 fn input_order_is_a_storage_detail() {
1755 let op = operation("op-shuffled");
1756 let chain = live_chain(&[
1757 configure_envelope(&op),
1758 agent_start_envelope(&op),
1759 resolve_overflow_envelope(&op, 1),
1760 ]);
1761 let mut shuffled = blobs(&chain);
1762 shuffled.reverse();
1763 let report = validate_journal(&shuffled);
1764 assert_eq!(
1765 report.exit_code(),
1766 0,
1767 "the chain's own links define the order"
1768 );
1769 }
1770
1771 #[test]
1772 fn two_operations_validate_as_independent_segments() {
1773 let op_a = operation("op-seg-a");
1774 let op_b = operation("op-seg-b");
1775 let chain_a = live_chain(&[configure_envelope(&op_a), agent_start_envelope(&op_a)]);
1776 let chain_b = live_chain(&[configure_envelope(&op_b), agent_start_envelope(&op_b)]);
1777 let mut mixed = Vec::new();
1779 for index in 0..2 {
1780 mixed.push(chain_a[index].record_bytes().into_vec());
1781 mixed.push(chain_b[index].record_bytes().into_vec());
1782 }
1783 let report = validate_journal(&mixed);
1784 assert_eq!(report.segments.len(), 2);
1785 assert_eq!(report.exit_code(), 0);
1786 }
1787
1788 #[test]
1793 fn a_gap_in_the_chain_fails_c1_and_degrades_c3() {
1794 let op = operation("op-gapped");
1795 let chain = live_chain(&[
1796 configure_envelope(&op),
1797 agent_start_envelope(&op),
1798 resolve_overflow_envelope(&op, 1),
1799 ]);
1800 let gapped = blobs(&[chain[0].clone(), chain[2].clone()]);
1801 let report = validate_journal(&gapped);
1802 assert_eq!(rule(&report, 0, "C1").verdict, Verdict::Fail);
1803 assert_eq!(
1804 rule(&report, 0, "C3").verdict,
1805 Verdict::Degraded,
1806 "a re-plan over a broken chain would only re-report the C1 break"
1807 );
1808 assert_eq!(report.exit_code(), 1);
1809 }
1810
1811 #[test]
1816 fn two_different_records_for_one_input_fail_c2() {
1817 let op = operation("op-dup-input");
1818 let chain_a = hand_chain(&[configure_envelope(&op), agent_start_envelope(&op)]);
1821 let mut later_start = agent_start_envelope(&op);
1822 later_start.observed_at_ms = WireU64::new(1_700_000_001_500);
1823 let chain_b = hand_chain(&[configure_envelope(&op), later_start]);
1824 assert_ne!(
1825 chain_a[1].record_digest(),
1826 chain_b[1].record_digest(),
1827 "the fixture must produce two different records for one input id"
1828 );
1829 let report = validate_journal(&blobs(&[
1830 chain_a[0].clone(),
1831 chain_a[1].clone(),
1832 chain_b[1].clone(),
1833 ]));
1834 assert_eq!(rule(&report, 0, "C2").verdict, Verdict::Fail);
1835 assert!(
1836 rule(&report, 0, "C2").detail.contains("in-start"),
1837 "{}",
1838 rule(&report, 0, "C2").detail
1839 );
1840 assert_eq!(report.exit_code(), 1);
1841 }
1842
1843 #[test]
1848 fn a_repeated_attempt_pair_is_a_reused_launch_token() {
1849 let op = operation("op-dup-launch");
1850 let chain = hand_chain(&[
1851 configure_envelope(&op),
1852 resolve_spawn_envelope(
1853 &op,
1854 "in-ack-1",
1855 1_700_000_001_000,
1856 "op-dup-launch:step:0:effect:0",
1857 &[("writer", "writer:attempt:1")],
1858 ),
1859 resolve_spawn_envelope(
1860 &op,
1861 "in-ack-2",
1862 1_700_000_002_000,
1863 "op-dup-launch:step:0:effect:0",
1864 &[("writer", "writer:attempt:1")],
1865 ),
1866 ]);
1867 let report = validate_journal(&blobs(&chain));
1868 assert_eq!(rule(&report, 0, "C1").verdict, Verdict::Pass);
1869 assert_eq!(rule(&report, 0, "C4").verdict, Verdict::Fail);
1870 assert!(
1871 rule(&report, 0, "C4").detail.contains("LaunchToken"),
1872 "the verdict names the token reuse: {}",
1873 rule(&report, 0, "C4").detail
1874 );
1875 assert_eq!(report.exit_code(), 1);
1876 }
1877
1878 #[test]
1879 fn a_resolution_naming_a_future_step_fails_c4() {
1880 let op = operation("op-future-effect");
1881 let chain = hand_chain(&[
1882 configure_envelope(&op),
1883 resolve_spawn_envelope(
1884 &op,
1885 "in-ack-1",
1886 1_700_000_001_000,
1887 "op-future-effect:step:5:effect:0",
1888 &[("writer", "writer:attempt:1")],
1889 ),
1890 ]);
1891 let report = validate_journal(&blobs(&chain));
1892 assert_eq!(rule(&report, 0, "C4").verdict, Verdict::Fail);
1893 assert!(
1894 rule(&report, 0, "C4")
1895 .detail
1896 .contains("precedes the publication"),
1897 "{}",
1898 rule(&report, 0, "C4").detail
1899 );
1900 }
1901
1902 #[test]
1903 fn a_resolution_naming_another_operation_fails_c4() {
1904 let op = operation("op-foreign-effect");
1905 let chain = hand_chain(&[
1906 configure_envelope(&op),
1907 resolve_spawn_envelope(
1908 &op,
1909 "in-ack-1",
1910 1_700_000_001_000,
1911 "op-somewhere-else:step:0:effect:0",
1912 &[("writer", "writer:attempt:1")],
1913 ),
1914 ]);
1915 let report = validate_journal(&blobs(&chain));
1916 assert_eq!(rule(&report, 0, "C4").verdict, Verdict::Fail);
1917 assert!(
1918 rule(&report, 0, "C4").detail.contains("another operation"),
1919 "{}",
1920 rule(&report, 0, "C4").detail
1921 );
1922 }
1923
1924 #[test]
1929 fn a_tampered_hop_fails_integrity_validation() {
1930 let op = operation("op-tampered");
1931 let chain = live_chain(&[
1932 configure_envelope(&op),
1933 agent_start_envelope(&op),
1934 resolve_overflow_envelope(&op, 1),
1935 ]);
1936 let mut input = blobs(&chain);
1937 let mut forged: serde_json::Value = serde_json::from_slice(&input[1]).unwrap();
1940 forged["step_digest"] = serde_json::Value::String(chain[0].record_digest().to_string());
1941 input[1] = serde_json::to_vec(&forged).unwrap();
1942
1943 let report = validate_journal(&input);
1944 assert_eq!(report.segments.len(), 1);
1945 assert_eq!(report.segments[0].degraded_hops.len(), 1);
1946 assert_eq!(
1947 rule(&report, 0, "C1").verdict,
1948 Verdict::Fail,
1949 "a digest mismatch must fail C1: {}",
1950 rule(&report, 0, "C1").detail
1951 );
1952 assert_eq!(rule(&report, 0, "C3").verdict, Verdict::Degraded);
1953 assert_eq!(rule(&report, 0, "C4").verdict, Verdict::Degraded);
1954 assert_eq!(
1955 report.exit_code(),
1956 1,
1957 "proven digest corruption must fail the validator"
1958 );
1959 }
1960
1961 #[test]
1962 fn missing_legacy_digest_degrades_without_claiming_corruption() {
1963 let op = operation("op-legacy");
1964 let chain = live_chain(&[configure_envelope(&op)]);
1965 let mut legacy: serde_json::Value = serde_json::from_slice(&blobs(&chain)[0]).unwrap();
1966 legacy.as_object_mut().unwrap().remove("step_digest");
1967 let report = validate_journal(&[serde_json::to_vec(&legacy).unwrap()]);
1968 assert_eq!(rule(&report, 0, "C1").verdict, Verdict::Degraded);
1969 assert_eq!(report.exit_code(), 0);
1970 }
1971
1972 #[test]
1973 fn unparseable_input_is_evidence_insufficient_not_guilty() {
1974 let report = validate_journal(&[b"this is not a record".to_vec()]);
1975 assert!(report.segments.is_empty());
1976 assert_eq!(report.unparseable_records, 1);
1977 assert_eq!(report.exit_code(), 2);
1978 }
1979
1980 #[test]
1981 fn garbage_beside_a_green_chain_stays_exit_2_without_a_violation() {
1982 let op = operation("op-plus-garbage");
1983 let chain = live_chain(&[configure_envelope(&op), agent_start_envelope(&op)]);
1984 let mut input = blobs(&chain);
1985 input.push(b"this is not a record".to_vec());
1986 let report = validate_journal(&input);
1987 assert_eq!(report.segments.len(), 1);
1988 assert_eq!(rule(&report, 0, "C1").verdict, Verdict::Pass);
1989 assert_eq!(report.unparseable_records, 1);
1990 assert_eq!(
1991 report.exit_code(),
1992 2,
1993 "no violation was proven, but the evidence was partially unreadable"
1994 );
1995 }
1996
1997 #[test]
1998 fn an_empty_journal_is_evidence_insufficient() {
1999 let report = validate_journal::<Vec<u8>>(&[]);
2000 assert_eq!(report.exit_code(), 2);
2001 }
2002
2003 fn session_event(value: serde_json::Value) -> Vec<u8> {
2008 serde_json::to_vec(&value).unwrap()
2009 }
2010
2011 #[test]
2012 fn session_events_classify_leniently_across_host_spellings() {
2013 let node_attempt = session_event(json!({
2014 "kind": "provider_attempt",
2015 "effect_id": "op:step:1:effect:0",
2016 "request_fingerprint": "fp-1",
2017 "route": { "routeId": "route-a", "provider": "p" },
2018 "status": "success"
2019 }));
2020 let py_attempt = session_event(json!({
2021 "kind": "provider_attempt",
2022 "effect_id": "op:step:2:effect:0",
2023 "route": { "route_id": "route-b" }
2024 }));
2025 let run_started = session_event(json!({
2026 "kind": "run_started",
2027 "run_id": "run-1",
2028 "route": { "routeId": "route-a" }
2029 }));
2030 let node_measured = session_event(json!({
2031 "kind": "prompt_measured",
2032 "turn": 1,
2033 "effect_id": "op:step:1:effect:0",
2034 "measurement": { "requestFingerprint": "fp-1", "inputTokens": 10 }
2035 }));
2036 let py_measured = session_event(json!({
2037 "kind": "prompt_measured",
2038 "measurement": { "request_fingerprint": "fp-2" }
2039 }));
2040 let llm_completed = session_event(json!({
2041 "kind": "llm_completed",
2042 "effect_id": "op:step:2:effect:0",
2043 "invocation_id": "op:step:1:effect:0"
2044 }));
2045 let unknown_kind = session_event(json!({ "kind": "compressed", "turn": 3 }));
2046 let kindless = session_event(json!({ "turn": 3 }));
2047
2048 assert_eq!(
2049 classify_session_event(&node_attempt),
2050 Some(EvidenceEvent::ProviderAttempt {
2051 effect_id: Some("op:step:1:effect:0".to_string()),
2052 request_fingerprint: Some("fp-1".to_string()),
2053 route_id: Some("route-a".to_string()),
2054 status: Some("success".to_string()),
2055 })
2056 );
2057 assert_eq!(
2058 classify_session_event(&py_attempt),
2059 Some(EvidenceEvent::ProviderAttempt {
2060 effect_id: Some("op:step:2:effect:0".to_string()),
2061 request_fingerprint: None,
2062 route_id: Some("route-b".to_string()),
2063 status: None,
2064 })
2065 );
2066 assert_eq!(
2067 classify_session_event(&run_started),
2068 Some(EvidenceEvent::RunStarted {
2069 route_id: Some("route-a".to_string())
2070 })
2071 );
2072 assert_eq!(
2073 classify_session_event(&node_measured),
2074 Some(EvidenceEvent::PromptMeasured {
2075 effect_id: Some("op:step:1:effect:0".to_string()),
2076 request_fingerprint: Some("fp-1".to_string()),
2077 })
2078 );
2079 assert_eq!(
2080 classify_session_event(&py_measured),
2081 Some(EvidenceEvent::PromptMeasured {
2082 effect_id: None,
2083 request_fingerprint: Some("fp-2".to_string()),
2084 })
2085 );
2086 assert_eq!(
2087 classify_session_event(&llm_completed),
2088 Some(EvidenceEvent::LlmCompleted {
2089 effect_id: Some("op:step:2:effect:0".to_string()),
2090 invocation_id: Some("op:step:1:effect:0".to_string()),
2091 })
2092 );
2093 assert_eq!(
2094 classify_session_event(&unknown_kind),
2095 Some(EvidenceEvent::Other),
2096 "unknown kinds are parseable but ignored — the vocabulary evolves"
2097 );
2098 assert_eq!(classify_session_event(&kindless), Some(EvidenceEvent::Other));
2099 assert_eq!(
2100 classify_session_event(b"not json"),
2101 None,
2102 "a non-object event blob is unparseable input, never a violation"
2103 );
2104 }
2105
2106 #[test]
2107 fn dual_input_with_a_green_journal_and_real_events_stays_green() {
2108 let op = operation("op-dual-green");
2109 let chain = live_chain(&[configure_envelope(&op), agent_start_envelope(&op)]);
2110 let stream = vec![
2111 session_event(json!({
2112 "kind": "run_started",
2113 "run_id": "r1",
2114 "route": { "routeId": "route-a" }
2115 })),
2116 session_event(json!({
2117 "kind": "prompt_measured",
2118 "turn": 1,
2119 "effect_id": "op-dual-green:step:1:effect:0",
2120 "measurement": { "requestFingerprint": "fp-1", "inputTokens": 10 }
2121 })),
2122 session_event(json!({
2123 "kind": "provider_attempt",
2124 "effect_id": "op-dual-green:step:1:effect:0",
2125 "request_fingerprint": "fp-1",
2126 "route": { "routeId": "route-a" },
2127 "status": "success"
2128 })),
2129 session_event(json!({
2130 "kind": "llm_completed",
2131 "turn": 1,
2132 "effect_id": "op-dual-green:step:1:effect:0",
2133 "invocation_id": "op-dual-green:step:1:effect:0"
2134 })),
2135 ];
2136 let report = validate_with_session_log(&blobs(&chain), &[stream]);
2137 assert_eq!(report.session_events, Some(4));
2138 assert_eq!(report.unparseable_events, 0);
2139 for id in ["C6.1", "C6.2", "C6.3", "C8"] {
2140 assert_eq!(
2141 cross(&report, id).verdict,
2142 Verdict::Pass,
2143 "{id}: {}",
2144 cross(&report, id).detail
2145 );
2146 }
2147 assert!(
2148 !report.deferred.iter().any(|line| line.starts_with("c6.") || line.starts_with("c8.")),
2149 "C6/C8 are implemented — the interim scope notes are gone"
2150 );
2151 assert_eq!(report.exit_code(), 0);
2152 }
2153
2154 #[test]
2155 fn journal_only_validation_carries_no_session_plane() {
2156 let op = operation("op-journal-only");
2157 let chain = live_chain(&[configure_envelope(&op), agent_start_envelope(&op)]);
2158 let report = validate_journal(&blobs(&chain));
2159 assert_eq!(report.session_events, None);
2160 assert_eq!(report.unparseable_events, 0);
2161 assert_eq!(report.deferred.len(), 2, "batch-1 deferred scope is unchanged");
2162 assert_eq!(report.exit_code(), 0);
2163 }
2164
2165 #[test]
2166 fn an_empty_session_plane_is_evidence_insufficient() {
2167 let op = operation("op-empty-session");
2168 let chain = live_chain(&[configure_envelope(&op), agent_start_envelope(&op)]);
2169 let report = validate_with_session_log(&blobs(&chain), &[Vec::<Vec<u8>>::new()]);
2170 assert_eq!(report.session_events, Some(0));
2171 assert!(!report.has_violations(), "an empty log proves nothing either way");
2172 assert_eq!(report.exit_code(), 2);
2173 }
2174
2175 #[test]
2176 fn garbage_session_events_are_evidence_insufficient_not_guilty() {
2177 let op = operation("op-garbage-session");
2178 let chain = live_chain(&[configure_envelope(&op), agent_start_envelope(&op)]);
2179 let stream = vec![
2180 session_event(json!({ "kind": "run_started", "run_id": "r1" })),
2181 b"this is not an event".to_vec(),
2182 ];
2183 let report = validate_with_session_log(&blobs(&chain), &[stream]);
2184 assert_eq!(report.session_events, Some(1));
2185 assert_eq!(report.unparseable_events, 1);
2186 assert!(!report.has_violations());
2187 assert_eq!(report.exit_code(), 2);
2188 }
2189
2190 fn honest_dual_input(op_name: &str) -> (Vec<KernelRecord>, Vec<Vec<u8>>) {
2197 let op = operation(op_name);
2198 let chain = live_chain(&[
2199 configure_envelope(&op),
2200 agent_start_envelope(&op),
2201 resolve_overflow_envelope(&op, 1),
2202 ]);
2203 let effect = format!("{op_name}:step:1:effect:0");
2204 let stream = vec![
2205 session_event(json!({
2206 "kind": "run_started",
2207 "run_id": "r1",
2208 "route": { "routeId": "route-a" }
2209 })),
2210 session_event(json!({
2211 "kind": "prompt_measured",
2212 "turn": 1,
2213 "effect_id": effect,
2214 "measurement": { "requestFingerprint": "fp-1", "inputTokens": 10 }
2215 })),
2216 session_event(json!({
2217 "kind": "provider_attempt",
2218 "effect_id": effect,
2219 "request_fingerprint": "fp-1",
2220 "route": { "routeId": "route-a" },
2221 "status": "success"
2222 })),
2223 ];
2224 (chain, stream)
2225 }
2226
2227 #[test]
2228 fn an_attempt_naming_an_effect_the_replan_never_published_fails_c6() {
2229 let (chain, mut stream) = honest_dual_input("op-forged-effect");
2230 stream[2] = session_event(json!({
2231 "kind": "provider_attempt",
2232 "effect_id": "op-forged-effect:step:1:effect:7",
2233 "request_fingerprint": "fp-1",
2234 "route": { "routeId": "route-a" },
2235 "status": "success"
2236 }));
2237 let report = validate_with_session_log(&blobs(&chain), &[stream]);
2238 assert_eq!(cross(&report, "C6.1").verdict, Verdict::Fail);
2239 assert!(
2240 cross(&report, "C6.1").detail.contains("never published"),
2241 "{}",
2242 cross(&report, "C6.1").detail
2243 );
2244 assert_eq!(report.exit_code(), 1, "the forged attempt turns the run red");
2245 }
2246
2247 #[test]
2248 fn an_attempt_without_an_effect_id_fails_c6_as_forged_evidence() {
2249 let (chain, mut stream) = honest_dual_input("op-keyless-attempt");
2250 stream[2] = session_event(json!({
2251 "kind": "provider_attempt",
2252 "request_fingerprint": "fp-1",
2253 "route": { "routeId": "route-a" },
2254 "status": "success"
2255 }));
2256 let report = validate_with_session_log(&blobs(&chain), &[stream]);
2257 assert_eq!(cross(&report, "C6.1").verdict, Verdict::Fail);
2258 assert!(
2259 cross(&report, "C6.1").detail.contains("without effect_id"),
2260 "{}",
2261 cross(&report, "C6.1").detail
2262 );
2263 assert_eq!(report.exit_code(), 1);
2264 }
2265
2266 #[test]
2267 fn an_attempt_past_the_journal_tip_degrades_c6_instead_of_failing() {
2268 let (chain, mut stream) = honest_dual_input("op-prefix-attempt");
2269 stream[2] = session_event(json!({
2270 "kind": "provider_attempt",
2271 "effect_id": "op-prefix-attempt:step:9:effect:0",
2272 "request_fingerprint": "fp-1",
2273 "route": { "routeId": "route-a" },
2274 "status": "success"
2275 }));
2276 let report = validate_with_session_log(&blobs(&chain), &[stream]);
2277 assert_eq!(
2278 cross(&report, "C6.1").verdict,
2279 Verdict::Degraded,
2280 "a journal prefix cannot disprove an effect past its tip: {}",
2281 cross(&report, "C6.1").detail
2282 );
2283 assert_eq!(report.exit_code(), 0, "degradation never turns the run red");
2284 }
2285
2286 #[test]
2287 fn an_attempt_on_an_operation_without_a_segment_degrades_c6() {
2288 let (chain, mut stream) = honest_dual_input("op-subset-journal");
2289 stream[2] = session_event(json!({
2290 "kind": "provider_attempt",
2291 "effect_id": "op-elsewhere:step:1:effect:0",
2292 "request_fingerprint": "fp-1",
2293 "route": { "routeId": "route-a" },
2294 "status": "success"
2295 }));
2296 let report = validate_with_session_log(&blobs(&chain), &[stream]);
2297 assert_eq!(cross(&report, "C6.1").verdict, Verdict::Degraded);
2298 assert!(
2299 cross(&report, "C6.1").detail.contains("no journal segment"),
2300 "{}",
2301 cross(&report, "C6.1").detail
2302 );
2303 }
2304
2305 #[test]
2306 fn an_orphan_fingerprint_fails_c6() {
2307 let (chain, mut stream) = honest_dual_input("op-orphan-fp");
2308 stream.remove(1); let report = validate_with_session_log(&blobs(&chain), &[stream]);
2310 assert_eq!(cross(&report, "C6.2").verdict, Verdict::Fail);
2311 assert!(
2312 cross(&report, "C6.2").detail.contains("fp-1"),
2313 "{}",
2314 cross(&report, "C6.2").detail
2315 );
2316 assert_eq!(report.exit_code(), 1);
2317 }
2318
2319 #[test]
2320 fn an_attempt_without_a_fingerprint_fails_c6() {
2321 let (chain, mut stream) = honest_dual_input("op-fpless-attempt");
2322 stream[2] = session_event(json!({
2323 "kind": "provider_attempt",
2324 "effect_id": "op-fpless-attempt:step:1:effect:0",
2325 "route": { "routeId": "route-a" },
2326 "status": "success"
2327 }));
2328 let report = validate_with_session_log(&blobs(&chain), &[stream]);
2329 assert_eq!(cross(&report, "C6.2").verdict, Verdict::Fail);
2330 assert!(
2331 cross(&report, "C6.2")
2332 .detail
2333 .contains("without request_fingerprint"),
2334 "{}",
2335 cross(&report, "C6.2").detail
2336 );
2337 }
2338
2339 #[test]
2340 fn an_in_run_route_change_fails_c6() {
2341 let (chain, mut stream) = honest_dual_input("op-route-flip");
2342 stream[2] = session_event(json!({
2343 "kind": "provider_attempt",
2344 "effect_id": "op-route-flip:step:1:effect:0",
2345 "request_fingerprint": "fp-1",
2346 "route": { "routeId": "route-b" },
2347 "status": "success"
2348 }));
2349 let report = validate_with_session_log(&blobs(&chain), &[stream]);
2350 assert_eq!(cross(&report, "C6.3").verdict, Verdict::Fail);
2351 assert!(
2352 cross(&report, "C6.3").detail.contains("in-run route change"),
2353 "{}",
2354 cross(&report, "C6.3").detail
2355 );
2356 assert_eq!(report.exit_code(), 1);
2357 }
2358
2359 #[test]
2360 fn a_cross_resume_route_change_degrades_c6_per_q3() {
2361 let (chain, mut stream) = honest_dual_input("op-route-resume");
2362 stream.push(session_event(json!({
2365 "kind": "run_started",
2366 "run_id": "r1",
2367 "route": { "routeId": "route-b" }
2368 })));
2369 stream.push(session_event(json!({
2370 "kind": "prompt_measured",
2371 "turn": 2,
2372 "effect_id": "op-route-resume:step:1:effect:0",
2373 "measurement": { "requestFingerprint": "fp-2", "inputTokens": 11 }
2374 })));
2375 stream.push(session_event(json!({
2376 "kind": "provider_attempt",
2377 "effect_id": "op-route-resume:step:1:effect:0",
2378 "request_fingerprint": "fp-2",
2379 "route": { "routeId": "route-b" },
2380 "status": "success"
2381 })));
2382 let report = validate_with_session_log(&blobs(&chain), &[stream]);
2383 assert_eq!(
2384 cross(&report, "C6.3").verdict,
2385 Verdict::Degraded,
2386 "cross-resume route changes mark, they do not fail: {}",
2387 cross(&report, "C6.3").detail
2388 );
2389 assert!(
2390 cross(&report, "C6.3").detail.contains("cross-resume"),
2391 "{}",
2392 cross(&report, "C6.3").detail
2393 );
2394 assert_eq!(report.exit_code(), 0);
2395 }
2396
2397 #[test]
2398 fn a_pre_0_2_63_log_without_attempts_degrades_every_c6_clause() {
2399 let op = operation("op-old-log");
2400 let chain = live_chain(&[configure_envelope(&op), agent_start_envelope(&op)]);
2401 let stream = vec![
2402 session_event(json!({ "kind": "run_started", "run_id": "r1" })),
2403 session_event(json!({ "kind": "llm_completed", "turn": 1, "content": "done" })),
2404 ];
2405 let report = validate_with_session_log(&blobs(&chain), &[stream]);
2406 for id in ["C6.1", "C6.2", "C6.3", "C8"] {
2407 assert_eq!(
2408 cross(&report, id).verdict,
2409 Verdict::Degraded,
2410 "{id}: old logs degrade (C7), never fail — {}",
2411 cross(&report, id).detail
2412 );
2413 }
2414 assert_eq!(report.exit_code(), 0);
2415 }
2416
2417 fn overflow_retry_chain(op_name: &str) -> Vec<KernelRecord> {
2427 let op = operation(op_name);
2428 live_chain(&[
2429 configure_envelope(&op),
2430 agent_start_with_history_envelope(&op, 14),
2431 resolve_overflow_envelope(&op, 1),
2432 resolve_completed_envelope(&op, "in-resolve-2", 1_700_000_003_000, 2, false),
2433 ])
2434 }
2435
2436 #[test]
2437 fn an_honest_overflow_retry_invocation_passes_c8() {
2438 let chain = overflow_retry_chain("op-c8-green");
2439 let stream = vec![
2440 session_event(json!({ "kind": "run_started", "run_id": "r1" })),
2441 session_event(json!({
2442 "kind": "llm_completed",
2443 "turn": 1,
2444 "effect_id": "op-c8-green:step:2:effect:0",
2445 "invocation_id": "op-c8-green:step:1:effect:0"
2446 })),
2447 ];
2448 let report = validate_with_session_log(&blobs(&chain), &[stream]);
2449 assert_eq!(
2450 cross(&report, "C8").verdict,
2451 Verdict::Pass,
2452 "{}",
2453 cross(&report, "C8").detail
2454 );
2455 assert!(
2456 cross(&report, "C8").detail.contains("1 retried invocation(s)"),
2457 "{}",
2458 cross(&report, "C8").detail
2459 );
2460 assert_eq!(report.exit_code(), 0);
2461 }
2462
2463 #[test]
2464 fn a_completed_chain_head_is_the_merge_forgery() {
2465 let op = operation("op-c8-merged");
2469 let chain = live_chain(&[
2470 configure_envelope(&op),
2471 agent_start_envelope(&op),
2472 resolve_completed_envelope(&op, "in-resolve-1", 1_700_000_002_000, 1, true),
2473 ]);
2474 let stream = vec![session_event(json!({
2475 "kind": "llm_completed",
2476 "turn": 1,
2477 "effect_id": "op-c8-merged:step:2:effect:0",
2478 "invocation_id": "op-c8-merged:step:1:effect:0"
2479 }))];
2480 let report = validate_with_session_log(&blobs(&chain), &[stream]);
2481 assert_eq!(cross(&report, "C8").verdict, Verdict::Fail);
2482 assert!(
2483 cross(&report, "C8").detail.contains("closes its invocation"),
2484 "{}",
2485 cross(&report, "C8").detail
2486 );
2487 assert_eq!(report.exit_code(), 1);
2488 }
2489
2490 #[test]
2491 fn a_selected_effect_preceding_the_chain_head_fails_c8() {
2492 let chain = overflow_retry_chain("op-c8-backwards");
2493 let stream = vec![session_event(json!({
2494 "kind": "llm_completed",
2495 "turn": 1,
2496 "effect_id": "op-c8-backwards:step:1:effect:0",
2497 "invocation_id": "op-c8-backwards:step:2:effect:0"
2498 }))];
2499 let report = validate_with_session_log(&blobs(&chain), &[stream]);
2500 assert_eq!(cross(&report, "C8").verdict, Verdict::Fail);
2501 assert!(
2502 cross(&report, "C8").detail.contains("does not follow the chain head"),
2503 "{}",
2504 cross(&report, "C8").detail
2505 );
2506 }
2507
2508 #[test]
2509 fn a_selected_effect_the_replan_never_published_fails_c8() {
2510 let chain = overflow_retry_chain("op-c8-phantom");
2511 let stream = vec![session_event(json!({
2512 "kind": "llm_completed",
2513 "turn": 1,
2514 "effect_id": "op-c8-phantom:step:2:effect:9",
2515 "invocation_id": "op-c8-phantom:step:1:effect:0"
2516 }))];
2517 let report = validate_with_session_log(&blobs(&chain), &[stream]);
2518 assert_eq!(cross(&report, "C8").verdict, Verdict::Fail);
2519 assert!(
2520 cross(&report, "C8").detail.contains("never published"),
2521 "{}",
2522 cross(&report, "C8").detail
2523 );
2524 assert_eq!(report.exit_code(), 1);
2525 }
2526
2527 #[test]
2528 fn a_first_try_invocation_has_no_adjacency_to_prove() {
2529 let op = operation("op-c8-first-try");
2530 let chain = live_chain(&[
2531 configure_envelope(&op),
2532 agent_start_envelope(&op),
2533 resolve_completed_envelope(&op, "in-resolve-1", 1_700_000_002_000, 1, false),
2534 ]);
2535 let stream = vec![session_event(json!({
2536 "kind": "llm_completed",
2537 "turn": 1,
2538 "effect_id": "op-c8-first-try:step:1:effect:0",
2539 "invocation_id": "op-c8-first-try:step:1:effect:0"
2540 }))];
2541 let report = validate_with_session_log(&blobs(&chain), &[stream]);
2542 assert_eq!(
2543 cross(&report, "C8").verdict,
2544 Verdict::Pass,
2545 "{}",
2546 cross(&report, "C8").detail
2547 );
2548 assert!(
2549 cross(&report, "C8").detail.contains("1 first-try"),
2550 "{}",
2551 cross(&report, "C8").detail
2552 );
2553 assert_eq!(report.exit_code(), 0);
2554 }
2555
2556 #[test]
2557 fn an_invocation_past_the_journal_tip_degrades_c8() {
2558 let op = operation("op-c8-lag");
2561 let chain = live_chain(&[configure_envelope(&op), agent_start_envelope(&op)]);
2562 let stream = vec![session_event(json!({
2563 "kind": "llm_completed",
2564 "turn": 1,
2565 "effect_id": "op-c8-lag:step:2:effect:0",
2566 "invocation_id": "op-c8-lag:step:1:effect:0"
2567 }))];
2568 let report = validate_with_session_log(&blobs(&chain), &[stream]);
2569 assert_eq!(
2570 cross(&report, "C8").verdict,
2571 Verdict::Degraded,
2572 "{}",
2573 cross(&report, "C8").detail
2574 );
2575 assert_eq!(report.exit_code(), 0);
2576 }
2577}