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