1use crate::error::EngineError;
64use crate::gate_results::{file_artefact_ref, resolve_artefact, ArtefactResolution};
65use crate::outcomes::MissionOutcomes;
66use crate::paths::MissionPaths;
67use crate::provenance::{ArtefactStatus, ProvenanceChain};
68use cap_fs_ext::{FollowSymlinks, OpenOptionsFollowExt as _};
69use cap_std::ambient_authority;
70use cap_std::fs::{Dir, OpenOptions};
71use serde::{Deserialize, Serialize};
72use sha2::{Digest, Sha256};
73use std::io::{ErrorKind, Read as _, Write as _};
74use std::path::{Component, Path, PathBuf};
75
76pub const BUNDLE_FORMAT_VERSION: u32 = 1;
79
80pub const MANIFEST_FILE: &str = "manifest.json";
81pub const SUMMARY_FILE: &str = "summary.md";
82pub const CHAIN_FILE: &str = "chain.json";
83pub const ESCALATIONS_FILE: &str = "escalations.json";
84pub const COST_FILE: &str = "cost.json";
85pub const LOG_FILE: &str = "events.jsonl";
86pub const ARTEFACTS_DIR: &str = "artefacts";
87
88const MISSION_DOCUMENTS: [&str; 5] = [
95 "plan.md",
96 "plan.json",
97 "research.md",
98 "estimate.json",
99 "report.md",
100];
101
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
104#[serde(rename_all = "lowercase")]
105pub enum EntryKind {
106 Summary,
108 Chain,
110 Escalations,
112 Cost,
114 Log,
116 Artefact,
119}
120
121#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
125#[serde(rename_all = "camelCase")]
126pub struct ManifestEntry {
127 #[serde(skip_serializing_if = "Option::is_none")]
129 pub path: Option<String>,
130 #[serde(skip_serializing_if = "Option::is_none")]
132 pub sha256: Option<String>,
133 pub source: String,
137 pub kind: EntryKind,
138 #[serde(skip_serializing_if = "Option::is_none")]
142 pub status: Option<ArtefactStatus>,
143}
144
145#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
151#[serde(rename_all = "camelCase")]
152pub struct EvidenceManifest {
153 pub version: u32,
154 pub mission_id: String,
155 pub entries: Vec<ManifestEntry>,
156}
157
158#[derive(Debug, Clone, PartialEq, Eq)]
162pub struct BundleFile {
163 pub path: String,
164 pub bytes: Vec<u8>,
165}
166
167#[derive(Debug, Clone, PartialEq)]
172pub struct EvidenceBundle {
173 pub manifest: EvidenceManifest,
174 pub files: Vec<BundleFile>,
175}
176
177#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
181#[serde(rename_all = "camelCase")]
182pub struct MissionCostSummary {
183 #[serde(default, skip_serializing_if = "Option::is_none")]
185 pub outcome_reasons: Option<crate::outcomes::reasons::MissionReasons>,
186 pub total_cost_usd: f64,
188 pub non_meta_commits: u64,
191 pub usd_per_commit: Option<f64>,
194 pub cycle_time_ms: Option<u64>,
197 pub closed: bool,
199 pub interventions: u64,
201}
202
203#[derive(Debug, Clone, PartialEq, Eq)]
205pub struct ExportOutcome {
206 pub out_dir: PathBuf,
207 pub files_written: usize,
209 pub resolved_artefacts: usize,
210 pub unresolved_artefacts: usize,
211}
212
213fn sha256_hex(bytes: &[u8]) -> String {
218 let digest = Sha256::digest(bytes);
219 digest.iter().map(|b| format!("{b:02x}")).collect()
220}
221
222fn to_json_bytes<T: Serialize>(value: &T) -> anyhow::Result<Vec<u8>> {
226 let mut text = serde_json::to_string_pretty(value)?;
227 text.push('\n');
228 Ok(text.into_bytes())
229}
230
231fn artefact_bundle_path(reference: &str) -> Option<String> {
237 let relative = reference.strip_prefix(crate::gate_results::FILE_REF_SCHEME)?;
238 let mut parts = Vec::new();
239 for component in Path::new(relative).components() {
240 match component {
241 Component::Normal(part) => parts.push(part.to_str()?),
242 Component::CurDir => {}
246 Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None,
249 }
250 }
251 if parts.is_empty() {
252 return None;
253 }
254 Some(format!("{ARTEFACTS_DIR}/{}", parts.join("/")))
255}
256
257fn read_raw_artefact(mission_dir: &Path, reference: &str) -> (ArtefactStatus, Option<Vec<u8>>) {
266 let ArtefactResolution::Resolved { path } = resolve_artefact(mission_dir, reference) else {
267 return (ArtefactStatus::Unresolved, None);
268 };
269 let read = crate::paths::open_read_nofollow(&path).and_then(|mut file| {
270 let mut bytes = Vec::new();
271 file.read_to_end(&mut bytes)?;
272 Ok(bytes)
273 });
274 match read {
275 Ok(bytes) => (ArtefactStatus::Resolved, Some(bytes)),
276 Err(_) => (ArtefactStatus::Unresolved, None),
277 }
278}
279
280fn wire_name<T: Serialize>(value: &T) -> String {
285 serde_json::to_value(value)
286 .ok()
287 .and_then(|v| v.as_str().map(str::to_string))
288 .expect("gate/role enums always serialize to a string")
289}
290
291fn one_line(text: &str) -> String {
294 text.split_whitespace().collect::<Vec<_>>().join(" ")
295}
296
297fn md_cell(text: &str) -> String {
300 one_line(text).replace('|', "\\|")
301}
302
303fn format_duration_ms(ms: u64) -> String {
307 const S: u64 = 1_000;
308 const M: u64 = 60 * S;
309 const H: u64 = 60 * M;
310 const D: u64 = 24 * H;
311 if ms >= D {
312 format!("{:.1}d", ms as f64 / D as f64)
313 } else if ms >= H {
314 format!("{:.1}h", ms as f64 / H as f64)
315 } else if ms >= M {
316 format!("{}m", ms / M)
317 } else {
318 format!("{}s", ms / S)
319 }
320}
321
322fn render_summary(
326 chain: &ProvenanceChain,
327 cost: &MissionCostSummary,
328 escalations: &[crate::outcomes::EscalationRow],
329 artefact_entries: &[ManifestEntry],
330) -> String {
331 let mut out = String::new();
332 out.push_str(&format!(
333 "# Evidence bundle — mission {}\n\n",
334 chain.mission_id
335 ));
336 out.push_str(
337 "Portable audit package (KRZ-326). Everything below derives from the mission's\n\
338 append-only event log (`events.jsonl`, included verbatim — every line crossed\n\
339 the redact-at-write boundary when appended) plus the mission-relative artefact\n\
340 bytes under `artefacts/`. Artefacts ship as scrubbed text: they are redacted\n\
341 at export (not at write), and any byte that is not valid UTF-8 travels as the\n\
342 replacement character. References whose bytes were no longer on disk at\n\
343 export time are listed as `unresolved` in `manifest.json` — named, never\n\
344 silently omitted.\n\n",
345 );
346
347 out.push_str("## Mission\n\n");
348 match &chain.goal {
349 Some(goal) => out.push_str(&format!("- Goal: {}\n", one_line(goal))),
350 None => out.push_str("- Goal: (not recorded in the log)\n"),
351 }
352 if let (Some(mission_branch), Some(base_branch)) = (&chain.mission_branch, &chain.base_branch) {
353 let pinned = chain
354 .base_sha
355 .as_deref()
356 .map(|sha| format!(" @ {sha}"))
357 .unwrap_or_default();
358 out.push_str(&format!(
359 "- Branch: {mission_branch} (base {base_branch}{pinned})\n"
360 ));
361 }
362 match &chain.outcome {
363 Some(terminal) => {
364 let reason = terminal
365 .reason
366 .as_deref()
367 .map(|reason| format!(" — {}", one_line(reason)))
368 .unwrap_or_default();
369 out.push_str(&format!(
370 "- Outcome: {} at seq {}{}\n",
371 terminal.status.as_str(),
372 terminal.seq,
373 reason
374 ));
375 }
376 None => out.push_str("- Outcome: in flight (no terminal event recorded)\n"),
377 }
378 let usd_per_commit = cost
379 .usd_per_commit
380 .map(|usd| format!("${usd:.4}/commit"))
381 .unwrap_or_else(|| "n/a (no non-meta commits)".to_string());
382 out.push_str(&format!(
383 "- Cost: ${:.4} across {} non-meta commits ({})\n",
384 cost.total_cost_usd, cost.non_meta_commits, usd_per_commit
385 ));
386 let cycle = cost
387 .cycle_time_ms
388 .map(format_duration_ms)
389 .unwrap_or_else(|| "n/a (in flight)".to_string());
390 out.push_str(&format!(
391 "- Cycle time: {cycle} | Interventions: {} | Closed: {}\n\n",
392 cost.interventions,
393 if cost.closed { "yes" } else { "no" }
394 ));
395
396 out.push_str("## Gate ladder (log order)\n\n");
397 if chain.gates.is_empty() {
398 out.push_str("(no gate.result events recorded)\n\n");
399 } else {
400 out.push_str(
401 "| seq | surface | kind | # | gate | verdict | score | artefact | resolution |\n\
402 |----:|---------|------|--:|------|---------|-------|----------|------------|\n",
403 );
404 for gate in &chain.gates {
405 let score = match (gate.score, gate.threshold) {
406 (Some(score), Some(threshold)) => format!("{score}/{threshold}"),
407 _ => "—".to_string(),
408 };
409 out.push_str(&format!(
410 "| {} | {} | {} | {} | {} | {} | {} | `{}` | {} |\n",
411 gate.seq,
412 wire_name(&gate.surface),
413 wire_name(&gate.kind),
414 gate.index,
415 md_cell(&gate.gate),
416 wire_name(&gate.verdict),
417 score,
418 md_cell(&gate.artefact_ref),
419 gate.artefact.as_str(),
420 ));
421 }
422 out.push('\n');
423 }
424
425 if !chain.gate_evaluations.is_empty() {
426 out.push_str("## External gate decisions\n\n");
427 for record in &chain.gate_evaluations {
428 let request = &record.requested.request.params;
429 let status = if let Some(reason) = &record.closed {
430 format!("closed: {}", md_cell(reason))
431 } else {
432 match &record.resolution {
433 Some(resolution) => format!("{:?}", resolution.disposition),
434 None if record.finished.is_some() => "awaiting engine resolution".into(),
435 None => "interrupted or pending evaluation".into(),
436 }
437 };
438 out.push_str(&format!(
439 "- `{}` / {:?} / `{}`: {}; consumed={} (effect completion is separate).\n",
440 request.gate_id.as_str(),
441 request.stage,
442 request.attempt_id.as_str(),
443 status,
444 record.consumed.is_some()
445 ));
446 }
447 out.push('\n');
448 }
449
450 if let Some(coverage) = &chain.standards {
457 out.push_str(&crate::standards_coverage::render_coverage_markdown(
458 coverage,
459 ));
460 out.push('\n');
461 }
462
463 out.push_str("## Sessions (workers and reviewers)\n\n");
464 if chain.sessions.is_empty() {
465 out.push_str("(no sessions recorded)\n\n");
466 } else {
467 out.push_str(
468 "| seq | run | role | backend | model | prompt hash | transcript | resolution |\n\
469 |----:|-----|------|---------|-------|-------------|------------|------------|\n",
470 );
471 for session in &chain.sessions {
472 out.push_str(&format!(
473 "| {} | {} | {} | {} | {} | `{}` | `{}` | {} |\n",
474 session.seq,
475 md_cell(&session.run_id),
476 wire_name(&session.role),
477 session.backend.as_deref().unwrap_or("?"),
478 md_cell(&session.model),
479 session.prompt_hash,
480 md_cell(&session.transcript_ref),
481 session.transcript.as_str(),
482 ));
483 }
484 out.push('\n');
485 }
486
487 out.push_str("## Human decisions\n\n");
488 if chain.decisions.is_empty() {
489 out.push_str("(no human decisions recorded)\n\n");
490 } else {
491 for decision in &chain.decisions {
492 out.push_str(&format!(
493 "- [seq {}] {} — {}\n",
494 decision.seq,
495 decision.kind.as_str(),
496 one_line(&decision.summary)
497 ));
498 }
499 out.push('\n');
500 }
501
502 out.push_str("## Escalations\n\n");
503 if escalations.is_empty() {
504 out.push_str("(no escalations recorded)\n\n");
505 } else {
506 for row in escalations {
507 let latency = row
508 .latency_ms
509 .map(|ms| format!(" (latency {ms} ms)"))
510 .unwrap_or_default();
511 out.push_str(&format!(
512 "- [{}] {}: {} → {}{}\n",
513 row.ts.to_rfc3339(),
514 row.kind.as_str(),
515 one_line(&row.summary),
516 one_line(&row.decision),
517 latency,
518 ));
519 }
520 out.push('\n');
521 }
522
523 out.push_str("## Artefacts\n\n");
524 out.push_str(
525 "| bundle path | source | sha256 | status |\n\
526 |-------------|--------|--------|--------|\n",
527 );
528 for entry in artefact_entries {
529 let status = entry.status.map(|status| status.as_str()).unwrap_or("—");
530 out.push_str(&format!(
531 "| {} | `{}` | {} | {} |\n",
532 entry
533 .path
534 .as_deref()
535 .map(|path| format!("`{path}`"))
536 .unwrap_or_else(|| "—".to_string()),
537 md_cell(&entry.source),
538 entry.sha256.as_deref().unwrap_or("—"),
539 status,
540 ));
541 }
542 out.push('\n');
543 out.push_str(&format!(
544 "Regenerate with `kranz evidence-bundle {}`; the same event log always yields\n\
545 the same bundle bytes.\n",
546 chain.mission_id
547 ));
548 out
549}
550
551pub fn assemble_evidence_bundle(
561 repo_root: &Path,
562 mission_id: &str,
563) -> anyhow::Result<EvidenceBundle> {
564 let paths = MissionPaths::new(repo_root, mission_id);
565 paths.require_no_follow()?;
566 let mission_dir = paths.mission_dir();
567
568 let (events, log_bytes) =
578 crate::event_log::EventLog::read_events_and_log_bytes(&paths.events_file())?;
579
580 let chain = crate::provenance::provenance_chain(&mission_dir, mission_id, &events)?;
581 let outcomes: MissionOutcomes = crate::outcomes::mission_outcomes(mission_id, &events);
582 let cost = MissionCostSummary {
583 outcome_reasons: Some(outcomes.outcome_reasons.clone()),
584 total_cost_usd: outcomes.cost_usd,
585 non_meta_commits: outcomes.non_meta_commits,
586 usd_per_commit: (outcomes.non_meta_commits > 0)
587 .then(|| outcomes.cost_usd / outcomes.non_meta_commits as f64),
588 cycle_time_ms: outcomes.cycle_time_ms,
589 closed: outcomes.is_closed,
590 interventions: outcomes.interventions,
591 };
592
593 let mut references: Vec<String> = Vec::new();
600 let mut push_reference = |reference: String| {
601 if reference.starts_with(crate::gate_results::FILE_REF_SCHEME)
602 && !references.contains(&reference)
603 {
604 references.push(reference);
605 }
606 };
607 for gate in &chain.gates {
608 push_reference(gate.artefact_ref.clone());
609 }
610 let mut gate_expected = std::collections::BTreeMap::new();
611 let mut paired = std::collections::BTreeMap::new();
612 for gate in &chain.gates {
613 if gate.gate.starts_with("baseline-candidate:") {
614 let descriptor =
615 crate::contract_controls::pair::descriptor(gate.artefact_detail.as_deref());
616 let expected = descriptor.as_ref().map(|d| (d.digest.clone(), d.bytes));
617 gate_expected
618 .entry(gate.artefact_ref.clone())
619 .and_modify(|prior: &mut Option<_>| {
620 if *prior != expected {
621 *prior = None;
622 }
623 })
624 .or_insert(expected);
625 paired.insert(gate.artefact_ref.clone(), descriptor);
626 }
627 }
628 for record in &chain.gate_evaluations {
629 for artifact in record.requested.retained_inputs.iter().chain(
630 record
631 .finished
632 .iter()
633 .flat_map(|finished| &finished.artifacts),
634 ) {
635 let reference = file_artefact_ref(artifact.path.as_str());
636 let expected = (artifact.retained_digest.clone(), artifact.retained_bytes);
637 gate_expected
638 .entry(reference.clone())
639 .and_modify(|prior: &mut Option<_>| {
640 if prior.as_ref() != Some(&expected) {
641 *prior = None;
642 }
643 })
644 .or_insert(Some(expected));
645 push_reference(reference);
646 }
647 }
648 for session in &chain.sessions {
649 push_reference(file_artefact_ref(&session.transcript_ref));
650 }
651 for document in MISSION_DOCUMENTS {
652 push_reference(file_artefact_ref(document));
653 }
654
655 let mut artefact_entries: Vec<ManifestEntry> = Vec::new();
656 let mut artefact_files: Vec<BundleFile> = Vec::new();
657 for reference in &references {
658 let (mut status, mut bytes) = if let Some(descriptor) = paired.get(reference) {
659 match descriptor.as_ref().and_then(|d| {
660 crate::contract_controls::pair::retained_bytes(&mission_dir, reference, d)
661 }) {
662 Some(bytes) => (ArtefactStatus::Resolved, Some(bytes)),
663 None => (ArtefactStatus::Unresolved, None),
664 }
665 } else {
666 read_raw_artefact(&mission_dir, reference)
667 };
668 if let Some(expected) = gate_expected.get(reference) {
669 let matches =
670 expected
671 .as_ref()
672 .zip(bytes.as_ref())
673 .is_some_and(|((digest, length), bytes)| {
674 *length == bytes.len() as u64
675 && *digest == crate::gate_evaluation::protocol::Digest::of(bytes)
676 });
677 if !matches {
678 status = ArtefactStatus::Unresolved;
679 bytes = None;
680 }
681 }
682 let bytes =
685 bytes.map(|bytes| crate::scrub::scrub(&String::from_utf8_lossy(&bytes)).into_bytes());
686 match artefact_bundle_path(reference).zip(bytes) {
687 Some((path, bytes)) => {
688 artefact_entries.push(ManifestEntry {
689 path: Some(path.clone()),
690 sha256: Some(sha256_hex(&bytes)),
691 source: reference.clone(),
692 kind: EntryKind::Artefact,
693 status: Some(status),
694 });
695 artefact_files.push(BundleFile { path, bytes });
696 }
697 None => artefact_entries.push(ManifestEntry {
698 path: None,
699 sha256: None,
700 source: reference.clone(),
701 kind: EntryKind::Artefact,
702 status: Some(ArtefactStatus::Unresolved),
703 }),
704 }
705 }
706
707 let summary = render_summary(&chain, &cost, &outcomes.escalations, &artefact_entries);
710
711 let mut files: Vec<BundleFile> = Vec::new();
712 let mut entries: Vec<ManifestEntry> = Vec::new();
713 let mut push_generated = |path: &str, source: &str, kind: EntryKind, bytes: Vec<u8>| {
714 entries.push(ManifestEntry {
715 path: Some(path.to_string()),
716 sha256: Some(sha256_hex(&bytes)),
717 source: source.to_string(),
718 kind,
719 status: None,
720 });
721 files.push(BundleFile {
722 path: path.to_string(),
723 bytes,
724 });
725 };
726 push_generated(
727 SUMMARY_FILE,
728 "derived:human-summary",
729 EntryKind::Summary,
730 summary.into_bytes(),
731 );
732 push_generated(
733 CHAIN_FILE,
734 "derived:provenance-chain",
735 EntryKind::Chain,
736 to_json_bytes(&chain)?,
737 );
738 push_generated(
739 ESCALATIONS_FILE,
740 "derived:escalations-fold",
741 EntryKind::Escalations,
742 to_json_bytes(&outcomes.escalations)?,
743 );
744 push_generated(
745 COST_FILE,
746 "derived:cost-fold",
747 EntryKind::Cost,
748 to_json_bytes(&cost)?,
749 );
750 push_generated(LOG_FILE, "file:events.jsonl", EntryKind::Log, log_bytes);
751 files.extend(artefact_files);
752 entries.extend(artefact_entries);
753
754 Ok(EvidenceBundle {
755 manifest: EvidenceManifest {
756 version: BUNDLE_FORMAT_VERSION,
757 mission_id: mission_id.to_string(),
758 entries,
759 },
760 files,
761 })
762}
763
764fn absolute_lexical(path: &Path) -> anyhow::Result<PathBuf> {
772 let absolute = std::path::absolute(path)?;
773 let mut out = PathBuf::new();
774 for component in absolute.components() {
775 match component {
776 Component::CurDir => {}
777 Component::ParentDir => {
778 if out.file_name().is_some() {
779 out.pop();
780 } else if !out.has_root() {
781 out.push("..");
782 }
783 }
784 other => out.push(other.as_os_str()),
785 }
786 }
787 Ok(out)
788}
789
790struct OutDirPlan {
793 anchor: PathBuf,
798 tail: Vec<String>,
800 canonical_out: PathBuf,
804}
805
806fn plan_out_dir(out_dir: &Path) -> anyhow::Result<OutDirPlan> {
813 let normalized = absolute_lexical(out_dir)?;
814 let mut anchor = normalized.as_path();
815 loop {
816 match std::fs::symlink_metadata(anchor) {
817 Ok(metadata) => {
818 let file_type = metadata.file_type();
819 if file_type.is_symlink() {
820 return Err(EngineError::InvalidState(format!(
821 "bundle output {} resolves through a symlinked component: {}",
822 out_dir.display(),
823 anchor.display()
824 ))
825 .into());
826 }
827 if !file_type.is_dir() {
828 return Err(EngineError::InvalidState(format!(
829 "bundle output {} is blocked by a non-directory component: {}",
830 out_dir.display(),
831 anchor.display()
832 ))
833 .into());
834 }
835 break;
836 }
837 Err(error) if error.kind() == ErrorKind::NotFound => {
838 anchor = anchor.parent().ok_or_else(|| {
839 EngineError::InvalidState(format!(
840 "bundle output {} has no existing ancestor",
841 out_dir.display()
842 ))
843 })?;
844 }
845 Err(error) => return Err(error.into()),
846 }
847 }
848 let canonical_anchor = anchor.canonicalize()?;
849 let mut tail = Vec::new();
850 let mut canonical_out = canonical_anchor.clone();
851 for component in normalized
854 .strip_prefix(anchor)
855 .map_err(|_| {
856 EngineError::InvalidState(format!(
857 "bundle output {} escaped its anchor",
858 out_dir.display()
859 ))
860 })?
861 .components()
862 {
863 let Component::Normal(name) = component else {
864 return Err(EngineError::InvalidState(format!(
865 "bundle output {} has a non-normal component below its anchor",
866 out_dir.display()
867 ))
868 .into());
869 };
870 let name = name.to_str().ok_or_else(|| {
871 EngineError::InvalidState(format!(
872 "bundle output {} has a non-UTF-8 component",
873 out_dir.display()
874 ))
875 })?;
876 tail.push(name.to_string());
877 canonical_out.push(name);
878 }
879 Ok(OutDirPlan {
880 anchor: canonical_anchor,
881 tail,
882 canonical_out,
883 })
884}
885
886fn pin_out_dir(plan: &OutDirPlan) -> anyhow::Result<Dir> {
893 let mut dir = Dir::open_ambient_dir(&plan.anchor, ambient_authority())?;
894 let mut walked = plan.anchor.clone();
895 for component in &plan.tail {
896 walked.push(component);
897 dir = crate::paths::open_real_subdir(&dir, component, &walked, true)?;
898 }
899 Ok(dir)
900}
901
902fn write_bundle_files(bundle: &EvidenceBundle, out_dir: &Path, out: &Dir) -> anyhow::Result<usize> {
912 let mut entries = out.entries().map_err(|error| {
913 EngineError::InvalidState(format!(
914 "bundle output {} is not an empty directory: {error}",
915 out_dir.display()
916 ))
917 })?;
918 if entries.next().is_some() {
919 return Err(EngineError::InvalidState(format!(
920 "bundle output {} is not empty; choose a fresh --out or remove it",
921 out_dir.display()
922 ))
923 .into());
924 }
925
926 let manifest_bytes = to_json_bytes(&bundle.manifest)?;
927 let mut written = 0usize;
928 for (relative, bytes) in bundle
931 .files
932 .iter()
933 .map(|file| (file.path.as_str(), file.bytes.as_slice()))
934 .chain([(MANIFEST_FILE, manifest_bytes.as_slice())])
935 {
936 let mut names = Vec::new();
937 for component in relative.split('/') {
938 if component.is_empty() || component == "." || component == ".." {
939 return Err(
940 EngineError::InvalidState(format!("unsafe bundle path {relative:?}")).into(),
941 );
942 }
943 names.push(component);
944 }
945 let (leaf, parents) = names.split_last().expect("validated non-empty");
946 let mut dir = None;
947 let mut display = out_dir.to_path_buf();
948 for parent in parents {
949 display.push(parent);
950 dir = Some(crate::paths::open_real_subdir(
951 dir.as_ref().unwrap_or(out),
952 parent,
953 &display,
954 true,
955 )?);
956 }
957 let mut options = OpenOptions::new();
958 options
959 .write(true)
960 .create_new(true)
961 .follow(FollowSymlinks::No);
962 let mut file = dir.as_ref().unwrap_or(out).open_with(leaf, &options)?;
963 file.write_all(bytes)?;
964 written += 1;
965 }
966 Ok(written)
967}
968
969pub fn write_evidence_bundle(bundle: &EvidenceBundle, out_dir: &Path) -> anyhow::Result<usize> {
977 let plan = plan_out_dir(out_dir)?;
978 let out = pin_out_dir(&plan)?;
979 write_bundle_files(bundle, out_dir, &out)
980}
981
982pub fn export_evidence_bundle(
997 repo_root: &Path,
998 mission_id: &str,
999 out_dir: &Path,
1000) -> anyhow::Result<ExportOutcome> {
1001 let paths = MissionPaths::new(repo_root, mission_id);
1002 paths.require_no_follow()?;
1003 let refusal = || {
1004 EngineError::InvalidState(format!(
1005 "bundle output {} must be outside the mission dir {}",
1006 out_dir.display(),
1007 paths.mission_dir().display()
1008 ))
1009 };
1010 let out_lexical = absolute_lexical(out_dir)?;
1013 let mission_lexical = absolute_lexical(&paths.mission_dir())?;
1014 if out_lexical.starts_with(&mission_lexical) {
1015 return Err(refusal().into());
1016 }
1017 let plan = plan_out_dir(out_dir)?;
1024 match std::fs::symlink_metadata(paths.mission_dir()) {
1025 Ok(_) => {
1026 if plan
1027 .canonical_out
1028 .starts_with(paths.mission_dir().canonicalize()?)
1029 {
1030 return Err(refusal().into());
1031 }
1032 }
1033 Err(error) if error.kind() == ErrorKind::NotFound => {}
1034 Err(error) => return Err(error.into()),
1035 }
1036
1037 let bundle = assemble_evidence_bundle(repo_root, mission_id)?;
1038 let out = pin_out_dir(&plan)?;
1039 let files_written = write_bundle_files(&bundle, out_dir, &out)?;
1040 let resolved_artefacts = bundle
1041 .manifest
1042 .entries
1043 .iter()
1044 .filter(|entry| entry.status == Some(ArtefactStatus::Resolved))
1045 .count();
1046 let unresolved_artefacts = bundle
1047 .manifest
1048 .entries
1049 .iter()
1050 .filter(|entry| entry.status == Some(ArtefactStatus::Unresolved))
1051 .count();
1052 Ok(ExportOutcome {
1053 out_dir: out_dir.to_path_buf(),
1054 files_written,
1055 resolved_artefacts,
1056 unresolved_artefacts,
1057 })
1058}
1059
1060#[cfg(test)]
1061mod tests {
1062 use super::*;
1063 use crate::event_log::{EventLog, LockForce};
1064 use crate::events::EventKind;
1065 use crate::gate::{GateKind, GateSurface, GateVerdict};
1066 use crate::types::{GrantKind, MissionConfig, Plan, Role, RunResult, TokenUsage};
1067 use std::collections::BTreeMap;
1068 use std::time::Duration;
1069 use tempfile::TempDir;
1070
1071 fn seed_mission(repo_root: &Path, id: &str, kinds: Vec<EventKind>) -> MissionPaths {
1075 let paths = MissionPaths::new(repo_root, id);
1076 let mut log = EventLog::acquire(&paths, id, Duration::ZERO, LockForce::No).unwrap();
1077 for kind in kinds {
1078 log.append(kind).unwrap();
1079 }
1080 paths
1081 }
1082
1083 fn sample_plan() -> Plan {
1084 Plan {
1085 goal: "ship the thing".into(),
1086 validation_contract: vec![],
1087 milestones: vec![],
1088 considered_alternatives: None,
1089 command_grants: vec![],
1090 touch_set: vec![],
1091 standards_manifest: None,
1092 reviewer_independence: None,
1093 }
1094 }
1095
1096 fn created() -> EventKind {
1097 EventKind::MissionCreated {
1098 goal: "ship the thing".into(),
1099 base_branch: "main".into(),
1100 mission_branch: "kranz/mission-x".into(),
1101 config: MissionConfig::default(),
1102 }
1103 }
1104
1105 fn gate_result(
1106 gate: &str,
1107 surface: GateSurface,
1108 kind: GateKind,
1109 index: u32,
1110 artefact_ref: &str,
1111 ) -> EventKind {
1112 EventKind::GateResult {
1113 gate: gate.to_string(),
1114 surface,
1115 kind,
1116 index,
1117 verdict: GateVerdict::Pass,
1118 artefact_ref: artefact_ref.to_string(),
1119 artefact_detail: None,
1120 score: None,
1121 threshold: None,
1122 rule_ids: Vec::new(),
1123 }
1124 }
1125
1126 fn worker_spawned(run_id: &str, role: Role, model: &str) -> EventKind {
1127 EventKind::WorkerSpawned {
1128 backend: None,
1129 run_id: run_id.to_string(),
1130 role,
1131 feature_id: None,
1132 milestone_id: None,
1133 candidate: None,
1134 executor_route: None,
1135 sdk_session_id: format!("sess-{run_id}"),
1136 model: model.to_string(),
1137 quant: "n/a".to_string(),
1138 weight_hash: None,
1139 prompt_hash: "aaaabbbbcccc".to_string(),
1140 transcript_path: MissionPaths::transcript_rel(run_id),
1141 }
1142 }
1143
1144 fn seed_full_mission(root: &Path) -> MissionPaths {
1152 let paths = seed_mission(
1153 root,
1154 "m-1",
1155 vec![
1156 created(),
1157 EventKind::PlanApproved {
1158 plan: sample_plan(),
1159 base_sha: Some("deadbeef".to_string()),
1160 },
1161 gate_result(
1162 "vacuous-filter",
1163 GateSurface::Approval,
1164 GateKind::Deterministic,
1165 0,
1166 "contract gate vacuous-filter",
1167 ),
1168 gate_result(
1169 "merge-gate-suite",
1170 GateSurface::Approval,
1171 GateKind::Deterministic,
1172 1,
1173 "file:runs/gate-base.jsonl",
1174 ),
1175 gate_result(
1176 "merge-gate-suite-recheck",
1177 GateSurface::Approval,
1178 GateKind::Deterministic,
1179 2,
1180 "file:runs/gate-base.jsonl",
1183 ),
1184 gate_result(
1185 "plan-review",
1186 GateSurface::Approval,
1187 GateKind::ModelJudged,
1188 0,
1189 "file:runs/gone.jsonl",
1190 ),
1191 worker_spawned("r-1", Role::Worker, "gpt-5"),
1192 EventKind::WorkerCompleted {
1193 run_id: "r-1".into(),
1194 result: RunResult::Pass,
1195 tokens: TokenUsage {
1196 input: 100,
1197 output: 50,
1198 cache_read: 0,
1199 cache_write: 0,
1200 },
1201 cost_usd: Some(0.42),
1202 report: None,
1203 },
1204 EventKind::FeatureCompleted {
1205 feature_id: "f-1-1".into(),
1206 commits: vec!["abc1234 implement the widget".into()],
1207 },
1208 EventKind::GrantRequested {
1209 milestone_id: "ms-1".into(),
1210 kind: GrantKind::Command,
1211 command: "cargo test".into(),
1212 },
1213 EventKind::GrantApproved {
1214 kind: GrantKind::Command,
1215 command: "cargo test".into(),
1216 },
1217 worker_spawned("r-2", Role::Worker, "my-local-model"),
1218 worker_spawned("r-3", Role::ValidatorScrutiny, "sonnet"),
1219 EventKind::MilestoneBlocked {
1220 block_context: None,
1221 milestone_id: "ms-1".into(),
1222 reason: "fix-cycle cap".into(),
1223 },
1224 EventKind::MilestoneUnblocked {
1225 block_context: None,
1226 milestone_id: "ms-1".into(),
1227 reason: "user skipped findings".into(),
1228 validator_guidance: None,
1229 },
1230 EventKind::UserMessage {
1231 text: "skip the flaky test".into(),
1232 interrupt: false,
1233 },
1234 gate_result(
1235 "merge-gate-suite",
1236 GateSurface::FinalGate,
1237 GateKind::Deterministic,
1238 0,
1239 ".kranz/merge-gates.json",
1240 ),
1241 EventKind::MissionCompleted {},
1242 ],
1243 );
1244 std::fs::write(paths.runs_dir().join("gate-base.jsonl"), b"{}").unwrap();
1246 std::fs::write(paths.runs_dir().join("r-1.jsonl"), b"{}").unwrap();
1247 std::fs::write(paths.plan_md_file(), b"# plan\n").unwrap();
1248 std::fs::write(paths.plan_file(), b"{}").unwrap();
1249 std::fs::write(paths.report_file(), b"# report\n").unwrap();
1250 paths
1251 }
1252
1253 fn collect_files(dir: &Path) -> BTreeMap<String, Vec<u8>> {
1257 let mut out = BTreeMap::new();
1258 let mut stack = vec![dir.to_path_buf()];
1259 while let Some(current) = stack.pop() {
1260 for entry in std::fs::read_dir(¤t).unwrap() {
1261 let path = entry.unwrap().path();
1262 if path.is_dir() {
1263 stack.push(path);
1264 } else {
1265 let relative = path
1266 .strip_prefix(dir)
1267 .unwrap()
1268 .components()
1269 .map(|c| c.as_os_str().to_str().unwrap().to_string())
1270 .collect::<Vec<_>>()
1271 .join("/");
1272 out.insert(relative, std::fs::read(&path).unwrap());
1273 }
1274 }
1275 }
1276 out
1277 }
1278
1279 fn manifest_entry<'m>(manifest: &'m EvidenceManifest, source: &str) -> &'m ManifestEntry {
1280 manifest
1281 .entries
1282 .iter()
1283 .find(|entry| entry.source == source)
1284 .unwrap_or_else(|| panic!("manifest entry {source} missing"))
1285 }
1286
1287 #[test]
1294 fn evidence_bundle_opens_standalone_with_no_host_paths() {
1295 let tmp = TempDir::new().unwrap();
1296 seed_full_mission(tmp.path());
1297 let out = tmp.path().join("bundle-out");
1298 let outcome = export_evidence_bundle(tmp.path(), "m-1", &out).unwrap();
1299
1300 for name in [
1301 MANIFEST_FILE,
1302 SUMMARY_FILE,
1303 CHAIN_FILE,
1304 ESCALATIONS_FILE,
1305 COST_FILE,
1306 LOG_FILE,
1307 ] {
1308 assert!(out.join(name).is_file(), "{name} missing from the bundle");
1309 }
1310 for shipped in [
1311 "artefacts/runs/gate-base.jsonl",
1312 "artefacts/runs/r-1.jsonl",
1313 "artefacts/plan.md",
1314 "artefacts/plan.json",
1315 "artefacts/report.md",
1316 ] {
1317 assert!(
1318 out.join(shipped).is_file(),
1319 "{shipped} missing from artefacts/"
1320 );
1321 }
1322 assert_eq!(outcome.files_written, 11);
1325 assert_eq!(outcome.resolved_artefacts, 5);
1326 assert_eq!(outcome.unresolved_artefacts, 5);
1327
1328 let host = tmp.path().to_string_lossy().to_string();
1331 let files = collect_files(&out);
1332 for (relative, bytes) in &files {
1333 let text = String::from_utf8_lossy(bytes);
1334 assert!(
1335 !text.contains(&host),
1336 "host path leaked into bundle file {relative}"
1337 );
1338 }
1339
1340 let manifest: EvidenceManifest =
1343 serde_json::from_str(&std::fs::read_to_string(out.join(MANIFEST_FILE)).unwrap())
1344 .unwrap();
1345 assert_eq!(manifest.version, BUNDLE_FORMAT_VERSION);
1346 assert_eq!(manifest.mission_id, "m-1");
1347 for entry in &manifest.entries {
1348 if let (Some(path), Some(sha256)) = (&entry.path, &entry.sha256) {
1349 let bytes = std::fs::read(out.join(path)).unwrap();
1350 assert_eq!(&sha256_hex(&bytes), sha256, "sha256 mismatch for {path}");
1351 }
1352 }
1353 assert_eq!(
1355 manifest
1356 .entries
1357 .iter()
1358 .filter(|entry| entry.source == "file:runs/gate-base.jsonl")
1359 .count(),
1360 1
1361 );
1362 assert!(manifest
1364 .entries
1365 .iter()
1366 .all(|entry| entry.source != "contract gate vacuous-filter"));
1367 let gone = manifest_entry(&manifest, "file:runs/gone.jsonl");
1370 assert_eq!(gone.status, Some(ArtefactStatus::Unresolved));
1371 assert!(gone.path.is_none() && gone.sha256.is_none());
1372 let chain: ProvenanceChain =
1374 serde_json::from_str(&std::fs::read_to_string(out.join(CHAIN_FILE)).unwrap()).unwrap();
1375 assert_eq!(chain.gates.len(), 5);
1376 let cost: MissionCostSummary =
1378 serde_json::from_str(&std::fs::read_to_string(out.join(COST_FILE)).unwrap()).unwrap();
1379 assert_eq!(cost.total_cost_usd, 0.42);
1380 assert_eq!(cost.non_meta_commits, 1);
1381 assert_eq!(cost.usd_per_commit, Some(0.42));
1382 assert!(cost.closed);
1383 }
1384
1385 #[test]
1389 fn evidence_bundle_is_byte_identical_across_exports() {
1390 let tmp = TempDir::new().unwrap();
1391 seed_full_mission(tmp.path());
1392
1393 let first = assemble_evidence_bundle(tmp.path(), "m-1").unwrap();
1394 let second = assemble_evidence_bundle(tmp.path(), "m-1").unwrap();
1395 assert_eq!(first, second);
1396 assert_eq!(
1397 serde_json::to_string_pretty(&first.manifest).unwrap(),
1398 serde_json::to_string_pretty(&second.manifest).unwrap()
1399 );
1400
1401 let out_a = tmp.path().join("out-a");
1402 let out_b = tmp.path().join("out-b");
1403 export_evidence_bundle(tmp.path(), "m-1", &out_a).unwrap();
1404 export_evidence_bundle(tmp.path(), "m-1", &out_b).unwrap();
1405 assert_eq!(collect_files(&out_a), collect_files(&out_b));
1406 }
1407
1408 #[test]
1413 fn evidence_bundle_redacted_secret_leaves_fingerprints_only() {
1414 let tmp = TempDir::new().unwrap();
1415 let secret = "sk-ant-F00barBazQuux9_7";
1416 let text = format!("the key is {secret} ok");
1417 let findings = crate::scrub::scan_text(&text);
1419 assert_eq!(findings.len(), 1, "fixture must trip exactly one rule");
1420 let fingerprint = findings[0].fingerprint.clone();
1421
1422 seed_mission(
1423 tmp.path(),
1424 "m-sec",
1425 vec![
1426 created(),
1427 EventKind::UserMessage {
1428 text,
1429 interrupt: false,
1430 },
1431 EventKind::MissionCompleted {},
1432 ],
1433 );
1434
1435 let out = tmp.path().join("bundle-sec");
1436 export_evidence_bundle(tmp.path(), "m-sec", &out).unwrap();
1437 let files = collect_files(&out);
1438 assert!(!files.is_empty());
1439 for (relative, bytes) in &files {
1440 let text = String::from_utf8_lossy(bytes);
1441 assert!(
1442 !text.contains(secret),
1443 "secret value leaked into bundle file {relative}"
1444 );
1445 }
1446 let log = String::from_utf8_lossy(&files[LOG_FILE]).to_string();
1449 assert!(log.contains(&fingerprint), "audit fingerprint missing");
1450 assert!(log.contains("[REDACTED]"));
1451 }
1452
1453 #[test]
1460 fn evidence_bundle_scrubs_artefact_bytes_and_hashes_the_redacted_form() {
1461 let tmp = TempDir::new().unwrap();
1462 let secret = "sk-ant-F00barBazQuux9_7";
1463 let paths = seed_full_mission(tmp.path());
1464 let planted = format!("{{\"text\":\"the key is {secret} ok\"}}\n");
1467 std::fs::write(paths.runs_dir().join("r-1.jsonl"), planted.as_bytes()).unwrap();
1468
1469 let out = tmp.path().join("bundle-artefact-secret");
1470 export_evidence_bundle(tmp.path(), "m-1", &out).unwrap();
1471 let files = collect_files(&out);
1472 for (relative, bytes) in &files {
1473 let text = String::from_utf8_lossy(bytes);
1474 assert!(
1475 !text.contains(secret),
1476 "secret value leaked into bundle file {relative}"
1477 );
1478 }
1479 let shipped = &files["artefacts/runs/r-1.jsonl"];
1480 assert!(String::from_utf8_lossy(shipped).contains("[REDACTED]"));
1481
1482 let manifest: EvidenceManifest =
1484 serde_json::from_str(&std::fs::read_to_string(out.join(MANIFEST_FILE)).unwrap())
1485 .unwrap();
1486 let entry = manifest_entry(&manifest, "file:runs/r-1.jsonl");
1487 assert_eq!(entry.sha256.as_deref(), Some(sha256_hex(shipped).as_str()));
1488 }
1489
1490 #[test]
1494 fn evidence_bundle_scrubs_non_utf8_artefact_bytes_lossily() {
1495 let tmp = TempDir::new().unwrap();
1496 let secret = "sk-ant-F00barBazQuux9_7";
1497 let paths = seed_full_mission(tmp.path());
1498 let mut planted = format!("the key is {secret} ok").into_bytes();
1499 planted.push(0xff);
1500 std::fs::write(paths.runs_dir().join("r-1.jsonl"), &planted).unwrap();
1501
1502 let bundle = assemble_evidence_bundle(tmp.path(), "m-1").unwrap();
1503 let shipped = bundle
1504 .files
1505 .iter()
1506 .find(|file| file.path == "artefacts/runs/r-1.jsonl")
1507 .expect("artefact shipped");
1508 let text = String::from_utf8(shipped.bytes.clone()).expect("lossy decode yields UTF-8");
1509 assert!(!text.contains(secret));
1510 assert!(text.contains("[REDACTED]"));
1511 assert!(
1512 text.contains('\u{fffd}'),
1513 "invalid byte became a replacement"
1514 );
1515 }
1516
1517 #[test]
1521 fn evidence_bundle_missing_artefact_bytes_become_unresolved_manifest_entries() {
1522 let tmp = TempDir::new().unwrap();
1523 let paths = seed_full_mission(tmp.path());
1524 std::fs::remove_dir_all(paths.runs_dir()).unwrap();
1525
1526 let bundle = assemble_evidence_bundle(tmp.path(), "m-1").unwrap();
1527 for source in [
1528 "file:runs/gate-base.jsonl",
1529 "file:runs/gone.jsonl",
1530 "file:runs/r-1.jsonl",
1531 "file:runs/r-2.jsonl",
1532 "file:runs/r-3.jsonl",
1533 "file:research.md",
1534 "file:estimate.json",
1535 ] {
1536 let entry = manifest_entry(&bundle.manifest, source);
1537 assert_eq!(
1538 entry.status,
1539 Some(ArtefactStatus::Unresolved),
1540 "{source} must be unresolved with its bytes gone"
1541 );
1542 assert!(entry.path.is_none() && entry.sha256.is_none());
1543 }
1544 for source in ["file:plan.md", "file:plan.json", "file:report.md"] {
1546 assert_eq!(
1547 manifest_entry(&bundle.manifest, source).status,
1548 Some(ArtefactStatus::Resolved),
1549 "{source} must still resolve"
1550 );
1551 }
1552 assert!(bundle
1554 .files
1555 .iter()
1556 .all(|file| !file.path.starts_with("artefacts/runs/")));
1557 }
1558
1559 #[test]
1563 fn evidence_bundle_refuses_out_dir_inside_the_mission_dir() {
1564 let tmp = TempDir::new().unwrap();
1565 let paths = seed_full_mission(tmp.path());
1566 let inside = paths.mission_dir().join("bundle");
1567 let result = export_evidence_bundle(tmp.path(), "m-1", &inside);
1568 assert!(result.is_err(), "an in-mission --out must be refused");
1569 assert!(!inside.exists(), "nothing must be written on refusal");
1570 }
1571
1572 #[test]
1575 fn evidence_bundle_refuses_a_non_empty_out_dir() {
1576 let tmp = TempDir::new().unwrap();
1577 seed_full_mission(tmp.path());
1578 let out = tmp.path().join("bundle-used");
1579 std::fs::create_dir_all(&out).unwrap();
1580 std::fs::write(out.join("stale.txt"), b"stale").unwrap();
1581 let result = export_evidence_bundle(tmp.path(), "m-1", &out);
1582 assert!(result.is_err(), "a non-empty --out must be refused");
1583 assert_eq!(
1584 std::fs::read_to_string(out.join("stale.txt")).unwrap(),
1585 "stale"
1586 );
1587 }
1588
1589 #[test]
1596 fn evidence_single_snapshot_torn_tail_is_excluded_from_parse_and_bytes() {
1597 use std::io::Write as _;
1598 let tmp = TempDir::new().unwrap();
1599 let paths = seed_full_mission(tmp.path());
1600 let pristine = std::fs::read(paths.events_file()).unwrap();
1601 let mut file = std::fs::OpenOptions::new()
1604 .append(true)
1605 .open(paths.events_file())
1606 .unwrap();
1607 file.write_all(b"{\"seq\":999,\"ts\":\"torn").unwrap();
1608 drop(file);
1609
1610 let bundle = assemble_evidence_bundle(tmp.path(), "m-1").unwrap();
1611 let shipped = bundle
1612 .files
1613 .iter()
1614 .find(|file| file.path == LOG_FILE)
1615 .expect("the raw log ships");
1616 assert_eq!(
1617 shipped.bytes, pristine,
1618 "the torn tail is in NEITHER the events nor the shipped bytes"
1619 );
1620 assert_eq!(bundle.manifest.mission_id, "m-1");
1622 let replay = paths.runs_dir().join("replay.jsonl");
1626 std::fs::write(&replay, &shipped.bytes).unwrap();
1627 let folded = crate::event_log::EventLog::read_events(&paths.events_file()).unwrap();
1628 let refolded = crate::event_log::EventLog::read_events(&replay).unwrap();
1629 assert_eq!(refolded.len(), folded.len());
1630 assert_eq!(
1631 refolded.last().map(|event| event.seq),
1632 folded.last().map(|event| event.seq)
1633 );
1634 }
1635
1636 #[test]
1643 fn evidence_outdir_containment_refuses_dotdot_escape_into_the_mission() {
1644 let tmp = TempDir::new().unwrap();
1645 let paths = seed_full_mission(tmp.path());
1646 let escape = tmp
1647 .path()
1648 .join("outside")
1649 .join("..")
1650 .join(".kranz")
1651 .join("missions")
1652 .join("m-1")
1653 .join("bundle");
1654 let result = export_evidence_bundle(tmp.path(), "m-1", &escape);
1655 assert!(result.is_err(), "the `..` shape must be refused");
1656 assert!(
1657 !paths.mission_dir().join("bundle").exists(),
1658 "nothing must be written on refusal"
1659 );
1660 }
1661
1662 #[cfg(unix)]
1667 #[test]
1668 fn evidence_outdir_containment_refuses_a_symlinked_component() {
1669 use std::os::unix::fs::symlink;
1670 let tmp = TempDir::new().unwrap();
1671 let paths = seed_full_mission(tmp.path());
1672 let link = tmp.path().join("linked-out");
1673 symlink(paths.mission_dir(), &link).unwrap();
1674 let result = export_evidence_bundle(tmp.path(), "m-1", &link.join("bundle"));
1675 let err = result.expect_err("a symlinked out-dir component must be refused");
1676 assert!(err.to_string().contains("symlinked"), "{err}");
1677 assert!(
1678 !paths.mission_dir().join("bundle").exists(),
1679 "nothing must be written through the link"
1680 );
1681 }
1682
1683 #[test]
1686 fn evidence_outdir_containment_normal_external_dir_works() {
1687 let tmp = TempDir::new().unwrap();
1688 seed_full_mission(tmp.path());
1689 let out = tmp.path().join("fresh").join("bundle-out");
1690 let outcome = export_evidence_bundle(tmp.path(), "m-1", &out).unwrap();
1691 assert!(outcome.files_written > 0);
1692 assert!(out.join(MANIFEST_FILE).is_file());
1693 assert!(out.join(LOG_FILE).is_file());
1694 }
1695
1696 fn pinned_plan() -> Plan {
1702 let rule = |id: &str, revision: u64, status: &str| crate::types::PinnedRule {
1703 id: id.to_string(),
1704 revision,
1705 rfc: "RFC-001".to_string(),
1706 level: "must".to_string(),
1707 effective_status: status.to_string(),
1708 statement: format!("statement for {id}"),
1709 domains: Vec::new(),
1710 stages: vec!["validation".to_string()],
1711 when_paths: Vec::new(),
1712 task_classes: Vec::new(),
1713 checker: Some("gate:zz-gate".to_string()),
1714 waivable: false,
1715 };
1716 Plan {
1717 standards_manifest: Some(Box::new(crate::types::StandardsPin {
1718 pack_name: "zz-pack".to_string(),
1719 pack_dir: "vendor/pack".to_string(),
1720 standards_root: "standards".to_string(),
1721 digest: "ab".repeat(32),
1722 source: crate::types::StandardsPinSource::RepoTracked,
1723 task_class: None,
1724 touch_set: vec!["crates/**".to_string()],
1725 context_paths: Vec::new(),
1726 gates: Vec::new(),
1727 rules: vec![
1728 rule("ZZ-FAIL-001", 2, "enforced"),
1729 rule("ZZ-PASS-001", 1, "enforced"),
1730 rule("ZZ-QUIET-001", 1, "enforced"),
1731 ],
1732 })),
1733 ..sample_plan()
1734 }
1735 }
1736
1737 fn seed_pinned_mission(root: &Path) -> MissionPaths {
1742 let mut gate = gate_result(
1743 "zz-gate",
1744 GateSurface::FinalGate,
1745 GateKind::Deterministic,
1746 0,
1747 "file:runs/gone.jsonl",
1748 );
1749 if let EventKind::GateResult { rule_ids, .. } = &mut gate {
1750 *rule_ids = vec!["ZZ-PASS-001".to_string()];
1751 }
1752 seed_mission(
1753 root,
1754 "m-1",
1755 vec![
1756 created(),
1757 EventKind::PlanApproved {
1758 plan: pinned_plan(),
1759 base_sha: Some("deadbeef".to_string()),
1760 },
1761 EventKind::StandardsResolved {
1762 source: "repo-tracked".to_string(),
1763 pack_name: "zz-pack".to_string(),
1764 standards_root: "standards".to_string(),
1765 digest: "ab".repeat(32),
1766 stage: "approval".to_string(),
1767 task_class: None,
1768 touch_set: vec!["crates/**".to_string()],
1769 context_paths: Vec::new(),
1770 rules: Vec::new(),
1771 approval_seq: 2,
1772 },
1773 gate,
1774 EventKind::ValidationFinding {
1775 milestone_id: "ms-1".into(),
1776 run_id: "v-1".into(),
1777 finding: crate::types::Finding {
1778 subject: "a-1".into(),
1779 severity: "major".into(),
1780 evidence: "the rule failed".into(),
1781 suggested_fix: String::new(),
1782 class: String::new(),
1783 rule: Some(crate::types::RuleCitation {
1784 id: "ZZ-FAIL-001".to_string(),
1785 revision: 2,
1786 source: "zz-pack standards".to_string(),
1787 digest: "ab".repeat(32),
1788 lifecycle: "enforced".to_string(),
1789 level: "must".to_string(),
1790 checker: Some("gate:zz-gate".to_string()),
1791 }),
1792 },
1793 },
1794 EventKind::MissionCompleted {},
1795 ],
1796 )
1797 }
1798
1799 #[test]
1804 fn flight_rules_provenance_bundle_renders_coverage_byte_identically() {
1805 let tmp = TempDir::new().unwrap();
1806 seed_pinned_mission(tmp.path());
1807 let first = assemble_evidence_bundle(tmp.path(), "m-1").unwrap();
1808 let second = assemble_evidence_bundle(tmp.path(), "m-1").unwrap();
1809 assert_eq!(first, second, "same log → byte-identical bundle");
1810
1811 let summary = first
1812 .files
1813 .iter()
1814 .find(|file| file.path == SUMMARY_FILE)
1815 .expect("summary ships");
1816 let summary = String::from_utf8(summary.bytes.clone()).unwrap();
1817 assert!(
1818 summary.contains("## Flight Rules standards coverage"),
1819 "{summary}"
1820 );
1821 assert!(
1822 summary.contains("| ZZ-FAIL-001 | r2 | enforced | must | gate:zz-gate | failed |"),
1823 "{summary}"
1824 );
1825 assert!(
1826 summary.contains("| ZZ-PASS-001 | r1 | enforced | must | gate:zz-gate | passed |"),
1827 "{summary}"
1828 );
1829 assert!(
1830 summary
1831 .contains("| ZZ-QUIET-001 | r1 | enforced | must | gate:zz-gate | not-evaluated |"),
1832 "{summary}"
1833 );
1834 assert!(
1836 summary.contains("gate.result seq 4 zz-gate pass `file:runs/gone.jsonl`"),
1837 "{summary}"
1838 );
1839
1840 let chain = first
1841 .files
1842 .iter()
1843 .find(|file| file.path == CHAIN_FILE)
1844 .expect("the chain ships");
1845 let chain = String::from_utf8(chain.bytes.clone()).unwrap();
1846 assert!(chain.contains("\"standards\""), "{chain}");
1847 assert!(
1848 chain.contains("\"disposition\": \"not-evaluated\""),
1849 "{chain}"
1850 );
1851 }
1852
1853 #[test]
1858 fn flight_rules_provenance_bundle_removed_artefacts_stay_unresolved() {
1859 let tmp = TempDir::new().unwrap();
1860 seed_pinned_mission(tmp.path());
1861 let bundle = assemble_evidence_bundle(tmp.path(), "m-1").unwrap();
1863 let entry = manifest_entry(&bundle.manifest, "file:runs/gone.jsonl");
1864 assert_eq!(entry.status, Some(ArtefactStatus::Unresolved));
1865 assert_eq!(entry.path, None, "an unresolved entry has no bytes path");
1866 let summary = bundle
1870 .files
1871 .iter()
1872 .find(|file| file.path == SUMMARY_FILE)
1873 .expect("summary ships");
1874 let summary = String::from_utf8(summary.bytes.clone()).unwrap();
1875 assert!(summary.contains("`file:runs/gone.jsonl`"), "{summary}");
1876 assert!(
1877 summary.contains("Absence of evidence is never rendered as pass"),
1878 "{summary}"
1879 );
1880 }
1881
1882 #[test]
1887 fn flight_rules_provenance_bundle_pre_flight_rules_mission_is_unchanged() {
1888 let tmp = TempDir::new().unwrap();
1889 seed_full_mission(tmp.path());
1890 let bundle = assemble_evidence_bundle(tmp.path(), "m-1").unwrap();
1891 let summary = bundle
1892 .files
1893 .iter()
1894 .find(|file| file.path == SUMMARY_FILE)
1895 .expect("summary ships");
1896 let summary = String::from_utf8(summary.bytes.clone()).unwrap();
1897 assert!(
1898 !summary.contains("Flight Rules standards coverage"),
1899 "no pin, no matrix: {summary}"
1900 );
1901 let chain = bundle
1902 .files
1903 .iter()
1904 .find(|file| file.path == CHAIN_FILE)
1905 .expect("the chain ships");
1906 let chain = String::from_utf8(chain.bytes.clone()).unwrap();
1907 assert!(
1908 !chain.contains("\"standards\""),
1909 "a pre-Flight-Rules chain carries no standards key: {chain}"
1910 );
1911 }
1912}