Skip to main content

canic_host/deployment_truth/
report.rs

1use super::*;
2use std::collections::{BTreeMap, BTreeSet};
3
4///
5/// LocalDeploymentCheckRequest
6///
7#[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
19/// Build local plan and inventory, then return the passive safety check bundle.
20pub 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/// Compare intended deployment state with observed inventory into a machine diff.
67#[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/// Render an operator-facing safety report from a machine deployment diff.
157#[must_use]
158pub fn safety_report_from_diff(
159    report_id: impl Into<String>,
160    diff_id: Option<String>,
161    diff: &DeploymentDiffV1,
162) -> SafetyReportV1 {
163    let status = safety_status(&diff.hard_failures, &diff.warnings);
164    SafetyReportV1 {
165        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
166        report_id: report_id.into(),
167        diff_id,
168        status,
169        summary: safety_summary(status, diff.hard_failures.len(), diff.warnings.len()),
170        hard_failures: diff.hard_failures.clone(),
171        warnings: diff.warnings.clone(),
172        next_actions: safety_next_actions(status),
173    }
174}
175
176fn compare_identity(
177    plan: &DeploymentPlanV1,
178    inventory: &DeploymentInventoryV1,
179    hard_failures: &mut Vec<SafetyFindingV1>,
180) {
181    let Some(observed) = &inventory.observed_identity else {
182        hard_failures.push(finding(
183            "identity_unobserved",
184            "deployment identity was not observed",
185            SafetySeverityV1::HardFailure,
186            None,
187        ));
188        return;
189    };
190
191    if observed.network != plan.deployment_identity.network {
192        hard_failures.push(finding(
193            "network_mismatch",
194            format!(
195                "plan network {} differs from observed network {}",
196                plan.deployment_identity.network, observed.network
197            ),
198            SafetySeverityV1::HardFailure,
199            Some("deployment_identity.network".to_string()),
200        ));
201    }
202    if let (Some(expected), Some(actual)) = (
203        plan.deployment_identity.root_principal.as_ref(),
204        observed.root_principal.as_ref(),
205    ) && expected != actual
206    {
207        hard_failures.push(finding(
208            "root_trust_anchor_mismatch",
209            format!("plan root {expected} differs from observed root {actual}"),
210            SafetySeverityV1::HardFailure,
211            Some("deployment_identity.root_principal".to_string()),
212        ));
213    }
214    match (
215        plan.deployment_identity.deployment_manifest_digest.as_ref(),
216        observed.deployment_manifest_digest.as_ref(),
217    ) {
218        (Some(expected), Some(actual)) if expected != actual => {
219            hard_failures.push(finding(
220                "deployment_manifest_mismatch",
221                "deployment manifest digest differs from the observed local config",
222                SafetySeverityV1::HardFailure,
223                Some("deployment_identity.deployment_manifest_digest".to_string()),
224            ));
225        }
226        (Some(_), None) => {
227            hard_failures.push(finding(
228                "deployment_manifest_unobserved",
229                "deployment manifest digest was not observed",
230                SafetySeverityV1::HardFailure,
231                Some("deployment_identity.deployment_manifest_digest".to_string()),
232            ));
233        }
234        _ => {}
235    }
236}
237
238fn compare_authority_profile(
239    plan: &DeploymentPlanV1,
240    controller_diff: &mut Vec<DiffItemV1>,
241    hard_failures: &mut Vec<SafetyFindingV1>,
242) {
243    let mut reported = BTreeSet::new();
244    for controller in &plan.authority_profile.expected_controllers {
245        if !is_staging_or_emergency_controller(plan, controller) {
246            continue;
247        }
248        if !reported.insert(controller.as_str()) {
249            continue;
250        }
251        controller_diff.push(diff_item(
252            "controller_authority_overlap",
253            "authority_profile",
254            Some("expected-only".to_string()),
255            Some(controller.clone()),
256            SafetySeverityV1::HardFailure,
257        ));
258        hard_failures.push(finding(
259            "controller_authority_overlap",
260            format!(
261                "controller {controller} appears in both expected and staging/emergency authority"
262            ),
263            SafetySeverityV1::HardFailure,
264            Some("authority_profile".to_string()),
265        ));
266    }
267}
268
269fn compare_artifacts(
270    plan: &DeploymentPlanV1,
271    inventory: &DeploymentInventoryV1,
272    artifact_diff: &mut Vec<DiffItemV1>,
273    hard_failures: &mut Vec<SafetyFindingV1>,
274    warnings: &mut Vec<SafetyFindingV1>,
275) {
276    let observed_by_role = inventory
277        .observed_artifacts
278        .iter()
279        .map(|artifact| (artifact.role.as_str(), artifact))
280        .collect::<BTreeMap<_, _>>();
281
282    for expected in &plan.role_artifacts {
283        let Some(observed) = observed_by_role.get(expected.role.as_str()) else {
284            artifact_diff.push(diff_item(
285                "artifact",
286                &expected.role,
287                expected.wasm_gz_path.clone(),
288                None,
289                SafetySeverityV1::HardFailure,
290            ));
291            hard_failures.push(finding(
292                "artifact_missing",
293                format!("missing observed artifact for role {}", expected.role),
294                SafetySeverityV1::HardFailure,
295                Some(expected.role.clone()),
296            ));
297            continue;
298        };
299
300        compare_artifact_file_sha256(expected, observed, artifact_diff, hard_failures);
301
302        match (
303            expected.wasm_gz_sha256.as_ref(),
304            observed.payload_sha256.as_ref(),
305        ) {
306            (Some(want), Some(got)) if want != got => {
307                artifact_diff.push(diff_item(
308                    "artifact_sha256",
309                    &expected.role,
310                    Some(want.clone()),
311                    Some(got.clone()),
312                    SafetySeverityV1::HardFailure,
313                ));
314                hard_failures.push(finding(
315                    "artifact_digest_mismatch",
316                    format!("artifact digest mismatch for role {}", expected.role),
317                    SafetySeverityV1::HardFailure,
318                    Some(expected.role.clone()),
319                ));
320            }
321            (Some(want), None) => warnings.push(finding(
322                "artifact_digest_unobserved",
323                format!(
324                    "expected artifact digest {want} for role {} was not observed",
325                    expected.role
326                ),
327                SafetySeverityV1::Warning,
328                Some(expected.role.clone()),
329            )),
330            _ => {}
331        }
332    }
333}
334
335fn compare_artifact_file_sha256(
336    expected: &RoleArtifactV1,
337    observed: &ObservedArtifactV1,
338    artifact_diff: &mut Vec<DiffItemV1>,
339    hard_failures: &mut Vec<SafetyFindingV1>,
340) {
341    match (
342        expected.observed_wasm_gz_file_sha256.as_ref(),
343        observed.file_sha256.as_ref(),
344    ) {
345        (Some(want), Some(got)) if want != got => {
346            artifact_diff.push(diff_item(
347                "artifact_file_sha256",
348                &expected.role,
349                Some(want.clone()),
350                Some(got.clone()),
351                SafetySeverityV1::HardFailure,
352            ));
353            hard_failures.push(finding(
354                "artifact_file_digest_mismatch",
355                format!(
356                    "observed artifact file digest changed during deployment truth check for role {}",
357                    expected.role
358                ),
359                SafetySeverityV1::HardFailure,
360                Some(expected.role.clone()),
361            ));
362        }
363        (_, Some(got)) => {
364            artifact_diff.push(diff_item(
365                "artifact_file_sha256",
366                &expected.role,
367                expected.observed_wasm_gz_file_sha256.clone(),
368                Some(got.clone()),
369                SafetySeverityV1::Info,
370            ));
371        }
372        _ => {}
373    }
374}
375
376fn compare_canisters(
377    plan: &DeploymentPlanV1,
378    inventory: &DeploymentInventoryV1,
379    controller_diff: &mut Vec<DiffItemV1>,
380    hard_failures: &mut Vec<SafetyFindingV1>,
381    warnings: &mut Vec<SafetyFindingV1>,
382) {
383    for expected in &plan.expected_canisters {
384        let observed = expected.canister_id.as_ref().map_or_else(
385            || {
386                inventory
387                    .observed_canisters
388                    .iter()
389                    .find(|canister| canister.role.as_deref() == Some(expected.role.as_str()))
390            },
391            |id| {
392                inventory
393                    .observed_canisters
394                    .iter()
395                    .find(|canister| &canister.canister_id == id)
396            },
397        );
398        let Some(observed) = observed else {
399            let severity = if expected.canister_id.is_some() {
400                SafetySeverityV1::HardFailure
401            } else {
402                SafetySeverityV1::Warning
403            };
404            controller_diff.push(diff_item(
405                "canister",
406                &expected.role,
407                expected.canister_id.clone(),
408                None,
409                severity,
410            ));
411            let finding = finding(
412                if expected.canister_id.is_some() {
413                    "canister_missing"
414                } else {
415                    "canister_unobserved"
416                },
417                format!("missing observed canister for role {}", expected.role),
418                severity,
419                Some(expected.role.clone()),
420            );
421            if expected.canister_id.is_some() {
422                hard_failures.push(finding);
423            } else {
424                warnings.push(finding);
425            }
426            continue;
427        };
428        if matches!(
429            observed.control_class,
430            CanisterControlClassV1::UnknownUnsafe | CanisterControlClassV1::UserControlled
431        ) && expected.control_class == CanisterControlClassV1::DeploymentControlled
432        {
433            controller_diff.push(diff_item(
434                "control_class",
435                &expected.role,
436                Some("DeploymentControlled".to_string()),
437                Some(format!("{:?}", observed.control_class)),
438                SafetySeverityV1::HardFailure,
439            ));
440            hard_failures.push(finding(
441                "unsafe_control_class",
442                format!("role {} has unsafe observed control class", expected.role),
443                SafetySeverityV1::HardFailure,
444                Some(expected.role.clone()),
445            ));
446        }
447        compare_role_controllers(plan, observed, controller_diff, hard_failures, warnings);
448    }
449}
450
451fn compare_role_controllers(
452    plan: &DeploymentPlanV1,
453    observed: &ObservedCanisterV1,
454    controller_diff: &mut Vec<DiffItemV1>,
455    hard_failures: &mut Vec<SafetyFindingV1>,
456    warnings: &mut Vec<SafetyFindingV1>,
457) {
458    let role = observed.role.as_deref().unwrap_or("unknown");
459    for expected in &plan.authority_profile.expected_controllers {
460        if observed
461            .controllers
462            .iter()
463            .any(|controller| controller == expected)
464        {
465            continue;
466        }
467        controller_diff.push(diff_item(
468            "controller_missing",
469            role,
470            Some(expected.clone()),
471            Some(controller_set_label(&observed.controllers)),
472            SafetySeverityV1::HardFailure,
473        ));
474        hard_failures.push(finding(
475            "expected_controller_missing",
476            format!("role {role} is missing expected controller {expected}"),
477            SafetySeverityV1::HardFailure,
478            Some(role.to_string()),
479        ));
480    }
481
482    for observed_controller in &observed.controllers {
483        if is_declared_controller(plan, observed_controller) {
484            continue;
485        }
486        controller_diff.push(diff_item(
487            "controller_extra",
488            role,
489            Some(controller_set_label(
490                &plan.authority_profile.expected_controllers,
491            )),
492            Some(observed_controller.clone()),
493            SafetySeverityV1::Warning,
494        ));
495        warnings.push(finding(
496            "extra_controller_observed",
497            format!("role {role} has controller outside the expected authority profile"),
498            SafetySeverityV1::Warning,
499            Some(role.to_string()),
500        ));
501    }
502}
503
504fn is_declared_controller(plan: &DeploymentPlanV1, controller: &str) -> bool {
505    plan.authority_profile
506        .expected_controllers
507        .iter()
508        .chain(plan.authority_profile.staging_controllers.iter())
509        .chain(plan.authority_profile.emergency_controllers.iter())
510        .any(|expected| expected == controller)
511}
512
513fn is_staging_or_emergency_controller(plan: &DeploymentPlanV1, controller: &str) -> bool {
514    plan.authority_profile
515        .staging_controllers
516        .iter()
517        .chain(plan.authority_profile.emergency_controllers.iter())
518        .any(|declared| declared == controller)
519}
520
521fn controller_set_label(controllers: &[String]) -> String {
522    if controllers.is_empty() {
523        return "<none>".to_string();
524    }
525    controllers.join(",")
526}
527
528fn compare_module_hashes(
529    plan: &DeploymentPlanV1,
530    inventory: &DeploymentInventoryV1,
531    module_hash_diff: &mut Vec<DiffItemV1>,
532    hard_failures: &mut Vec<SafetyFindingV1>,
533    warnings: &mut Vec<SafetyFindingV1>,
534) {
535    let observed_by_role = inventory
536        .observed_canisters
537        .iter()
538        .filter_map(|canister| canister.role.as_deref().map(|role| (role, canister)))
539        .collect::<BTreeMap<_, _>>();
540
541    for artifact in &plan.role_artifacts {
542        let Some(expected) = artifact.installed_module_hash.as_ref() else {
543            continue;
544        };
545        let Some(observed_canister) = observed_by_role.get(artifact.role.as_str()) else {
546            continue;
547        };
548        match observed_canister.module_hash.as_ref() {
549            Some(observed) if observed != expected => {
550                module_hash_diff.push(diff_item(
551                    "installed_module_hash",
552                    &artifact.role,
553                    Some(expected.clone()),
554                    Some(observed.clone()),
555                    SafetySeverityV1::HardFailure,
556                ));
557                hard_failures.push(finding(
558                    "installed_module_hash_mismatch",
559                    format!("installed module hash differs for role {}", artifact.role),
560                    SafetySeverityV1::HardFailure,
561                    Some(artifact.role.clone()),
562                ));
563            }
564            None => warnings.push(finding(
565                "installed_module_hash_unobserved",
566                format!(
567                    "installed module hash was not observed for role {}",
568                    artifact.role
569                ),
570                SafetySeverityV1::Warning,
571                Some(artifact.role.clone()),
572            )),
573            _ => {}
574        }
575    }
576}
577
578fn compare_raw_config(
579    plan: &DeploymentPlanV1,
580    inventory: &DeploymentInventoryV1,
581    embedded_config_diff: &mut Vec<DiffItemV1>,
582    hard_failures: &mut Vec<SafetyFindingV1>,
583) {
584    let mut expected = plan
585        .role_artifacts
586        .iter()
587        .filter_map(|artifact| artifact.raw_config_sha256.as_ref())
588        .collect::<Vec<_>>();
589    expected.sort_unstable();
590    expected.dedup();
591    let [expected] = expected.as_slice() else {
592        if expected.len() > 1 {
593            hard_failures.push(finding(
594                "raw_config_plan_inconsistent",
595                "planned role artifacts disagree on raw config digest",
596                SafetySeverityV1::HardFailure,
597                Some("role_artifacts.raw_config_sha256".to_string()),
598            ));
599        }
600        return;
601    };
602
603    if let Some(observed) = &inventory.local_config.raw_config_sha256
604        && observed != *expected
605    {
606        embedded_config_diff.push(diff_item(
607            "raw_config_sha256",
608            "deployment",
609            Some((*expected).clone()),
610            Some(observed.clone()),
611            SafetySeverityV1::HardFailure,
612        ));
613        hard_failures.push(finding(
614            "raw_config_digest_mismatch",
615            "raw local config digest changed during deployment truth check",
616            SafetySeverityV1::HardFailure,
617            Some("local_config.raw_sha256".to_string()),
618        ));
619    }
620}
621
622fn compare_embedded_config(
623    plan: &DeploymentPlanV1,
624    inventory: &DeploymentInventoryV1,
625    embedded_config_diff: &mut Vec<DiffItemV1>,
626    hard_failures: &mut Vec<SafetyFindingV1>,
627    warnings: &mut Vec<SafetyFindingV1>,
628) {
629    let Some(expected) = &plan.deployment_identity.canonical_runtime_config_digest else {
630        return;
631    };
632    match &inventory.local_config.canonical_embedded_config_sha256 {
633        Some(observed) if observed != expected => {
634            embedded_config_diff.push(diff_item(
635                "canonical_config",
636                "deployment",
637                Some(expected.clone()),
638                Some(observed.clone()),
639                SafetySeverityV1::HardFailure,
640            ));
641            hard_failures.push(finding(
642                "canonical_config_mismatch",
643                "canonical runtime config digest differs from the plan",
644                SafetySeverityV1::HardFailure,
645                Some("local_config".to_string()),
646            ));
647        }
648        None => warnings.push(finding(
649            "canonical_config_unobserved",
650            "canonical runtime config digest was not observed",
651            SafetySeverityV1::Warning,
652            Some("local_config".to_string()),
653        )),
654        _ => {}
655    }
656}
657
658fn compare_verifier_readiness(
659    plan: &DeploymentPlanV1,
660    inventory: &DeploymentInventoryV1,
661    verifier_readiness_diff: &mut Vec<DiffItemV1>,
662    warnings: &mut Vec<SafetyFindingV1>,
663) {
664    if !plan.expected_verifier_readiness.required {
665        return;
666    }
667    if inventory.observed_verifier_readiness.status == ObservationStatusV1::NotObserved {
668        verifier_readiness_diff.push(diff_item(
669            "verifier_readiness",
670            "deployment",
671            Some("required".to_string()),
672            Some("not_observed".to_string()),
673            SafetySeverityV1::Warning,
674        ));
675        warnings.push(finding(
676            "verifier_readiness_unobserved",
677            "verifier readiness was required but not observed",
678            SafetySeverityV1::Warning,
679            Some("verifier_readiness".to_string()),
680        ));
681    }
682}
683
684fn finding(
685    code: impl Into<String>,
686    message: impl Into<String>,
687    severity: SafetySeverityV1,
688    subject: Option<String>,
689) -> SafetyFindingV1 {
690    SafetyFindingV1 {
691        code: code.into(),
692        message: message.into(),
693        severity,
694        subject,
695    }
696}
697
698fn diff_item(
699    category: impl Into<String>,
700    subject: impl Into<String>,
701    expected: Option<String>,
702    observed: Option<String>,
703    severity: SafetySeverityV1,
704) -> DiffItemV1 {
705    DiffItemV1 {
706        category: category.into(),
707        subject: subject.into(),
708        expected,
709        observed,
710        severity,
711    }
712}
713
714const fn safety_status(
715    hard_failures: &[SafetyFindingV1],
716    warnings: &[SafetyFindingV1],
717) -> SafetyStatusV1 {
718    if !hard_failures.is_empty() {
719        SafetyStatusV1::Blocked
720    } else if !warnings.is_empty() {
721        SafetyStatusV1::Warning
722    } else {
723        SafetyStatusV1::Safe
724    }
725}
726
727fn resume_safety_reasons(
728    hard_failures: &[SafetyFindingV1],
729    warnings: &[SafetyFindingV1],
730) -> Vec<String> {
731    if !hard_failures.is_empty() {
732        return hard_failures
733            .iter()
734            .map(|finding| finding.message.clone())
735            .collect();
736    }
737    if !warnings.is_empty() {
738        return warnings
739            .iter()
740            .map(|finding| finding.message.clone())
741            .collect();
742    }
743    vec!["no blocking deployment truth differences were found".to_string()]
744}
745
746fn safety_summary(
747    status: SafetyStatusV1,
748    hard_failure_count: usize,
749    warning_count: usize,
750) -> String {
751    match status {
752        SafetyStatusV1::Safe => "deployment inventory matches the checked plan".to_string(),
753        SafetyStatusV1::Warning => {
754            format!("deployment inventory has {warning_count} warning(s)")
755        }
756        SafetyStatusV1::Blocked => {
757            format!(
758                "deployment inventory has {hard_failure_count} blocking issue(s) and {warning_count} warning(s)"
759            )
760        }
761        SafetyStatusV1::NotEvaluated => "deployment safety has not been evaluated".to_string(),
762    }
763}
764
765fn safety_next_actions(status: SafetyStatusV1) -> Vec<String> {
766    match status {
767        SafetyStatusV1::Safe => Vec::new(),
768        SafetyStatusV1::Warning => {
769            vec!["review deployment warnings before continuing".to_string()]
770        }
771        SafetyStatusV1::Blocked => {
772            vec!["resolve blocking deployment truth differences before mutation".to_string()]
773        }
774        SafetyStatusV1::NotEvaluated => vec!["collect deployment inventory".to_string()],
775    }
776}