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 let Some(coverage) = &chain.standards {
444 out.push_str(&crate::standards_coverage::render_coverage_markdown(
445 coverage,
446 ));
447 out.push('\n');
448 }
449
450 out.push_str("## Sessions (workers and reviewers)\n\n");
451 if chain.sessions.is_empty() {
452 out.push_str("(no sessions recorded)\n\n");
453 } else {
454 out.push_str(
455 "| seq | run | role | backend | model | prompt hash | transcript | resolution |\n\
456 |----:|-----|------|---------|-------|-------------|------------|------------|\n",
457 );
458 for session in &chain.sessions {
459 out.push_str(&format!(
460 "| {} | {} | {} | {} | {} | `{}` | `{}` | {} |\n",
461 session.seq,
462 md_cell(&session.run_id),
463 wire_name(&session.role),
464 session.backend.as_deref().unwrap_or("?"),
465 md_cell(&session.model),
466 session.prompt_hash,
467 md_cell(&session.transcript_ref),
468 session.transcript.as_str(),
469 ));
470 }
471 out.push('\n');
472 }
473
474 out.push_str("## Human decisions\n\n");
475 if chain.decisions.is_empty() {
476 out.push_str("(no human decisions recorded)\n\n");
477 } else {
478 for decision in &chain.decisions {
479 out.push_str(&format!(
480 "- [seq {}] {} — {}\n",
481 decision.seq,
482 decision.kind.as_str(),
483 one_line(&decision.summary)
484 ));
485 }
486 out.push('\n');
487 }
488
489 out.push_str("## Escalations\n\n");
490 if escalations.is_empty() {
491 out.push_str("(no escalations recorded)\n\n");
492 } else {
493 for row in escalations {
494 let latency = row
495 .latency_ms
496 .map(|ms| format!(" (latency {ms} ms)"))
497 .unwrap_or_default();
498 out.push_str(&format!(
499 "- [{}] {}: {} → {}{}\n",
500 row.ts.to_rfc3339(),
501 row.kind.as_str(),
502 one_line(&row.summary),
503 one_line(&row.decision),
504 latency,
505 ));
506 }
507 out.push('\n');
508 }
509
510 out.push_str("## Artefacts\n\n");
511 out.push_str(
512 "| bundle path | source | sha256 | status |\n\
513 |-------------|--------|--------|--------|\n",
514 );
515 for entry in artefact_entries {
516 let status = entry.status.map(|status| status.as_str()).unwrap_or("—");
517 out.push_str(&format!(
518 "| {} | `{}` | {} | {} |\n",
519 entry
520 .path
521 .as_deref()
522 .map(|path| format!("`{path}`"))
523 .unwrap_or_else(|| "—".to_string()),
524 md_cell(&entry.source),
525 entry.sha256.as_deref().unwrap_or("—"),
526 status,
527 ));
528 }
529 out.push('\n');
530 out.push_str(&format!(
531 "Regenerate with `kranz evidence-bundle {}`; the same event log always yields\n\
532 the same bundle bytes.\n",
533 chain.mission_id
534 ));
535 out
536}
537
538pub fn assemble_evidence_bundle(
548 repo_root: &Path,
549 mission_id: &str,
550) -> anyhow::Result<EvidenceBundle> {
551 let paths = MissionPaths::new(repo_root, mission_id);
552 paths.require_no_follow()?;
553 let mission_dir = paths.mission_dir();
554
555 let (events, log_bytes) =
565 crate::event_log::EventLog::read_events_and_log_bytes(&paths.events_file())?;
566
567 let chain = crate::provenance::provenance_chain(&mission_dir, mission_id, &events)?;
568 let outcomes: MissionOutcomes = crate::outcomes::mission_outcomes(mission_id, &events);
569 let cost = MissionCostSummary {
570 total_cost_usd: outcomes.cost_usd,
571 non_meta_commits: outcomes.non_meta_commits,
572 usd_per_commit: (outcomes.non_meta_commits > 0)
573 .then(|| outcomes.cost_usd / outcomes.non_meta_commits as f64),
574 cycle_time_ms: outcomes.cycle_time_ms,
575 closed: outcomes.is_closed,
576 interventions: outcomes.interventions,
577 };
578
579 let mut references: Vec<String> = Vec::new();
586 let mut push_reference = |reference: String| {
587 if reference.starts_with(crate::gate_results::FILE_REF_SCHEME)
588 && !references.contains(&reference)
589 {
590 references.push(reference);
591 }
592 };
593 for gate in &chain.gates {
594 push_reference(gate.artefact_ref.clone());
595 }
596 for session in &chain.sessions {
597 push_reference(file_artefact_ref(&session.transcript_ref));
598 }
599 for document in MISSION_DOCUMENTS {
600 push_reference(file_artefact_ref(document));
601 }
602
603 let mut artefact_entries: Vec<ManifestEntry> = Vec::new();
604 let mut artefact_files: Vec<BundleFile> = Vec::new();
605 for reference in &references {
606 let (status, bytes) = read_artefact(&mission_dir, reference);
607 match artefact_bundle_path(reference).zip(bytes) {
608 Some((path, bytes)) => {
609 artefact_entries.push(ManifestEntry {
610 path: Some(path.clone()),
611 sha256: Some(sha256_hex(&bytes)),
612 source: reference.clone(),
613 kind: EntryKind::Artefact,
614 status: Some(status),
615 });
616 artefact_files.push(BundleFile { path, bytes });
617 }
618 None => artefact_entries.push(ManifestEntry {
619 path: None,
620 sha256: None,
621 source: reference.clone(),
622 kind: EntryKind::Artefact,
623 status: Some(ArtefactStatus::Unresolved),
624 }),
625 }
626 }
627
628 let summary = render_summary(&chain, &cost, &outcomes.escalations, &artefact_entries);
631
632 let mut files: Vec<BundleFile> = Vec::new();
633 let mut entries: Vec<ManifestEntry> = Vec::new();
634 let mut push_generated = |path: &str, source: &str, kind: EntryKind, bytes: Vec<u8>| {
635 entries.push(ManifestEntry {
636 path: Some(path.to_string()),
637 sha256: Some(sha256_hex(&bytes)),
638 source: source.to_string(),
639 kind,
640 status: None,
641 });
642 files.push(BundleFile {
643 path: path.to_string(),
644 bytes,
645 });
646 };
647 push_generated(
648 SUMMARY_FILE,
649 "derived:human-summary",
650 EntryKind::Summary,
651 summary.into_bytes(),
652 );
653 push_generated(
654 CHAIN_FILE,
655 "derived:provenance-chain",
656 EntryKind::Chain,
657 to_json_bytes(&chain)?,
658 );
659 push_generated(
660 ESCALATIONS_FILE,
661 "derived:escalations-fold",
662 EntryKind::Escalations,
663 to_json_bytes(&outcomes.escalations)?,
664 );
665 push_generated(
666 COST_FILE,
667 "derived:cost-fold",
668 EntryKind::Cost,
669 to_json_bytes(&cost)?,
670 );
671 push_generated(LOG_FILE, "file:events.jsonl", EntryKind::Log, log_bytes);
672 files.extend(artefact_files);
673 entries.extend(artefact_entries);
674
675 Ok(EvidenceBundle {
676 manifest: EvidenceManifest {
677 version: BUNDLE_FORMAT_VERSION,
678 mission_id: mission_id.to_string(),
679 entries,
680 },
681 files,
682 })
683}
684
685fn absolute_lexical(path: &Path) -> anyhow::Result<PathBuf> {
693 let absolute = std::path::absolute(path)?;
694 let mut out = PathBuf::new();
695 for component in absolute.components() {
696 match component {
697 Component::CurDir => {}
698 Component::ParentDir => {
699 if out.file_name().is_some() {
700 out.pop();
701 } else if !out.has_root() {
702 out.push("..");
703 }
704 }
705 other => out.push(other.as_os_str()),
706 }
707 }
708 Ok(out)
709}
710
711struct OutDirPlan {
714 anchor: PathBuf,
719 tail: Vec<String>,
721 canonical_out: PathBuf,
725}
726
727fn plan_out_dir(out_dir: &Path) -> anyhow::Result<OutDirPlan> {
734 let normalized = absolute_lexical(out_dir)?;
735 let mut anchor = normalized.as_path();
736 loop {
737 match std::fs::symlink_metadata(anchor) {
738 Ok(metadata) => {
739 let file_type = metadata.file_type();
740 if file_type.is_symlink() {
741 return Err(EngineError::InvalidState(format!(
742 "bundle output {} resolves through a symlinked component: {}",
743 out_dir.display(),
744 anchor.display()
745 ))
746 .into());
747 }
748 if !file_type.is_dir() {
749 return Err(EngineError::InvalidState(format!(
750 "bundle output {} is blocked by a non-directory component: {}",
751 out_dir.display(),
752 anchor.display()
753 ))
754 .into());
755 }
756 break;
757 }
758 Err(error) if error.kind() == ErrorKind::NotFound => {
759 anchor = anchor.parent().ok_or_else(|| {
760 EngineError::InvalidState(format!(
761 "bundle output {} has no existing ancestor",
762 out_dir.display()
763 ))
764 })?;
765 }
766 Err(error) => return Err(error.into()),
767 }
768 }
769 let canonical_anchor = anchor.canonicalize()?;
770 let mut tail = Vec::new();
771 let mut canonical_out = canonical_anchor.clone();
772 for component in normalized
775 .strip_prefix(anchor)
776 .map_err(|_| {
777 EngineError::InvalidState(format!(
778 "bundle output {} escaped its anchor",
779 out_dir.display()
780 ))
781 })?
782 .components()
783 {
784 let Component::Normal(name) = component else {
785 return Err(EngineError::InvalidState(format!(
786 "bundle output {} has a non-normal component below its anchor",
787 out_dir.display()
788 ))
789 .into());
790 };
791 let name = name.to_str().ok_or_else(|| {
792 EngineError::InvalidState(format!(
793 "bundle output {} has a non-UTF-8 component",
794 out_dir.display()
795 ))
796 })?;
797 tail.push(name.to_string());
798 canonical_out.push(name);
799 }
800 Ok(OutDirPlan {
801 anchor: canonical_anchor,
802 tail,
803 canonical_out,
804 })
805}
806
807fn pin_out_dir(plan: &OutDirPlan) -> anyhow::Result<Dir> {
814 let mut dir = Dir::open_ambient_dir(&plan.anchor, ambient_authority())?;
815 let mut walked = plan.anchor.clone();
816 for component in &plan.tail {
817 walked.push(component);
818 dir = crate::paths::open_real_subdir(&dir, component, &walked, true)?;
819 }
820 Ok(dir)
821}
822
823fn write_bundle_files(bundle: &EvidenceBundle, out_dir: &Path, out: &Dir) -> anyhow::Result<usize> {
833 let mut entries = out.entries().map_err(|error| {
834 EngineError::InvalidState(format!(
835 "bundle output {} is not an empty directory: {error}",
836 out_dir.display()
837 ))
838 })?;
839 if entries.next().is_some() {
840 return Err(EngineError::InvalidState(format!(
841 "bundle output {} is not empty; choose a fresh --out or remove it",
842 out_dir.display()
843 ))
844 .into());
845 }
846
847 let manifest_bytes = to_json_bytes(&bundle.manifest)?;
848 let mut written = 0usize;
849 for (relative, bytes) in bundle
852 .files
853 .iter()
854 .map(|file| (file.path.as_str(), file.bytes.as_slice()))
855 .chain([(MANIFEST_FILE, manifest_bytes.as_slice())])
856 {
857 let mut names = Vec::new();
858 for component in relative.split('/') {
859 if component.is_empty() || component == "." || component == ".." {
860 return Err(
861 EngineError::InvalidState(format!("unsafe bundle path {relative:?}")).into(),
862 );
863 }
864 names.push(component);
865 }
866 let (leaf, parents) = names.split_last().expect("validated non-empty");
867 let mut dir = None;
868 let mut display = out_dir.to_path_buf();
869 for parent in parents {
870 display.push(parent);
871 dir = Some(crate::paths::open_real_subdir(
872 dir.as_ref().unwrap_or(out),
873 parent,
874 &display,
875 true,
876 )?);
877 }
878 let mut options = OpenOptions::new();
879 options
880 .write(true)
881 .create_new(true)
882 .follow(FollowSymlinks::No);
883 let mut file = dir.as_ref().unwrap_or(out).open_with(leaf, &options)?;
884 file.write_all(bytes)?;
885 written += 1;
886 }
887 Ok(written)
888}
889
890pub fn write_evidence_bundle(bundle: &EvidenceBundle, out_dir: &Path) -> anyhow::Result<usize> {
898 let plan = plan_out_dir(out_dir)?;
899 let out = pin_out_dir(&plan)?;
900 write_bundle_files(bundle, out_dir, &out)
901}
902
903pub fn export_evidence_bundle(
918 repo_root: &Path,
919 mission_id: &str,
920 out_dir: &Path,
921) -> anyhow::Result<ExportOutcome> {
922 let paths = MissionPaths::new(repo_root, mission_id);
923 paths.require_no_follow()?;
924 let refusal = || {
925 EngineError::InvalidState(format!(
926 "bundle output {} must be outside the mission dir {}",
927 out_dir.display(),
928 paths.mission_dir().display()
929 ))
930 };
931 let out_lexical = absolute_lexical(out_dir)?;
934 let mission_lexical = absolute_lexical(&paths.mission_dir())?;
935 if out_lexical.starts_with(&mission_lexical) {
936 return Err(refusal().into());
937 }
938 let plan = plan_out_dir(out_dir)?;
945 match std::fs::symlink_metadata(paths.mission_dir()) {
946 Ok(_) => {
947 if plan
948 .canonical_out
949 .starts_with(paths.mission_dir().canonicalize()?)
950 {
951 return Err(refusal().into());
952 }
953 }
954 Err(error) if error.kind() == ErrorKind::NotFound => {}
955 Err(error) => return Err(error.into()),
956 }
957
958 let bundle = assemble_evidence_bundle(repo_root, mission_id)?;
959 let out = pin_out_dir(&plan)?;
960 let files_written = write_bundle_files(&bundle, out_dir, &out)?;
961 let resolved_artefacts = bundle
962 .manifest
963 .entries
964 .iter()
965 .filter(|entry| entry.status == Some(ArtefactStatus::Resolved))
966 .count();
967 let unresolved_artefacts = bundle
968 .manifest
969 .entries
970 .iter()
971 .filter(|entry| entry.status == Some(ArtefactStatus::Unresolved))
972 .count();
973 Ok(ExportOutcome {
974 out_dir: out_dir.to_path_buf(),
975 files_written,
976 resolved_artefacts,
977 unresolved_artefacts,
978 })
979}
980
981#[cfg(test)]
982mod tests {
983 use super::*;
984 use crate::event_log::{EventLog, LockForce};
985 use crate::events::EventKind;
986 use crate::gate::{GateKind, GateSurface, GateVerdict};
987 use crate::types::{GrantKind, MissionConfig, Plan, Role, RunResult, TokenUsage};
988 use std::collections::BTreeMap;
989 use std::time::Duration;
990 use tempfile::TempDir;
991
992 fn seed_mission(repo_root: &Path, id: &str, kinds: Vec<EventKind>) -> MissionPaths {
996 let paths = MissionPaths::new(repo_root, id);
997 let mut log = EventLog::acquire(&paths, id, Duration::ZERO, LockForce::No).unwrap();
998 for kind in kinds {
999 log.append(kind).unwrap();
1000 }
1001 paths
1002 }
1003
1004 fn sample_plan() -> Plan {
1005 Plan {
1006 goal: "ship the thing".into(),
1007 validation_contract: vec![],
1008 milestones: vec![],
1009 considered_alternatives: None,
1010 command_grants: vec![],
1011 touch_set: vec![],
1012 standards_manifest: None,
1013 reviewer_independence: None,
1014 }
1015 }
1016
1017 fn created() -> EventKind {
1018 EventKind::MissionCreated {
1019 goal: "ship the thing".into(),
1020 base_branch: "main".into(),
1021 mission_branch: "kranz/mission-x".into(),
1022 config: MissionConfig::default(),
1023 }
1024 }
1025
1026 fn gate_result(
1027 gate: &str,
1028 surface: GateSurface,
1029 kind: GateKind,
1030 index: u32,
1031 artefact_ref: &str,
1032 ) -> EventKind {
1033 EventKind::GateResult {
1034 gate: gate.to_string(),
1035 surface,
1036 kind,
1037 index,
1038 verdict: GateVerdict::Pass,
1039 artefact_ref: artefact_ref.to_string(),
1040 artefact_detail: None,
1041 score: None,
1042 threshold: None,
1043 rule_ids: Vec::new(),
1044 }
1045 }
1046
1047 fn worker_spawned(run_id: &str, role: Role, model: &str) -> EventKind {
1048 EventKind::WorkerSpawned {
1049 backend: None,
1050 run_id: run_id.to_string(),
1051 role,
1052 feature_id: None,
1053 milestone_id: None,
1054 candidate: None,
1055 executor_route: None,
1056 sdk_session_id: format!("sess-{run_id}"),
1057 model: model.to_string(),
1058 quant: "n/a".to_string(),
1059 weight_hash: None,
1060 prompt_hash: "aaaabbbbcccc".to_string(),
1061 transcript_path: MissionPaths::transcript_rel(run_id),
1062 }
1063 }
1064
1065 fn seed_full_mission(root: &Path) -> MissionPaths {
1073 let paths = seed_mission(
1074 root,
1075 "m-1",
1076 vec![
1077 created(),
1078 EventKind::PlanApproved {
1079 plan: sample_plan(),
1080 base_sha: Some("deadbeef".to_string()),
1081 },
1082 gate_result(
1083 "vacuous-filter",
1084 GateSurface::Approval,
1085 GateKind::Deterministic,
1086 0,
1087 "contract gate vacuous-filter",
1088 ),
1089 gate_result(
1090 "merge-gate-suite",
1091 GateSurface::Approval,
1092 GateKind::Deterministic,
1093 1,
1094 "file:runs/gate-base.jsonl",
1095 ),
1096 gate_result(
1097 "merge-gate-suite-recheck",
1098 GateSurface::Approval,
1099 GateKind::Deterministic,
1100 2,
1101 "file:runs/gate-base.jsonl",
1104 ),
1105 gate_result(
1106 "plan-review",
1107 GateSurface::Approval,
1108 GateKind::ModelJudged,
1109 0,
1110 "file:runs/gone.jsonl",
1111 ),
1112 worker_spawned("r-1", Role::Worker, "gpt-5"),
1113 EventKind::WorkerCompleted {
1114 run_id: "r-1".into(),
1115 result: RunResult::Pass,
1116 tokens: TokenUsage {
1117 input: 100,
1118 output: 50,
1119 cache_read: 0,
1120 cache_write: 0,
1121 },
1122 cost_usd: Some(0.42),
1123 report: None,
1124 },
1125 EventKind::FeatureCompleted {
1126 feature_id: "f-1-1".into(),
1127 commits: vec!["abc1234 implement the widget".into()],
1128 },
1129 EventKind::GrantRequested {
1130 milestone_id: "ms-1".into(),
1131 kind: GrantKind::Command,
1132 command: "cargo test".into(),
1133 },
1134 EventKind::GrantApproved {
1135 kind: GrantKind::Command,
1136 command: "cargo test".into(),
1137 },
1138 worker_spawned("r-2", Role::Worker, "my-local-model"),
1139 worker_spawned("r-3", Role::ValidatorScrutiny, "sonnet"),
1140 EventKind::MilestoneBlocked {
1141 block_context: None,
1142 milestone_id: "ms-1".into(),
1143 reason: "fix-cycle cap".into(),
1144 },
1145 EventKind::MilestoneUnblocked {
1146 block_context: None,
1147 milestone_id: "ms-1".into(),
1148 reason: "user skipped findings".into(),
1149 validator_guidance: None,
1150 },
1151 EventKind::UserMessage {
1152 text: "skip the flaky test".into(),
1153 interrupt: false,
1154 },
1155 gate_result(
1156 "merge-gate-suite",
1157 GateSurface::FinalGate,
1158 GateKind::Deterministic,
1159 0,
1160 ".kranz/merge-gates.json",
1161 ),
1162 EventKind::MissionCompleted {},
1163 ],
1164 );
1165 std::fs::write(paths.runs_dir().join("gate-base.jsonl"), b"{}").unwrap();
1167 std::fs::write(paths.runs_dir().join("r-1.jsonl"), b"{}").unwrap();
1168 std::fs::write(paths.plan_md_file(), b"# plan\n").unwrap();
1169 std::fs::write(paths.plan_file(), b"{}").unwrap();
1170 std::fs::write(paths.report_file(), b"# report\n").unwrap();
1171 paths
1172 }
1173
1174 fn collect_files(dir: &Path) -> BTreeMap<String, Vec<u8>> {
1178 let mut out = BTreeMap::new();
1179 let mut stack = vec![dir.to_path_buf()];
1180 while let Some(current) = stack.pop() {
1181 for entry in std::fs::read_dir(¤t).unwrap() {
1182 let path = entry.unwrap().path();
1183 if path.is_dir() {
1184 stack.push(path);
1185 } else {
1186 let relative = path
1187 .strip_prefix(dir)
1188 .unwrap()
1189 .components()
1190 .map(|c| c.as_os_str().to_str().unwrap().to_string())
1191 .collect::<Vec<_>>()
1192 .join("/");
1193 out.insert(relative, std::fs::read(&path).unwrap());
1194 }
1195 }
1196 }
1197 out
1198 }
1199
1200 fn manifest_entry<'m>(manifest: &'m EvidenceManifest, source: &str) -> &'m ManifestEntry {
1201 manifest
1202 .entries
1203 .iter()
1204 .find(|entry| entry.source == source)
1205 .unwrap_or_else(|| panic!("manifest entry {source} missing"))
1206 }
1207
1208 #[test]
1215 fn evidence_bundle_opens_standalone_with_no_host_paths() {
1216 let tmp = TempDir::new().unwrap();
1217 seed_full_mission(tmp.path());
1218 let out = tmp.path().join("bundle-out");
1219 let outcome = export_evidence_bundle(tmp.path(), "m-1", &out).unwrap();
1220
1221 for name in [
1222 MANIFEST_FILE,
1223 SUMMARY_FILE,
1224 CHAIN_FILE,
1225 ESCALATIONS_FILE,
1226 COST_FILE,
1227 LOG_FILE,
1228 ] {
1229 assert!(out.join(name).is_file(), "{name} missing from the bundle");
1230 }
1231 for shipped in [
1232 "artefacts/runs/gate-base.jsonl",
1233 "artefacts/runs/r-1.jsonl",
1234 "artefacts/plan.md",
1235 "artefacts/plan.json",
1236 "artefacts/report.md",
1237 ] {
1238 assert!(
1239 out.join(shipped).is_file(),
1240 "{shipped} missing from artefacts/"
1241 );
1242 }
1243 assert_eq!(outcome.files_written, 11);
1246 assert_eq!(outcome.resolved_artefacts, 5);
1247 assert_eq!(outcome.unresolved_artefacts, 5);
1248
1249 let host = tmp.path().to_string_lossy().to_string();
1252 let files = collect_files(&out);
1253 for (relative, bytes) in &files {
1254 let text = String::from_utf8_lossy(bytes);
1255 assert!(
1256 !text.contains(&host),
1257 "host path leaked into bundle file {relative}"
1258 );
1259 }
1260
1261 let manifest: EvidenceManifest =
1264 serde_json::from_str(&std::fs::read_to_string(out.join(MANIFEST_FILE)).unwrap())
1265 .unwrap();
1266 assert_eq!(manifest.version, BUNDLE_FORMAT_VERSION);
1267 assert_eq!(manifest.mission_id, "m-1");
1268 for entry in &manifest.entries {
1269 if let (Some(path), Some(sha256)) = (&entry.path, &entry.sha256) {
1270 let bytes = std::fs::read(out.join(path)).unwrap();
1271 assert_eq!(&sha256_hex(&bytes), sha256, "sha256 mismatch for {path}");
1272 }
1273 }
1274 assert_eq!(
1276 manifest
1277 .entries
1278 .iter()
1279 .filter(|entry| entry.source == "file:runs/gate-base.jsonl")
1280 .count(),
1281 1
1282 );
1283 assert!(manifest
1285 .entries
1286 .iter()
1287 .all(|entry| entry.source != "contract gate vacuous-filter"));
1288 let gone = manifest_entry(&manifest, "file:runs/gone.jsonl");
1291 assert_eq!(gone.status, Some(ArtefactStatus::Unresolved));
1292 assert!(gone.path.is_none() && gone.sha256.is_none());
1293 let chain: ProvenanceChain =
1295 serde_json::from_str(&std::fs::read_to_string(out.join(CHAIN_FILE)).unwrap()).unwrap();
1296 assert_eq!(chain.gates.len(), 5);
1297 let cost: MissionCostSummary =
1299 serde_json::from_str(&std::fs::read_to_string(out.join(COST_FILE)).unwrap()).unwrap();
1300 assert_eq!(cost.total_cost_usd, 0.42);
1301 assert_eq!(cost.non_meta_commits, 1);
1302 assert_eq!(cost.usd_per_commit, Some(0.42));
1303 assert!(cost.closed);
1304 }
1305
1306 #[test]
1310 fn evidence_bundle_is_byte_identical_across_exports() {
1311 let tmp = TempDir::new().unwrap();
1312 seed_full_mission(tmp.path());
1313
1314 let first = assemble_evidence_bundle(tmp.path(), "m-1").unwrap();
1315 let second = assemble_evidence_bundle(tmp.path(), "m-1").unwrap();
1316 assert_eq!(first, second);
1317 assert_eq!(
1318 serde_json::to_string_pretty(&first.manifest).unwrap(),
1319 serde_json::to_string_pretty(&second.manifest).unwrap()
1320 );
1321
1322 let out_a = tmp.path().join("out-a");
1323 let out_b = tmp.path().join("out-b");
1324 export_evidence_bundle(tmp.path(), "m-1", &out_a).unwrap();
1325 export_evidence_bundle(tmp.path(), "m-1", &out_b).unwrap();
1326 assert_eq!(collect_files(&out_a), collect_files(&out_b));
1327 }
1328
1329 #[test]
1334 fn evidence_bundle_redacted_secret_leaves_fingerprints_only() {
1335 let tmp = TempDir::new().unwrap();
1336 let secret = "sk-ant-F00barBazQuux9_7";
1337 let text = format!("the key is {secret} ok");
1338 let findings = crate::scrub::scan_text(&text);
1340 assert_eq!(findings.len(), 1, "fixture must trip exactly one rule");
1341 let fingerprint = findings[0].fingerprint.clone();
1342
1343 seed_mission(
1344 tmp.path(),
1345 "m-sec",
1346 vec![
1347 created(),
1348 EventKind::UserMessage {
1349 text,
1350 interrupt: false,
1351 },
1352 EventKind::MissionCompleted {},
1353 ],
1354 );
1355
1356 let out = tmp.path().join("bundle-sec");
1357 export_evidence_bundle(tmp.path(), "m-sec", &out).unwrap();
1358 let files = collect_files(&out);
1359 assert!(!files.is_empty());
1360 for (relative, bytes) in &files {
1361 let text = String::from_utf8_lossy(bytes);
1362 assert!(
1363 !text.contains(secret),
1364 "secret value leaked into bundle file {relative}"
1365 );
1366 }
1367 let log = String::from_utf8_lossy(&files[LOG_FILE]).to_string();
1370 assert!(log.contains(&fingerprint), "audit fingerprint missing");
1371 assert!(log.contains("[REDACTED]"));
1372 }
1373
1374 #[test]
1381 fn evidence_bundle_scrubs_artefact_bytes_and_hashes_the_redacted_form() {
1382 let tmp = TempDir::new().unwrap();
1383 let secret = "sk-ant-F00barBazQuux9_7";
1384 let paths = seed_full_mission(tmp.path());
1385 let planted = format!("{{\"text\":\"the key is {secret} ok\"}}\n");
1388 std::fs::write(paths.runs_dir().join("r-1.jsonl"), planted.as_bytes()).unwrap();
1389
1390 let out = tmp.path().join("bundle-artefact-secret");
1391 export_evidence_bundle(tmp.path(), "m-1", &out).unwrap();
1392 let files = collect_files(&out);
1393 for (relative, bytes) in &files {
1394 let text = String::from_utf8_lossy(bytes);
1395 assert!(
1396 !text.contains(secret),
1397 "secret value leaked into bundle file {relative}"
1398 );
1399 }
1400 let shipped = &files["artefacts/runs/r-1.jsonl"];
1401 assert!(String::from_utf8_lossy(shipped).contains("[REDACTED]"));
1402
1403 let manifest: EvidenceManifest =
1405 serde_json::from_str(&std::fs::read_to_string(out.join(MANIFEST_FILE)).unwrap())
1406 .unwrap();
1407 let entry = manifest_entry(&manifest, "file:runs/r-1.jsonl");
1408 assert_eq!(entry.sha256.as_deref(), Some(sha256_hex(shipped).as_str()));
1409 }
1410
1411 #[test]
1415 fn evidence_bundle_scrubs_non_utf8_artefact_bytes_lossily() {
1416 let tmp = TempDir::new().unwrap();
1417 let secret = "sk-ant-F00barBazQuux9_7";
1418 let paths = seed_full_mission(tmp.path());
1419 let mut planted = format!("the key is {secret} ok").into_bytes();
1420 planted.push(0xff);
1421 std::fs::write(paths.runs_dir().join("r-1.jsonl"), &planted).unwrap();
1422
1423 let bundle = assemble_evidence_bundle(tmp.path(), "m-1").unwrap();
1424 let shipped = bundle
1425 .files
1426 .iter()
1427 .find(|file| file.path == "artefacts/runs/r-1.jsonl")
1428 .expect("artefact shipped");
1429 let text = String::from_utf8(shipped.bytes.clone()).expect("lossy decode yields UTF-8");
1430 assert!(!text.contains(secret));
1431 assert!(text.contains("[REDACTED]"));
1432 assert!(
1433 text.contains('\u{fffd}'),
1434 "invalid byte became a replacement"
1435 );
1436 }
1437
1438 #[test]
1442 fn evidence_bundle_missing_artefact_bytes_become_unresolved_manifest_entries() {
1443 let tmp = TempDir::new().unwrap();
1444 let paths = seed_full_mission(tmp.path());
1445 std::fs::remove_dir_all(paths.runs_dir()).unwrap();
1446
1447 let bundle = assemble_evidence_bundle(tmp.path(), "m-1").unwrap();
1448 for source in [
1449 "file:runs/gate-base.jsonl",
1450 "file:runs/gone.jsonl",
1451 "file:runs/r-1.jsonl",
1452 "file:runs/r-2.jsonl",
1453 "file:runs/r-3.jsonl",
1454 "file:research.md",
1455 "file:estimate.json",
1456 ] {
1457 let entry = manifest_entry(&bundle.manifest, source);
1458 assert_eq!(
1459 entry.status,
1460 Some(ArtefactStatus::Unresolved),
1461 "{source} must be unresolved with its bytes gone"
1462 );
1463 assert!(entry.path.is_none() && entry.sha256.is_none());
1464 }
1465 for source in ["file:plan.md", "file:plan.json", "file:report.md"] {
1467 assert_eq!(
1468 manifest_entry(&bundle.manifest, source).status,
1469 Some(ArtefactStatus::Resolved),
1470 "{source} must still resolve"
1471 );
1472 }
1473 assert!(bundle
1475 .files
1476 .iter()
1477 .all(|file| !file.path.starts_with("artefacts/runs/")));
1478 }
1479
1480 #[test]
1484 fn evidence_bundle_refuses_out_dir_inside_the_mission_dir() {
1485 let tmp = TempDir::new().unwrap();
1486 let paths = seed_full_mission(tmp.path());
1487 let inside = paths.mission_dir().join("bundle");
1488 let result = export_evidence_bundle(tmp.path(), "m-1", &inside);
1489 assert!(result.is_err(), "an in-mission --out must be refused");
1490 assert!(!inside.exists(), "nothing must be written on refusal");
1491 }
1492
1493 #[test]
1496 fn evidence_bundle_refuses_a_non_empty_out_dir() {
1497 let tmp = TempDir::new().unwrap();
1498 seed_full_mission(tmp.path());
1499 let out = tmp.path().join("bundle-used");
1500 std::fs::create_dir_all(&out).unwrap();
1501 std::fs::write(out.join("stale.txt"), b"stale").unwrap();
1502 let result = export_evidence_bundle(tmp.path(), "m-1", &out);
1503 assert!(result.is_err(), "a non-empty --out must be refused");
1504 assert_eq!(
1505 std::fs::read_to_string(out.join("stale.txt")).unwrap(),
1506 "stale"
1507 );
1508 }
1509
1510 #[test]
1517 fn evidence_single_snapshot_torn_tail_is_excluded_from_parse_and_bytes() {
1518 use std::io::Write as _;
1519 let tmp = TempDir::new().unwrap();
1520 let paths = seed_full_mission(tmp.path());
1521 let pristine = std::fs::read(paths.events_file()).unwrap();
1522 let mut file = std::fs::OpenOptions::new()
1525 .append(true)
1526 .open(paths.events_file())
1527 .unwrap();
1528 file.write_all(b"{\"seq\":999,\"ts\":\"torn").unwrap();
1529 drop(file);
1530
1531 let bundle = assemble_evidence_bundle(tmp.path(), "m-1").unwrap();
1532 let shipped = bundle
1533 .files
1534 .iter()
1535 .find(|file| file.path == LOG_FILE)
1536 .expect("the raw log ships");
1537 assert_eq!(
1538 shipped.bytes, pristine,
1539 "the torn tail is in NEITHER the events nor the shipped bytes"
1540 );
1541 assert_eq!(bundle.manifest.mission_id, "m-1");
1543 let replay = paths.runs_dir().join("replay.jsonl");
1547 std::fs::write(&replay, &shipped.bytes).unwrap();
1548 let folded = crate::event_log::EventLog::read_events(&paths.events_file()).unwrap();
1549 let refolded = crate::event_log::EventLog::read_events(&replay).unwrap();
1550 assert_eq!(refolded.len(), folded.len());
1551 assert_eq!(
1552 refolded.last().map(|event| event.seq),
1553 folded.last().map(|event| event.seq)
1554 );
1555 }
1556
1557 #[test]
1564 fn evidence_outdir_containment_refuses_dotdot_escape_into_the_mission() {
1565 let tmp = TempDir::new().unwrap();
1566 let paths = seed_full_mission(tmp.path());
1567 let escape = tmp
1568 .path()
1569 .join("outside")
1570 .join("..")
1571 .join(".kranz")
1572 .join("missions")
1573 .join("m-1")
1574 .join("bundle");
1575 let result = export_evidence_bundle(tmp.path(), "m-1", &escape);
1576 assert!(result.is_err(), "the `..` shape must be refused");
1577 assert!(
1578 !paths.mission_dir().join("bundle").exists(),
1579 "nothing must be written on refusal"
1580 );
1581 }
1582
1583 #[cfg(unix)]
1588 #[test]
1589 fn evidence_outdir_containment_refuses_a_symlinked_component() {
1590 use std::os::unix::fs::symlink;
1591 let tmp = TempDir::new().unwrap();
1592 let paths = seed_full_mission(tmp.path());
1593 let link = tmp.path().join("linked-out");
1594 symlink(paths.mission_dir(), &link).unwrap();
1595 let result = export_evidence_bundle(tmp.path(), "m-1", &link.join("bundle"));
1596 let err = result.expect_err("a symlinked out-dir component must be refused");
1597 assert!(err.to_string().contains("symlinked"), "{err}");
1598 assert!(
1599 !paths.mission_dir().join("bundle").exists(),
1600 "nothing must be written through the link"
1601 );
1602 }
1603
1604 #[test]
1607 fn evidence_outdir_containment_normal_external_dir_works() {
1608 let tmp = TempDir::new().unwrap();
1609 seed_full_mission(tmp.path());
1610 let out = tmp.path().join("fresh").join("bundle-out");
1611 let outcome = export_evidence_bundle(tmp.path(), "m-1", &out).unwrap();
1612 assert!(outcome.files_written > 0);
1613 assert!(out.join(MANIFEST_FILE).is_file());
1614 assert!(out.join(LOG_FILE).is_file());
1615 }
1616
1617 fn pinned_plan() -> Plan {
1623 let rule = |id: &str, revision: u64, status: &str| crate::types::PinnedRule {
1624 id: id.to_string(),
1625 revision,
1626 rfc: "RFC-001".to_string(),
1627 level: "must".to_string(),
1628 effective_status: status.to_string(),
1629 statement: format!("statement for {id}"),
1630 domains: Vec::new(),
1631 stages: vec!["validation".to_string()],
1632 when_paths: Vec::new(),
1633 task_classes: Vec::new(),
1634 checker: Some("gate:zz-gate".to_string()),
1635 waivable: false,
1636 };
1637 Plan {
1638 standards_manifest: Some(Box::new(crate::types::StandardsPin {
1639 pack_name: "zz-pack".to_string(),
1640 pack_dir: "vendor/pack".to_string(),
1641 standards_root: "standards".to_string(),
1642 digest: "ab".repeat(32),
1643 source: crate::types::StandardsPinSource::RepoTracked,
1644 task_class: None,
1645 touch_set: vec!["crates/**".to_string()],
1646 context_paths: Vec::new(),
1647 gates: Vec::new(),
1648 rules: vec![
1649 rule("ZZ-FAIL-001", 2, "enforced"),
1650 rule("ZZ-PASS-001", 1, "enforced"),
1651 rule("ZZ-QUIET-001", 1, "enforced"),
1652 ],
1653 })),
1654 ..sample_plan()
1655 }
1656 }
1657
1658 fn seed_pinned_mission(root: &Path) -> MissionPaths {
1663 let mut gate = gate_result(
1664 "zz-gate",
1665 GateSurface::FinalGate,
1666 GateKind::Deterministic,
1667 0,
1668 "file:runs/gone.jsonl",
1669 );
1670 if let EventKind::GateResult { rule_ids, .. } = &mut gate {
1671 *rule_ids = vec!["ZZ-PASS-001".to_string()];
1672 }
1673 seed_mission(
1674 root,
1675 "m-1",
1676 vec![
1677 created(),
1678 EventKind::PlanApproved {
1679 plan: pinned_plan(),
1680 base_sha: Some("deadbeef".to_string()),
1681 },
1682 EventKind::StandardsResolved {
1683 source: "repo-tracked".to_string(),
1684 pack_name: "zz-pack".to_string(),
1685 standards_root: "standards".to_string(),
1686 digest: "ab".repeat(32),
1687 stage: "approval".to_string(),
1688 task_class: None,
1689 touch_set: vec!["crates/**".to_string()],
1690 context_paths: Vec::new(),
1691 rules: Vec::new(),
1692 approval_seq: 2,
1693 },
1694 gate,
1695 EventKind::ValidationFinding {
1696 milestone_id: "ms-1".into(),
1697 run_id: "v-1".into(),
1698 finding: crate::types::Finding {
1699 subject: "a-1".into(),
1700 severity: "major".into(),
1701 evidence: "the rule failed".into(),
1702 suggested_fix: String::new(),
1703 class: String::new(),
1704 rule: Some(crate::types::RuleCitation {
1705 id: "ZZ-FAIL-001".to_string(),
1706 revision: 2,
1707 source: "zz-pack standards".to_string(),
1708 digest: "ab".repeat(32),
1709 lifecycle: "enforced".to_string(),
1710 level: "must".to_string(),
1711 checker: Some("gate:zz-gate".to_string()),
1712 }),
1713 },
1714 },
1715 EventKind::MissionCompleted {},
1716 ],
1717 )
1718 }
1719
1720 #[test]
1725 fn flight_rules_provenance_bundle_renders_coverage_byte_identically() {
1726 let tmp = TempDir::new().unwrap();
1727 seed_pinned_mission(tmp.path());
1728 let first = assemble_evidence_bundle(tmp.path(), "m-1").unwrap();
1729 let second = assemble_evidence_bundle(tmp.path(), "m-1").unwrap();
1730 assert_eq!(first, second, "same log → byte-identical bundle");
1731
1732 let summary = first
1733 .files
1734 .iter()
1735 .find(|file| file.path == SUMMARY_FILE)
1736 .expect("summary ships");
1737 let summary = String::from_utf8(summary.bytes.clone()).unwrap();
1738 assert!(
1739 summary.contains("## Flight Rules standards coverage"),
1740 "{summary}"
1741 );
1742 assert!(
1743 summary.contains("| ZZ-FAIL-001 | r2 | enforced | must | gate:zz-gate | failed |"),
1744 "{summary}"
1745 );
1746 assert!(
1747 summary.contains("| ZZ-PASS-001 | r1 | enforced | must | gate:zz-gate | passed |"),
1748 "{summary}"
1749 );
1750 assert!(
1751 summary
1752 .contains("| ZZ-QUIET-001 | r1 | enforced | must | gate:zz-gate | not-evaluated |"),
1753 "{summary}"
1754 );
1755 assert!(
1757 summary.contains("gate.result seq 4 zz-gate pass `file:runs/gone.jsonl`"),
1758 "{summary}"
1759 );
1760
1761 let chain = first
1762 .files
1763 .iter()
1764 .find(|file| file.path == CHAIN_FILE)
1765 .expect("the chain ships");
1766 let chain = String::from_utf8(chain.bytes.clone()).unwrap();
1767 assert!(chain.contains("\"standards\""), "{chain}");
1768 assert!(
1769 chain.contains("\"disposition\": \"not-evaluated\""),
1770 "{chain}"
1771 );
1772 }
1773
1774 #[test]
1779 fn flight_rules_provenance_bundle_removed_artefacts_stay_unresolved() {
1780 let tmp = TempDir::new().unwrap();
1781 seed_pinned_mission(tmp.path());
1782 let bundle = assemble_evidence_bundle(tmp.path(), "m-1").unwrap();
1784 let entry = manifest_entry(&bundle.manifest, "file:runs/gone.jsonl");
1785 assert_eq!(entry.status, Some(ArtefactStatus::Unresolved));
1786 assert_eq!(entry.path, None, "an unresolved entry has no bytes path");
1787 let summary = bundle
1791 .files
1792 .iter()
1793 .find(|file| file.path == SUMMARY_FILE)
1794 .expect("summary ships");
1795 let summary = String::from_utf8(summary.bytes.clone()).unwrap();
1796 assert!(summary.contains("`file:runs/gone.jsonl`"), "{summary}");
1797 assert!(
1798 summary.contains("Absence of evidence is never rendered as pass"),
1799 "{summary}"
1800 );
1801 }
1802
1803 #[test]
1808 fn flight_rules_provenance_bundle_pre_flight_rules_mission_is_unchanged() {
1809 let tmp = TempDir::new().unwrap();
1810 seed_full_mission(tmp.path());
1811 let bundle = assemble_evidence_bundle(tmp.path(), "m-1").unwrap();
1812 let summary = bundle
1813 .files
1814 .iter()
1815 .find(|file| file.path == SUMMARY_FILE)
1816 .expect("summary ships");
1817 let summary = String::from_utf8(summary.bytes.clone()).unwrap();
1818 assert!(
1819 !summary.contains("Flight Rules standards coverage"),
1820 "no pin, no matrix: {summary}"
1821 );
1822 let chain = bundle
1823 .files
1824 .iter()
1825 .find(|file| file.path == CHAIN_FILE)
1826 .expect("the chain ships");
1827 let chain = String::from_utf8(chain.bytes.clone()).unwrap();
1828 assert!(
1829 !chain.contains("\"standards\""),
1830 "a pre-Flight-Rules chain carries no standards key: {chain}"
1831 );
1832 }
1833}