use crate::error::EngineError;
use crate::gate_results::{file_artefact_ref, resolve_artefact, ArtefactResolution};
use crate::outcomes::MissionOutcomes;
use crate::paths::MissionPaths;
use crate::provenance::{ArtefactStatus, ProvenanceChain};
use cap_fs_ext::{FollowSymlinks, OpenOptionsFollowExt as _};
use cap_std::ambient_authority;
use cap_std::fs::{Dir, OpenOptions};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::io::{ErrorKind, Read as _, Write as _};
use std::path::{Component, Path, PathBuf};
pub const BUNDLE_FORMAT_VERSION: u32 = 1;
pub const MANIFEST_FILE: &str = "manifest.json";
pub const SUMMARY_FILE: &str = "summary.md";
pub const CHAIN_FILE: &str = "chain.json";
pub const ESCALATIONS_FILE: &str = "escalations.json";
pub const COST_FILE: &str = "cost.json";
pub const LOG_FILE: &str = "events.jsonl";
pub const ARTEFACTS_DIR: &str = "artefacts";
const MISSION_DOCUMENTS: [&str; 5] = [
"plan.md",
"plan.json",
"research.md",
"estimate.json",
"report.md",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum EntryKind {
Summary,
Chain,
Escalations,
Cost,
Log,
Artefact,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ManifestEntry {
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sha256: Option<String>,
pub source: String,
pub kind: EntryKind,
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<ArtefactStatus>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EvidenceManifest {
pub version: u32,
pub mission_id: String,
pub entries: Vec<ManifestEntry>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BundleFile {
pub path: String,
pub bytes: Vec<u8>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct EvidenceBundle {
pub manifest: EvidenceManifest,
pub files: Vec<BundleFile>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MissionCostSummary {
pub total_cost_usd: f64,
pub non_meta_commits: u64,
pub usd_per_commit: Option<f64>,
pub cycle_time_ms: Option<u64>,
pub closed: bool,
pub interventions: u64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExportOutcome {
pub out_dir: PathBuf,
pub files_written: usize,
pub resolved_artefacts: usize,
pub unresolved_artefacts: usize,
}
fn sha256_hex(bytes: &[u8]) -> String {
let digest = Sha256::digest(bytes);
digest.iter().map(|b| format!("{b:02x}")).collect()
}
fn to_json_bytes<T: Serialize>(value: &T) -> anyhow::Result<Vec<u8>> {
let mut text = serde_json::to_string_pretty(value)?;
text.push('\n');
Ok(text.into_bytes())
}
fn artefact_bundle_path(reference: &str) -> Option<String> {
let relative = reference.strip_prefix(crate::gate_results::FILE_REF_SCHEME)?;
let mut parts = Vec::new();
for component in Path::new(relative).components() {
match component {
Component::Normal(part) => parts.push(part.to_str()?),
Component::CurDir => {}
Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None,
}
}
if parts.is_empty() {
return None;
}
Some(format!("{ARTEFACTS_DIR}/{}", parts.join("/")))
}
fn read_artefact(mission_dir: &Path, reference: &str) -> (ArtefactStatus, Option<Vec<u8>>) {
let ArtefactResolution::Resolved { path } = resolve_artefact(mission_dir, reference) else {
return (ArtefactStatus::Unresolved, None);
};
let read = crate::paths::open_read_nofollow(&path).and_then(|mut file| {
let mut bytes = Vec::new();
file.read_to_end(&mut bytes)?;
Ok(bytes)
});
match read {
Ok(bytes) => {
let scrubbed = crate::scrub::scrub(&String::from_utf8_lossy(&bytes));
(ArtefactStatus::Resolved, Some(scrubbed.into_bytes()))
}
Err(_) => (ArtefactStatus::Unresolved, None),
}
}
fn wire_name<T: Serialize>(value: &T) -> String {
serde_json::to_value(value)
.ok()
.and_then(|v| v.as_str().map(str::to_string))
.expect("gate/role enums always serialize to a string")
}
fn one_line(text: &str) -> String {
text.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn md_cell(text: &str) -> String {
one_line(text).replace('|', "\\|")
}
fn format_duration_ms(ms: u64) -> String {
const S: u64 = 1_000;
const M: u64 = 60 * S;
const H: u64 = 60 * M;
const D: u64 = 24 * H;
if ms >= D {
format!("{:.1}d", ms as f64 / D as f64)
} else if ms >= H {
format!("{:.1}h", ms as f64 / H as f64)
} else if ms >= M {
format!("{}m", ms / M)
} else {
format!("{}s", ms / S)
}
}
fn render_summary(
chain: &ProvenanceChain,
cost: &MissionCostSummary,
escalations: &[crate::outcomes::EscalationRow],
artefact_entries: &[ManifestEntry],
) -> String {
let mut out = String::new();
out.push_str(&format!(
"# Evidence bundle — mission {}\n\n",
chain.mission_id
));
out.push_str(
"Portable audit package (KRZ-326). Everything below derives from the mission's\n\
append-only event log (`events.jsonl`, included verbatim — every line crossed\n\
the redact-at-write boundary when appended) plus the mission-relative artefact\n\
bytes under `artefacts/`. Artefacts ship as scrubbed text: they are redacted\n\
at export (not at write), and any byte that is not valid UTF-8 travels as the\n\
replacement character. References whose bytes were no longer on disk at\n\
export time are listed as `unresolved` in `manifest.json` — named, never\n\
silently omitted.\n\n",
);
out.push_str("## Mission\n\n");
match &chain.goal {
Some(goal) => out.push_str(&format!("- Goal: {}\n", one_line(goal))),
None => out.push_str("- Goal: (not recorded in the log)\n"),
}
if let (Some(mission_branch), Some(base_branch)) = (&chain.mission_branch, &chain.base_branch) {
let pinned = chain
.base_sha
.as_deref()
.map(|sha| format!(" @ {sha}"))
.unwrap_or_default();
out.push_str(&format!(
"- Branch: {mission_branch} (base {base_branch}{pinned})\n"
));
}
match &chain.outcome {
Some(terminal) => {
let reason = terminal
.reason
.as_deref()
.map(|reason| format!(" — {}", one_line(reason)))
.unwrap_or_default();
out.push_str(&format!(
"- Outcome: {} at seq {}{}\n",
terminal.status.as_str(),
terminal.seq,
reason
));
}
None => out.push_str("- Outcome: in flight (no terminal event recorded)\n"),
}
let usd_per_commit = cost
.usd_per_commit
.map(|usd| format!("${usd:.4}/commit"))
.unwrap_or_else(|| "n/a (no non-meta commits)".to_string());
out.push_str(&format!(
"- Cost: ${:.4} across {} non-meta commits ({})\n",
cost.total_cost_usd, cost.non_meta_commits, usd_per_commit
));
let cycle = cost
.cycle_time_ms
.map(format_duration_ms)
.unwrap_or_else(|| "n/a (in flight)".to_string());
out.push_str(&format!(
"- Cycle time: {cycle} | Interventions: {} | Closed: {}\n\n",
cost.interventions,
if cost.closed { "yes" } else { "no" }
));
out.push_str("## Gate ladder (log order)\n\n");
if chain.gates.is_empty() {
out.push_str("(no gate.result events recorded)\n\n");
} else {
out.push_str(
"| seq | surface | kind | # | gate | verdict | score | artefact | resolution |\n\
|----:|---------|------|--:|------|---------|-------|----------|------------|\n",
);
for gate in &chain.gates {
let score = match (gate.score, gate.threshold) {
(Some(score), Some(threshold)) => format!("{score}/{threshold}"),
_ => "—".to_string(),
};
out.push_str(&format!(
"| {} | {} | {} | {} | {} | {} | {} | `{}` | {} |\n",
gate.seq,
wire_name(&gate.surface),
wire_name(&gate.kind),
gate.index,
md_cell(&gate.gate),
wire_name(&gate.verdict),
score,
md_cell(&gate.artefact_ref),
gate.artefact.as_str(),
));
}
out.push('\n');
}
if let Some(coverage) = &chain.standards {
out.push_str(&crate::standards_coverage::render_coverage_markdown(
coverage,
));
out.push('\n');
}
out.push_str("## Sessions (workers and reviewers)\n\n");
if chain.sessions.is_empty() {
out.push_str("(no sessions recorded)\n\n");
} else {
out.push_str(
"| seq | run | role | backend | model | prompt hash | transcript | resolution |\n\
|----:|-----|------|---------|-------|-------------|------------|------------|\n",
);
for session in &chain.sessions {
out.push_str(&format!(
"| {} | {} | {} | {} | {} | `{}` | `{}` | {} |\n",
session.seq,
md_cell(&session.run_id),
wire_name(&session.role),
session.backend.as_deref().unwrap_or("?"),
md_cell(&session.model),
session.prompt_hash,
md_cell(&session.transcript_ref),
session.transcript.as_str(),
));
}
out.push('\n');
}
out.push_str("## Human decisions\n\n");
if chain.decisions.is_empty() {
out.push_str("(no human decisions recorded)\n\n");
} else {
for decision in &chain.decisions {
out.push_str(&format!(
"- [seq {}] {} — {}\n",
decision.seq,
decision.kind.as_str(),
one_line(&decision.summary)
));
}
out.push('\n');
}
out.push_str("## Escalations\n\n");
if escalations.is_empty() {
out.push_str("(no escalations recorded)\n\n");
} else {
for row in escalations {
let latency = row
.latency_ms
.map(|ms| format!(" (latency {ms} ms)"))
.unwrap_or_default();
out.push_str(&format!(
"- [{}] {}: {} → {}{}\n",
row.ts.to_rfc3339(),
row.kind.as_str(),
one_line(&row.summary),
one_line(&row.decision),
latency,
));
}
out.push('\n');
}
out.push_str("## Artefacts\n\n");
out.push_str(
"| bundle path | source | sha256 | status |\n\
|-------------|--------|--------|--------|\n",
);
for entry in artefact_entries {
let status = entry.status.map(|status| status.as_str()).unwrap_or("—");
out.push_str(&format!(
"| {} | `{}` | {} | {} |\n",
entry
.path
.as_deref()
.map(|path| format!("`{path}`"))
.unwrap_or_else(|| "—".to_string()),
md_cell(&entry.source),
entry.sha256.as_deref().unwrap_or("—"),
status,
));
}
out.push('\n');
out.push_str(&format!(
"Regenerate with `kranz evidence-bundle {}`; the same event log always yields\n\
the same bundle bytes.\n",
chain.mission_id
));
out
}
pub fn assemble_evidence_bundle(
repo_root: &Path,
mission_id: &str,
) -> anyhow::Result<EvidenceBundle> {
let paths = MissionPaths::new(repo_root, mission_id);
paths.require_no_follow()?;
let mission_dir = paths.mission_dir();
let (events, log_bytes) =
crate::event_log::EventLog::read_events_and_log_bytes(&paths.events_file())?;
let chain = crate::provenance::provenance_chain(&mission_dir, mission_id, &events)?;
let outcomes: MissionOutcomes = crate::outcomes::mission_outcomes(mission_id, &events);
let cost = MissionCostSummary {
total_cost_usd: outcomes.cost_usd,
non_meta_commits: outcomes.non_meta_commits,
usd_per_commit: (outcomes.non_meta_commits > 0)
.then(|| outcomes.cost_usd / outcomes.non_meta_commits as f64),
cycle_time_ms: outcomes.cycle_time_ms,
closed: outcomes.is_closed,
interventions: outcomes.interventions,
};
let mut references: Vec<String> = Vec::new();
let mut push_reference = |reference: String| {
if reference.starts_with(crate::gate_results::FILE_REF_SCHEME)
&& !references.contains(&reference)
{
references.push(reference);
}
};
for gate in &chain.gates {
push_reference(gate.artefact_ref.clone());
}
for session in &chain.sessions {
push_reference(file_artefact_ref(&session.transcript_ref));
}
for document in MISSION_DOCUMENTS {
push_reference(file_artefact_ref(document));
}
let mut artefact_entries: Vec<ManifestEntry> = Vec::new();
let mut artefact_files: Vec<BundleFile> = Vec::new();
for reference in &references {
let (status, bytes) = read_artefact(&mission_dir, reference);
match artefact_bundle_path(reference).zip(bytes) {
Some((path, bytes)) => {
artefact_entries.push(ManifestEntry {
path: Some(path.clone()),
sha256: Some(sha256_hex(&bytes)),
source: reference.clone(),
kind: EntryKind::Artefact,
status: Some(status),
});
artefact_files.push(BundleFile { path, bytes });
}
None => artefact_entries.push(ManifestEntry {
path: None,
sha256: None,
source: reference.clone(),
kind: EntryKind::Artefact,
status: Some(ArtefactStatus::Unresolved),
}),
}
}
let summary = render_summary(&chain, &cost, &outcomes.escalations, &artefact_entries);
let mut files: Vec<BundleFile> = Vec::new();
let mut entries: Vec<ManifestEntry> = Vec::new();
let mut push_generated = |path: &str, source: &str, kind: EntryKind, bytes: Vec<u8>| {
entries.push(ManifestEntry {
path: Some(path.to_string()),
sha256: Some(sha256_hex(&bytes)),
source: source.to_string(),
kind,
status: None,
});
files.push(BundleFile {
path: path.to_string(),
bytes,
});
};
push_generated(
SUMMARY_FILE,
"derived:human-summary",
EntryKind::Summary,
summary.into_bytes(),
);
push_generated(
CHAIN_FILE,
"derived:provenance-chain",
EntryKind::Chain,
to_json_bytes(&chain)?,
);
push_generated(
ESCALATIONS_FILE,
"derived:escalations-fold",
EntryKind::Escalations,
to_json_bytes(&outcomes.escalations)?,
);
push_generated(
COST_FILE,
"derived:cost-fold",
EntryKind::Cost,
to_json_bytes(&cost)?,
);
push_generated(LOG_FILE, "file:events.jsonl", EntryKind::Log, log_bytes);
files.extend(artefact_files);
entries.extend(artefact_entries);
Ok(EvidenceBundle {
manifest: EvidenceManifest {
version: BUNDLE_FORMAT_VERSION,
mission_id: mission_id.to_string(),
entries,
},
files,
})
}
fn absolute_lexical(path: &Path) -> anyhow::Result<PathBuf> {
let absolute = std::path::absolute(path)?;
let mut out = PathBuf::new();
for component in absolute.components() {
match component {
Component::CurDir => {}
Component::ParentDir => {
if out.file_name().is_some() {
out.pop();
} else if !out.has_root() {
out.push("..");
}
}
other => out.push(other.as_os_str()),
}
}
Ok(out)
}
struct OutDirPlan {
anchor: PathBuf,
tail: Vec<String>,
canonical_out: PathBuf,
}
fn plan_out_dir(out_dir: &Path) -> anyhow::Result<OutDirPlan> {
let normalized = absolute_lexical(out_dir)?;
let mut anchor = normalized.as_path();
loop {
match std::fs::symlink_metadata(anchor) {
Ok(metadata) => {
let file_type = metadata.file_type();
if file_type.is_symlink() {
return Err(EngineError::InvalidState(format!(
"bundle output {} resolves through a symlinked component: {}",
out_dir.display(),
anchor.display()
))
.into());
}
if !file_type.is_dir() {
return Err(EngineError::InvalidState(format!(
"bundle output {} is blocked by a non-directory component: {}",
out_dir.display(),
anchor.display()
))
.into());
}
break;
}
Err(error) if error.kind() == ErrorKind::NotFound => {
anchor = anchor.parent().ok_or_else(|| {
EngineError::InvalidState(format!(
"bundle output {} has no existing ancestor",
out_dir.display()
))
})?;
}
Err(error) => return Err(error.into()),
}
}
let canonical_anchor = anchor.canonicalize()?;
let mut tail = Vec::new();
let mut canonical_out = canonical_anchor.clone();
for component in normalized
.strip_prefix(anchor)
.map_err(|_| {
EngineError::InvalidState(format!(
"bundle output {} escaped its anchor",
out_dir.display()
))
})?
.components()
{
let Component::Normal(name) = component else {
return Err(EngineError::InvalidState(format!(
"bundle output {} has a non-normal component below its anchor",
out_dir.display()
))
.into());
};
let name = name.to_str().ok_or_else(|| {
EngineError::InvalidState(format!(
"bundle output {} has a non-UTF-8 component",
out_dir.display()
))
})?;
tail.push(name.to_string());
canonical_out.push(name);
}
Ok(OutDirPlan {
anchor: canonical_anchor,
tail,
canonical_out,
})
}
fn pin_out_dir(plan: &OutDirPlan) -> anyhow::Result<Dir> {
let mut dir = Dir::open_ambient_dir(&plan.anchor, ambient_authority())?;
let mut walked = plan.anchor.clone();
for component in &plan.tail {
walked.push(component);
dir = crate::paths::open_real_subdir(&dir, component, &walked, true)?;
}
Ok(dir)
}
fn write_bundle_files(bundle: &EvidenceBundle, out_dir: &Path, out: &Dir) -> anyhow::Result<usize> {
let mut entries = out.entries().map_err(|error| {
EngineError::InvalidState(format!(
"bundle output {} is not an empty directory: {error}",
out_dir.display()
))
})?;
if entries.next().is_some() {
return Err(EngineError::InvalidState(format!(
"bundle output {} is not empty; choose a fresh --out or remove it",
out_dir.display()
))
.into());
}
let manifest_bytes = to_json_bytes(&bundle.manifest)?;
let mut written = 0usize;
for (relative, bytes) in bundle
.files
.iter()
.map(|file| (file.path.as_str(), file.bytes.as_slice()))
.chain([(MANIFEST_FILE, manifest_bytes.as_slice())])
{
let mut names = Vec::new();
for component in relative.split('/') {
if component.is_empty() || component == "." || component == ".." {
return Err(
EngineError::InvalidState(format!("unsafe bundle path {relative:?}")).into(),
);
}
names.push(component);
}
let (leaf, parents) = names.split_last().expect("validated non-empty");
let mut dir = None;
let mut display = out_dir.to_path_buf();
for parent in parents {
display.push(parent);
dir = Some(crate::paths::open_real_subdir(
dir.as_ref().unwrap_or(out),
parent,
&display,
true,
)?);
}
let mut options = OpenOptions::new();
options
.write(true)
.create_new(true)
.follow(FollowSymlinks::No);
let mut file = dir.as_ref().unwrap_or(out).open_with(leaf, &options)?;
file.write_all(bytes)?;
written += 1;
}
Ok(written)
}
pub fn write_evidence_bundle(bundle: &EvidenceBundle, out_dir: &Path) -> anyhow::Result<usize> {
let plan = plan_out_dir(out_dir)?;
let out = pin_out_dir(&plan)?;
write_bundle_files(bundle, out_dir, &out)
}
pub fn export_evidence_bundle(
repo_root: &Path,
mission_id: &str,
out_dir: &Path,
) -> anyhow::Result<ExportOutcome> {
let paths = MissionPaths::new(repo_root, mission_id);
paths.require_no_follow()?;
let refusal = || {
EngineError::InvalidState(format!(
"bundle output {} must be outside the mission dir {}",
out_dir.display(),
paths.mission_dir().display()
))
};
let out_lexical = absolute_lexical(out_dir)?;
let mission_lexical = absolute_lexical(&paths.mission_dir())?;
if out_lexical.starts_with(&mission_lexical) {
return Err(refusal().into());
}
let plan = plan_out_dir(out_dir)?;
match std::fs::symlink_metadata(paths.mission_dir()) {
Ok(_) => {
if plan
.canonical_out
.starts_with(paths.mission_dir().canonicalize()?)
{
return Err(refusal().into());
}
}
Err(error) if error.kind() == ErrorKind::NotFound => {}
Err(error) => return Err(error.into()),
}
let bundle = assemble_evidence_bundle(repo_root, mission_id)?;
let out = pin_out_dir(&plan)?;
let files_written = write_bundle_files(&bundle, out_dir, &out)?;
let resolved_artefacts = bundle
.manifest
.entries
.iter()
.filter(|entry| entry.status == Some(ArtefactStatus::Resolved))
.count();
let unresolved_artefacts = bundle
.manifest
.entries
.iter()
.filter(|entry| entry.status == Some(ArtefactStatus::Unresolved))
.count();
Ok(ExportOutcome {
out_dir: out_dir.to_path_buf(),
files_written,
resolved_artefacts,
unresolved_artefacts,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::event_log::{EventLog, LockForce};
use crate::events::EventKind;
use crate::gate::{GateKind, GateSurface, GateVerdict};
use crate::types::{GrantKind, MissionConfig, Plan, Role, RunResult, TokenUsage};
use std::collections::BTreeMap;
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() -> EventKind {
EventKind::MissionCreated {
goal: "ship the thing".into(),
base_branch: "main".into(),
mission_branch: "kranz/mission-x".into(),
config: MissionConfig::default(),
}
}
fn gate_result(
gate: &str,
surface: GateSurface,
kind: GateKind,
index: u32,
artefact_ref: &str,
) -> EventKind {
EventKind::GateResult {
gate: gate.to_string(),
surface,
kind,
index,
verdict: GateVerdict::Pass,
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) -> 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: "aaaabbbbcccc".to_string(),
transcript_path: MissionPaths::transcript_rel(run_id),
}
}
fn seed_full_mission(root: &Path) -> MissionPaths {
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,
"contract gate vacuous-filter",
),
gate_result(
"merge-gate-suite",
GateSurface::Approval,
GateKind::Deterministic,
1,
"file:runs/gate-base.jsonl",
),
gate_result(
"merge-gate-suite-recheck",
GateSurface::Approval,
GateKind::Deterministic,
2,
"file:runs/gate-base.jsonl",
),
gate_result(
"plan-review",
GateSurface::Approval,
GateKind::ModelJudged,
0,
"file:runs/gone.jsonl",
),
worker_spawned("r-1", Role::Worker, "gpt-5"),
EventKind::WorkerCompleted {
run_id: "r-1".into(),
result: RunResult::Pass,
tokens: TokenUsage {
input: 100,
output: 50,
cache_read: 0,
cache_write: 0,
},
cost_usd: Some(0.42),
report: None,
},
EventKind::FeatureCompleted {
feature_id: "f-1-1".into(),
commits: vec!["abc1234 implement the widget".into()],
},
EventKind::GrantRequested {
milestone_id: "ms-1".into(),
kind: GrantKind::Command,
command: "cargo test".into(),
},
EventKind::GrantApproved {
kind: GrantKind::Command,
command: "cargo test".into(),
},
worker_spawned("r-2", Role::Worker, "my-local-model"),
worker_spawned("r-3", Role::ValidatorScrutiny, "sonnet"),
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::UserMessage {
text: "skip the flaky test".into(),
interrupt: false,
},
gate_result(
"merge-gate-suite",
GateSurface::FinalGate,
GateKind::Deterministic,
0,
".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();
std::fs::write(paths.plan_md_file(), b"# plan\n").unwrap();
std::fs::write(paths.plan_file(), b"{}").unwrap();
std::fs::write(paths.report_file(), b"# report\n").unwrap();
paths
}
fn collect_files(dir: &Path) -> BTreeMap<String, Vec<u8>> {
let mut out = BTreeMap::new();
let mut stack = vec![dir.to_path_buf()];
while let Some(current) = stack.pop() {
for entry in std::fs::read_dir(¤t).unwrap() {
let path = entry.unwrap().path();
if path.is_dir() {
stack.push(path);
} else {
let relative = path
.strip_prefix(dir)
.unwrap()
.components()
.map(|c| c.as_os_str().to_str().unwrap().to_string())
.collect::<Vec<_>>()
.join("/");
out.insert(relative, std::fs::read(&path).unwrap());
}
}
}
out
}
fn manifest_entry<'m>(manifest: &'m EvidenceManifest, source: &str) -> &'m ManifestEntry {
manifest
.entries
.iter()
.find(|entry| entry.source == source)
.unwrap_or_else(|| panic!("manifest entry {source} missing"))
}
#[test]
fn evidence_bundle_opens_standalone_with_no_host_paths() {
let tmp = TempDir::new().unwrap();
seed_full_mission(tmp.path());
let out = tmp.path().join("bundle-out");
let outcome = export_evidence_bundle(tmp.path(), "m-1", &out).unwrap();
for name in [
MANIFEST_FILE,
SUMMARY_FILE,
CHAIN_FILE,
ESCALATIONS_FILE,
COST_FILE,
LOG_FILE,
] {
assert!(out.join(name).is_file(), "{name} missing from the bundle");
}
for shipped in [
"artefacts/runs/gate-base.jsonl",
"artefacts/runs/r-1.jsonl",
"artefacts/plan.md",
"artefacts/plan.json",
"artefacts/report.md",
] {
assert!(
out.join(shipped).is_file(),
"{shipped} missing from artefacts/"
);
}
assert_eq!(outcome.files_written, 11);
assert_eq!(outcome.resolved_artefacts, 5);
assert_eq!(outcome.unresolved_artefacts, 5);
let host = tmp.path().to_string_lossy().to_string();
let files = collect_files(&out);
for (relative, bytes) in &files {
let text = String::from_utf8_lossy(bytes);
assert!(
!text.contains(&host),
"host path leaked into bundle file {relative}"
);
}
let manifest: EvidenceManifest =
serde_json::from_str(&std::fs::read_to_string(out.join(MANIFEST_FILE)).unwrap())
.unwrap();
assert_eq!(manifest.version, BUNDLE_FORMAT_VERSION);
assert_eq!(manifest.mission_id, "m-1");
for entry in &manifest.entries {
if let (Some(path), Some(sha256)) = (&entry.path, &entry.sha256) {
let bytes = std::fs::read(out.join(path)).unwrap();
assert_eq!(&sha256_hex(&bytes), sha256, "sha256 mismatch for {path}");
}
}
assert_eq!(
manifest
.entries
.iter()
.filter(|entry| entry.source == "file:runs/gate-base.jsonl")
.count(),
1
);
assert!(manifest
.entries
.iter()
.all(|entry| entry.source != "contract gate vacuous-filter"));
let gone = manifest_entry(&manifest, "file:runs/gone.jsonl");
assert_eq!(gone.status, Some(ArtefactStatus::Unresolved));
assert!(gone.path.is_none() && gone.sha256.is_none());
let chain: ProvenanceChain =
serde_json::from_str(&std::fs::read_to_string(out.join(CHAIN_FILE)).unwrap()).unwrap();
assert_eq!(chain.gates.len(), 5);
let cost: MissionCostSummary =
serde_json::from_str(&std::fs::read_to_string(out.join(COST_FILE)).unwrap()).unwrap();
assert_eq!(cost.total_cost_usd, 0.42);
assert_eq!(cost.non_meta_commits, 1);
assert_eq!(cost.usd_per_commit, Some(0.42));
assert!(cost.closed);
}
#[test]
fn evidence_bundle_is_byte_identical_across_exports() {
let tmp = TempDir::new().unwrap();
seed_full_mission(tmp.path());
let first = assemble_evidence_bundle(tmp.path(), "m-1").unwrap();
let second = assemble_evidence_bundle(tmp.path(), "m-1").unwrap();
assert_eq!(first, second);
assert_eq!(
serde_json::to_string_pretty(&first.manifest).unwrap(),
serde_json::to_string_pretty(&second.manifest).unwrap()
);
let out_a = tmp.path().join("out-a");
let out_b = tmp.path().join("out-b");
export_evidence_bundle(tmp.path(), "m-1", &out_a).unwrap();
export_evidence_bundle(tmp.path(), "m-1", &out_b).unwrap();
assert_eq!(collect_files(&out_a), collect_files(&out_b));
}
#[test]
fn evidence_bundle_redacted_secret_leaves_fingerprints_only() {
let tmp = TempDir::new().unwrap();
let secret = "sk-ant-F00barBazQuux9_7";
let text = format!("the key is {secret} ok");
let findings = crate::scrub::scan_text(&text);
assert_eq!(findings.len(), 1, "fixture must trip exactly one rule");
let fingerprint = findings[0].fingerprint.clone();
seed_mission(
tmp.path(),
"m-sec",
vec![
created(),
EventKind::UserMessage {
text,
interrupt: false,
},
EventKind::MissionCompleted {},
],
);
let out = tmp.path().join("bundle-sec");
export_evidence_bundle(tmp.path(), "m-sec", &out).unwrap();
let files = collect_files(&out);
assert!(!files.is_empty());
for (relative, bytes) in &files {
let text = String::from_utf8_lossy(bytes);
assert!(
!text.contains(secret),
"secret value leaked into bundle file {relative}"
);
}
let log = String::from_utf8_lossy(&files[LOG_FILE]).to_string();
assert!(log.contains(&fingerprint), "audit fingerprint missing");
assert!(log.contains("[REDACTED]"));
}
#[test]
fn evidence_bundle_scrubs_artefact_bytes_and_hashes_the_redacted_form() {
let tmp = TempDir::new().unwrap();
let secret = "sk-ant-F00barBazQuux9_7";
let paths = seed_full_mission(tmp.path());
let planted = format!("{{\"text\":\"the key is {secret} ok\"}}\n");
std::fs::write(paths.runs_dir().join("r-1.jsonl"), planted.as_bytes()).unwrap();
let out = tmp.path().join("bundle-artefact-secret");
export_evidence_bundle(tmp.path(), "m-1", &out).unwrap();
let files = collect_files(&out);
for (relative, bytes) in &files {
let text = String::from_utf8_lossy(bytes);
assert!(
!text.contains(secret),
"secret value leaked into bundle file {relative}"
);
}
let shipped = &files["artefacts/runs/r-1.jsonl"];
assert!(String::from_utf8_lossy(shipped).contains("[REDACTED]"));
let manifest: EvidenceManifest =
serde_json::from_str(&std::fs::read_to_string(out.join(MANIFEST_FILE)).unwrap())
.unwrap();
let entry = manifest_entry(&manifest, "file:runs/r-1.jsonl");
assert_eq!(entry.sha256.as_deref(), Some(sha256_hex(shipped).as_str()));
}
#[test]
fn evidence_bundle_scrubs_non_utf8_artefact_bytes_lossily() {
let tmp = TempDir::new().unwrap();
let secret = "sk-ant-F00barBazQuux9_7";
let paths = seed_full_mission(tmp.path());
let mut planted = format!("the key is {secret} ok").into_bytes();
planted.push(0xff);
std::fs::write(paths.runs_dir().join("r-1.jsonl"), &planted).unwrap();
let bundle = assemble_evidence_bundle(tmp.path(), "m-1").unwrap();
let shipped = bundle
.files
.iter()
.find(|file| file.path == "artefacts/runs/r-1.jsonl")
.expect("artefact shipped");
let text = String::from_utf8(shipped.bytes.clone()).expect("lossy decode yields UTF-8");
assert!(!text.contains(secret));
assert!(text.contains("[REDACTED]"));
assert!(
text.contains('\u{fffd}'),
"invalid byte became a replacement"
);
}
#[test]
fn evidence_bundle_missing_artefact_bytes_become_unresolved_manifest_entries() {
let tmp = TempDir::new().unwrap();
let paths = seed_full_mission(tmp.path());
std::fs::remove_dir_all(paths.runs_dir()).unwrap();
let bundle = assemble_evidence_bundle(tmp.path(), "m-1").unwrap();
for source in [
"file:runs/gate-base.jsonl",
"file:runs/gone.jsonl",
"file:runs/r-1.jsonl",
"file:runs/r-2.jsonl",
"file:runs/r-3.jsonl",
"file:research.md",
"file:estimate.json",
] {
let entry = manifest_entry(&bundle.manifest, source);
assert_eq!(
entry.status,
Some(ArtefactStatus::Unresolved),
"{source} must be unresolved with its bytes gone"
);
assert!(entry.path.is_none() && entry.sha256.is_none());
}
for source in ["file:plan.md", "file:plan.json", "file:report.md"] {
assert_eq!(
manifest_entry(&bundle.manifest, source).status,
Some(ArtefactStatus::Resolved),
"{source} must still resolve"
);
}
assert!(bundle
.files
.iter()
.all(|file| !file.path.starts_with("artefacts/runs/")));
}
#[test]
fn evidence_bundle_refuses_out_dir_inside_the_mission_dir() {
let tmp = TempDir::new().unwrap();
let paths = seed_full_mission(tmp.path());
let inside = paths.mission_dir().join("bundle");
let result = export_evidence_bundle(tmp.path(), "m-1", &inside);
assert!(result.is_err(), "an in-mission --out must be refused");
assert!(!inside.exists(), "nothing must be written on refusal");
}
#[test]
fn evidence_bundle_refuses_a_non_empty_out_dir() {
let tmp = TempDir::new().unwrap();
seed_full_mission(tmp.path());
let out = tmp.path().join("bundle-used");
std::fs::create_dir_all(&out).unwrap();
std::fs::write(out.join("stale.txt"), b"stale").unwrap();
let result = export_evidence_bundle(tmp.path(), "m-1", &out);
assert!(result.is_err(), "a non-empty --out must be refused");
assert_eq!(
std::fs::read_to_string(out.join("stale.txt")).unwrap(),
"stale"
);
}
#[test]
fn evidence_single_snapshot_torn_tail_is_excluded_from_parse_and_bytes() {
use std::io::Write as _;
let tmp = TempDir::new().unwrap();
let paths = seed_full_mission(tmp.path());
let pristine = std::fs::read(paths.events_file()).unwrap();
let mut file = std::fs::OpenOptions::new()
.append(true)
.open(paths.events_file())
.unwrap();
file.write_all(b"{\"seq\":999,\"ts\":\"torn").unwrap();
drop(file);
let bundle = assemble_evidence_bundle(tmp.path(), "m-1").unwrap();
let shipped = bundle
.files
.iter()
.find(|file| file.path == LOG_FILE)
.expect("the raw log ships");
assert_eq!(
shipped.bytes, pristine,
"the torn tail is in NEITHER the events nor the shipped bytes"
);
assert_eq!(bundle.manifest.mission_id, "m-1");
let replay = paths.runs_dir().join("replay.jsonl");
std::fs::write(&replay, &shipped.bytes).unwrap();
let folded = crate::event_log::EventLog::read_events(&paths.events_file()).unwrap();
let refolded = crate::event_log::EventLog::read_events(&replay).unwrap();
assert_eq!(refolded.len(), folded.len());
assert_eq!(
refolded.last().map(|event| event.seq),
folded.last().map(|event| event.seq)
);
}
#[test]
fn evidence_outdir_containment_refuses_dotdot_escape_into_the_mission() {
let tmp = TempDir::new().unwrap();
let paths = seed_full_mission(tmp.path());
let escape = tmp
.path()
.join("outside")
.join("..")
.join(".kranz")
.join("missions")
.join("m-1")
.join("bundle");
let result = export_evidence_bundle(tmp.path(), "m-1", &escape);
assert!(result.is_err(), "the `..` shape must be refused");
assert!(
!paths.mission_dir().join("bundle").exists(),
"nothing must be written on refusal"
);
}
#[cfg(unix)]
#[test]
fn evidence_outdir_containment_refuses_a_symlinked_component() {
use std::os::unix::fs::symlink;
let tmp = TempDir::new().unwrap();
let paths = seed_full_mission(tmp.path());
let link = tmp.path().join("linked-out");
symlink(paths.mission_dir(), &link).unwrap();
let result = export_evidence_bundle(tmp.path(), "m-1", &link.join("bundle"));
let err = result.expect_err("a symlinked out-dir component must be refused");
assert!(err.to_string().contains("symlinked"), "{err}");
assert!(
!paths.mission_dir().join("bundle").exists(),
"nothing must be written through the link"
);
}
#[test]
fn evidence_outdir_containment_normal_external_dir_works() {
let tmp = TempDir::new().unwrap();
seed_full_mission(tmp.path());
let out = tmp.path().join("fresh").join("bundle-out");
let outcome = export_evidence_bundle(tmp.path(), "m-1", &out).unwrap();
assert!(outcome.files_written > 0);
assert!(out.join(MANIFEST_FILE).is_file());
assert!(out.join(LOG_FILE).is_file());
}
fn pinned_plan() -> Plan {
let rule = |id: &str, revision: u64, status: &str| crate::types::PinnedRule {
id: id.to_string(),
revision,
rfc: "RFC-001".to_string(),
level: "must".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"),
rule("ZZ-PASS-001", 1, "enforced"),
rule("ZZ-QUIET-001", 1, "enforced"),
],
})),
..sample_plan()
}
}
fn seed_pinned_mission(root: &Path) -> MissionPaths {
let mut gate = gate_result(
"zz-gate",
GateSurface::FinalGate,
GateKind::Deterministic,
0,
"file:runs/gone.jsonl",
);
if let EventKind::GateResult { rule_ids, .. } = &mut gate {
*rule_ids = vec!["ZZ-PASS-001".to_string()];
}
seed_mission(
root,
"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::new(),
approval_seq: 2,
},
gate,
EventKind::ValidationFinding {
milestone_id: "ms-1".into(),
run_id: "v-1".into(),
finding: crate::types::Finding {
subject: "a-1".into(),
severity: "major".into(),
evidence: "the rule failed".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 {},
],
)
}
#[test]
fn flight_rules_provenance_bundle_renders_coverage_byte_identically() {
let tmp = TempDir::new().unwrap();
seed_pinned_mission(tmp.path());
let first = assemble_evidence_bundle(tmp.path(), "m-1").unwrap();
let second = assemble_evidence_bundle(tmp.path(), "m-1").unwrap();
assert_eq!(first, second, "same log → byte-identical bundle");
let summary = first
.files
.iter()
.find(|file| file.path == SUMMARY_FILE)
.expect("summary ships");
let summary = String::from_utf8(summary.bytes.clone()).unwrap();
assert!(
summary.contains("## Flight Rules standards coverage"),
"{summary}"
);
assert!(
summary.contains("| ZZ-FAIL-001 | r2 | enforced | must | gate:zz-gate | failed |"),
"{summary}"
);
assert!(
summary.contains("| ZZ-PASS-001 | r1 | enforced | must | gate:zz-gate | passed |"),
"{summary}"
);
assert!(
summary
.contains("| ZZ-QUIET-001 | r1 | enforced | must | gate:zz-gate | not-evaluated |"),
"{summary}"
);
assert!(
summary.contains("gate.result seq 4 zz-gate pass `file:runs/gone.jsonl`"),
"{summary}"
);
let chain = first
.files
.iter()
.find(|file| file.path == CHAIN_FILE)
.expect("the chain ships");
let chain = String::from_utf8(chain.bytes.clone()).unwrap();
assert!(chain.contains("\"standards\""), "{chain}");
assert!(
chain.contains("\"disposition\": \"not-evaluated\""),
"{chain}"
);
}
#[test]
fn flight_rules_provenance_bundle_removed_artefacts_stay_unresolved() {
let tmp = TempDir::new().unwrap();
seed_pinned_mission(tmp.path());
let bundle = assemble_evidence_bundle(tmp.path(), "m-1").unwrap();
let entry = manifest_entry(&bundle.manifest, "file:runs/gone.jsonl");
assert_eq!(entry.status, Some(ArtefactStatus::Unresolved));
assert_eq!(entry.path, None, "an unresolved entry has no bytes path");
let summary = bundle
.files
.iter()
.find(|file| file.path == SUMMARY_FILE)
.expect("summary ships");
let summary = String::from_utf8(summary.bytes.clone()).unwrap();
assert!(summary.contains("`file:runs/gone.jsonl`"), "{summary}");
assert!(
summary.contains("Absence of evidence is never rendered as pass"),
"{summary}"
);
}
#[test]
fn flight_rules_provenance_bundle_pre_flight_rules_mission_is_unchanged() {
let tmp = TempDir::new().unwrap();
seed_full_mission(tmp.path());
let bundle = assemble_evidence_bundle(tmp.path(), "m-1").unwrap();
let summary = bundle
.files
.iter()
.find(|file| file.path == SUMMARY_FILE)
.expect("summary ships");
let summary = String::from_utf8(summary.bytes.clone()).unwrap();
assert!(
!summary.contains("Flight Rules standards coverage"),
"no pin, no matrix: {summary}"
);
let chain = bundle
.files
.iter()
.find(|file| file.path == CHAIN_FILE)
.expect("the chain ships");
let chain = String::from_utf8(chain.bytes.clone()).unwrap();
assert!(
!chain.contains("\"standards\""),
"a pre-Flight-Rules chain carries no standards key: {chain}"
);
}
}