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