1use std::collections::HashMap;
80
81use serde::Serialize;
82
83use crate::runtime::kernel::wire::ConfigDefaults;
84use crate::runtime::kernel::wire::checkpoint::KernelCheckpoint;
85use crate::runtime::kernel::wire::effect::{
86 EffectKind, EffectOutcome, EffectSuccess, ProviderOutcome, SpawnTasksEffect,
87};
88use crate::runtime::kernel::wire::record::{
89 KernelRecord, NormalizedPayload, RecordError, verify_record_chain,
90};
91use crate::runtime::kernel::wire::restore::{RestoredOperation, restore_operation};
92use crate::runtime::kernel::wire::transaction::InMemoryRecordIndex;
93
94pub const UNATTRIBUTED_SEGMENT: &str = "(unattributed)";
97
98const DEFERRED_ALWAYS: &str = "c4.parent_chain: parent links are not journaled; an orphan spawn \
101 cannot resolve (no outstanding effect), which C3's re-plan enforces structurally";
102
103const DEFERRED_WITHOUT_CHECKPOINT: &str = "c5b.launch_token_ledger: the durable LaunchToken \
107 ledger lives in checkpoints; provide --checkpoint to check token reuse across TaskLaunch \
108 payloads (the journal-direct shadow — (task_id, attempt_id) pair uniqueness — is checked \
109 under C4)";
110
111#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
113pub struct RuleReport {
114 pub rule: String,
116 pub verdict: Verdict,
117 pub detail: String,
119}
120
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
122#[serde(rename_all = "snake_case")]
123pub enum Verdict {
124 Pass,
125 Fail,
126 Degraded,
128}
129
130#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
132pub struct DegradedHop {
133 pub ordinal: usize,
135 pub step_seq: Option<u64>,
136 pub reason: String,
138}
139
140#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
142pub struct SegmentReport {
143 pub operation_id: String,
144 pub hops: usize,
145 pub degraded_hops: Vec<DegradedHop>,
146 pub rules: Vec<RuleReport>,
147}
148
149#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
150pub struct ValidationReport {
151 pub segments: Vec<SegmentReport>,
152 pub unparseable_records: usize,
154 #[serde(default)]
157 pub cross_checks: Vec<RuleReport>,
158 #[serde(default)]
161 pub unparseable_events: usize,
162 #[serde(default)]
166 pub session_events: Option<usize>,
167 #[serde(default)]
170 pub checkpoint_checks: Vec<RuleReport>,
171 #[serde(default)]
174 pub unparseable_checkpoints: usize,
175 #[serde(default)]
179 pub checkpoints: Option<usize>,
180 pub deferred: Vec<String>,
182}
183
184impl ValidationReport {
185 pub fn has_violations(&self) -> bool {
186 self.segments
187 .iter()
188 .flat_map(|segment| segment.rules.iter())
189 .chain(self.cross_checks.iter())
190 .chain(self.checkpoint_checks.iter())
191 .any(|rule| rule.verdict == Verdict::Fail)
192 }
193
194 pub fn has_violations_for(&self, operation_id: &str) -> bool {
198 self.segments
199 .iter()
200 .filter(|segment| segment.operation_id == operation_id)
201 .flat_map(|segment| segment.rules.iter())
202 .chain(self.cross_checks.iter())
203 .chain(self.checkpoint_checks.iter())
204 .any(|rule| rule.verdict == Verdict::Fail)
205 }
206
207 pub fn for_operation(&self, operation_id: &str) -> Self {
210 let mut report = self.clone();
211 report
212 .segments
213 .retain(|segment| segment.operation_id == operation_id);
214 report
215 }
216
217 pub fn has_insufficient_evidence(&self) -> bool {
219 self.unparseable_records > 0
220 || self.unparseable_events > 0
221 || self.unparseable_checkpoints > 0
222 || matches!(self.session_events, Some(0))
223 || matches!(self.checkpoints, Some(0))
224 }
225
226 pub fn exit_code(&self) -> i32 {
232 if self.has_violations() {
233 1
234 } else if self.has_insufficient_evidence() || self.segments.is_empty() {
235 2
236 } else {
237 0
238 }
239 }
240}
241
242enum Hop {
245 Complete(KernelRecord),
246 Degraded(DegradedRecord),
247}
248
249struct DegradedRecord {
250 ordinal: usize,
251 operation_id: Option<String>,
252 input_id: Option<String>,
253 step_seq: Option<u64>,
254 previous_record_digest: Option<String>,
255 record_digest: Option<String>,
256 reason: String,
257 integrity_failure: bool,
258}
259
260impl DegradedRecord {
261 fn marking(&self) -> DegradedHop {
262 DegradedHop {
263 ordinal: self.ordinal,
264 step_seq: self.step_seq,
265 reason: self.reason.clone(),
266 }
267 }
268}
269
270impl Hop {
271 fn operation_id(&self) -> Option<&str> {
272 match self {
273 Self::Complete(record) => Some(record.operation_id().as_str()),
274 Self::Degraded(degraded) => degraded.operation_id.as_deref(),
275 }
276 }
277
278 fn step_seq(&self) -> Option<u64> {
279 match self {
280 Self::Complete(record) => Some(record.step_seq().get()),
281 Self::Degraded(degraded) => degraded.step_seq,
282 }
283 }
284}
285
286pub fn validate_journal<B: AsRef<[u8]>>(blobs: &[B]) -> ValidationReport {
290 validate_with_session_log(blobs, &[] as &[Vec<Vec<u8>>])
291}
292
293pub fn validate_with_session_log<J, S>(
298 journal_blobs: &[J],
299 session_streams: &[Vec<S>],
300) -> ValidationReport
301where
302 J: AsRef<[u8]>,
303 S: AsRef<[u8]>,
304{
305 validate_with_checkpoint(journal_blobs, session_streams, &[] as &[Vec<u8>], false)
306}
307
308pub fn validate_with_checkpoint<J, S, C>(
315 journal_blobs: &[J],
316 session_streams: &[Vec<S>],
317 checkpoint_blobs: &[C],
318 strict: bool,
319) -> ValidationReport
320where
321 J: AsRef<[u8]>,
322 S: AsRef<[u8]>,
323 C: AsRef<[u8]>,
324{
325 let (outcomes, unparseable_records, segment_records) = validate_journal_plane(journal_blobs);
326
327 let mut streams: Vec<SessionStream> = Vec::with_capacity(session_streams.len());
328 for stream_blobs in session_streams {
329 let mut events = Vec::with_capacity(stream_blobs.len());
330 let mut unparseable_events = 0;
331 for blob in stream_blobs {
332 match classify_session_event(blob.as_ref()) {
333 Some(event) => events.push(event),
334 None => unparseable_events += 1,
335 }
336 }
337 streams.push(SessionStream {
338 events,
339 unparseable_events,
340 });
341 }
342
343 let session_plane_provided = !session_streams.is_empty();
344 let session_events =
345 session_plane_provided.then(|| streams.iter().map(|stream| stream.events.len()).sum());
346 let unparseable_events = streams.iter().map(|stream| stream.unparseable_events).sum();
347
348 let cross_checks = if session_plane_provided {
350 let mut checks = check_c6(&streams, &outcomes);
351 checks.push(check_c8(&streams, &outcomes));
352 checks
353 } else {
354 Vec::new()
355 };
356
357 let checkpoint_plane_provided = !checkpoint_blobs.is_empty();
359 let (checkpoint_checks, unparseable_checkpoints, checkpoints) = if checkpoint_plane_provided {
360 let (checks, unparseable, parsed) = check_c5(&segment_records, checkpoint_blobs, strict);
361 (checks, unparseable, Some(parsed))
362 } else {
363 (Vec::new(), 0, None)
364 };
365
366 let mut deferred = vec![DEFERRED_ALWAYS.to_string()];
367 if !checkpoint_plane_provided {
368 deferred.push(DEFERRED_WITHOUT_CHECKPOINT.to_string());
369 }
370
371 ValidationReport {
372 segments: outcomes.into_iter().map(|outcome| outcome.report).collect(),
373 unparseable_records,
374 cross_checks,
375 unparseable_events,
376 session_events,
377 checkpoint_checks,
378 unparseable_checkpoints,
379 checkpoints,
380 deferred,
381 }
382}
383
384fn validate_journal_plane<B: AsRef<[u8]>>(
388 blobs: &[B],
389) -> (
390 Vec<SegmentOutcome>,
391 usize,
392 HashMap<String, Vec<KernelRecord>>,
393) {
394 let mut hops: Vec<Hop> = Vec::with_capacity(blobs.len());
395 let mut unparseable_records = 0;
396 for (ordinal, blob) in blobs.iter().enumerate() {
397 match classify(ordinal, blob.as_ref()) {
398 Some(hop) => hops.push(hop),
399 None => unparseable_records += 1,
400 }
401 }
402
403 let mut segments: HashMap<String, Vec<Hop>> = HashMap::new();
404 let mut complete: HashMap<String, Vec<KernelRecord>> = HashMap::new();
405 for hop in hops {
406 let key = hop
407 .operation_id()
408 .map(str::to_string)
409 .unwrap_or_else(|| UNATTRIBUTED_SEGMENT.to_string());
410 if let Hop::Complete(record) = &hop {
411 complete
412 .entry(key.clone())
413 .or_default()
414 .push(record.clone());
415 }
416 segments.entry(key).or_default().push(hop);
417 }
418
419 let mut keys: Vec<String> = segments.keys().cloned().collect();
420 keys.sort();
421 let reports = keys
422 .iter()
423 .map(|key| validate_segment(key, segments.remove(key).unwrap_or_default()))
424 .collect();
425 (reports, unparseable_records, complete)
426}
427
428pub struct SessionStream {
435 pub events: Vec<EvidenceEvent>,
436 pub unparseable_events: usize,
437}
438
439#[derive(Debug, Clone, PartialEq, Eq)]
442pub enum EvidenceEvent {
443 RunStarted { route_id: Option<String> },
446 ProviderAttempt {
448 effect_id: Option<String>,
449 request_fingerprint: Option<String>,
450 route_id: Option<String>,
451 status: Option<String>,
452 },
453 PromptMeasured {
456 effect_id: Option<String>,
457 request_fingerprint: Option<String>,
458 },
459 LlmCompleted {
462 effect_id: Option<String>,
463 invocation_id: Option<String>,
464 },
465 Other,
467}
468
469fn classify_session_event(bytes: &[u8]) -> Option<EvidenceEvent> {
472 let value: serde_json::Value = serde_json::from_slice(bytes).ok()?;
473 let object = value.as_object()?;
474 let string = |key: &str| {
475 object
476 .get(key)
477 .and_then(serde_json::Value::as_str)
478 .map(str::to_string)
479 };
480 let kind = string("kind");
481 let event = match kind.as_deref() {
482 Some("run_started") => EvidenceEvent::RunStarted {
483 route_id: route_id_of(&value),
484 },
485 Some("provider_attempt") => EvidenceEvent::ProviderAttempt {
486 effect_id: string("effect_id"),
487 request_fingerprint: string("request_fingerprint"),
488 route_id: route_id_of(&value),
489 status: string("status"),
490 },
491 Some("prompt_measured") => EvidenceEvent::PromptMeasured {
492 effect_id: string("effect_id"),
493 request_fingerprint: object
496 .get("measurement")
497 .and_then(|measurement| {
498 measurement
499 .get("requestFingerprint")
500 .or_else(|| measurement.get("request_fingerprint"))
501 })
502 .and_then(serde_json::Value::as_str)
503 .map(str::to_string),
504 },
505 Some("llm_completed") => EvidenceEvent::LlmCompleted {
506 effect_id: string("effect_id"),
507 invocation_id: string("invocation_id"),
508 },
509 _ => EvidenceEvent::Other,
510 };
511 Some(event)
512}
513
514fn route_id_of(event: &serde_json::Value) -> Option<String> {
516 let route = event.get("route")?;
517 route
518 .get("routeId")
519 .or_else(|| route.get("route_id"))
520 .and_then(serde_json::Value::as_str)
521 .map(str::to_string)
522}
523
524fn check_c6(streams: &[SessionStream], outcomes: &[SegmentOutcome]) -> Vec<RuleReport> {
553 let segments: HashMap<&str, &SegmentOutcome> = outcomes
554 .iter()
555 .filter(|outcome| outcome.report.operation_id != UNATTRIBUTED_SEGMENT)
556 .map(|outcome| (outcome.report.operation_id.as_str(), outcome))
557 .collect();
558
559 let mut correspondence = ClauseAccumulator::default();
560 let mut fingerprints = ClauseAccumulator::default();
561 let mut routes = ClauseAccumulator::default();
562 let mut total_attempts = 0usize;
563
564 for (index, stream) in streams.iter().enumerate() {
565 let measured: std::collections::HashSet<&str> = stream
566 .events
567 .iter()
568 .filter_map(|event| match event {
569 EvidenceEvent::PromptMeasured {
570 request_fingerprint,
571 ..
572 } => request_fingerprint.as_deref(),
573 _ => None,
574 })
575 .collect();
576
577 let mut baseline: Option<&str> = None;
580 let mut last_pinned: Option<&str> = None;
581
582 for event in &stream.events {
583 match event {
584 EvidenceEvent::RunStarted { route_id } => {
585 if let Some(new_route) = route_id.as_deref() {
586 if let Some(previous) = last_pinned
587 && previous != new_route
588 {
589 routes.degraded(format!(
590 "stream #{index}: run resumed on route {new_route} (was \
591 {previous}) — a cross-resume change, degraded per Q3"
592 ));
593 }
594 baseline = Some(new_route);
595 last_pinned = Some(new_route);
596 } else {
597 baseline = None;
600 }
601 }
602 EvidenceEvent::ProviderAttempt {
603 effect_id,
604 request_fingerprint,
605 route_id,
606 ..
607 } => {
608 total_attempts += 1;
609 let label = effect_id.as_deref().unwrap_or("(no effect_id)");
610
611 match effect_id.as_deref() {
613 None => correspondence.violation(format!(
614 "stream #{index}: provider_attempt without effect_id — the writers \
615 mint it from the kernel effect, so a keyless attempt is forged \
616 evidence"
617 )),
618 Some(effect) => match parse_effect_step(effect) {
619 None => correspondence.violation(format!(
620 "stream #{index}: attempt names {effect}, which is not in the \
621 `operation:step:N:effect:M` vocabulary"
622 )),
623 Some((operation, step)) => match segments.get(operation) {
624 None => correspondence.degraded(format!(
625 "stream #{index}: attempt names {effect}, but operation \
626 {operation} has no journal segment (the journal may cover \
627 a subset of the session)"
628 )),
629 Some(outcome) => match &outcome.effects {
630 None => correspondence.degraded(format!(
631 "stream #{index}: segment {operation} could not be \
632 re-planned, so {effect}'s publication is unverifiable"
633 )),
634 Some(effects) if effects.published.contains(effect) => {
635 correspondence.checked += 1;
636 }
637 Some(effects) if step > effects.max_step => {
638 correspondence.degraded(format!(
639 "stream #{index}: attempt names {effect} at step \
640 {step}, past the journal's tip (step {}) — a \
641 prefix cannot disprove it",
642 effects.max_step
643 ));
644 }
645 Some(_) => correspondence.violation(format!(
646 "stream #{index}: attempt names {effect}, but the \
647 deterministic re-plan of {operation} never published \
648 it — the attempt is unmoored from the journal"
649 )),
650 },
651 },
652 },
653 }
654
655 match request_fingerprint.as_deref() {
657 None => fingerprints.violation(format!(
658 "stream #{index}: provider_attempt {label} without \
659 request_fingerprint — the writers require it (G2)"
660 )),
661 Some(fingerprint) if measured.contains(fingerprint) => {
662 fingerprints.checked += 1;
663 }
664 Some(fingerprint) => fingerprints.violation(format!(
665 "stream #{index}: attempt {label} carries fingerprint \
666 {fingerprint}, but no prompt_measured in this session carries it"
667 )),
668 }
669
670 match (route_id.as_deref(), baseline) {
672 (Some(route), Some(pinned)) if route != pinned => {
673 routes.violation(format!(
674 "stream #{index}: attempt {label} ran on route {route} inside \
675 a run pinned to {pinned} — an in-run route change is a \
676 violation (Q3)"
677 ))
678 }
679 (Some(_), Some(_)) => routes.checked += 1,
680 _ => routes.unverifiable += 1,
682 }
683 }
684 _ => {}
685 }
686 }
687 }
688
689 vec![
690 correspondence.report(
691 "C6.1",
692 total_attempts,
693 "attempt↔journal effect correspondence",
694 ),
695 fingerprints.report(
696 "C6.2",
697 total_attempts,
698 "attempt fingerprint↔prompt_measured join",
699 ),
700 routes.report("C6.3", total_attempts, "in-run route stability"),
701 ]
702}
703
704#[derive(Default)]
707struct ClauseAccumulator {
708 checked: usize,
709 unverifiable: usize,
710 violations: Vec<String>,
711 degraded_notes: Vec<String>,
712}
713
714impl ClauseAccumulator {
715 fn violation(&mut self, detail: String) {
716 self.violations.push(detail);
717 }
718
719 fn degraded(&mut self, note: String) {
720 self.degraded_notes.push(note);
721 }
722
723 fn report(self, rule: &str, total_attempts: usize, what: &str) -> RuleReport {
724 let rule = rule.to_string();
725 if !self.violations.is_empty() {
726 return RuleReport {
727 rule,
728 verdict: Verdict::Fail,
729 detail: self.violations.join("; "),
730 };
731 }
732 if total_attempts == 0 {
733 return RuleReport {
734 rule,
735 verdict: Verdict::Degraded,
736 detail: format!(
737 "no provider_attempt events in any stream — a pre-0.2.63 log carries none \
738 (C7); {what} unchecked"
739 ),
740 };
741 }
742 if !self.degraded_notes.is_empty() || self.unverifiable > 0 {
743 let mut detail = self.degraded_notes.join("; ");
744 if self.unverifiable > 0 {
745 if !detail.is_empty() {
746 detail.push_str("; ");
747 }
748 detail.push_str(&format!(
749 "{} attempt(s) unverifiable (no pinned run route)",
750 self.unverifiable
751 ));
752 }
753 return RuleReport {
754 rule,
755 verdict: Verdict::Degraded,
756 detail: format!("{} attempt(s) verified for {what}; {detail}", self.checked),
757 };
758 }
759 RuleReport {
760 rule,
761 verdict: Verdict::Pass,
762 detail: format!(
763 "{} attempt(s) verified — {what} holds across every stream",
764 self.checked
765 ),
766 }
767 }
768}
769
770fn check_c8(streams: &[SessionStream], outcomes: &[SegmentOutcome]) -> RuleReport {
804 let rule = "C8".to_string();
805 let segments: HashMap<&str, &SegmentOutcome> = outcomes
806 .iter()
807 .filter(|outcome| outcome.report.operation_id != UNATTRIBUTED_SEGMENT)
808 .map(|outcome| (outcome.report.operation_id.as_str(), outcome))
809 .collect();
810
811 let mut llm_completed_events = 0usize;
812 let mut checked = 0usize;
813 let mut trivial = 0usize;
814 let mut unverifiable = 0usize;
815 let mut violations: Vec<String> = Vec::new();
816 let mut degraded_notes: Vec<String> = Vec::new();
817
818 for (index, stream) in streams.iter().enumerate() {
819 for event in &stream.events {
820 let EvidenceEvent::LlmCompleted {
821 effect_id,
822 invocation_id,
823 } = event
824 else {
825 continue;
826 };
827 llm_completed_events += 1;
828 let (Some(head), Some(selected)) = (invocation_id.as_deref(), effect_id.as_deref())
829 else {
830 unverifiable += 1;
833 continue;
834 };
835 if head == selected {
836 trivial += 1;
837 continue;
838 }
839
840 let (Some((head_op, head_step)), Some((selected_op, selected_step))) =
841 (parse_effect_step(head), parse_effect_step(selected))
842 else {
843 violations.push(format!(
844 "stream #{index}: llm_completed claims invocation {head} → {selected}, but \
845 one of the pair is not in the `operation:step:N:effect:M` vocabulary"
846 ));
847 continue;
848 };
849 if head_op != selected_op {
850 violations.push(format!(
851 "stream #{index}: llm_completed claims invocation {head} → {selected} — an \
852 invocation chain cannot cross operations"
853 ));
854 continue;
855 }
856 if selected_step <= head_step {
857 violations.push(format!(
858 "stream #{index}: llm_completed claims invocation {head} → {selected}, but \
859 the selected effect does not follow the chain head"
860 ));
861 continue;
862 }
863 let Some(outcome) = segments.get(head_op) else {
864 degraded_notes.push(format!(
865 "stream #{index}: operation {head_op} has no journal segment (the journal \
866 may cover a subset of the session)"
867 ));
868 continue;
869 };
870 let Some(effects) = &outcome.effects else {
871 degraded_notes.push(format!(
872 "stream #{index}: segment {head_op} could not be re-planned, so the \
873 invocation {head} → {selected} is unverifiable"
874 ));
875 continue;
876 };
877
878 match effects.resolutions.get(head) {
880 Some(ResolutionFact::Completed) | Some(ResolutionFact::Other) => {
881 violations.push(format!(
882 "stream #{index}: llm_completed claims invocation {head} → {selected}, \
883 but {head} resolved to completion — a completed effect closes its \
884 invocation; nothing chains from it"
885 ));
886 continue;
887 }
888 Some(ResolutionFact::Overflow) | Some(ResolutionFact::Failed) => {}
889 None if effects.published.contains(head) => degraded_notes.push(format!(
890 "stream #{index}: chain head {head} is published but its resolution has \
891 not landed in the journal (the planes are not synchronised)"
892 )),
893 None if head_step > effects.max_step => degraded_notes.push(format!(
894 "stream #{index}: chain head {head} claims step {head_step}, past the \
895 journal's tip (step {})",
896 effects.max_step
897 )),
898 None => {
899 violations.push(format!(
900 "stream #{index}: llm_completed claims invocation head {head}, but the \
901 deterministic re-plan of {head_op} never published it"
902 ));
903 continue;
904 }
905 }
906
907 if effects.resolutions.contains_key(selected) {
909 checked += 1;
910 } else if effects.published.contains(selected) || selected_step > effects.max_step {
911 degraded_notes.push(format!(
912 "stream #{index}: selected effect {selected} is not resolved in the \
913 journal (the planes are not synchronised)"
914 ));
915 } else {
916 violations.push(format!(
917 "stream #{index}: llm_completed selects {selected}, but the deterministic \
918 re-plan of {selected_op} never published it"
919 ));
920 }
921 }
922 }
923
924 if !violations.is_empty() {
925 return RuleReport {
926 rule,
927 verdict: Verdict::Fail,
928 detail: violations.join("; "),
929 };
930 }
931 if llm_completed_events == 0 {
932 return RuleReport {
933 rule,
934 verdict: Verdict::Degraded,
935 detail: "no llm_completed events in any stream — invocation adjacency unchecked"
936 .to_string(),
937 };
938 }
939 if !degraded_notes.is_empty() || unverifiable > 0 {
940 let mut detail = degraded_notes.join("; ");
941 if unverifiable > 0 {
942 if !detail.is_empty() {
943 detail.push_str("; ");
944 }
945 detail.push_str(&format!(
946 "{unverifiable} llm_completed event(s) without invocation_id/effect_id \
947 (pre-0.2.63 fields — C7)"
948 ));
949 }
950 if !detail.is_empty() {
951 return RuleReport {
952 rule,
953 verdict: Verdict::Degraded,
954 detail: format!(
955 "{checked} retried invocation(s) verified, {trivial} first-try chain(s) \
956 closed; {detail}"
957 ),
958 };
959 }
960 }
961 RuleReport {
962 rule,
963 verdict: Verdict::Pass,
964 detail: format!(
965 "{checked} retried invocation(s) verified end-to-end, {trivial} first-try chain(s) \
966 closed"
967 ),
968 }
969}
970
971fn classify(ordinal: usize, bytes: &[u8]) -> Option<Hop> {
975 let error = match KernelRecord::from_record_bytes(bytes) {
976 Ok(record) => return Some(Hop::Complete(record)),
977 Err(error) => error,
978 };
979 let value: serde_json::Value = serde_json::from_slice(bytes).ok()?;
980 let object = value.as_object()?;
981 let string = |key: &str| {
982 object
983 .get(key)
984 .and_then(serde_json::Value::as_str)
985 .map(str::to_string)
986 };
987 let step_seq = object.get("step_seq").and_then(|value| {
990 value
991 .as_u64()
992 .or_else(|| value.as_str().and_then(|text| text.parse().ok()))
993 });
994 let degraded = DegradedRecord {
995 ordinal,
996 operation_id: string("operation_id"),
997 input_id: string("input_id"),
998 step_seq,
999 previous_record_digest: string("previous_record_digest"),
1000 record_digest: string("record_digest"),
1001 reason: format!("{}: {}", error.code().as_str(), error.message()),
1002 integrity_failure: matches!(error, RecordError::DigestMismatch(_)),
1003 };
1004 if degraded.operation_id.is_some() || degraded.step_seq.is_some() {
1007 Some(Hop::Degraded(degraded))
1008 } else {
1009 None
1010 }
1011}
1012
1013fn validate_segment(operation_id: &str, mut hops: Vec<Hop>) -> SegmentOutcome {
1014 hops.sort_by_key(|hop| {
1017 (
1018 hop.step_seq().unwrap_or(u64::MAX),
1019 match hop {
1020 Hop::Complete(_) => 0usize,
1021 Hop::Degraded(degraded) => degraded.ordinal,
1022 },
1023 )
1024 });
1025
1026 let degraded_hops: Vec<DegradedHop> = hops
1027 .iter()
1028 .filter_map(|hop| match hop {
1029 Hop::Degraded(degraded) => Some(degraded.marking()),
1030 Hop::Complete(_) => None,
1031 })
1032 .collect();
1033 let hop_count = hops.len();
1034
1035 let c1 = check_c1(&hops);
1036 let replan = replan_segment(&hops, &c1);
1037 let c2 = check_c2(&hops);
1038 let c3 = render_c3(&replan);
1039 let c4 = check_c4(&hops, operation_id);
1040
1041 let effects = match &replan {
1044 Replan::Restored(restored) => {
1045 let mut published: std::collections::HashSet<String> = std::collections::HashSet::new();
1046 let mut resolutions: HashMap<String, ResolutionFact> = HashMap::new();
1047 for hop in &hops {
1048 if let Some((effect_id, fact)) = resolution_of(hop) {
1049 published.insert(effect_id.clone());
1050 resolutions.insert(effect_id, fact);
1051 }
1052 }
1053 published.extend(
1054 restored
1055 .transaction
1056 .pending_effects()
1057 .map(|effect| effect.effect_id.as_str().to_string()),
1058 );
1059 let max_step = hops
1060 .iter()
1061 .filter_map(|hop| hop.step_seq())
1062 .max()
1063 .unwrap_or(0);
1064 Some(SegmentEffects {
1065 max_step,
1066 published,
1067 resolutions,
1068 })
1069 }
1070 _ => None,
1071 };
1072
1073 SegmentOutcome {
1074 report: SegmentReport {
1075 operation_id: operation_id.to_string(),
1076 hops: hop_count,
1077 degraded_hops,
1078 rules: vec![c1, c2, c3, c4],
1079 },
1080 effects,
1081 }
1082}
1083
1084struct SegmentOutcome {
1086 report: SegmentReport,
1087 effects: Option<SegmentEffects>,
1089}
1090
1091struct SegmentEffects {
1092 max_step: u64,
1095 published: std::collections::HashSet<String>,
1097 resolutions: HashMap<String, ResolutionFact>,
1099}
1100
1101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1104enum ResolutionFact {
1105 Completed,
1107 Overflow,
1110 Failed,
1114 Other,
1116}
1117
1118fn resolution_of(hop: &Hop) -> Option<(String, ResolutionFact)> {
1121 let Hop::Complete(record) = hop else {
1122 return None;
1123 };
1124 let input = record.normalized_input().ok()?;
1125 let NormalizedPayload::ResolveEffect(resolve) = &input.input else {
1126 return None;
1127 };
1128 let fact = match &resolve.outcome {
1129 EffectOutcome::Failed(_) => ResolutionFact::Failed,
1130 EffectOutcome::Succeeded(success) => match &success.result {
1131 EffectSuccess::Provider(provider) => match &provider.outcome {
1132 ProviderOutcome::Completed(_) => ResolutionFact::Completed,
1133 ProviderOutcome::ContextOverflow(_) => ResolutionFact::Overflow,
1134 },
1135 _ => ResolutionFact::Other,
1136 },
1137 };
1138 Some((resolve.effect_id.as_str().to_string(), fact))
1139}
1140
1141fn check_c1(hops: &[Hop]) -> RuleReport {
1143 let rule = "C1".to_string();
1144 if hops.is_empty() {
1145 return RuleReport {
1146 rule,
1147 verdict: Verdict::Degraded,
1148 detail: "no records in this segment".to_string(),
1149 };
1150 }
1151 let all_complete = hops.iter().all(|hop| matches!(hop, Hop::Complete(_)));
1152 if all_complete {
1153 let records: Vec<KernelRecord> = hops
1154 .iter()
1155 .filter_map(|hop| match hop {
1156 Hop::Complete(record) => Some(record.clone()),
1157 Hop::Degraded(_) => None,
1158 })
1159 .collect();
1160 return match verify_record_chain(&records) {
1161 Ok(genesis_digest) => RuleReport {
1162 rule,
1163 verdict: Verdict::Pass,
1164 detail: format!(
1165 "{} record(s), genesis {genesis_digest}, every link verified",
1166 records.len()
1167 ),
1168 },
1169 Err(error) => RuleReport {
1170 rule,
1171 verdict: Verdict::Fail,
1172 detail: format!("{}: {}", error.code().as_str(), error.message()),
1173 },
1174 };
1175 }
1176
1177 let mut broken: Vec<String> = hops
1180 .iter()
1181 .filter_map(|hop| match hop {
1182 Hop::Degraded(record) if record.integrity_failure => Some(record.reason.clone()),
1183 _ => None,
1184 })
1185 .collect();
1186 let mut unverifiable_links = 0usize;
1187 let mut previous: Option<(&Hop, Option<&KernelRecord>)> = None;
1188 for hop in hops {
1189 let step = hop.step_seq();
1190 let (prev_digest, _) = digests_of(hop);
1191 if let Some((previous_hop, previous_complete)) = previous {
1192 let previous_step = previous_hop.step_seq();
1193 let previous_digest = digests_of(previous_hop).1;
1194 match (prev_digest, previous_digest) {
1195 (Some(expected), Some(actual)) if expected != actual => broken.push(format!(
1196 "hop at step {} expects head {expected}, but its predecessor's digest is \
1197 {actual}",
1198 step.map_or("?".to_string(), |seq| seq.to_string()),
1199 )),
1200 (None, _) => unverifiable_links += 1,
1201 (_, None) => unverifiable_links += 1,
1202 _ => {}
1203 }
1204 match (step, previous_step) {
1205 (Some(step), Some(previous_step)) if step != previous_step + 1 => broken.push(
1206 format!("hop is step {step}, but its predecessor is step {previous_step}"),
1207 ),
1208 (Some(_), Some(_)) => {}
1209 _ => unverifiable_links += 1,
1210 }
1211 if let (Hop::Complete(record), Some(previous_record)) = (hop, previous_complete)
1214 && let Err(error) = record.verify_follows(Some(previous_record))
1215 {
1216 broken.push(format!("{}: {}", error.code().as_str(), error.message()));
1217 }
1218 } else if let Hop::Complete(record) = hop
1219 && let Err(error) = record.verify_follows(None)
1220 {
1221 broken.push(format!("{}: {}", error.code().as_str(), error.message()));
1222 }
1223 previous = Some((
1224 hop,
1225 match hop {
1226 Hop::Complete(record) => Some(record),
1227 Hop::Degraded(_) => None,
1228 },
1229 ));
1230 }
1231
1232 if !broken.is_empty() {
1233 return RuleReport {
1234 rule,
1235 verdict: Verdict::Fail,
1236 detail: broken.join("; "),
1237 };
1238 }
1239 RuleReport {
1240 rule,
1241 verdict: Verdict::Degraded,
1242 detail: format!(
1243 "partial chain: every surviving link verified, {unverifiable_links} link(s) \
1244 unverifiable across degraded hop(s)"
1245 ),
1246 }
1247}
1248
1249fn check_c2(hops: &[Hop]) -> RuleReport {
1251 let rule = "C2".to_string();
1252 let mut by_input: HashMap<&str, &str> = HashMap::new();
1253 let mut conflicts: Vec<String> = Vec::new();
1254 let mut retries = 0usize;
1255 let mut unverifiable = 0usize;
1256 for hop in hops {
1257 let (input_id, record_digest) = match hop {
1258 Hop::Complete(record) => (
1259 Some(record.input_id().as_str()),
1260 Some(record.record_digest().as_str()),
1261 ),
1262 Hop::Degraded(degraded) => (
1263 degraded.input_id.as_deref(),
1264 degraded.record_digest.as_deref(),
1265 ),
1266 };
1267 let Some(input_id) = input_id else { continue };
1268 let Some(digest) = record_digest else {
1269 unverifiable += 1;
1270 continue;
1271 };
1272 match by_input.get(input_id) {
1273 Some(existing) if *existing != digest => conflicts.push(format!(
1274 "input {input_id} produced two different records ({existing} and {digest}); a \
1275 retry must reach the same record"
1276 )),
1277 Some(_) => retries += 1,
1278 None => {
1279 by_input.insert(input_id, digest);
1280 }
1281 }
1282 }
1283 if !conflicts.is_empty() {
1284 return RuleReport {
1285 rule,
1286 verdict: Verdict::Fail,
1287 detail: conflicts.join("; "),
1288 };
1289 }
1290 if unverifiable > 0 {
1291 return RuleReport {
1292 rule,
1293 verdict: Verdict::Degraded,
1294 detail: format!(
1295 "{} unique input(s), {retries} idempotent retry hit(s); {unverifiable} degraded \
1296 hop(s) could not be compared",
1297 by_input.len(),
1298 ),
1299 };
1300 }
1301 RuleReport {
1302 rule,
1303 verdict: Verdict::Pass,
1304 detail: format!(
1305 "{} unique input(s), {retries} idempotent retry hit(s), no divergent duplicates",
1306 by_input.len(),
1307 ),
1308 }
1309}
1310
1311enum Replan {
1316 Unavailable(&'static str),
1318 Failed(String),
1320 Restored(RestoredOperation),
1321}
1322
1323fn replan_segment(hops: &[Hop], c1: &RuleReport) -> Replan {
1324 if hops.iter().any(|hop| matches!(hop, Hop::Degraded(_))) {
1325 return Replan::Unavailable(
1326 "re-plan requires complete records; this segment has degraded hops",
1327 );
1328 }
1329 if c1.verdict == Verdict::Fail {
1330 return Replan::Unavailable(
1331 "C1 failed; a re-plan over a broken chain would only re-report that break",
1332 );
1333 }
1334 let records: Vec<KernelRecord> = hops
1335 .iter()
1336 .filter_map(|hop| match hop {
1337 Hop::Complete(record) => Some(record.clone()),
1338 Hop::Degraded(_) => None,
1339 })
1340 .collect();
1341 if records.is_empty() {
1342 return Replan::Unavailable("no records in this segment");
1343 }
1344 match restore_operation(
1345 None,
1346 &records,
1347 ConfigDefaults::default(),
1348 InMemoryRecordIndex::from_records(&records),
1349 ) {
1350 Ok(restored) => Replan::Restored(restored),
1351 Err(fault) => Replan::Failed(format!("{}: {}", fault.code.as_str(), fault.message)),
1352 }
1353}
1354
1355fn render_c3(replan: &Replan) -> RuleReport {
1356 let rule = "C3".to_string();
1357 match replan {
1358 Replan::Unavailable(reason) => RuleReport {
1359 rule,
1360 verdict: Verdict::Degraded,
1361 detail: (*reason).to_string(),
1362 },
1363 Replan::Failed(fault) => RuleReport {
1364 rule,
1365 verdict: Verdict::Fail,
1366 detail: fault.clone(),
1367 },
1368 Replan::Restored(restored) => RuleReport {
1369 rule,
1370 verdict: Verdict::Pass,
1371 detail: format!(
1372 "re-planned {} record(s) from genesis; every durable record digest reproduced",
1373 restored.cost.records_before_checkpoint
1374 ),
1375 },
1376 }
1377}
1378
1379fn check_c4(hops: &[Hop], operation_id: &str) -> RuleReport {
1381 let rule = "C4".to_string();
1382 struct LaunchFact {
1383 task_id: String,
1384 attempt_id: String,
1385 step_seq: u64,
1386 effect_id: String,
1387 }
1388
1389 let mut launches: Vec<LaunchFact> = Vec::new();
1390 let mut unreadable_inputs = 0usize;
1391 for hop in hops {
1392 let Hop::Complete(record) = hop else { continue };
1393 let input = match record.normalized_input() {
1394 Ok(input) => input,
1395 Err(_) => {
1396 unreadable_inputs += 1;
1397 continue;
1398 }
1399 };
1400 let NormalizedPayload::ResolveEffect(resolve) = &input.input else {
1401 continue;
1402 };
1403 let EffectOutcome::Succeeded(success) = &resolve.outcome else {
1404 continue;
1405 };
1406 let EffectSuccess::TasksSpawned(spawned) = &success.result else {
1407 continue;
1408 };
1409 for attempt in &spawned.attempts {
1410 launches.push(LaunchFact {
1411 task_id: attempt.task_id.as_str().to_string(),
1412 attempt_id: attempt.attempt_id.as_str().to_string(),
1413 step_seq: record.step_seq().get(),
1414 effect_id: resolve.effect_id.as_str().to_string(),
1415 });
1416 }
1417 }
1418
1419 let mut violations: Vec<String> = Vec::new();
1420 let mut seen: HashMap<(&str, &str), u64> = HashMap::new();
1421 for fact in &launches {
1422 let pair = (fact.task_id.as_str(), fact.attempt_id.as_str());
1423 if let Some(first_step) = seen.insert(pair, fact.step_seq) {
1424 violations.push(format!(
1425 "task {} attempt {} launched at steps {first_step} and {}; the launch token is \
1426 derived from that pair, so a repeated pair is a reused LaunchToken",
1427 fact.task_id, fact.attempt_id, fact.step_seq,
1428 ));
1429 }
1430 match parse_effect_step(&fact.effect_id) {
1431 Some((effect_operation, effect_step)) => {
1432 if effect_operation != operation_id {
1433 violations.push(format!(
1434 "task {} launch at step {} resolves effect {} of another operation — \
1435 causation cannot cross operations",
1436 fact.task_id, fact.step_seq, fact.effect_id,
1437 ));
1438 } else if effect_step >= fact.step_seq {
1439 violations.push(format!(
1440 "task {} launch resolved at step {} names an effect published at step \
1441 {effect_step} — the resolution precedes the publication",
1442 fact.task_id, fact.step_seq,
1443 ));
1444 }
1445 }
1446 None => violations.push(format!(
1447 "task {} launch at step {} names effect {}, which is not in the \
1448 `operation:step:N:effect:M` vocabulary",
1449 fact.task_id, fact.step_seq, fact.effect_id,
1450 )),
1451 }
1452 }
1453
1454 if !violations.is_empty() {
1455 return RuleReport {
1456 rule,
1457 verdict: Verdict::Fail,
1458 detail: violations.join("; "),
1459 };
1460 }
1461 let degraded_hops = hops
1462 .iter()
1463 .filter(|hop| matches!(hop, Hop::Degraded(_)))
1464 .count();
1465 if degraded_hops > 0 || unreadable_inputs > 0 {
1466 return RuleReport {
1467 rule,
1468 verdict: Verdict::Degraded,
1469 detail: format!(
1470 "{} launch(es) checked; {degraded_hops} degraded hop(s) and \
1471 {unreadable_inputs} unreadable input(s) could hide further launches",
1472 launches.len(),
1473 ),
1474 };
1475 }
1476 RuleReport {
1477 rule,
1478 verdict: Verdict::Pass,
1479 detail: format!(
1480 "{} launch(es), every (task_id, attempt_id) pair unique, every spawn resolution \
1481 names an earlier step of this operation",
1482 launches.len(),
1483 ),
1484 }
1485}
1486
1487fn parse_effect_step(effect_id: &str) -> Option<(&str, u64)> {
1490 let (before_effect, _) = effect_id.rsplit_once(":effect:")?;
1491 let (operation, step) = before_effect.rsplit_once(":step:")?;
1492 Some((operation, step.parse().ok()?))
1493}
1494
1495fn digests_of(hop: &Hop) -> (Option<&str>, Option<&str>) {
1496 match hop {
1497 Hop::Complete(record) => (
1498 record
1499 .previous_record_digest()
1500 .map(|digest| digest.as_str()),
1501 Some(record.record_digest().as_str()),
1502 ),
1503 Hop::Degraded(degraded) => (
1504 degraded.previous_record_digest.as_deref(),
1505 degraded.record_digest.as_deref(),
1506 ),
1507 }
1508}
1509
1510struct C5Clauses {
1517 violations: Vec<String>,
1518 degradations: Vec<String>,
1519 confirmations: Vec<String>,
1520}
1521
1522impl C5Clauses {
1523 fn new() -> Self {
1524 Self {
1525 violations: Vec::new(),
1526 degradations: Vec::new(),
1527 confirmations: Vec::new(),
1528 }
1529 }
1530
1531 fn violation(&mut self, detail: String) {
1532 self.violations.push(detail);
1533 }
1534
1535 fn degraded(&mut self, detail: String) {
1536 self.degradations.push(detail);
1537 }
1538
1539 fn confirms(&mut self, detail: String) {
1540 self.confirmations.push(detail);
1541 }
1542
1543 fn report(self, rule: &str) -> RuleReport {
1544 let rule = rule.to_string();
1545 if !self.violations.is_empty() {
1546 return RuleReport {
1547 rule,
1548 verdict: Verdict::Fail,
1549 detail: self.violations.join("; "),
1550 };
1551 }
1552 if !self.degradations.is_empty() {
1553 let mut detail = self.degradations.join("; ");
1554 if !self.confirmations.is_empty() {
1555 detail.push_str("; ");
1556 detail.push_str(&self.confirmations.join("; "));
1557 }
1558 return RuleReport {
1559 rule,
1560 verdict: Verdict::Degraded,
1561 detail,
1562 };
1563 }
1564 RuleReport {
1565 rule,
1566 verdict: Verdict::Pass,
1567 detail: if self.confirmations.is_empty() {
1568 "nothing to anchor".to_string()
1569 } else {
1570 self.confirmations.join("; ")
1571 },
1572 }
1573 }
1574}
1575
1576fn check_c5<C: AsRef<[u8]>>(
1579 segments: &HashMap<String, Vec<KernelRecord>>,
1580 blobs: &[C],
1581 strict: bool,
1582) -> (Vec<RuleReport>, usize, usize) {
1583 let mut checks = Vec::new();
1584 let mut unparseable = 0usize;
1585 let mut parsed = 0usize;
1586 for blob in blobs {
1587 match KernelCheckpoint::from_checkpoint_bytes(blob.as_ref()) {
1588 Ok(checkpoint) => {
1589 parsed += 1;
1590 let records = segments.get(checkpoint.operation_id().as_str());
1591 let replay = strict.then(|| strict_replay(&checkpoint, records.map(Vec::as_slice)));
1593 checks.push(check_c5a(
1594 &checkpoint,
1595 records.map(Vec::as_slice),
1596 replay.as_ref(),
1597 ));
1598 checks.push(check_c5b(&checkpoint, replay.as_ref()));
1599 }
1600 Err(error) => {
1601 unparseable += 1;
1602 checks.push(RuleReport {
1603 rule: "C5a".to_string(),
1604 verdict: Verdict::Degraded,
1605 detail: format!(
1606 "checkpoint blob did not decode — its claims are unverifiable (C7): {}",
1607 error.message()
1608 ),
1609 });
1610 }
1611 }
1612 }
1613 (checks, unparseable, parsed)
1614}
1615
1616enum StrictReplay {
1623 Skipped(String),
1625 Faulted(String),
1627 Done {
1628 at_base: KernelCheckpoint,
1634 ladder: Result<(), String>,
1635 above_records: usize,
1636 },
1637}
1638
1639fn strict_replay(checkpoint: &KernelCheckpoint, records: Option<&[KernelRecord]>) -> StrictReplay {
1640 let through = checkpoint.through_step_seq().get();
1641 let base = checkpoint.base_step_seq().get();
1642 let Some(records) = records else {
1643 return StrictReplay::Skipped(
1644 "the journal holds no segment for this operation".to_string(),
1645 );
1646 };
1647
1648 let mut steps: Vec<u64> = records
1652 .iter()
1653 .map(|record| record.step_seq().get())
1654 .filter(|step| *step <= through)
1655 .collect();
1656 steps.sort_unstable();
1657 steps.dedup();
1658 if steps != (0..=through).collect::<Vec<u64>>() {
1659 return StrictReplay::Skipped(format!(
1660 "the journal does not hold an unbroken record run from step 0 through {through} \
1661 (pruned prefix or partial copy); the re-plan replay cannot start at genesis"
1662 ));
1663 }
1664
1665 let fold_to_base = || -> Result<KernelCheckpoint, String> {
1669 let mut prefix: Vec<&KernelRecord> = records
1670 .iter()
1671 .filter(|record| record.step_seq().get() <= base)
1672 .collect();
1673 prefix.sort_by_key(|record| record.step_seq().get());
1674 let prefix: Vec<KernelRecord> = prefix.into_iter().cloned().collect();
1675 let folded = restore_operation(
1676 None,
1677 &prefix,
1678 ConfigDefaults::default(),
1679 InMemoryRecordIndex::from_records(&prefix),
1680 )
1681 .map_err(|fault| format!("{}: {}", fault.code.as_str(), fault.message))?;
1682 folded
1683 .transaction
1684 .checkpoint_candidate(folded.driver.project_logical_state())
1685 .map_err(|fault| format!("{}: {}", fault.code.as_str(), fault.message))?
1686 .decode()
1687 .map_err(|error| {
1688 format!(
1689 "the re-derived checkpoint does not decode: {}",
1690 error.message()
1691 )
1692 })
1693 };
1694 let at_base = match fold_to_base() {
1695 Ok(checkpoint) => checkpoint,
1696 Err(fault) => return StrictReplay::Faulted(fault),
1697 };
1698
1699 let mut above: Vec<&KernelRecord> = records
1703 .iter()
1704 .filter(|record| record.step_seq().get() > through)
1705 .collect();
1706 above.sort_by_key(|record| record.step_seq().get());
1707 let above_records = above.len();
1708 let above: Vec<KernelRecord> = above.into_iter().cloned().collect();
1709 let ladder = restore_operation(
1710 Some(checkpoint),
1711 &above,
1712 ConfigDefaults::default(),
1713 InMemoryRecordIndex::from_records(&above),
1714 )
1715 .map(|_: RestoredOperation<InMemoryRecordIndex>| ())
1716 .map_err(|fault| format!("{}: {}", fault.code.as_str(), fault.message));
1717
1718 StrictReplay::Done {
1719 at_base,
1720 ladder,
1721 above_records,
1722 }
1723}
1724
1725fn check_c5a(
1729 checkpoint: &KernelCheckpoint,
1730 records: Option<&[KernelRecord]>,
1731 replay: Option<&StrictReplay>,
1732) -> RuleReport {
1733 let rule = "C5a";
1734 let mut clauses = C5Clauses::new();
1735 let Some(records) = records else {
1736 return RuleReport {
1737 rule: rule.to_string(),
1738 verdict: Verdict::Degraded,
1739 detail: format!(
1740 "checkpoint names operation {}; the journal holds no segment for it, so no \
1741 anchor can be checked",
1742 checkpoint.operation_id()
1743 ),
1744 };
1745 };
1746 let through = checkpoint.through_step_seq().get();
1747
1748 match record_at(records, 0) {
1750 Some(genesis)
1751 if genesis.record_digest().as_str() != checkpoint.genesis_digest().as_str() =>
1752 {
1753 clauses.violation(format!(
1754 "the journal's genesis record hashes to {}, but the checkpoint binds genesis {} — \
1755 this checkpoint was captured on another chain",
1756 genesis.record_digest(),
1757 checkpoint.genesis_digest()
1758 ));
1759 }
1760 Some(_) => clauses.confirms("genesis digest anchored".to_string()),
1761 None => clauses.degraded(
1762 "the genesis record is not in the journal (pruned prefix); the identity anchor is \
1763 unverifiable"
1764 .to_string(),
1765 ),
1766 }
1767
1768 match record_at(records, through) {
1771 Some(record)
1772 if record.record_digest().as_str()
1773 != checkpoint.covered_transaction_head_digest().as_str() =>
1774 {
1775 clauses.violation(format!(
1776 "the journal record at the covered step {through} hashes to {}, but the \
1777 checkpoint's covered head is {}",
1778 record.record_digest(),
1779 checkpoint.covered_transaction_head_digest()
1780 ));
1781 }
1782 Some(_) => clauses.confirms(format!("covered head anchored at step {through}")),
1783 None => clauses.degraded(format!(
1784 "no journal record at the covered step {through}; the covered head is unverifiable"
1785 )),
1786 }
1787
1788 let base = checkpoint.base_step_seq().get();
1791 if base != through {
1792 match record_at(records, base) {
1793 Some(record)
1794 if record.record_digest().as_str() != checkpoint.base_record_digest().as_str() =>
1795 {
1796 clauses.violation(format!(
1797 "the journal record at the tail base step {base} hashes to {}, but the \
1798 checkpoint anchors its tail on {}",
1799 record.record_digest(),
1800 checkpoint.base_record_digest()
1801 ));
1802 }
1803 _ => {}
1804 }
1805 }
1806 let mut reconciled = 0usize;
1807 let mut pruned = 0usize;
1808 for entry in checkpoint.tail_inputs() {
1809 match record_at(records, entry.step_seq.get()) {
1810 Some(record) if record.record_digest().as_str() != entry.record_digest.as_str() => {
1811 clauses.violation(format!(
1812 "the journal record at step {} disagrees with the checkpoint's bounded tail \
1813 (journal {}, checkpoint {})",
1814 entry.step_seq.get(),
1815 record.record_digest(),
1816 entry.record_digest
1817 ));
1818 }
1819 Some(_) => reconciled += 1,
1820 None => pruned += 1,
1821 }
1822 }
1823 if reconciled > 0 {
1824 clauses.confirms(format!(
1825 "{reconciled} bounded-tail entries reconcile with the journal"
1826 ));
1827 }
1828 if pruned > 0 {
1829 clauses.degraded(format!(
1830 "{pruned} bounded-tail entries have no journal record (pruned interval)"
1831 ));
1832 }
1833
1834 match replay {
1835 Some(StrictReplay::Skipped(reason)) => {
1836 clauses.degraded(format!("strict replay skipped: {reason}"));
1837 }
1838 Some(StrictReplay::Faulted(fault)) => {
1839 clauses.violation(format!("strict replay faulted: {fault}"));
1840 }
1841 Some(StrictReplay::Done {
1842 at_base,
1843 ladder,
1844 above_records,
1845 }) => {
1846 let base = checkpoint.base_step_seq().get();
1847 if at_base.state_digest() != checkpoint.state_digest() {
1848 clauses.violation(format!(
1849 "strict replay folds the journal to state digest {} at the checkpoint's \
1850 base step {base}, but the checkpoint captured {} there",
1851 at_base.state_digest(),
1852 checkpoint.state_digest()
1853 ));
1854 } else {
1855 clauses.confirms(format!(
1856 "strict replay reproduces the captured state digest at step {base}"
1857 ));
1858 }
1859 match ladder {
1860 Ok(()) => clauses.confirms(format!(
1861 "the checkpoint+tail restore ladder holds against the {above_records} \
1862 journal record(s) above the covered step"
1863 )),
1864 Err(fault) => clauses.violation(format!(
1865 "the checkpoint+tail restore ladder faults against this journal: {fault}"
1866 )),
1867 }
1868 }
1869 None => {}
1870 }
1871
1872 clauses.report(rule)
1873}
1874
1875fn check_c5b(checkpoint: &KernelCheckpoint, replay: Option<&StrictReplay>) -> RuleReport {
1881 let rule = "C5b";
1882 let mut clauses = C5Clauses::new();
1883 let transition = &checkpoint.logical_state().transition;
1884 let through = checkpoint.through_step_seq().get();
1885
1886 let mut mints: HashMap<&str, u64> = HashMap::new();
1887 for entry in &transition.launch_tokens {
1888 let token = entry.launch_token.as_str();
1889 let step = entry.step_seq.get();
1890 match mints.get(token) {
1891 Some(previous) if *previous != step => clauses.violation(format!(
1892 "launch token {token} is minted at step {step} and step {previous} — reuse \
1893 across TaskLaunch payloads"
1894 )),
1895 Some(_) => clauses.violation(format!(
1896 "launch token {token} is registered twice at step {step} — a duplicated ledger \
1897 entry"
1898 )),
1899 None => {
1900 mints.insert(token, step);
1901 }
1902 }
1903 if step > through {
1904 clauses.violation(format!(
1905 "launch token {token} is minted at step {step}, beyond the covered boundary \
1906 {through}"
1907 ));
1908 }
1909 }
1910
1911 for effect in &transition.pending_effects {
1912 let EffectKind::SpawnTasks(spawn) = &effect.effect else {
1913 continue;
1914 };
1915 let SpawnTasksEffect { tasks, .. } = spawn;
1916 let effect_step = parse_effect_step(effect.effect_id.as_str()).map(|(_, step)| step);
1917 for launch in tasks {
1918 let token = launch.launch_token.as_str();
1919 match (mints.get(token), effect_step) {
1920 (None, _) => clauses.violation(format!(
1921 "pending effect {} carries launch token {token} the ledger never registered",
1922 effect.effect_id
1923 )),
1924 (Some(&minted), Some(step)) if minted != step => clauses.violation(format!(
1925 "pending effect {} carries launch token {token} minted at step {minted}, not \
1926 at the effect's own step {step}",
1927 effect.effect_id
1928 )),
1929 _ => {}
1930 }
1931 }
1932 }
1933
1934 if !transition.launch_tokens.is_empty() {
1935 clauses.confirms(format!(
1936 "{} launch token(s) anchored; no reuse across TaskLaunch payloads",
1937 transition.launch_tokens.len()
1938 ));
1939 }
1940
1941 match replay {
1942 Some(StrictReplay::Skipped(reason)) => {
1943 clauses.degraded(format!("strict replay skipped: {reason}"));
1944 }
1945 Some(StrictReplay::Faulted(fault)) => {
1946 clauses.violation(format!("strict replay faulted: {fault}"));
1947 }
1948 Some(StrictReplay::Done { at_base, .. }) => {
1949 let replayed_ledger = &at_base.logical_state().transition.launch_tokens;
1950 if replayed_ledger != &transition.launch_tokens {
1951 clauses.violation(format!(
1952 "strict replay mints a different launch-token ledger: the journal fold \
1953 registers {} token(s), the checkpoint carries {}",
1954 replayed_ledger.len(),
1955 transition.launch_tokens.len()
1956 ));
1957 } else {
1958 clauses.confirms(
1959 "strict replay reproduces the launch-token ledger exactly".to_string(),
1960 );
1961 }
1962 }
1963 None => {}
1964 }
1965
1966 clauses.report(rule)
1967}
1968
1969fn record_at(records: &[KernelRecord], step_seq: u64) -> Option<&KernelRecord> {
1971 records
1972 .iter()
1973 .find(|record| record.step_seq().get() == step_seq)
1974}
1975
1976#[cfg(test)]
1981mod tests {
1982 use serde_json::json;
1983
1984 use super::*;
1985 use crate::runtime::kernel::wire::config::{
1986 ConfigDefaults, ExecutionPolicy, HostEffectSupport, OperationConfig,
1987 };
1988 use crate::runtime::kernel::wire::driver::CanonicalOperationDriver;
1989 use crate::runtime::kernel::wire::effect::{
1990 EffectKindTag,
1991 EffectSucceeded,
1992 ProviderCompleted,
1993 ProviderContextOverflow,
1994 ProviderMessage,
1995 ProviderOutcome,
1996 ProviderSuccess,
1997 TaskLaunchOutcome,
1998 TaskLaunchStarted,
1999 TaskLaunchStatus,
2000 TasksSpawnedSuccess,
2001 ToolCall as WireToolCall,
2004 };
2005 use crate::runtime::kernel::wire::envelope::{
2006 ConfigureOperation, KernelInput, ResolveEffect, StartOperation, WireEnvelope,
2007 };
2008 use crate::runtime::kernel::wire::record::{KernelRecord, NormalizedInput};
2009 use crate::runtime::kernel::wire::root::{
2010 InitialContext, LogicalAgentSpec, LogicalMessage, LogicalTask, MessageRole, RootAgentEntry,
2011 RootEntry, RootWorkflowEntry, WorkflowNode as WireWorkflowNode,
2012 WorkflowSpec as WireWorkflowSpec,
2013 };
2014 use crate::runtime::kernel::wire::scalar::{
2015 AttemptId, BoundedJson, CallId, EffectId, InputId, NodeId, OperationId, TaskId, WireU64,
2016 };
2017 use crate::runtime::kernel::wire::transaction::{InMemoryRecordIndex, KernelTransaction};
2018
2019 fn operation(id: &str) -> OperationId {
2024 OperationId::new(id).unwrap()
2025 }
2026
2027 fn envelope(op: &OperationId, id: &str, at: u64, input: KernelInput) -> WireEnvelope {
2028 WireEnvelope::new(
2029 op.clone(),
2030 InputId::new(id).unwrap(),
2031 WireU64::new(at),
2032 input,
2033 )
2034 }
2035
2036 fn configure_envelope(op: &OperationId) -> WireEnvelope {
2037 envelope(
2038 op,
2039 "in-configure",
2040 1_700_000_000_000,
2041 KernelInput::ConfigureOperation(ConfigureOperation {
2042 config: OperationConfig {
2043 execution_policy: Some(ExecutionPolicy {
2044 max_turns: Some(12),
2045 ..ExecutionPolicy::default()
2046 }),
2047 host_effect_support: HostEffectSupport::new([
2048 EffectKindTag::CallProvider,
2049 EffectKindTag::SpawnTasks,
2050 ]),
2051 ..OperationConfig::default()
2052 },
2053 }),
2054 )
2055 }
2056
2057 fn agent_start_envelope(op: &OperationId) -> WireEnvelope {
2058 envelope(
2059 op,
2060 "in-start",
2061 1_700_000_001_000,
2062 KernelInput::StartOperation(StartOperation {
2063 entry: RootEntry::Agent(RootAgentEntry {
2064 task: LogicalTask::new("write the brief"),
2065 run_spec: Some(LogicalAgentSpec::new("write the brief")),
2066 }),
2067 initial_context: InitialContext::default(),
2068 }),
2069 )
2070 }
2071
2072 fn agent_start_with_history_envelope(op: &OperationId, messages: usize) -> WireEnvelope {
2076 envelope(
2077 op,
2078 "in-start",
2079 1_700_000_001_000,
2080 KernelInput::StartOperation(StartOperation {
2081 entry: RootEntry::Agent(RootAgentEntry {
2082 task: LogicalTask::new("write the brief"),
2083 run_spec: Some(LogicalAgentSpec::new("write the brief")),
2084 }),
2085 initial_context: InitialContext {
2086 messages: (0..messages)
2087 .map(|index| LogicalMessage {
2088 role: if index % 2 == 0 {
2089 MessageRole::User
2090 } else {
2091 MessageRole::Assistant
2092 },
2093 content: format!(
2094 "turn {index}: a long enough body that compaction has \
2095 something to reclaim when the prompt stops fitting"
2096 ),
2097 tokens: Some(64),
2098 tool_call_id: None,
2099 })
2100 .collect(),
2101 ..InitialContext::default()
2102 },
2103 }),
2104 )
2105 }
2106
2107 fn workflow_start_envelope(op: &OperationId) -> WireEnvelope {
2108 envelope(
2109 op,
2110 "in-start",
2111 1_700_000_001_000,
2112 KernelInput::StartOperation(StartOperation {
2113 entry: RootEntry::Workflow(RootWorkflowEntry {
2114 spec: WireWorkflowSpec {
2115 name: "brief".to_string(),
2116 nodes: vec![
2117 WireWorkflowNode {
2118 node_id: NodeId::new("collect").unwrap(),
2119 task: LogicalTask::new("collect the sources"),
2120 depends_on: vec![],
2121 run_spec: Some(LogicalAgentSpec::new("collect the sources")),
2122 },
2123 WireWorkflowNode {
2124 node_id: NodeId::new("write").unwrap(),
2125 task: LogicalTask::new("write the brief"),
2126 depends_on: vec![NodeId::new("collect").unwrap()],
2127 run_spec: Some(LogicalAgentSpec::new("write the brief")),
2128 },
2129 ],
2130 },
2131 }),
2132 initial_context: InitialContext::default(),
2133 }),
2134 )
2135 }
2136
2137 fn resolve_overflow_envelope(op: &OperationId, effect_step: u64) -> WireEnvelope {
2138 envelope(
2139 op,
2140 "in-resolve",
2141 1_700_000_002_000,
2142 KernelInput::ResolveEffect(ResolveEffect {
2143 effect_id: EffectId::new(format!("{op}:step:{effect_step}:effect:0")).unwrap(),
2144 outcome: EffectOutcome::Succeeded(EffectSucceeded {
2145 result: EffectSuccess::Provider(ProviderSuccess {
2146 outcome: ProviderOutcome::ContextOverflow(
2147 ProviderContextOverflow::default(),
2148 ),
2149 }),
2150 }),
2151 }),
2152 )
2153 }
2154
2155 fn resolve_completed_envelope(
2158 op: &OperationId,
2159 id: &str,
2160 at: u64,
2161 effect_step: u64,
2162 with_tool_call: bool,
2163 ) -> WireEnvelope {
2164 envelope(
2165 op,
2166 id,
2167 at,
2168 KernelInput::ResolveEffect(ResolveEffect {
2169 effect_id: EffectId::new(format!("{op}:step:{effect_step}:effect:0")).unwrap(),
2170 outcome: EffectOutcome::Succeeded(EffectSucceeded {
2171 result: EffectSuccess::Provider(ProviderSuccess {
2172 outcome: ProviderOutcome::Completed(ProviderCompleted {
2173 message: ProviderMessage {
2174 role: MessageRole::Assistant,
2175 content: "done".to_string(),
2176 tool_calls: if with_tool_call {
2177 vec![WireToolCall {
2178 call_id: CallId::new("call-1").unwrap(),
2179 name: "read_file".to_string(),
2180 arguments: BoundedJson::new(json!({})).unwrap(),
2181 }]
2182 } else {
2183 Vec::new()
2184 },
2185 tool_call_id: None,
2186 tokens: None,
2187 },
2188 observed_input_tokens: None,
2189 observed_output_tokens: None,
2190 stop_reason: None,
2191 }),
2192 }),
2193 }),
2194 }),
2195 )
2196 }
2197
2198 fn resolve_spawn_envelope(
2199 op: &OperationId,
2200 id: &str,
2201 at: u64,
2202 effect_id: &str,
2203 tasks: &[(&str, &str)],
2204 ) -> WireEnvelope {
2205 envelope(
2206 op,
2207 id,
2208 at,
2209 KernelInput::ResolveEffect(ResolveEffect {
2210 effect_id: EffectId::new(effect_id).unwrap(),
2211 outcome: EffectOutcome::Succeeded(EffectSucceeded {
2212 result: EffectSuccess::TasksSpawned(TasksSpawnedSuccess {
2213 attempts: tasks
2214 .iter()
2215 .map(|(task, attempt)| TaskLaunchOutcome {
2216 task_id: TaskId::new(*task).unwrap(),
2217 attempt_id: AttemptId::new(*attempt).unwrap(),
2218 outcome: TaskLaunchStatus::Started(TaskLaunchStarted {}),
2219 })
2220 .collect(),
2221 }),
2222 }),
2223 }),
2224 )
2225 }
2226
2227 fn live_chain(envelopes: &[WireEnvelope]) -> Vec<KernelRecord> {
2234 let mut tx = KernelTransaction::new(ConfigDefaults::default(), InMemoryRecordIndex::new());
2235 let mut driver = CanonicalOperationDriver::new();
2236 let mut journal = Vec::new();
2237 for envelope in envelopes {
2238 let preparation = tx.prepare(envelope, |context| driver.plan(context));
2239 let token = preparation
2240 .token()
2241 .unwrap_or_else(|| {
2242 panic!("expected a prepared step, got {:?}", preparation.fault())
2243 })
2244 .clone();
2245 let head = preparation.record().unwrap().record_digest().clone();
2246 let committed = tx.commit(&token, &head).expect("commit must succeed");
2247 journal.push(committed.record.clone());
2248 driver
2249 .note_committed(committed.step_seq)
2250 .expect("the driver folds the step it planned");
2251 }
2252 journal
2253 }
2254
2255 fn hand_chain(envelopes: &[WireEnvelope]) -> Vec<KernelRecord> {
2259 let mut records: Vec<KernelRecord> = Vec::new();
2260 for (index, envelope) in envelopes.iter().enumerate() {
2261 let input = NormalizedInput::normalize(envelope, &ConfigDefaults::default())
2262 .expect("the envelope normalises");
2263 let step = json!({ "planned": format!("step-{index}"), "effects": [] });
2264 let record =
2265 KernelRecord::chain(records.last(), &input, &step).expect("the record chains");
2266 records.push(record);
2267 }
2268 records
2269 }
2270
2271 fn blobs(records: &[KernelRecord]) -> Vec<Vec<u8>> {
2272 records
2273 .iter()
2274 .map(|record| record.record_bytes().into_vec())
2275 .collect()
2276 }
2277
2278 fn rule<'a>(report: &'a ValidationReport, segment: usize, id: &str) -> &'a RuleReport {
2279 report.segments[segment]
2280 .rules
2281 .iter()
2282 .find(|rule| rule.rule == id)
2283 .unwrap_or_else(|| panic!("segment {segment} has no {id} verdict"))
2284 }
2285
2286 fn cross<'a>(report: &'a ValidationReport, id: &str) -> &'a RuleReport {
2287 report
2288 .cross_checks
2289 .iter()
2290 .find(|rule| rule.rule == id)
2291 .unwrap_or_else(|| panic!("the report has no {id} cross-check"))
2292 }
2293
2294 #[test]
2299 fn a_green_agent_chain_passes_every_rule() {
2300 let op = operation("op-green-agent");
2301 let chain = live_chain(&[
2302 configure_envelope(&op),
2303 agent_start_envelope(&op),
2304 resolve_overflow_envelope(&op, 1),
2305 ]);
2306 let report = validate_journal(&blobs(&chain));
2307 assert_eq!(report.segments.len(), 1);
2308 for id in ["C1", "C2", "C3", "C4"] {
2309 assert_eq!(
2310 rule(&report, 0, id).verdict,
2311 Verdict::Pass,
2312 "{id}: {}",
2313 rule(&report, 0, id).detail
2314 );
2315 }
2316 assert!(
2317 rule(&report, 0, "C3")
2318 .detail
2319 .contains("every durable record digest reproduced"),
2320 "C3 proves the re-plan: {}",
2321 rule(&report, 0, "C3").detail
2322 );
2323 assert_eq!(report.exit_code(), 0);
2324 assert_eq!(report.unparseable_records, 0);
2325 }
2326
2327 #[test]
2328 fn a_green_workflow_chain_passes_c4_with_real_launches() {
2329 let op = operation("op-green-workflow");
2330 let chain = live_chain(&[
2331 configure_envelope(&op),
2332 workflow_start_envelope(&op),
2333 resolve_spawn_envelope(
2334 &op,
2335 "in-ack-1",
2336 1_700_000_002_000,
2337 "op-green-workflow:step:1:effect:0",
2338 &[("wf-node0", "wf-node0:attempt:1")],
2339 ),
2340 ]);
2341 let report = validate_journal(&blobs(&chain));
2342 assert_eq!(report.segments.len(), 1);
2343 for id in ["C1", "C2", "C3", "C4"] {
2344 assert_eq!(
2345 rule(&report, 0, id).verdict,
2346 Verdict::Pass,
2347 "{id}: {}",
2348 rule(&report, 0, id).detail
2349 );
2350 }
2351 assert!(
2352 rule(&report, 0, "C4").detail.contains("1 launch(es)"),
2353 "{}",
2354 rule(&report, 0, "C4").detail
2355 );
2356 assert_eq!(report.deferred.len(), 2, "batch-1 scope limits are named");
2357 assert_eq!(report.exit_code(), 0);
2358 }
2359
2360 #[test]
2361 fn input_order_is_a_storage_detail() {
2362 let op = operation("op-shuffled");
2363 let chain = live_chain(&[
2364 configure_envelope(&op),
2365 agent_start_envelope(&op),
2366 resolve_overflow_envelope(&op, 1),
2367 ]);
2368 let mut shuffled = blobs(&chain);
2369 shuffled.reverse();
2370 let report = validate_journal(&shuffled);
2371 assert_eq!(
2372 report.exit_code(),
2373 0,
2374 "the chain's own links define the order"
2375 );
2376 }
2377
2378 #[test]
2379 fn two_operations_validate_as_independent_segments() {
2380 let op_a = operation("op-seg-a");
2381 let op_b = operation("op-seg-b");
2382 let chain_a = live_chain(&[configure_envelope(&op_a), agent_start_envelope(&op_a)]);
2383 let chain_b = live_chain(&[configure_envelope(&op_b), agent_start_envelope(&op_b)]);
2384 let mut mixed = Vec::new();
2386 for index in 0..2 {
2387 mixed.push(chain_a[index].record_bytes().into_vec());
2388 mixed.push(chain_b[index].record_bytes().into_vec());
2389 }
2390 let report = validate_journal(&mixed);
2391 assert_eq!(report.segments.len(), 2);
2392 assert_eq!(report.exit_code(), 0);
2393 }
2394
2395 #[test]
2400 fn a_gap_in_the_chain_fails_c1_and_degrades_c3() {
2401 let op = operation("op-gapped");
2402 let chain = live_chain(&[
2403 configure_envelope(&op),
2404 agent_start_envelope(&op),
2405 resolve_overflow_envelope(&op, 1),
2406 ]);
2407 let gapped = blobs(&[chain[0].clone(), chain[2].clone()]);
2408 let report = validate_journal(&gapped);
2409 assert_eq!(rule(&report, 0, "C1").verdict, Verdict::Fail);
2410 assert_eq!(
2411 rule(&report, 0, "C3").verdict,
2412 Verdict::Degraded,
2413 "a re-plan over a broken chain would only re-report the C1 break"
2414 );
2415 assert_eq!(report.exit_code(), 1);
2416 }
2417
2418 #[test]
2423 fn two_different_records_for_one_input_fail_c2() {
2424 let op = operation("op-dup-input");
2425 let chain_a = hand_chain(&[configure_envelope(&op), agent_start_envelope(&op)]);
2428 let mut later_start = agent_start_envelope(&op);
2429 later_start.observed_at_ms = WireU64::new(1_700_000_001_500);
2430 let chain_b = hand_chain(&[configure_envelope(&op), later_start]);
2431 assert_ne!(
2432 chain_a[1].record_digest(),
2433 chain_b[1].record_digest(),
2434 "the fixture must produce two different records for one input id"
2435 );
2436 let report = validate_journal(&blobs(&[
2437 chain_a[0].clone(),
2438 chain_a[1].clone(),
2439 chain_b[1].clone(),
2440 ]));
2441 assert_eq!(rule(&report, 0, "C2").verdict, Verdict::Fail);
2442 assert!(
2443 rule(&report, 0, "C2").detail.contains("in-start"),
2444 "{}",
2445 rule(&report, 0, "C2").detail
2446 );
2447 assert_eq!(report.exit_code(), 1);
2448 }
2449
2450 #[test]
2455 fn a_repeated_attempt_pair_is_a_reused_launch_token() {
2456 let op = operation("op-dup-launch");
2457 let chain = hand_chain(&[
2458 configure_envelope(&op),
2459 resolve_spawn_envelope(
2460 &op,
2461 "in-ack-1",
2462 1_700_000_001_000,
2463 "op-dup-launch:step:0:effect:0",
2464 &[("writer", "writer:attempt:1")],
2465 ),
2466 resolve_spawn_envelope(
2467 &op,
2468 "in-ack-2",
2469 1_700_000_002_000,
2470 "op-dup-launch:step:0:effect:0",
2471 &[("writer", "writer:attempt:1")],
2472 ),
2473 ]);
2474 let report = validate_journal(&blobs(&chain));
2475 assert_eq!(rule(&report, 0, "C1").verdict, Verdict::Pass);
2476 assert_eq!(rule(&report, 0, "C4").verdict, Verdict::Fail);
2477 assert!(
2478 rule(&report, 0, "C4").detail.contains("LaunchToken"),
2479 "the verdict names the token reuse: {}",
2480 rule(&report, 0, "C4").detail
2481 );
2482 assert_eq!(report.exit_code(), 1);
2483 }
2484
2485 #[test]
2486 fn a_resolution_naming_a_future_step_fails_c4() {
2487 let op = operation("op-future-effect");
2488 let chain = hand_chain(&[
2489 configure_envelope(&op),
2490 resolve_spawn_envelope(
2491 &op,
2492 "in-ack-1",
2493 1_700_000_001_000,
2494 "op-future-effect:step:5:effect:0",
2495 &[("writer", "writer:attempt:1")],
2496 ),
2497 ]);
2498 let report = validate_journal(&blobs(&chain));
2499 assert_eq!(rule(&report, 0, "C4").verdict, Verdict::Fail);
2500 assert!(
2501 rule(&report, 0, "C4")
2502 .detail
2503 .contains("precedes the publication"),
2504 "{}",
2505 rule(&report, 0, "C4").detail
2506 );
2507 }
2508
2509 #[test]
2510 fn a_resolution_naming_another_operation_fails_c4() {
2511 let op = operation("op-foreign-effect");
2512 let chain = hand_chain(&[
2513 configure_envelope(&op),
2514 resolve_spawn_envelope(
2515 &op,
2516 "in-ack-1",
2517 1_700_000_001_000,
2518 "op-somewhere-else:step:0:effect:0",
2519 &[("writer", "writer:attempt:1")],
2520 ),
2521 ]);
2522 let report = validate_journal(&blobs(&chain));
2523 assert_eq!(rule(&report, 0, "C4").verdict, Verdict::Fail);
2524 assert!(
2525 rule(&report, 0, "C4").detail.contains("another operation"),
2526 "{}",
2527 rule(&report, 0, "C4").detail
2528 );
2529 }
2530
2531 #[test]
2536 fn a_tampered_hop_fails_integrity_validation() {
2537 let op = operation("op-tampered");
2538 let chain = live_chain(&[
2539 configure_envelope(&op),
2540 agent_start_envelope(&op),
2541 resolve_overflow_envelope(&op, 1),
2542 ]);
2543 let mut input = blobs(&chain);
2544 let mut forged: serde_json::Value = serde_json::from_slice(&input[1]).unwrap();
2547 forged["step_digest"] = serde_json::Value::String(chain[0].record_digest().to_string());
2548 input[1] = serde_json::to_vec(&forged).unwrap();
2549
2550 let report = validate_journal(&input);
2551 assert_eq!(report.segments.len(), 1);
2552 assert_eq!(report.segments[0].degraded_hops.len(), 1);
2553 assert_eq!(
2554 rule(&report, 0, "C1").verdict,
2555 Verdict::Fail,
2556 "a digest mismatch must fail C1: {}",
2557 rule(&report, 0, "C1").detail
2558 );
2559 assert_eq!(rule(&report, 0, "C3").verdict, Verdict::Degraded);
2560 assert_eq!(rule(&report, 0, "C4").verdict, Verdict::Degraded);
2561 assert_eq!(
2562 report.exit_code(),
2563 1,
2564 "proven digest corruption must fail the validator"
2565 );
2566 }
2567
2568 #[test]
2569 fn missing_legacy_digest_degrades_without_claiming_corruption() {
2570 let op = operation("op-legacy");
2571 let chain = live_chain(&[configure_envelope(&op)]);
2572 let mut legacy: serde_json::Value = serde_json::from_slice(&blobs(&chain)[0]).unwrap();
2573 legacy.as_object_mut().unwrap().remove("step_digest");
2574 let report = validate_journal(&[serde_json::to_vec(&legacy).unwrap()]);
2575 assert_eq!(rule(&report, 0, "C1").verdict, Verdict::Degraded);
2576 assert_eq!(report.exit_code(), 0);
2577 }
2578
2579 #[test]
2580 fn unparseable_input_is_evidence_insufficient_not_guilty() {
2581 let report = validate_journal(&[b"this is not a record".to_vec()]);
2582 assert!(report.segments.is_empty());
2583 assert_eq!(report.unparseable_records, 1);
2584 assert_eq!(report.exit_code(), 2);
2585 }
2586
2587 #[test]
2588 fn garbage_beside_a_green_chain_stays_exit_2_without_a_violation() {
2589 let op = operation("op-plus-garbage");
2590 let chain = live_chain(&[configure_envelope(&op), agent_start_envelope(&op)]);
2591 let mut input = blobs(&chain);
2592 input.push(b"this is not a record".to_vec());
2593 let report = validate_journal(&input);
2594 assert_eq!(report.segments.len(), 1);
2595 assert_eq!(rule(&report, 0, "C1").verdict, Verdict::Pass);
2596 assert_eq!(report.unparseable_records, 1);
2597 assert_eq!(
2598 report.exit_code(),
2599 2,
2600 "no violation was proven, but the evidence was partially unreadable"
2601 );
2602 }
2603
2604 #[test]
2605 fn an_empty_journal_is_evidence_insufficient() {
2606 let report = validate_journal::<Vec<u8>>(&[]);
2607 assert_eq!(report.exit_code(), 2);
2608 }
2609
2610 fn session_event(value: serde_json::Value) -> Vec<u8> {
2615 serde_json::to_vec(&value).unwrap()
2616 }
2617
2618 #[test]
2619 fn session_events_classify_leniently_across_host_spellings() {
2620 let node_attempt = session_event(json!({
2621 "kind": "provider_attempt",
2622 "effect_id": "op:step:1:effect:0",
2623 "request_fingerprint": "fp-1",
2624 "route": { "routeId": "route-a", "provider": "p" },
2625 "status": "success"
2626 }));
2627 let py_attempt = session_event(json!({
2628 "kind": "provider_attempt",
2629 "effect_id": "op:step:2:effect:0",
2630 "route": { "route_id": "route-b" }
2631 }));
2632 let run_started = session_event(json!({
2633 "kind": "run_started",
2634 "run_id": "run-1",
2635 "route": { "routeId": "route-a" }
2636 }));
2637 let node_measured = session_event(json!({
2638 "kind": "prompt_measured",
2639 "turn": 1,
2640 "effect_id": "op:step:1:effect:0",
2641 "measurement": { "requestFingerprint": "fp-1", "inputTokens": 10 }
2642 }));
2643 let py_measured = session_event(json!({
2644 "kind": "prompt_measured",
2645 "measurement": { "request_fingerprint": "fp-2" }
2646 }));
2647 let llm_completed = session_event(json!({
2648 "kind": "llm_completed",
2649 "effect_id": "op:step:2:effect:0",
2650 "invocation_id": "op:step:1:effect:0"
2651 }));
2652 let unknown_kind = session_event(json!({ "kind": "compressed", "turn": 3 }));
2653 let kindless = session_event(json!({ "turn": 3 }));
2654
2655 assert_eq!(
2656 classify_session_event(&node_attempt),
2657 Some(EvidenceEvent::ProviderAttempt {
2658 effect_id: Some("op:step:1:effect:0".to_string()),
2659 request_fingerprint: Some("fp-1".to_string()),
2660 route_id: Some("route-a".to_string()),
2661 status: Some("success".to_string()),
2662 })
2663 );
2664 assert_eq!(
2665 classify_session_event(&py_attempt),
2666 Some(EvidenceEvent::ProviderAttempt {
2667 effect_id: Some("op:step:2:effect:0".to_string()),
2668 request_fingerprint: None,
2669 route_id: Some("route-b".to_string()),
2670 status: None,
2671 })
2672 );
2673 assert_eq!(
2674 classify_session_event(&run_started),
2675 Some(EvidenceEvent::RunStarted {
2676 route_id: Some("route-a".to_string())
2677 })
2678 );
2679 assert_eq!(
2680 classify_session_event(&node_measured),
2681 Some(EvidenceEvent::PromptMeasured {
2682 effect_id: Some("op:step:1:effect:0".to_string()),
2683 request_fingerprint: Some("fp-1".to_string()),
2684 })
2685 );
2686 assert_eq!(
2687 classify_session_event(&py_measured),
2688 Some(EvidenceEvent::PromptMeasured {
2689 effect_id: None,
2690 request_fingerprint: Some("fp-2".to_string()),
2691 })
2692 );
2693 assert_eq!(
2694 classify_session_event(&llm_completed),
2695 Some(EvidenceEvent::LlmCompleted {
2696 effect_id: Some("op:step:2:effect:0".to_string()),
2697 invocation_id: Some("op:step:1:effect:0".to_string()),
2698 })
2699 );
2700 assert_eq!(
2701 classify_session_event(&unknown_kind),
2702 Some(EvidenceEvent::Other),
2703 "unknown kinds are parseable but ignored — the vocabulary evolves"
2704 );
2705 assert_eq!(
2706 classify_session_event(&kindless),
2707 Some(EvidenceEvent::Other)
2708 );
2709 assert_eq!(
2710 classify_session_event(b"not json"),
2711 None,
2712 "a non-object event blob is unparseable input, never a violation"
2713 );
2714 }
2715
2716 #[test]
2717 fn dual_input_with_a_green_journal_and_real_events_stays_green() {
2718 let op = operation("op-dual-green");
2719 let chain = live_chain(&[configure_envelope(&op), agent_start_envelope(&op)]);
2720 let stream = vec![
2721 session_event(json!({
2722 "kind": "run_started",
2723 "run_id": "r1",
2724 "route": { "routeId": "route-a" }
2725 })),
2726 session_event(json!({
2727 "kind": "prompt_measured",
2728 "turn": 1,
2729 "effect_id": "op-dual-green:step:1:effect:0",
2730 "measurement": { "requestFingerprint": "fp-1", "inputTokens": 10 }
2731 })),
2732 session_event(json!({
2733 "kind": "provider_attempt",
2734 "effect_id": "op-dual-green:step:1:effect:0",
2735 "request_fingerprint": "fp-1",
2736 "route": { "routeId": "route-a" },
2737 "status": "success"
2738 })),
2739 session_event(json!({
2740 "kind": "llm_completed",
2741 "turn": 1,
2742 "effect_id": "op-dual-green:step:1:effect:0",
2743 "invocation_id": "op-dual-green:step:1:effect:0"
2744 })),
2745 ];
2746 let report = validate_with_session_log(&blobs(&chain), &[stream]);
2747 assert_eq!(report.session_events, Some(4));
2748 assert_eq!(report.unparseable_events, 0);
2749 for id in ["C6.1", "C6.2", "C6.3", "C8"] {
2750 assert_eq!(
2751 cross(&report, id).verdict,
2752 Verdict::Pass,
2753 "{id}: {}",
2754 cross(&report, id).detail
2755 );
2756 }
2757 assert!(
2758 !report
2759 .deferred
2760 .iter()
2761 .any(|line| line.starts_with("c6.") || line.starts_with("c8.")),
2762 "C6/C8 are implemented — the interim scope notes are gone"
2763 );
2764 assert_eq!(report.exit_code(), 0);
2765 }
2766
2767 #[test]
2768 fn journal_only_validation_carries_no_session_plane() {
2769 let op = operation("op-journal-only");
2770 let chain = live_chain(&[configure_envelope(&op), agent_start_envelope(&op)]);
2771 let report = validate_journal(&blobs(&chain));
2772 assert_eq!(report.session_events, None);
2773 assert_eq!(report.unparseable_events, 0);
2774 assert_eq!(
2775 report.deferred.len(),
2776 2,
2777 "batch-1 deferred scope is unchanged"
2778 );
2779 assert_eq!(report.exit_code(), 0);
2780 }
2781
2782 #[test]
2783 fn an_empty_session_plane_is_evidence_insufficient() {
2784 let op = operation("op-empty-session");
2785 let chain = live_chain(&[configure_envelope(&op), agent_start_envelope(&op)]);
2786 let report = validate_with_session_log(&blobs(&chain), &[Vec::<Vec<u8>>::new()]);
2787 assert_eq!(report.session_events, Some(0));
2788 assert!(
2789 !report.has_violations(),
2790 "an empty log proves nothing either way"
2791 );
2792 assert_eq!(report.exit_code(), 2);
2793 }
2794
2795 #[test]
2796 fn garbage_session_events_are_evidence_insufficient_not_guilty() {
2797 let op = operation("op-garbage-session");
2798 let chain = live_chain(&[configure_envelope(&op), agent_start_envelope(&op)]);
2799 let stream = vec![
2800 session_event(json!({ "kind": "run_started", "run_id": "r1" })),
2801 b"this is not an event".to_vec(),
2802 ];
2803 let report = validate_with_session_log(&blobs(&chain), &[stream]);
2804 assert_eq!(report.session_events, Some(1));
2805 assert_eq!(report.unparseable_events, 1);
2806 assert!(!report.has_violations());
2807 assert_eq!(report.exit_code(), 2);
2808 }
2809
2810 fn honest_dual_input(op_name: &str) -> (Vec<KernelRecord>, Vec<Vec<u8>>) {
2817 let op = operation(op_name);
2818 let chain = live_chain(&[
2819 configure_envelope(&op),
2820 agent_start_envelope(&op),
2821 resolve_overflow_envelope(&op, 1),
2822 ]);
2823 let effect = format!("{op_name}:step:1:effect:0");
2824 let stream = vec![
2825 session_event(json!({
2826 "kind": "run_started",
2827 "run_id": "r1",
2828 "route": { "routeId": "route-a" }
2829 })),
2830 session_event(json!({
2831 "kind": "prompt_measured",
2832 "turn": 1,
2833 "effect_id": effect,
2834 "measurement": { "requestFingerprint": "fp-1", "inputTokens": 10 }
2835 })),
2836 session_event(json!({
2837 "kind": "provider_attempt",
2838 "effect_id": effect,
2839 "request_fingerprint": "fp-1",
2840 "route": { "routeId": "route-a" },
2841 "status": "success"
2842 })),
2843 ];
2844 (chain, stream)
2845 }
2846
2847 #[test]
2848 fn an_attempt_naming_an_effect_the_replan_never_published_fails_c6() {
2849 let (chain, mut stream) = honest_dual_input("op-forged-effect");
2850 stream[2] = session_event(json!({
2851 "kind": "provider_attempt",
2852 "effect_id": "op-forged-effect:step:1:effect:7",
2853 "request_fingerprint": "fp-1",
2854 "route": { "routeId": "route-a" },
2855 "status": "success"
2856 }));
2857 let report = validate_with_session_log(&blobs(&chain), &[stream]);
2858 assert_eq!(cross(&report, "C6.1").verdict, Verdict::Fail);
2859 assert!(
2860 cross(&report, "C6.1").detail.contains("never published"),
2861 "{}",
2862 cross(&report, "C6.1").detail
2863 );
2864 assert_eq!(
2865 report.exit_code(),
2866 1,
2867 "the forged attempt turns the run red"
2868 );
2869 }
2870
2871 #[test]
2872 fn an_attempt_without_an_effect_id_fails_c6_as_forged_evidence() {
2873 let (chain, mut stream) = honest_dual_input("op-keyless-attempt");
2874 stream[2] = session_event(json!({
2875 "kind": "provider_attempt",
2876 "request_fingerprint": "fp-1",
2877 "route": { "routeId": "route-a" },
2878 "status": "success"
2879 }));
2880 let report = validate_with_session_log(&blobs(&chain), &[stream]);
2881 assert_eq!(cross(&report, "C6.1").verdict, Verdict::Fail);
2882 assert!(
2883 cross(&report, "C6.1").detail.contains("without effect_id"),
2884 "{}",
2885 cross(&report, "C6.1").detail
2886 );
2887 assert_eq!(report.exit_code(), 1);
2888 }
2889
2890 #[test]
2891 fn an_attempt_past_the_journal_tip_degrades_c6_instead_of_failing() {
2892 let (chain, mut stream) = honest_dual_input("op-prefix-attempt");
2893 stream[2] = session_event(json!({
2894 "kind": "provider_attempt",
2895 "effect_id": "op-prefix-attempt:step:9:effect:0",
2896 "request_fingerprint": "fp-1",
2897 "route": { "routeId": "route-a" },
2898 "status": "success"
2899 }));
2900 let report = validate_with_session_log(&blobs(&chain), &[stream]);
2901 assert_eq!(
2902 cross(&report, "C6.1").verdict,
2903 Verdict::Degraded,
2904 "a journal prefix cannot disprove an effect past its tip: {}",
2905 cross(&report, "C6.1").detail
2906 );
2907 assert_eq!(report.exit_code(), 0, "degradation never turns the run red");
2908 }
2909
2910 #[test]
2911 fn an_attempt_on_an_operation_without_a_segment_degrades_c6() {
2912 let (chain, mut stream) = honest_dual_input("op-subset-journal");
2913 stream[2] = session_event(json!({
2914 "kind": "provider_attempt",
2915 "effect_id": "op-elsewhere:step:1:effect:0",
2916 "request_fingerprint": "fp-1",
2917 "route": { "routeId": "route-a" },
2918 "status": "success"
2919 }));
2920 let report = validate_with_session_log(&blobs(&chain), &[stream]);
2921 assert_eq!(cross(&report, "C6.1").verdict, Verdict::Degraded);
2922 assert!(
2923 cross(&report, "C6.1").detail.contains("no journal segment"),
2924 "{}",
2925 cross(&report, "C6.1").detail
2926 );
2927 }
2928
2929 #[test]
2930 fn an_orphan_fingerprint_fails_c6() {
2931 let (chain, mut stream) = honest_dual_input("op-orphan-fp");
2932 stream.remove(1); let report = validate_with_session_log(&blobs(&chain), &[stream]);
2934 assert_eq!(cross(&report, "C6.2").verdict, Verdict::Fail);
2935 assert!(
2936 cross(&report, "C6.2").detail.contains("fp-1"),
2937 "{}",
2938 cross(&report, "C6.2").detail
2939 );
2940 assert_eq!(report.exit_code(), 1);
2941 }
2942
2943 #[test]
2944 fn an_attempt_without_a_fingerprint_fails_c6() {
2945 let (chain, mut stream) = honest_dual_input("op-fpless-attempt");
2946 stream[2] = session_event(json!({
2947 "kind": "provider_attempt",
2948 "effect_id": "op-fpless-attempt:step:1:effect:0",
2949 "route": { "routeId": "route-a" },
2950 "status": "success"
2951 }));
2952 let report = validate_with_session_log(&blobs(&chain), &[stream]);
2953 assert_eq!(cross(&report, "C6.2").verdict, Verdict::Fail);
2954 assert!(
2955 cross(&report, "C6.2")
2956 .detail
2957 .contains("without request_fingerprint"),
2958 "{}",
2959 cross(&report, "C6.2").detail
2960 );
2961 }
2962
2963 #[test]
2964 fn an_in_run_route_change_fails_c6() {
2965 let (chain, mut stream) = honest_dual_input("op-route-flip");
2966 stream[2] = session_event(json!({
2967 "kind": "provider_attempt",
2968 "effect_id": "op-route-flip:step:1:effect:0",
2969 "request_fingerprint": "fp-1",
2970 "route": { "routeId": "route-b" },
2971 "status": "success"
2972 }));
2973 let report = validate_with_session_log(&blobs(&chain), &[stream]);
2974 assert_eq!(cross(&report, "C6.3").verdict, Verdict::Fail);
2975 assert!(
2976 cross(&report, "C6.3")
2977 .detail
2978 .contains("in-run route change"),
2979 "{}",
2980 cross(&report, "C6.3").detail
2981 );
2982 assert_eq!(report.exit_code(), 1);
2983 }
2984
2985 #[test]
2986 fn a_cross_resume_route_change_degrades_c6_per_q3() {
2987 let (chain, mut stream) = honest_dual_input("op-route-resume");
2988 stream.push(session_event(json!({
2991 "kind": "run_started",
2992 "run_id": "r1",
2993 "route": { "routeId": "route-b" }
2994 })));
2995 stream.push(session_event(json!({
2996 "kind": "prompt_measured",
2997 "turn": 2,
2998 "effect_id": "op-route-resume:step:1:effect:0",
2999 "measurement": { "requestFingerprint": "fp-2", "inputTokens": 11 }
3000 })));
3001 stream.push(session_event(json!({
3002 "kind": "provider_attempt",
3003 "effect_id": "op-route-resume:step:1:effect:0",
3004 "request_fingerprint": "fp-2",
3005 "route": { "routeId": "route-b" },
3006 "status": "success"
3007 })));
3008 let report = validate_with_session_log(&blobs(&chain), &[stream]);
3009 assert_eq!(
3010 cross(&report, "C6.3").verdict,
3011 Verdict::Degraded,
3012 "cross-resume route changes mark, they do not fail: {}",
3013 cross(&report, "C6.3").detail
3014 );
3015 assert!(
3016 cross(&report, "C6.3").detail.contains("cross-resume"),
3017 "{}",
3018 cross(&report, "C6.3").detail
3019 );
3020 assert_eq!(report.exit_code(), 0);
3021 }
3022
3023 #[test]
3024 fn a_pre_0_2_63_log_without_attempts_degrades_every_c6_clause() {
3025 let op = operation("op-old-log");
3026 let chain = live_chain(&[configure_envelope(&op), agent_start_envelope(&op)]);
3027 let stream = vec![
3028 session_event(json!({ "kind": "run_started", "run_id": "r1" })),
3029 session_event(json!({ "kind": "llm_completed", "turn": 1, "content": "done" })),
3030 ];
3031 let report = validate_with_session_log(&blobs(&chain), &[stream]);
3032 for id in ["C6.1", "C6.2", "C6.3", "C8"] {
3033 assert_eq!(
3034 cross(&report, id).verdict,
3035 Verdict::Degraded,
3036 "{id}: old logs degrade (C7), never fail — {}",
3037 cross(&report, id).detail
3038 );
3039 }
3040 assert_eq!(report.exit_code(), 0);
3041 }
3042
3043 fn overflow_retry_chain(op_name: &str) -> Vec<KernelRecord> {
3053 let op = operation(op_name);
3054 live_chain(&[
3055 configure_envelope(&op),
3056 agent_start_with_history_envelope(&op, 14),
3057 resolve_overflow_envelope(&op, 1),
3058 resolve_completed_envelope(&op, "in-resolve-2", 1_700_000_003_000, 2, false),
3059 ])
3060 }
3061
3062 #[test]
3063 fn an_honest_overflow_retry_invocation_passes_c8() {
3064 let chain = overflow_retry_chain("op-c8-green");
3065 let stream = vec![
3066 session_event(json!({ "kind": "run_started", "run_id": "r1" })),
3067 session_event(json!({
3068 "kind": "llm_completed",
3069 "turn": 1,
3070 "effect_id": "op-c8-green:step:2:effect:0",
3071 "invocation_id": "op-c8-green:step:1:effect:0"
3072 })),
3073 ];
3074 let report = validate_with_session_log(&blobs(&chain), &[stream]);
3075 assert_eq!(
3076 cross(&report, "C8").verdict,
3077 Verdict::Pass,
3078 "{}",
3079 cross(&report, "C8").detail
3080 );
3081 assert!(
3082 cross(&report, "C8")
3083 .detail
3084 .contains("1 retried invocation(s)"),
3085 "{}",
3086 cross(&report, "C8").detail
3087 );
3088 assert_eq!(report.exit_code(), 0);
3089 }
3090
3091 #[test]
3092 fn a_completed_chain_head_is_the_merge_forgery() {
3093 let op = operation("op-c8-merged");
3097 let chain = live_chain(&[
3098 configure_envelope(&op),
3099 agent_start_envelope(&op),
3100 resolve_completed_envelope(&op, "in-resolve-1", 1_700_000_002_000, 1, true),
3101 ]);
3102 let stream = vec![session_event(json!({
3103 "kind": "llm_completed",
3104 "turn": 1,
3105 "effect_id": "op-c8-merged:step:2:effect:0",
3106 "invocation_id": "op-c8-merged:step:1:effect:0"
3107 }))];
3108 let report = validate_with_session_log(&blobs(&chain), &[stream]);
3109 assert_eq!(cross(&report, "C8").verdict, Verdict::Fail);
3110 assert!(
3111 cross(&report, "C8")
3112 .detail
3113 .contains("closes its invocation"),
3114 "{}",
3115 cross(&report, "C8").detail
3116 );
3117 assert_eq!(report.exit_code(), 1);
3118 }
3119
3120 #[test]
3121 fn a_selected_effect_preceding_the_chain_head_fails_c8() {
3122 let chain = overflow_retry_chain("op-c8-backwards");
3123 let stream = vec![session_event(json!({
3124 "kind": "llm_completed",
3125 "turn": 1,
3126 "effect_id": "op-c8-backwards:step:1:effect:0",
3127 "invocation_id": "op-c8-backwards:step:2:effect:0"
3128 }))];
3129 let report = validate_with_session_log(&blobs(&chain), &[stream]);
3130 assert_eq!(cross(&report, "C8").verdict, Verdict::Fail);
3131 assert!(
3132 cross(&report, "C8")
3133 .detail
3134 .contains("does not follow the chain head"),
3135 "{}",
3136 cross(&report, "C8").detail
3137 );
3138 }
3139
3140 #[test]
3141 fn a_selected_effect_the_replan_never_published_fails_c8() {
3142 let chain = overflow_retry_chain("op-c8-phantom");
3143 let stream = vec![session_event(json!({
3144 "kind": "llm_completed",
3145 "turn": 1,
3146 "effect_id": "op-c8-phantom:step:2:effect:9",
3147 "invocation_id": "op-c8-phantom:step:1:effect:0"
3148 }))];
3149 let report = validate_with_session_log(&blobs(&chain), &[stream]);
3150 assert_eq!(cross(&report, "C8").verdict, Verdict::Fail);
3151 assert!(
3152 cross(&report, "C8").detail.contains("never published"),
3153 "{}",
3154 cross(&report, "C8").detail
3155 );
3156 assert_eq!(report.exit_code(), 1);
3157 }
3158
3159 #[test]
3160 fn a_first_try_invocation_has_no_adjacency_to_prove() {
3161 let op = operation("op-c8-first-try");
3162 let chain = live_chain(&[
3163 configure_envelope(&op),
3164 agent_start_envelope(&op),
3165 resolve_completed_envelope(&op, "in-resolve-1", 1_700_000_002_000, 1, false),
3166 ]);
3167 let stream = vec![session_event(json!({
3168 "kind": "llm_completed",
3169 "turn": 1,
3170 "effect_id": "op-c8-first-try:step:1:effect:0",
3171 "invocation_id": "op-c8-first-try:step:1:effect:0"
3172 }))];
3173 let report = validate_with_session_log(&blobs(&chain), &[stream]);
3174 assert_eq!(
3175 cross(&report, "C8").verdict,
3176 Verdict::Pass,
3177 "{}",
3178 cross(&report, "C8").detail
3179 );
3180 assert!(
3181 cross(&report, "C8").detail.contains("1 first-try"),
3182 "{}",
3183 cross(&report, "C8").detail
3184 );
3185 assert_eq!(report.exit_code(), 0);
3186 }
3187
3188 #[test]
3189 fn an_invocation_past_the_journal_tip_degrades_c8() {
3190 let op = operation("op-c8-lag");
3193 let chain = live_chain(&[configure_envelope(&op), agent_start_envelope(&op)]);
3194 let stream = vec![session_event(json!({
3195 "kind": "llm_completed",
3196 "turn": 1,
3197 "effect_id": "op-c8-lag:step:2:effect:0",
3198 "invocation_id": "op-c8-lag:step:1:effect:0"
3199 }))];
3200 let report = validate_with_session_log(&blobs(&chain), &[stream]);
3201 assert_eq!(
3202 cross(&report, "C8").verdict,
3203 Verdict::Degraded,
3204 "{}",
3205 cross(&report, "C8").detail
3206 );
3207 assert_eq!(report.exit_code(), 0);
3208 }
3209
3210 use crate::runtime::kernel::wire::checkpoint::{
3215 CheckpointDraft, KernelCheckpoint, LaunchTokenState,
3216 };
3217 use crate::runtime::kernel::wire::driver::PlannedStep;
3218 use crate::runtime::kernel::wire::transaction::CheckpointBoundary;
3219 use std::path::PathBuf;
3220
3221 fn live_runtime(
3224 envelopes: &[WireEnvelope],
3225 ) -> (
3226 Vec<KernelRecord>,
3227 KernelTransaction<PlannedStep, InMemoryRecordIndex>,
3228 CanonicalOperationDriver,
3229 ) {
3230 let mut tx = KernelTransaction::new(ConfigDefaults::default(), InMemoryRecordIndex::new());
3231 let mut driver = CanonicalOperationDriver::new();
3232 let mut journal = Vec::new();
3233 for envelope in envelopes {
3234 let preparation = tx.prepare(envelope, |context| driver.plan(context));
3235 let token = preparation
3236 .token()
3237 .unwrap_or_else(|| {
3238 panic!("expected a prepared step, got {:?}", preparation.fault())
3239 })
3240 .clone();
3241 let head = preparation.record().unwrap().record_digest().clone();
3242 let committed = tx.commit(&token, &head).expect("commit must succeed");
3243 journal.push(committed.record.clone());
3244 driver
3245 .note_committed(committed.step_seq)
3246 .expect("the driver folds the step it planned");
3247 }
3248 (journal, tx, driver)
3249 }
3250
3251 fn checkpoint_at_head(
3252 tx: &KernelTransaction<PlannedStep, InMemoryRecordIndex>,
3253 driver: &CanonicalOperationDriver,
3254 ) -> KernelCheckpoint {
3255 tx.checkpoint_candidate(driver.project_logical_state())
3256 .expect("the head checkpoints")
3257 .decode()
3258 .expect("the candidate decodes")
3259 }
3260
3261 fn checkpoint_blob(checkpoint: &KernelCheckpoint) -> Vec<u8> {
3262 checkpoint.checkpoint_bytes().into_vec()
3263 }
3264
3265 fn checkpoint_check<'a>(report: &'a ValidationReport, id: &str) -> &'a RuleReport {
3266 report
3267 .checkpoint_checks
3268 .iter()
3269 .find(|rule| rule.rule == id)
3270 .unwrap_or_else(|| panic!("the report has no {id} checkpoint-check"))
3271 }
3272
3273 fn no_streams() -> Vec<Vec<Vec<u8>>> {
3274 Vec::new()
3275 }
3276
3277 #[test]
3278 fn without_a_checkpoint_plane_c5_is_deferred_not_red() {
3279 let op = operation("op-c5-deferred");
3280 let chain = live_chain(&[configure_envelope(&op), agent_start_envelope(&op)]);
3281 let report = validate_journal(&blobs(&chain));
3282 assert!(report.checkpoint_checks.is_empty());
3283 assert_eq!(report.checkpoints, None);
3284 assert_eq!(report.unparseable_checkpoints, 0);
3285 assert_eq!(
3286 report.deferred.len(),
3287 2,
3288 "the checkpoint plane was never offered"
3289 );
3290 assert!(
3291 report.deferred[1].contains("c5b.launch_token_ledger"),
3292 "{}",
3293 report.deferred[1]
3294 );
3295 assert_eq!(report.exit_code(), 0);
3296 }
3297
3298 #[test]
3299 fn a_real_checkpoint_anchors_its_journal() {
3300 let op = operation("op-c5-green");
3301 let (chain, tx, driver) = live_runtime(&[
3302 configure_envelope(&op),
3303 agent_start_envelope(&op),
3304 resolve_overflow_envelope(&op, 1),
3305 ]);
3306 let checkpoint = checkpoint_at_head(&tx, &driver);
3307 let report = validate_with_checkpoint(
3308 &blobs(&chain),
3309 &no_streams(),
3310 &[checkpoint_blob(&checkpoint)],
3311 false,
3312 );
3313 assert_eq!(report.checkpoints, Some(1));
3314 assert_eq!(report.unparseable_checkpoints, 0);
3315 for id in ["C5a", "C5b"] {
3316 assert_eq!(
3317 checkpoint_check(&report, id).verdict,
3318 Verdict::Pass,
3319 "{id}: {}",
3320 checkpoint_check(&report, id).detail
3321 );
3322 }
3323 assert!(
3324 checkpoint_check(&report, "C5a")
3325 .detail
3326 .contains("covered head anchored at step"),
3327 "{}",
3328 checkpoint_check(&report, "C5a").detail
3329 );
3330 assert_eq!(
3331 report.deferred.len(),
3332 1,
3333 "the checkpoint plane retires the c5b deferral"
3334 );
3335 assert_eq!(report.exit_code(), 0);
3336 }
3337
3338 #[test]
3339 fn strict_replay_reproduces_the_covered_state_and_the_ladder_holds() {
3340 let op = operation("op-c5-strict");
3341 let (chain, tx, driver) = live_runtime(&[
3342 configure_envelope(&op),
3343 agent_start_envelope(&op),
3344 resolve_overflow_envelope(&op, 1),
3345 ]);
3346 let checkpoint = checkpoint_at_head(&tx, &driver);
3347 let report = validate_with_checkpoint(
3348 &blobs(&chain),
3349 &no_streams(),
3350 &[checkpoint_blob(&checkpoint)],
3351 true,
3352 );
3353 let c5a = checkpoint_check(&report, "C5a");
3354 assert_eq!(c5a.verdict, Verdict::Pass, "{}", c5a.detail);
3355 assert!(
3356 c5a.detail
3357 .contains("strict replay reproduces the captured state digest"),
3358 "{}",
3359 c5a.detail
3360 );
3361 assert!(
3362 c5a.detail.contains("restore ladder holds"),
3363 "{}",
3364 c5a.detail
3365 );
3366 let c5b = checkpoint_check(&report, "C5b");
3367 assert_eq!(c5b.verdict, Verdict::Pass, "{}", c5b.detail);
3368 assert_eq!(report.exit_code(), 0);
3369 }
3370
3371 #[test]
3372 fn strict_replay_with_tail_records_drives_the_whole_ladder() {
3373 let op = operation("op-c5-strict-tail");
3374 let (mut chain, mut tx, mut driver) =
3375 live_runtime(&[configure_envelope(&op), agent_start_envelope(&op)]);
3376 let checkpoint = checkpoint_at_head(&tx, &driver);
3379 let preparation = tx.prepare(&resolve_overflow_envelope(&op, 1), |context| {
3380 driver.plan(context)
3381 });
3382 let token = preparation.token().expect("the tail step prepares").clone();
3383 let head = preparation.record().unwrap().record_digest().clone();
3384 let committed = tx.commit(&token, &head).expect("the tail step commits");
3385 chain.push(committed.record.clone());
3386 driver
3387 .note_committed(committed.step_seq)
3388 .expect("the driver folds the tail step");
3389
3390 let report = validate_with_checkpoint(
3391 &blobs(&chain),
3392 &no_streams(),
3393 &[checkpoint_blob(&checkpoint)],
3394 true,
3395 );
3396 let c5a = checkpoint_check(&report, "C5a");
3397 assert_eq!(c5a.verdict, Verdict::Pass, "{}", c5a.detail);
3398 assert!(
3399 c5a.detail.contains("against the 1 journal record(s) above"),
3400 "{}",
3401 c5a.detail
3402 );
3403 assert_eq!(report.exit_code(), 0);
3404 }
3405
3406 #[test]
3407 fn a_bounded_tail_checkpoint_reconciles_with_the_journal() {
3408 let op = operation("op-c5-bounded");
3409 let (mut chain, mut tx, mut driver) =
3410 live_runtime(&[configure_envelope(&op), agent_start_envelope(&op)]);
3411 let candidate = tx
3414 .checkpoint_candidate(driver.project_logical_state())
3415 .expect("the base checkpoints");
3416 let boundary: CheckpointBoundary = candidate.boundary();
3417 let base_state = candidate
3418 .decode()
3419 .expect("the base decodes")
3420 .logical_state()
3421 .clone();
3422 let preparation = tx.prepare(&resolve_overflow_envelope(&op, 1), |context| {
3423 driver.plan(context)
3424 });
3425 let token = preparation.token().expect("the tail step prepares").clone();
3426 let head = preparation.record().unwrap().record_digest().clone();
3427 let committed = tx.commit(&token, &head).expect("the tail step commits");
3428 chain.push(committed.record.clone());
3429 driver
3430 .note_committed(committed.step_seq)
3431 .expect("the driver folds the tail step");
3432
3433 let rebased = tx
3434 .checkpoint_rebase(&boundary, base_state)
3435 .expect("the window rebases")
3436 .decode()
3437 .expect("the rebase decodes");
3438 assert_eq!(rebased.base_step_seq().get(), 1);
3439 assert_eq!(rebased.through_step_seq().get(), 2);
3440 assert_eq!(rebased.tail_inputs().len(), 1);
3441
3442 let report = validate_with_checkpoint(
3446 &blobs(&chain),
3447 &no_streams(),
3448 &[checkpoint_blob(&rebased)],
3449 true,
3450 );
3451 let c5a = checkpoint_check(&report, "C5a");
3452 assert_eq!(c5a.verdict, Verdict::Pass, "{}", c5a.detail);
3453 assert!(
3454 c5a.detail.contains("1 bounded-tail entries reconcile"),
3455 "{}",
3456 c5a.detail
3457 );
3458 assert!(
3459 c5a.detail.contains("the captured state digest at step 1"),
3460 "{}",
3461 c5a.detail
3462 );
3463 assert_eq!(report.exit_code(), 0);
3464 }
3465
3466 #[test]
3467 fn a_checkpoint_from_another_chain_fails_c5a() {
3468 let op = operation("op-c5-foreign");
3469 let (chain, _tx, _driver) =
3472 live_runtime(&[configure_envelope(&op), agent_start_envelope(&op)]);
3473 let foreign_config = |max_turns: u32| {
3474 envelope(
3475 &op,
3476 "in-configure",
3477 1_700_000_000_000,
3478 KernelInput::ConfigureOperation(ConfigureOperation {
3479 config: OperationConfig {
3480 execution_policy: Some(ExecutionPolicy {
3481 max_turns: Some(max_turns),
3482 ..ExecutionPolicy::default()
3483 }),
3484 host_effect_support: HostEffectSupport::new([
3485 EffectKindTag::CallProvider,
3486 EffectKindTag::SpawnTasks,
3487 ]),
3488 ..OperationConfig::default()
3489 },
3490 }),
3491 )
3492 };
3493 let (_chain_other, other_tx, other_driver) =
3494 live_runtime(&[foreign_config(24), agent_start_envelope(&op)]);
3495 let foreign = checkpoint_at_head(&other_tx, &other_driver);
3496
3497 let report = validate_with_checkpoint(
3498 &blobs(&chain),
3499 &no_streams(),
3500 &[checkpoint_blob(&foreign)],
3501 false,
3502 );
3503 let c5a = checkpoint_check(&report, "C5a");
3504 assert_eq!(c5a.verdict, Verdict::Fail, "{}", c5a.detail);
3505 assert!(
3506 c5a.detail.contains("captured on another chain"),
3507 "{}",
3508 c5a.detail
3509 );
3510 assert_eq!(report.exit_code(), 1);
3511 }
3512
3513 #[test]
3514 fn a_reused_launch_token_fails_c5b() {
3515 let op = operation("op-c5-tokens");
3516 let (chain, tx, driver) = live_runtime(&[
3517 configure_envelope(&op),
3518 workflow_start_envelope(&op),
3519 resolve_spawn_envelope(
3520 &op,
3521 "in-ack-1",
3522 1_700_000_002_000,
3523 "op-c5-tokens:step:1:effect:0",
3524 &[("wf-node0", "wf-node0:attempt:1")],
3525 ),
3526 ]);
3527 let checkpoint = checkpoint_at_head(&tx, &driver);
3528 assert_eq!(
3529 checkpoint.logical_state().transition.launch_tokens.len(),
3530 1,
3531 "the workflow start minted one launch token"
3532 );
3533
3534 let mut state = checkpoint.logical_state().clone();
3537 let minted = state.transition.launch_tokens[0].clone();
3538 state.transition.launch_tokens.push(LaunchTokenState {
3539 launch_token: minted.launch_token,
3540 step_seq: WireU64::new(2),
3541 });
3542 let forged = KernelCheckpoint::assemble(CheckpointDraft {
3543 operation_id: checkpoint.operation_id().clone(),
3544 genesis_digest: checkpoint.genesis_digest().clone(),
3545 base_step_seq: checkpoint.base_step_seq(),
3546 base_record_digest: checkpoint.base_record_digest().clone(),
3547 through_step_seq: checkpoint.through_step_seq(),
3548 covered_transaction_head_digest: checkpoint.covered_transaction_head_digest().clone(),
3549 logical_state: state,
3550 tail_inputs: checkpoint.tail_inputs().to_vec(),
3551 })
3552 .expect("the forged draft assembles");
3553
3554 let report = validate_with_checkpoint(
3555 &blobs(&chain),
3556 &no_streams(),
3557 &[checkpoint_blob(&forged)],
3558 false,
3559 );
3560 let c5b = checkpoint_check(&report, "C5b");
3561 assert_eq!(c5b.verdict, Verdict::Fail, "{}", c5b.detail);
3562 assert!(
3563 c5b.detail.contains("reuse across TaskLaunch payloads"),
3564 "{}",
3565 c5b.detail
3566 );
3567 assert_eq!(report.exit_code(), 1);
3568 }
3569
3570 #[test]
3571 fn strict_replay_catches_a_ledger_the_journal_never_minted() {
3572 let op = operation("op-c5-moved-mint");
3573 let (chain, tx, driver) = live_runtime(&[
3574 configure_envelope(&op),
3575 workflow_start_envelope(&op),
3576 resolve_spawn_envelope(
3577 &op,
3578 "in-ack-1",
3579 1_700_000_002_000,
3580 "op-c5-moved-mint:step:1:effect:0",
3581 &[("wf-node0", "wf-node0:attempt:1")],
3582 ),
3583 ]);
3584 let checkpoint = checkpoint_at_head(&tx, &driver);
3585
3586 let mut state = checkpoint.logical_state().clone();
3590 state.transition.launch_tokens[0].step_seq = WireU64::new(2);
3591 let forged = KernelCheckpoint::assemble(CheckpointDraft {
3592 operation_id: checkpoint.operation_id().clone(),
3593 genesis_digest: checkpoint.genesis_digest().clone(),
3594 base_step_seq: checkpoint.base_step_seq(),
3595 base_record_digest: checkpoint.base_record_digest().clone(),
3596 through_step_seq: checkpoint.through_step_seq(),
3597 covered_transaction_head_digest: checkpoint.covered_transaction_head_digest().clone(),
3598 logical_state: state,
3599 tail_inputs: checkpoint.tail_inputs().to_vec(),
3600 })
3601 .expect("the forged draft assembles");
3602
3603 let default_report = validate_with_checkpoint(
3604 &blobs(&chain),
3605 &no_streams(),
3606 &[checkpoint_blob(&forged)],
3607 false,
3608 );
3609 assert_eq!(
3610 checkpoint_check(&default_report, "C5b").verdict,
3611 Verdict::Pass,
3612 "the default plane cannot see a moved mint: {}",
3613 checkpoint_check(&default_report, "C5b").detail
3614 );
3615
3616 let strict_report = validate_with_checkpoint(
3617 &blobs(&chain),
3618 &no_streams(),
3619 &[checkpoint_blob(&forged)],
3620 true,
3621 );
3622 let c5b = checkpoint_check(&strict_report, "C5b");
3623 assert_eq!(c5b.verdict, Verdict::Fail, "{}", c5b.detail);
3624 assert!(
3625 c5b.detail.contains("different launch-token ledger"),
3626 "{}",
3627 c5b.detail
3628 );
3629 assert_eq!(
3630 checkpoint_check(&strict_report, "C5a").verdict,
3631 Verdict::Fail,
3632 "the ledger is part of the state, so the state digest moves too"
3633 );
3634 assert_eq!(strict_report.exit_code(), 1);
3635 }
3636
3637 #[test]
3638 fn a_tail_disconnected_from_the_journal_fails_c5a() {
3639 let op = operation("op-c5-spliced");
3640 let (chain_a, tx, driver) = live_runtime(&[
3641 configure_envelope(&op),
3642 agent_start_envelope(&op),
3643 resolve_overflow_envelope(&op, 1),
3644 ]);
3645 let checkpoint = checkpoint_at_head(&tx, &driver);
3646
3647 let other_resolution = envelope(
3651 &op,
3652 "in-resolve-other",
3653 1_700_000_002_000,
3654 KernelInput::ResolveEffect(ResolveEffect {
3655 effect_id: EffectId::new("op-c5-spliced:step:1:effect:0").unwrap(),
3656 outcome: EffectOutcome::Succeeded(EffectSucceeded {
3657 result: EffectSuccess::Provider(ProviderSuccess {
3658 outcome: ProviderOutcome::ContextOverflow(
3659 ProviderContextOverflow::default(),
3660 ),
3661 }),
3662 }),
3663 }),
3664 );
3665 let (chain_b, _tx_b, _driver_b) = live_runtime(&[
3666 configure_envelope(&op),
3667 agent_start_envelope(&op),
3668 other_resolution,
3669 ]);
3670 let mut spliced = chain_a[..2].to_vec();
3671 spliced.push(chain_b[2].clone());
3672
3673 let report = validate_with_checkpoint(
3674 &blobs(&spliced),
3675 &no_streams(),
3676 &[checkpoint_blob(&checkpoint)],
3677 false,
3678 );
3679 assert_eq!(
3680 rule(&report, 0, "C1").verdict,
3681 Verdict::Pass,
3682 "the splice chains cleanly — C1 is not the witness here"
3683 );
3684 let c5a = checkpoint_check(&report, "C5a");
3685 assert_eq!(c5a.verdict, Verdict::Fail, "{}", c5a.detail);
3686 assert!(c5a.detail.contains("covered step 2"), "{}", c5a.detail);
3687 assert_eq!(report.exit_code(), 1);
3688 }
3689
3690 #[test]
3691 fn a_pruned_journal_degrades_the_c5_anchors_and_c1_keeps_its_verdict() {
3692 let op = operation("op-c5-pruned");
3693 let (chain, tx, driver) = live_runtime(&[
3694 configure_envelope(&op),
3695 agent_start_envelope(&op),
3696 resolve_overflow_envelope(&op, 1),
3697 ]);
3698 let checkpoint = checkpoint_at_head(&tx, &driver);
3699 let pruned = chain[2..].to_vec();
3701
3702 let report = validate_with_checkpoint(
3703 &blobs(&pruned),
3704 &no_streams(),
3705 &[checkpoint_blob(&checkpoint)],
3706 true,
3707 );
3708 let c5a = checkpoint_check(&report, "C5a");
3711 assert_eq!(c5a.verdict, Verdict::Degraded, "{}", c5a.detail);
3712 assert!(
3713 c5a.detail.contains("identity anchor is unverifiable"),
3714 "{}",
3715 c5a.detail
3716 );
3717 assert!(
3718 c5a.detail.contains("covered head anchored at step 2"),
3719 "{}",
3720 c5a.detail
3721 );
3722 assert!(
3723 c5a.detail.contains("strict replay skipped"),
3724 "{}",
3725 c5a.detail
3726 );
3727 assert_eq!(rule(&report, 0, "C1").verdict, Verdict::Fail);
3731 assert!(report.has_violations());
3732 assert_eq!(report.exit_code(), 1);
3733 }
3734
3735 #[test]
3736 fn an_unparseable_checkpoint_leaves_evidence_insufficient() {
3737 let op = operation("op-c5-junk");
3738 let chain = live_chain(&[configure_envelope(&op), agent_start_envelope(&op)]);
3739 let report = validate_with_checkpoint(
3740 &blobs(&chain),
3741 &no_streams(),
3742 &[b"{not a checkpoint".to_vec()],
3743 false,
3744 );
3745 assert_eq!(report.unparseable_checkpoints, 1);
3746 assert_eq!(report.checkpoints, Some(0));
3747 assert_eq!(
3748 checkpoint_check(&report, "C5a").verdict,
3749 Verdict::Degraded,
3750 "{}",
3751 checkpoint_check(&report, "C5a").detail
3752 );
3753 assert_eq!(report.exit_code(), 2);
3754 }
3755
3756 #[test]
3757 fn the_published_golden_checkpoints_decode_through_the_checkpoint_plane() {
3758 let fixture_dir =
3759 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixtures/kernel-wire");
3760 let mut blobs = Vec::new();
3761 for name in [
3762 "golden_checkpoint_agent_turn",
3763 "golden_checkpoint_bounded_tail",
3764 ] {
3765 let wrapper: serde_json::Value = serde_json::from_str(
3766 &std::fs::read_to_string(fixture_dir.join(format!("{name}.json")))
3767 .expect("fixture readable"),
3768 )
3769 .expect("fixture json");
3770 blobs.push(
3771 serde_json::to_vec(&wrapper["checkpoint"]).expect("the nested checkpoint writes"),
3772 );
3773 }
3774 let report = validate_with_checkpoint(&[] as &[Vec<u8>], &no_streams(), &blobs, false);
3778 assert_eq!(report.checkpoints, Some(2));
3779 assert_eq!(report.unparseable_checkpoints, 0);
3780 let c5a = checkpoint_check(&report, "C5a");
3781 assert_eq!(c5a.verdict, Verdict::Degraded, "{}", c5a.detail);
3782 assert!(c5a.detail.contains("holds no segment"), "{}", c5a.detail);
3783 let c5b = checkpoint_check(&report, "C5b");
3784 assert_ne!(c5b.verdict, Verdict::Fail, "{}", c5b.detail);
3785 assert!(!report.has_violations());
3786 }
3787}