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 #[serde(default, skip_serializing_if = "Vec::is_empty")]
289 pub gate_evaluations: Vec<crate::gate_evaluation::lifecycle::Record>,
290 pub sessions: Vec<SessionLink>,
291 pub decisions: Vec<DecisionLink>,
292 #[serde(default)]
296 pub divergences: Vec<DivergenceLink>,
297 #[serde(default, skip_serializing_if = "Option::is_none")]
304 pub standards: Option<crate::standards_coverage::StandardsCoverage>,
305 pub outcome: Option<TerminalLink>,
308}
309
310pub fn provenance_chain(
322 mission_dir: &Path,
323 mission_id: &str,
324 events: &[Event],
325) -> Result<ProvenanceChain> {
326 let mut chain = ProvenanceChain {
327 mission_id: mission_id.to_string(),
328 goal: None,
329 base_branch: None,
330 mission_branch: None,
331 base_sha: None,
332 gates: Vec::new(),
333 gate_evaluations: Vec::new(),
334 sessions: Vec::new(),
335 decisions: Vec::new(),
336 divergences: Vec::new(),
337 standards: None,
338 outcome: None,
339 };
340 if events.iter().any(|event| {
341 event.mission_id == mission_id
342 && matches!(event.kind, EventKind::GateEvaluationRequested { .. })
343 }) {
344 let mission_events: Vec<_> = events
345 .iter()
346 .filter(|event| event.mission_id == mission_id)
347 .cloned()
348 .collect();
349 chain.gate_evaluations = crate::reducer::fold(&mission_events)?
350 .gate_evaluations
351 .into_values()
352 .collect();
353 chain
354 .gate_evaluations
355 .sort_by_key(|record| record.requested_seq);
356 }
357 let mut config: Option<MissionConfig> = None;
360 let mut plan_approved_seq: Option<u64> = None;
361
362 for event in events.iter().filter(|e| e.mission_id == mission_id) {
363 match &event.kind {
364 EventKind::MissionCreated {
365 goal,
366 base_branch,
367 mission_branch,
368 config: created,
369 } => {
370 chain.goal = Some(goal.clone());
371 chain.base_branch = Some(base_branch.clone());
372 chain.mission_branch = Some(mission_branch.clone());
373 config = Some(created.clone());
374 }
375 EventKind::PlanApproved { base_sha, .. } => {
376 chain.base_sha = base_sha.clone();
377 plan_approved_seq = Some(event.seq);
378 chain.decisions.push(DecisionLink {
379 seq: event.seq,
380 kind: DecisionKind::PlanApproval,
381 summary: "plan approved".to_string(),
382 });
383 }
384 EventKind::PlanRevised { revision, .. } => chain.decisions.push(DecisionLink {
385 seq: event.seq,
386 kind: DecisionKind::PlanRevision,
387 summary: format!("plan revision {revision} approved"),
388 }),
389 EventKind::PlanRevisionRejected { revision, reason } => {
390 chain.decisions.push(DecisionLink {
391 seq: event.seq,
392 kind: DecisionKind::PlanRevisionRejection,
393 summary: format!("plan revision {revision} rejected: {reason}"),
394 });
395 }
396 EventKind::GrantApproved { kind, command } => chain.decisions.push(DecisionLink {
397 seq: event.seq,
398 kind: DecisionKind::GrantApproval,
399 summary: format!(
400 "approved {}: {command}",
401 crate::escalation_metrics::grant_kind_str(kind)
402 ),
403 }),
404 EventKind::GrantDenied {
405 kind,
406 command,
407 reason,
408 } => chain.decisions.push(DecisionLink {
409 seq: event.seq,
410 kind: DecisionKind::GrantDenial,
411 summary: format!(
412 "denied {}: {command} ({reason})",
413 crate::escalation_metrics::grant_kind_str(kind)
414 ),
415 }),
416 EventKind::MilestoneUnblocked {
417 milestone_id,
418 reason,
419 block_context,
420 ..
421 } if !crate::escalation_metrics::is_engine_lift(reason, block_context.as_ref()) => {
422 chain.decisions.push(DecisionLink {
423 seq: event.seq,
424 kind: DecisionKind::MilestoneUnblock,
425 summary: format!("unblocked {milestone_id}: {reason}"),
426 });
427 }
428 EventKind::UserMessage { text, .. } => {
429 let kind = match plan_approved_seq {
432 Some(approved) if event.seq >= approved => DecisionKind::Steer,
433 _ => DecisionKind::OperatorMessage,
434 };
435 chain.decisions.push(DecisionLink {
436 seq: event.seq,
437 kind,
438 summary: text.clone(),
439 });
440 }
441 EventKind::MissionAbandoned { reason } => {
442 chain.decisions.push(DecisionLink {
443 seq: event.seq,
444 kind: DecisionKind::MissionAbandoned,
445 summary: format!("abandoned: {reason}"),
446 });
447 if chain.outcome.is_none() {
448 chain.outcome = Some(TerminalLink {
449 seq: event.seq,
450 status: TerminalStatus::Abandoned,
451 reason: Some(reason.clone()),
452 });
453 }
454 }
455 EventKind::MissionCompleted {} => {
456 if chain.outcome.is_none() {
457 chain.outcome = Some(TerminalLink {
458 seq: event.seq,
459 status: TerminalStatus::Completed,
460 reason: None,
461 });
462 }
463 }
464 EventKind::MissionFailed { reason } => {
465 if chain.outcome.is_none() {
466 chain.outcome = Some(TerminalLink {
467 seq: event.seq,
468 status: TerminalStatus::Failed,
469 reason: Some(reason.clone()),
470 });
471 }
472 }
473 EventKind::GateResult {
474 gate,
475 surface,
476 kind,
477 index,
478 verdict,
479 artefact_ref,
480 artefact_detail,
481 score,
482 threshold,
483 rule_ids,
484 } => chain.gates.push(GateLink {
485 seq: event.seq,
486 gate: gate.clone(),
487 surface: *surface,
488 kind: *kind,
489 index: *index,
490 verdict: *verdict,
491 artefact_ref: artefact_ref.clone(),
492 artefact_detail: artefact_detail.clone(),
493 score: *score,
494 threshold: *threshold,
495 artefact: ArtefactStatus::classify(&resolve_artefact(mission_dir, artefact_ref)),
496 rule_ids: rule_ids.clone(),
497 }),
498 EventKind::DivergenceNoted {
499 unit,
500 candidates,
501 diverged,
502 } => chain.divergences.push(DivergenceLink::Noted {
503 seq: event.seq,
504 unit: unit.clone(),
505 candidates: candidates.clone(),
506 diverged: *diverged,
507 }),
508 EventKind::DivergenceResolved {
509 unit,
510 selected,
511 reason,
512 decided_by,
513 } => chain.divergences.push(DivergenceLink::Resolved {
514 seq: event.seq,
515 unit: unit.clone(),
516 selected: *selected,
517 reason: reason.clone(),
518 decided_by: decided_by.clone(),
519 }),
520 EventKind::WorkerSpawned {
521 run_id,
522 role,
523 feature_id,
524 milestone_id,
525 model,
526 quant,
527 weight_hash,
528 prompt_hash,
529 transcript_path,
530 backend,
531 ..
532 } => chain.sessions.push(SessionLink {
533 seq: event.seq,
534 run_id: run_id.clone(),
535 role: *role,
536 backend: (backend.is_some() || config.is_some()).then(|| {
537 crate::cost::resolved_run_backend(*backend, *role, config.as_ref())
538 .as_str()
539 .to_string()
540 }),
541 model: model.clone(),
542 quant: quant.clone(),
543 weight_hash: weight_hash.clone(),
544 prompt_hash: prompt_hash.clone(),
545 feature_id: feature_id.clone(),
546 milestone_id: milestone_id.clone(),
547 transcript_ref: transcript_path.clone(),
548 transcript: ArtefactStatus::classify(&resolve_artefact(
549 mission_dir,
550 &file_artefact_ref(transcript_path),
551 )),
552 }),
553 EventKind::ConfigChanged { patch } => {
554 if let Some(current) = &mut config {
555 let mut value = serde_json::to_value(&*current)?;
558 crate::reducer::deep_merge(&mut value, patch);
559 *current = serde_json::from_value(value).map_err(|e| {
560 EngineError::Config(format!(
561 "config.changed patch produced invalid config: {e}"
562 ))
563 })?;
564 }
565 }
566 _ => {}
567 }
568 }
569 chain.standards = crate::standards_coverage::standards_coverage(mission_id, events);
574 Ok(chain)
575}
576
577pub fn compute_provenance(repo_root: &Path, mission_id: &str) -> anyhow::Result<ProvenanceChain> {
584 let paths = crate::paths::MissionPaths::new(repo_root, mission_id);
585 paths.require_no_follow()?;
586 let events = crate::event_log::EventLog::read_events(&paths.events_file())?;
587 Ok(provenance_chain(&paths.mission_dir(), mission_id, &events)?)
588}
589
590#[cfg(test)]
591mod tests {
592 use super::*;
593 use crate::event_log::{EventLog, LockForce};
594 use crate::paths::MissionPaths;
595 use crate::types::{GrantKind, Plan};
596 use std::time::Duration;
597 use tempfile::TempDir;
598
599 fn seed_mission(repo_root: &Path, id: &str, kinds: Vec<EventKind>) -> MissionPaths {
603 let paths = MissionPaths::new(repo_root, id);
604 let mut log = EventLog::acquire(&paths, id, Duration::ZERO, LockForce::No).unwrap();
605 for kind in kinds {
606 log.append(kind).unwrap();
607 }
608 paths
609 }
610
611 fn sample_plan() -> Plan {
612 Plan {
613 goal: "ship the thing".into(),
614 validation_contract: vec![],
615 milestones: vec![],
616 considered_alternatives: None,
617 command_grants: vec![],
618 touch_set: vec![],
619 standards_manifest: None,
620 reviewer_independence: None,
621 }
622 }
623
624 fn created_config() -> MissionConfig {
627 let mut config = MissionConfig::default();
628 config.worker.backend = Some("codex".to_string());
629 config
630 }
631
632 fn created() -> EventKind {
633 EventKind::MissionCreated {
634 goal: "ship the thing".into(),
635 base_branch: "main".into(),
636 mission_branch: "kranz/mission-x".into(),
637 config: created_config(),
638 }
639 }
640
641 fn gate_result(
642 gate: &str,
643 surface: GateSurface,
644 kind: GateKind,
645 index: u32,
646 verdict: GateVerdict,
647 artefact_ref: &str,
648 ) -> EventKind {
649 EventKind::GateResult {
650 gate: gate.to_string(),
651 surface,
652 kind,
653 index,
654 verdict,
655 artefact_ref: artefact_ref.to_string(),
656 artefact_detail: None,
657 score: None,
658 threshold: None,
659 rule_ids: Vec::new(),
660 }
661 }
662
663 fn worker_spawned(run_id: &str, role: Role, model: &str, prompt_hash: &str) -> EventKind {
664 EventKind::WorkerSpawned {
665 backend: None,
666 run_id: run_id.to_string(),
667 role,
668 feature_id: None,
669 milestone_id: None,
670 candidate: None,
671 executor_route: None,
672 sdk_session_id: format!("sess-{run_id}"),
673 model: model.to_string(),
674 quant: "n/a".to_string(),
675 weight_hash: None,
676 prompt_hash: prompt_hash.to_string(),
677 transcript_path: MissionPaths::transcript_rel(run_id),
678 }
679 }
680
681 fn seed_full_mission(root: &Path) -> MissionPaths {
689 let mut judged = gate_result(
690 "plan-review",
691 GateSurface::Approval,
692 GateKind::ModelJudged,
693 0,
694 GateVerdict::Pass,
695 "file:runs/gone.jsonl",
696 );
697 if let EventKind::GateResult {
698 artefact_detail,
699 score,
700 threshold,
701 ..
702 } = &mut judged
703 {
704 *artefact_detail = Some("looks sound".to_string());
705 *score = Some(0.9);
706 *threshold = Some(0.5);
707 }
708 let paths = seed_mission(
709 root,
710 "m-1",
711 vec![
712 created(),
713 EventKind::PlanApproved {
714 plan: sample_plan(),
715 base_sha: Some("deadbeef".to_string()),
716 },
717 gate_result(
718 "vacuous-filter",
719 GateSurface::Approval,
720 GateKind::Deterministic,
721 0,
722 GateVerdict::Pass,
723 "contract gate vacuous-filter",
724 ),
725 gate_result(
726 "merge-gate-suite",
727 GateSurface::Approval,
728 GateKind::Deterministic,
729 1,
730 GateVerdict::Pass,
731 "file:runs/gate-base.jsonl",
732 ),
733 judged,
734 {
735 let mut spawn = worker_spawned("r-1", Role::Worker, "gpt-5", "aaaabbbbcccc");
736 if let EventKind::WorkerSpawned {
737 feature_id,
738 milestone_id,
739 ..
740 } = &mut spawn
741 {
742 *feature_id = Some("f-1-1".to_string());
743 *milestone_id = Some("ms-1".to_string());
744 }
745 spawn
746 },
747 EventKind::GrantRequested {
748 milestone_id: "ms-1".into(),
749 kind: GrantKind::Command,
750 command: "cargo test".into(),
751 },
752 EventKind::GrantApproved {
753 kind: GrantKind::Command,
754 command: "cargo test".into(),
755 },
756 EventKind::ConfigChanged {
757 patch: serde_json::json!({"worker": {"backend": "local"}}),
758 },
759 worker_spawned("r-2", Role::Worker, "my-local-model", "dddd11112222"),
760 worker_spawned("r-3", Role::ValidatorScrutiny, "sonnet", "ffff33334444"),
761 EventKind::MilestoneBlocked {
762 block_context: None,
763 milestone_id: "ms-1".into(),
764 reason: "fix-cycle cap".into(),
765 },
766 EventKind::MilestoneUnblocked {
767 block_context: None,
768 milestone_id: "ms-1".into(),
769 reason: "user skipped findings".into(),
770 validator_guidance: None,
771 },
772 EventKind::MilestoneUnblocked {
773 block_context: None,
774 milestone_id: "ms-1".into(),
775 reason: crate::workspace_gate::GATE_LIFT_REASON.to_string(),
776 validator_guidance: None,
777 },
778 EventKind::UserMessage {
779 text: "skip the flaky test".into(),
780 interrupt: false,
781 },
782 gate_result(
783 "merge-gate-suite",
784 GateSurface::FinalGate,
785 GateKind::Deterministic,
786 0,
787 GateVerdict::Pass,
788 ".kranz/merge-gates.json",
789 ),
790 EventKind::MissionCompleted {},
791 ],
792 );
793 std::fs::write(paths.runs_dir().join("gate-base.jsonl"), b"{}").unwrap();
795 std::fs::write(paths.runs_dir().join("r-1.jsonl"), b"{}").unwrap();
796 paths
797 }
798
799 #[test]
805 fn provenance_replay_names_ladder_sessions_decisions_and_outcome_in_order() {
806 let tmp = TempDir::new().unwrap();
807 let paths = seed_full_mission(tmp.path());
808 let events = EventLog::read_events(&paths.events_file()).unwrap();
809 let chain = provenance_chain(&paths.mission_dir(), "m-1", &events).unwrap();
810
811 assert_eq!(chain.mission_id, "m-1");
813 assert_eq!(chain.goal.as_deref(), Some("ship the thing"));
814 assert_eq!(chain.base_branch.as_deref(), Some("main"));
815 assert_eq!(chain.mission_branch.as_deref(), Some("kranz/mission-x"));
816 assert_eq!(chain.base_sha.as_deref(), Some("deadbeef"));
817
818 let ladder: Vec<(
820 u64,
821 &str,
822 GateSurface,
823 GateKind,
824 u32,
825 GateVerdict,
826 ArtefactStatus,
827 )> = chain
828 .gates
829 .iter()
830 .map(|gate| {
831 (
832 gate.seq,
833 gate.gate.as_str(),
834 gate.surface,
835 gate.kind,
836 gate.index,
837 gate.verdict,
838 gate.artefact,
839 )
840 })
841 .collect();
842 assert_eq!(
843 ladder,
844 vec![
845 (
846 3,
847 "vacuous-filter",
848 GateSurface::Approval,
849 GateKind::Deterministic,
850 0,
851 GateVerdict::Pass,
852 ArtefactStatus::Inline
853 ),
854 (
855 4,
856 "merge-gate-suite",
857 GateSurface::Approval,
858 GateKind::Deterministic,
859 1,
860 GateVerdict::Pass,
861 ArtefactStatus::Resolved
862 ),
863 (
864 5,
865 "plan-review",
866 GateSurface::Approval,
867 GateKind::ModelJudged,
868 0,
869 GateVerdict::Pass,
870 ArtefactStatus::Unresolved
871 ),
872 (
873 16,
874 "merge-gate-suite",
875 GateSurface::FinalGate,
876 GateKind::Deterministic,
877 0,
878 GateVerdict::Pass,
879 ArtefactStatus::Inline
880 ),
881 ]
882 );
883 assert_eq!(chain.gates[0].artefact_ref, "contract gate vacuous-filter");
885 assert_eq!(chain.gates[1].artefact_ref, "file:runs/gate-base.jsonl");
886 assert_eq!(chain.gates[2].artefact_ref, "file:runs/gone.jsonl");
887 assert_eq!(
888 chain.gates[2].artefact_detail.as_deref(),
889 Some("looks sound")
890 );
891 assert_eq!(chain.gates[2].score, Some(0.9));
892 assert_eq!(chain.gates[2].threshold, Some(0.5));
893
894 assert_eq!(chain.sessions.len(), 3);
898 let r1 = &chain.sessions[0];
899 assert_eq!(r1.seq, 6);
900 assert_eq!(r1.role, Role::Worker);
901 assert_eq!(r1.backend.as_deref(), Some("codex"));
902 assert_eq!(r1.model, "gpt-5");
903 assert_eq!(r1.prompt_hash, "aaaabbbbcccc");
904 assert_eq!(r1.feature_id.as_deref(), Some("f-1-1"));
905 assert_eq!(r1.milestone_id.as_deref(), Some("ms-1"));
906 assert_eq!(r1.transcript_ref, "runs/r-1.jsonl");
907 assert_eq!(r1.transcript, ArtefactStatus::Resolved);
908 let r2 = &chain.sessions[1];
909 assert_eq!(r2.backend.as_deref(), Some("local"));
910 assert_eq!(r2.model, "my-local-model");
911 assert_eq!(r2.prompt_hash, "dddd11112222");
912 assert_eq!(r2.transcript, ArtefactStatus::Unresolved);
914 let r3 = &chain.sessions[2];
915 assert_eq!(r3.role, Role::ValidatorScrutiny);
916 assert_eq!(r3.backend.as_deref(), Some("claude"));
917
918 let decisions: Vec<(u64, DecisionKind, &str)> = chain
921 .decisions
922 .iter()
923 .map(|d| (d.seq, d.kind, d.summary.as_str()))
924 .collect();
925 assert_eq!(
926 decisions,
927 vec![
928 (2, DecisionKind::PlanApproval, "plan approved"),
929 (
930 8,
931 DecisionKind::GrantApproval,
932 "approved command: cargo test"
933 ),
934 (
935 13,
936 DecisionKind::MilestoneUnblock,
937 "unblocked ms-1: user skipped findings"
938 ),
939 (15, DecisionKind::Steer, "skip the flaky test"),
940 ]
941 );
942
943 assert_eq!(
944 chain.outcome,
945 Some(TerminalLink {
946 seq: 17,
947 status: TerminalStatus::Completed,
948 reason: None,
949 })
950 );
951 }
952
953 #[test]
957 fn provenance_replay_without_runs_dir_reconstructs_with_unresolved_refs() {
958 let tmp = TempDir::new().unwrap();
959 let paths = seed_full_mission(tmp.path());
960 std::fs::remove_dir_all(paths.runs_dir()).unwrap();
961
962 let chain = compute_provenance(tmp.path(), "m-1").unwrap();
963 assert_eq!(chain.gates.len(), 4);
964 assert_eq!(chain.gates[1].artefact, ArtefactStatus::Unresolved);
965 assert_eq!(chain.gates[0].artefact, ArtefactStatus::Inline);
967 assert_eq!(chain.gates[3].artefact, ArtefactStatus::Inline);
968 assert_eq!(chain.sessions.len(), 3);
969 for session in &chain.sessions {
970 assert_eq!(
971 session.transcript,
972 ArtefactStatus::Unresolved,
973 "{} must read unresolved with runs/ gone",
974 session.run_id
975 );
976 }
977 assert_eq!(chain.decisions.len(), 4);
978 assert_eq!(
979 chain.outcome.map(|o| o.status),
980 Some(TerminalStatus::Completed)
981 );
982 }
983
984 #[test]
987 fn provenance_replay_machine_form_is_byte_identical_across_replays() {
988 let tmp = TempDir::new().unwrap();
989 seed_full_mission(tmp.path());
990 let first = compute_provenance(tmp.path(), "m-1").unwrap();
991 let second = compute_provenance(tmp.path(), "m-1").unwrap();
992 assert_eq!(first, second);
993 let first_json = serde_json::to_string_pretty(&first).unwrap();
994 let second_json = serde_json::to_string_pretty(&second).unwrap();
995 assert_eq!(first_json, second_json);
996 assert!(
999 !first_json.contains(&tmp.path().to_string_lossy().to_string()),
1000 "host path leaked into the machine form: {first_json}"
1001 );
1002 }
1003
1004 #[test]
1008 fn provenance_replay_pre_gate_logs_still_fold() {
1009 let tmp = TempDir::new().unwrap();
1010 let paths = seed_mission(
1011 tmp.path(),
1012 "m-old",
1013 vec![
1014 EventKind::MissionCreated {
1015 goal: "legacy goal".into(),
1016 base_branch: "main".into(),
1017 mission_branch: "kranz/mission-old".into(),
1018 config: MissionConfig::default(),
1019 },
1020 EventKind::PlanApproved {
1021 plan: sample_plan(),
1022 base_sha: None,
1023 },
1024 worker_spawned("r-1", Role::Worker, "sonnet", "9999aaaabbbb"),
1025 EventKind::MissionFailed {
1026 reason: "honest failure".into(),
1027 },
1028 ],
1029 );
1030 let events = EventLog::read_events(&paths.events_file()).unwrap();
1031 let chain = provenance_chain(&paths.mission_dir(), "m-old", &events).unwrap();
1032 assert!(chain.gates.is_empty());
1033 assert_eq!(chain.base_sha, None);
1034 assert_eq!(chain.sessions.len(), 1);
1035 assert_eq!(chain.sessions[0].backend.as_deref(), Some("claude"));
1036 assert_eq!(chain.sessions[0].prompt_hash, "9999aaaabbbb");
1037 assert_eq!(
1038 chain.outcome,
1039 Some(TerminalLink {
1040 seq: 4,
1041 status: TerminalStatus::Failed,
1042 reason: Some("honest failure".to_string()),
1043 })
1044 );
1045 let paths = seed_mission(
1048 tmp.path(),
1049 "m-draft",
1050 vec![
1051 EventKind::UserMessage {
1052 text: "make it smaller".into(),
1053 interrupt: false,
1054 },
1055 EventKind::PlanApproved {
1056 plan: sample_plan(),
1057 base_sha: None,
1058 },
1059 ],
1060 );
1061 let events = EventLog::read_events(&paths.events_file()).unwrap();
1062 let chain = provenance_chain(&paths.mission_dir(), "m-draft", &events).unwrap();
1063 assert_eq!(
1064 chain
1065 .decisions
1066 .iter()
1067 .map(|d| (d.seq, d.kind))
1068 .collect::<Vec<_>>(),
1069 vec![
1070 (1, DecisionKind::OperatorMessage),
1071 (2, DecisionKind::PlanApproval)
1072 ]
1073 );
1074 assert_eq!(chain.goal, None);
1077 assert_eq!(chain.outcome, None);
1078 }
1079
1080 #[test]
1084 fn provenance_replay_invalid_config_patch_fails_like_the_reducer() {
1085 let tmp = TempDir::new().unwrap();
1086 let paths = seed_mission(
1087 tmp.path(),
1088 "m-1",
1089 vec![
1090 created(),
1091 EventKind::ConfigChanged {
1092 patch: serde_json::json!({"maxFixCyclesPerMilestone": "not-a-number"}),
1093 },
1094 ],
1095 );
1096 let events = EventLog::read_events(&paths.events_file()).unwrap();
1097 let result = provenance_chain(&paths.mission_dir(), "m-1", &events);
1098 assert!(
1099 matches!(result, Err(EngineError::Config(_))),
1100 "expected the reducer's Config error, got {result:?}"
1101 );
1102 }
1103
1104 #[test]
1110 fn divergence_event_provenance_chain_carries_record_and_resolution() {
1111 let tmp = TempDir::new().unwrap();
1112 let candidate = |run_id: &str, tree: &str| crate::types::DivergenceCandidate {
1113 run_id: run_id.into(),
1114 branch: format!("kranz/pool/m-1/f-1-1-{run_id}"),
1115 backend: "claude".into(),
1116 tree: tree.into(),
1117 };
1118 let paths = seed_mission(
1119 tmp.path(),
1120 "m-1",
1121 vec![
1122 created(),
1123 EventKind::DivergenceNoted {
1124 unit: "f-1-1".into(),
1125 candidates: vec![candidate("r-c0", "aaa"), candidate("r-c1", "bbb")],
1126 diverged: true,
1127 },
1128 EventKind::DivergenceResolved {
1129 unit: "f-1-1".into(),
1130 selected: Some(1),
1131 reason: "codex kept it total".into(),
1132 decided_by: "operator".into(),
1133 },
1134 ],
1135 );
1136 let events = EventLog::read_events(&paths.events_file()).unwrap();
1137 let chain = provenance_chain(&paths.mission_dir(), "m-1", &events).unwrap();
1138 assert_eq!(chain.divergences.len(), 2);
1139 match &chain.divergences[0] {
1140 DivergenceLink::Noted {
1141 seq,
1142 unit,
1143 candidates,
1144 diverged,
1145 } => {
1146 assert_eq!(*seq, 2);
1147 assert_eq!(unit, "f-1-1");
1148 assert!(diverged);
1149 assert_eq!(candidates.len(), 2);
1150 assert_eq!(candidates[1].tree, "bbb");
1151 }
1152 other => panic!("expected the noted link first: {other:?}"),
1153 }
1154 match &chain.divergences[1] {
1155 DivergenceLink::Resolved {
1156 seq,
1157 unit,
1158 selected,
1159 reason,
1160 decided_by,
1161 } => {
1162 assert_eq!(*seq, 3);
1163 assert_eq!(unit, "f-1-1");
1164 assert_eq!(*selected, Some(1));
1165 assert_eq!(reason, "codex kept it total");
1166 assert_eq!(decided_by, "operator");
1167 }
1168 other => panic!("expected the resolution link: {other:?}"),
1169 }
1170
1171 let quiet = provenance_chain(&paths.mission_dir(), "m-1", &[]).unwrap();
1174 assert!(quiet.divergences.is_empty());
1175 let json = serde_json::to_value(&chain).unwrap();
1176 let mut stripped = json.clone();
1177 stripped.as_object_mut().unwrap().remove("divergences");
1178 let back: ProvenanceChain = serde_json::from_value(stripped).unwrap();
1179 assert!(back.divergences.is_empty());
1180 }
1181
1182 fn pinned_plan() -> Plan {
1187 let rule = |id: &str, revision: u64, status: &str, level: &str| crate::types::PinnedRule {
1188 id: id.to_string(),
1189 revision,
1190 rfc: "RFC-001".to_string(),
1191 level: level.to_string(),
1192 effective_status: status.to_string(),
1193 statement: format!("statement for {id}"),
1194 domains: Vec::new(),
1195 stages: vec!["validation".to_string()],
1196 when_paths: Vec::new(),
1197 task_classes: Vec::new(),
1198 checker: Some("gate:zz-gate".to_string()),
1199 waivable: false,
1200 };
1201 Plan {
1202 standards_manifest: Some(Box::new(crate::types::StandardsPin {
1203 pack_name: "zz-pack".to_string(),
1204 pack_dir: "vendor/pack".to_string(),
1205 standards_root: "standards".to_string(),
1206 digest: "ab".repeat(32),
1207 source: crate::types::StandardsPinSource::RepoTracked,
1208 task_class: None,
1209 touch_set: vec!["crates/**".to_string()],
1210 context_paths: Vec::new(),
1211 gates: Vec::new(),
1212 rules: vec![
1213 rule("ZZ-FAIL-001", 2, "enforced", "must"),
1214 rule("ZZ-QUIET-001", 1, "approved", "should"),
1215 ],
1216 })),
1217 ..sample_plan()
1218 }
1219 }
1220
1221 #[test]
1225 fn flight_rules_provenance_replay_folds_the_coverage_matrix() {
1226 let tmp = TempDir::new().unwrap();
1227 seed_mission(
1228 tmp.path(),
1229 "m-1",
1230 vec![
1231 created(),
1232 EventKind::PlanApproved {
1233 plan: pinned_plan(),
1234 base_sha: Some("deadbeef".to_string()),
1235 },
1236 EventKind::StandardsResolved {
1237 source: "repo-tracked".to_string(),
1238 pack_name: "zz-pack".to_string(),
1239 standards_root: "standards".to_string(),
1240 digest: "ab".repeat(32),
1241 stage: "approval".to_string(),
1242 task_class: None,
1243 touch_set: vec!["crates/**".to_string()],
1244 context_paths: Vec::new(),
1245 rules: vec![
1246 crate::types::StandardsRuleRef {
1247 id: "ZZ-FAIL-001".to_string(),
1248 revision: 2,
1249 effective_status: "enforced".to_string(),
1250 },
1251 crate::types::StandardsRuleRef {
1252 id: "ZZ-QUIET-001".to_string(),
1253 revision: 1,
1254 effective_status: "approved".to_string(),
1255 },
1256 ],
1257 approval_seq: 2,
1258 },
1259 EventKind::ValidationFinding {
1260 milestone_id: "ms-1".into(),
1261 run_id: "v-1".into(),
1262 finding: crate::types::Finding {
1263 subject: "a-1".into(),
1264 severity: "major".into(),
1265 evidence: "broke the rule".into(),
1266 suggested_fix: String::new(),
1267 class: String::new(),
1268 rule: Some(crate::types::RuleCitation {
1269 id: "ZZ-FAIL-001".to_string(),
1270 revision: 2,
1271 source: "zz-pack standards".to_string(),
1272 digest: "ab".repeat(32),
1273 lifecycle: "enforced".to_string(),
1274 level: "must".to_string(),
1275 checker: Some("gate:zz-gate".to_string()),
1276 }),
1277 },
1278 },
1279 EventKind::MissionCompleted {},
1280 ],
1281 );
1282 let chain = compute_provenance(tmp.path(), "m-1").unwrap();
1283 let coverage = chain
1284 .standards
1285 .as_ref()
1286 .expect("the matrix rides the chain");
1287 assert_eq!(coverage.pack_name, "zz-pack");
1288 assert_eq!(coverage.approval_seq, 2);
1289 assert_eq!(coverage.resolution_seq, Some(3));
1290 assert_eq!(coverage.rules.len(), 2);
1291 let failed = &coverage.rules[0];
1292 assert_eq!(failed.id, "ZZ-FAIL-001");
1293 assert_eq!(
1294 failed.disposition,
1295 crate::standards_coverage::RuleDisposition::Failed
1296 );
1297 assert_eq!(failed.evidence.len(), 1);
1298 assert_eq!(failed.evidence[0].seq, 4);
1299 assert_eq!(failed.evidence[0].mechanism, "v-1");
1300 let quiet = &coverage.rules[1];
1301 assert_eq!(quiet.id, "ZZ-QUIET-001");
1302 assert_eq!(
1303 quiet.disposition,
1304 crate::standards_coverage::RuleDisposition::NotEvaluated
1305 );
1306
1307 let json = serde_json::to_value(&chain).unwrap();
1310 assert_eq!(json["standards"]["digest"], "ab".repeat(32));
1311 assert_eq!(json["standards"]["rules"][0]["disposition"], "failed");
1312 let mut stripped = json.clone();
1313 stripped.as_object_mut().unwrap().remove("standards");
1314 let back: ProvenanceChain = serde_json::from_value(stripped).unwrap();
1315 assert!(back.standards.is_none());
1316 let row = &json["standards"]["rules"][1];
1320 assert_eq!(row["id"], "ZZ-QUIET-001");
1321 assert_eq!(row["revision"], 1);
1322 assert_eq!(row["lifecycle"], "approved");
1323 assert_eq!(row["level"], "should");
1324 assert_eq!(row["checker"], "gate:zz-gate");
1325 assert_eq!(row["statement"], "statement for ZZ-QUIET-001");
1326 }
1327
1328 #[test]
1333 fn flight_rules_provenance_pre_flight_rules_chain_is_unchanged() {
1334 let tmp = TempDir::new().unwrap();
1335 seed_full_mission(tmp.path());
1336 let chain = compute_provenance(tmp.path(), "m-1").unwrap();
1337 assert!(chain.standards.is_none());
1338 let json = serde_json::to_string_pretty(&chain).unwrap();
1339 assert!(
1340 !json.contains("\"standards\""),
1341 "a pre-Flight-Rules chain carries no standards key: {json}"
1342 );
1343 let mut value = serde_json::to_value(&chain).unwrap();
1345 value.as_object_mut().unwrap().remove("divergences");
1346 let back: ProvenanceChain = serde_json::from_value(value).unwrap();
1347 assert!(back.standards.is_none());
1348 }
1349}