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
39use std::collections::HashMap;
40
41use serde::Serialize;
42
43use crate::runtime::kernel::wire::ConfigDefaults;
44use crate::runtime::kernel::wire::effect::{EffectOutcome, EffectSuccess};
45use crate::runtime::kernel::wire::record::{
46    KernelRecord, NormalizedPayload, RecordError, verify_record_chain,
47};
48use crate::runtime::kernel::wire::restore::restore_operation;
49use crate::runtime::kernel::wire::transaction::InMemoryRecordIndex;
50
51/// The pseudo-segment for degraded hops whose `operation_id` did not survive. Kept obviously
52/// synthetic so a report reader never confuses it with a real operation.
53pub const UNATTRIBUTED_SEGMENT: &str = "(unattributed)";
54
55/// Batch-1 scope limits, surfaced verbatim on every report so a reader never mistakes a green
56/// segment for a complete C4.
57const DEFERRED: &[&str] = &[
58    "c4.parent_chain: parent links are not journaled; an orphan spawn cannot resolve (no \
59     outstanding effect), which C3's re-plan enforces structurally",
60    "c4.launch_token_ledger: the durable LaunchToken ledger lives in checkpoints, so reuse \
61     across different TaskLaunch payloads is a batch-2 (checkpoint input) check; the \
62     journal-direct shadow — (task_id, attempt_id) pair uniqueness — is checked here",
63];
64
65/// One rule's verdict on one segment.
66#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
67pub struct RuleReport {
68    /// `C1`…`C4`.
69    pub rule: String,
70    pub verdict: Verdict,
71    /// What was checked, or what broke, or why the check degraded.
72    pub detail: String,
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
76#[serde(rename_all = "snake_case")]
77pub enum Verdict {
78    Pass,
79    Fail,
80    /// C7: the check could not run to completion on this segment's evidence. Never a failure.
81    Degraded,
82}
83
84/// A hop whose strict record decode failed but whose identity fields survived — the C7 marking.
85#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
86pub struct DegradedHop {
87    /// Position in the validator's input, for cross-referencing the raw journal.
88    pub ordinal: usize,
89    pub step_seq: Option<u64>,
90    /// Why the strict decode rejected the bytes.
91    pub reason: String,
92}
93
94/// One operation's chain, judged independently.
95#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
96pub struct SegmentReport {
97    pub operation_id: String,
98    pub hops: usize,
99    pub degraded_hops: Vec<DegradedHop>,
100    pub rules: Vec<RuleReport>,
101}
102
103#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
104pub struct ValidationReport {
105    pub segments: Vec<SegmentReport>,
106    /// Blobs that are not records at all (not JSON objects, or carrying no identity fields).
107    pub unparseable_records: usize,
108    /// Batch-scope limits a green verdict does not cover.
109    pub deferred: Vec<String>,
110}
111
112impl ValidationReport {
113    pub fn has_violations(&self) -> bool {
114        self.segments
115            .iter()
116            .flat_map(|segment| segment.rules.iter())
117            .any(|rule| rule.verdict == Verdict::Fail)
118    }
119
120    /// The CLI contract (P7 §3.2): `0` all green, `1` a violation was proven, `2` the evidence
121    /// was insufficient. A proven violation outranks insufficient evidence; degraded hops and
122    /// deferred scope never move the code.
123    pub fn exit_code(&self) -> i32 {
124        if self.has_violations() {
125            1
126        } else if self.unparseable_records > 0 || self.segments.is_empty() {
127            2
128        } else {
129            0
130        }
131    }
132}
133
134/// One input blob, classified. `Complete` records are self-digest-verified by construction
135/// ([`KernelRecord::from_record_bytes`] cannot produce an unverified one).
136enum Hop {
137    Complete(KernelRecord),
138    Degraded(DegradedRecord),
139}
140
141struct DegradedRecord {
142    ordinal: usize,
143    operation_id: Option<String>,
144    input_id: Option<String>,
145    step_seq: Option<u64>,
146    previous_record_digest: Option<String>,
147    record_digest: Option<String>,
148    reason: String,
149    integrity_failure: bool,
150}
151
152impl DegradedRecord {
153    fn marking(&self) -> DegradedHop {
154        DegradedHop {
155            ordinal: self.ordinal,
156            step_seq: self.step_seq,
157            reason: self.reason.clone(),
158        }
159    }
160}
161
162impl Hop {
163    fn operation_id(&self) -> Option<&str> {
164        match self {
165            Self::Complete(record) => Some(record.operation_id().as_str()),
166            Self::Degraded(degraded) => degraded.operation_id.as_deref(),
167        }
168    }
169
170    fn step_seq(&self) -> Option<u64> {
171        match self {
172            Self::Complete(record) => Some(record.step_seq().get()),
173            Self::Degraded(degraded) => degraded.step_seq,
174        }
175    }
176}
177
178/// Validate a journal prefix: a sequence of opaque record byte blobs, in any order. Records
179/// group into per-operation segments, each judged independently; blob order never matters
180/// because the chain's own `step_seq`/digest links define the order.
181pub fn validate_journal<B: AsRef<[u8]>>(blobs: &[B]) -> ValidationReport {
182    let mut hops: Vec<Hop> = Vec::with_capacity(blobs.len());
183    let mut unparseable_records = 0;
184    for (ordinal, blob) in blobs.iter().enumerate() {
185        match classify(ordinal, blob.as_ref()) {
186            Some(hop) => hops.push(hop),
187            None => unparseable_records += 1,
188        }
189    }
190
191    let mut segments: HashMap<String, Vec<Hop>> = HashMap::new();
192    for hop in hops {
193        let key = hop
194            .operation_id()
195            .map(str::to_string)
196            .unwrap_or_else(|| UNATTRIBUTED_SEGMENT.to_string());
197        segments.entry(key).or_default().push(hop);
198    }
199
200    let mut keys: Vec<String> = segments.keys().cloned().collect();
201    keys.sort();
202    let reports = keys
203        .iter()
204        .map(|key| validate_segment(key, segments.remove(key).unwrap_or_default()))
205        .collect();
206
207    ValidationReport {
208        segments: reports,
209        unparseable_records,
210        deferred: DEFERRED.iter().map(|line| (*line).to_string()).collect(),
211    }
212}
213
214/// Strict first, lenient second: a record that fails the strict decode but still shows its
215/// identity fields retains its context for C7 reporting. Proven digest corruption still fails
216/// C1; only unavailable evidence degrades. Anything else is not a record.
217fn classify(ordinal: usize, bytes: &[u8]) -> Option<Hop> {
218    let error = match KernelRecord::from_record_bytes(bytes) {
219        Ok(record) => return Some(Hop::Complete(record)),
220        Err(error) => error,
221    };
222    let value: serde_json::Value = serde_json::from_slice(bytes).ok()?;
223    let object = value.as_object()?;
224    let string = |key: &str| {
225        object
226            .get(key)
227            .and_then(serde_json::Value::as_str)
228            .map(str::to_string)
229    };
230    // `step_seq` rides the wire as a branded decimal string (scalar.rs), but an old-format or
231    // foreign record may carry a bare number — accept both.
232    let step_seq = object.get("step_seq").and_then(|value| {
233        value
234            .as_u64()
235            .or_else(|| value.as_str().and_then(|text| text.parse().ok()))
236    });
237    let degraded = DegradedRecord {
238        ordinal,
239        operation_id: string("operation_id"),
240        input_id: string("input_id"),
241        step_seq,
242        previous_record_digest: string("previous_record_digest"),
243        record_digest: string("record_digest"),
244        reason: format!("{}: {}", error.code().as_str(), error.message()),
245        integrity_failure: matches!(error, RecordError::DigestMismatch(_)),
246    };
247    // An old-format record must still answer "which chain, which hop" to count as evidence;
248    // without either it is unparseable input.
249    if degraded.operation_id.is_some() || degraded.step_seq.is_some() {
250        Some(Hop::Degraded(degraded))
251    } else {
252        None
253    }
254}
255
256fn validate_segment(operation_id: &str, mut hops: Vec<Hop>) -> SegmentReport {
257    // The chain's own fields define the order; the input order is a storage detail. Hops that
258    // cannot say where they sit sort last, in input order.
259    hops.sort_by_key(|hop| {
260        (
261            hop.step_seq().unwrap_or(u64::MAX),
262            match hop {
263                Hop::Complete(_) => 0usize,
264                Hop::Degraded(degraded) => degraded.ordinal,
265            },
266        )
267    });
268
269    let degraded_hops: Vec<DegradedHop> = hops
270        .iter()
271        .filter_map(|hop| match hop {
272            Hop::Degraded(degraded) => Some(degraded.marking()),
273            Hop::Complete(_) => None,
274        })
275        .collect();
276    let hop_count = hops.len();
277
278    let c1 = check_c1(&hops);
279    let c2 = check_c2(&hops);
280    let c3 = check_c3(&hops, &c1);
281    let c4 = check_c4(&hops, operation_id);
282
283    SegmentReport {
284        operation_id: operation_id.to_string(),
285        hops: hop_count,
286        degraded_hops,
287        rules: vec![c1, c2, c3, c4],
288    }
289}
290
291/// C1 · chain integrity.
292fn check_c1(hops: &[Hop]) -> RuleReport {
293    let rule = "C1".to_string();
294    if hops.is_empty() {
295        return RuleReport {
296            rule,
297            verdict: Verdict::Degraded,
298            detail: "no records in this segment".to_string(),
299        };
300    }
301    let all_complete = hops.iter().all(|hop| matches!(hop, Hop::Complete(_)));
302    if all_complete {
303        let records: Vec<KernelRecord> = hops
304            .iter()
305            .filter_map(|hop| match hop {
306                Hop::Complete(record) => Some(record.clone()),
307                Hop::Degraded(_) => None,
308            })
309            .collect();
310        return match verify_record_chain(&records) {
311            Ok(genesis_digest) => RuleReport {
312                rule,
313                verdict: Verdict::Pass,
314                detail: format!(
315                    "{} record(s), genesis {genesis_digest}, every link verified",
316                    records.len()
317                ),
318            },
319            Err(error) => RuleReport {
320                rule,
321                verdict: Verdict::Fail,
322                detail: format!("{}: {}", error.code().as_str(), error.message()),
323            },
324        };
325    }
326
327    // Mixed segment: check every link whose digests survived, and the genesis claim when the
328    // first hop can make one. Degraded hops verify nothing themselves.
329    let mut broken: Vec<String> = hops
330        .iter()
331        .filter_map(|hop| match hop {
332            Hop::Degraded(record) if record.integrity_failure => Some(record.reason.clone()),
333            _ => None,
334        })
335        .collect();
336    let mut unverifiable_links = 0usize;
337    let mut previous: Option<(&Hop, Option<&KernelRecord>)> = None;
338    for hop in hops {
339        let step = hop.step_seq();
340        let (prev_digest, _) = digests_of(hop);
341        if let Some((previous_hop, previous_complete)) = previous {
342            let previous_step = previous_hop.step_seq();
343            let previous_digest = digests_of(previous_hop).1;
344            match (prev_digest, previous_digest) {
345                (Some(expected), Some(actual)) if expected != actual => broken.push(format!(
346                    "hop at step {} expects head {expected}, but its predecessor's digest is \
347                     {actual}",
348                    step.map_or("?".to_string(), |seq| seq.to_string()),
349                )),
350                (None, _) => unverifiable_links += 1,
351                (_, None) => unverifiable_links += 1,
352                _ => {}
353            }
354            match (step, previous_step) {
355                (Some(step), Some(previous_step)) if step != previous_step + 1 => broken.push(
356                    format!("hop is step {step}, but its predecessor is step {previous_step}"),
357                ),
358                (Some(_), Some(_)) => {}
359                _ => unverifiable_links += 1,
360            }
361            // `verify_follows` is only meaningful across an unbroken run of complete records:
362            // a degraded hop in between severs the chain of custody for the +1/digest pair.
363            if let (Hop::Complete(record), Some(previous_record)) = (hop, previous_complete)
364                && let Err(error) = record.verify_follows(Some(previous_record))
365            {
366                broken.push(format!("{}: {}", error.code().as_str(), error.message()));
367            }
368        } else if let Hop::Complete(record) = hop
369            && let Err(error) = record.verify_follows(None)
370        {
371            broken.push(format!("{}: {}", error.code().as_str(), error.message()));
372        }
373        previous = Some((
374            hop,
375            match hop {
376                Hop::Complete(record) => Some(record),
377                Hop::Degraded(_) => None,
378            },
379        ));
380    }
381
382    if !broken.is_empty() {
383        return RuleReport {
384            rule,
385            verdict: Verdict::Fail,
386            detail: broken.join("; "),
387        };
388    }
389    RuleReport {
390        rule,
391        verdict: Verdict::Degraded,
392        detail: format!(
393            "partial chain: every surviving link verified, {unverifiable_links} link(s) \
394             unverifiable across degraded hop(s)"
395        ),
396    }
397}
398
399/// C2 · input idempotency: one input_id, one record.
400fn check_c2(hops: &[Hop]) -> RuleReport {
401    let rule = "C2".to_string();
402    let mut by_input: HashMap<&str, &str> = HashMap::new();
403    let mut conflicts: Vec<String> = Vec::new();
404    let mut retries = 0usize;
405    let mut unverifiable = 0usize;
406    for hop in hops {
407        let (input_id, record_digest) = match hop {
408            Hop::Complete(record) => (
409                Some(record.input_id().as_str()),
410                Some(record.record_digest().as_str()),
411            ),
412            Hop::Degraded(degraded) => (
413                degraded.input_id.as_deref(),
414                degraded.record_digest.as_deref(),
415            ),
416        };
417        let Some(input_id) = input_id else { continue };
418        let Some(digest) = record_digest else {
419            unverifiable += 1;
420            continue;
421        };
422        match by_input.get(input_id) {
423            Some(existing) if *existing != digest => conflicts.push(format!(
424                "input {input_id} produced two different records ({existing} and {digest}); a \
425                 retry must reach the same record"
426            )),
427            Some(_) => retries += 1,
428            None => {
429                by_input.insert(input_id, digest);
430            }
431        }
432    }
433    if !conflicts.is_empty() {
434        return RuleReport {
435            rule,
436            verdict: Verdict::Fail,
437            detail: conflicts.join("; "),
438        };
439    }
440    if unverifiable > 0 {
441        return RuleReport {
442            rule,
443            verdict: Verdict::Degraded,
444            detail: format!(
445                "{} unique input(s), {retries} idempotent retry hit(s); {unverifiable} degraded \
446                 hop(s) could not be compared",
447                by_input.len(),
448            ),
449        };
450    }
451    RuleReport {
452        rule,
453        verdict: Verdict::Pass,
454        detail: format!(
455            "{} unique input(s), {retries} idempotent retry hit(s), no divergent duplicates",
456            by_input.len(),
457        ),
458    }
459}
460
461/// C3 · causal closure: the §12.2 genesis-leg restore re-plans every transition and compares
462/// each produced record digest against the durable one.
463fn check_c3(hops: &[Hop], c1: &RuleReport) -> RuleReport {
464    let rule = "C3".to_string();
465    if hops.iter().any(|hop| matches!(hop, Hop::Degraded(_))) {
466        return RuleReport {
467            rule,
468            verdict: Verdict::Degraded,
469            detail: "re-plan requires complete records; this segment has degraded hops".to_string(),
470        };
471    }
472    if c1.verdict == Verdict::Fail {
473        return RuleReport {
474            rule,
475            verdict: Verdict::Degraded,
476            detail: "C1 failed; a re-plan over a broken chain would only re-report that break"
477                .to_string(),
478        };
479    }
480    let records: Vec<KernelRecord> = hops
481        .iter()
482        .filter_map(|hop| match hop {
483            Hop::Complete(record) => Some(record.clone()),
484            Hop::Degraded(_) => None,
485        })
486        .collect();
487    if records.is_empty() {
488        return RuleReport {
489            rule,
490            verdict: Verdict::Degraded,
491            detail: "no records in this segment".to_string(),
492        };
493    }
494    match restore_operation(
495        None,
496        &records,
497        ConfigDefaults::default(),
498        InMemoryRecordIndex::from_records(&records),
499    ) {
500        Ok(restored) => RuleReport {
501            rule,
502            verdict: Verdict::Pass,
503            detail: format!(
504                "re-planned {} record(s) from genesis; every durable record digest reproduced",
505                restored.cost.records_before_checkpoint
506            ),
507        },
508        Err(fault) => RuleReport {
509            rule,
510            verdict: Verdict::Fail,
511            detail: format!("{}: {}", fault.code.as_str(), fault.message),
512        },
513    }
514}
515
516/// C4 · task lineage, the journal-direct half.
517fn check_c4(hops: &[Hop], operation_id: &str) -> RuleReport {
518    let rule = "C4".to_string();
519    struct LaunchFact {
520        task_id: String,
521        attempt_id: String,
522        step_seq: u64,
523        effect_id: String,
524    }
525
526    let mut launches: Vec<LaunchFact> = Vec::new();
527    let mut unreadable_inputs = 0usize;
528    for hop in hops {
529        let Hop::Complete(record) = hop else { continue };
530        let input = match record.normalized_input() {
531            Ok(input) => input,
532            Err(_) => {
533                unreadable_inputs += 1;
534                continue;
535            }
536        };
537        let NormalizedPayload::ResolveEffect(resolve) = &input.input else {
538            continue;
539        };
540        let EffectOutcome::Succeeded(success) = &resolve.outcome else {
541            continue;
542        };
543        let EffectSuccess::TasksSpawned(spawned) = &success.result else {
544            continue;
545        };
546        for attempt in &spawned.attempts {
547            launches.push(LaunchFact {
548                task_id: attempt.task_id.as_str().to_string(),
549                attempt_id: attempt.attempt_id.as_str().to_string(),
550                step_seq: record.step_seq().get(),
551                effect_id: resolve.effect_id.as_str().to_string(),
552            });
553        }
554    }
555
556    let mut violations: Vec<String> = Vec::new();
557    let mut seen: HashMap<(&str, &str), u64> = HashMap::new();
558    for fact in &launches {
559        let pair = (fact.task_id.as_str(), fact.attempt_id.as_str());
560        if let Some(first_step) = seen.insert(pair, fact.step_seq) {
561            violations.push(format!(
562                "task {} attempt {} launched at steps {first_step} and {}; the launch token is \
563                 derived from that pair, so a repeated pair is a reused LaunchToken",
564                fact.task_id, fact.attempt_id, fact.step_seq,
565            ));
566        }
567        match parse_effect_step(&fact.effect_id) {
568            Some((effect_operation, effect_step)) => {
569                if effect_operation != operation_id {
570                    violations.push(format!(
571                        "task {} launch at step {} resolves effect {} of another operation — \
572                         causation cannot cross operations",
573                        fact.task_id, fact.step_seq, fact.effect_id,
574                    ));
575                } else if effect_step >= fact.step_seq {
576                    violations.push(format!(
577                        "task {} launch resolved at step {} names an effect published at step \
578                         {effect_step} — the resolution precedes the publication",
579                        fact.task_id, fact.step_seq,
580                    ));
581                }
582            }
583            None => violations.push(format!(
584                "task {} launch at step {} names effect {}, which is not in the \
585                 `operation:step:N:effect:M` vocabulary",
586                fact.task_id, fact.step_seq, fact.effect_id,
587            )),
588        }
589    }
590
591    if !violations.is_empty() {
592        return RuleReport {
593            rule,
594            verdict: Verdict::Fail,
595            detail: violations.join("; "),
596        };
597    }
598    let degraded_hops = hops
599        .iter()
600        .filter(|hop| matches!(hop, Hop::Degraded(_)))
601        .count();
602    if degraded_hops > 0 || unreadable_inputs > 0 {
603        return RuleReport {
604            rule,
605            verdict: Verdict::Degraded,
606            detail: format!(
607                "{} launch(es) checked; {degraded_hops} degraded hop(s) and \
608                 {unreadable_inputs} unreadable input(s) could hide further launches",
609                launches.len(),
610            ),
611        };
612    }
613    RuleReport {
614        rule,
615        verdict: Verdict::Pass,
616        detail: format!(
617            "{} launch(es), every (task_id, attempt_id) pair unique, every spawn resolution \
618             names an earlier step of this operation",
619            launches.len(),
620        ),
621    }
622}
623
624/// The kernel's effect-id vocabulary is `{operation}:step:{N}:effect:{M}` (driver minting).
625/// Operation ids may themselves contain colons, so parse from the right.
626fn parse_effect_step(effect_id: &str) -> Option<(&str, u64)> {
627    let (before_effect, _) = effect_id.rsplit_once(":effect:")?;
628    let (operation, step) = before_effect.rsplit_once(":step:")?;
629    Some((operation, step.parse().ok()?))
630}
631
632fn digests_of(hop: &Hop) -> (Option<&str>, Option<&str>) {
633    match hop {
634        Hop::Complete(record) => (
635            record
636                .previous_record_digest()
637                .map(|digest| digest.as_str()),
638            Some(record.record_digest().as_str()),
639        ),
640        Hop::Degraded(degraded) => (
641            degraded.previous_record_digest.as_deref(),
642            degraded.record_digest.as_deref(),
643        ),
644    }
645}
646
647// ---------------------------------------------------------------------------------------------
648// tests
649// ---------------------------------------------------------------------------------------------
650
651#[cfg(test)]
652mod tests {
653    use serde_json::json;
654
655    use super::*;
656    use crate::runtime::kernel::wire::config::{
657        ConfigDefaults, ExecutionPolicy, HostEffectSupport, OperationConfig,
658    };
659    use crate::runtime::kernel::wire::driver::CanonicalOperationDriver;
660    use crate::runtime::kernel::wire::effect::{
661        EffectKindTag, EffectSucceeded, ProviderContextOverflow, ProviderOutcome, ProviderSuccess,
662        TaskLaunchOutcome, TaskLaunchStarted, TaskLaunchStatus, TasksSpawnedSuccess,
663    };
664    use crate::runtime::kernel::wire::envelope::{
665        ConfigureOperation, KernelInput, ResolveEffect, StartOperation, WireEnvelope,
666    };
667    use crate::runtime::kernel::wire::record::{KernelRecord, NormalizedInput};
668    use crate::runtime::kernel::wire::root::{
669        InitialContext, LogicalAgentSpec, LogicalTask, RootAgentEntry, RootEntry,
670        RootWorkflowEntry, WorkflowNode, WorkflowSpec,
671    };
672    use crate::runtime::kernel::wire::scalar::{
673        AttemptId, EffectId, InputId, NodeId, OperationId, TaskId, WireU64,
674    };
675    use crate::runtime::kernel::wire::transaction::{InMemoryRecordIndex, KernelTransaction};
676
677    // -----------------------------------------------------------------------------------------
678    // envelopes
679    // -----------------------------------------------------------------------------------------
680
681    fn operation(id: &str) -> OperationId {
682        OperationId::new(id).unwrap()
683    }
684
685    fn envelope(op: &OperationId, id: &str, at: u64, input: KernelInput) -> WireEnvelope {
686        WireEnvelope::new(
687            op.clone(),
688            InputId::new(id).unwrap(),
689            WireU64::new(at),
690            input,
691        )
692    }
693
694    fn configure_envelope(op: &OperationId) -> WireEnvelope {
695        envelope(
696            op,
697            "in-configure",
698            1_700_000_000_000,
699            KernelInput::ConfigureOperation(ConfigureOperation {
700                config: OperationConfig {
701                    execution_policy: Some(ExecutionPolicy {
702                        max_turns: Some(12),
703                        ..ExecutionPolicy::default()
704                    }),
705                    host_effect_support: HostEffectSupport::new([
706                        EffectKindTag::CallProvider,
707                        EffectKindTag::SpawnTasks,
708                    ]),
709                    ..OperationConfig::default()
710                },
711            }),
712        )
713    }
714
715    fn agent_start_envelope(op: &OperationId) -> WireEnvelope {
716        envelope(
717            op,
718            "in-start",
719            1_700_000_001_000,
720            KernelInput::StartOperation(StartOperation {
721                entry: RootEntry::Agent(RootAgentEntry {
722                    task: LogicalTask::new("write the brief"),
723                    run_spec: Some(LogicalAgentSpec::new("write the brief")),
724                }),
725                initial_context: InitialContext::default(),
726            }),
727        )
728    }
729
730    fn workflow_start_envelope(op: &OperationId) -> WireEnvelope {
731        envelope(
732            op,
733            "in-start",
734            1_700_000_001_000,
735            KernelInput::StartOperation(StartOperation {
736                entry: RootEntry::Workflow(RootWorkflowEntry {
737                    spec: WorkflowSpec {
738                        name: "brief".to_string(),
739                        nodes: vec![
740                            WorkflowNode {
741                                node_id: NodeId::new("collect").unwrap(),
742                                task: LogicalTask::new("collect the sources"),
743                                depends_on: vec![],
744                                run_spec: Some(LogicalAgentSpec::new("collect the sources")),
745                            },
746                            WorkflowNode {
747                                node_id: NodeId::new("write").unwrap(),
748                                task: LogicalTask::new("write the brief"),
749                                depends_on: vec![NodeId::new("collect").unwrap()],
750                                run_spec: Some(LogicalAgentSpec::new("write the brief")),
751                            },
752                        ],
753                    },
754                }),
755                initial_context: InitialContext::default(),
756            }),
757        )
758    }
759
760    fn resolve_overflow_envelope(op: &OperationId, effect_step: u64) -> WireEnvelope {
761        envelope(
762            op,
763            "in-resolve",
764            1_700_000_002_000,
765            KernelInput::ResolveEffect(ResolveEffect {
766                effect_id: EffectId::new(format!("{op}:step:{effect_step}:effect:0")).unwrap(),
767                outcome: EffectOutcome::Succeeded(EffectSucceeded {
768                    result: EffectSuccess::Provider(ProviderSuccess {
769                        outcome: ProviderOutcome::ContextOverflow(
770                            ProviderContextOverflow::default(),
771                        ),
772                    }),
773                }),
774            }),
775        )
776    }
777
778    fn resolve_spawn_envelope(
779        op: &OperationId,
780        id: &str,
781        at: u64,
782        effect_id: &str,
783        tasks: &[(&str, &str)],
784    ) -> WireEnvelope {
785        envelope(
786            op,
787            id,
788            at,
789            KernelInput::ResolveEffect(ResolveEffect {
790                effect_id: EffectId::new(effect_id).unwrap(),
791                outcome: EffectOutcome::Succeeded(EffectSucceeded {
792                    result: EffectSuccess::TasksSpawned(TasksSpawnedSuccess {
793                        attempts: tasks
794                            .iter()
795                            .map(|(task, attempt)| TaskLaunchOutcome {
796                                task_id: TaskId::new(*task).unwrap(),
797                                attempt_id: AttemptId::new(*attempt).unwrap(),
798                                outcome: TaskLaunchStatus::Started(TaskLaunchStarted {}),
799                            })
800                            .collect(),
801                    }),
802                }),
803            }),
804        )
805    }
806
807    // -----------------------------------------------------------------------------------------
808    // chain builders
809    // -----------------------------------------------------------------------------------------
810
811    /// The honest path: a live transaction driven by the real driver, so every record's step is
812    /// exactly what a re-plan reproduces. This is what a host's journal prefix looks like.
813    fn live_chain(envelopes: &[WireEnvelope]) -> Vec<KernelRecord> {
814        let mut tx = KernelTransaction::new(ConfigDefaults::default(), InMemoryRecordIndex::new());
815        let mut driver = CanonicalOperationDriver::new();
816        let mut journal = Vec::new();
817        for envelope in envelopes {
818            let preparation = tx.prepare(envelope, |context| driver.plan(context));
819            let token = preparation
820                .token()
821                .unwrap_or_else(|| {
822                    panic!("expected a prepared step, got {:?}", preparation.fault())
823                })
824                .clone();
825            let head = preparation.record().unwrap().record_digest().clone();
826            let committed = tx.commit(&token, &head).expect("commit must succeed");
827            journal.push(committed.record.clone());
828            driver
829                .note_committed(committed.step_seq)
830                .expect("the driver folds the step it planned");
831        }
832        journal
833    }
834
835    /// A structurally sound chain whose steps are hand-pinned JSON — **not** the driver's plans.
836    /// C1/C2/C4 read only the records, so they judge these chains; C3 necessarily fails on them
837    /// (the re-plan cannot reproduce a hand-pinned step) and is simply not asserted there.
838    fn hand_chain(envelopes: &[WireEnvelope]) -> Vec<KernelRecord> {
839        let mut records: Vec<KernelRecord> = Vec::new();
840        for (index, envelope) in envelopes.iter().enumerate() {
841            let input = NormalizedInput::normalize(envelope, &ConfigDefaults::default())
842                .expect("the envelope normalises");
843            let step = json!({ "planned": format!("step-{index}"), "effects": [] });
844            let record =
845                KernelRecord::chain(records.last(), &input, &step).expect("the record chains");
846            records.push(record);
847        }
848        records
849    }
850
851    fn blobs(records: &[KernelRecord]) -> Vec<Vec<u8>> {
852        records
853            .iter()
854            .map(|record| record.record_bytes().into_vec())
855            .collect()
856    }
857
858    fn rule<'a>(report: &'a ValidationReport, segment: usize, id: &str) -> &'a RuleReport {
859        report.segments[segment]
860            .rules
861            .iter()
862            .find(|rule| rule.rule == id)
863            .unwrap_or_else(|| panic!("segment {segment} has no {id} verdict"))
864    }
865
866    // -----------------------------------------------------------------------------------------
867    // green paths
868    // -----------------------------------------------------------------------------------------
869
870    #[test]
871    fn a_green_agent_chain_passes_every_rule() {
872        let op = operation("op-green-agent");
873        let chain = live_chain(&[
874            configure_envelope(&op),
875            agent_start_envelope(&op),
876            resolve_overflow_envelope(&op, 1),
877        ]);
878        let report = validate_journal(&blobs(&chain));
879        assert_eq!(report.segments.len(), 1);
880        for id in ["C1", "C2", "C3", "C4"] {
881            assert_eq!(
882                rule(&report, 0, id).verdict,
883                Verdict::Pass,
884                "{id}: {}",
885                rule(&report, 0, id).detail
886            );
887        }
888        assert!(
889            rule(&report, 0, "C3")
890                .detail
891                .contains("every durable record digest reproduced"),
892            "C3 proves the re-plan: {}",
893            rule(&report, 0, "C3").detail
894        );
895        assert_eq!(report.exit_code(), 0);
896        assert_eq!(report.unparseable_records, 0);
897    }
898
899    #[test]
900    fn a_green_workflow_chain_passes_c4_with_real_launches() {
901        let op = operation("op-green-workflow");
902        let chain = live_chain(&[
903            configure_envelope(&op),
904            workflow_start_envelope(&op),
905            resolve_spawn_envelope(
906                &op,
907                "in-ack-1",
908                1_700_000_002_000,
909                "op-green-workflow:step:1:effect:0",
910                &[("wf-node0", "wf-node0:attempt:1")],
911            ),
912        ]);
913        let report = validate_journal(&blobs(&chain));
914        assert_eq!(report.segments.len(), 1);
915        for id in ["C1", "C2", "C3", "C4"] {
916            assert_eq!(
917                rule(&report, 0, id).verdict,
918                Verdict::Pass,
919                "{id}: {}",
920                rule(&report, 0, id).detail
921            );
922        }
923        assert!(
924            rule(&report, 0, "C4").detail.contains("1 launch(es)"),
925            "{}",
926            rule(&report, 0, "C4").detail
927        );
928        assert_eq!(report.deferred.len(), 2, "batch-1 scope limits are named");
929        assert_eq!(report.exit_code(), 0);
930    }
931
932    #[test]
933    fn input_order_is_a_storage_detail() {
934        let op = operation("op-shuffled");
935        let chain = live_chain(&[
936            configure_envelope(&op),
937            agent_start_envelope(&op),
938            resolve_overflow_envelope(&op, 1),
939        ]);
940        let mut shuffled = blobs(&chain);
941        shuffled.reverse();
942        let report = validate_journal(&shuffled);
943        assert_eq!(
944            report.exit_code(),
945            0,
946            "the chain's own links define the order"
947        );
948    }
949
950    #[test]
951    fn two_operations_validate_as_independent_segments() {
952        let op_a = operation("op-seg-a");
953        let op_b = operation("op-seg-b");
954        let chain_a = live_chain(&[configure_envelope(&op_a), agent_start_envelope(&op_a)]);
955        let chain_b = live_chain(&[configure_envelope(&op_b), agent_start_envelope(&op_b)]);
956        // Interleaved and sharing input ids — idempotency is namespaced per operation.
957        let mut mixed = Vec::new();
958        for index in 0..2 {
959            mixed.push(chain_a[index].record_bytes().into_vec());
960            mixed.push(chain_b[index].record_bytes().into_vec());
961        }
962        let report = validate_journal(&mixed);
963        assert_eq!(report.segments.len(), 2);
964        assert_eq!(report.exit_code(), 0);
965    }
966
967    // -----------------------------------------------------------------------------------------
968    // C1 · chain integrity
969    // -----------------------------------------------------------------------------------------
970
971    #[test]
972    fn a_gap_in_the_chain_fails_c1_and_degrades_c3() {
973        let op = operation("op-gapped");
974        let chain = live_chain(&[
975            configure_envelope(&op),
976            agent_start_envelope(&op),
977            resolve_overflow_envelope(&op, 1),
978        ]);
979        let gapped = blobs(&[chain[0].clone(), chain[2].clone()]);
980        let report = validate_journal(&gapped);
981        assert_eq!(rule(&report, 0, "C1").verdict, Verdict::Fail);
982        assert_eq!(
983            rule(&report, 0, "C3").verdict,
984            Verdict::Degraded,
985            "a re-plan over a broken chain would only re-report the C1 break"
986        );
987        assert_eq!(report.exit_code(), 1);
988    }
989
990    // -----------------------------------------------------------------------------------------
991    // C2 · input idempotency
992    // -----------------------------------------------------------------------------------------
993
994    #[test]
995    fn two_different_records_for_one_input_fail_c2() {
996        let op = operation("op-dup-input");
997        // Two chains over the same operation id whose `in-start` envelopes differ only in the
998        // observed clock — same input id, different canonical input, different records.
999        let chain_a = hand_chain(&[configure_envelope(&op), agent_start_envelope(&op)]);
1000        let mut later_start = agent_start_envelope(&op);
1001        later_start.observed_at_ms = WireU64::new(1_700_000_001_500);
1002        let chain_b = hand_chain(&[configure_envelope(&op), later_start]);
1003        assert_ne!(
1004            chain_a[1].record_digest(),
1005            chain_b[1].record_digest(),
1006            "the fixture must produce two different records for one input id"
1007        );
1008        let report = validate_journal(&blobs(&[
1009            chain_a[0].clone(),
1010            chain_a[1].clone(),
1011            chain_b[1].clone(),
1012        ]));
1013        assert_eq!(rule(&report, 0, "C2").verdict, Verdict::Fail);
1014        assert!(
1015            rule(&report, 0, "C2").detail.contains("in-start"),
1016            "{}",
1017            rule(&report, 0, "C2").detail
1018        );
1019        assert_eq!(report.exit_code(), 1);
1020    }
1021
1022    // -----------------------------------------------------------------------------------------
1023    // C4 · task lineage
1024    // -----------------------------------------------------------------------------------------
1025
1026    #[test]
1027    fn a_repeated_attempt_pair_is_a_reused_launch_token() {
1028        let op = operation("op-dup-launch");
1029        let chain = hand_chain(&[
1030            configure_envelope(&op),
1031            resolve_spawn_envelope(
1032                &op,
1033                "in-ack-1",
1034                1_700_000_001_000,
1035                "op-dup-launch:step:0:effect:0",
1036                &[("writer", "writer:attempt:1")],
1037            ),
1038            resolve_spawn_envelope(
1039                &op,
1040                "in-ack-2",
1041                1_700_000_002_000,
1042                "op-dup-launch:step:0:effect:0",
1043                &[("writer", "writer:attempt:1")],
1044            ),
1045        ]);
1046        let report = validate_journal(&blobs(&chain));
1047        assert_eq!(rule(&report, 0, "C1").verdict, Verdict::Pass);
1048        assert_eq!(rule(&report, 0, "C4").verdict, Verdict::Fail);
1049        assert!(
1050            rule(&report, 0, "C4").detail.contains("LaunchToken"),
1051            "the verdict names the token reuse: {}",
1052            rule(&report, 0, "C4").detail
1053        );
1054        assert_eq!(report.exit_code(), 1);
1055    }
1056
1057    #[test]
1058    fn a_resolution_naming_a_future_step_fails_c4() {
1059        let op = operation("op-future-effect");
1060        let chain = hand_chain(&[
1061            configure_envelope(&op),
1062            resolve_spawn_envelope(
1063                &op,
1064                "in-ack-1",
1065                1_700_000_001_000,
1066                "op-future-effect:step:5:effect:0",
1067                &[("writer", "writer:attempt:1")],
1068            ),
1069        ]);
1070        let report = validate_journal(&blobs(&chain));
1071        assert_eq!(rule(&report, 0, "C4").verdict, Verdict::Fail);
1072        assert!(
1073            rule(&report, 0, "C4")
1074                .detail
1075                .contains("precedes the publication"),
1076            "{}",
1077            rule(&report, 0, "C4").detail
1078        );
1079    }
1080
1081    #[test]
1082    fn a_resolution_naming_another_operation_fails_c4() {
1083        let op = operation("op-foreign-effect");
1084        let chain = hand_chain(&[
1085            configure_envelope(&op),
1086            resolve_spawn_envelope(
1087                &op,
1088                "in-ack-1",
1089                1_700_000_001_000,
1090                "op-somewhere-else:step:0:effect:0",
1091                &[("writer", "writer:attempt:1")],
1092            ),
1093        ]);
1094        let report = validate_journal(&blobs(&chain));
1095        assert_eq!(rule(&report, 0, "C4").verdict, Verdict::Fail);
1096        assert!(
1097            rule(&report, 0, "C4").detail.contains("another operation"),
1098            "{}",
1099            rule(&report, 0, "C4").detail
1100        );
1101    }
1102
1103    // -----------------------------------------------------------------------------------------
1104    // C7 · degradation
1105    // -----------------------------------------------------------------------------------------
1106
1107    #[test]
1108    fn a_tampered_hop_fails_integrity_validation() {
1109        let op = operation("op-tampered");
1110        let chain = live_chain(&[
1111            configure_envelope(&op),
1112            agent_start_envelope(&op),
1113            resolve_overflow_envelope(&op, 1),
1114        ]);
1115        let mut input = blobs(&chain);
1116        // Corrupt the middle record's step_digest: the strict decode now fails the self-digest
1117        // check. Surviving identity fields must not hide proven corruption.
1118        let mut forged: serde_json::Value = serde_json::from_slice(&input[1]).unwrap();
1119        forged["step_digest"] = serde_json::Value::String(chain[0].record_digest().to_string());
1120        input[1] = serde_json::to_vec(&forged).unwrap();
1121
1122        let report = validate_journal(&input);
1123        assert_eq!(report.segments.len(), 1);
1124        assert_eq!(report.segments[0].degraded_hops.len(), 1);
1125        assert_eq!(
1126            rule(&report, 0, "C1").verdict,
1127            Verdict::Fail,
1128            "a digest mismatch must fail C1: {}",
1129            rule(&report, 0, "C1").detail
1130        );
1131        assert_eq!(rule(&report, 0, "C3").verdict, Verdict::Degraded);
1132        assert_eq!(rule(&report, 0, "C4").verdict, Verdict::Degraded);
1133        assert_eq!(
1134            report.exit_code(),
1135            1,
1136            "proven digest corruption must fail the validator"
1137        );
1138    }
1139
1140    #[test]
1141    fn missing_legacy_digest_degrades_without_claiming_corruption() {
1142        let op = operation("op-legacy");
1143        let chain = live_chain(&[configure_envelope(&op)]);
1144        let mut legacy: serde_json::Value = serde_json::from_slice(&blobs(&chain)[0]).unwrap();
1145        legacy.as_object_mut().unwrap().remove("step_digest");
1146        let report = validate_journal(&[serde_json::to_vec(&legacy).unwrap()]);
1147        assert_eq!(rule(&report, 0, "C1").verdict, Verdict::Degraded);
1148        assert_eq!(report.exit_code(), 0);
1149    }
1150
1151    #[test]
1152    fn unparseable_input_is_evidence_insufficient_not_guilty() {
1153        let report = validate_journal(&[b"this is not a record".to_vec()]);
1154        assert!(report.segments.is_empty());
1155        assert_eq!(report.unparseable_records, 1);
1156        assert_eq!(report.exit_code(), 2);
1157    }
1158
1159    #[test]
1160    fn garbage_beside_a_green_chain_stays_exit_2_without_a_violation() {
1161        let op = operation("op-plus-garbage");
1162        let chain = live_chain(&[configure_envelope(&op), agent_start_envelope(&op)]);
1163        let mut input = blobs(&chain);
1164        input.push(b"this is not a record".to_vec());
1165        let report = validate_journal(&input);
1166        assert_eq!(report.segments.len(), 1);
1167        assert_eq!(rule(&report, 0, "C1").verdict, Verdict::Pass);
1168        assert_eq!(report.unparseable_records, 1);
1169        assert_eq!(
1170            report.exit_code(),
1171            2,
1172            "no violation was proven, but the evidence was partially unreadable"
1173        );
1174    }
1175
1176    #[test]
1177    fn an_empty_journal_is_evidence_insufficient() {
1178        let report = validate_journal::<Vec<u8>>(&[]);
1179        assert_eq!(report.exit_code(), 2);
1180    }
1181}