1use 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
51pub const UNATTRIBUTED_SEGMENT: &str = "(unattributed)";
54
55const 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#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
67pub struct RuleReport {
68 pub rule: String,
70 pub verdict: Verdict,
71 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 Degraded,
82}
83
84#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
86pub struct DegradedHop {
87 pub ordinal: usize,
89 pub step_seq: Option<u64>,
90 pub reason: String,
92}
93
94#[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 pub unparseable_records: usize,
108 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 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
134enum 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
178pub 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
214fn 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 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 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 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
291fn 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 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 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
399fn 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
461fn 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
516fn 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
624fn 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#[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 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 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 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 #[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 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 #[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 #[test]
995 fn two_different_records_for_one_input_fail_c2() {
996 let op = operation("op-dup-input");
997 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 #[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 #[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 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}