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