Skip to main content

deepstrike_core/runtime/chain_validator/
mod.rs

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