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
14struct DuplicateEvidenceGroup {
18 subject: String,
19 count: usize,
20 evidence_label: String,
21 is_conflict: bool,
22}
23
24#[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
39pub 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#[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
169struct 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#[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#[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#[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}