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/// Compare intended state, observed inventory, and a prior receipt into a
157/// resume-aware deployment diff.
158#[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/// Render an operator-facing safety report from a machine deployment diff.
170#[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}