use crate::error::{EngineError, Result};
use crate::events::{Event, EventKind};
use crate::gate::{GateKind, GateSurface, GateVerdict};
use crate::gate_results::{file_artefact_ref, resolve_artefact, ArtefactResolution};
use crate::types::{MissionConfig, Role};
use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ArtefactStatus {
Resolved,
Unresolved,
Inline,
}
impl ArtefactStatus {
fn classify(resolution: &ArtefactResolution) -> Self {
match resolution {
ArtefactResolution::Resolved { .. } => Self::Resolved,
ArtefactResolution::Unresolved { .. } => Self::Unresolved,
ArtefactResolution::Inline => Self::Inline,
}
}
pub fn as_str(&self) -> &'static str {
match self {
Self::Resolved => "resolved",
Self::Unresolved => "unresolved",
Self::Inline => "inline",
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GateLink {
pub seq: u64,
pub gate: String,
pub surface: GateSurface,
pub kind: GateKind,
pub index: u32,
pub verdict: GateVerdict,
pub artefact_ref: String,
pub artefact_detail: Option<String>,
pub score: Option<f64>,
pub threshold: Option<f64>,
pub artefact: ArtefactStatus,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub rule_ids: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionLink {
pub seq: u64,
pub run_id: String,
pub role: Role,
pub backend: Option<String>,
pub model: String,
pub quant: String,
pub weight_hash: Option<String>,
pub prompt_hash: String,
pub feature_id: Option<String>,
pub milestone_id: Option<String>,
pub transcript_ref: String,
pub transcript: ArtefactStatus,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum DecisionKind {
PlanApproval,
PlanRevision,
PlanRevisionRejection,
GrantApproval,
GrantDenial,
MilestoneUnblock,
Steer,
OperatorMessage,
MissionAbandoned,
}
impl DecisionKind {
pub fn as_str(&self) -> &'static str {
match self {
Self::PlanApproval => "plan-approval",
Self::PlanRevision => "plan-revision",
Self::PlanRevisionRejection => "plan-revision-rejection",
Self::GrantApproval => "grant-approval",
Self::GrantDenial => "grant-denial",
Self::MilestoneUnblock => "milestone-unblock",
Self::Steer => "steer",
Self::OperatorMessage => "operator-message",
Self::MissionAbandoned => "mission-abandoned",
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DecisionLink {
pub seq: u64,
pub kind: DecisionKind,
pub summary: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum DivergenceLink {
Noted {
seq: u64,
unit: String,
candidates: Vec<crate::types::DivergenceCandidate>,
diverged: bool,
},
Resolved {
seq: u64,
unit: String,
selected: Option<u32>,
reason: String,
decided_by: String,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TerminalStatus {
Completed,
Failed,
Abandoned,
}
impl TerminalStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Completed => "completed",
Self::Failed => "failed",
Self::Abandoned => "abandoned",
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TerminalLink {
pub seq: u64,
pub status: TerminalStatus,
pub reason: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProvenanceChain {
pub mission_id: String,
pub goal: Option<String>,
pub base_branch: Option<String>,
pub mission_branch: Option<String>,
pub base_sha: Option<String>,
pub gates: Vec<GateLink>,
pub sessions: Vec<SessionLink>,
pub decisions: Vec<DecisionLink>,
#[serde(default)]
pub divergences: Vec<DivergenceLink>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub standards: Option<crate::standards_coverage::StandardsCoverage>,
pub outcome: Option<TerminalLink>,
}
pub fn provenance_chain(
mission_dir: &Path,
mission_id: &str,
events: &[Event],
) -> Result<ProvenanceChain> {
let mut chain = ProvenanceChain {
mission_id: mission_id.to_string(),
goal: None,
base_branch: None,
mission_branch: None,
base_sha: None,
gates: Vec::new(),
sessions: Vec::new(),
decisions: Vec::new(),
divergences: Vec::new(),
standards: None,
outcome: None,
};
let mut config: Option<MissionConfig> = None;
let mut plan_approved_seq: Option<u64> = None;
for event in events.iter().filter(|e| e.mission_id == mission_id) {
match &event.kind {
EventKind::MissionCreated {
goal,
base_branch,
mission_branch,
config: created,
} => {
chain.goal = Some(goal.clone());
chain.base_branch = Some(base_branch.clone());
chain.mission_branch = Some(mission_branch.clone());
config = Some(created.clone());
}
EventKind::PlanApproved { base_sha, .. } => {
chain.base_sha = base_sha.clone();
plan_approved_seq = Some(event.seq);
chain.decisions.push(DecisionLink {
seq: event.seq,
kind: DecisionKind::PlanApproval,
summary: "plan approved".to_string(),
});
}
EventKind::PlanRevised { revision, .. } => chain.decisions.push(DecisionLink {
seq: event.seq,
kind: DecisionKind::PlanRevision,
summary: format!("plan revision {revision} approved"),
}),
EventKind::PlanRevisionRejected { revision, reason } => {
chain.decisions.push(DecisionLink {
seq: event.seq,
kind: DecisionKind::PlanRevisionRejection,
summary: format!("plan revision {revision} rejected: {reason}"),
});
}
EventKind::GrantApproved { kind, command } => chain.decisions.push(DecisionLink {
seq: event.seq,
kind: DecisionKind::GrantApproval,
summary: format!(
"approved {}: {command}",
crate::escalation_metrics::grant_kind_str(kind)
),
}),
EventKind::GrantDenied {
kind,
command,
reason,
} => chain.decisions.push(DecisionLink {
seq: event.seq,
kind: DecisionKind::GrantDenial,
summary: format!(
"denied {}: {command} ({reason})",
crate::escalation_metrics::grant_kind_str(kind)
),
}),
EventKind::MilestoneUnblocked {
milestone_id,
reason,
block_context,
..
} if !crate::escalation_metrics::is_engine_lift(reason, block_context.as_ref()) => {
chain.decisions.push(DecisionLink {
seq: event.seq,
kind: DecisionKind::MilestoneUnblock,
summary: format!("unblocked {milestone_id}: {reason}"),
});
}
EventKind::UserMessage { text, .. } => {
let kind = match plan_approved_seq {
Some(approved) if event.seq >= approved => DecisionKind::Steer,
_ => DecisionKind::OperatorMessage,
};
chain.decisions.push(DecisionLink {
seq: event.seq,
kind,
summary: text.clone(),
});
}
EventKind::MissionAbandoned { reason } => {
chain.decisions.push(DecisionLink {
seq: event.seq,
kind: DecisionKind::MissionAbandoned,
summary: format!("abandoned: {reason}"),
});
if chain.outcome.is_none() {
chain.outcome = Some(TerminalLink {
seq: event.seq,
status: TerminalStatus::Abandoned,
reason: Some(reason.clone()),
});
}
}
EventKind::MissionCompleted {} => {
if chain.outcome.is_none() {
chain.outcome = Some(TerminalLink {
seq: event.seq,
status: TerminalStatus::Completed,
reason: None,
});
}
}
EventKind::MissionFailed { reason } => {
if chain.outcome.is_none() {
chain.outcome = Some(TerminalLink {
seq: event.seq,
status: TerminalStatus::Failed,
reason: Some(reason.clone()),
});
}
}
EventKind::GateResult {
gate,
surface,
kind,
index,
verdict,
artefact_ref,
artefact_detail,
score,
threshold,
rule_ids,
} => chain.gates.push(GateLink {
seq: event.seq,
gate: gate.clone(),
surface: *surface,
kind: *kind,
index: *index,
verdict: *verdict,
artefact_ref: artefact_ref.clone(),
artefact_detail: artefact_detail.clone(),
score: *score,
threshold: *threshold,
artefact: ArtefactStatus::classify(&resolve_artefact(mission_dir, artefact_ref)),
rule_ids: rule_ids.clone(),
}),
EventKind::DivergenceNoted {
unit,
candidates,
diverged,
} => chain.divergences.push(DivergenceLink::Noted {
seq: event.seq,
unit: unit.clone(),
candidates: candidates.clone(),
diverged: *diverged,
}),
EventKind::DivergenceResolved {
unit,
selected,
reason,
decided_by,
} => chain.divergences.push(DivergenceLink::Resolved {
seq: event.seq,
unit: unit.clone(),
selected: *selected,
reason: reason.clone(),
decided_by: decided_by.clone(),
}),
EventKind::WorkerSpawned {
run_id,
role,
feature_id,
milestone_id,
model,
quant,
weight_hash,
prompt_hash,
transcript_path,
backend,
..
} => chain.sessions.push(SessionLink {
seq: event.seq,
run_id: run_id.clone(),
role: *role,
backend: (backend.is_some() || config.is_some()).then(|| {
crate::cost::resolved_run_backend(*backend, *role, config.as_ref())
.as_str()
.to_string()
}),
model: model.clone(),
quant: quant.clone(),
weight_hash: weight_hash.clone(),
prompt_hash: prompt_hash.clone(),
feature_id: feature_id.clone(),
milestone_id: milestone_id.clone(),
transcript_ref: transcript_path.clone(),
transcript: ArtefactStatus::classify(&resolve_artefact(
mission_dir,
&file_artefact_ref(transcript_path),
)),
}),
EventKind::ConfigChanged { patch } => {
if let Some(current) = &mut config {
let mut value = serde_json::to_value(&*current)?;
crate::reducer::deep_merge(&mut value, patch);
*current = serde_json::from_value(value).map_err(|e| {
EngineError::Config(format!(
"config.changed patch produced invalid config: {e}"
))
})?;
}
}
_ => {}
}
}
chain.standards = crate::standards_coverage::standards_coverage(mission_id, events);
Ok(chain)
}
pub fn compute_provenance(repo_root: &Path, mission_id: &str) -> anyhow::Result<ProvenanceChain> {
let paths = crate::paths::MissionPaths::new(repo_root, mission_id);
paths.require_no_follow()?;
let events = crate::event_log::EventLog::read_events(&paths.events_file())?;
Ok(provenance_chain(&paths.mission_dir(), mission_id, &events)?)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::event_log::{EventLog, LockForce};
use crate::paths::MissionPaths;
use crate::types::{GrantKind, Plan};
use std::time::Duration;
use tempfile::TempDir;
fn seed_mission(repo_root: &Path, id: &str, kinds: Vec<EventKind>) -> MissionPaths {
let paths = MissionPaths::new(repo_root, id);
let mut log = EventLog::acquire(&paths, id, Duration::ZERO, LockForce::No).unwrap();
for kind in kinds {
log.append(kind).unwrap();
}
paths
}
fn sample_plan() -> Plan {
Plan {
goal: "ship the thing".into(),
validation_contract: vec![],
milestones: vec![],
considered_alternatives: None,
command_grants: vec![],
touch_set: vec![],
standards_manifest: None,
reviewer_independence: None,
}
}
fn created_config() -> MissionConfig {
let mut config = MissionConfig::default();
config.worker.backend = Some("codex".to_string());
config
}
fn created() -> EventKind {
EventKind::MissionCreated {
goal: "ship the thing".into(),
base_branch: "main".into(),
mission_branch: "kranz/mission-x".into(),
config: created_config(),
}
}
fn gate_result(
gate: &str,
surface: GateSurface,
kind: GateKind,
index: u32,
verdict: GateVerdict,
artefact_ref: &str,
) -> EventKind {
EventKind::GateResult {
gate: gate.to_string(),
surface,
kind,
index,
verdict,
artefact_ref: artefact_ref.to_string(),
artefact_detail: None,
score: None,
threshold: None,
rule_ids: Vec::new(),
}
}
fn worker_spawned(run_id: &str, role: Role, model: &str, prompt_hash: &str) -> EventKind {
EventKind::WorkerSpawned {
backend: None,
run_id: run_id.to_string(),
role,
feature_id: None,
milestone_id: None,
candidate: None,
executor_route: None,
sdk_session_id: format!("sess-{run_id}"),
model: model.to_string(),
quant: "n/a".to_string(),
weight_hash: None,
prompt_hash: prompt_hash.to_string(),
transcript_path: MissionPaths::transcript_rel(run_id),
}
}
fn seed_full_mission(root: &Path) -> MissionPaths {
let mut judged = gate_result(
"plan-review",
GateSurface::Approval,
GateKind::ModelJudged,
0,
GateVerdict::Pass,
"file:runs/gone.jsonl",
);
if let EventKind::GateResult {
artefact_detail,
score,
threshold,
..
} = &mut judged
{
*artefact_detail = Some("looks sound".to_string());
*score = Some(0.9);
*threshold = Some(0.5);
}
let paths = seed_mission(
root,
"m-1",
vec![
created(),
EventKind::PlanApproved {
plan: sample_plan(),
base_sha: Some("deadbeef".to_string()),
},
gate_result(
"vacuous-filter",
GateSurface::Approval,
GateKind::Deterministic,
0,
GateVerdict::Pass,
"contract gate vacuous-filter",
),
gate_result(
"merge-gate-suite",
GateSurface::Approval,
GateKind::Deterministic,
1,
GateVerdict::Pass,
"file:runs/gate-base.jsonl",
),
judged,
{
let mut spawn = worker_spawned("r-1", Role::Worker, "gpt-5", "aaaabbbbcccc");
if let EventKind::WorkerSpawned {
feature_id,
milestone_id,
..
} = &mut spawn
{
*feature_id = Some("f-1-1".to_string());
*milestone_id = Some("ms-1".to_string());
}
spawn
},
EventKind::GrantRequested {
milestone_id: "ms-1".into(),
kind: GrantKind::Command,
command: "cargo test".into(),
},
EventKind::GrantApproved {
kind: GrantKind::Command,
command: "cargo test".into(),
},
EventKind::ConfigChanged {
patch: serde_json::json!({"worker": {"backend": "local"}}),
},
worker_spawned("r-2", Role::Worker, "my-local-model", "dddd11112222"),
worker_spawned("r-3", Role::ValidatorScrutiny, "sonnet", "ffff33334444"),
EventKind::MilestoneBlocked {
block_context: None,
milestone_id: "ms-1".into(),
reason: "fix-cycle cap".into(),
},
EventKind::MilestoneUnblocked {
block_context: None,
milestone_id: "ms-1".into(),
reason: "user skipped findings".into(),
validator_guidance: None,
},
EventKind::MilestoneUnblocked {
block_context: None,
milestone_id: "ms-1".into(),
reason: crate::workspace_gate::GATE_LIFT_REASON.to_string(),
validator_guidance: None,
},
EventKind::UserMessage {
text: "skip the flaky test".into(),
interrupt: false,
},
gate_result(
"merge-gate-suite",
GateSurface::FinalGate,
GateKind::Deterministic,
0,
GateVerdict::Pass,
".kranz/merge-gates.json",
),
EventKind::MissionCompleted {},
],
);
std::fs::write(paths.runs_dir().join("gate-base.jsonl"), b"{}").unwrap();
std::fs::write(paths.runs_dir().join("r-1.jsonl"), b"{}").unwrap();
paths
}
#[test]
fn provenance_replay_names_ladder_sessions_decisions_and_outcome_in_order() {
let tmp = TempDir::new().unwrap();
let paths = seed_full_mission(tmp.path());
let events = EventLog::read_events(&paths.events_file()).unwrap();
let chain = provenance_chain(&paths.mission_dir(), "m-1", &events).unwrap();
assert_eq!(chain.mission_id, "m-1");
assert_eq!(chain.goal.as_deref(), Some("ship the thing"));
assert_eq!(chain.base_branch.as_deref(), Some("main"));
assert_eq!(chain.mission_branch.as_deref(), Some("kranz/mission-x"));
assert_eq!(chain.base_sha.as_deref(), Some("deadbeef"));
let ladder: Vec<(
u64,
&str,
GateSurface,
GateKind,
u32,
GateVerdict,
ArtefactStatus,
)> = chain
.gates
.iter()
.map(|gate| {
(
gate.seq,
gate.gate.as_str(),
gate.surface,
gate.kind,
gate.index,
gate.verdict,
gate.artefact,
)
})
.collect();
assert_eq!(
ladder,
vec![
(
3,
"vacuous-filter",
GateSurface::Approval,
GateKind::Deterministic,
0,
GateVerdict::Pass,
ArtefactStatus::Inline
),
(
4,
"merge-gate-suite",
GateSurface::Approval,
GateKind::Deterministic,
1,
GateVerdict::Pass,
ArtefactStatus::Resolved
),
(
5,
"plan-review",
GateSurface::Approval,
GateKind::ModelJudged,
0,
GateVerdict::Pass,
ArtefactStatus::Unresolved
),
(
16,
"merge-gate-suite",
GateSurface::FinalGate,
GateKind::Deterministic,
0,
GateVerdict::Pass,
ArtefactStatus::Inline
),
]
);
assert_eq!(chain.gates[0].artefact_ref, "contract gate vacuous-filter");
assert_eq!(chain.gates[1].artefact_ref, "file:runs/gate-base.jsonl");
assert_eq!(chain.gates[2].artefact_ref, "file:runs/gone.jsonl");
assert_eq!(
chain.gates[2].artefact_detail.as_deref(),
Some("looks sound")
);
assert_eq!(chain.gates[2].score, Some(0.9));
assert_eq!(chain.gates[2].threshold, Some(0.5));
assert_eq!(chain.sessions.len(), 3);
let r1 = &chain.sessions[0];
assert_eq!(r1.seq, 6);
assert_eq!(r1.role, Role::Worker);
assert_eq!(r1.backend.as_deref(), Some("codex"));
assert_eq!(r1.model, "gpt-5");
assert_eq!(r1.prompt_hash, "aaaabbbbcccc");
assert_eq!(r1.feature_id.as_deref(), Some("f-1-1"));
assert_eq!(r1.milestone_id.as_deref(), Some("ms-1"));
assert_eq!(r1.transcript_ref, "runs/r-1.jsonl");
assert_eq!(r1.transcript, ArtefactStatus::Resolved);
let r2 = &chain.sessions[1];
assert_eq!(r2.backend.as_deref(), Some("local"));
assert_eq!(r2.model, "my-local-model");
assert_eq!(r2.prompt_hash, "dddd11112222");
assert_eq!(r2.transcript, ArtefactStatus::Unresolved);
let r3 = &chain.sessions[2];
assert_eq!(r3.role, Role::ValidatorScrutiny);
assert_eq!(r3.backend.as_deref(), Some("claude"));
let decisions: Vec<(u64, DecisionKind, &str)> = chain
.decisions
.iter()
.map(|d| (d.seq, d.kind, d.summary.as_str()))
.collect();
assert_eq!(
decisions,
vec![
(2, DecisionKind::PlanApproval, "plan approved"),
(
8,
DecisionKind::GrantApproval,
"approved command: cargo test"
),
(
13,
DecisionKind::MilestoneUnblock,
"unblocked ms-1: user skipped findings"
),
(15, DecisionKind::Steer, "skip the flaky test"),
]
);
assert_eq!(
chain.outcome,
Some(TerminalLink {
seq: 17,
status: TerminalStatus::Completed,
reason: None,
})
);
}
#[test]
fn provenance_replay_without_runs_dir_reconstructs_with_unresolved_refs() {
let tmp = TempDir::new().unwrap();
let paths = seed_full_mission(tmp.path());
std::fs::remove_dir_all(paths.runs_dir()).unwrap();
let chain = compute_provenance(tmp.path(), "m-1").unwrap();
assert_eq!(chain.gates.len(), 4);
assert_eq!(chain.gates[1].artefact, ArtefactStatus::Unresolved);
assert_eq!(chain.gates[0].artefact, ArtefactStatus::Inline);
assert_eq!(chain.gates[3].artefact, ArtefactStatus::Inline);
assert_eq!(chain.sessions.len(), 3);
for session in &chain.sessions {
assert_eq!(
session.transcript,
ArtefactStatus::Unresolved,
"{} must read unresolved with runs/ gone",
session.run_id
);
}
assert_eq!(chain.decisions.len(), 4);
assert_eq!(
chain.outcome.map(|o| o.status),
Some(TerminalStatus::Completed)
);
}
#[test]
fn provenance_replay_machine_form_is_byte_identical_across_replays() {
let tmp = TempDir::new().unwrap();
seed_full_mission(tmp.path());
let first = compute_provenance(tmp.path(), "m-1").unwrap();
let second = compute_provenance(tmp.path(), "m-1").unwrap();
assert_eq!(first, second);
let first_json = serde_json::to_string_pretty(&first).unwrap();
let second_json = serde_json::to_string_pretty(&second).unwrap();
assert_eq!(first_json, second_json);
assert!(
!first_json.contains(&tmp.path().to_string_lossy().to_string()),
"host path leaked into the machine form: {first_json}"
);
}
#[test]
fn provenance_replay_pre_gate_logs_still_fold() {
let tmp = TempDir::new().unwrap();
let paths = seed_mission(
tmp.path(),
"m-old",
vec![
EventKind::MissionCreated {
goal: "legacy goal".into(),
base_branch: "main".into(),
mission_branch: "kranz/mission-old".into(),
config: MissionConfig::default(),
},
EventKind::PlanApproved {
plan: sample_plan(),
base_sha: None,
},
worker_spawned("r-1", Role::Worker, "sonnet", "9999aaaabbbb"),
EventKind::MissionFailed {
reason: "honest failure".into(),
},
],
);
let events = EventLog::read_events(&paths.events_file()).unwrap();
let chain = provenance_chain(&paths.mission_dir(), "m-old", &events).unwrap();
assert!(chain.gates.is_empty());
assert_eq!(chain.base_sha, None);
assert_eq!(chain.sessions.len(), 1);
assert_eq!(chain.sessions[0].backend.as_deref(), Some("claude"));
assert_eq!(chain.sessions[0].prompt_hash, "9999aaaabbbb");
assert_eq!(
chain.outcome,
Some(TerminalLink {
seq: 4,
status: TerminalStatus::Failed,
reason: Some("honest failure".to_string()),
})
);
let paths = seed_mission(
tmp.path(),
"m-draft",
vec![
EventKind::UserMessage {
text: "make it smaller".into(),
interrupt: false,
},
EventKind::PlanApproved {
plan: sample_plan(),
base_sha: None,
},
],
);
let events = EventLog::read_events(&paths.events_file()).unwrap();
let chain = provenance_chain(&paths.mission_dir(), "m-draft", &events).unwrap();
assert_eq!(
chain
.decisions
.iter()
.map(|d| (d.seq, d.kind))
.collect::<Vec<_>>(),
vec![
(1, DecisionKind::OperatorMessage),
(2, DecisionKind::PlanApproval)
]
);
assert_eq!(chain.goal, None);
assert_eq!(chain.outcome, None);
}
#[test]
fn provenance_replay_invalid_config_patch_fails_like_the_reducer() {
let tmp = TempDir::new().unwrap();
let paths = seed_mission(
tmp.path(),
"m-1",
vec![
created(),
EventKind::ConfigChanged {
patch: serde_json::json!({"maxFixCyclesPerMilestone": "not-a-number"}),
},
],
);
let events = EventLog::read_events(&paths.events_file()).unwrap();
let result = provenance_chain(&paths.mission_dir(), "m-1", &events);
assert!(
matches!(result, Err(EngineError::Config(_))),
"expected the reducer's Config error, got {result:?}"
);
}
#[test]
fn divergence_event_provenance_chain_carries_record_and_resolution() {
let tmp = TempDir::new().unwrap();
let candidate = |run_id: &str, tree: &str| crate::types::DivergenceCandidate {
run_id: run_id.into(),
branch: format!("kranz/pool/m-1/f-1-1-{run_id}"),
backend: "claude".into(),
tree: tree.into(),
};
let paths = seed_mission(
tmp.path(),
"m-1",
vec![
created(),
EventKind::DivergenceNoted {
unit: "f-1-1".into(),
candidates: vec![candidate("r-c0", "aaa"), candidate("r-c1", "bbb")],
diverged: true,
},
EventKind::DivergenceResolved {
unit: "f-1-1".into(),
selected: Some(1),
reason: "codex kept it total".into(),
decided_by: "operator".into(),
},
],
);
let events = EventLog::read_events(&paths.events_file()).unwrap();
let chain = provenance_chain(&paths.mission_dir(), "m-1", &events).unwrap();
assert_eq!(chain.divergences.len(), 2);
match &chain.divergences[0] {
DivergenceLink::Noted {
seq,
unit,
candidates,
diverged,
} => {
assert_eq!(*seq, 2);
assert_eq!(unit, "f-1-1");
assert!(diverged);
assert_eq!(candidates.len(), 2);
assert_eq!(candidates[1].tree, "bbb");
}
other => panic!("expected the noted link first: {other:?}"),
}
match &chain.divergences[1] {
DivergenceLink::Resolved {
seq,
unit,
selected,
reason,
decided_by,
} => {
assert_eq!(*seq, 3);
assert_eq!(unit, "f-1-1");
assert_eq!(*selected, Some(1));
assert_eq!(reason, "codex kept it total");
assert_eq!(decided_by, "operator");
}
other => panic!("expected the resolution link: {other:?}"),
}
let quiet = provenance_chain(&paths.mission_dir(), "m-1", &[]).unwrap();
assert!(quiet.divergences.is_empty());
let json = serde_json::to_value(&chain).unwrap();
let mut stripped = json.clone();
stripped.as_object_mut().unwrap().remove("divergences");
let back: ProvenanceChain = serde_json::from_value(stripped).unwrap();
assert!(back.divergences.is_empty());
}
fn pinned_plan() -> Plan {
let rule = |id: &str, revision: u64, status: &str, level: &str| crate::types::PinnedRule {
id: id.to_string(),
revision,
rfc: "RFC-001".to_string(),
level: level.to_string(),
effective_status: status.to_string(),
statement: format!("statement for {id}"),
domains: Vec::new(),
stages: vec!["validation".to_string()],
when_paths: Vec::new(),
task_classes: Vec::new(),
checker: Some("gate:zz-gate".to_string()),
waivable: false,
};
Plan {
standards_manifest: Some(Box::new(crate::types::StandardsPin {
pack_name: "zz-pack".to_string(),
pack_dir: "vendor/pack".to_string(),
standards_root: "standards".to_string(),
digest: "ab".repeat(32),
source: crate::types::StandardsPinSource::RepoTracked,
task_class: None,
touch_set: vec!["crates/**".to_string()],
context_paths: Vec::new(),
gates: Vec::new(),
rules: vec![
rule("ZZ-FAIL-001", 2, "enforced", "must"),
rule("ZZ-QUIET-001", 1, "approved", "should"),
],
})),
..sample_plan()
}
}
#[test]
fn flight_rules_provenance_replay_folds_the_coverage_matrix() {
let tmp = TempDir::new().unwrap();
seed_mission(
tmp.path(),
"m-1",
vec![
created(),
EventKind::PlanApproved {
plan: pinned_plan(),
base_sha: Some("deadbeef".to_string()),
},
EventKind::StandardsResolved {
source: "repo-tracked".to_string(),
pack_name: "zz-pack".to_string(),
standards_root: "standards".to_string(),
digest: "ab".repeat(32),
stage: "approval".to_string(),
task_class: None,
touch_set: vec!["crates/**".to_string()],
context_paths: Vec::new(),
rules: vec![
crate::types::StandardsRuleRef {
id: "ZZ-FAIL-001".to_string(),
revision: 2,
effective_status: "enforced".to_string(),
},
crate::types::StandardsRuleRef {
id: "ZZ-QUIET-001".to_string(),
revision: 1,
effective_status: "approved".to_string(),
},
],
approval_seq: 2,
},
EventKind::ValidationFinding {
milestone_id: "ms-1".into(),
run_id: "v-1".into(),
finding: crate::types::Finding {
subject: "a-1".into(),
severity: "major".into(),
evidence: "broke the rule".into(),
suggested_fix: String::new(),
class: String::new(),
rule: Some(crate::types::RuleCitation {
id: "ZZ-FAIL-001".to_string(),
revision: 2,
source: "zz-pack standards".to_string(),
digest: "ab".repeat(32),
lifecycle: "enforced".to_string(),
level: "must".to_string(),
checker: Some("gate:zz-gate".to_string()),
}),
},
},
EventKind::MissionCompleted {},
],
);
let chain = compute_provenance(tmp.path(), "m-1").unwrap();
let coverage = chain
.standards
.as_ref()
.expect("the matrix rides the chain");
assert_eq!(coverage.pack_name, "zz-pack");
assert_eq!(coverage.approval_seq, 2);
assert_eq!(coverage.resolution_seq, Some(3));
assert_eq!(coverage.rules.len(), 2);
let failed = &coverage.rules[0];
assert_eq!(failed.id, "ZZ-FAIL-001");
assert_eq!(
failed.disposition,
crate::standards_coverage::RuleDisposition::Failed
);
assert_eq!(failed.evidence.len(), 1);
assert_eq!(failed.evidence[0].seq, 4);
assert_eq!(failed.evidence[0].mechanism, "v-1");
let quiet = &coverage.rules[1];
assert_eq!(quiet.id, "ZZ-QUIET-001");
assert_eq!(
quiet.disposition,
crate::standards_coverage::RuleDisposition::NotEvaluated
);
let json = serde_json::to_value(&chain).unwrap();
assert_eq!(json["standards"]["digest"], "ab".repeat(32));
assert_eq!(json["standards"]["rules"][0]["disposition"], "failed");
let mut stripped = json.clone();
stripped.as_object_mut().unwrap().remove("standards");
let back: ProvenanceChain = serde_json::from_value(stripped).unwrap();
assert!(back.standards.is_none());
let row = &json["standards"]["rules"][1];
assert_eq!(row["id"], "ZZ-QUIET-001");
assert_eq!(row["revision"], 1);
assert_eq!(row["lifecycle"], "approved");
assert_eq!(row["level"], "should");
assert_eq!(row["checker"], "gate:zz-gate");
assert_eq!(row["statement"], "statement for ZZ-QUIET-001");
}
#[test]
fn flight_rules_provenance_pre_flight_rules_chain_is_unchanged() {
let tmp = TempDir::new().unwrap();
seed_full_mission(tmp.path());
let chain = compute_provenance(tmp.path(), "m-1").unwrap();
assert!(chain.standards.is_none());
let json = serde_json::to_string_pretty(&chain).unwrap();
assert!(
!json.contains("\"standards\""),
"a pre-Flight-Rules chain carries no standards key: {json}"
);
let mut value = serde_json::to_value(&chain).unwrap();
value.as_object_mut().unwrap().remove("divergences");
let back: ProvenanceChain = serde_json::from_value(value).unwrap();
assert!(back.standards.is_none());
}
}