1use crate::error::{EngineError, Result};
63use crate::events::{Event, EventKind};
64use crate::gate::{GateKind, GateSurface, GateVerdict};
65use crate::gate_results::{file_artefact_ref, resolve_artefact, ArtefactResolution};
66use crate::types::{MissionConfig, Role};
67use serde::{Deserialize, Serialize};
68use std::path::Path;
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
74#[serde(rename_all = "lowercase")]
75pub enum ArtefactStatus {
76 Resolved,
78 Unresolved,
82 Inline,
85}
86
87impl ArtefactStatus {
88 fn classify(resolution: &ArtefactResolution) -> Self {
90 match resolution {
91 ArtefactResolution::Resolved { .. } => Self::Resolved,
92 ArtefactResolution::Unresolved { .. } => Self::Unresolved,
93 ArtefactResolution::Inline => Self::Inline,
94 }
95 }
96
97 pub fn as_str(&self) -> &'static str {
99 match self {
100 Self::Resolved => "resolved",
101 Self::Unresolved => "unresolved",
102 Self::Inline => "inline",
103 }
104 }
105}
106
107#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
111#[serde(rename_all = "camelCase")]
112pub struct GateLink {
113 pub seq: u64,
114 pub gate: String,
115 pub surface: GateSurface,
116 pub kind: GateKind,
118 pub index: u32,
120 pub verdict: GateVerdict,
121 pub artefact_ref: String,
123 pub artefact_detail: Option<String>,
126 pub score: Option<f64>,
128 pub threshold: Option<f64>,
129 pub artefact: ArtefactStatus,
131 #[serde(default, skip_serializing_if = "Vec::is_empty")]
136 pub rule_ids: Vec<String>,
137}
138
139#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
143#[serde(rename_all = "camelCase")]
144pub struct SessionLink {
145 pub seq: u64,
146 pub run_id: String,
147 pub role: Role,
148 pub backend: Option<String>,
151 pub model: String,
152 pub quant: String,
153 pub weight_hash: Option<String>,
154 pub prompt_hash: String,
157 pub feature_id: Option<String>,
158 pub milestone_id: Option<String>,
159 pub transcript_ref: String,
161 pub transcript: ArtefactStatus,
165}
166
167#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
169#[serde(rename_all = "kebab-case")]
170pub enum DecisionKind {
171 PlanApproval,
172 PlanRevision,
173 PlanRevisionRejection,
174 GrantApproval,
175 GrantDenial,
176 MilestoneUnblock,
177 Steer,
180 OperatorMessage,
182 MissionAbandoned,
183}
184
185impl DecisionKind {
186 pub fn as_str(&self) -> &'static str {
189 match self {
190 Self::PlanApproval => "plan-approval",
191 Self::PlanRevision => "plan-revision",
192 Self::PlanRevisionRejection => "plan-revision-rejection",
193 Self::GrantApproval => "grant-approval",
194 Self::GrantDenial => "grant-denial",
195 Self::MilestoneUnblock => "milestone-unblock",
196 Self::Steer => "steer",
197 Self::OperatorMessage => "operator-message",
198 Self::MissionAbandoned => "mission-abandoned",
199 }
200 }
201}
202
203#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
205#[serde(rename_all = "camelCase")]
206pub struct DecisionLink {
207 pub seq: u64,
208 pub kind: DecisionKind,
209 pub summary: String,
212}
213
214#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
222#[serde(tag = "kind", rename_all = "kebab-case")]
223pub enum DivergenceLink {
224 Noted {
227 seq: u64,
228 unit: String,
229 candidates: Vec<crate::types::DivergenceCandidate>,
230 diverged: bool,
231 },
232 Resolved {
235 seq: u64,
236 unit: String,
237 selected: Option<u32>,
238 reason: String,
239 decided_by: String,
240 },
241}
242
243#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
245#[serde(rename_all = "lowercase")]
246pub enum TerminalStatus {
247 Completed,
248 Failed,
249 Abandoned,
250}
251
252impl TerminalStatus {
253 pub fn as_str(&self) -> &'static str {
255 match self {
256 Self::Completed => "completed",
257 Self::Failed => "failed",
258 Self::Abandoned => "abandoned",
259 }
260 }
261}
262
263#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
266#[serde(rename_all = "camelCase")]
267pub struct TerminalLink {
268 pub seq: u64,
269 pub status: TerminalStatus,
270 pub reason: Option<String>,
271}
272
273#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
278#[serde(rename_all = "camelCase")]
279pub struct ProvenanceChain {
280 pub mission_id: String,
281 pub goal: Option<String>,
282 pub base_branch: Option<String>,
283 pub mission_branch: Option<String>,
284 pub base_sha: Option<String>,
287 pub gates: Vec<GateLink>,
288 pub sessions: Vec<SessionLink>,
289 pub decisions: Vec<DecisionLink>,
290 #[serde(default)]
294 pub divergences: Vec<DivergenceLink>,
295 #[serde(default, skip_serializing_if = "Option::is_none")]
302 pub standards: Option<crate::standards_coverage::StandardsCoverage>,
303 pub outcome: Option<TerminalLink>,
306}
307
308pub fn provenance_chain(
320 mission_dir: &Path,
321 mission_id: &str,
322 events: &[Event],
323) -> Result<ProvenanceChain> {
324 let mut chain = ProvenanceChain {
325 mission_id: mission_id.to_string(),
326 goal: None,
327 base_branch: None,
328 mission_branch: None,
329 base_sha: None,
330 gates: Vec::new(),
331 sessions: Vec::new(),
332 decisions: Vec::new(),
333 divergences: Vec::new(),
334 standards: None,
335 outcome: None,
336 };
337 let mut config: Option<MissionConfig> = None;
340 let mut plan_approved_seq: Option<u64> = None;
341
342 for event in events.iter().filter(|e| e.mission_id == mission_id) {
343 match &event.kind {
344 EventKind::MissionCreated {
345 goal,
346 base_branch,
347 mission_branch,
348 config: created,
349 } => {
350 chain.goal = Some(goal.clone());
351 chain.base_branch = Some(base_branch.clone());
352 chain.mission_branch = Some(mission_branch.clone());
353 config = Some(created.clone());
354 }
355 EventKind::PlanApproved { base_sha, .. } => {
356 chain.base_sha = base_sha.clone();
357 plan_approved_seq = Some(event.seq);
358 chain.decisions.push(DecisionLink {
359 seq: event.seq,
360 kind: DecisionKind::PlanApproval,
361 summary: "plan approved".to_string(),
362 });
363 }
364 EventKind::PlanRevised { revision, .. } => chain.decisions.push(DecisionLink {
365 seq: event.seq,
366 kind: DecisionKind::PlanRevision,
367 summary: format!("plan revision {revision} approved"),
368 }),
369 EventKind::PlanRevisionRejected { revision, reason } => {
370 chain.decisions.push(DecisionLink {
371 seq: event.seq,
372 kind: DecisionKind::PlanRevisionRejection,
373 summary: format!("plan revision {revision} rejected: {reason}"),
374 });
375 }
376 EventKind::GrantApproved { kind, command } => chain.decisions.push(DecisionLink {
377 seq: event.seq,
378 kind: DecisionKind::GrantApproval,
379 summary: format!(
380 "approved {}: {command}",
381 crate::escalation_metrics::grant_kind_str(kind)
382 ),
383 }),
384 EventKind::GrantDenied {
385 kind,
386 command,
387 reason,
388 } => chain.decisions.push(DecisionLink {
389 seq: event.seq,
390 kind: DecisionKind::GrantDenial,
391 summary: format!(
392 "denied {}: {command} ({reason})",
393 crate::escalation_metrics::grant_kind_str(kind)
394 ),
395 }),
396 EventKind::MilestoneUnblocked {
397 milestone_id,
398 reason,
399 block_context,
400 ..
401 } if !crate::escalation_metrics::is_engine_lift(reason, block_context.as_ref()) => {
402 chain.decisions.push(DecisionLink {
403 seq: event.seq,
404 kind: DecisionKind::MilestoneUnblock,
405 summary: format!("unblocked {milestone_id}: {reason}"),
406 });
407 }
408 EventKind::UserMessage { text, .. } => {
409 let kind = match plan_approved_seq {
412 Some(approved) if event.seq >= approved => DecisionKind::Steer,
413 _ => DecisionKind::OperatorMessage,
414 };
415 chain.decisions.push(DecisionLink {
416 seq: event.seq,
417 kind,
418 summary: text.clone(),
419 });
420 }
421 EventKind::MissionAbandoned { reason } => {
422 chain.decisions.push(DecisionLink {
423 seq: event.seq,
424 kind: DecisionKind::MissionAbandoned,
425 summary: format!("abandoned: {reason}"),
426 });
427 if chain.outcome.is_none() {
428 chain.outcome = Some(TerminalLink {
429 seq: event.seq,
430 status: TerminalStatus::Abandoned,
431 reason: Some(reason.clone()),
432 });
433 }
434 }
435 EventKind::MissionCompleted {} => {
436 if chain.outcome.is_none() {
437 chain.outcome = Some(TerminalLink {
438 seq: event.seq,
439 status: TerminalStatus::Completed,
440 reason: None,
441 });
442 }
443 }
444 EventKind::MissionFailed { reason } => {
445 if chain.outcome.is_none() {
446 chain.outcome = Some(TerminalLink {
447 seq: event.seq,
448 status: TerminalStatus::Failed,
449 reason: Some(reason.clone()),
450 });
451 }
452 }
453 EventKind::GateResult {
454 gate,
455 surface,
456 kind,
457 index,
458 verdict,
459 artefact_ref,
460 artefact_detail,
461 score,
462 threshold,
463 rule_ids,
464 } => chain.gates.push(GateLink {
465 seq: event.seq,
466 gate: gate.clone(),
467 surface: *surface,
468 kind: *kind,
469 index: *index,
470 verdict: *verdict,
471 artefact_ref: artefact_ref.clone(),
472 artefact_detail: artefact_detail.clone(),
473 score: *score,
474 threshold: *threshold,
475 artefact: ArtefactStatus::classify(&resolve_artefact(mission_dir, artefact_ref)),
476 rule_ids: rule_ids.clone(),
477 }),
478 EventKind::DivergenceNoted {
479 unit,
480 candidates,
481 diverged,
482 } => chain.divergences.push(DivergenceLink::Noted {
483 seq: event.seq,
484 unit: unit.clone(),
485 candidates: candidates.clone(),
486 diverged: *diverged,
487 }),
488 EventKind::DivergenceResolved {
489 unit,
490 selected,
491 reason,
492 decided_by,
493 } => chain.divergences.push(DivergenceLink::Resolved {
494 seq: event.seq,
495 unit: unit.clone(),
496 selected: *selected,
497 reason: reason.clone(),
498 decided_by: decided_by.clone(),
499 }),
500 EventKind::WorkerSpawned {
501 run_id,
502 role,
503 feature_id,
504 milestone_id,
505 model,
506 quant,
507 weight_hash,
508 prompt_hash,
509 transcript_path,
510 backend,
511 ..
512 } => chain.sessions.push(SessionLink {
513 seq: event.seq,
514 run_id: run_id.clone(),
515 role: *role,
516 backend: (backend.is_some() || config.is_some()).then(|| {
517 crate::cost::resolved_run_backend(*backend, *role, config.as_ref())
518 .as_str()
519 .to_string()
520 }),
521 model: model.clone(),
522 quant: quant.clone(),
523 weight_hash: weight_hash.clone(),
524 prompt_hash: prompt_hash.clone(),
525 feature_id: feature_id.clone(),
526 milestone_id: milestone_id.clone(),
527 transcript_ref: transcript_path.clone(),
528 transcript: ArtefactStatus::classify(&resolve_artefact(
529 mission_dir,
530 &file_artefact_ref(transcript_path),
531 )),
532 }),
533 EventKind::ConfigChanged { patch } => {
534 if let Some(current) = &mut config {
535 let mut value = serde_json::to_value(&*current)?;
538 crate::reducer::deep_merge(&mut value, patch);
539 *current = serde_json::from_value(value).map_err(|e| {
540 EngineError::Config(format!(
541 "config.changed patch produced invalid config: {e}"
542 ))
543 })?;
544 }
545 }
546 _ => {}
547 }
548 }
549 chain.standards = crate::standards_coverage::standards_coverage(mission_id, events);
554 Ok(chain)
555}
556
557pub fn compute_provenance(repo_root: &Path, mission_id: &str) -> anyhow::Result<ProvenanceChain> {
564 let paths = crate::paths::MissionPaths::new(repo_root, mission_id);
565 paths.require_no_follow()?;
566 let events = crate::event_log::EventLog::read_events(&paths.events_file())?;
567 Ok(provenance_chain(&paths.mission_dir(), mission_id, &events)?)
568}
569
570#[cfg(test)]
571mod tests {
572 use super::*;
573 use crate::event_log::{EventLog, LockForce};
574 use crate::paths::MissionPaths;
575 use crate::types::{GrantKind, Plan};
576 use std::time::Duration;
577 use tempfile::TempDir;
578
579 fn seed_mission(repo_root: &Path, id: &str, kinds: Vec<EventKind>) -> MissionPaths {
583 let paths = MissionPaths::new(repo_root, id);
584 let mut log = EventLog::acquire(&paths, id, Duration::ZERO, LockForce::No).unwrap();
585 for kind in kinds {
586 log.append(kind).unwrap();
587 }
588 paths
589 }
590
591 fn sample_plan() -> Plan {
592 Plan {
593 goal: "ship the thing".into(),
594 validation_contract: vec![],
595 milestones: vec![],
596 considered_alternatives: None,
597 command_grants: vec![],
598 touch_set: vec![],
599 standards_manifest: None,
600 reviewer_independence: None,
601 }
602 }
603
604 fn created_config() -> MissionConfig {
607 let mut config = MissionConfig::default();
608 config.worker.backend = Some("codex".to_string());
609 config
610 }
611
612 fn created() -> EventKind {
613 EventKind::MissionCreated {
614 goal: "ship the thing".into(),
615 base_branch: "main".into(),
616 mission_branch: "kranz/mission-x".into(),
617 config: created_config(),
618 }
619 }
620
621 fn gate_result(
622 gate: &str,
623 surface: GateSurface,
624 kind: GateKind,
625 index: u32,
626 verdict: GateVerdict,
627 artefact_ref: &str,
628 ) -> EventKind {
629 EventKind::GateResult {
630 gate: gate.to_string(),
631 surface,
632 kind,
633 index,
634 verdict,
635 artefact_ref: artefact_ref.to_string(),
636 artefact_detail: None,
637 score: None,
638 threshold: None,
639 rule_ids: Vec::new(),
640 }
641 }
642
643 fn worker_spawned(run_id: &str, role: Role, model: &str, prompt_hash: &str) -> EventKind {
644 EventKind::WorkerSpawned {
645 backend: None,
646 run_id: run_id.to_string(),
647 role,
648 feature_id: None,
649 milestone_id: None,
650 candidate: None,
651 executor_route: None,
652 sdk_session_id: format!("sess-{run_id}"),
653 model: model.to_string(),
654 quant: "n/a".to_string(),
655 weight_hash: None,
656 prompt_hash: prompt_hash.to_string(),
657 transcript_path: MissionPaths::transcript_rel(run_id),
658 }
659 }
660
661 fn seed_full_mission(root: &Path) -> MissionPaths {
669 let mut judged = gate_result(
670 "plan-review",
671 GateSurface::Approval,
672 GateKind::ModelJudged,
673 0,
674 GateVerdict::Pass,
675 "file:runs/gone.jsonl",
676 );
677 if let EventKind::GateResult {
678 artefact_detail,
679 score,
680 threshold,
681 ..
682 } = &mut judged
683 {
684 *artefact_detail = Some("looks sound".to_string());
685 *score = Some(0.9);
686 *threshold = Some(0.5);
687 }
688 let paths = seed_mission(
689 root,
690 "m-1",
691 vec![
692 created(),
693 EventKind::PlanApproved {
694 plan: sample_plan(),
695 base_sha: Some("deadbeef".to_string()),
696 },
697 gate_result(
698 "vacuous-filter",
699 GateSurface::Approval,
700 GateKind::Deterministic,
701 0,
702 GateVerdict::Pass,
703 "contract gate vacuous-filter",
704 ),
705 gate_result(
706 "merge-gate-suite",
707 GateSurface::Approval,
708 GateKind::Deterministic,
709 1,
710 GateVerdict::Pass,
711 "file:runs/gate-base.jsonl",
712 ),
713 judged,
714 {
715 let mut spawn = worker_spawned("r-1", Role::Worker, "gpt-5", "aaaabbbbcccc");
716 if let EventKind::WorkerSpawned {
717 feature_id,
718 milestone_id,
719 ..
720 } = &mut spawn
721 {
722 *feature_id = Some("f-1-1".to_string());
723 *milestone_id = Some("ms-1".to_string());
724 }
725 spawn
726 },
727 EventKind::GrantRequested {
728 milestone_id: "ms-1".into(),
729 kind: GrantKind::Command,
730 command: "cargo test".into(),
731 },
732 EventKind::GrantApproved {
733 kind: GrantKind::Command,
734 command: "cargo test".into(),
735 },
736 EventKind::ConfigChanged {
737 patch: serde_json::json!({"worker": {"backend": "local"}}),
738 },
739 worker_spawned("r-2", Role::Worker, "my-local-model", "dddd11112222"),
740 worker_spawned("r-3", Role::ValidatorScrutiny, "sonnet", "ffff33334444"),
741 EventKind::MilestoneBlocked {
742 block_context: None,
743 milestone_id: "ms-1".into(),
744 reason: "fix-cycle cap".into(),
745 },
746 EventKind::MilestoneUnblocked {
747 block_context: None,
748 milestone_id: "ms-1".into(),
749 reason: "user skipped findings".into(),
750 validator_guidance: None,
751 },
752 EventKind::MilestoneUnblocked {
753 block_context: None,
754 milestone_id: "ms-1".into(),
755 reason: crate::workspace_gate::GATE_LIFT_REASON.to_string(),
756 validator_guidance: None,
757 },
758 EventKind::UserMessage {
759 text: "skip the flaky test".into(),
760 interrupt: false,
761 },
762 gate_result(
763 "merge-gate-suite",
764 GateSurface::FinalGate,
765 GateKind::Deterministic,
766 0,
767 GateVerdict::Pass,
768 ".kranz/merge-gates.json",
769 ),
770 EventKind::MissionCompleted {},
771 ],
772 );
773 std::fs::write(paths.runs_dir().join("gate-base.jsonl"), b"{}").unwrap();
775 std::fs::write(paths.runs_dir().join("r-1.jsonl"), b"{}").unwrap();
776 paths
777 }
778
779 #[test]
785 fn provenance_replay_names_ladder_sessions_decisions_and_outcome_in_order() {
786 let tmp = TempDir::new().unwrap();
787 let paths = seed_full_mission(tmp.path());
788 let events = EventLog::read_events(&paths.events_file()).unwrap();
789 let chain = provenance_chain(&paths.mission_dir(), "m-1", &events).unwrap();
790
791 assert_eq!(chain.mission_id, "m-1");
793 assert_eq!(chain.goal.as_deref(), Some("ship the thing"));
794 assert_eq!(chain.base_branch.as_deref(), Some("main"));
795 assert_eq!(chain.mission_branch.as_deref(), Some("kranz/mission-x"));
796 assert_eq!(chain.base_sha.as_deref(), Some("deadbeef"));
797
798 let ladder: Vec<(
800 u64,
801 &str,
802 GateSurface,
803 GateKind,
804 u32,
805 GateVerdict,
806 ArtefactStatus,
807 )> = chain
808 .gates
809 .iter()
810 .map(|gate| {
811 (
812 gate.seq,
813 gate.gate.as_str(),
814 gate.surface,
815 gate.kind,
816 gate.index,
817 gate.verdict,
818 gate.artefact,
819 )
820 })
821 .collect();
822 assert_eq!(
823 ladder,
824 vec![
825 (
826 3,
827 "vacuous-filter",
828 GateSurface::Approval,
829 GateKind::Deterministic,
830 0,
831 GateVerdict::Pass,
832 ArtefactStatus::Inline
833 ),
834 (
835 4,
836 "merge-gate-suite",
837 GateSurface::Approval,
838 GateKind::Deterministic,
839 1,
840 GateVerdict::Pass,
841 ArtefactStatus::Resolved
842 ),
843 (
844 5,
845 "plan-review",
846 GateSurface::Approval,
847 GateKind::ModelJudged,
848 0,
849 GateVerdict::Pass,
850 ArtefactStatus::Unresolved
851 ),
852 (
853 16,
854 "merge-gate-suite",
855 GateSurface::FinalGate,
856 GateKind::Deterministic,
857 0,
858 GateVerdict::Pass,
859 ArtefactStatus::Inline
860 ),
861 ]
862 );
863 assert_eq!(chain.gates[0].artefact_ref, "contract gate vacuous-filter");
865 assert_eq!(chain.gates[1].artefact_ref, "file:runs/gate-base.jsonl");
866 assert_eq!(chain.gates[2].artefact_ref, "file:runs/gone.jsonl");
867 assert_eq!(
868 chain.gates[2].artefact_detail.as_deref(),
869 Some("looks sound")
870 );
871 assert_eq!(chain.gates[2].score, Some(0.9));
872 assert_eq!(chain.gates[2].threshold, Some(0.5));
873
874 assert_eq!(chain.sessions.len(), 3);
878 let r1 = &chain.sessions[0];
879 assert_eq!(r1.seq, 6);
880 assert_eq!(r1.role, Role::Worker);
881 assert_eq!(r1.backend.as_deref(), Some("codex"));
882 assert_eq!(r1.model, "gpt-5");
883 assert_eq!(r1.prompt_hash, "aaaabbbbcccc");
884 assert_eq!(r1.feature_id.as_deref(), Some("f-1-1"));
885 assert_eq!(r1.milestone_id.as_deref(), Some("ms-1"));
886 assert_eq!(r1.transcript_ref, "runs/r-1.jsonl");
887 assert_eq!(r1.transcript, ArtefactStatus::Resolved);
888 let r2 = &chain.sessions[1];
889 assert_eq!(r2.backend.as_deref(), Some("local"));
890 assert_eq!(r2.model, "my-local-model");
891 assert_eq!(r2.prompt_hash, "dddd11112222");
892 assert_eq!(r2.transcript, ArtefactStatus::Unresolved);
894 let r3 = &chain.sessions[2];
895 assert_eq!(r3.role, Role::ValidatorScrutiny);
896 assert_eq!(r3.backend.as_deref(), Some("claude"));
897
898 let decisions: Vec<(u64, DecisionKind, &str)> = chain
901 .decisions
902 .iter()
903 .map(|d| (d.seq, d.kind, d.summary.as_str()))
904 .collect();
905 assert_eq!(
906 decisions,
907 vec![
908 (2, DecisionKind::PlanApproval, "plan approved"),
909 (
910 8,
911 DecisionKind::GrantApproval,
912 "approved command: cargo test"
913 ),
914 (
915 13,
916 DecisionKind::MilestoneUnblock,
917 "unblocked ms-1: user skipped findings"
918 ),
919 (15, DecisionKind::Steer, "skip the flaky test"),
920 ]
921 );
922
923 assert_eq!(
924 chain.outcome,
925 Some(TerminalLink {
926 seq: 17,
927 status: TerminalStatus::Completed,
928 reason: None,
929 })
930 );
931 }
932
933 #[test]
937 fn provenance_replay_without_runs_dir_reconstructs_with_unresolved_refs() {
938 let tmp = TempDir::new().unwrap();
939 let paths = seed_full_mission(tmp.path());
940 std::fs::remove_dir_all(paths.runs_dir()).unwrap();
941
942 let chain = compute_provenance(tmp.path(), "m-1").unwrap();
943 assert_eq!(chain.gates.len(), 4);
944 assert_eq!(chain.gates[1].artefact, ArtefactStatus::Unresolved);
945 assert_eq!(chain.gates[0].artefact, ArtefactStatus::Inline);
947 assert_eq!(chain.gates[3].artefact, ArtefactStatus::Inline);
948 assert_eq!(chain.sessions.len(), 3);
949 for session in &chain.sessions {
950 assert_eq!(
951 session.transcript,
952 ArtefactStatus::Unresolved,
953 "{} must read unresolved with runs/ gone",
954 session.run_id
955 );
956 }
957 assert_eq!(chain.decisions.len(), 4);
958 assert_eq!(
959 chain.outcome.map(|o| o.status),
960 Some(TerminalStatus::Completed)
961 );
962 }
963
964 #[test]
967 fn provenance_replay_machine_form_is_byte_identical_across_replays() {
968 let tmp = TempDir::new().unwrap();
969 seed_full_mission(tmp.path());
970 let first = compute_provenance(tmp.path(), "m-1").unwrap();
971 let second = compute_provenance(tmp.path(), "m-1").unwrap();
972 assert_eq!(first, second);
973 let first_json = serde_json::to_string_pretty(&first).unwrap();
974 let second_json = serde_json::to_string_pretty(&second).unwrap();
975 assert_eq!(first_json, second_json);
976 assert!(
979 !first_json.contains(&tmp.path().to_string_lossy().to_string()),
980 "host path leaked into the machine form: {first_json}"
981 );
982 }
983
984 #[test]
988 fn provenance_replay_pre_gate_logs_still_fold() {
989 let tmp = TempDir::new().unwrap();
990 let paths = seed_mission(
991 tmp.path(),
992 "m-old",
993 vec![
994 EventKind::MissionCreated {
995 goal: "legacy goal".into(),
996 base_branch: "main".into(),
997 mission_branch: "kranz/mission-old".into(),
998 config: MissionConfig::default(),
999 },
1000 EventKind::PlanApproved {
1001 plan: sample_plan(),
1002 base_sha: None,
1003 },
1004 worker_spawned("r-1", Role::Worker, "sonnet", "9999aaaabbbb"),
1005 EventKind::MissionFailed {
1006 reason: "honest failure".into(),
1007 },
1008 ],
1009 );
1010 let events = EventLog::read_events(&paths.events_file()).unwrap();
1011 let chain = provenance_chain(&paths.mission_dir(), "m-old", &events).unwrap();
1012 assert!(chain.gates.is_empty());
1013 assert_eq!(chain.base_sha, None);
1014 assert_eq!(chain.sessions.len(), 1);
1015 assert_eq!(chain.sessions[0].backend.as_deref(), Some("claude"));
1016 assert_eq!(chain.sessions[0].prompt_hash, "9999aaaabbbb");
1017 assert_eq!(
1018 chain.outcome,
1019 Some(TerminalLink {
1020 seq: 4,
1021 status: TerminalStatus::Failed,
1022 reason: Some("honest failure".to_string()),
1023 })
1024 );
1025 let paths = seed_mission(
1028 tmp.path(),
1029 "m-draft",
1030 vec![
1031 EventKind::UserMessage {
1032 text: "make it smaller".into(),
1033 interrupt: false,
1034 },
1035 EventKind::PlanApproved {
1036 plan: sample_plan(),
1037 base_sha: None,
1038 },
1039 ],
1040 );
1041 let events = EventLog::read_events(&paths.events_file()).unwrap();
1042 let chain = provenance_chain(&paths.mission_dir(), "m-draft", &events).unwrap();
1043 assert_eq!(
1044 chain
1045 .decisions
1046 .iter()
1047 .map(|d| (d.seq, d.kind))
1048 .collect::<Vec<_>>(),
1049 vec![
1050 (1, DecisionKind::OperatorMessage),
1051 (2, DecisionKind::PlanApproval)
1052 ]
1053 );
1054 assert_eq!(chain.goal, None);
1057 assert_eq!(chain.outcome, None);
1058 }
1059
1060 #[test]
1064 fn provenance_replay_invalid_config_patch_fails_like_the_reducer() {
1065 let tmp = TempDir::new().unwrap();
1066 let paths = seed_mission(
1067 tmp.path(),
1068 "m-1",
1069 vec![
1070 created(),
1071 EventKind::ConfigChanged {
1072 patch: serde_json::json!({"maxFixCyclesPerMilestone": "not-a-number"}),
1073 },
1074 ],
1075 );
1076 let events = EventLog::read_events(&paths.events_file()).unwrap();
1077 let result = provenance_chain(&paths.mission_dir(), "m-1", &events);
1078 assert!(
1079 matches!(result, Err(EngineError::Config(_))),
1080 "expected the reducer's Config error, got {result:?}"
1081 );
1082 }
1083
1084 #[test]
1090 fn divergence_event_provenance_chain_carries_record_and_resolution() {
1091 let tmp = TempDir::new().unwrap();
1092 let candidate = |run_id: &str, tree: &str| crate::types::DivergenceCandidate {
1093 run_id: run_id.into(),
1094 branch: format!("kranz/pool/m-1/f-1-1-{run_id}"),
1095 backend: "claude".into(),
1096 tree: tree.into(),
1097 };
1098 let paths = seed_mission(
1099 tmp.path(),
1100 "m-1",
1101 vec![
1102 created(),
1103 EventKind::DivergenceNoted {
1104 unit: "f-1-1".into(),
1105 candidates: vec![candidate("r-c0", "aaa"), candidate("r-c1", "bbb")],
1106 diverged: true,
1107 },
1108 EventKind::DivergenceResolved {
1109 unit: "f-1-1".into(),
1110 selected: Some(1),
1111 reason: "codex kept it total".into(),
1112 decided_by: "operator".into(),
1113 },
1114 ],
1115 );
1116 let events = EventLog::read_events(&paths.events_file()).unwrap();
1117 let chain = provenance_chain(&paths.mission_dir(), "m-1", &events).unwrap();
1118 assert_eq!(chain.divergences.len(), 2);
1119 match &chain.divergences[0] {
1120 DivergenceLink::Noted {
1121 seq,
1122 unit,
1123 candidates,
1124 diverged,
1125 } => {
1126 assert_eq!(*seq, 2);
1127 assert_eq!(unit, "f-1-1");
1128 assert!(diverged);
1129 assert_eq!(candidates.len(), 2);
1130 assert_eq!(candidates[1].tree, "bbb");
1131 }
1132 other => panic!("expected the noted link first: {other:?}"),
1133 }
1134 match &chain.divergences[1] {
1135 DivergenceLink::Resolved {
1136 seq,
1137 unit,
1138 selected,
1139 reason,
1140 decided_by,
1141 } => {
1142 assert_eq!(*seq, 3);
1143 assert_eq!(unit, "f-1-1");
1144 assert_eq!(*selected, Some(1));
1145 assert_eq!(reason, "codex kept it total");
1146 assert_eq!(decided_by, "operator");
1147 }
1148 other => panic!("expected the resolution link: {other:?}"),
1149 }
1150
1151 let quiet = provenance_chain(&paths.mission_dir(), "m-1", &[]).unwrap();
1154 assert!(quiet.divergences.is_empty());
1155 let json = serde_json::to_value(&chain).unwrap();
1156 let mut stripped = json.clone();
1157 stripped.as_object_mut().unwrap().remove("divergences");
1158 let back: ProvenanceChain = serde_json::from_value(stripped).unwrap();
1159 assert!(back.divergences.is_empty());
1160 }
1161
1162 fn pinned_plan() -> Plan {
1167 let rule = |id: &str, revision: u64, status: &str, level: &str| crate::types::PinnedRule {
1168 id: id.to_string(),
1169 revision,
1170 rfc: "RFC-001".to_string(),
1171 level: level.to_string(),
1172 effective_status: status.to_string(),
1173 statement: format!("statement for {id}"),
1174 domains: Vec::new(),
1175 stages: vec!["validation".to_string()],
1176 when_paths: Vec::new(),
1177 task_classes: Vec::new(),
1178 checker: Some("gate:zz-gate".to_string()),
1179 waivable: false,
1180 };
1181 Plan {
1182 standards_manifest: Some(Box::new(crate::types::StandardsPin {
1183 pack_name: "zz-pack".to_string(),
1184 pack_dir: "vendor/pack".to_string(),
1185 standards_root: "standards".to_string(),
1186 digest: "ab".repeat(32),
1187 source: crate::types::StandardsPinSource::RepoTracked,
1188 task_class: None,
1189 touch_set: vec!["crates/**".to_string()],
1190 context_paths: Vec::new(),
1191 gates: Vec::new(),
1192 rules: vec![
1193 rule("ZZ-FAIL-001", 2, "enforced", "must"),
1194 rule("ZZ-QUIET-001", 1, "approved", "should"),
1195 ],
1196 })),
1197 ..sample_plan()
1198 }
1199 }
1200
1201 #[test]
1205 fn flight_rules_provenance_replay_folds_the_coverage_matrix() {
1206 let tmp = TempDir::new().unwrap();
1207 seed_mission(
1208 tmp.path(),
1209 "m-1",
1210 vec![
1211 created(),
1212 EventKind::PlanApproved {
1213 plan: pinned_plan(),
1214 base_sha: Some("deadbeef".to_string()),
1215 },
1216 EventKind::StandardsResolved {
1217 source: "repo-tracked".to_string(),
1218 pack_name: "zz-pack".to_string(),
1219 standards_root: "standards".to_string(),
1220 digest: "ab".repeat(32),
1221 stage: "approval".to_string(),
1222 task_class: None,
1223 touch_set: vec!["crates/**".to_string()],
1224 context_paths: Vec::new(),
1225 rules: vec![
1226 crate::types::StandardsRuleRef {
1227 id: "ZZ-FAIL-001".to_string(),
1228 revision: 2,
1229 effective_status: "enforced".to_string(),
1230 },
1231 crate::types::StandardsRuleRef {
1232 id: "ZZ-QUIET-001".to_string(),
1233 revision: 1,
1234 effective_status: "approved".to_string(),
1235 },
1236 ],
1237 approval_seq: 2,
1238 },
1239 EventKind::ValidationFinding {
1240 milestone_id: "ms-1".into(),
1241 run_id: "v-1".into(),
1242 finding: crate::types::Finding {
1243 subject: "a-1".into(),
1244 severity: "major".into(),
1245 evidence: "broke the rule".into(),
1246 suggested_fix: String::new(),
1247 class: String::new(),
1248 rule: Some(crate::types::RuleCitation {
1249 id: "ZZ-FAIL-001".to_string(),
1250 revision: 2,
1251 source: "zz-pack standards".to_string(),
1252 digest: "ab".repeat(32),
1253 lifecycle: "enforced".to_string(),
1254 level: "must".to_string(),
1255 checker: Some("gate:zz-gate".to_string()),
1256 }),
1257 },
1258 },
1259 EventKind::MissionCompleted {},
1260 ],
1261 );
1262 let chain = compute_provenance(tmp.path(), "m-1").unwrap();
1263 let coverage = chain
1264 .standards
1265 .as_ref()
1266 .expect("the matrix rides the chain");
1267 assert_eq!(coverage.pack_name, "zz-pack");
1268 assert_eq!(coverage.approval_seq, 2);
1269 assert_eq!(coverage.resolution_seq, Some(3));
1270 assert_eq!(coverage.rules.len(), 2);
1271 let failed = &coverage.rules[0];
1272 assert_eq!(failed.id, "ZZ-FAIL-001");
1273 assert_eq!(
1274 failed.disposition,
1275 crate::standards_coverage::RuleDisposition::Failed
1276 );
1277 assert_eq!(failed.evidence.len(), 1);
1278 assert_eq!(failed.evidence[0].seq, 4);
1279 assert_eq!(failed.evidence[0].mechanism, "v-1");
1280 let quiet = &coverage.rules[1];
1281 assert_eq!(quiet.id, "ZZ-QUIET-001");
1282 assert_eq!(
1283 quiet.disposition,
1284 crate::standards_coverage::RuleDisposition::NotEvaluated
1285 );
1286
1287 let json = serde_json::to_value(&chain).unwrap();
1290 assert_eq!(json["standards"]["digest"], "ab".repeat(32));
1291 assert_eq!(json["standards"]["rules"][0]["disposition"], "failed");
1292 let mut stripped = json.clone();
1293 stripped.as_object_mut().unwrap().remove("standards");
1294 let back: ProvenanceChain = serde_json::from_value(stripped).unwrap();
1295 assert!(back.standards.is_none());
1296 let row = &json["standards"]["rules"][1];
1300 assert_eq!(row["id"], "ZZ-QUIET-001");
1301 assert_eq!(row["revision"], 1);
1302 assert_eq!(row["lifecycle"], "approved");
1303 assert_eq!(row["level"], "should");
1304 assert_eq!(row["checker"], "gate:zz-gate");
1305 assert_eq!(row["statement"], "statement for ZZ-QUIET-001");
1306 }
1307
1308 #[test]
1313 fn flight_rules_provenance_pre_flight_rules_chain_is_unchanged() {
1314 let tmp = TempDir::new().unwrap();
1315 seed_full_mission(tmp.path());
1316 let chain = compute_provenance(tmp.path(), "m-1").unwrap();
1317 assert!(chain.standards.is_none());
1318 let json = serde_json::to_string_pretty(&chain).unwrap();
1319 assert!(
1320 !json.contains("\"standards\""),
1321 "a pre-Flight-Rules chain carries no standards key: {json}"
1322 );
1323 let mut value = serde_json::to_value(&chain).unwrap();
1325 value.as_object_mut().unwrap().remove("divergences");
1326 let back: ProvenanceChain = serde_json::from_value(value).unwrap();
1327 assert!(back.standards.is_none());
1328 }
1329}