Skip to main content

canic_host/deployment_truth/
report.rs

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