Skip to main content

canic_host/deployment_truth/
report.rs

1use super::*;
2use std::collections::{BTreeMap, BTreeSet};
3
4///
5/// DuplicateEvidenceGroup
6///
7struct DuplicateEvidenceGroup {
8    subject: String,
9    count: usize,
10    evidence_label: String,
11    is_conflict: bool,
12}
13
14///
15/// LocalDeploymentCheckRequest
16///
17#[derive(Clone, Debug, Eq, PartialEq)]
18pub struct LocalDeploymentCheckRequest {
19    pub deployment_name: String,
20    pub network: String,
21    pub workspace_root: std::path::PathBuf,
22    pub icp_root: std::path::PathBuf,
23    pub config_path: Option<std::path::PathBuf>,
24    pub observed_at: String,
25    pub runtime_variant: String,
26    pub build_profile: String,
27}
28
29/// Build local plan and inventory, then return the passive safety check bundle.
30pub fn check_local_deployment(
31    request: &LocalDeploymentCheckRequest,
32) -> Result<DeploymentCheckV1, DeploymentTruthError> {
33    let plan = build_local_deployment_plan(&LocalDeploymentPlanRequest {
34        deployment_name: request.deployment_name.clone(),
35        network: request.network.clone(),
36        workspace_root: request.workspace_root.clone(),
37        icp_root: request.icp_root.clone(),
38        config_path: request.config_path.clone(),
39        runtime_variant: request.runtime_variant.clone(),
40        build_profile: request.build_profile.clone(),
41    });
42    let inventory = collect_local_deployment_inventory(&LocalInventoryRequest {
43        deployment_name: request.deployment_name.clone(),
44        network: request.network.clone(),
45        workspace_root: request.workspace_root.clone(),
46        icp_root: request.icp_root.clone(),
47        config_path: request.config_path.clone(),
48        observed_at: request.observed_at.clone(),
49    })?;
50    let diff = compare_plan_to_inventory(&plan, &inventory);
51    let report = safety_report_from_diff(
52        format!(
53            "local:{}:{}:report",
54            request.network, request.deployment_name
55        ),
56        Some(format!(
57            "local:{}:{}:diff",
58            request.network, request.deployment_name
59        )),
60        &diff,
61    );
62
63    Ok(DeploymentCheckV1 {
64        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
65        check_id: format!(
66            "local:{}:{}:check",
67            request.network, request.deployment_name
68        ),
69        plan,
70        inventory,
71        diff,
72        report,
73    })
74}
75
76/// Compare intended deployment state with observed inventory into a machine diff.
77#[must_use]
78pub fn compare_plan_to_inventory(
79    plan: &DeploymentPlanV1,
80    inventory: &DeploymentInventoryV1,
81) -> DeploymentDiffV1 {
82    let mut artifact_diff = Vec::new();
83    let mut controller_diff = Vec::new();
84    let mut pool_diff = Vec::new();
85    let mut embedded_config_diff = Vec::new();
86    let mut module_hash_diff = Vec::new();
87    let mut verifier_readiness_diff = Vec::new();
88    let mut hard_failures = Vec::new();
89    let mut warnings = Vec::new();
90
91    compare_identity(plan, inventory, &mut hard_failures);
92    compare_authority_profile(plan, &mut controller_diff, &mut hard_failures);
93    compare_artifacts(
94        plan,
95        inventory,
96        &mut artifact_diff,
97        &mut hard_failures,
98        &mut warnings,
99    );
100    compare_observed_canister_id_conflicts(
101        inventory,
102        &mut controller_diff,
103        &mut hard_failures,
104        &mut warnings,
105    );
106    compare_observed_canister_pool_role_conflicts(inventory, &mut pool_diff, &mut hard_failures);
107    compare_canisters(
108        plan,
109        inventory,
110        &mut controller_diff,
111        &mut hard_failures,
112        &mut warnings,
113    );
114    compare_pools(
115        plan,
116        inventory,
117        &mut pool_diff,
118        &mut hard_failures,
119        &mut warnings,
120    );
121    compare_module_hashes(
122        plan,
123        inventory,
124        &mut module_hash_diff,
125        &mut hard_failures,
126        &mut warnings,
127    );
128    compare_raw_config(
129        plan,
130        inventory,
131        &mut embedded_config_diff,
132        &mut hard_failures,
133    );
134    compare_embedded_config(
135        plan,
136        inventory,
137        &mut embedded_config_diff,
138        &mut hard_failures,
139        &mut warnings,
140    );
141    compare_verifier_readiness(
142        plan,
143        inventory,
144        &mut verifier_readiness_diff,
145        &mut hard_failures,
146        &mut warnings,
147    );
148    for assumption in &plan.unresolved_assumptions {
149        warnings.push(SafetyFindingV1 {
150            code: "plan_assumption".to_string(),
151            message: assumption.description.clone(),
152            severity: SafetySeverityV1::Warning,
153            subject: Some(assumption.key.clone()),
154        });
155    }
156    for gap in &inventory.unresolved_observations {
157        warnings.push(SafetyFindingV1 {
158            code: "observation_gap".to_string(),
159            message: gap.description.clone(),
160            severity: SafetySeverityV1::Warning,
161            subject: Some(gap.key.clone()),
162        });
163    }
164
165    let status = safety_status(&hard_failures, &warnings);
166    DeploymentDiffV1 {
167        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
168        plan_identity: plan.deployment_identity.clone(),
169        observed_identity: inventory.observed_identity.clone(),
170        artifact_diff,
171        controller_diff,
172        pool_diff,
173        embedded_config_diff,
174        module_hash_diff,
175        verifier_readiness_diff,
176        resume_safety: ResumeSafetyV1 {
177            status,
178            reasons: resume_safety_reasons(&hard_failures, &warnings),
179        },
180        hard_failures,
181        warnings,
182        resumable_phases: Vec::new(),
183    }
184}
185
186/// Compare intended state, observed inventory, and a prior receipt into a
187/// resume-aware deployment diff.
188#[must_use]
189pub fn compare_plan_inventory_and_receipt(
190    plan: &DeploymentPlanV1,
191    inventory: &DeploymentInventoryV1,
192    receipt: &DeploymentReceiptV1,
193) -> DeploymentDiffV1 {
194    let mut diff = compare_plan_to_inventory(plan, inventory);
195    apply_receipt_resume_safety(plan, receipt, &mut diff);
196    diff
197}
198
199/// Render an operator-facing safety report from a machine deployment diff.
200#[must_use]
201pub fn safety_report_from_diff(
202    report_id: impl Into<String>,
203    diff_id: Option<String>,
204    diff: &DeploymentDiffV1,
205) -> SafetyReportV1 {
206    let status = safety_status(&diff.hard_failures, &diff.warnings);
207    SafetyReportV1 {
208        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
209        report_id: report_id.into(),
210        diff_id,
211        status,
212        summary: safety_summary(status, diff.hard_failures.len(), diff.warnings.len()),
213        hard_failures: diff.hard_failures.clone(),
214        warnings: diff.warnings.clone(),
215        next_actions: safety_next_actions(status),
216    }
217}
218
219fn apply_receipt_resume_safety(
220    plan: &DeploymentPlanV1,
221    receipt: &DeploymentReceiptV1,
222    diff: &mut DeploymentDiffV1,
223) {
224    validate_receipt_identity(plan, receipt, &mut diff.hard_failures);
225    validate_receipt_command_result(receipt, &mut diff.hard_failures);
226    let phase_conflicts =
227        validate_receipt_phase_duplicates(receipt, &mut diff.hard_failures, &mut diff.warnings);
228    let role_phase_conflicts = validate_receipt_role_phase_duplicates(
229        receipt,
230        &mut diff.hard_failures,
231        &mut diff.warnings,
232    );
233    if !diff.hard_failures.is_empty() {
234        diff.resume_safety.status = safety_status(&diff.hard_failures, &diff.warnings);
235        diff.resume_safety.reasons = resume_safety_reasons(&diff.hard_failures, &diff.warnings);
236        return;
237    }
238    let phase_failures = receipt_phase_failures(receipt);
239    for receipt in &receipt.phase_receipts {
240        if phase_conflicts.contains(&receipt.phase) {
241            continue;
242        }
243        if receipt.verified_postcondition.status != ObservationStatusV1::Observed {
244            diff.hard_failures.push(finding(
245                "receipt_postcondition_unverified",
246                format!(
247                    "receipt phase {} has no observed postcondition",
248                    receipt.phase
249                ),
250                SafetySeverityV1::HardFailure,
251                Some(receipt.phase.clone()),
252            ));
253            continue;
254        }
255        if phase_failures.contains(receipt.phase.as_str()) {
256            continue;
257        }
258        if role_phase_conflicts.contains(receipt.phase.as_str()) {
259            continue;
260        }
261        diff.resumable_phases.push(receipt.phase.clone());
262    }
263    diff.resumable_phases.sort();
264    diff.resumable_phases.dedup();
265    diff.resume_safety.status = safety_status(&diff.hard_failures, &diff.warnings);
266    diff.resume_safety.reasons = resume_safety_reasons(&diff.hard_failures, &diff.warnings);
267}
268
269fn validate_receipt_phase_duplicates(
270    receipt: &DeploymentReceiptV1,
271    hard_failures: &mut Vec<SafetyFindingV1>,
272    warnings: &mut Vec<SafetyFindingV1>,
273) -> BTreeSet<String> {
274    let mut conflicting_phases = BTreeSet::new();
275    for group in duplicate_evidence_groups(
276        &receipt.phase_receipts,
277        |phase_receipt| phase_receipt.phase.as_str().to_string(),
278        receipt_phase_evidence_label,
279        " | ",
280    ) {
281        if group.is_conflict {
282            conflicting_phases.insert(group.subject.clone());
283            hard_failures.push(finding(
284                "receipt_phase_conflict",
285                format!(
286                    "receipt phase {} has conflicting evidence: {}",
287                    group.subject, group.evidence_label
288                ),
289                SafetySeverityV1::HardFailure,
290                Some(group.subject),
291            ));
292        } else {
293            warnings.push(finding(
294                "duplicate_receipt_phase",
295                format!(
296                    "receipt phase {} was reported {} times with identical evidence",
297                    group.subject, group.count
298                ),
299                SafetySeverityV1::Warning,
300                Some(group.subject),
301            ));
302        }
303    }
304    conflicting_phases
305}
306
307fn receipt_phase_evidence_label(receipt: &PhaseReceiptV1) -> String {
308    format!(
309        "status={:?};evidence={}",
310        receipt.verified_postcondition.status,
311        receipt.verified_postcondition.evidence.join(",")
312    )
313}
314
315fn validate_receipt_role_phase_duplicates(
316    receipt: &DeploymentReceiptV1,
317    hard_failures: &mut Vec<SafetyFindingV1>,
318    warnings: &mut Vec<SafetyFindingV1>,
319) -> BTreeSet<String> {
320    let mut conflicting_phases = BTreeSet::new();
321    for group in duplicate_evidence_groups(
322        &receipt.role_phase_receipts,
323        role_phase_subject,
324        role_phase_evidence_label,
325        " | ",
326    ) {
327        if group.is_conflict {
328            if let Some(phase) = group
329                .subject
330                .rsplit_once(':')
331                .map(|(_, phase)| phase.to_string())
332            {
333                conflicting_phases.insert(phase);
334            }
335            hard_failures.push(finding(
336                "receipt_role_phase_conflict",
337                format!(
338                    "receipt role phase {} has conflicting evidence: {}",
339                    group.subject, group.evidence_label
340                ),
341                SafetySeverityV1::HardFailure,
342                Some(group.subject),
343            ));
344        } else {
345            warnings.push(finding(
346                "duplicate_receipt_role_phase",
347                format!(
348                    "receipt role phase {} was reported {} times with identical evidence",
349                    group.subject, group.count
350                ),
351                SafetySeverityV1::Warning,
352                Some(group.subject),
353            ));
354        }
355    }
356    conflicting_phases
357}
358
359fn role_phase_subject(receipt: &RolePhaseReceiptV1) -> String {
360    format!("{}:{}", receipt.role, receipt.phase)
361}
362
363fn role_phase_evidence_label(receipt: &RolePhaseReceiptV1) -> String {
364    format!(
365        "result={:?};previous={};target={};observed={};artifact={};config={};error={}",
366        receipt.result,
367        receipt.previous_module_hash.as_deref().unwrap_or("<none>"),
368        receipt.target_module_hash.as_deref().unwrap_or("<none>"),
369        receipt
370            .observed_module_hash_after
371            .as_deref()
372            .unwrap_or("<none>"),
373        receipt.artifact_digest.as_deref().unwrap_or("<none>"),
374        receipt
375            .canonical_embedded_config_sha256
376            .as_deref()
377            .unwrap_or("<none>"),
378        receipt.error.as_deref().unwrap_or("<none>")
379    )
380}
381
382fn validate_receipt_identity(
383    plan: &DeploymentPlanV1,
384    receipt: &DeploymentReceiptV1,
385    hard_failures: &mut Vec<SafetyFindingV1>,
386) {
387    if receipt.plan_id != plan.plan_id {
388        hard_failures.push(finding(
389            "receipt_plan_mismatch",
390            format!(
391                "receipt plan {} does not match current plan {}",
392                receipt.plan_id, plan.plan_id
393            ),
394            SafetySeverityV1::HardFailure,
395            Some("receipt.plan_id".to_string()),
396        ));
397    }
398    if let (Some(expected), Some(observed)) = (
399        plan.deployment_identity.root_principal.as_ref(),
400        receipt.root_principal.as_ref(),
401    ) && expected != observed
402    {
403        hard_failures.push(finding(
404            "receipt_root_mismatch",
405            format!("receipt root {observed} does not match current plan root {expected}"),
406            SafetySeverityV1::HardFailure,
407            Some("receipt.root_principal".to_string()),
408        ));
409    }
410}
411
412fn validate_receipt_command_result(
413    receipt: &DeploymentReceiptV1,
414    hard_failures: &mut Vec<SafetyFindingV1>,
415) {
416    if let DeploymentCommandResultV1::Failed { code, message } = &receipt.command_result {
417        hard_failures.push(finding(
418            "receipt_failed_command",
419            format!("receipt command failed with {code}: {message}"),
420            SafetySeverityV1::HardFailure,
421            Some("receipt.command_result".to_string()),
422        ));
423    }
424}
425
426fn receipt_phase_failures(receipt: &DeploymentReceiptV1) -> BTreeSet<&str> {
427    let mut failures = BTreeSet::new();
428    for role_receipt in &receipt.role_phase_receipts {
429        if matches!(role_receipt.result, RolePhaseResultV1::Failed) {
430            failures.insert(role_receipt.phase.as_str());
431        }
432    }
433    failures
434}
435
436fn compare_identity(
437    plan: &DeploymentPlanV1,
438    inventory: &DeploymentInventoryV1,
439    hard_failures: &mut Vec<SafetyFindingV1>,
440) {
441    let Some(observed) = &inventory.observed_identity else {
442        hard_failures.push(finding(
443            "identity_unobserved",
444            "deployment identity was not observed",
445            SafetySeverityV1::HardFailure,
446            None,
447        ));
448        return;
449    };
450
451    if observed.network != plan.deployment_identity.network {
452        hard_failures.push(finding(
453            "network_mismatch",
454            format!(
455                "plan network {} differs from observed network {}",
456                plan.deployment_identity.network, observed.network
457            ),
458            SafetySeverityV1::HardFailure,
459            Some("deployment_identity.network".to_string()),
460        ));
461    }
462    if let (Some(expected), Some(actual)) = (
463        plan.deployment_identity.root_principal.as_ref(),
464        observed.root_principal.as_ref(),
465    ) && expected != actual
466    {
467        hard_failures.push(finding(
468            "root_trust_anchor_mismatch",
469            format!("plan root {expected} differs from observed root {actual}"),
470            SafetySeverityV1::HardFailure,
471            Some("deployment_identity.root_principal".to_string()),
472        ));
473    }
474    match (
475        plan.deployment_identity.deployment_manifest_digest.as_ref(),
476        observed.deployment_manifest_digest.as_ref(),
477    ) {
478        (Some(expected), Some(actual)) if expected != actual => {
479            hard_failures.push(finding(
480                "deployment_manifest_mismatch",
481                "deployment manifest digest differs from the observed local config",
482                SafetySeverityV1::HardFailure,
483                Some("deployment_identity.deployment_manifest_digest".to_string()),
484            ));
485        }
486        (Some(_), None) => {
487            hard_failures.push(finding(
488                "deployment_manifest_unobserved",
489                "deployment manifest digest was not observed",
490                SafetySeverityV1::HardFailure,
491                Some("deployment_identity.deployment_manifest_digest".to_string()),
492            ));
493        }
494        _ => {}
495    }
496}
497
498fn compare_authority_profile(
499    plan: &DeploymentPlanV1,
500    controller_diff: &mut Vec<DiffItemV1>,
501    hard_failures: &mut Vec<SafetyFindingV1>,
502) {
503    let mut reported = BTreeSet::new();
504    for controller in &plan.authority_profile.expected_controllers {
505        if !is_staging_or_emergency_controller(plan, controller) {
506            continue;
507        }
508        if !reported.insert(controller.as_str()) {
509            continue;
510        }
511        controller_diff.push(diff_item(
512            "controller_authority_overlap",
513            "authority_profile",
514            Some("expected-only".to_string()),
515            Some(controller.clone()),
516            SafetySeverityV1::HardFailure,
517        ));
518        hard_failures.push(finding(
519            "controller_authority_overlap",
520            format!(
521                "controller {controller} appears in both expected and staging/emergency authority"
522            ),
523            SafetySeverityV1::HardFailure,
524            Some("authority_profile".to_string()),
525        ));
526    }
527}
528
529fn compare_artifacts(
530    plan: &DeploymentPlanV1,
531    inventory: &DeploymentInventoryV1,
532    artifact_diff: &mut Vec<DiffItemV1>,
533    hard_failures: &mut Vec<SafetyFindingV1>,
534    warnings: &mut Vec<SafetyFindingV1>,
535) {
536    let planned_conflicting_roles =
537        compare_planned_artifact_role_conflicts(plan, artifact_diff, hard_failures, warnings);
538    let conflicting_roles =
539        compare_observed_artifact_role_conflicts(inventory, artifact_diff, hard_failures, warnings);
540    let mut observed_by_role = BTreeMap::new();
541    for artifact in &inventory.observed_artifacts {
542        if conflicting_roles.contains(&artifact.role) {
543            continue;
544        }
545        observed_by_role
546            .entry(artifact.role.as_str())
547            .or_insert(artifact);
548    }
549
550    let mut compared_roles = BTreeSet::new();
551    for expected in &plan.role_artifacts {
552        if planned_conflicting_roles.contains(&expected.role)
553            || conflicting_roles.contains(&expected.role)
554            || !compared_roles.insert(expected.role.as_str())
555        {
556            continue;
557        }
558        let Some(observed) = observed_by_role.get(expected.role.as_str()) else {
559            record_missing_artifact(expected, artifact_diff, hard_failures);
560            continue;
561        };
562
563        compare_artifact_file_sha256(expected, observed, artifact_diff, hard_failures);
564        compare_artifact_payload_sha256(expected, observed, artifact_diff, hard_failures, warnings);
565    }
566}
567
568fn compare_planned_artifact_role_conflicts(
569    plan: &DeploymentPlanV1,
570    artifact_diff: &mut Vec<DiffItemV1>,
571    hard_failures: &mut Vec<SafetyFindingV1>,
572    warnings: &mut Vec<SafetyFindingV1>,
573) -> BTreeSet<String> {
574    let mut conflicting_roles = BTreeSet::new();
575    for group in duplicate_evidence_groups(
576        &plan.role_artifacts,
577        |planned| planned.role.as_str().to_string(),
578        planned_artifact_evidence_label,
579        " | ",
580    ) {
581        if group.is_conflict {
582            conflicting_roles.insert(group.subject.clone());
583            artifact_diff.push(diff_item(
584                "planned_artifact_role_conflict",
585                &group.subject,
586                Some("one planned artifact".to_string()),
587                Some(group.evidence_label.clone()),
588                SafetySeverityV1::HardFailure,
589            ));
590            hard_failures.push(finding(
591                "planned_artifact_role_conflict",
592                format!(
593                    "planned artifact role {} has conflicting evidence: {}",
594                    group.subject, group.evidence_label
595                ),
596                SafetySeverityV1::HardFailure,
597                Some(group.subject),
598            ));
599        } else {
600            artifact_diff.push(diff_item(
601                "planned_artifact_duplicate",
602                &group.subject,
603                Some(group.evidence_label.clone()),
604                Some(group.count.to_string()),
605                SafetySeverityV1::Warning,
606            ));
607            warnings.push(finding(
608                "duplicate_planned_artifact_role",
609                format!(
610                    "planned artifact role {} was declared {} times with identical evidence",
611                    group.subject, group.count
612                ),
613                SafetySeverityV1::Warning,
614                Some(group.subject),
615            ));
616        }
617    }
618    conflicting_roles
619}
620
621fn planned_artifact_evidence_label(planned: &RoleArtifactV1) -> String {
622    format!(
623        "wasm_gz_path={};wasm_gz={};file={};module={};raw_config={};canonical={}",
624        planned.wasm_gz_path.as_deref().unwrap_or("<none>"),
625        planned.wasm_gz_sha256.as_deref().unwrap_or("<none>"),
626        planned
627            .observed_wasm_gz_file_sha256
628            .as_deref()
629            .unwrap_or("<none>"),
630        planned.installed_module_hash.as_deref().unwrap_or("<none>"),
631        planned.raw_config_sha256.as_deref().unwrap_or("<none>"),
632        planned
633            .canonical_embedded_config_sha256
634            .as_deref()
635            .unwrap_or("<none>")
636    )
637}
638
639fn compare_observed_artifact_role_conflicts(
640    inventory: &DeploymentInventoryV1,
641    artifact_diff: &mut Vec<DiffItemV1>,
642    hard_failures: &mut Vec<SafetyFindingV1>,
643    warnings: &mut Vec<SafetyFindingV1>,
644) -> BTreeSet<String> {
645    let mut conflicting_roles = BTreeSet::new();
646    for group in duplicate_evidence_groups(
647        &inventory.observed_artifacts,
648        |observed| observed.role.as_str().to_string(),
649        observed_artifact_evidence_label,
650        " | ",
651    ) {
652        if group.is_conflict {
653            conflicting_roles.insert(group.subject.clone());
654            artifact_diff.push(diff_item(
655                "artifact_role_conflict",
656                &group.subject,
657                Some("one artifact observation".to_string()),
658                Some(group.evidence_label.clone()),
659                SafetySeverityV1::HardFailure,
660            ));
661            hard_failures.push(finding(
662                "artifact_role_conflict",
663                format!(
664                    "observed artifact role {} has conflicting evidence: {}",
665                    group.subject, group.evidence_label
666                ),
667                SafetySeverityV1::HardFailure,
668                Some(group.subject),
669            ));
670        } else {
671            artifact_diff.push(diff_item(
672                "artifact_duplicate",
673                &group.subject,
674                Some(group.evidence_label.clone()),
675                Some(group.count.to_string()),
676                SafetySeverityV1::Warning,
677            ));
678            warnings.push(finding(
679                "duplicate_artifact_observed",
680                format!(
681                    "observed artifact role {} was reported {} times with identical evidence",
682                    group.subject, group.count
683                ),
684                SafetySeverityV1::Warning,
685                Some(group.subject),
686            ));
687        }
688    }
689    conflicting_roles
690}
691
692fn observed_artifact_evidence_label(observed: &ObservedArtifactV1) -> String {
693    format!(
694        "path={};file={};payload={};size={};source={:?}",
695        observed.artifact_path,
696        observed.file_sha256.as_deref().unwrap_or("<none>"),
697        observed.payload_sha256.as_deref().unwrap_or("<none>"),
698        observed
699            .payload_size_bytes
700            .map_or_else(|| "<none>".to_string(), |size| size.to_string()),
701        observed.source
702    )
703}
704
705fn record_missing_artifact(
706    expected: &RoleArtifactV1,
707    artifact_diff: &mut Vec<DiffItemV1>,
708    hard_failures: &mut Vec<SafetyFindingV1>,
709) {
710    artifact_diff.push(diff_item(
711        "artifact",
712        &expected.role,
713        expected.wasm_gz_path.clone(),
714        None,
715        SafetySeverityV1::HardFailure,
716    ));
717    hard_failures.push(finding(
718        "artifact_missing",
719        format!("missing observed artifact for role {}", expected.role),
720        SafetySeverityV1::HardFailure,
721        Some(expected.role.clone()),
722    ));
723}
724
725fn compare_artifact_file_sha256(
726    expected: &RoleArtifactV1,
727    observed: &ObservedArtifactV1,
728    artifact_diff: &mut Vec<DiffItemV1>,
729    hard_failures: &mut Vec<SafetyFindingV1>,
730) {
731    match (
732        expected.observed_wasm_gz_file_sha256.as_ref(),
733        observed.file_sha256.as_ref(),
734    ) {
735        (Some(want), Some(got)) if want != got => {
736            artifact_diff.push(diff_item(
737                "artifact_file_sha256",
738                &expected.role,
739                Some(want.clone()),
740                Some(got.clone()),
741                SafetySeverityV1::HardFailure,
742            ));
743            hard_failures.push(finding(
744                "artifact_file_digest_mismatch",
745                format!(
746                    "observed artifact file digest changed during deployment truth check for role {}",
747                    expected.role
748                ),
749                SafetySeverityV1::HardFailure,
750                Some(expected.role.clone()),
751            ));
752        }
753        (_, Some(got)) => {
754            artifact_diff.push(diff_item(
755                "artifact_file_sha256",
756                &expected.role,
757                expected.observed_wasm_gz_file_sha256.clone(),
758                Some(got.clone()),
759                SafetySeverityV1::Info,
760            ));
761        }
762        _ => {}
763    }
764}
765
766fn compare_artifact_payload_sha256(
767    expected: &RoleArtifactV1,
768    observed: &ObservedArtifactV1,
769    artifact_diff: &mut Vec<DiffItemV1>,
770    hard_failures: &mut Vec<SafetyFindingV1>,
771    warnings: &mut Vec<SafetyFindingV1>,
772) {
773    match (
774        expected.wasm_gz_sha256.as_ref(),
775        observed.payload_sha256.as_ref(),
776    ) {
777        (Some(want), Some(got)) if want != got => {
778            artifact_diff.push(diff_item(
779                "artifact_sha256",
780                &expected.role,
781                Some(want.clone()),
782                Some(got.clone()),
783                SafetySeverityV1::HardFailure,
784            ));
785            hard_failures.push(finding(
786                "artifact_digest_mismatch",
787                format!("artifact digest mismatch for role {}", expected.role),
788                SafetySeverityV1::HardFailure,
789                Some(expected.role.clone()),
790            ));
791        }
792        (Some(want), None) => warnings.push(finding(
793            "artifact_digest_unobserved",
794            format!(
795                "expected artifact digest {want} for role {} was not observed",
796                expected.role
797            ),
798            SafetySeverityV1::Warning,
799            Some(expected.role.clone()),
800        )),
801        _ => {}
802    }
803}
804
805fn compare_observed_canister_id_conflicts(
806    inventory: &DeploymentInventoryV1,
807    controller_diff: &mut Vec<DiffItemV1>,
808    hard_failures: &mut Vec<SafetyFindingV1>,
809    warnings: &mut Vec<SafetyFindingV1>,
810) {
811    for group in duplicate_evidence_groups(
812        &inventory.observed_canisters,
813        |observed| observed.canister_id.as_str().to_string(),
814        observed_role_label,
815        ",",
816    ) {
817        if group.is_conflict {
818            controller_diff.push(diff_item(
819                "canister_id_role_conflict",
820                &group.subject,
821                None,
822                Some(group.evidence_label.clone()),
823                SafetySeverityV1::HardFailure,
824            ));
825            hard_failures.push(finding(
826                "canister_id_role_conflict",
827                format!(
828                    "observed canister {} has conflicting roles {}",
829                    group.subject, group.evidence_label
830                ),
831                SafetySeverityV1::HardFailure,
832                Some(group.subject),
833            ));
834        } else {
835            controller_diff.push(diff_item(
836                "canister_duplicate",
837                &group.subject,
838                Some(group.evidence_label.clone()),
839                Some(group.count.to_string()),
840                SafetySeverityV1::Warning,
841            ));
842            warnings.push(finding(
843                "duplicate_canister_observed",
844                format!(
845                    "observed canister {} was reported {} times for role {}",
846                    group.subject, group.count, group.evidence_label
847                ),
848                SafetySeverityV1::Warning,
849                Some(group.subject),
850            ));
851        }
852    }
853}
854
855fn observed_role_label(observed: &ObservedCanisterV1) -> String {
856    observed
857        .role
858        .clone()
859        .unwrap_or_else(|| "<unknown>".to_string())
860}
861
862fn compare_canisters(
863    plan: &DeploymentPlanV1,
864    inventory: &DeploymentInventoryV1,
865    controller_diff: &mut Vec<DiffItemV1>,
866    hard_failures: &mut Vec<SafetyFindingV1>,
867    warnings: &mut Vec<SafetyFindingV1>,
868) {
869    let planned_conflicts =
870        compare_planned_canister_conflicts(plan, controller_diff, hard_failures, warnings);
871    let mut matched_observed = BTreeSet::new();
872    let mut compared_planned = BTreeSet::new();
873    for expected in &plan.expected_canisters {
874        if planned_conflicts.role_conflicts.contains(&expected.role)
875            || expected
876                .canister_id
877                .as_ref()
878                .is_some_and(|id| planned_conflicts.id_conflicts.contains(id))
879            || !compared_planned.insert(planned_canister_evidence_label(expected))
880        {
881            continue;
882        }
883        let observed = expected.canister_id.as_ref().map_or_else(
884            || {
885                let role_matches = inventory
886                    .observed_canisters
887                    .iter()
888                    .filter(|canister| canister.role.as_deref() == Some(expected.role.as_str()))
889                    .collect::<Vec<_>>();
890                if role_matches.len() > 1 {
891                    record_ambiguous_canister_role(
892                        expected,
893                        &role_matches,
894                        controller_diff,
895                        hard_failures,
896                    );
897                    None
898                } else {
899                    role_matches.into_iter().next()
900                }
901            },
902            |id| {
903                inventory
904                    .observed_canisters
905                    .iter()
906                    .find(|canister| &canister.canister_id == id)
907            },
908        );
909        let Some(observed) = observed else {
910            record_missing_canister(expected, controller_diff, hard_failures, warnings);
911            continue;
912        };
913        matched_observed.insert(observed.canister_id.as_str());
914        compare_observed_role(expected, observed, controller_diff, hard_failures);
915        record_unsafe_canister_control_class(expected, observed, controller_diff, hard_failures);
916        compare_role_controllers(plan, observed, controller_diff, hard_failures, warnings);
917    }
918    warn_extra_observed_canisters(
919        plan,
920        inventory,
921        controller_diff,
922        warnings,
923        &matched_observed,
924    );
925}
926
927struct PlannedCanisterConflicts {
928    role_conflicts: BTreeSet<String>,
929    id_conflicts: BTreeSet<String>,
930}
931
932fn compare_planned_canister_conflicts(
933    plan: &DeploymentPlanV1,
934    controller_diff: &mut Vec<DiffItemV1>,
935    hard_failures: &mut Vec<SafetyFindingV1>,
936    warnings: &mut Vec<SafetyFindingV1>,
937) -> PlannedCanisterConflicts {
938    let mut role_conflicts = BTreeSet::new();
939    let mut id_conflicts = BTreeSet::new();
940
941    for group in duplicate_evidence_groups(
942        &plan.expected_canisters,
943        |planned| planned.role.as_str().to_string(),
944        planned_canister_evidence_label,
945        " | ",
946    ) {
947        if group.is_conflict {
948            role_conflicts.insert(group.subject.clone());
949            controller_diff.push(diff_item(
950                "planned_canister_role_conflict",
951                &group.subject,
952                Some("one planned canister".to_string()),
953                Some(group.evidence_label.clone()),
954                SafetySeverityV1::HardFailure,
955            ));
956            hard_failures.push(finding(
957                "planned_canister_role_conflict",
958                format!(
959                    "planned canister role {} has conflicting evidence: {}",
960                    group.subject, group.evidence_label
961                ),
962                SafetySeverityV1::HardFailure,
963                Some(group.subject),
964            ));
965        } else {
966            controller_diff.push(diff_item(
967                "planned_canister_duplicate",
968                &group.subject,
969                Some(group.evidence_label.clone()),
970                Some(group.count.to_string()),
971                SafetySeverityV1::Warning,
972            ));
973            warnings.push(finding(
974                "duplicate_planned_canister_role",
975                format!(
976                    "planned canister role {} was declared {} times with identical evidence",
977                    group.subject, group.count
978                ),
979                SafetySeverityV1::Warning,
980                Some(group.subject),
981            ));
982        }
983    }
984
985    for group in conflicting_assignment_groups(
986        &plan.expected_canisters,
987        |planned| planned.canister_id.clone(),
988        |planned| planned.role.clone(),
989        ",",
990    ) {
991        id_conflicts.insert(group.subject.clone());
992        controller_diff.push(diff_item(
993            "planned_canister_id_conflict",
994            &group.subject,
995            Some("one planned role".to_string()),
996            Some(group.evidence_label.clone()),
997            SafetySeverityV1::HardFailure,
998        ));
999        hard_failures.push(finding(
1000            "planned_canister_id_conflict",
1001            format!(
1002                "planned canister id {} is assigned to conflicting roles {}",
1003                group.subject, group.evidence_label
1004            ),
1005            SafetySeverityV1::HardFailure,
1006            Some(group.subject),
1007        ));
1008    }
1009
1010    PlannedCanisterConflicts {
1011        role_conflicts,
1012        id_conflicts,
1013    }
1014}
1015
1016fn planned_canister_evidence_label(planned: &ExpectedCanisterV1) -> String {
1017    format!(
1018        "role={};id={};control={:?}",
1019        planned.role,
1020        planned.canister_id.as_deref().unwrap_or("<none>"),
1021        planned.control_class
1022    )
1023}
1024
1025fn record_missing_canister(
1026    expected: &ExpectedCanisterV1,
1027    controller_diff: &mut Vec<DiffItemV1>,
1028    hard_failures: &mut Vec<SafetyFindingV1>,
1029    warnings: &mut Vec<SafetyFindingV1>,
1030) {
1031    let severity = if expected.canister_id.is_some() {
1032        SafetySeverityV1::HardFailure
1033    } else {
1034        SafetySeverityV1::Warning
1035    };
1036    controller_diff.push(diff_item(
1037        "canister",
1038        &expected.role,
1039        expected.canister_id.clone(),
1040        None,
1041        severity,
1042    ));
1043    let finding = finding(
1044        if expected.canister_id.is_some() {
1045            "canister_missing"
1046        } else {
1047            "canister_unobserved"
1048        },
1049        format!("missing observed canister for role {}", expected.role),
1050        severity,
1051        Some(expected.role.clone()),
1052    );
1053    if expected.canister_id.is_some() {
1054        hard_failures.push(finding);
1055    } else {
1056        warnings.push(finding);
1057    }
1058}
1059
1060fn record_unsafe_canister_control_class(
1061    expected: &ExpectedCanisterV1,
1062    observed: &ObservedCanisterV1,
1063    controller_diff: &mut Vec<DiffItemV1>,
1064    hard_failures: &mut Vec<SafetyFindingV1>,
1065) {
1066    if !matches!(
1067        observed.control_class,
1068        CanisterControlClassV1::UnknownUnsafe | CanisterControlClassV1::UserControlled
1069    ) || expected.control_class != CanisterControlClassV1::DeploymentControlled
1070    {
1071        return;
1072    }
1073    controller_diff.push(diff_item(
1074        "control_class",
1075        &expected.role,
1076        Some("DeploymentControlled".to_string()),
1077        Some(format!("{:?}", observed.control_class)),
1078        SafetySeverityV1::HardFailure,
1079    ));
1080    hard_failures.push(finding(
1081        "unsafe_control_class",
1082        format!("role {} has unsafe observed control class", expected.role),
1083        SafetySeverityV1::HardFailure,
1084        Some(expected.role.clone()),
1085    ));
1086}
1087
1088fn record_ambiguous_canister_role(
1089    expected: &ExpectedCanisterV1,
1090    observed_matches: &[&ObservedCanisterV1],
1091    controller_diff: &mut Vec<DiffItemV1>,
1092    hard_failures: &mut Vec<SafetyFindingV1>,
1093) {
1094    let observed_ids = observed_matches
1095        .iter()
1096        .map(|canister| canister.canister_id.as_str())
1097        .collect::<Vec<_>>()
1098        .join(",");
1099    controller_diff.push(diff_item(
1100        "canister_role_ambiguous",
1101        &expected.role,
1102        Some("one observed canister".to_string()),
1103        Some(observed_ids.clone()),
1104        SafetySeverityV1::HardFailure,
1105    ));
1106    hard_failures.push(finding(
1107        "canister_role_ambiguous",
1108        format!(
1109            "expected role {} has multiple observed canisters: {observed_ids}",
1110            expected.role
1111        ),
1112        SafetySeverityV1::HardFailure,
1113        Some(expected.role.clone()),
1114    ));
1115}
1116
1117fn compare_observed_role(
1118    expected: &ExpectedCanisterV1,
1119    observed: &ObservedCanisterV1,
1120    controller_diff: &mut Vec<DiffItemV1>,
1121    hard_failures: &mut Vec<SafetyFindingV1>,
1122) {
1123    let Some(observed_role) = observed.role.as_deref() else {
1124        return;
1125    };
1126    if observed_role == expected.role {
1127        return;
1128    }
1129    controller_diff.push(diff_item(
1130        "role_mismatch",
1131        &expected.role,
1132        Some(expected.role.clone()),
1133        Some(observed_role.to_string()),
1134        SafetySeverityV1::HardFailure,
1135    ));
1136    hard_failures.push(finding(
1137        "canister_role_mismatch",
1138        format!(
1139            "expected canister {} to have role {}, observed role {observed_role}",
1140            observed.canister_id, expected.role
1141        ),
1142        SafetySeverityV1::HardFailure,
1143        Some(expected.role.clone()),
1144    ));
1145}
1146
1147fn warn_extra_observed_canisters(
1148    plan: &DeploymentPlanV1,
1149    inventory: &DeploymentInventoryV1,
1150    controller_diff: &mut Vec<DiffItemV1>,
1151    warnings: &mut Vec<SafetyFindingV1>,
1152    matched_observed: &BTreeSet<&str>,
1153) {
1154    let expected_pool_roles = plan
1155        .expected_pool
1156        .iter()
1157        .filter_map(|pool| pool.role.as_deref())
1158        .collect::<BTreeSet<_>>();
1159
1160    for observed in &inventory.observed_canisters {
1161        if matched_observed.contains(observed.canister_id.as_str()) {
1162            continue;
1163        }
1164        if let Some(role) = observed.role.as_deref()
1165            && expected_pool_roles.contains(role)
1166        {
1167            continue;
1168        }
1169        let subject = observed_canister_subject(observed);
1170        controller_diff.push(diff_item(
1171            "canister_extra",
1172            &subject,
1173            None,
1174            Some(observed.canister_id.clone()),
1175            SafetySeverityV1::Warning,
1176        ));
1177        warnings.push(finding(
1178            "extra_canister_observed",
1179            format!("observed undeclared canister {subject}"),
1180            SafetySeverityV1::Warning,
1181            Some(subject),
1182        ));
1183    }
1184}
1185
1186fn observed_canister_subject(observed: &ObservedCanisterV1) -> String {
1187    observed
1188        .role
1189        .clone()
1190        .unwrap_or_else(|| observed.canister_id.clone())
1191}
1192
1193fn compare_observed_canister_pool_role_conflicts(
1194    inventory: &DeploymentInventoryV1,
1195    pool_diff: &mut Vec<DiffItemV1>,
1196    hard_failures: &mut Vec<SafetyFindingV1>,
1197) {
1198    let mut pools_by_id = BTreeMap::<&str, Vec<&ObservedPoolCanisterV1>>::new();
1199    for observed_pool in &inventory.observed_pool {
1200        pools_by_id
1201            .entry(observed_pool.canister_id.as_str())
1202            .or_default()
1203            .push(observed_pool);
1204    }
1205
1206    for observed_canister in &inventory.observed_canisters {
1207        let Some(canister_role) = observed_canister.role.as_deref() else {
1208            continue;
1209        };
1210        let Some(observed_pools) = pools_by_id.get(observed_canister.canister_id.as_str()) else {
1211            continue;
1212        };
1213        for observed_pool in observed_pools {
1214            let Some(pool_role) = observed_pool.role.as_deref() else {
1215                continue;
1216            };
1217            if pool_role == canister_role {
1218                continue;
1219            }
1220            let observed_label = format!(
1221                "canister={};pool={}",
1222                observed_canister_subject(observed_canister),
1223                observed_pool_subject(observed_pool)
1224            );
1225            pool_diff.push(diff_item(
1226                "canister_pool_role_conflict",
1227                &observed_canister.canister_id,
1228                None,
1229                Some(observed_label.clone()),
1230                SafetySeverityV1::HardFailure,
1231            ));
1232            hard_failures.push(finding(
1233                "canister_pool_role_conflict",
1234                format!(
1235                    "observed canister {} has conflicting canister/pool roles {observed_label}",
1236                    observed_canister.canister_id
1237                ),
1238                SafetySeverityV1::HardFailure,
1239                Some(observed_canister.canister_id.clone()),
1240            ));
1241        }
1242    }
1243}
1244
1245fn compare_pools(
1246    plan: &DeploymentPlanV1,
1247    inventory: &DeploymentInventoryV1,
1248    pool_diff: &mut Vec<DiffItemV1>,
1249    hard_failures: &mut Vec<SafetyFindingV1>,
1250    warnings: &mut Vec<SafetyFindingV1>,
1251) {
1252    let planned_conflicts =
1253        compare_planned_pool_conflicts(plan, pool_diff, hard_failures, warnings);
1254    compare_observed_pool_id_conflicts(inventory, pool_diff, hard_failures, warnings);
1255    let mut matched_observed = BTreeSet::new();
1256    let mut compared_planned = BTreeSet::new();
1257    for expected in &plan.expected_pool {
1258        if planned_conflicts
1259            .subject_conflicts
1260            .contains(&expected_pool_subject(expected))
1261            || expected
1262                .canister_id
1263                .as_ref()
1264                .is_some_and(|id| planned_conflicts.id_conflicts.contains(id))
1265            || !compared_planned.insert(planned_pool_evidence_label(expected))
1266        {
1267            continue;
1268        }
1269        compare_expected_pool(
1270            expected,
1271            inventory,
1272            pool_diff,
1273            hard_failures,
1274            warnings,
1275            &mut matched_observed,
1276        );
1277    }
1278
1279    for observed in &inventory.observed_pool {
1280        warn_extra_observed_pool(plan, observed, pool_diff, warnings, &matched_observed);
1281    }
1282}
1283
1284struct PlannedPoolConflicts {
1285    subject_conflicts: BTreeSet<String>,
1286    id_conflicts: BTreeSet<String>,
1287}
1288
1289fn compare_planned_pool_conflicts(
1290    plan: &DeploymentPlanV1,
1291    pool_diff: &mut Vec<DiffItemV1>,
1292    hard_failures: &mut Vec<SafetyFindingV1>,
1293    warnings: &mut Vec<SafetyFindingV1>,
1294) -> PlannedPoolConflicts {
1295    let mut subject_conflicts = BTreeSet::new();
1296    let mut id_conflicts = BTreeSet::new();
1297
1298    for group in duplicate_evidence_groups(
1299        &plan.expected_pool,
1300        expected_pool_subject,
1301        planned_pool_evidence_label,
1302        " | ",
1303    ) {
1304        if group.is_conflict {
1305            subject_conflicts.insert(group.subject.clone());
1306            pool_diff.push(diff_item(
1307                "planned_pool_conflict",
1308                &group.subject,
1309                Some("one planned pool canister".to_string()),
1310                Some(group.evidence_label.clone()),
1311                SafetySeverityV1::HardFailure,
1312            ));
1313            hard_failures.push(finding(
1314                "planned_pool_conflict",
1315                format!(
1316                    "planned pool {} has conflicting evidence: {}",
1317                    group.subject, group.evidence_label
1318                ),
1319                SafetySeverityV1::HardFailure,
1320                Some(group.subject),
1321            ));
1322        } else {
1323            pool_diff.push(diff_item(
1324                "planned_pool_duplicate",
1325                &group.subject,
1326                Some(group.evidence_label.clone()),
1327                Some(group.count.to_string()),
1328                SafetySeverityV1::Warning,
1329            ));
1330            warnings.push(finding(
1331                "duplicate_planned_pool",
1332                format!(
1333                    "planned pool {} was declared {} times with identical evidence",
1334                    group.subject, group.count
1335                ),
1336                SafetySeverityV1::Warning,
1337                Some(group.subject),
1338            ));
1339        }
1340    }
1341
1342    for group in conflicting_assignment_groups(
1343        &plan.expected_pool,
1344        |planned| planned.canister_id.clone(),
1345        expected_pool_subject,
1346        ",",
1347    ) {
1348        id_conflicts.insert(group.subject.clone());
1349        pool_diff.push(diff_item(
1350            "planned_pool_id_conflict",
1351            &group.subject,
1352            Some("one planned pool identity".to_string()),
1353            Some(group.evidence_label.clone()),
1354            SafetySeverityV1::HardFailure,
1355        ));
1356        hard_failures.push(finding(
1357            "planned_pool_id_conflict",
1358            format!(
1359                "planned pool id {} is assigned to conflicting identities {}",
1360                group.subject, group.evidence_label
1361            ),
1362            SafetySeverityV1::HardFailure,
1363            Some(group.subject),
1364        ));
1365    }
1366
1367    PlannedPoolConflicts {
1368        subject_conflicts,
1369        id_conflicts,
1370    }
1371}
1372
1373fn planned_pool_evidence_label(planned: &ExpectedPoolCanisterV1) -> String {
1374    format!(
1375        "pool={};role={};id={}",
1376        planned.pool,
1377        planned.role.as_deref().unwrap_or("<none>"),
1378        planned.canister_id.as_deref().unwrap_or("<none>")
1379    )
1380}
1381
1382fn compare_observed_pool_id_conflicts(
1383    inventory: &DeploymentInventoryV1,
1384    pool_diff: &mut Vec<DiffItemV1>,
1385    hard_failures: &mut Vec<SafetyFindingV1>,
1386    warnings: &mut Vec<SafetyFindingV1>,
1387) {
1388    for group in duplicate_evidence_groups(
1389        &inventory.observed_pool,
1390        |observed| observed.canister_id.as_str().to_string(),
1391        observed_pool_subject,
1392        ",",
1393    ) {
1394        if group.is_conflict {
1395            pool_diff.push(diff_item(
1396                "pool_canister_id_conflict",
1397                &group.subject,
1398                None,
1399                Some(group.evidence_label.clone()),
1400                SafetySeverityV1::HardFailure,
1401            ));
1402            hard_failures.push(finding(
1403                "pool_canister_id_conflict",
1404                format!(
1405                    "observed pool canister {} has conflicting pool identities {}",
1406                    group.subject, group.evidence_label
1407                ),
1408                SafetySeverityV1::HardFailure,
1409                Some(group.subject),
1410            ));
1411        } else {
1412            pool_diff.push(diff_item(
1413                "pool_canister_duplicate",
1414                &group.subject,
1415                Some(group.evidence_label.clone()),
1416                Some(group.count.to_string()),
1417                SafetySeverityV1::Warning,
1418            ));
1419            warnings.push(finding(
1420                "duplicate_pool_canister_observed",
1421                format!(
1422                    "observed pool canister {} was reported {} times for {}",
1423                    group.subject, group.count, group.evidence_label
1424                ),
1425                SafetySeverityV1::Warning,
1426                Some(group.subject),
1427            ));
1428        }
1429    }
1430}
1431
1432fn compare_expected_pool<'a>(
1433    expected: &ExpectedPoolCanisterV1,
1434    inventory: &'a DeploymentInventoryV1,
1435    pool_diff: &mut Vec<DiffItemV1>,
1436    hard_failures: &mut Vec<SafetyFindingV1>,
1437    warnings: &mut Vec<SafetyFindingV1>,
1438    matched_observed: &mut BTreeSet<&'a str>,
1439) {
1440    let observed = expected
1441        .canister_id
1442        .as_ref()
1443        .and_then(|id| {
1444            inventory
1445                .observed_pool
1446                .iter()
1447                .find(|pool| &pool.canister_id == id)
1448        })
1449        .or_else(|| {
1450            inventory
1451                .observed_pool
1452                .iter()
1453                .find(|pool| pool_matches_expected_pool(pool, expected))
1454        });
1455    let Some(observed) = observed else {
1456        record_missing_pool(expected, pool_diff, hard_failures, warnings);
1457        return;
1458    };
1459
1460    matched_observed.insert(observed.canister_id.as_str());
1461    record_pool_id_mismatch(expected, observed, pool_diff, hard_failures);
1462    record_unsafe_pool_control_class(observed, pool_diff, hard_failures);
1463}
1464
1465fn record_missing_pool(
1466    expected: &ExpectedPoolCanisterV1,
1467    pool_diff: &mut Vec<DiffItemV1>,
1468    hard_failures: &mut Vec<SafetyFindingV1>,
1469    warnings: &mut Vec<SafetyFindingV1>,
1470) {
1471    let severity = if expected.canister_id.is_some() {
1472        SafetySeverityV1::HardFailure
1473    } else {
1474        SafetySeverityV1::Warning
1475    };
1476    let subject = expected_pool_subject(expected);
1477    pool_diff.push(diff_item(
1478        "pool_canister",
1479        &subject,
1480        expected.canister_id.clone(),
1481        None,
1482        severity,
1483    ));
1484    let finding = finding(
1485        if expected.canister_id.is_some() {
1486            "pool_canister_missing"
1487        } else {
1488            "pool_canister_unobserved"
1489        },
1490        format!("missing observed pool canister for {subject}"),
1491        severity,
1492        Some(subject),
1493    );
1494    if expected.canister_id.is_some() {
1495        hard_failures.push(finding);
1496    } else {
1497        warnings.push(finding);
1498    }
1499}
1500
1501fn record_pool_id_mismatch(
1502    expected: &ExpectedPoolCanisterV1,
1503    observed: &ObservedPoolCanisterV1,
1504    pool_diff: &mut Vec<DiffItemV1>,
1505    hard_failures: &mut Vec<SafetyFindingV1>,
1506) {
1507    if let Some(expected_id) = expected.canister_id.as_ref()
1508        && observed.canister_id != *expected_id
1509    {
1510        let subject = observed_pool_subject(observed);
1511        pool_diff.push(diff_item(
1512            "pool_canister_id",
1513            &subject,
1514            Some(expected_id.clone()),
1515            Some(observed.canister_id.clone()),
1516            SafetySeverityV1::HardFailure,
1517        ));
1518        hard_failures.push(finding(
1519            "pool_canister_id_mismatch",
1520            format!(
1521                "pool canister {subject} has observed id {}, expected {expected_id}",
1522                observed.canister_id
1523            ),
1524            SafetySeverityV1::HardFailure,
1525            Some(subject),
1526        ));
1527    }
1528}
1529
1530fn record_unsafe_pool_control_class(
1531    observed: &ObservedPoolCanisterV1,
1532    pool_diff: &mut Vec<DiffItemV1>,
1533    hard_failures: &mut Vec<SafetyFindingV1>,
1534) {
1535    if !matches!(
1536        observed.control_class,
1537        CanisterControlClassV1::UnknownUnsafe | CanisterControlClassV1::UserControlled
1538    ) {
1539        return;
1540    }
1541    let subject = observed_pool_subject(observed);
1542    pool_diff.push(diff_item(
1543        "pool_control_class",
1544        &subject,
1545        Some("CanicManagedPool".to_string()),
1546        Some(format!("{:?}", observed.control_class)),
1547        SafetySeverityV1::HardFailure,
1548    ));
1549    hard_failures.push(finding(
1550        "unsafe_pool_control_class",
1551        format!("pool canister {subject} has unsafe observed control class"),
1552        SafetySeverityV1::HardFailure,
1553        Some(subject),
1554    ));
1555}
1556
1557fn warn_extra_observed_pool(
1558    plan: &DeploymentPlanV1,
1559    observed: &ObservedPoolCanisterV1,
1560    pool_diff: &mut Vec<DiffItemV1>,
1561    warnings: &mut Vec<SafetyFindingV1>,
1562    matched_observed: &BTreeSet<&str>,
1563) {
1564    if matched_observed.contains(observed.canister_id.as_str())
1565        || plan.expected_pool.iter().any(|expected| {
1566            expected.canister_id.as_ref() == Some(&observed.canister_id)
1567                || pool_matches_expected_pool(observed, expected)
1568        })
1569    {
1570        return;
1571    }
1572    let subject = observed_pool_subject(observed);
1573    pool_diff.push(diff_item(
1574        "pool_extra",
1575        &subject,
1576        None,
1577        Some(observed.canister_id.clone()),
1578        SafetySeverityV1::Warning,
1579    ));
1580    warnings.push(finding(
1581        "extra_pool_canister_observed",
1582        format!("observed undeclared pool canister {subject}"),
1583        SafetySeverityV1::Warning,
1584        Some(subject),
1585    ));
1586}
1587
1588fn pool_matches_expected_pool(
1589    observed: &ObservedPoolCanisterV1,
1590    expected: &ExpectedPoolCanisterV1,
1591) -> bool {
1592    observed.pool == expected.pool
1593        && expected
1594            .role
1595            .as_ref()
1596            .is_none_or(|role| observed.role.as_ref() == Some(role))
1597}
1598
1599fn expected_pool_subject(expected: &ExpectedPoolCanisterV1) -> String {
1600    expected.role.as_ref().map_or_else(
1601        || expected.pool.clone(),
1602        |role| format!("{}:{role}", expected.pool),
1603    )
1604}
1605
1606fn observed_pool_subject(observed: &ObservedPoolCanisterV1) -> String {
1607    observed.role.as_ref().map_or_else(
1608        || observed.pool.clone(),
1609        |role| format!("{}:{role}", observed.pool),
1610    )
1611}
1612
1613fn compare_role_controllers(
1614    plan: &DeploymentPlanV1,
1615    observed: &ObservedCanisterV1,
1616    controller_diff: &mut Vec<DiffItemV1>,
1617    hard_failures: &mut Vec<SafetyFindingV1>,
1618    warnings: &mut Vec<SafetyFindingV1>,
1619) {
1620    let role = observed.role.as_deref().unwrap_or("unknown");
1621    if observed.controllers.is_empty() && !observed_source_includes_live_status(observed) {
1622        warnings.push(finding(
1623            "controllers_unobserved",
1624            format!("controllers were not observed for role {role}"),
1625            SafetySeverityV1::Warning,
1626            Some(role.to_string()),
1627        ));
1628        return;
1629    }
1630    for expected in &plan.authority_profile.expected_controllers {
1631        if observed
1632            .controllers
1633            .iter()
1634            .any(|controller| controller == expected)
1635        {
1636            continue;
1637        }
1638        record_missing_expected_controller(
1639            role,
1640            expected,
1641            &observed.controllers,
1642            controller_diff,
1643            hard_failures,
1644        );
1645    }
1646
1647    for observed_controller in &observed.controllers {
1648        if is_declared_controller(plan, observed_controller) {
1649            continue;
1650        }
1651        record_extra_controller(role, observed_controller, plan, controller_diff, warnings);
1652    }
1653}
1654
1655fn record_missing_expected_controller(
1656    role: &str,
1657    expected: &str,
1658    observed_controllers: &[String],
1659    controller_diff: &mut Vec<DiffItemV1>,
1660    hard_failures: &mut Vec<SafetyFindingV1>,
1661) {
1662    controller_diff.push(diff_item(
1663        "controller_missing",
1664        role,
1665        Some(expected.to_string()),
1666        Some(controller_set_label(observed_controllers)),
1667        SafetySeverityV1::HardFailure,
1668    ));
1669    hard_failures.push(finding(
1670        "expected_controller_missing",
1671        format!("role {role} is missing expected controller {expected}"),
1672        SafetySeverityV1::HardFailure,
1673        Some(role.to_string()),
1674    ));
1675}
1676
1677fn record_extra_controller(
1678    role: &str,
1679    observed_controller: &str,
1680    plan: &DeploymentPlanV1,
1681    controller_diff: &mut Vec<DiffItemV1>,
1682    warnings: &mut Vec<SafetyFindingV1>,
1683) {
1684    controller_diff.push(diff_item(
1685        "controller_extra",
1686        role,
1687        Some(controller_set_label(
1688            &plan.authority_profile.expected_controllers,
1689        )),
1690        Some(observed_controller.to_string()),
1691        SafetySeverityV1::Warning,
1692    ));
1693    warnings.push(finding(
1694        "extra_controller_observed",
1695        format!("role {role} has controller outside the expected authority profile"),
1696        SafetySeverityV1::Warning,
1697        Some(role.to_string()),
1698    ));
1699}
1700
1701fn observed_source_includes_live_status(observed: &ObservedCanisterV1) -> bool {
1702    observed
1703        .role_assignment_source
1704        .as_deref()
1705        .is_some_and(|source| source.contains("icp_canister_status"))
1706}
1707
1708fn is_declared_controller(plan: &DeploymentPlanV1, controller: &str) -> bool {
1709    plan.authority_profile
1710        .expected_controllers
1711        .iter()
1712        .chain(plan.authority_profile.staging_controllers.iter())
1713        .chain(plan.authority_profile.emergency_controllers.iter())
1714        .any(|expected| expected == controller)
1715}
1716
1717fn is_staging_or_emergency_controller(plan: &DeploymentPlanV1, controller: &str) -> bool {
1718    plan.authority_profile
1719        .staging_controllers
1720        .iter()
1721        .chain(plan.authority_profile.emergency_controllers.iter())
1722        .any(|declared| declared == controller)
1723}
1724
1725fn controller_set_label(controllers: &[String]) -> String {
1726    if controllers.is_empty() {
1727        return "<none>".to_string();
1728    }
1729    controllers.join(",")
1730}
1731
1732fn compare_module_hashes(
1733    plan: &DeploymentPlanV1,
1734    inventory: &DeploymentInventoryV1,
1735    module_hash_diff: &mut Vec<DiffItemV1>,
1736    hard_failures: &mut Vec<SafetyFindingV1>,
1737    warnings: &mut Vec<SafetyFindingV1>,
1738) {
1739    for artifact in &plan.role_artifacts {
1740        let Some(expected) = artifact.installed_module_hash.as_ref() else {
1741            continue;
1742        };
1743        let Some(observed_canister) = observed_canister_for_module_hash(
1744            plan,
1745            inventory,
1746            &artifact.role,
1747            module_hash_diff,
1748            hard_failures,
1749        ) else {
1750            continue;
1751        };
1752        match observed_canister.module_hash.as_ref() {
1753            Some(observed) if observed != expected => record_module_hash_mismatch(
1754                &artifact.role,
1755                expected,
1756                observed,
1757                module_hash_diff,
1758                hard_failures,
1759            ),
1760            None => record_module_hash_unobserved(&artifact.role, warnings),
1761            _ => {}
1762        }
1763    }
1764}
1765
1766fn observed_canister_for_module_hash<'a>(
1767    plan: &DeploymentPlanV1,
1768    inventory: &'a DeploymentInventoryV1,
1769    role: &str,
1770    module_hash_diff: &mut Vec<DiffItemV1>,
1771    hard_failures: &mut Vec<SafetyFindingV1>,
1772) -> Option<&'a ObservedCanisterV1> {
1773    if let Some(expected_id) = expected_canister_id_for_role(plan, role) {
1774        return inventory
1775            .observed_canisters
1776            .iter()
1777            .find(|canister| canister.canister_id == expected_id);
1778    }
1779
1780    let role_matches = inventory
1781        .observed_canisters
1782        .iter()
1783        .filter(|canister| canister.role.as_deref() == Some(role))
1784        .collect::<Vec<_>>();
1785    if role_matches.len() > 1 {
1786        record_ambiguous_module_hash_role(role, &role_matches, module_hash_diff, hard_failures);
1787        return None;
1788    }
1789
1790    role_matches.into_iter().next()
1791}
1792
1793fn record_module_hash_mismatch(
1794    role: &str,
1795    expected: &str,
1796    observed: &str,
1797    module_hash_diff: &mut Vec<DiffItemV1>,
1798    hard_failures: &mut Vec<SafetyFindingV1>,
1799) {
1800    module_hash_diff.push(diff_item(
1801        "installed_module_hash",
1802        role,
1803        Some(expected.to_string()),
1804        Some(observed.to_string()),
1805        SafetySeverityV1::HardFailure,
1806    ));
1807    hard_failures.push(finding(
1808        "installed_module_hash_mismatch",
1809        format!("installed module hash differs for role {role}"),
1810        SafetySeverityV1::HardFailure,
1811        Some(role.to_string()),
1812    ));
1813}
1814
1815fn record_module_hash_unobserved(role: &str, warnings: &mut Vec<SafetyFindingV1>) {
1816    warnings.push(finding(
1817        "installed_module_hash_unobserved",
1818        format!("installed module hash was not observed for role {role}"),
1819        SafetySeverityV1::Warning,
1820        Some(role.to_string()),
1821    ));
1822}
1823
1824fn record_ambiguous_module_hash_role(
1825    role: &str,
1826    role_matches: &[&ObservedCanisterV1],
1827    module_hash_diff: &mut Vec<DiffItemV1>,
1828    hard_failures: &mut Vec<SafetyFindingV1>,
1829) {
1830    let observed_ids = role_matches
1831        .iter()
1832        .map(|canister| canister.canister_id.as_str())
1833        .collect::<Vec<_>>()
1834        .join(",");
1835    module_hash_diff.push(diff_item(
1836        "installed_module_hash_ambiguous",
1837        role,
1838        Some("one observed canister".to_string()),
1839        Some(observed_ids.clone()),
1840        SafetySeverityV1::HardFailure,
1841    ));
1842    hard_failures.push(finding(
1843        "installed_module_hash_ambiguous",
1844        format!(
1845            "installed module hash for role {role} has multiple observed canisters: {observed_ids}"
1846        ),
1847        SafetySeverityV1::HardFailure,
1848        Some(role.to_string()),
1849    ));
1850}
1851
1852fn expected_canister_id_for_role<'a>(plan: &'a DeploymentPlanV1, role: &str) -> Option<&'a str> {
1853    plan.expected_canisters
1854        .iter()
1855        .find(|canister| canister.role == role)
1856        .and_then(|canister| canister.canister_id.as_deref())
1857}
1858
1859fn compare_raw_config(
1860    plan: &DeploymentPlanV1,
1861    inventory: &DeploymentInventoryV1,
1862    embedded_config_diff: &mut Vec<DiffItemV1>,
1863    hard_failures: &mut Vec<SafetyFindingV1>,
1864) {
1865    let mut expected = plan
1866        .role_artifacts
1867        .iter()
1868        .filter_map(|artifact| artifact.raw_config_sha256.as_ref())
1869        .collect::<Vec<_>>();
1870    expected.sort_unstable();
1871    expected.dedup();
1872    let [expected] = expected.as_slice() else {
1873        if expected.len() > 1 {
1874            hard_failures.push(finding(
1875                "raw_config_plan_inconsistent",
1876                "planned role artifacts disagree on raw config digest",
1877                SafetySeverityV1::HardFailure,
1878                Some("role_artifacts.raw_config_sha256".to_string()),
1879            ));
1880        }
1881        return;
1882    };
1883
1884    if let Some(observed) = &inventory.local_config.raw_config_sha256
1885        && observed != *expected
1886    {
1887        record_raw_config_mismatch(expected, observed, embedded_config_diff, hard_failures);
1888    }
1889}
1890
1891fn record_raw_config_mismatch(
1892    expected: &str,
1893    observed: &str,
1894    embedded_config_diff: &mut Vec<DiffItemV1>,
1895    hard_failures: &mut Vec<SafetyFindingV1>,
1896) {
1897    embedded_config_diff.push(diff_item(
1898        "raw_config_sha256",
1899        "deployment",
1900        Some(expected.to_string()),
1901        Some(observed.to_string()),
1902        SafetySeverityV1::HardFailure,
1903    ));
1904    hard_failures.push(finding(
1905        "raw_config_digest_mismatch",
1906        "raw local config digest changed during deployment truth check",
1907        SafetySeverityV1::HardFailure,
1908        Some("local_config.raw_sha256".to_string()),
1909    ));
1910}
1911
1912fn compare_embedded_config(
1913    plan: &DeploymentPlanV1,
1914    inventory: &DeploymentInventoryV1,
1915    embedded_config_diff: &mut Vec<DiffItemV1>,
1916    hard_failures: &mut Vec<SafetyFindingV1>,
1917    warnings: &mut Vec<SafetyFindingV1>,
1918) {
1919    let Some(expected) = &plan.deployment_identity.canonical_runtime_config_digest else {
1920        return;
1921    };
1922    match &inventory.local_config.canonical_embedded_config_sha256 {
1923        Some(observed) if observed != expected => {
1924            record_canonical_config_mismatch(
1925                expected,
1926                observed,
1927                embedded_config_diff,
1928                hard_failures,
1929            );
1930        }
1931        None => record_canonical_config_unobserved(warnings),
1932        _ => {}
1933    }
1934}
1935
1936fn record_canonical_config_mismatch(
1937    expected: &str,
1938    observed: &str,
1939    embedded_config_diff: &mut Vec<DiffItemV1>,
1940    hard_failures: &mut Vec<SafetyFindingV1>,
1941) {
1942    embedded_config_diff.push(diff_item(
1943        "canonical_config",
1944        "deployment",
1945        Some(expected.to_string()),
1946        Some(observed.to_string()),
1947        SafetySeverityV1::HardFailure,
1948    ));
1949    hard_failures.push(finding(
1950        "canonical_config_mismatch",
1951        "canonical runtime config digest differs from the plan",
1952        SafetySeverityV1::HardFailure,
1953        Some("local_config".to_string()),
1954    ));
1955}
1956
1957fn record_canonical_config_unobserved(warnings: &mut Vec<SafetyFindingV1>) {
1958    warnings.push(finding(
1959        "canonical_config_unobserved",
1960        "canonical runtime config digest was not observed",
1961        SafetySeverityV1::Warning,
1962        Some("local_config".to_string()),
1963    ));
1964}
1965
1966fn compare_verifier_readiness(
1967    plan: &DeploymentPlanV1,
1968    inventory: &DeploymentInventoryV1,
1969    verifier_readiness_diff: &mut Vec<DiffItemV1>,
1970    hard_failures: &mut Vec<SafetyFindingV1>,
1971    warnings: &mut Vec<SafetyFindingV1>,
1972) {
1973    if !plan.expected_verifier_readiness.required {
1974        return;
1975    }
1976    if inventory.observed_verifier_readiness.status == ObservationStatusV1::NotObserved {
1977        verifier_readiness_diff.push(diff_item(
1978            "verifier_readiness",
1979            "deployment",
1980            Some("required".to_string()),
1981            Some("not_observed".to_string()),
1982            SafetySeverityV1::Warning,
1983        ));
1984        warnings.push(finding(
1985            "verifier_readiness_unobserved",
1986            "verifier readiness was required but not observed",
1987            SafetySeverityV1::Warning,
1988            Some("verifier_readiness".to_string()),
1989        ));
1990    }
1991
1992    let planned_conflicting_roles = compare_planned_verifier_epoch_conflicts(
1993        plan,
1994        verifier_readiness_diff,
1995        hard_failures,
1996        warnings,
1997    );
1998    let conflicting_roles = compare_observed_verifier_epoch_conflicts(
1999        inventory,
2000        verifier_readiness_diff,
2001        hard_failures,
2002        warnings,
2003    );
2004    let mut observed_by_role = BTreeMap::new();
2005    for epoch in &inventory.observed_verifier_readiness.role_epochs {
2006        if conflicting_roles.contains(&epoch.role) {
2007            continue;
2008        }
2009        observed_by_role.entry(epoch.role.as_str()).or_insert(epoch);
2010    }
2011    let mut compared_roles = BTreeSet::new();
2012    for expected in &plan.expected_verifier_readiness.expected_role_epochs {
2013        if planned_conflicting_roles.contains(&expected.role)
2014            || conflicting_roles.contains(&expected.role)
2015            || !compared_roles.insert(expected.role.as_str())
2016        {
2017            continue;
2018        }
2019        let observed = observed_by_role.get(expected.role.as_str());
2020        if let Some(observed_epoch) = observed.and_then(|observed| {
2021            (observed.status == ObservationStatusV1::Observed)
2022                .then_some(observed.observed_epoch)
2023                .flatten()
2024        }) {
2025            if observed_epoch < expected.minimum_epoch {
2026                record_stale_verifier_role_epoch(
2027                    expected,
2028                    observed_epoch,
2029                    verifier_readiness_diff,
2030                    hard_failures,
2031                );
2032            }
2033        } else {
2034            record_unobserved_verifier_role_epoch(expected, verifier_readiness_diff, warnings);
2035        }
2036    }
2037}
2038
2039fn record_stale_verifier_role_epoch(
2040    expected: &RoleEpochExpectationV1,
2041    observed_epoch: u64,
2042    verifier_readiness_diff: &mut Vec<DiffItemV1>,
2043    hard_failures: &mut Vec<SafetyFindingV1>,
2044) {
2045    verifier_readiness_diff.push(diff_item(
2046        "verifier_role_epoch",
2047        &expected.role,
2048        Some(expected.minimum_epoch.to_string()),
2049        Some(observed_epoch.to_string()),
2050        SafetySeverityV1::HardFailure,
2051    ));
2052    hard_failures.push(finding(
2053        "verifier_role_epoch_stale",
2054        format!(
2055            "verifier role {} has epoch {observed_epoch}, expected at least {}",
2056            expected.role, expected.minimum_epoch
2057        ),
2058        SafetySeverityV1::HardFailure,
2059        Some(expected.role.clone()),
2060    ));
2061}
2062
2063fn record_unobserved_verifier_role_epoch(
2064    expected: &RoleEpochExpectationV1,
2065    verifier_readiness_diff: &mut Vec<DiffItemV1>,
2066    warnings: &mut Vec<SafetyFindingV1>,
2067) {
2068    verifier_readiness_diff.push(diff_item(
2069        "verifier_role_epoch",
2070        &expected.role,
2071        Some(expected.minimum_epoch.to_string()),
2072        Some("not_observed".to_string()),
2073        SafetySeverityV1::Warning,
2074    ));
2075    warnings.push(finding(
2076        "verifier_role_epoch_unobserved",
2077        format!("verifier role {} epoch was not observed", expected.role),
2078        SafetySeverityV1::Warning,
2079        Some(expected.role.clone()),
2080    ));
2081}
2082
2083fn compare_planned_verifier_epoch_conflicts(
2084    plan: &DeploymentPlanV1,
2085    verifier_readiness_diff: &mut Vec<DiffItemV1>,
2086    hard_failures: &mut Vec<SafetyFindingV1>,
2087    warnings: &mut Vec<SafetyFindingV1>,
2088) -> BTreeSet<String> {
2089    let mut conflicting_roles = BTreeSet::new();
2090    for group in duplicate_evidence_groups(
2091        &plan.expected_verifier_readiness.expected_role_epochs,
2092        |expected| expected.role.as_str().to_string(),
2093        |expected| expected.minimum_epoch.to_string(),
2094        ",",
2095    ) {
2096        if group.is_conflict {
2097            conflicting_roles.insert(group.subject.clone());
2098            verifier_readiness_diff.push(diff_item(
2099                "planned_verifier_role_epoch_conflict",
2100                &group.subject,
2101                Some("one minimum epoch".to_string()),
2102                Some(group.evidence_label.clone()),
2103                SafetySeverityV1::HardFailure,
2104            ));
2105            hard_failures.push(finding(
2106                "planned_verifier_role_epoch_conflict",
2107                format!(
2108                    "planned verifier role {} has conflicting minimum epochs: {}",
2109                    group.subject, group.evidence_label
2110                ),
2111                SafetySeverityV1::HardFailure,
2112                Some(group.subject),
2113            ));
2114        } else {
2115            verifier_readiness_diff.push(diff_item(
2116                "planned_verifier_role_epoch_duplicate",
2117                &group.subject,
2118                Some(group.evidence_label.clone()),
2119                Some(group.count.to_string()),
2120                SafetySeverityV1::Warning,
2121            ));
2122            warnings.push(finding(
2123                "duplicate_planned_verifier_role_epoch",
2124                format!(
2125                    "planned verifier role {} epoch was declared {} times with identical evidence",
2126                    group.subject, group.count
2127                ),
2128                SafetySeverityV1::Warning,
2129                Some(group.subject),
2130            ));
2131        }
2132    }
2133    conflicting_roles
2134}
2135
2136fn compare_observed_verifier_epoch_conflicts(
2137    inventory: &DeploymentInventoryV1,
2138    verifier_readiness_diff: &mut Vec<DiffItemV1>,
2139    hard_failures: &mut Vec<SafetyFindingV1>,
2140    warnings: &mut Vec<SafetyFindingV1>,
2141) -> BTreeSet<String> {
2142    let mut conflicting_roles = BTreeSet::new();
2143    for group in duplicate_evidence_groups(
2144        &inventory.observed_verifier_readiness.role_epochs,
2145        |observed| observed.role.as_str().to_string(),
2146        verifier_epoch_evidence_label,
2147        ",",
2148    ) {
2149        if group.is_conflict {
2150            conflicting_roles.insert(group.subject.clone());
2151            verifier_readiness_diff.push(diff_item(
2152                "verifier_role_epoch_conflict",
2153                &group.subject,
2154                Some("one epoch observation".to_string()),
2155                Some(group.evidence_label.clone()),
2156                SafetySeverityV1::HardFailure,
2157            ));
2158            hard_failures.push(finding(
2159                "verifier_role_epoch_conflict",
2160                format!(
2161                    "verifier role {} has conflicting epoch observations: {}",
2162                    group.subject, group.evidence_label
2163                ),
2164                SafetySeverityV1::HardFailure,
2165                Some(group.subject),
2166            ));
2167        } else {
2168            verifier_readiness_diff.push(diff_item(
2169                "verifier_role_epoch_duplicate",
2170                &group.subject,
2171                Some(group.evidence_label.clone()),
2172                Some(group.count.to_string()),
2173                SafetySeverityV1::Warning,
2174            ));
2175            warnings.push(finding(
2176                "duplicate_verifier_role_epoch_observed",
2177                format!(
2178                    "verifier role {} epoch was reported {} times with identical evidence",
2179                    group.subject, group.count
2180                ),
2181                SafetySeverityV1::Warning,
2182                Some(group.subject),
2183            ));
2184        }
2185    }
2186    conflicting_roles
2187}
2188
2189fn verifier_epoch_evidence_label(observed: &RoleEpochObservationV1) -> String {
2190    format!(
2191        "epoch={};status={:?}",
2192        observed
2193            .observed_epoch
2194            .map_or_else(|| "<none>".to_string(), |epoch| epoch.to_string()),
2195        observed.status
2196    )
2197}
2198
2199fn finding(
2200    code: impl Into<String>,
2201    message: impl Into<String>,
2202    severity: SafetySeverityV1,
2203    subject: Option<String>,
2204) -> SafetyFindingV1 {
2205    SafetyFindingV1 {
2206        code: code.into(),
2207        message: message.into(),
2208        severity,
2209        subject,
2210    }
2211}
2212
2213fn diff_item(
2214    category: impl Into<String>,
2215    subject: impl Into<String>,
2216    expected: Option<String>,
2217    observed: Option<String>,
2218    severity: SafetySeverityV1,
2219) -> DiffItemV1 {
2220    DiffItemV1 {
2221        category: category.into(),
2222        subject: subject.into(),
2223        expected,
2224        observed,
2225        severity,
2226    }
2227}
2228
2229fn duplicate_evidence_groups<T>(
2230    items: &[T],
2231    subject: impl Fn(&T) -> String,
2232    evidence: impl Fn(&T) -> String,
2233    evidence_separator: &str,
2234) -> Vec<DuplicateEvidenceGroup> {
2235    let mut groups = Vec::new();
2236    for (subject, entries) in group_by_subject(items, |item| Some(subject(item))) {
2237        if entries.len() <= 1 {
2238            continue;
2239        }
2240        let evidence_values = entries
2241            .iter()
2242            .map(|entry| evidence(entry))
2243            .collect::<BTreeSet<_>>();
2244        groups.push(DuplicateEvidenceGroup {
2245            subject,
2246            count: entries.len(),
2247            evidence_label: evidence_values
2248                .iter()
2249                .cloned()
2250                .collect::<Vec<_>>()
2251                .join(evidence_separator),
2252            is_conflict: evidence_values.len() > 1,
2253        });
2254    }
2255    groups
2256}
2257
2258fn conflicting_assignment_groups<T>(
2259    items: &[T],
2260    subject: impl Fn(&T) -> Option<String>,
2261    value: impl Fn(&T) -> String,
2262    value_separator: &str,
2263) -> Vec<DuplicateEvidenceGroup> {
2264    let mut groups = Vec::new();
2265    for (subject, entries) in group_by_subject(items, subject) {
2266        if entries.len() <= 1 {
2267            continue;
2268        }
2269        let values = entries
2270            .iter()
2271            .map(|entry| value(entry))
2272            .collect::<BTreeSet<_>>();
2273        if values.len() <= 1 {
2274            continue;
2275        }
2276        groups.push(DuplicateEvidenceGroup {
2277            subject,
2278            count: entries.len(),
2279            evidence_label: values
2280                .iter()
2281                .cloned()
2282                .collect::<Vec<_>>()
2283                .join(value_separator),
2284            is_conflict: true,
2285        });
2286    }
2287    groups
2288}
2289
2290fn group_by_subject<T>(
2291    items: &[T],
2292    subject: impl Fn(&T) -> Option<String>,
2293) -> BTreeMap<String, Vec<&T>> {
2294    let mut by_subject = BTreeMap::<String, Vec<&T>>::new();
2295    for item in items {
2296        if let Some(subject) = subject(item) {
2297            by_subject.entry(subject).or_default().push(item);
2298        }
2299    }
2300    by_subject
2301}
2302
2303const fn safety_status(
2304    hard_failures: &[SafetyFindingV1],
2305    warnings: &[SafetyFindingV1],
2306) -> SafetyStatusV1 {
2307    if !hard_failures.is_empty() {
2308        SafetyStatusV1::Blocked
2309    } else if !warnings.is_empty() {
2310        SafetyStatusV1::Warning
2311    } else {
2312        SafetyStatusV1::Safe
2313    }
2314}
2315
2316fn resume_safety_reasons(
2317    hard_failures: &[SafetyFindingV1],
2318    warnings: &[SafetyFindingV1],
2319) -> Vec<String> {
2320    if !hard_failures.is_empty() {
2321        return hard_failures
2322            .iter()
2323            .map(|finding| finding.message.clone())
2324            .collect();
2325    }
2326    if !warnings.is_empty() {
2327        return warnings
2328            .iter()
2329            .map(|finding| finding.message.clone())
2330            .collect();
2331    }
2332    vec!["no blocking deployment truth differences were found".to_string()]
2333}
2334
2335fn safety_summary(
2336    status: SafetyStatusV1,
2337    hard_failure_count: usize,
2338    warning_count: usize,
2339) -> String {
2340    match status {
2341        SafetyStatusV1::Safe => "deployment inventory matches the checked plan".to_string(),
2342        SafetyStatusV1::Warning => {
2343            format!("deployment inventory has {warning_count} warning(s)")
2344        }
2345        SafetyStatusV1::Blocked => {
2346            format!(
2347                "deployment inventory has {hard_failure_count} blocking issue(s) and {warning_count} warning(s)"
2348            )
2349        }
2350        SafetyStatusV1::NotEvaluated => "deployment safety has not been evaluated".to_string(),
2351    }
2352}
2353
2354fn safety_next_actions(status: SafetyStatusV1) -> Vec<String> {
2355    match status {
2356        SafetyStatusV1::Safe => Vec::new(),
2357        SafetyStatusV1::Warning => {
2358            vec!["review deployment warnings before continuing".to_string()]
2359        }
2360        SafetyStatusV1::Blocked => {
2361            vec!["resolve blocking deployment truth differences before mutation".to_string()]
2362        }
2363        SafetyStatusV1::NotEvaluated => vec!["collect deployment inventory".to_string()],
2364    }
2365}