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_artefact(mission_dir: &Path, reference: &str) -> (ArtefactStatus, Option<Vec<u8>>) {
278 let ArtefactResolution::Resolved { path } = resolve_artefact(mission_dir, reference) else {
279 return (ArtefactStatus::Unresolved, None);
280 };
281 let read = crate::paths::open_read_nofollow(&path).and_then(|mut file| {
282 let mut bytes = Vec::new();
283 file.read_to_end(&mut bytes)?;
284 Ok(bytes)
285 });
286 match read {
287 Ok(bytes) => {
288 let scrubbed = crate::scrub::scrub(&String::from_utf8_lossy(&bytes));
289 (ArtefactStatus::Resolved, Some(scrubbed.into_bytes()))
290 }
291 Err(_) => (ArtefactStatus::Unresolved, None),
292 }
293}
294
295fn wire_name<T: Serialize>(value: &T) -> String {
300 serde_json::to_value(value)
301 .ok()
302 .and_then(|v| v.as_str().map(str::to_string))
303 .expect("gate/role enums always serialize to a string")
304}
305
306fn one_line(text: &str) -> String {
309 text.split_whitespace().collect::<Vec<_>>().join(" ")
310}
311
312fn md_cell(text: &str) -> String {
315 one_line(text).replace('|', "\\|")
316}
317
318fn format_duration_ms(ms: u64) -> String {
322 const S: u64 = 1_000;
323 const M: u64 = 60 * S;
324 const H: u64 = 60 * M;
325 const D: u64 = 24 * H;
326 if ms >= D {
327 format!("{:.1}d", ms as f64 / D as f64)
328 } else if ms >= H {
329 format!("{:.1}h", ms as f64 / H as f64)
330 } else if ms >= M {
331 format!("{}m", ms / M)
332 } else {
333 format!("{}s", ms / S)
334 }
335}
336
337fn render_summary(
341 chain: &ProvenanceChain,
342 cost: &MissionCostSummary,
343 escalations: &[crate::outcomes::EscalationRow],
344 artefact_entries: &[ManifestEntry],
345) -> String {
346 let mut out = String::new();
347 out.push_str(&format!(
348 "# Evidence bundle — mission {}\n\n",
349 chain.mission_id
350 ));
351 out.push_str(
352 "Portable audit package (KRZ-326). Everything below derives from the mission's\n\
353 append-only event log (`events.jsonl`, included verbatim — every line crossed\n\
354 the redact-at-write boundary when appended) plus the mission-relative artefact\n\
355 bytes under `artefacts/`. Artefacts ship as scrubbed text: they are redacted\n\
356 at export (not at write), and any byte that is not valid UTF-8 travels as the\n\
357 replacement character. References whose bytes were no longer on disk at\n\
358 export time are listed as `unresolved` in `manifest.json` — named, never\n\
359 silently omitted.\n\n",
360 );
361
362 out.push_str("## Mission\n\n");
363 match &chain.goal {
364 Some(goal) => out.push_str(&format!("- Goal: {}\n", one_line(goal))),
365 None => out.push_str("- Goal: (not recorded in the log)\n"),
366 }
367 if let (Some(mission_branch), Some(base_branch)) = (&chain.mission_branch, &chain.base_branch) {
368 let pinned = chain
369 .base_sha
370 .as_deref()
371 .map(|sha| format!(" @ {sha}"))
372 .unwrap_or_default();
373 out.push_str(&format!(
374 "- Branch: {mission_branch} (base {base_branch}{pinned})\n"
375 ));
376 }
377 match &chain.outcome {
378 Some(terminal) => {
379 let reason = terminal
380 .reason
381 .as_deref()
382 .map(|reason| format!(" — {}", one_line(reason)))
383 .unwrap_or_default();
384 out.push_str(&format!(
385 "- Outcome: {} at seq {}{}\n",
386 terminal.status.as_str(),
387 terminal.seq,
388 reason
389 ));
390 }
391 None => out.push_str("- Outcome: in flight (no terminal event recorded)\n"),
392 }
393 let usd_per_commit = cost
394 .usd_per_commit
395 .map(|usd| format!("${usd:.4}/commit"))
396 .unwrap_or_else(|| "n/a (no non-meta commits)".to_string());
397 out.push_str(&format!(
398 "- Cost: ${:.4} across {} non-meta commits ({})\n",
399 cost.total_cost_usd, cost.non_meta_commits, usd_per_commit
400 ));
401 let cycle = cost
402 .cycle_time_ms
403 .map(format_duration_ms)
404 .unwrap_or_else(|| "n/a (in flight)".to_string());
405 out.push_str(&format!(
406 "- Cycle time: {cycle} | Interventions: {} | Closed: {}\n\n",
407 cost.interventions,
408 if cost.closed { "yes" } else { "no" }
409 ));
410
411 out.push_str("## Gate ladder (log order)\n\n");
412 if chain.gates.is_empty() {
413 out.push_str("(no gate.result events recorded)\n\n");
414 } else {
415 out.push_str(
416 "| seq | surface | kind | # | gate | verdict | score | artefact | resolution |\n\
417 |----:|---------|------|--:|------|---------|-------|----------|------------|\n",
418 );
419 for gate in &chain.gates {
420 let score = match (gate.score, gate.threshold) {
421 (Some(score), Some(threshold)) => format!("{score}/{threshold}"),
422 _ => "—".to_string(),
423 };
424 out.push_str(&format!(
425 "| {} | {} | {} | {} | {} | {} | {} | `{}` | {} |\n",
426 gate.seq,
427 wire_name(&gate.surface),
428 wire_name(&gate.kind),
429 gate.index,
430 md_cell(&gate.gate),
431 wire_name(&gate.verdict),
432 score,
433 md_cell(&gate.artefact_ref),
434 gate.artefact.as_str(),
435 ));
436 }
437 out.push('\n');
438 }
439
440 if !chain.gate_evaluations.is_empty() {
441 out.push_str("## External gate decisions\n\n");
442 for record in &chain.gate_evaluations {
443 let request = &record.requested.request.params;
444 let status = if let Some(reason) = &record.closed {
445 format!("closed: {}", md_cell(reason))
446 } else {
447 match &record.resolution {
448 Some(resolution) => format!("{:?}", resolution.disposition),
449 None if record.finished.is_some() => "awaiting engine resolution".into(),
450 None => "interrupted or pending evaluation".into(),
451 }
452 };
453 out.push_str(&format!(
454 "- `{}` / {:?} / `{}`: {}; consumed={} (effect completion is separate).\n",
455 request.gate_id.as_str(),
456 request.stage,
457 request.attempt_id.as_str(),
458 status,
459 record.consumed.is_some()
460 ));
461 }
462 out.push('\n');
463 }
464
465 if let Some(coverage) = &chain.standards {
472 out.push_str(&crate::standards_coverage::render_coverage_markdown(
473 coverage,
474 ));
475 out.push('\n');
476 }
477
478 out.push_str("## Sessions (workers and reviewers)\n\n");
479 if chain.sessions.is_empty() {
480 out.push_str("(no sessions recorded)\n\n");
481 } else {
482 out.push_str(
483 "| seq | run | role | backend | model | prompt hash | transcript | resolution |\n\
484 |----:|-----|------|---------|-------|-------------|------------|------------|\n",
485 );
486 for session in &chain.sessions {
487 out.push_str(&format!(
488 "| {} | {} | {} | {} | {} | `{}` | `{}` | {} |\n",
489 session.seq,
490 md_cell(&session.run_id),
491 wire_name(&session.role),
492 session.backend.as_deref().unwrap_or("?"),
493 md_cell(&session.model),
494 session.prompt_hash,
495 md_cell(&session.transcript_ref),
496 session.transcript.as_str(),
497 ));
498 }
499 out.push('\n');
500 }
501
502 out.push_str("## Human decisions\n\n");
503 if chain.decisions.is_empty() {
504 out.push_str("(no human decisions recorded)\n\n");
505 } else {
506 for decision in &chain.decisions {
507 out.push_str(&format!(
508 "- [seq {}] {} — {}\n",
509 decision.seq,
510 decision.kind.as_str(),
511 one_line(&decision.summary)
512 ));
513 }
514 out.push('\n');
515 }
516
517 out.push_str("## Escalations\n\n");
518 if escalations.is_empty() {
519 out.push_str("(no escalations recorded)\n\n");
520 } else {
521 for row in escalations {
522 let latency = row
523 .latency_ms
524 .map(|ms| format!(" (latency {ms} ms)"))
525 .unwrap_or_default();
526 out.push_str(&format!(
527 "- [{}] {}: {} → {}{}\n",
528 row.ts.to_rfc3339(),
529 row.kind.as_str(),
530 one_line(&row.summary),
531 one_line(&row.decision),
532 latency,
533 ));
534 }
535 out.push('\n');
536 }
537
538 out.push_str("## Artefacts\n\n");
539 out.push_str(
540 "| bundle path | source | sha256 | status |\n\
541 |-------------|--------|--------|--------|\n",
542 );
543 for entry in artefact_entries {
544 let status = entry.status.map(|status| status.as_str()).unwrap_or("—");
545 out.push_str(&format!(
546 "| {} | `{}` | {} | {} |\n",
547 entry
548 .path
549 .as_deref()
550 .map(|path| format!("`{path}`"))
551 .unwrap_or_else(|| "—".to_string()),
552 md_cell(&entry.source),
553 entry.sha256.as_deref().unwrap_or("—"),
554 status,
555 ));
556 }
557 out.push('\n');
558 out.push_str(&format!(
559 "Regenerate with `kranz evidence-bundle {}`; the same event log always yields\n\
560 the same bundle bytes.\n",
561 chain.mission_id
562 ));
563 out
564}
565
566pub fn assemble_evidence_bundle(
576 repo_root: &Path,
577 mission_id: &str,
578) -> anyhow::Result<EvidenceBundle> {
579 let paths = MissionPaths::new(repo_root, mission_id);
580 paths.require_no_follow()?;
581 let mission_dir = paths.mission_dir();
582
583 let (events, log_bytes) =
593 crate::event_log::EventLog::read_events_and_log_bytes(&paths.events_file())?;
594
595 let chain = crate::provenance::provenance_chain(&mission_dir, mission_id, &events)?;
596 let outcomes: MissionOutcomes = crate::outcomes::mission_outcomes(mission_id, &events);
597 let cost = MissionCostSummary {
598 outcome_reasons: Some(outcomes.outcome_reasons.clone()),
599 total_cost_usd: outcomes.cost_usd,
600 non_meta_commits: outcomes.non_meta_commits,
601 usd_per_commit: (outcomes.non_meta_commits > 0)
602 .then(|| outcomes.cost_usd / outcomes.non_meta_commits as f64),
603 cycle_time_ms: outcomes.cycle_time_ms,
604 closed: outcomes.is_closed,
605 interventions: outcomes.interventions,
606 };
607
608 let mut references: Vec<String> = Vec::new();
615 let mut push_reference = |reference: String| {
616 if reference.starts_with(crate::gate_results::FILE_REF_SCHEME)
617 && !references.contains(&reference)
618 {
619 references.push(reference);
620 }
621 };
622 for gate in &chain.gates {
623 push_reference(gate.artefact_ref.clone());
624 }
625 let mut gate_expected = std::collections::BTreeMap::new();
626 let mut paired = std::collections::BTreeMap::new();
627 for gate in &chain.gates {
628 if gate.gate.starts_with("baseline-candidate:") {
629 let descriptor =
630 crate::contract_controls::pair::descriptor(gate.artefact_detail.as_deref());
631 let expected = descriptor.as_ref().map(|d| (d.digest.clone(), d.bytes));
632 gate_expected
633 .entry(gate.artefact_ref.clone())
634 .and_modify(|prior: &mut Option<_>| {
635 if *prior != expected {
636 *prior = None;
637 }
638 })
639 .or_insert(expected);
640 paired.insert(gate.artefact_ref.clone(), descriptor);
641 }
642 }
643 for record in &chain.gate_evaluations {
644 for artifact in record.requested.retained_inputs.iter().chain(
645 record
646 .finished
647 .iter()
648 .flat_map(|finished| &finished.artifacts),
649 ) {
650 let reference = file_artefact_ref(artifact.path.as_str());
651 let expected = (artifact.retained_digest.clone(), artifact.retained_bytes);
652 gate_expected
653 .entry(reference.clone())
654 .and_modify(|prior: &mut Option<_>| {
655 if prior.as_ref() != Some(&expected) {
656 *prior = None;
657 }
658 })
659 .or_insert(Some(expected));
660 push_reference(reference);
661 }
662 }
663 for session in &chain.sessions {
664 push_reference(file_artefact_ref(&session.transcript_ref));
665 }
666 for document in MISSION_DOCUMENTS {
667 push_reference(file_artefact_ref(document));
668 }
669
670 let mut artefact_entries: Vec<ManifestEntry> = Vec::new();
671 let mut artefact_files: Vec<BundleFile> = Vec::new();
672 for reference in &references {
673 let (mut status, mut bytes) = if let Some(descriptor) = paired.get(reference) {
674 match descriptor.as_ref().and_then(|d| {
675 crate::contract_controls::pair::retained_bytes(&mission_dir, reference, d)
676 }) {
677 Some(bytes) => (
678 ArtefactStatus::Resolved,
679 Some(crate::scrub::scrub(&String::from_utf8_lossy(&bytes)).into_bytes()),
680 ),
681 None => (ArtefactStatus::Unresolved, None),
682 }
683 } else {
684 read_artefact(&mission_dir, reference)
685 };
686 if let Some(expected) = gate_expected.get(reference) {
687 let matches =
688 expected
689 .as_ref()
690 .zip(bytes.as_ref())
691 .is_some_and(|((digest, length), bytes)| {
692 *length == bytes.len() as u64
693 && *digest == crate::gate_evaluation::protocol::Digest::of(bytes)
694 });
695 if !matches {
696 status = ArtefactStatus::Unresolved;
697 bytes = None;
698 }
699 }
700 match artefact_bundle_path(reference).zip(bytes) {
701 Some((path, bytes)) => {
702 artefact_entries.push(ManifestEntry {
703 path: Some(path.clone()),
704 sha256: Some(sha256_hex(&bytes)),
705 source: reference.clone(),
706 kind: EntryKind::Artefact,
707 status: Some(status),
708 });
709 artefact_files.push(BundleFile { path, bytes });
710 }
711 None => artefact_entries.push(ManifestEntry {
712 path: None,
713 sha256: None,
714 source: reference.clone(),
715 kind: EntryKind::Artefact,
716 status: Some(ArtefactStatus::Unresolved),
717 }),
718 }
719 }
720
721 let summary = render_summary(&chain, &cost, &outcomes.escalations, &artefact_entries);
724
725 let mut files: Vec<BundleFile> = Vec::new();
726 let mut entries: Vec<ManifestEntry> = Vec::new();
727 let mut push_generated = |path: &str, source: &str, kind: EntryKind, bytes: Vec<u8>| {
728 entries.push(ManifestEntry {
729 path: Some(path.to_string()),
730 sha256: Some(sha256_hex(&bytes)),
731 source: source.to_string(),
732 kind,
733 status: None,
734 });
735 files.push(BundleFile {
736 path: path.to_string(),
737 bytes,
738 });
739 };
740 push_generated(
741 SUMMARY_FILE,
742 "derived:human-summary",
743 EntryKind::Summary,
744 summary.into_bytes(),
745 );
746 push_generated(
747 CHAIN_FILE,
748 "derived:provenance-chain",
749 EntryKind::Chain,
750 to_json_bytes(&chain)?,
751 );
752 push_generated(
753 ESCALATIONS_FILE,
754 "derived:escalations-fold",
755 EntryKind::Escalations,
756 to_json_bytes(&outcomes.escalations)?,
757 );
758 push_generated(
759 COST_FILE,
760 "derived:cost-fold",
761 EntryKind::Cost,
762 to_json_bytes(&cost)?,
763 );
764 push_generated(LOG_FILE, "file:events.jsonl", EntryKind::Log, log_bytes);
765 files.extend(artefact_files);
766 entries.extend(artefact_entries);
767
768 Ok(EvidenceBundle {
769 manifest: EvidenceManifest {
770 version: BUNDLE_FORMAT_VERSION,
771 mission_id: mission_id.to_string(),
772 entries,
773 },
774 files,
775 })
776}
777
778fn absolute_lexical(path: &Path) -> anyhow::Result<PathBuf> {
786 let absolute = std::path::absolute(path)?;
787 let mut out = PathBuf::new();
788 for component in absolute.components() {
789 match component {
790 Component::CurDir => {}
791 Component::ParentDir => {
792 if out.file_name().is_some() {
793 out.pop();
794 } else if !out.has_root() {
795 out.push("..");
796 }
797 }
798 other => out.push(other.as_os_str()),
799 }
800 }
801 Ok(out)
802}
803
804struct OutDirPlan {
807 anchor: PathBuf,
812 tail: Vec<String>,
814 canonical_out: PathBuf,
818}
819
820fn plan_out_dir(out_dir: &Path) -> anyhow::Result<OutDirPlan> {
827 let normalized = absolute_lexical(out_dir)?;
828 let mut anchor = normalized.as_path();
829 loop {
830 match std::fs::symlink_metadata(anchor) {
831 Ok(metadata) => {
832 let file_type = metadata.file_type();
833 if file_type.is_symlink() {
834 return Err(EngineError::InvalidState(format!(
835 "bundle output {} resolves through a symlinked component: {}",
836 out_dir.display(),
837 anchor.display()
838 ))
839 .into());
840 }
841 if !file_type.is_dir() {
842 return Err(EngineError::InvalidState(format!(
843 "bundle output {} is blocked by a non-directory component: {}",
844 out_dir.display(),
845 anchor.display()
846 ))
847 .into());
848 }
849 break;
850 }
851 Err(error) if error.kind() == ErrorKind::NotFound => {
852 anchor = anchor.parent().ok_or_else(|| {
853 EngineError::InvalidState(format!(
854 "bundle output {} has no existing ancestor",
855 out_dir.display()
856 ))
857 })?;
858 }
859 Err(error) => return Err(error.into()),
860 }
861 }
862 let canonical_anchor = anchor.canonicalize()?;
863 let mut tail = Vec::new();
864 let mut canonical_out = canonical_anchor.clone();
865 for component in normalized
868 .strip_prefix(anchor)
869 .map_err(|_| {
870 EngineError::InvalidState(format!(
871 "bundle output {} escaped its anchor",
872 out_dir.display()
873 ))
874 })?
875 .components()
876 {
877 let Component::Normal(name) = component else {
878 return Err(EngineError::InvalidState(format!(
879 "bundle output {} has a non-normal component below its anchor",
880 out_dir.display()
881 ))
882 .into());
883 };
884 let name = name.to_str().ok_or_else(|| {
885 EngineError::InvalidState(format!(
886 "bundle output {} has a non-UTF-8 component",
887 out_dir.display()
888 ))
889 })?;
890 tail.push(name.to_string());
891 canonical_out.push(name);
892 }
893 Ok(OutDirPlan {
894 anchor: canonical_anchor,
895 tail,
896 canonical_out,
897 })
898}
899
900fn pin_out_dir(plan: &OutDirPlan) -> anyhow::Result<Dir> {
907 let mut dir = Dir::open_ambient_dir(&plan.anchor, ambient_authority())?;
908 let mut walked = plan.anchor.clone();
909 for component in &plan.tail {
910 walked.push(component);
911 dir = crate::paths::open_real_subdir(&dir, component, &walked, true)?;
912 }
913 Ok(dir)
914}
915
916fn write_bundle_files(bundle: &EvidenceBundle, out_dir: &Path, out: &Dir) -> anyhow::Result<usize> {
926 let mut entries = out.entries().map_err(|error| {
927 EngineError::InvalidState(format!(
928 "bundle output {} is not an empty directory: {error}",
929 out_dir.display()
930 ))
931 })?;
932 if entries.next().is_some() {
933 return Err(EngineError::InvalidState(format!(
934 "bundle output {} is not empty; choose a fresh --out or remove it",
935 out_dir.display()
936 ))
937 .into());
938 }
939
940 let manifest_bytes = to_json_bytes(&bundle.manifest)?;
941 let mut written = 0usize;
942 for (relative, bytes) in bundle
945 .files
946 .iter()
947 .map(|file| (file.path.as_str(), file.bytes.as_slice()))
948 .chain([(MANIFEST_FILE, manifest_bytes.as_slice())])
949 {
950 let mut names = Vec::new();
951 for component in relative.split('/') {
952 if component.is_empty() || component == "." || component == ".." {
953 return Err(
954 EngineError::InvalidState(format!("unsafe bundle path {relative:?}")).into(),
955 );
956 }
957 names.push(component);
958 }
959 let (leaf, parents) = names.split_last().expect("validated non-empty");
960 let mut dir = None;
961 let mut display = out_dir.to_path_buf();
962 for parent in parents {
963 display.push(parent);
964 dir = Some(crate::paths::open_real_subdir(
965 dir.as_ref().unwrap_or(out),
966 parent,
967 &display,
968 true,
969 )?);
970 }
971 let mut options = OpenOptions::new();
972 options
973 .write(true)
974 .create_new(true)
975 .follow(FollowSymlinks::No);
976 let mut file = dir.as_ref().unwrap_or(out).open_with(leaf, &options)?;
977 file.write_all(bytes)?;
978 written += 1;
979 }
980 Ok(written)
981}
982
983pub fn write_evidence_bundle(bundle: &EvidenceBundle, out_dir: &Path) -> anyhow::Result<usize> {
991 let plan = plan_out_dir(out_dir)?;
992 let out = pin_out_dir(&plan)?;
993 write_bundle_files(bundle, out_dir, &out)
994}
995
996pub fn export_evidence_bundle(
1011 repo_root: &Path,
1012 mission_id: &str,
1013 out_dir: &Path,
1014) -> anyhow::Result<ExportOutcome> {
1015 let paths = MissionPaths::new(repo_root, mission_id);
1016 paths.require_no_follow()?;
1017 let refusal = || {
1018 EngineError::InvalidState(format!(
1019 "bundle output {} must be outside the mission dir {}",
1020 out_dir.display(),
1021 paths.mission_dir().display()
1022 ))
1023 };
1024 let out_lexical = absolute_lexical(out_dir)?;
1027 let mission_lexical = absolute_lexical(&paths.mission_dir())?;
1028 if out_lexical.starts_with(&mission_lexical) {
1029 return Err(refusal().into());
1030 }
1031 let plan = plan_out_dir(out_dir)?;
1038 match std::fs::symlink_metadata(paths.mission_dir()) {
1039 Ok(_) => {
1040 if plan
1041 .canonical_out
1042 .starts_with(paths.mission_dir().canonicalize()?)
1043 {
1044 return Err(refusal().into());
1045 }
1046 }
1047 Err(error) if error.kind() == ErrorKind::NotFound => {}
1048 Err(error) => return Err(error.into()),
1049 }
1050
1051 let bundle = assemble_evidence_bundle(repo_root, mission_id)?;
1052 let out = pin_out_dir(&plan)?;
1053 let files_written = write_bundle_files(&bundle, out_dir, &out)?;
1054 let resolved_artefacts = bundle
1055 .manifest
1056 .entries
1057 .iter()
1058 .filter(|entry| entry.status == Some(ArtefactStatus::Resolved))
1059 .count();
1060 let unresolved_artefacts = bundle
1061 .manifest
1062 .entries
1063 .iter()
1064 .filter(|entry| entry.status == Some(ArtefactStatus::Unresolved))
1065 .count();
1066 Ok(ExportOutcome {
1067 out_dir: out_dir.to_path_buf(),
1068 files_written,
1069 resolved_artefacts,
1070 unresolved_artefacts,
1071 })
1072}
1073
1074#[cfg(test)]
1075mod tests {
1076 use super::*;
1077 use crate::event_log::{EventLog, LockForce};
1078 use crate::events::EventKind;
1079 use crate::gate::{GateKind, GateSurface, GateVerdict};
1080 use crate::types::{GrantKind, MissionConfig, Plan, Role, RunResult, TokenUsage};
1081 use std::collections::BTreeMap;
1082 use std::time::Duration;
1083 use tempfile::TempDir;
1084
1085 fn seed_mission(repo_root: &Path, id: &str, kinds: Vec<EventKind>) -> MissionPaths {
1089 let paths = MissionPaths::new(repo_root, id);
1090 let mut log = EventLog::acquire(&paths, id, Duration::ZERO, LockForce::No).unwrap();
1091 for kind in kinds {
1092 log.append(kind).unwrap();
1093 }
1094 paths
1095 }
1096
1097 fn sample_plan() -> Plan {
1098 Plan {
1099 goal: "ship the thing".into(),
1100 validation_contract: vec![],
1101 milestones: vec![],
1102 considered_alternatives: None,
1103 command_grants: vec![],
1104 touch_set: vec![],
1105 standards_manifest: None,
1106 reviewer_independence: None,
1107 }
1108 }
1109
1110 fn created() -> EventKind {
1111 EventKind::MissionCreated {
1112 goal: "ship the thing".into(),
1113 base_branch: "main".into(),
1114 mission_branch: "kranz/mission-x".into(),
1115 config: MissionConfig::default(),
1116 }
1117 }
1118
1119 fn gate_result(
1120 gate: &str,
1121 surface: GateSurface,
1122 kind: GateKind,
1123 index: u32,
1124 artefact_ref: &str,
1125 ) -> EventKind {
1126 EventKind::GateResult {
1127 gate: gate.to_string(),
1128 surface,
1129 kind,
1130 index,
1131 verdict: GateVerdict::Pass,
1132 artefact_ref: artefact_ref.to_string(),
1133 artefact_detail: None,
1134 score: None,
1135 threshold: None,
1136 rule_ids: Vec::new(),
1137 }
1138 }
1139
1140 fn worker_spawned(run_id: &str, role: Role, model: &str) -> EventKind {
1141 EventKind::WorkerSpawned {
1142 backend: None,
1143 run_id: run_id.to_string(),
1144 role,
1145 feature_id: None,
1146 milestone_id: None,
1147 candidate: None,
1148 executor_route: None,
1149 sdk_session_id: format!("sess-{run_id}"),
1150 model: model.to_string(),
1151 quant: "n/a".to_string(),
1152 weight_hash: None,
1153 prompt_hash: "aaaabbbbcccc".to_string(),
1154 transcript_path: MissionPaths::transcript_rel(run_id),
1155 }
1156 }
1157
1158 fn seed_full_mission(root: &Path) -> MissionPaths {
1166 let paths = seed_mission(
1167 root,
1168 "m-1",
1169 vec![
1170 created(),
1171 EventKind::PlanApproved {
1172 plan: sample_plan(),
1173 base_sha: Some("deadbeef".to_string()),
1174 },
1175 gate_result(
1176 "vacuous-filter",
1177 GateSurface::Approval,
1178 GateKind::Deterministic,
1179 0,
1180 "contract gate vacuous-filter",
1181 ),
1182 gate_result(
1183 "merge-gate-suite",
1184 GateSurface::Approval,
1185 GateKind::Deterministic,
1186 1,
1187 "file:runs/gate-base.jsonl",
1188 ),
1189 gate_result(
1190 "merge-gate-suite-recheck",
1191 GateSurface::Approval,
1192 GateKind::Deterministic,
1193 2,
1194 "file:runs/gate-base.jsonl",
1197 ),
1198 gate_result(
1199 "plan-review",
1200 GateSurface::Approval,
1201 GateKind::ModelJudged,
1202 0,
1203 "file:runs/gone.jsonl",
1204 ),
1205 worker_spawned("r-1", Role::Worker, "gpt-5"),
1206 EventKind::WorkerCompleted {
1207 run_id: "r-1".into(),
1208 result: RunResult::Pass,
1209 tokens: TokenUsage {
1210 input: 100,
1211 output: 50,
1212 cache_read: 0,
1213 cache_write: 0,
1214 },
1215 cost_usd: Some(0.42),
1216 report: None,
1217 },
1218 EventKind::FeatureCompleted {
1219 feature_id: "f-1-1".into(),
1220 commits: vec!["abc1234 implement the widget".into()],
1221 },
1222 EventKind::GrantRequested {
1223 milestone_id: "ms-1".into(),
1224 kind: GrantKind::Command,
1225 command: "cargo test".into(),
1226 },
1227 EventKind::GrantApproved {
1228 kind: GrantKind::Command,
1229 command: "cargo test".into(),
1230 },
1231 worker_spawned("r-2", Role::Worker, "my-local-model"),
1232 worker_spawned("r-3", Role::ValidatorScrutiny, "sonnet"),
1233 EventKind::MilestoneBlocked {
1234 block_context: None,
1235 milestone_id: "ms-1".into(),
1236 reason: "fix-cycle cap".into(),
1237 },
1238 EventKind::MilestoneUnblocked {
1239 block_context: None,
1240 milestone_id: "ms-1".into(),
1241 reason: "user skipped findings".into(),
1242 validator_guidance: None,
1243 },
1244 EventKind::UserMessage {
1245 text: "skip the flaky test".into(),
1246 interrupt: false,
1247 },
1248 gate_result(
1249 "merge-gate-suite",
1250 GateSurface::FinalGate,
1251 GateKind::Deterministic,
1252 0,
1253 ".kranz/merge-gates.json",
1254 ),
1255 EventKind::MissionCompleted {},
1256 ],
1257 );
1258 std::fs::write(paths.runs_dir().join("gate-base.jsonl"), b"{}").unwrap();
1260 std::fs::write(paths.runs_dir().join("r-1.jsonl"), b"{}").unwrap();
1261 std::fs::write(paths.plan_md_file(), b"# plan\n").unwrap();
1262 std::fs::write(paths.plan_file(), b"{}").unwrap();
1263 std::fs::write(paths.report_file(), b"# report\n").unwrap();
1264 paths
1265 }
1266
1267 fn collect_files(dir: &Path) -> BTreeMap<String, Vec<u8>> {
1271 let mut out = BTreeMap::new();
1272 let mut stack = vec![dir.to_path_buf()];
1273 while let Some(current) = stack.pop() {
1274 for entry in std::fs::read_dir(¤t).unwrap() {
1275 let path = entry.unwrap().path();
1276 if path.is_dir() {
1277 stack.push(path);
1278 } else {
1279 let relative = path
1280 .strip_prefix(dir)
1281 .unwrap()
1282 .components()
1283 .map(|c| c.as_os_str().to_str().unwrap().to_string())
1284 .collect::<Vec<_>>()
1285 .join("/");
1286 out.insert(relative, std::fs::read(&path).unwrap());
1287 }
1288 }
1289 }
1290 out
1291 }
1292
1293 fn manifest_entry<'m>(manifest: &'m EvidenceManifest, source: &str) -> &'m ManifestEntry {
1294 manifest
1295 .entries
1296 .iter()
1297 .find(|entry| entry.source == source)
1298 .unwrap_or_else(|| panic!("manifest entry {source} missing"))
1299 }
1300
1301 #[test]
1308 fn evidence_bundle_opens_standalone_with_no_host_paths() {
1309 let tmp = TempDir::new().unwrap();
1310 seed_full_mission(tmp.path());
1311 let out = tmp.path().join("bundle-out");
1312 let outcome = export_evidence_bundle(tmp.path(), "m-1", &out).unwrap();
1313
1314 for name in [
1315 MANIFEST_FILE,
1316 SUMMARY_FILE,
1317 CHAIN_FILE,
1318 ESCALATIONS_FILE,
1319 COST_FILE,
1320 LOG_FILE,
1321 ] {
1322 assert!(out.join(name).is_file(), "{name} missing from the bundle");
1323 }
1324 for shipped in [
1325 "artefacts/runs/gate-base.jsonl",
1326 "artefacts/runs/r-1.jsonl",
1327 "artefacts/plan.md",
1328 "artefacts/plan.json",
1329 "artefacts/report.md",
1330 ] {
1331 assert!(
1332 out.join(shipped).is_file(),
1333 "{shipped} missing from artefacts/"
1334 );
1335 }
1336 assert_eq!(outcome.files_written, 11);
1339 assert_eq!(outcome.resolved_artefacts, 5);
1340 assert_eq!(outcome.unresolved_artefacts, 5);
1341
1342 let host = tmp.path().to_string_lossy().to_string();
1345 let files = collect_files(&out);
1346 for (relative, bytes) in &files {
1347 let text = String::from_utf8_lossy(bytes);
1348 assert!(
1349 !text.contains(&host),
1350 "host path leaked into bundle file {relative}"
1351 );
1352 }
1353
1354 let manifest: EvidenceManifest =
1357 serde_json::from_str(&std::fs::read_to_string(out.join(MANIFEST_FILE)).unwrap())
1358 .unwrap();
1359 assert_eq!(manifest.version, BUNDLE_FORMAT_VERSION);
1360 assert_eq!(manifest.mission_id, "m-1");
1361 for entry in &manifest.entries {
1362 if let (Some(path), Some(sha256)) = (&entry.path, &entry.sha256) {
1363 let bytes = std::fs::read(out.join(path)).unwrap();
1364 assert_eq!(&sha256_hex(&bytes), sha256, "sha256 mismatch for {path}");
1365 }
1366 }
1367 assert_eq!(
1369 manifest
1370 .entries
1371 .iter()
1372 .filter(|entry| entry.source == "file:runs/gate-base.jsonl")
1373 .count(),
1374 1
1375 );
1376 assert!(manifest
1378 .entries
1379 .iter()
1380 .all(|entry| entry.source != "contract gate vacuous-filter"));
1381 let gone = manifest_entry(&manifest, "file:runs/gone.jsonl");
1384 assert_eq!(gone.status, Some(ArtefactStatus::Unresolved));
1385 assert!(gone.path.is_none() && gone.sha256.is_none());
1386 let chain: ProvenanceChain =
1388 serde_json::from_str(&std::fs::read_to_string(out.join(CHAIN_FILE)).unwrap()).unwrap();
1389 assert_eq!(chain.gates.len(), 5);
1390 let cost: MissionCostSummary =
1392 serde_json::from_str(&std::fs::read_to_string(out.join(COST_FILE)).unwrap()).unwrap();
1393 assert_eq!(cost.total_cost_usd, 0.42);
1394 assert_eq!(cost.non_meta_commits, 1);
1395 assert_eq!(cost.usd_per_commit, Some(0.42));
1396 assert!(cost.closed);
1397 }
1398
1399 #[test]
1403 fn evidence_bundle_is_byte_identical_across_exports() {
1404 let tmp = TempDir::new().unwrap();
1405 seed_full_mission(tmp.path());
1406
1407 let first = assemble_evidence_bundle(tmp.path(), "m-1").unwrap();
1408 let second = assemble_evidence_bundle(tmp.path(), "m-1").unwrap();
1409 assert_eq!(first, second);
1410 assert_eq!(
1411 serde_json::to_string_pretty(&first.manifest).unwrap(),
1412 serde_json::to_string_pretty(&second.manifest).unwrap()
1413 );
1414
1415 let out_a = tmp.path().join("out-a");
1416 let out_b = tmp.path().join("out-b");
1417 export_evidence_bundle(tmp.path(), "m-1", &out_a).unwrap();
1418 export_evidence_bundle(tmp.path(), "m-1", &out_b).unwrap();
1419 assert_eq!(collect_files(&out_a), collect_files(&out_b));
1420 }
1421
1422 #[test]
1427 fn evidence_bundle_redacted_secret_leaves_fingerprints_only() {
1428 let tmp = TempDir::new().unwrap();
1429 let secret = "sk-ant-F00barBazQuux9_7";
1430 let text = format!("the key is {secret} ok");
1431 let findings = crate::scrub::scan_text(&text);
1433 assert_eq!(findings.len(), 1, "fixture must trip exactly one rule");
1434 let fingerprint = findings[0].fingerprint.clone();
1435
1436 seed_mission(
1437 tmp.path(),
1438 "m-sec",
1439 vec![
1440 created(),
1441 EventKind::UserMessage {
1442 text,
1443 interrupt: false,
1444 },
1445 EventKind::MissionCompleted {},
1446 ],
1447 );
1448
1449 let out = tmp.path().join("bundle-sec");
1450 export_evidence_bundle(tmp.path(), "m-sec", &out).unwrap();
1451 let files = collect_files(&out);
1452 assert!(!files.is_empty());
1453 for (relative, bytes) in &files {
1454 let text = String::from_utf8_lossy(bytes);
1455 assert!(
1456 !text.contains(secret),
1457 "secret value leaked into bundle file {relative}"
1458 );
1459 }
1460 let log = String::from_utf8_lossy(&files[LOG_FILE]).to_string();
1463 assert!(log.contains(&fingerprint), "audit fingerprint missing");
1464 assert!(log.contains("[REDACTED]"));
1465 }
1466
1467 #[test]
1474 fn evidence_bundle_scrubs_artefact_bytes_and_hashes_the_redacted_form() {
1475 let tmp = TempDir::new().unwrap();
1476 let secret = "sk-ant-F00barBazQuux9_7";
1477 let paths = seed_full_mission(tmp.path());
1478 let planted = format!("{{\"text\":\"the key is {secret} ok\"}}\n");
1481 std::fs::write(paths.runs_dir().join("r-1.jsonl"), planted.as_bytes()).unwrap();
1482
1483 let out = tmp.path().join("bundle-artefact-secret");
1484 export_evidence_bundle(tmp.path(), "m-1", &out).unwrap();
1485 let files = collect_files(&out);
1486 for (relative, bytes) in &files {
1487 let text = String::from_utf8_lossy(bytes);
1488 assert!(
1489 !text.contains(secret),
1490 "secret value leaked into bundle file {relative}"
1491 );
1492 }
1493 let shipped = &files["artefacts/runs/r-1.jsonl"];
1494 assert!(String::from_utf8_lossy(shipped).contains("[REDACTED]"));
1495
1496 let manifest: EvidenceManifest =
1498 serde_json::from_str(&std::fs::read_to_string(out.join(MANIFEST_FILE)).unwrap())
1499 .unwrap();
1500 let entry = manifest_entry(&manifest, "file:runs/r-1.jsonl");
1501 assert_eq!(entry.sha256.as_deref(), Some(sha256_hex(shipped).as_str()));
1502 }
1503
1504 #[test]
1508 fn evidence_bundle_scrubs_non_utf8_artefact_bytes_lossily() {
1509 let tmp = TempDir::new().unwrap();
1510 let secret = "sk-ant-F00barBazQuux9_7";
1511 let paths = seed_full_mission(tmp.path());
1512 let mut planted = format!("the key is {secret} ok").into_bytes();
1513 planted.push(0xff);
1514 std::fs::write(paths.runs_dir().join("r-1.jsonl"), &planted).unwrap();
1515
1516 let bundle = assemble_evidence_bundle(tmp.path(), "m-1").unwrap();
1517 let shipped = bundle
1518 .files
1519 .iter()
1520 .find(|file| file.path == "artefacts/runs/r-1.jsonl")
1521 .expect("artefact shipped");
1522 let text = String::from_utf8(shipped.bytes.clone()).expect("lossy decode yields UTF-8");
1523 assert!(!text.contains(secret));
1524 assert!(text.contains("[REDACTED]"));
1525 assert!(
1526 text.contains('\u{fffd}'),
1527 "invalid byte became a replacement"
1528 );
1529 }
1530
1531 #[test]
1535 fn evidence_bundle_missing_artefact_bytes_become_unresolved_manifest_entries() {
1536 let tmp = TempDir::new().unwrap();
1537 let paths = seed_full_mission(tmp.path());
1538 std::fs::remove_dir_all(paths.runs_dir()).unwrap();
1539
1540 let bundle = assemble_evidence_bundle(tmp.path(), "m-1").unwrap();
1541 for source in [
1542 "file:runs/gate-base.jsonl",
1543 "file:runs/gone.jsonl",
1544 "file:runs/r-1.jsonl",
1545 "file:runs/r-2.jsonl",
1546 "file:runs/r-3.jsonl",
1547 "file:research.md",
1548 "file:estimate.json",
1549 ] {
1550 let entry = manifest_entry(&bundle.manifest, source);
1551 assert_eq!(
1552 entry.status,
1553 Some(ArtefactStatus::Unresolved),
1554 "{source} must be unresolved with its bytes gone"
1555 );
1556 assert!(entry.path.is_none() && entry.sha256.is_none());
1557 }
1558 for source in ["file:plan.md", "file:plan.json", "file:report.md"] {
1560 assert_eq!(
1561 manifest_entry(&bundle.manifest, source).status,
1562 Some(ArtefactStatus::Resolved),
1563 "{source} must still resolve"
1564 );
1565 }
1566 assert!(bundle
1568 .files
1569 .iter()
1570 .all(|file| !file.path.starts_with("artefacts/runs/")));
1571 }
1572
1573 #[test]
1577 fn evidence_bundle_refuses_out_dir_inside_the_mission_dir() {
1578 let tmp = TempDir::new().unwrap();
1579 let paths = seed_full_mission(tmp.path());
1580 let inside = paths.mission_dir().join("bundle");
1581 let result = export_evidence_bundle(tmp.path(), "m-1", &inside);
1582 assert!(result.is_err(), "an in-mission --out must be refused");
1583 assert!(!inside.exists(), "nothing must be written on refusal");
1584 }
1585
1586 #[test]
1589 fn evidence_bundle_refuses_a_non_empty_out_dir() {
1590 let tmp = TempDir::new().unwrap();
1591 seed_full_mission(tmp.path());
1592 let out = tmp.path().join("bundle-used");
1593 std::fs::create_dir_all(&out).unwrap();
1594 std::fs::write(out.join("stale.txt"), b"stale").unwrap();
1595 let result = export_evidence_bundle(tmp.path(), "m-1", &out);
1596 assert!(result.is_err(), "a non-empty --out must be refused");
1597 assert_eq!(
1598 std::fs::read_to_string(out.join("stale.txt")).unwrap(),
1599 "stale"
1600 );
1601 }
1602
1603 #[test]
1610 fn evidence_single_snapshot_torn_tail_is_excluded_from_parse_and_bytes() {
1611 use std::io::Write as _;
1612 let tmp = TempDir::new().unwrap();
1613 let paths = seed_full_mission(tmp.path());
1614 let pristine = std::fs::read(paths.events_file()).unwrap();
1615 let mut file = std::fs::OpenOptions::new()
1618 .append(true)
1619 .open(paths.events_file())
1620 .unwrap();
1621 file.write_all(b"{\"seq\":999,\"ts\":\"torn").unwrap();
1622 drop(file);
1623
1624 let bundle = assemble_evidence_bundle(tmp.path(), "m-1").unwrap();
1625 let shipped = bundle
1626 .files
1627 .iter()
1628 .find(|file| file.path == LOG_FILE)
1629 .expect("the raw log ships");
1630 assert_eq!(
1631 shipped.bytes, pristine,
1632 "the torn tail is in NEITHER the events nor the shipped bytes"
1633 );
1634 assert_eq!(bundle.manifest.mission_id, "m-1");
1636 let replay = paths.runs_dir().join("replay.jsonl");
1640 std::fs::write(&replay, &shipped.bytes).unwrap();
1641 let folded = crate::event_log::EventLog::read_events(&paths.events_file()).unwrap();
1642 let refolded = crate::event_log::EventLog::read_events(&replay).unwrap();
1643 assert_eq!(refolded.len(), folded.len());
1644 assert_eq!(
1645 refolded.last().map(|event| event.seq),
1646 folded.last().map(|event| event.seq)
1647 );
1648 }
1649
1650 #[test]
1657 fn evidence_outdir_containment_refuses_dotdot_escape_into_the_mission() {
1658 let tmp = TempDir::new().unwrap();
1659 let paths = seed_full_mission(tmp.path());
1660 let escape = tmp
1661 .path()
1662 .join("outside")
1663 .join("..")
1664 .join(".kranz")
1665 .join("missions")
1666 .join("m-1")
1667 .join("bundle");
1668 let result = export_evidence_bundle(tmp.path(), "m-1", &escape);
1669 assert!(result.is_err(), "the `..` shape must be refused");
1670 assert!(
1671 !paths.mission_dir().join("bundle").exists(),
1672 "nothing must be written on refusal"
1673 );
1674 }
1675
1676 #[cfg(unix)]
1681 #[test]
1682 fn evidence_outdir_containment_refuses_a_symlinked_component() {
1683 use std::os::unix::fs::symlink;
1684 let tmp = TempDir::new().unwrap();
1685 let paths = seed_full_mission(tmp.path());
1686 let link = tmp.path().join("linked-out");
1687 symlink(paths.mission_dir(), &link).unwrap();
1688 let result = export_evidence_bundle(tmp.path(), "m-1", &link.join("bundle"));
1689 let err = result.expect_err("a symlinked out-dir component must be refused");
1690 assert!(err.to_string().contains("symlinked"), "{err}");
1691 assert!(
1692 !paths.mission_dir().join("bundle").exists(),
1693 "nothing must be written through the link"
1694 );
1695 }
1696
1697 #[test]
1700 fn evidence_outdir_containment_normal_external_dir_works() {
1701 let tmp = TempDir::new().unwrap();
1702 seed_full_mission(tmp.path());
1703 let out = tmp.path().join("fresh").join("bundle-out");
1704 let outcome = export_evidence_bundle(tmp.path(), "m-1", &out).unwrap();
1705 assert!(outcome.files_written > 0);
1706 assert!(out.join(MANIFEST_FILE).is_file());
1707 assert!(out.join(LOG_FILE).is_file());
1708 }
1709
1710 fn pinned_plan() -> Plan {
1716 let rule = |id: &str, revision: u64, status: &str| crate::types::PinnedRule {
1717 id: id.to_string(),
1718 revision,
1719 rfc: "RFC-001".to_string(),
1720 level: "must".to_string(),
1721 effective_status: status.to_string(),
1722 statement: format!("statement for {id}"),
1723 domains: Vec::new(),
1724 stages: vec!["validation".to_string()],
1725 when_paths: Vec::new(),
1726 task_classes: Vec::new(),
1727 checker: Some("gate:zz-gate".to_string()),
1728 waivable: false,
1729 };
1730 Plan {
1731 standards_manifest: Some(Box::new(crate::types::StandardsPin {
1732 pack_name: "zz-pack".to_string(),
1733 pack_dir: "vendor/pack".to_string(),
1734 standards_root: "standards".to_string(),
1735 digest: "ab".repeat(32),
1736 source: crate::types::StandardsPinSource::RepoTracked,
1737 task_class: None,
1738 touch_set: vec!["crates/**".to_string()],
1739 context_paths: Vec::new(),
1740 gates: Vec::new(),
1741 rules: vec![
1742 rule("ZZ-FAIL-001", 2, "enforced"),
1743 rule("ZZ-PASS-001", 1, "enforced"),
1744 rule("ZZ-QUIET-001", 1, "enforced"),
1745 ],
1746 })),
1747 ..sample_plan()
1748 }
1749 }
1750
1751 fn seed_pinned_mission(root: &Path) -> MissionPaths {
1756 let mut gate = gate_result(
1757 "zz-gate",
1758 GateSurface::FinalGate,
1759 GateKind::Deterministic,
1760 0,
1761 "file:runs/gone.jsonl",
1762 );
1763 if let EventKind::GateResult { rule_ids, .. } = &mut gate {
1764 *rule_ids = vec!["ZZ-PASS-001".to_string()];
1765 }
1766 seed_mission(
1767 root,
1768 "m-1",
1769 vec![
1770 created(),
1771 EventKind::PlanApproved {
1772 plan: pinned_plan(),
1773 base_sha: Some("deadbeef".to_string()),
1774 },
1775 EventKind::StandardsResolved {
1776 source: "repo-tracked".to_string(),
1777 pack_name: "zz-pack".to_string(),
1778 standards_root: "standards".to_string(),
1779 digest: "ab".repeat(32),
1780 stage: "approval".to_string(),
1781 task_class: None,
1782 touch_set: vec!["crates/**".to_string()],
1783 context_paths: Vec::new(),
1784 rules: Vec::new(),
1785 approval_seq: 2,
1786 },
1787 gate,
1788 EventKind::ValidationFinding {
1789 milestone_id: "ms-1".into(),
1790 run_id: "v-1".into(),
1791 finding: crate::types::Finding {
1792 subject: "a-1".into(),
1793 severity: "major".into(),
1794 evidence: "the rule failed".into(),
1795 suggested_fix: String::new(),
1796 class: String::new(),
1797 rule: Some(crate::types::RuleCitation {
1798 id: "ZZ-FAIL-001".to_string(),
1799 revision: 2,
1800 source: "zz-pack standards".to_string(),
1801 digest: "ab".repeat(32),
1802 lifecycle: "enforced".to_string(),
1803 level: "must".to_string(),
1804 checker: Some("gate:zz-gate".to_string()),
1805 }),
1806 },
1807 },
1808 EventKind::MissionCompleted {},
1809 ],
1810 )
1811 }
1812
1813 #[test]
1818 fn flight_rules_provenance_bundle_renders_coverage_byte_identically() {
1819 let tmp = TempDir::new().unwrap();
1820 seed_pinned_mission(tmp.path());
1821 let first = assemble_evidence_bundle(tmp.path(), "m-1").unwrap();
1822 let second = assemble_evidence_bundle(tmp.path(), "m-1").unwrap();
1823 assert_eq!(first, second, "same log → byte-identical bundle");
1824
1825 let summary = first
1826 .files
1827 .iter()
1828 .find(|file| file.path == SUMMARY_FILE)
1829 .expect("summary ships");
1830 let summary = String::from_utf8(summary.bytes.clone()).unwrap();
1831 assert!(
1832 summary.contains("## Flight Rules standards coverage"),
1833 "{summary}"
1834 );
1835 assert!(
1836 summary.contains("| ZZ-FAIL-001 | r2 | enforced | must | gate:zz-gate | failed |"),
1837 "{summary}"
1838 );
1839 assert!(
1840 summary.contains("| ZZ-PASS-001 | r1 | enforced | must | gate:zz-gate | passed |"),
1841 "{summary}"
1842 );
1843 assert!(
1844 summary
1845 .contains("| ZZ-QUIET-001 | r1 | enforced | must | gate:zz-gate | not-evaluated |"),
1846 "{summary}"
1847 );
1848 assert!(
1850 summary.contains("gate.result seq 4 zz-gate pass `file:runs/gone.jsonl`"),
1851 "{summary}"
1852 );
1853
1854 let chain = first
1855 .files
1856 .iter()
1857 .find(|file| file.path == CHAIN_FILE)
1858 .expect("the chain ships");
1859 let chain = String::from_utf8(chain.bytes.clone()).unwrap();
1860 assert!(chain.contains("\"standards\""), "{chain}");
1861 assert!(
1862 chain.contains("\"disposition\": \"not-evaluated\""),
1863 "{chain}"
1864 );
1865 }
1866
1867 #[test]
1872 fn flight_rules_provenance_bundle_removed_artefacts_stay_unresolved() {
1873 let tmp = TempDir::new().unwrap();
1874 seed_pinned_mission(tmp.path());
1875 let bundle = assemble_evidence_bundle(tmp.path(), "m-1").unwrap();
1877 let entry = manifest_entry(&bundle.manifest, "file:runs/gone.jsonl");
1878 assert_eq!(entry.status, Some(ArtefactStatus::Unresolved));
1879 assert_eq!(entry.path, None, "an unresolved entry has no bytes path");
1880 let summary = bundle
1884 .files
1885 .iter()
1886 .find(|file| file.path == SUMMARY_FILE)
1887 .expect("summary ships");
1888 let summary = String::from_utf8(summary.bytes.clone()).unwrap();
1889 assert!(summary.contains("`file:runs/gone.jsonl`"), "{summary}");
1890 assert!(
1891 summary.contains("Absence of evidence is never rendered as pass"),
1892 "{summary}"
1893 );
1894 }
1895
1896 #[test]
1901 fn flight_rules_provenance_bundle_pre_flight_rules_mission_is_unchanged() {
1902 let tmp = TempDir::new().unwrap();
1903 seed_full_mission(tmp.path());
1904 let bundle = assemble_evidence_bundle(tmp.path(), "m-1").unwrap();
1905 let summary = bundle
1906 .files
1907 .iter()
1908 .find(|file| file.path == SUMMARY_FILE)
1909 .expect("summary ships");
1910 let summary = String::from_utf8(summary.bytes.clone()).unwrap();
1911 assert!(
1912 !summary.contains("Flight Rules standards coverage"),
1913 "no pin, no matrix: {summary}"
1914 );
1915 let chain = bundle
1916 .files
1917 .iter()
1918 .find(|file| file.path == CHAIN_FILE)
1919 .expect("the chain ships");
1920 let chain = String::from_utf8(chain.bytes.clone()).unwrap();
1921 assert!(
1922 !chain.contains("\"standards\""),
1923 "a pre-Flight-Rules chain carries no standards key: {chain}"
1924 );
1925 }
1926}