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