1use super::*;
2use std::collections::{BTreeMap, BTreeSet};
3
4#[derive(Clone, Debug, Eq, PartialEq)]
8pub struct LocalDeploymentCheckRequest {
9 pub deployment_name: String,
10 pub network: String,
11 pub workspace_root: std::path::PathBuf,
12 pub icp_root: std::path::PathBuf,
13 pub config_path: Option<std::path::PathBuf>,
14 pub observed_at: String,
15 pub runtime_variant: String,
16 pub build_profile: String,
17}
18
19pub fn check_local_deployment(
21 request: &LocalDeploymentCheckRequest,
22) -> Result<DeploymentCheckV1, DeploymentTruthError> {
23 let plan = build_local_deployment_plan(&LocalDeploymentPlanRequest {
24 deployment_name: request.deployment_name.clone(),
25 network: request.network.clone(),
26 workspace_root: request.workspace_root.clone(),
27 icp_root: request.icp_root.clone(),
28 config_path: request.config_path.clone(),
29 runtime_variant: request.runtime_variant.clone(),
30 build_profile: request.build_profile.clone(),
31 });
32 let inventory = collect_local_deployment_inventory(&LocalInventoryRequest {
33 deployment_name: request.deployment_name.clone(),
34 network: request.network.clone(),
35 workspace_root: request.workspace_root.clone(),
36 icp_root: request.icp_root.clone(),
37 config_path: request.config_path.clone(),
38 observed_at: request.observed_at.clone(),
39 })?;
40 let diff = compare_plan_to_inventory(&plan, &inventory);
41 let report = safety_report_from_diff(
42 format!(
43 "local:{}:{}:report",
44 request.network, request.deployment_name
45 ),
46 Some(format!(
47 "local:{}:{}:diff",
48 request.network, request.deployment_name
49 )),
50 &diff,
51 );
52
53 Ok(DeploymentCheckV1 {
54 schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
55 check_id: format!(
56 "local:{}:{}:check",
57 request.network, request.deployment_name
58 ),
59 plan,
60 inventory,
61 diff,
62 report,
63 })
64}
65
66#[must_use]
68pub fn compare_plan_to_inventory(
69 plan: &DeploymentPlanV1,
70 inventory: &DeploymentInventoryV1,
71) -> DeploymentDiffV1 {
72 let mut artifact_diff = Vec::new();
73 let mut controller_diff = Vec::new();
74 let pool_diff = Vec::new();
75 let mut embedded_config_diff = Vec::new();
76 let mut module_hash_diff = Vec::new();
77 let mut verifier_readiness_diff = Vec::new();
78 let mut hard_failures = Vec::new();
79 let mut warnings = Vec::new();
80
81 compare_identity(plan, inventory, &mut hard_failures);
82 compare_authority_profile(plan, &mut controller_diff, &mut hard_failures);
83 compare_artifacts(
84 plan,
85 inventory,
86 &mut artifact_diff,
87 &mut hard_failures,
88 &mut warnings,
89 );
90 compare_canisters(
91 plan,
92 inventory,
93 &mut controller_diff,
94 &mut hard_failures,
95 &mut warnings,
96 );
97 compare_module_hashes(
98 plan,
99 inventory,
100 &mut module_hash_diff,
101 &mut hard_failures,
102 &mut warnings,
103 );
104 compare_raw_config(
105 plan,
106 inventory,
107 &mut embedded_config_diff,
108 &mut hard_failures,
109 );
110 compare_embedded_config(
111 plan,
112 inventory,
113 &mut embedded_config_diff,
114 &mut hard_failures,
115 &mut warnings,
116 );
117 compare_verifier_readiness(plan, inventory, &mut verifier_readiness_diff, &mut warnings);
118 for assumption in &plan.unresolved_assumptions {
119 warnings.push(SafetyFindingV1 {
120 code: "plan_assumption".to_string(),
121 message: assumption.description.clone(),
122 severity: SafetySeverityV1::Warning,
123 subject: Some(assumption.key.clone()),
124 });
125 }
126 for gap in &inventory.unresolved_observations {
127 warnings.push(SafetyFindingV1 {
128 code: "observation_gap".to_string(),
129 message: gap.description.clone(),
130 severity: SafetySeverityV1::Warning,
131 subject: Some(gap.key.clone()),
132 });
133 }
134
135 let status = safety_status(&hard_failures, &warnings);
136 DeploymentDiffV1 {
137 schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
138 plan_identity: plan.deployment_identity.clone(),
139 observed_identity: inventory.observed_identity.clone(),
140 artifact_diff,
141 controller_diff,
142 pool_diff,
143 embedded_config_diff,
144 module_hash_diff,
145 verifier_readiness_diff,
146 resume_safety: ResumeSafetyV1 {
147 status,
148 reasons: resume_safety_reasons(&hard_failures, &warnings),
149 },
150 hard_failures,
151 warnings,
152 resumable_phases: Vec::new(),
153 }
154}
155
156#[must_use]
159pub fn compare_plan_inventory_and_receipt(
160 plan: &DeploymentPlanV1,
161 inventory: &DeploymentInventoryV1,
162 receipt: &DeploymentReceiptV1,
163) -> DeploymentDiffV1 {
164 let mut diff = compare_plan_to_inventory(plan, inventory);
165 apply_receipt_resume_safety(plan, receipt, &mut diff);
166 diff
167}
168
169#[must_use]
171pub fn safety_report_from_diff(
172 report_id: impl Into<String>,
173 diff_id: Option<String>,
174 diff: &DeploymentDiffV1,
175) -> SafetyReportV1 {
176 let status = safety_status(&diff.hard_failures, &diff.warnings);
177 SafetyReportV1 {
178 schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
179 report_id: report_id.into(),
180 diff_id,
181 status,
182 summary: safety_summary(status, diff.hard_failures.len(), diff.warnings.len()),
183 hard_failures: diff.hard_failures.clone(),
184 warnings: diff.warnings.clone(),
185 next_actions: safety_next_actions(status),
186 }
187}
188
189fn apply_receipt_resume_safety(
190 plan: &DeploymentPlanV1,
191 receipt: &DeploymentReceiptV1,
192 diff: &mut DeploymentDiffV1,
193) {
194 validate_receipt_identity(plan, receipt, &mut diff.hard_failures);
195 validate_receipt_command_result(receipt, &mut diff.hard_failures);
196 if !diff.hard_failures.is_empty() {
197 diff.resume_safety.status = safety_status(&diff.hard_failures, &diff.warnings);
198 diff.resume_safety.reasons = resume_safety_reasons(&diff.hard_failures, &diff.warnings);
199 return;
200 }
201 let phase_failures = receipt_phase_failures(receipt);
202 for receipt in &receipt.phase_receipts {
203 if receipt.verified_postcondition.status != ObservationStatusV1::Observed {
204 diff.hard_failures.push(finding(
205 "receipt_postcondition_unverified",
206 format!(
207 "receipt phase {} has no observed postcondition",
208 receipt.phase
209 ),
210 SafetySeverityV1::HardFailure,
211 Some(receipt.phase.clone()),
212 ));
213 continue;
214 }
215 if phase_failures.contains(receipt.phase.as_str()) {
216 continue;
217 }
218 diff.resumable_phases.push(receipt.phase.clone());
219 }
220 diff.resumable_phases.sort();
221 diff.resumable_phases.dedup();
222 diff.resume_safety.status = safety_status(&diff.hard_failures, &diff.warnings);
223 diff.resume_safety.reasons = resume_safety_reasons(&diff.hard_failures, &diff.warnings);
224}
225
226fn validate_receipt_identity(
227 plan: &DeploymentPlanV1,
228 receipt: &DeploymentReceiptV1,
229 hard_failures: &mut Vec<SafetyFindingV1>,
230) {
231 if receipt.plan_id != plan.plan_id {
232 hard_failures.push(finding(
233 "receipt_plan_mismatch",
234 format!(
235 "receipt plan {} does not match current plan {}",
236 receipt.plan_id, plan.plan_id
237 ),
238 SafetySeverityV1::HardFailure,
239 Some("receipt.plan_id".to_string()),
240 ));
241 }
242 if let (Some(expected), Some(observed)) = (
243 plan.deployment_identity.root_principal.as_ref(),
244 receipt.root_principal.as_ref(),
245 ) && expected != observed
246 {
247 hard_failures.push(finding(
248 "receipt_root_mismatch",
249 format!("receipt root {observed} does not match current plan root {expected}"),
250 SafetySeverityV1::HardFailure,
251 Some("receipt.root_principal".to_string()),
252 ));
253 }
254}
255
256fn validate_receipt_command_result(
257 receipt: &DeploymentReceiptV1,
258 hard_failures: &mut Vec<SafetyFindingV1>,
259) {
260 if let DeploymentCommandResultV1::Failed { code, message } = &receipt.command_result {
261 hard_failures.push(finding(
262 "receipt_failed_command",
263 format!("receipt command failed with {code}: {message}"),
264 SafetySeverityV1::HardFailure,
265 Some("receipt.command_result".to_string()),
266 ));
267 }
268}
269
270fn receipt_phase_failures(receipt: &DeploymentReceiptV1) -> BTreeSet<&str> {
271 let mut failures = BTreeSet::new();
272 for role_receipt in &receipt.role_phase_receipts {
273 if matches!(role_receipt.result, RolePhaseResultV1::Failed) {
274 failures.insert(role_receipt.phase.as_str());
275 }
276 }
277 failures
278}
279
280fn compare_identity(
281 plan: &DeploymentPlanV1,
282 inventory: &DeploymentInventoryV1,
283 hard_failures: &mut Vec<SafetyFindingV1>,
284) {
285 let Some(observed) = &inventory.observed_identity else {
286 hard_failures.push(finding(
287 "identity_unobserved",
288 "deployment identity was not observed",
289 SafetySeverityV1::HardFailure,
290 None,
291 ));
292 return;
293 };
294
295 if observed.network != plan.deployment_identity.network {
296 hard_failures.push(finding(
297 "network_mismatch",
298 format!(
299 "plan network {} differs from observed network {}",
300 plan.deployment_identity.network, observed.network
301 ),
302 SafetySeverityV1::HardFailure,
303 Some("deployment_identity.network".to_string()),
304 ));
305 }
306 if let (Some(expected), Some(actual)) = (
307 plan.deployment_identity.root_principal.as_ref(),
308 observed.root_principal.as_ref(),
309 ) && expected != actual
310 {
311 hard_failures.push(finding(
312 "root_trust_anchor_mismatch",
313 format!("plan root {expected} differs from observed root {actual}"),
314 SafetySeverityV1::HardFailure,
315 Some("deployment_identity.root_principal".to_string()),
316 ));
317 }
318 match (
319 plan.deployment_identity.deployment_manifest_digest.as_ref(),
320 observed.deployment_manifest_digest.as_ref(),
321 ) {
322 (Some(expected), Some(actual)) if expected != actual => {
323 hard_failures.push(finding(
324 "deployment_manifest_mismatch",
325 "deployment manifest digest differs from the observed local config",
326 SafetySeverityV1::HardFailure,
327 Some("deployment_identity.deployment_manifest_digest".to_string()),
328 ));
329 }
330 (Some(_), None) => {
331 hard_failures.push(finding(
332 "deployment_manifest_unobserved",
333 "deployment manifest digest was not observed",
334 SafetySeverityV1::HardFailure,
335 Some("deployment_identity.deployment_manifest_digest".to_string()),
336 ));
337 }
338 _ => {}
339 }
340}
341
342fn compare_authority_profile(
343 plan: &DeploymentPlanV1,
344 controller_diff: &mut Vec<DiffItemV1>,
345 hard_failures: &mut Vec<SafetyFindingV1>,
346) {
347 let mut reported = BTreeSet::new();
348 for controller in &plan.authority_profile.expected_controllers {
349 if !is_staging_or_emergency_controller(plan, controller) {
350 continue;
351 }
352 if !reported.insert(controller.as_str()) {
353 continue;
354 }
355 controller_diff.push(diff_item(
356 "controller_authority_overlap",
357 "authority_profile",
358 Some("expected-only".to_string()),
359 Some(controller.clone()),
360 SafetySeverityV1::HardFailure,
361 ));
362 hard_failures.push(finding(
363 "controller_authority_overlap",
364 format!(
365 "controller {controller} appears in both expected and staging/emergency authority"
366 ),
367 SafetySeverityV1::HardFailure,
368 Some("authority_profile".to_string()),
369 ));
370 }
371}
372
373fn compare_artifacts(
374 plan: &DeploymentPlanV1,
375 inventory: &DeploymentInventoryV1,
376 artifact_diff: &mut Vec<DiffItemV1>,
377 hard_failures: &mut Vec<SafetyFindingV1>,
378 warnings: &mut Vec<SafetyFindingV1>,
379) {
380 let observed_by_role = inventory
381 .observed_artifacts
382 .iter()
383 .map(|artifact| (artifact.role.as_str(), artifact))
384 .collect::<BTreeMap<_, _>>();
385
386 for expected in &plan.role_artifacts {
387 let Some(observed) = observed_by_role.get(expected.role.as_str()) else {
388 artifact_diff.push(diff_item(
389 "artifact",
390 &expected.role,
391 expected.wasm_gz_path.clone(),
392 None,
393 SafetySeverityV1::HardFailure,
394 ));
395 hard_failures.push(finding(
396 "artifact_missing",
397 format!("missing observed artifact for role {}", expected.role),
398 SafetySeverityV1::HardFailure,
399 Some(expected.role.clone()),
400 ));
401 continue;
402 };
403
404 compare_artifact_file_sha256(expected, observed, artifact_diff, hard_failures);
405
406 match (
407 expected.wasm_gz_sha256.as_ref(),
408 observed.payload_sha256.as_ref(),
409 ) {
410 (Some(want), Some(got)) if want != got => {
411 artifact_diff.push(diff_item(
412 "artifact_sha256",
413 &expected.role,
414 Some(want.clone()),
415 Some(got.clone()),
416 SafetySeverityV1::HardFailure,
417 ));
418 hard_failures.push(finding(
419 "artifact_digest_mismatch",
420 format!("artifact digest mismatch for role {}", expected.role),
421 SafetySeverityV1::HardFailure,
422 Some(expected.role.clone()),
423 ));
424 }
425 (Some(want), None) => warnings.push(finding(
426 "artifact_digest_unobserved",
427 format!(
428 "expected artifact digest {want} for role {} was not observed",
429 expected.role
430 ),
431 SafetySeverityV1::Warning,
432 Some(expected.role.clone()),
433 )),
434 _ => {}
435 }
436 }
437}
438
439fn compare_artifact_file_sha256(
440 expected: &RoleArtifactV1,
441 observed: &ObservedArtifactV1,
442 artifact_diff: &mut Vec<DiffItemV1>,
443 hard_failures: &mut Vec<SafetyFindingV1>,
444) {
445 match (
446 expected.observed_wasm_gz_file_sha256.as_ref(),
447 observed.file_sha256.as_ref(),
448 ) {
449 (Some(want), Some(got)) if want != got => {
450 artifact_diff.push(diff_item(
451 "artifact_file_sha256",
452 &expected.role,
453 Some(want.clone()),
454 Some(got.clone()),
455 SafetySeverityV1::HardFailure,
456 ));
457 hard_failures.push(finding(
458 "artifact_file_digest_mismatch",
459 format!(
460 "observed artifact file digest changed during deployment truth check for role {}",
461 expected.role
462 ),
463 SafetySeverityV1::HardFailure,
464 Some(expected.role.clone()),
465 ));
466 }
467 (_, Some(got)) => {
468 artifact_diff.push(diff_item(
469 "artifact_file_sha256",
470 &expected.role,
471 expected.observed_wasm_gz_file_sha256.clone(),
472 Some(got.clone()),
473 SafetySeverityV1::Info,
474 ));
475 }
476 _ => {}
477 }
478}
479
480fn compare_canisters(
481 plan: &DeploymentPlanV1,
482 inventory: &DeploymentInventoryV1,
483 controller_diff: &mut Vec<DiffItemV1>,
484 hard_failures: &mut Vec<SafetyFindingV1>,
485 warnings: &mut Vec<SafetyFindingV1>,
486) {
487 for expected in &plan.expected_canisters {
488 let observed = expected.canister_id.as_ref().map_or_else(
489 || {
490 inventory
491 .observed_canisters
492 .iter()
493 .find(|canister| canister.role.as_deref() == Some(expected.role.as_str()))
494 },
495 |id| {
496 inventory
497 .observed_canisters
498 .iter()
499 .find(|canister| &canister.canister_id == id)
500 },
501 );
502 let Some(observed) = observed else {
503 let severity = if expected.canister_id.is_some() {
504 SafetySeverityV1::HardFailure
505 } else {
506 SafetySeverityV1::Warning
507 };
508 controller_diff.push(diff_item(
509 "canister",
510 &expected.role,
511 expected.canister_id.clone(),
512 None,
513 severity,
514 ));
515 let finding = finding(
516 if expected.canister_id.is_some() {
517 "canister_missing"
518 } else {
519 "canister_unobserved"
520 },
521 format!("missing observed canister for role {}", expected.role),
522 severity,
523 Some(expected.role.clone()),
524 );
525 if expected.canister_id.is_some() {
526 hard_failures.push(finding);
527 } else {
528 warnings.push(finding);
529 }
530 continue;
531 };
532 if matches!(
533 observed.control_class,
534 CanisterControlClassV1::UnknownUnsafe | CanisterControlClassV1::UserControlled
535 ) && expected.control_class == CanisterControlClassV1::DeploymentControlled
536 {
537 controller_diff.push(diff_item(
538 "control_class",
539 &expected.role,
540 Some("DeploymentControlled".to_string()),
541 Some(format!("{:?}", observed.control_class)),
542 SafetySeverityV1::HardFailure,
543 ));
544 hard_failures.push(finding(
545 "unsafe_control_class",
546 format!("role {} has unsafe observed control class", expected.role),
547 SafetySeverityV1::HardFailure,
548 Some(expected.role.clone()),
549 ));
550 }
551 compare_role_controllers(plan, observed, controller_diff, hard_failures, warnings);
552 }
553}
554
555fn compare_role_controllers(
556 plan: &DeploymentPlanV1,
557 observed: &ObservedCanisterV1,
558 controller_diff: &mut Vec<DiffItemV1>,
559 hard_failures: &mut Vec<SafetyFindingV1>,
560 warnings: &mut Vec<SafetyFindingV1>,
561) {
562 let role = observed.role.as_deref().unwrap_or("unknown");
563 for expected in &plan.authority_profile.expected_controllers {
564 if observed
565 .controllers
566 .iter()
567 .any(|controller| controller == expected)
568 {
569 continue;
570 }
571 controller_diff.push(diff_item(
572 "controller_missing",
573 role,
574 Some(expected.clone()),
575 Some(controller_set_label(&observed.controllers)),
576 SafetySeverityV1::HardFailure,
577 ));
578 hard_failures.push(finding(
579 "expected_controller_missing",
580 format!("role {role} is missing expected controller {expected}"),
581 SafetySeverityV1::HardFailure,
582 Some(role.to_string()),
583 ));
584 }
585
586 for observed_controller in &observed.controllers {
587 if is_declared_controller(plan, observed_controller) {
588 continue;
589 }
590 controller_diff.push(diff_item(
591 "controller_extra",
592 role,
593 Some(controller_set_label(
594 &plan.authority_profile.expected_controllers,
595 )),
596 Some(observed_controller.clone()),
597 SafetySeverityV1::Warning,
598 ));
599 warnings.push(finding(
600 "extra_controller_observed",
601 format!("role {role} has controller outside the expected authority profile"),
602 SafetySeverityV1::Warning,
603 Some(role.to_string()),
604 ));
605 }
606}
607
608fn is_declared_controller(plan: &DeploymentPlanV1, controller: &str) -> bool {
609 plan.authority_profile
610 .expected_controllers
611 .iter()
612 .chain(plan.authority_profile.staging_controllers.iter())
613 .chain(plan.authority_profile.emergency_controllers.iter())
614 .any(|expected| expected == controller)
615}
616
617fn is_staging_or_emergency_controller(plan: &DeploymentPlanV1, controller: &str) -> bool {
618 plan.authority_profile
619 .staging_controllers
620 .iter()
621 .chain(plan.authority_profile.emergency_controllers.iter())
622 .any(|declared| declared == controller)
623}
624
625fn controller_set_label(controllers: &[String]) -> String {
626 if controllers.is_empty() {
627 return "<none>".to_string();
628 }
629 controllers.join(",")
630}
631
632fn compare_module_hashes(
633 plan: &DeploymentPlanV1,
634 inventory: &DeploymentInventoryV1,
635 module_hash_diff: &mut Vec<DiffItemV1>,
636 hard_failures: &mut Vec<SafetyFindingV1>,
637 warnings: &mut Vec<SafetyFindingV1>,
638) {
639 let observed_by_role = inventory
640 .observed_canisters
641 .iter()
642 .filter_map(|canister| canister.role.as_deref().map(|role| (role, canister)))
643 .collect::<BTreeMap<_, _>>();
644
645 for artifact in &plan.role_artifacts {
646 let Some(expected) = artifact.installed_module_hash.as_ref() else {
647 continue;
648 };
649 let Some(observed_canister) = observed_by_role.get(artifact.role.as_str()) else {
650 continue;
651 };
652 match observed_canister.module_hash.as_ref() {
653 Some(observed) if observed != expected => {
654 module_hash_diff.push(diff_item(
655 "installed_module_hash",
656 &artifact.role,
657 Some(expected.clone()),
658 Some(observed.clone()),
659 SafetySeverityV1::HardFailure,
660 ));
661 hard_failures.push(finding(
662 "installed_module_hash_mismatch",
663 format!("installed module hash differs for role {}", artifact.role),
664 SafetySeverityV1::HardFailure,
665 Some(artifact.role.clone()),
666 ));
667 }
668 None => warnings.push(finding(
669 "installed_module_hash_unobserved",
670 format!(
671 "installed module hash was not observed for role {}",
672 artifact.role
673 ),
674 SafetySeverityV1::Warning,
675 Some(artifact.role.clone()),
676 )),
677 _ => {}
678 }
679 }
680}
681
682fn compare_raw_config(
683 plan: &DeploymentPlanV1,
684 inventory: &DeploymentInventoryV1,
685 embedded_config_diff: &mut Vec<DiffItemV1>,
686 hard_failures: &mut Vec<SafetyFindingV1>,
687) {
688 let mut expected = plan
689 .role_artifacts
690 .iter()
691 .filter_map(|artifact| artifact.raw_config_sha256.as_ref())
692 .collect::<Vec<_>>();
693 expected.sort_unstable();
694 expected.dedup();
695 let [expected] = expected.as_slice() else {
696 if expected.len() > 1 {
697 hard_failures.push(finding(
698 "raw_config_plan_inconsistent",
699 "planned role artifacts disagree on raw config digest",
700 SafetySeverityV1::HardFailure,
701 Some("role_artifacts.raw_config_sha256".to_string()),
702 ));
703 }
704 return;
705 };
706
707 if let Some(observed) = &inventory.local_config.raw_config_sha256
708 && observed != *expected
709 {
710 embedded_config_diff.push(diff_item(
711 "raw_config_sha256",
712 "deployment",
713 Some((*expected).clone()),
714 Some(observed.clone()),
715 SafetySeverityV1::HardFailure,
716 ));
717 hard_failures.push(finding(
718 "raw_config_digest_mismatch",
719 "raw local config digest changed during deployment truth check",
720 SafetySeverityV1::HardFailure,
721 Some("local_config.raw_sha256".to_string()),
722 ));
723 }
724}
725
726fn compare_embedded_config(
727 plan: &DeploymentPlanV1,
728 inventory: &DeploymentInventoryV1,
729 embedded_config_diff: &mut Vec<DiffItemV1>,
730 hard_failures: &mut Vec<SafetyFindingV1>,
731 warnings: &mut Vec<SafetyFindingV1>,
732) {
733 let Some(expected) = &plan.deployment_identity.canonical_runtime_config_digest else {
734 return;
735 };
736 match &inventory.local_config.canonical_embedded_config_sha256 {
737 Some(observed) if observed != expected => {
738 embedded_config_diff.push(diff_item(
739 "canonical_config",
740 "deployment",
741 Some(expected.clone()),
742 Some(observed.clone()),
743 SafetySeverityV1::HardFailure,
744 ));
745 hard_failures.push(finding(
746 "canonical_config_mismatch",
747 "canonical runtime config digest differs from the plan",
748 SafetySeverityV1::HardFailure,
749 Some("local_config".to_string()),
750 ));
751 }
752 None => warnings.push(finding(
753 "canonical_config_unobserved",
754 "canonical runtime config digest was not observed",
755 SafetySeverityV1::Warning,
756 Some("local_config".to_string()),
757 )),
758 _ => {}
759 }
760}
761
762fn compare_verifier_readiness(
763 plan: &DeploymentPlanV1,
764 inventory: &DeploymentInventoryV1,
765 verifier_readiness_diff: &mut Vec<DiffItemV1>,
766 warnings: &mut Vec<SafetyFindingV1>,
767) {
768 if !plan.expected_verifier_readiness.required {
769 return;
770 }
771 if inventory.observed_verifier_readiness.status == ObservationStatusV1::NotObserved {
772 verifier_readiness_diff.push(diff_item(
773 "verifier_readiness",
774 "deployment",
775 Some("required".to_string()),
776 Some("not_observed".to_string()),
777 SafetySeverityV1::Warning,
778 ));
779 warnings.push(finding(
780 "verifier_readiness_unobserved",
781 "verifier readiness was required but not observed",
782 SafetySeverityV1::Warning,
783 Some("verifier_readiness".to_string()),
784 ));
785 }
786}
787
788fn finding(
789 code: impl Into<String>,
790 message: impl Into<String>,
791 severity: SafetySeverityV1,
792 subject: Option<String>,
793) -> SafetyFindingV1 {
794 SafetyFindingV1 {
795 code: code.into(),
796 message: message.into(),
797 severity,
798 subject,
799 }
800}
801
802fn diff_item(
803 category: impl Into<String>,
804 subject: impl Into<String>,
805 expected: Option<String>,
806 observed: Option<String>,
807 severity: SafetySeverityV1,
808) -> DiffItemV1 {
809 DiffItemV1 {
810 category: category.into(),
811 subject: subject.into(),
812 expected,
813 observed,
814 severity,
815 }
816}
817
818const fn safety_status(
819 hard_failures: &[SafetyFindingV1],
820 warnings: &[SafetyFindingV1],
821) -> SafetyStatusV1 {
822 if !hard_failures.is_empty() {
823 SafetyStatusV1::Blocked
824 } else if !warnings.is_empty() {
825 SafetyStatusV1::Warning
826 } else {
827 SafetyStatusV1::Safe
828 }
829}
830
831fn resume_safety_reasons(
832 hard_failures: &[SafetyFindingV1],
833 warnings: &[SafetyFindingV1],
834) -> Vec<String> {
835 if !hard_failures.is_empty() {
836 return hard_failures
837 .iter()
838 .map(|finding| finding.message.clone())
839 .collect();
840 }
841 if !warnings.is_empty() {
842 return warnings
843 .iter()
844 .map(|finding| finding.message.clone())
845 .collect();
846 }
847 vec!["no blocking deployment truth differences were found".to_string()]
848}
849
850fn safety_summary(
851 status: SafetyStatusV1,
852 hard_failure_count: usize,
853 warning_count: usize,
854) -> String {
855 match status {
856 SafetyStatusV1::Safe => "deployment inventory matches the checked plan".to_string(),
857 SafetyStatusV1::Warning => {
858 format!("deployment inventory has {warning_count} warning(s)")
859 }
860 SafetyStatusV1::Blocked => {
861 format!(
862 "deployment inventory has {hard_failure_count} blocking issue(s) and {warning_count} warning(s)"
863 )
864 }
865 SafetyStatusV1::NotEvaluated => "deployment safety has not been evaluated".to_string(),
866 }
867}
868
869fn safety_next_actions(status: SafetyStatusV1) -> Vec<String> {
870 match status {
871 SafetyStatusV1::Safe => Vec::new(),
872 SafetyStatusV1::Warning => {
873 vec!["review deployment warnings before continuing".to_string()]
874 }
875 SafetyStatusV1::Blocked => {
876 vec!["resolve blocking deployment truth differences before mutation".to_string()]
877 }
878 SafetyStatusV1::NotEvaluated => vec!["collect deployment inventory".to_string()],
879 }
880}