1use std::collections::BTreeSet;
4use std::path::{Component, Path, PathBuf};
5
6use serde::{Deserialize, Serialize};
7use serde_json::Value as JsonValue;
8
9use super::super::{now_rfc3339, ReplayTraceRun};
10use super::api::load_crystallization_trace;
11use super::shadow::{find_sequence_start, shadow_candidate};
12use super::types::{
13 CrystallizationApproval, CrystallizationArtifacts, CrystallizationReport,
14 CrystallizationSideEffect, CrystallizationTrace, PromotionApprovalRecord, PromotionCriteria,
15 PromotionDivergenceRecord, SavingsEstimate, SegmentKind, ShadowRunReport, WorkflowCandidate,
16 WorkflowCandidateStep, BUNDLE_EVAL_PACK_FILE, BUNDLE_FIXTURES_DIR, BUNDLE_MANIFEST_FILE,
17 BUNDLE_REPORT_FILE, BUNDLE_SCHEMA, BUNDLE_SCHEMA_VERSION, BUNDLE_WORKFLOW_FILE,
18 DEFAULT_ROLLOUT_POLICY,
19};
20use crate::value::VmError;
21
22#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
44#[serde(default)]
45pub struct BundleGenerator {
46 pub tool: String,
47 pub version: String,
48}
49
50impl Default for BundleGenerator {
51 fn default() -> Self {
52 Self {
53 tool: "harn".to_string(),
54 version: env!("CARGO_PKG_VERSION").to_string(),
55 }
56 }
57}
58
59#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
60#[serde(default)]
61pub struct BundleWorkflowRef {
62 pub path: String,
64 pub name: String,
66 pub package_name: String,
68 pub package_version: String,
70}
71
72#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
73#[serde(default)]
74pub struct BundleSourceTrace {
75 pub trace_id: String,
76 pub source_hash: String,
77 pub source_url: Option<String>,
80 pub source_receipt_id: Option<String>,
84 pub fixture_path: Option<String>,
87}
88
89#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
90#[serde(default)]
91pub struct BundleStep {
92 pub index: usize,
93 pub kind: String,
94 pub name: String,
95 pub segment: SegmentKind,
96 pub parameter_refs: Vec<String>,
97 pub side_effects: Vec<CrystallizationSideEffect>,
98 pub capabilities: Vec<String>,
99 pub required_secrets: Vec<String>,
100 pub approval: Option<CrystallizationApproval>,
101 pub review_notes: Vec<String>,
102}
103
104impl BundleStep {
105 fn from_candidate_step(step: &WorkflowCandidateStep) -> Self {
106 Self {
107 index: step.index,
108 kind: step.kind.clone(),
109 name: step.name.clone(),
110 segment: step.segment.clone(),
111 parameter_refs: step.parameter_refs.clone(),
112 side_effects: step.side_effects.clone(),
113 capabilities: step.capabilities.clone(),
114 required_secrets: step.required_secrets.clone(),
115 approval: step.approval.clone(),
116 review_notes: step.review_notes.clone(),
117 }
118 }
119}
120
121#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
122#[serde(default)]
123pub struct BundleEvalPackRef {
124 pub path: String,
126 pub link: Option<String>,
129}
130
131#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
132#[serde(default)]
133pub struct BundleFixtureRef {
134 pub path: String,
135 pub trace_id: String,
136 pub source_hash: String,
137 pub redacted: bool,
138}
139
140#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
141#[serde(default)]
142pub struct BundlePromotion {
143 pub owner: Option<String>,
144 pub approver: Option<String>,
145 pub author: Option<String>,
146 pub rollout_policy: String,
149 pub rollback_target: Option<String>,
150 pub created_at: String,
151 pub workflow_version: String,
152 pub package_name: String,
153 pub sample_count: usize,
154 pub confidence: f64,
155 pub shadow_success_count: usize,
156 pub shadow_failure_count: usize,
157 pub divergence_history: Vec<PromotionDivergenceRecord>,
158 pub approval_history: Vec<PromotionApprovalRecord>,
159 pub criteria: PromotionCriteria,
160 pub estimated_time_token_savings: SavingsEstimate,
161}
162
163impl Default for BundlePromotion {
164 fn default() -> Self {
165 Self {
166 owner: None,
167 approver: None,
168 author: None,
169 rollout_policy: DEFAULT_ROLLOUT_POLICY.to_string(),
170 rollback_target: None,
171 created_at: String::new(),
172 workflow_version: String::new(),
173 package_name: String::new(),
174 sample_count: 0,
175 confidence: 0.0,
176 shadow_success_count: 0,
177 shadow_failure_count: 0,
178 divergence_history: Vec::new(),
179 approval_history: Vec::new(),
180 criteria: PromotionCriteria::default(),
181 estimated_time_token_savings: SavingsEstimate::default(),
182 }
183 }
184}
185
186#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
187#[serde(default)]
188pub struct BundleRedactionSummary {
189 pub applied: bool,
190 pub rules: Vec<String>,
191 pub summary: String,
192 pub fixture_count: usize,
195}
196
197#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
198#[serde(default)]
199pub struct CrystallizationBundleManifest {
200 pub schema: String,
201 pub schema_version: u32,
202 pub generated_at: String,
203 pub generator: BundleGenerator,
204 pub kind: BundleKind,
205 pub candidate_id: String,
206 pub external_key: String,
207 pub title: String,
208 pub team: Option<String>,
209 pub repo: Option<String>,
210 pub risk_level: String,
211 pub workflow: BundleWorkflowRef,
212 pub source_trace_hashes: Vec<String>,
213 pub source_traces: Vec<BundleSourceTrace>,
214 pub deterministic_steps: Vec<BundleStep>,
215 pub fuzzy_steps: Vec<BundleStep>,
216 pub side_effects: Vec<CrystallizationSideEffect>,
217 pub capabilities: Vec<String>,
218 pub required_secrets: Vec<String>,
219 pub savings: SavingsEstimate,
220 pub shadow: ShadowRunReport,
221 pub eval_pack: Option<BundleEvalPackRef>,
222 pub fixtures: Vec<BundleFixtureRef>,
223 pub promotion: BundlePromotion,
224 pub redaction: BundleRedactionSummary,
225 pub confidence: f64,
226 pub rejection_reasons: Vec<String>,
227 pub warnings: Vec<String>,
228}
229
230#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
231#[serde(rename_all = "snake_case")]
232pub enum BundleKind {
233 #[default]
236 Candidate,
237 PlanOnly,
241 Rejected,
245}
246
247#[derive(Clone, Debug, Default)]
248pub struct BundleOptions {
249 pub external_key: Option<String>,
252 pub title: Option<String>,
253 pub team: Option<String>,
254 pub repo: Option<String>,
255 pub risk_level: Option<String>,
256 pub rollout_policy: Option<String>,
257}
258
259#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
260#[serde(default)]
261pub struct CrystallizationBundle {
262 pub manifest: CrystallizationBundleManifest,
263 pub report: CrystallizationReport,
264 pub harn_code: String,
265 pub eval_pack_toml: String,
266 pub fixtures: Vec<CrystallizationTrace>,
267}
268
269#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
271#[serde(default)]
272pub struct BundleValidation {
273 pub bundle_dir: String,
274 pub schema: String,
275 pub schema_version: u32,
276 pub kind: BundleKind,
277 pub candidate_id: String,
278 pub manifest_ok: bool,
279 pub workflow_ok: bool,
280 pub report_ok: bool,
281 pub eval_pack_ok: bool,
282 pub fixtures_ok: bool,
283 pub redaction_ok: bool,
284 pub problems: Vec<String>,
285}
286
287impl BundleValidation {
288 pub fn is_ok(&self) -> bool {
289 self.problems.is_empty()
290 }
291}
292
293pub fn build_crystallization_bundle(
297 artifacts: CrystallizationArtifacts,
298 traces: &[CrystallizationTrace],
299 options: BundleOptions,
300) -> Result<CrystallizationBundle, VmError> {
301 let CrystallizationArtifacts {
302 report,
303 harn_code,
304 eval_pack_toml,
305 } = artifacts;
306
307 let (selected, kind) = match report
308 .selected_candidate_id
309 .as_deref()
310 .and_then(|id| report.candidates.iter().find(|c| c.id == id))
311 {
312 Some(candidate) => {
313 let kind = if candidate_is_plan_only(candidate) {
314 BundleKind::PlanOnly
315 } else {
316 BundleKind::Candidate
317 };
318 (Some(candidate), kind)
319 }
320 None => (None, BundleKind::Rejected),
321 };
322
323 let workflow_name = selected
324 .map(|candidate| candidate.name.clone())
325 .unwrap_or_else(|| "crystallized_workflow".to_string());
326 let package_name = selected
327 .map(|candidate| candidate.promotion.package_name.clone())
328 .unwrap_or_else(|| workflow_name.replace('_', "-"));
329 let workflow_version = selected
330 .map(|candidate| candidate.promotion.version.clone())
331 .unwrap_or_else(|| "0.0.0".to_string());
332
333 let manifest_workflow = BundleWorkflowRef {
334 path: BUNDLE_WORKFLOW_FILE.to_string(),
335 name: workflow_name.clone(),
336 package_name: package_name.clone(),
337 package_version: workflow_version.clone(),
338 };
339
340 let external_key = options
341 .external_key
342 .clone()
343 .filter(|key| !key.trim().is_empty())
344 .unwrap_or_else(|| sanitize_external_key(&workflow_name));
345 let title = options
346 .title
347 .clone()
348 .filter(|title| !title.trim().is_empty())
349 .unwrap_or_else(|| infer_bundle_title(selected, &workflow_name));
350 let risk_level = options
351 .risk_level
352 .clone()
353 .filter(|risk| !risk.trim().is_empty())
354 .unwrap_or_else(|| infer_risk_level(selected));
355 let rollout_policy = options
356 .rollout_policy
357 .clone()
358 .filter(|policy| !policy.trim().is_empty())
359 .unwrap_or_else(|| DEFAULT_ROLLOUT_POLICY.to_string());
360
361 let (deterministic_steps, fuzzy_steps) = match selected {
362 Some(candidate) => candidate
363 .steps
364 .iter()
365 .map(BundleStep::from_candidate_step)
366 .partition::<Vec<_>, _>(|step| step.segment == SegmentKind::Deterministic),
367 None => (Vec::new(), Vec::new()),
368 };
369
370 let source_trace_hashes = selected
371 .map(|candidate| candidate.promotion.source_trace_hashes.clone())
372 .unwrap_or_default();
373
374 let mut source_traces = Vec::new();
375 let mut fixture_refs = Vec::new();
376 let mut fixture_payloads = Vec::new();
377 if let Some(candidate) = selected {
378 let mut fixture_trace_ids = BTreeSet::new();
379 for example in &candidate.examples {
380 fixture_trace_ids.insert(example.trace_id.clone());
381 }
382 for trace in traces {
383 if find_sequence_start(trace, &candidate.sequence_signature).is_some() {
384 fixture_trace_ids.insert(trace.id.clone());
385 }
386 }
387 for trace_id in fixture_trace_ids {
388 let trace = traces.iter().find(|trace| trace.id == trace_id);
389 let source_hash = trace
390 .and_then(|trace| trace.source_hash.clone())
391 .or_else(|| {
392 candidate
393 .examples
394 .iter()
395 .find(|example| example.trace_id == trace_id)
396 .map(|example| example.source_hash.clone())
397 })
398 .unwrap_or_default();
399 let fixture_relative = trace.map(|trace| {
400 format!(
401 "{BUNDLE_FIXTURES_DIR}/{}.json",
402 sanitize_fixture_name(&trace.id)
403 )
404 });
405 source_traces.push(BundleSourceTrace {
406 trace_id: trace_id.clone(),
407 source_hash: source_hash.clone(),
408 source_url: trace.and_then(|trace| trace.source.clone()),
409 source_receipt_id: trace
410 .and_then(|trace| trace.metadata.get("source_receipt_id"))
411 .and_then(|value| value.as_str().map(str::to_string)),
412 fixture_path: fixture_relative.clone(),
413 });
414 if let (Some(trace), Some(fixture_path)) = (trace, fixture_relative.clone()) {
415 let mut redacted = trace.clone();
416 redact_trace_for_bundle(&mut redacted);
417 fixture_refs.push(BundleFixtureRef {
418 path: fixture_path,
419 trace_id: trace.id.clone(),
420 source_hash,
421 redacted: true,
422 });
423 fixture_payloads.push(redacted);
424 }
425 }
426 }
427
428 let author = selected.and_then(|candidate| candidate.promotion.author.clone());
432 let promotion = BundlePromotion {
433 owner: author.clone(),
434 approver: selected.and_then(|candidate| candidate.promotion.approver.clone()),
435 author,
436 rollout_policy,
437 rollback_target: selected.and_then(|candidate| candidate.promotion.rollback_target.clone()),
438 created_at: now_rfc3339(),
439 workflow_version,
440 package_name: package_name.clone(),
441 sample_count: selected
442 .map(|candidate| candidate.promotion.sample_count)
443 .unwrap_or_default(),
444 confidence: selected
445 .map(|candidate| candidate.promotion.confidence)
446 .unwrap_or_default(),
447 shadow_success_count: selected
448 .map(|candidate| candidate.promotion.shadow_success_count)
449 .unwrap_or_default(),
450 shadow_failure_count: selected
451 .map(|candidate| candidate.promotion.shadow_failure_count)
452 .unwrap_or_default(),
453 divergence_history: selected
454 .map(|candidate| candidate.promotion.divergence_history.clone())
455 .unwrap_or_default(),
456 approval_history: selected
457 .map(|candidate| candidate.promotion.approval_history.clone())
458 .unwrap_or_default(),
459 criteria: selected
460 .map(|candidate| candidate.promotion.criteria.clone())
461 .unwrap_or_default(),
462 estimated_time_token_savings: selected
463 .map(|candidate| candidate.promotion.estimated_time_token_savings.clone())
464 .unwrap_or_default(),
465 };
466
467 let redaction = BundleRedactionSummary {
468 applied: !fixture_payloads.is_empty(),
469 rules: vec![
470 "sensitive_keys".to_string(),
471 "secret_value_heuristic".to_string(),
472 ],
473 summary: if fixture_payloads.is_empty() {
474 "no fixtures emitted".to_string()
475 } else {
476 "fixture payloads scrubbed of secret-like values and sensitive keys before write"
477 .to_string()
478 },
479 fixture_count: fixture_payloads.len(),
480 };
481
482 let eval_pack = if eval_pack_toml.trim().is_empty() {
483 None
484 } else {
485 Some(BundleEvalPackRef {
486 path: BUNDLE_EVAL_PACK_FILE.to_string(),
487 link: selected
488 .and_then(|candidate| candidate.promotion.eval_pack_link.clone())
489 .filter(|link| !link.trim().is_empty()),
490 })
491 };
492
493 let manifest = CrystallizationBundleManifest {
494 schema: BUNDLE_SCHEMA.to_string(),
495 schema_version: BUNDLE_SCHEMA_VERSION,
496 generated_at: now_rfc3339(),
497 generator: BundleGenerator::default(),
498 kind,
499 candidate_id: selected
500 .map(|candidate| candidate.id.clone())
501 .unwrap_or_default(),
502 external_key,
503 title,
504 team: options.team,
505 repo: options.repo,
506 risk_level,
507 workflow: manifest_workflow,
508 source_trace_hashes,
509 source_traces,
510 deterministic_steps,
511 fuzzy_steps,
512 side_effects: selected
513 .map(|candidate| candidate.side_effects.clone())
514 .unwrap_or_default(),
515 capabilities: selected
516 .map(|candidate| candidate.capabilities.clone())
517 .unwrap_or_default(),
518 required_secrets: selected
519 .map(|candidate| candidate.required_secrets.clone())
520 .unwrap_or_default(),
521 savings: selected
522 .map(|candidate| candidate.savings.clone())
523 .unwrap_or_default(),
524 shadow: selected
525 .map(|candidate| candidate.shadow.clone())
526 .unwrap_or_default(),
527 eval_pack,
528 fixtures: fixture_refs,
529 promotion,
530 redaction,
531 confidence: selected
532 .map(|candidate| candidate.confidence)
533 .unwrap_or(0.0),
534 rejection_reasons: report
535 .rejected_candidates
536 .iter()
537 .flat_map(|candidate| candidate.rejection_reasons.iter().cloned())
538 .collect(),
539 warnings: report.warnings.clone(),
540 };
541
542 Ok(CrystallizationBundle {
543 manifest,
544 report,
545 harn_code,
546 eval_pack_toml,
547 fixtures: fixture_payloads,
548 })
549}
550
551pub fn write_crystallization_bundle(
555 bundle: &CrystallizationBundle,
556 bundle_dir: &Path,
557) -> Result<CrystallizationBundleManifest, VmError> {
558 std::fs::create_dir_all(bundle_dir).map_err(|error| {
559 VmError::Runtime(format!(
560 "failed to create bundle dir {}: {error}",
561 bundle_dir.display()
562 ))
563 })?;
564 write_bytes(
565 &bundle_dir.join(BUNDLE_WORKFLOW_FILE),
566 bundle.harn_code.as_bytes(),
567 )?;
568 let report_json = serde_json::to_vec_pretty(&bundle.report)
569 .map_err(|error| VmError::Runtime(format!("failed to encode report JSON: {error}")))?;
570 write_bytes(&bundle_dir.join(BUNDLE_REPORT_FILE), &report_json)?;
571
572 if !bundle.eval_pack_toml.trim().is_empty() {
573 write_bytes(
574 &bundle_dir.join(BUNDLE_EVAL_PACK_FILE),
575 bundle.eval_pack_toml.as_bytes(),
576 )?;
577 }
578
579 if !bundle.fixtures.is_empty() {
580 let fixtures_dir = bundle_dir.join(BUNDLE_FIXTURES_DIR);
581 std::fs::create_dir_all(&fixtures_dir).map_err(|error| {
582 VmError::Runtime(format!(
583 "failed to create fixtures dir {}: {error}",
584 fixtures_dir.display()
585 ))
586 })?;
587 for trace in &bundle.fixtures {
588 let path = fixtures_dir.join(format!("{}.json", sanitize_fixture_name(&trace.id)));
589 let payload = serde_json::to_vec_pretty(trace).map_err(|error| {
590 VmError::Runtime(format!("failed to encode fixture {}: {error}", trace.id))
591 })?;
592 write_bytes(&path, &payload)?;
593 }
594 }
595
596 let manifest_json = serde_json::to_vec_pretty(&bundle.manifest)
597 .map_err(|error| VmError::Runtime(format!("failed to encode manifest JSON: {error}")))?;
598 write_bytes(&bundle_dir.join(BUNDLE_MANIFEST_FILE), &manifest_json)?;
599 Ok(bundle.manifest.clone())
600}
601
602pub fn load_crystallization_bundle_manifest(
606 bundle_dir: &Path,
607) -> Result<CrystallizationBundleManifest, VmError> {
608 let manifest_path = bundle_dir.join(BUNDLE_MANIFEST_FILE);
609 let bytes = std::fs::read(&manifest_path).map_err(|error| {
610 VmError::Runtime(format!(
611 "failed to read bundle manifest {}: {error}",
612 manifest_path.display()
613 ))
614 })?;
615 let manifest: CrystallizationBundleManifest =
616 serde_json::from_slice(&bytes).map_err(|error| {
617 VmError::Runtime(format!(
618 "failed to decode bundle manifest {}: {error}",
619 manifest_path.display()
620 ))
621 })?;
622 if manifest.schema != BUNDLE_SCHEMA {
623 return Err(VmError::Runtime(format!(
624 "bundle {} has unrecognized schema {:?} (expected {})",
625 bundle_dir.display(),
626 manifest.schema,
627 BUNDLE_SCHEMA
628 )));
629 }
630 if manifest.schema_version > BUNDLE_SCHEMA_VERSION {
631 return Err(VmError::Runtime(format!(
632 "bundle {} schema_version {} is newer than supported {}",
633 bundle_dir.display(),
634 manifest.schema_version,
635 BUNDLE_SCHEMA_VERSION
636 )));
637 }
638 Ok(manifest)
639}
640
641fn resolve_bundle_manifest_path(
642 bundle_dir: &Path,
643 relative_path: &str,
644 label: &str,
645) -> Result<PathBuf, String> {
646 let path = Path::new(relative_path);
647 if relative_path.trim().is_empty()
648 || path.is_absolute()
649 || path.components().any(|component| {
650 matches!(
651 component,
652 Component::ParentDir | Component::Prefix(_) | Component::RootDir
653 )
654 })
655 || has_windows_rooted_or_drive_relative_prefix(relative_path)
656 {
657 return Err(format!(
658 "manifest {label} path {relative_path:?} must stay inside the bundle"
659 ));
660 }
661 Ok(bundle_dir.join(path))
662}
663
664fn has_windows_rooted_or_drive_relative_prefix(path: &str) -> bool {
665 let normalized = path.replace('\\', "/");
666 let bytes = normalized.as_bytes();
667 normalized.starts_with('/')
668 || (bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':')
669}
670
671pub fn load_crystallization_bundle(
675 bundle_dir: &Path,
676) -> Result<(CrystallizationBundleManifest, Vec<CrystallizationTrace>), VmError> {
677 let manifest = load_crystallization_bundle_manifest(bundle_dir)?;
678 let mut traces = Vec::new();
679 for fixture in &manifest.fixtures {
680 let path = resolve_bundle_manifest_path(bundle_dir, &fixture.path, "fixture")
681 .map_err(VmError::Runtime)?;
682 traces.push(load_crystallization_trace(&path)?);
683 }
684 Ok((manifest, traces))
685}
686
687pub fn validate_crystallization_bundle(bundle_dir: &Path) -> Result<BundleValidation, VmError> {
690 let mut validation = BundleValidation {
691 bundle_dir: bundle_dir.display().to_string(),
692 ..BundleValidation::default()
693 };
694 let manifest = match load_crystallization_bundle_manifest(bundle_dir) {
695 Ok(manifest) => manifest,
696 Err(error) => {
697 validation.problems.push(error.to_string());
698 return Ok(validation);
699 }
700 };
701 validation.manifest_ok = true;
702 validation.schema = manifest.schema.clone();
703 validation.schema_version = manifest.schema_version;
704 validation.kind = manifest.kind.clone();
705 validation.candidate_id = manifest.candidate_id.clone();
706
707 match resolve_bundle_manifest_path(bundle_dir, &manifest.workflow.path, "workflow") {
708 Ok(workflow_path) if workflow_path.exists() => {
709 validation.workflow_ok = true;
710 }
711 Ok(workflow_path) => {
712 validation
713 .problems
714 .push(format!("missing workflow file {}", workflow_path.display()));
715 }
716 Err(problem) => validation.problems.push(problem),
717 }
718
719 let report_path = bundle_dir.join(BUNDLE_REPORT_FILE);
720 match std::fs::read(&report_path) {
721 Ok(bytes) => match serde_json::from_slice::<CrystallizationReport>(&bytes) {
722 Ok(report) => {
723 validation.report_ok = true;
724 if matches!(manifest.kind, BundleKind::Candidate | BundleKind::PlanOnly)
725 && manifest.candidate_id.is_empty()
726 {
727 validation
728 .problems
729 .push("manifest is non-rejected but has empty candidate_id".to_string());
730 }
731 if matches!(manifest.kind, BundleKind::Candidate | BundleKind::PlanOnly)
732 && report.selected_candidate_id.as_deref() != Some(&manifest.candidate_id)
733 {
734 validation.problems.push(format!(
735 "report selected_candidate_id {:?} does not match manifest candidate_id {}",
736 report.selected_candidate_id, manifest.candidate_id
737 ));
738 }
739 }
740 Err(error) => {
741 validation
742 .problems
743 .push(format!("invalid report.json: {error}"));
744 }
745 },
746 Err(error) => {
747 validation.problems.push(format!(
748 "missing report file {}: {error}",
749 report_path.display()
750 ));
751 }
752 }
753
754 if let Some(eval_pack) = &manifest.eval_pack {
755 match resolve_bundle_manifest_path(bundle_dir, &eval_pack.path, "eval_pack") {
756 Ok(path) if path.exists() => {
757 validation.eval_pack_ok = true;
758 }
759 Ok(path) => {
760 validation.problems.push(format!(
761 "manifest references eval pack {} but file is missing",
762 path.display()
763 ));
764 }
765 Err(problem) => validation.problems.push(problem),
766 }
767 } else {
768 validation.eval_pack_ok = true;
769 }
770
771 let mut fixtures_problem = false;
772 for fixture in &manifest.fixtures {
773 let path = match resolve_bundle_manifest_path(bundle_dir, &fixture.path, "fixture") {
774 Ok(path) => path,
775 Err(problem) => {
776 validation.problems.push(problem);
777 fixtures_problem = true;
778 continue;
779 }
780 };
781 if !path.exists() {
782 validation
783 .problems
784 .push(format!("missing fixture {}", path.display()));
785 fixtures_problem = true;
786 continue;
787 }
788 if !fixture.redacted {
789 validation.problems.push(format!(
790 "fixture {} is not marked redacted; bundle must not ship raw private payloads",
791 fixture.path
792 ));
793 fixtures_problem = true;
794 }
795 }
796 validation.fixtures_ok = !fixtures_problem;
797
798 if !manifest.redaction.applied && !manifest.fixtures.is_empty() {
799 validation
800 .problems
801 .push("redaction.applied is false but bundle includes fixtures".to_string());
802 } else {
803 validation.redaction_ok = true;
804 }
805 if !manifest
806 .required_secrets
807 .iter()
808 .all(|secret| secret_id_looks_logical(secret))
809 {
810 validation.problems.push(
811 "required_secrets contains a non-logical id (looks like a raw secret)".to_string(),
812 );
813 }
814
815 Ok(validation)
816}
817
818pub fn shadow_replay_bundle(
824 bundle_dir: &Path,
825) -> Result<(CrystallizationBundleManifest, ShadowRunReport), VmError> {
826 let (manifest, traces) = load_crystallization_bundle(bundle_dir)?;
827 let report_path = bundle_dir.join(BUNDLE_REPORT_FILE);
828 let bytes = std::fs::read(&report_path).map_err(|error| {
829 VmError::Runtime(format!(
830 "failed to read bundle report {}: {error}",
831 report_path.display()
832 ))
833 })?;
834 let report: CrystallizationReport = serde_json::from_slice(&bytes).map_err(|error| {
835 VmError::Runtime(format!(
836 "failed to decode bundle report {}: {error}",
837 report_path.display()
838 ))
839 })?;
840 let candidate = report
841 .selected_candidate_id
842 .as_deref()
843 .and_then(|id| report.candidates.iter().find(|c| c.id == id))
844 .ok_or_else(|| {
845 VmError::Runtime(format!(
846 "bundle {} has no selected candidate to replay",
847 bundle_dir.display()
848 ))
849 })?;
850 let shadow = shadow_candidate(candidate, &traces);
851 Ok((manifest, shadow))
852}
853
854fn write_bytes(path: &Path, bytes: &[u8]) -> Result<(), VmError> {
855 crate::atomic_io::atomic_write(path, bytes)
856 .map_err(|error| VmError::Runtime(format!("failed to write {}: {error}", path.display())))
857}
858
859fn sanitize_fixture_name(raw: &str) -> String {
860 let cleaned = raw
861 .chars()
862 .map(|ch| {
863 if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
864 ch
865 } else {
866 '_'
867 }
868 })
869 .collect::<String>();
870 if cleaned.trim_matches('_').is_empty() {
871 "trace".to_string()
872 } else {
873 cleaned.trim_matches('_').to_string()
874 }
875}
876
877fn sanitize_external_key(raw: &str) -> String {
878 let mut out = String::new();
879 let mut prev_dash = false;
880 for ch in raw.chars() {
881 let lowered = ch.to_ascii_lowercase();
882 if lowered.is_ascii_alphanumeric() {
883 out.push(lowered);
884 prev_dash = false;
885 } else if !prev_dash && !out.is_empty() {
886 out.push('-');
887 prev_dash = true;
888 }
889 }
890 let trimmed = out.trim_matches('-').to_string();
891 if trimmed.is_empty() {
892 "crystallized-workflow".to_string()
893 } else {
894 trimmed
895 }
896}
897
898fn infer_bundle_title(candidate: Option<&WorkflowCandidate>, fallback_name: &str) -> String {
899 if let Some(candidate) = candidate {
900 format!(
901 "{} ({} step{})",
902 candidate.name,
903 candidate.steps.len(),
904 if candidate.steps.len() == 1 { "" } else { "s" }
905 )
906 } else {
907 format!("rejected: {fallback_name}")
908 }
909}
910
911fn infer_risk_level(candidate: Option<&WorkflowCandidate>) -> String {
912 let Some(candidate) = candidate else {
913 return "high".to_string();
914 };
915 let touches_external = candidate.side_effects.iter().any(side_effect_is_external);
916 let needs_secret = !candidate.required_secrets.is_empty();
917 if touches_external && needs_secret {
918 "high".to_string()
919 } else if touches_external || needs_secret {
920 "medium".to_string()
921 } else {
922 "low".to_string()
923 }
924}
925
926fn side_effect_is_external(effect: &CrystallizationSideEffect) -> bool {
927 let kind = effect.kind.to_ascii_lowercase();
928 if kind.is_empty() {
929 return false;
930 }
931 let internal = kind.contains("receipt")
935 || kind.contains("event_log")
936 || kind.contains("memo")
937 || kind.contains("plan");
938 if internal {
939 return false;
940 }
941 kind.contains("post")
942 || kind.contains("write")
943 || kind.contains("publish")
944 || kind.contains("delete")
945 || kind.contains("send")
946}
947
948fn candidate_is_plan_only(candidate: &WorkflowCandidate) -> bool {
949 if candidate.steps.is_empty() {
950 return false;
951 }
952 candidate.side_effects.iter().all(|effect| {
953 let kind = effect.kind.to_ascii_lowercase();
954 kind.is_empty()
957 || kind.contains("receipt")
958 || kind.contains("event_log")
959 || kind.contains("memo")
960 || kind.contains("plan")
961 || (kind.contains("file") && !kind.contains("publish"))
962 })
963}
964
965pub(super) fn redact_trace_for_bundle(trace: &mut CrystallizationTrace) {
966 for action in &mut trace.actions {
967 redact_bundle_value(&mut action.inputs);
968 if let Some(output) = action.output.as_mut() {
969 redact_bundle_value(output);
970 }
971 if let Some(observed) = action.observed_output.as_mut() {
972 redact_bundle_value(observed);
973 }
974 for value in action.parameters.values_mut() {
975 redact_bundle_value(value);
976 }
977 for (_, value) in action.metadata.iter_mut() {
978 redact_bundle_value(value);
979 }
980 }
981 for (_, value) in trace.metadata.iter_mut() {
982 redact_bundle_value(value);
983 }
984 if let Some(run) = trace.replay_run.as_mut() {
985 redact_replay_run_for_bundle(run);
986 }
987}
988
989fn redact_replay_run_for_bundle(run: &mut ReplayTraceRun) {
990 for value in run
991 .event_log_entries
992 .iter_mut()
993 .chain(run.trigger_firings.iter_mut())
994 .chain(run.llm_interactions.iter_mut())
995 .chain(run.protocol_interactions.iter_mut())
996 .chain(run.approval_interactions.iter_mut())
997 .chain(run.effect_receipts.iter_mut())
998 .chain(run.agent_transcript_deltas.iter_mut())
999 .chain(run.final_artifacts.iter_mut())
1000 .chain(run.policy_decisions.iter_mut())
1001 {
1002 redact_bundle_value(value);
1003 }
1004}
1005
1006fn redact_bundle_value(value: &mut JsonValue) {
1007 match value {
1008 JsonValue::String(text) if looks_like_secret_value(text) => {
1009 *text = "[redacted]".to_string();
1010 }
1011 JsonValue::Array(items) => {
1012 for item in items {
1013 redact_bundle_value(item);
1014 }
1015 }
1016 JsonValue::Object(map) => {
1017 for (key, child) in map.iter_mut() {
1018 if is_sensitive_bundle_key(key) {
1019 *child = JsonValue::String("[redacted]".to_string());
1020 } else {
1021 redact_bundle_value(child);
1022 }
1023 }
1024 }
1025 _ => {}
1026 }
1027}
1028
1029fn is_sensitive_bundle_key(key: &str) -> bool {
1030 let lower = key.to_ascii_lowercase();
1031 lower.contains("secret")
1032 || lower.contains("token")
1033 || lower.contains("password")
1034 || lower.contains("api_key")
1035 || lower.contains("apikey")
1036 || lower == "authorization"
1037 || lower == "cookie"
1038 || lower == "set-cookie"
1039}
1040
1041fn looks_like_secret_value(value: &str) -> bool {
1042 let trimmed = value.trim();
1043 trimmed.starts_with("sk-")
1044 || trimmed.starts_with("ghp_")
1045 || trimmed.starts_with("ghs_")
1046 || trimmed.starts_with("xoxb-")
1047 || trimmed.starts_with("xoxp-")
1048 || trimmed.starts_with("AKIA")
1049 || (trimmed.len() > 48
1050 && trimmed
1051 .chars()
1052 .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-'))
1053}
1054
1055fn secret_id_looks_logical(value: &str) -> bool {
1056 !looks_like_secret_value(value) && !value.trim().is_empty()
1057}