1use super::*;
2use std::collections::{BTreeMap, BTreeSet};
3
4#[derive(Clone, Debug, Eq, PartialEq)]
8pub struct LocalDeploymentCheckRequest {
9 pub deployment_name: String,
10 pub network: String,
11 pub workspace_root: std::path::PathBuf,
12 pub icp_root: std::path::PathBuf,
13 pub config_path: Option<std::path::PathBuf>,
14 pub observed_at: String,
15 pub runtime_variant: String,
16 pub build_profile: String,
17}
18
19pub fn check_local_deployment(
21 request: &LocalDeploymentCheckRequest,
22) -> Result<DeploymentCheckV1, DeploymentTruthError> {
23 let plan = build_local_deployment_plan(&LocalDeploymentPlanRequest {
24 deployment_name: request.deployment_name.clone(),
25 network: request.network.clone(),
26 workspace_root: request.workspace_root.clone(),
27 icp_root: request.icp_root.clone(),
28 config_path: request.config_path.clone(),
29 runtime_variant: request.runtime_variant.clone(),
30 build_profile: request.build_profile.clone(),
31 });
32 let inventory = collect_local_deployment_inventory(&LocalInventoryRequest {
33 deployment_name: request.deployment_name.clone(),
34 network: request.network.clone(),
35 workspace_root: request.workspace_root.clone(),
36 icp_root: request.icp_root.clone(),
37 config_path: request.config_path.clone(),
38 observed_at: request.observed_at.clone(),
39 })?;
40 let diff = compare_plan_to_inventory(&plan, &inventory);
41 let report = safety_report_from_diff(
42 format!(
43 "local:{}:{}:report",
44 request.network, request.deployment_name
45 ),
46 Some(format!(
47 "local:{}:{}:diff",
48 request.network, request.deployment_name
49 )),
50 &diff,
51 );
52
53 Ok(DeploymentCheckV1 {
54 schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
55 check_id: format!(
56 "local:{}:{}:check",
57 request.network, request.deployment_name
58 ),
59 plan,
60 inventory,
61 diff,
62 report,
63 })
64}
65
66#[must_use]
68pub fn compare_plan_to_inventory(
69 plan: &DeploymentPlanV1,
70 inventory: &DeploymentInventoryV1,
71) -> DeploymentDiffV1 {
72 let mut artifact_diff = Vec::new();
73 let mut controller_diff = Vec::new();
74 let mut pool_diff = Vec::new();
75 let mut embedded_config_diff = Vec::new();
76 let mut module_hash_diff = Vec::new();
77 let mut verifier_readiness_diff = Vec::new();
78 let mut hard_failures = Vec::new();
79 let mut warnings = Vec::new();
80
81 compare_identity(plan, inventory, &mut hard_failures);
82 compare_authority_profile(plan, &mut controller_diff, &mut hard_failures);
83 compare_artifacts(
84 plan,
85 inventory,
86 &mut artifact_diff,
87 &mut hard_failures,
88 &mut warnings,
89 );
90 compare_canisters(
91 plan,
92 inventory,
93 &mut controller_diff,
94 &mut hard_failures,
95 &mut warnings,
96 );
97 compare_pools(
98 plan,
99 inventory,
100 &mut pool_diff,
101 &mut hard_failures,
102 &mut warnings,
103 );
104 compare_module_hashes(
105 plan,
106 inventory,
107 &mut module_hash_diff,
108 &mut hard_failures,
109 &mut warnings,
110 );
111 compare_raw_config(
112 plan,
113 inventory,
114 &mut embedded_config_diff,
115 &mut hard_failures,
116 );
117 compare_embedded_config(
118 plan,
119 inventory,
120 &mut embedded_config_diff,
121 &mut hard_failures,
122 &mut warnings,
123 );
124 compare_verifier_readiness(
125 plan,
126 inventory,
127 &mut verifier_readiness_diff,
128 &mut hard_failures,
129 &mut warnings,
130 );
131 for assumption in &plan.unresolved_assumptions {
132 warnings.push(SafetyFindingV1 {
133 code: "plan_assumption".to_string(),
134 message: assumption.description.clone(),
135 severity: SafetySeverityV1::Warning,
136 subject: Some(assumption.key.clone()),
137 });
138 }
139 for gap in &inventory.unresolved_observations {
140 warnings.push(SafetyFindingV1 {
141 code: "observation_gap".to_string(),
142 message: gap.description.clone(),
143 severity: SafetySeverityV1::Warning,
144 subject: Some(gap.key.clone()),
145 });
146 }
147
148 let status = safety_status(&hard_failures, &warnings);
149 DeploymentDiffV1 {
150 schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
151 plan_identity: plan.deployment_identity.clone(),
152 observed_identity: inventory.observed_identity.clone(),
153 artifact_diff,
154 controller_diff,
155 pool_diff,
156 embedded_config_diff,
157 module_hash_diff,
158 verifier_readiness_diff,
159 resume_safety: ResumeSafetyV1 {
160 status,
161 reasons: resume_safety_reasons(&hard_failures, &warnings),
162 },
163 hard_failures,
164 warnings,
165 resumable_phases: Vec::new(),
166 }
167}
168
169#[must_use]
172pub fn compare_plan_inventory_and_receipt(
173 plan: &DeploymentPlanV1,
174 inventory: &DeploymentInventoryV1,
175 receipt: &DeploymentReceiptV1,
176) -> DeploymentDiffV1 {
177 let mut diff = compare_plan_to_inventory(plan, inventory);
178 apply_receipt_resume_safety(plan, receipt, &mut diff);
179 diff
180}
181
182#[must_use]
184pub fn safety_report_from_diff(
185 report_id: impl Into<String>,
186 diff_id: Option<String>,
187 diff: &DeploymentDiffV1,
188) -> SafetyReportV1 {
189 let status = safety_status(&diff.hard_failures, &diff.warnings);
190 SafetyReportV1 {
191 schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
192 report_id: report_id.into(),
193 diff_id,
194 status,
195 summary: safety_summary(status, diff.hard_failures.len(), diff.warnings.len()),
196 hard_failures: diff.hard_failures.clone(),
197 warnings: diff.warnings.clone(),
198 next_actions: safety_next_actions(status),
199 }
200}
201
202fn apply_receipt_resume_safety(
203 plan: &DeploymentPlanV1,
204 receipt: &DeploymentReceiptV1,
205 diff: &mut DeploymentDiffV1,
206) {
207 validate_receipt_identity(plan, receipt, &mut diff.hard_failures);
208 validate_receipt_command_result(receipt, &mut diff.hard_failures);
209 if !diff.hard_failures.is_empty() {
210 diff.resume_safety.status = safety_status(&diff.hard_failures, &diff.warnings);
211 diff.resume_safety.reasons = resume_safety_reasons(&diff.hard_failures, &diff.warnings);
212 return;
213 }
214 let phase_failures = receipt_phase_failures(receipt);
215 for receipt in &receipt.phase_receipts {
216 if receipt.verified_postcondition.status != ObservationStatusV1::Observed {
217 diff.hard_failures.push(finding(
218 "receipt_postcondition_unverified",
219 format!(
220 "receipt phase {} has no observed postcondition",
221 receipt.phase
222 ),
223 SafetySeverityV1::HardFailure,
224 Some(receipt.phase.clone()),
225 ));
226 continue;
227 }
228 if phase_failures.contains(receipt.phase.as_str()) {
229 continue;
230 }
231 diff.resumable_phases.push(receipt.phase.clone());
232 }
233 diff.resumable_phases.sort();
234 diff.resumable_phases.dedup();
235 diff.resume_safety.status = safety_status(&diff.hard_failures, &diff.warnings);
236 diff.resume_safety.reasons = resume_safety_reasons(&diff.hard_failures, &diff.warnings);
237}
238
239fn validate_receipt_identity(
240 plan: &DeploymentPlanV1,
241 receipt: &DeploymentReceiptV1,
242 hard_failures: &mut Vec<SafetyFindingV1>,
243) {
244 if receipt.plan_id != plan.plan_id {
245 hard_failures.push(finding(
246 "receipt_plan_mismatch",
247 format!(
248 "receipt plan {} does not match current plan {}",
249 receipt.plan_id, plan.plan_id
250 ),
251 SafetySeverityV1::HardFailure,
252 Some("receipt.plan_id".to_string()),
253 ));
254 }
255 if let (Some(expected), Some(observed)) = (
256 plan.deployment_identity.root_principal.as_ref(),
257 receipt.root_principal.as_ref(),
258 ) && expected != observed
259 {
260 hard_failures.push(finding(
261 "receipt_root_mismatch",
262 format!("receipt root {observed} does not match current plan root {expected}"),
263 SafetySeverityV1::HardFailure,
264 Some("receipt.root_principal".to_string()),
265 ));
266 }
267}
268
269fn validate_receipt_command_result(
270 receipt: &DeploymentReceiptV1,
271 hard_failures: &mut Vec<SafetyFindingV1>,
272) {
273 if let DeploymentCommandResultV1::Failed { code, message } = &receipt.command_result {
274 hard_failures.push(finding(
275 "receipt_failed_command",
276 format!("receipt command failed with {code}: {message}"),
277 SafetySeverityV1::HardFailure,
278 Some("receipt.command_result".to_string()),
279 ));
280 }
281}
282
283fn receipt_phase_failures(receipt: &DeploymentReceiptV1) -> BTreeSet<&str> {
284 let mut failures = BTreeSet::new();
285 for role_receipt in &receipt.role_phase_receipts {
286 if matches!(role_receipt.result, RolePhaseResultV1::Failed) {
287 failures.insert(role_receipt.phase.as_str());
288 }
289 }
290 failures
291}
292
293fn compare_identity(
294 plan: &DeploymentPlanV1,
295 inventory: &DeploymentInventoryV1,
296 hard_failures: &mut Vec<SafetyFindingV1>,
297) {
298 let Some(observed) = &inventory.observed_identity else {
299 hard_failures.push(finding(
300 "identity_unobserved",
301 "deployment identity was not observed",
302 SafetySeverityV1::HardFailure,
303 None,
304 ));
305 return;
306 };
307
308 if observed.network != plan.deployment_identity.network {
309 hard_failures.push(finding(
310 "network_mismatch",
311 format!(
312 "plan network {} differs from observed network {}",
313 plan.deployment_identity.network, observed.network
314 ),
315 SafetySeverityV1::HardFailure,
316 Some("deployment_identity.network".to_string()),
317 ));
318 }
319 if let (Some(expected), Some(actual)) = (
320 plan.deployment_identity.root_principal.as_ref(),
321 observed.root_principal.as_ref(),
322 ) && expected != actual
323 {
324 hard_failures.push(finding(
325 "root_trust_anchor_mismatch",
326 format!("plan root {expected} differs from observed root {actual}"),
327 SafetySeverityV1::HardFailure,
328 Some("deployment_identity.root_principal".to_string()),
329 ));
330 }
331 match (
332 plan.deployment_identity.deployment_manifest_digest.as_ref(),
333 observed.deployment_manifest_digest.as_ref(),
334 ) {
335 (Some(expected), Some(actual)) if expected != actual => {
336 hard_failures.push(finding(
337 "deployment_manifest_mismatch",
338 "deployment manifest digest differs from the observed local config",
339 SafetySeverityV1::HardFailure,
340 Some("deployment_identity.deployment_manifest_digest".to_string()),
341 ));
342 }
343 (Some(_), None) => {
344 hard_failures.push(finding(
345 "deployment_manifest_unobserved",
346 "deployment manifest digest was not observed",
347 SafetySeverityV1::HardFailure,
348 Some("deployment_identity.deployment_manifest_digest".to_string()),
349 ));
350 }
351 _ => {}
352 }
353}
354
355fn compare_authority_profile(
356 plan: &DeploymentPlanV1,
357 controller_diff: &mut Vec<DiffItemV1>,
358 hard_failures: &mut Vec<SafetyFindingV1>,
359) {
360 let mut reported = BTreeSet::new();
361 for controller in &plan.authority_profile.expected_controllers {
362 if !is_staging_or_emergency_controller(plan, controller) {
363 continue;
364 }
365 if !reported.insert(controller.as_str()) {
366 continue;
367 }
368 controller_diff.push(diff_item(
369 "controller_authority_overlap",
370 "authority_profile",
371 Some("expected-only".to_string()),
372 Some(controller.clone()),
373 SafetySeverityV1::HardFailure,
374 ));
375 hard_failures.push(finding(
376 "controller_authority_overlap",
377 format!(
378 "controller {controller} appears in both expected and staging/emergency authority"
379 ),
380 SafetySeverityV1::HardFailure,
381 Some("authority_profile".to_string()),
382 ));
383 }
384}
385
386fn compare_artifacts(
387 plan: &DeploymentPlanV1,
388 inventory: &DeploymentInventoryV1,
389 artifact_diff: &mut Vec<DiffItemV1>,
390 hard_failures: &mut Vec<SafetyFindingV1>,
391 warnings: &mut Vec<SafetyFindingV1>,
392) {
393 let observed_by_role = inventory
394 .observed_artifacts
395 .iter()
396 .map(|artifact| (artifact.role.as_str(), artifact))
397 .collect::<BTreeMap<_, _>>();
398
399 for expected in &plan.role_artifacts {
400 let Some(observed) = observed_by_role.get(expected.role.as_str()) else {
401 artifact_diff.push(diff_item(
402 "artifact",
403 &expected.role,
404 expected.wasm_gz_path.clone(),
405 None,
406 SafetySeverityV1::HardFailure,
407 ));
408 hard_failures.push(finding(
409 "artifact_missing",
410 format!("missing observed artifact for role {}", expected.role),
411 SafetySeverityV1::HardFailure,
412 Some(expected.role.clone()),
413 ));
414 continue;
415 };
416
417 compare_artifact_file_sha256(expected, observed, artifact_diff, hard_failures);
418
419 match (
420 expected.wasm_gz_sha256.as_ref(),
421 observed.payload_sha256.as_ref(),
422 ) {
423 (Some(want), Some(got)) if want != got => {
424 artifact_diff.push(diff_item(
425 "artifact_sha256",
426 &expected.role,
427 Some(want.clone()),
428 Some(got.clone()),
429 SafetySeverityV1::HardFailure,
430 ));
431 hard_failures.push(finding(
432 "artifact_digest_mismatch",
433 format!("artifact digest mismatch for role {}", expected.role),
434 SafetySeverityV1::HardFailure,
435 Some(expected.role.clone()),
436 ));
437 }
438 (Some(want), None) => warnings.push(finding(
439 "artifact_digest_unobserved",
440 format!(
441 "expected artifact digest {want} for role {} was not observed",
442 expected.role
443 ),
444 SafetySeverityV1::Warning,
445 Some(expected.role.clone()),
446 )),
447 _ => {}
448 }
449 }
450}
451
452fn compare_artifact_file_sha256(
453 expected: &RoleArtifactV1,
454 observed: &ObservedArtifactV1,
455 artifact_diff: &mut Vec<DiffItemV1>,
456 hard_failures: &mut Vec<SafetyFindingV1>,
457) {
458 match (
459 expected.observed_wasm_gz_file_sha256.as_ref(),
460 observed.file_sha256.as_ref(),
461 ) {
462 (Some(want), Some(got)) if want != got => {
463 artifact_diff.push(diff_item(
464 "artifact_file_sha256",
465 &expected.role,
466 Some(want.clone()),
467 Some(got.clone()),
468 SafetySeverityV1::HardFailure,
469 ));
470 hard_failures.push(finding(
471 "artifact_file_digest_mismatch",
472 format!(
473 "observed artifact file digest changed during deployment truth check for role {}",
474 expected.role
475 ),
476 SafetySeverityV1::HardFailure,
477 Some(expected.role.clone()),
478 ));
479 }
480 (_, Some(got)) => {
481 artifact_diff.push(diff_item(
482 "artifact_file_sha256",
483 &expected.role,
484 expected.observed_wasm_gz_file_sha256.clone(),
485 Some(got.clone()),
486 SafetySeverityV1::Info,
487 ));
488 }
489 _ => {}
490 }
491}
492
493fn compare_canisters(
494 plan: &DeploymentPlanV1,
495 inventory: &DeploymentInventoryV1,
496 controller_diff: &mut Vec<DiffItemV1>,
497 hard_failures: &mut Vec<SafetyFindingV1>,
498 warnings: &mut Vec<SafetyFindingV1>,
499) {
500 for expected in &plan.expected_canisters {
501 let observed = expected.canister_id.as_ref().map_or_else(
502 || {
503 inventory
504 .observed_canisters
505 .iter()
506 .find(|canister| canister.role.as_deref() == Some(expected.role.as_str()))
507 },
508 |id| {
509 inventory
510 .observed_canisters
511 .iter()
512 .find(|canister| &canister.canister_id == id)
513 },
514 );
515 let Some(observed) = observed else {
516 let severity = if expected.canister_id.is_some() {
517 SafetySeverityV1::HardFailure
518 } else {
519 SafetySeverityV1::Warning
520 };
521 controller_diff.push(diff_item(
522 "canister",
523 &expected.role,
524 expected.canister_id.clone(),
525 None,
526 severity,
527 ));
528 let finding = finding(
529 if expected.canister_id.is_some() {
530 "canister_missing"
531 } else {
532 "canister_unobserved"
533 },
534 format!("missing observed canister for role {}", expected.role),
535 severity,
536 Some(expected.role.clone()),
537 );
538 if expected.canister_id.is_some() {
539 hard_failures.push(finding);
540 } else {
541 warnings.push(finding);
542 }
543 continue;
544 };
545 if matches!(
546 observed.control_class,
547 CanisterControlClassV1::UnknownUnsafe | CanisterControlClassV1::UserControlled
548 ) && expected.control_class == CanisterControlClassV1::DeploymentControlled
549 {
550 controller_diff.push(diff_item(
551 "control_class",
552 &expected.role,
553 Some("DeploymentControlled".to_string()),
554 Some(format!("{:?}", observed.control_class)),
555 SafetySeverityV1::HardFailure,
556 ));
557 hard_failures.push(finding(
558 "unsafe_control_class",
559 format!("role {} has unsafe observed control class", expected.role),
560 SafetySeverityV1::HardFailure,
561 Some(expected.role.clone()),
562 ));
563 }
564 compare_role_controllers(plan, observed, controller_diff, hard_failures, warnings);
565 }
566}
567
568fn compare_pools(
569 plan: &DeploymentPlanV1,
570 inventory: &DeploymentInventoryV1,
571 pool_diff: &mut Vec<DiffItemV1>,
572 hard_failures: &mut Vec<SafetyFindingV1>,
573 warnings: &mut Vec<SafetyFindingV1>,
574) {
575 let mut matched_observed = BTreeSet::new();
576 for expected in &plan.expected_pool {
577 compare_expected_pool(
578 expected,
579 inventory,
580 pool_diff,
581 hard_failures,
582 warnings,
583 &mut matched_observed,
584 );
585 }
586
587 for observed in &inventory.observed_pool {
588 warn_extra_observed_pool(plan, observed, pool_diff, warnings, &matched_observed);
589 }
590}
591
592fn compare_expected_pool<'a>(
593 expected: &ExpectedPoolCanisterV1,
594 inventory: &'a DeploymentInventoryV1,
595 pool_diff: &mut Vec<DiffItemV1>,
596 hard_failures: &mut Vec<SafetyFindingV1>,
597 warnings: &mut Vec<SafetyFindingV1>,
598 matched_observed: &mut BTreeSet<&'a str>,
599) {
600 let observed = expected
601 .canister_id
602 .as_ref()
603 .and_then(|id| {
604 inventory
605 .observed_pool
606 .iter()
607 .find(|pool| &pool.canister_id == id)
608 })
609 .or_else(|| {
610 inventory
611 .observed_pool
612 .iter()
613 .find(|pool| pool_matches_expected_pool(pool, expected))
614 });
615 let Some(observed) = observed else {
616 record_missing_pool(expected, pool_diff, hard_failures, warnings);
617 return;
618 };
619
620 matched_observed.insert(observed.canister_id.as_str());
621 record_pool_id_mismatch(expected, observed, pool_diff, hard_failures);
622 record_unsafe_pool_control_class(observed, pool_diff, hard_failures);
623}
624
625fn record_missing_pool(
626 expected: &ExpectedPoolCanisterV1,
627 pool_diff: &mut Vec<DiffItemV1>,
628 hard_failures: &mut Vec<SafetyFindingV1>,
629 warnings: &mut Vec<SafetyFindingV1>,
630) {
631 let severity = if expected.canister_id.is_some() {
632 SafetySeverityV1::HardFailure
633 } else {
634 SafetySeverityV1::Warning
635 };
636 let subject = expected_pool_subject(expected);
637 pool_diff.push(diff_item(
638 "pool_canister",
639 &subject,
640 expected.canister_id.clone(),
641 None,
642 severity,
643 ));
644 let finding = finding(
645 if expected.canister_id.is_some() {
646 "pool_canister_missing"
647 } else {
648 "pool_canister_unobserved"
649 },
650 format!("missing observed pool canister for {subject}"),
651 severity,
652 Some(subject),
653 );
654 if expected.canister_id.is_some() {
655 hard_failures.push(finding);
656 } else {
657 warnings.push(finding);
658 }
659}
660
661fn record_pool_id_mismatch(
662 expected: &ExpectedPoolCanisterV1,
663 observed: &ObservedPoolCanisterV1,
664 pool_diff: &mut Vec<DiffItemV1>,
665 hard_failures: &mut Vec<SafetyFindingV1>,
666) {
667 if let Some(expected_id) = expected.canister_id.as_ref()
668 && observed.canister_id != *expected_id
669 {
670 let subject = observed_pool_subject(observed);
671 pool_diff.push(diff_item(
672 "pool_canister_id",
673 &subject,
674 Some(expected_id.clone()),
675 Some(observed.canister_id.clone()),
676 SafetySeverityV1::HardFailure,
677 ));
678 hard_failures.push(finding(
679 "pool_canister_id_mismatch",
680 format!(
681 "pool canister {subject} has observed id {}, expected {expected_id}",
682 observed.canister_id
683 ),
684 SafetySeverityV1::HardFailure,
685 Some(subject),
686 ));
687 }
688}
689
690fn record_unsafe_pool_control_class(
691 observed: &ObservedPoolCanisterV1,
692 pool_diff: &mut Vec<DiffItemV1>,
693 hard_failures: &mut Vec<SafetyFindingV1>,
694) {
695 if !matches!(
696 observed.control_class,
697 CanisterControlClassV1::UnknownUnsafe | CanisterControlClassV1::UserControlled
698 ) {
699 return;
700 }
701 let subject = observed_pool_subject(observed);
702 pool_diff.push(diff_item(
703 "pool_control_class",
704 &subject,
705 Some("CanicManagedPool".to_string()),
706 Some(format!("{:?}", observed.control_class)),
707 SafetySeverityV1::HardFailure,
708 ));
709 hard_failures.push(finding(
710 "unsafe_pool_control_class",
711 format!("pool canister {subject} has unsafe observed control class"),
712 SafetySeverityV1::HardFailure,
713 Some(subject),
714 ));
715}
716
717fn warn_extra_observed_pool(
718 plan: &DeploymentPlanV1,
719 observed: &ObservedPoolCanisterV1,
720 pool_diff: &mut Vec<DiffItemV1>,
721 warnings: &mut Vec<SafetyFindingV1>,
722 matched_observed: &BTreeSet<&str>,
723) {
724 if matched_observed.contains(observed.canister_id.as_str())
725 || plan.expected_pool.iter().any(|expected| {
726 expected.canister_id.as_ref() == Some(&observed.canister_id)
727 || pool_matches_expected_pool(observed, expected)
728 })
729 {
730 return;
731 }
732 let subject = observed_pool_subject(observed);
733 pool_diff.push(diff_item(
734 "pool_extra",
735 &subject,
736 None,
737 Some(observed.canister_id.clone()),
738 SafetySeverityV1::Warning,
739 ));
740 warnings.push(finding(
741 "extra_pool_canister_observed",
742 format!("observed undeclared pool canister {subject}"),
743 SafetySeverityV1::Warning,
744 Some(subject),
745 ));
746}
747
748fn pool_matches_expected_pool(
749 observed: &ObservedPoolCanisterV1,
750 expected: &ExpectedPoolCanisterV1,
751) -> bool {
752 observed.pool == expected.pool
753 && expected
754 .role
755 .as_ref()
756 .is_none_or(|role| observed.role.as_ref() == Some(role))
757}
758
759fn expected_pool_subject(expected: &ExpectedPoolCanisterV1) -> String {
760 expected.role.as_ref().map_or_else(
761 || expected.pool.clone(),
762 |role| format!("{}:{role}", expected.pool),
763 )
764}
765
766fn observed_pool_subject(observed: &ObservedPoolCanisterV1) -> String {
767 observed.role.as_ref().map_or_else(
768 || observed.pool.clone(),
769 |role| format!("{}:{role}", observed.pool),
770 )
771}
772
773fn compare_role_controllers(
774 plan: &DeploymentPlanV1,
775 observed: &ObservedCanisterV1,
776 controller_diff: &mut Vec<DiffItemV1>,
777 hard_failures: &mut Vec<SafetyFindingV1>,
778 warnings: &mut Vec<SafetyFindingV1>,
779) {
780 let role = observed.role.as_deref().unwrap_or("unknown");
781 if observed.controllers.is_empty()
782 && observed.role_assignment_source.as_deref() != Some("icp_canister_status")
783 {
784 warnings.push(finding(
785 "controllers_unobserved",
786 format!("controllers were not observed for role {role}"),
787 SafetySeverityV1::Warning,
788 Some(role.to_string()),
789 ));
790 return;
791 }
792 for expected in &plan.authority_profile.expected_controllers {
793 if observed
794 .controllers
795 .iter()
796 .any(|controller| controller == expected)
797 {
798 continue;
799 }
800 controller_diff.push(diff_item(
801 "controller_missing",
802 role,
803 Some(expected.clone()),
804 Some(controller_set_label(&observed.controllers)),
805 SafetySeverityV1::HardFailure,
806 ));
807 hard_failures.push(finding(
808 "expected_controller_missing",
809 format!("role {role} is missing expected controller {expected}"),
810 SafetySeverityV1::HardFailure,
811 Some(role.to_string()),
812 ));
813 }
814
815 for observed_controller in &observed.controllers {
816 if is_declared_controller(plan, observed_controller) {
817 continue;
818 }
819 controller_diff.push(diff_item(
820 "controller_extra",
821 role,
822 Some(controller_set_label(
823 &plan.authority_profile.expected_controllers,
824 )),
825 Some(observed_controller.clone()),
826 SafetySeverityV1::Warning,
827 ));
828 warnings.push(finding(
829 "extra_controller_observed",
830 format!("role {role} has controller outside the expected authority profile"),
831 SafetySeverityV1::Warning,
832 Some(role.to_string()),
833 ));
834 }
835}
836
837fn is_declared_controller(plan: &DeploymentPlanV1, controller: &str) -> bool {
838 plan.authority_profile
839 .expected_controllers
840 .iter()
841 .chain(plan.authority_profile.staging_controllers.iter())
842 .chain(plan.authority_profile.emergency_controllers.iter())
843 .any(|expected| expected == controller)
844}
845
846fn is_staging_or_emergency_controller(plan: &DeploymentPlanV1, controller: &str) -> bool {
847 plan.authority_profile
848 .staging_controllers
849 .iter()
850 .chain(plan.authority_profile.emergency_controllers.iter())
851 .any(|declared| declared == controller)
852}
853
854fn controller_set_label(controllers: &[String]) -> String {
855 if controllers.is_empty() {
856 return "<none>".to_string();
857 }
858 controllers.join(",")
859}
860
861fn compare_module_hashes(
862 plan: &DeploymentPlanV1,
863 inventory: &DeploymentInventoryV1,
864 module_hash_diff: &mut Vec<DiffItemV1>,
865 hard_failures: &mut Vec<SafetyFindingV1>,
866 warnings: &mut Vec<SafetyFindingV1>,
867) {
868 let observed_by_role = inventory
869 .observed_canisters
870 .iter()
871 .filter_map(|canister| canister.role.as_deref().map(|role| (role, canister)))
872 .collect::<BTreeMap<_, _>>();
873
874 for artifact in &plan.role_artifacts {
875 let Some(expected) = artifact.installed_module_hash.as_ref() else {
876 continue;
877 };
878 let Some(observed_canister) = observed_by_role.get(artifact.role.as_str()) else {
879 continue;
880 };
881 match observed_canister.module_hash.as_ref() {
882 Some(observed) if observed != expected => {
883 module_hash_diff.push(diff_item(
884 "installed_module_hash",
885 &artifact.role,
886 Some(expected.clone()),
887 Some(observed.clone()),
888 SafetySeverityV1::HardFailure,
889 ));
890 hard_failures.push(finding(
891 "installed_module_hash_mismatch",
892 format!("installed module hash differs for role {}", artifact.role),
893 SafetySeverityV1::HardFailure,
894 Some(artifact.role.clone()),
895 ));
896 }
897 None => warnings.push(finding(
898 "installed_module_hash_unobserved",
899 format!(
900 "installed module hash was not observed for role {}",
901 artifact.role
902 ),
903 SafetySeverityV1::Warning,
904 Some(artifact.role.clone()),
905 )),
906 _ => {}
907 }
908 }
909}
910
911fn compare_raw_config(
912 plan: &DeploymentPlanV1,
913 inventory: &DeploymentInventoryV1,
914 embedded_config_diff: &mut Vec<DiffItemV1>,
915 hard_failures: &mut Vec<SafetyFindingV1>,
916) {
917 let mut expected = plan
918 .role_artifacts
919 .iter()
920 .filter_map(|artifact| artifact.raw_config_sha256.as_ref())
921 .collect::<Vec<_>>();
922 expected.sort_unstable();
923 expected.dedup();
924 let [expected] = expected.as_slice() else {
925 if expected.len() > 1 {
926 hard_failures.push(finding(
927 "raw_config_plan_inconsistent",
928 "planned role artifacts disagree on raw config digest",
929 SafetySeverityV1::HardFailure,
930 Some("role_artifacts.raw_config_sha256".to_string()),
931 ));
932 }
933 return;
934 };
935
936 if let Some(observed) = &inventory.local_config.raw_config_sha256
937 && observed != *expected
938 {
939 embedded_config_diff.push(diff_item(
940 "raw_config_sha256",
941 "deployment",
942 Some((*expected).clone()),
943 Some(observed.clone()),
944 SafetySeverityV1::HardFailure,
945 ));
946 hard_failures.push(finding(
947 "raw_config_digest_mismatch",
948 "raw local config digest changed during deployment truth check",
949 SafetySeverityV1::HardFailure,
950 Some("local_config.raw_sha256".to_string()),
951 ));
952 }
953}
954
955fn compare_embedded_config(
956 plan: &DeploymentPlanV1,
957 inventory: &DeploymentInventoryV1,
958 embedded_config_diff: &mut Vec<DiffItemV1>,
959 hard_failures: &mut Vec<SafetyFindingV1>,
960 warnings: &mut Vec<SafetyFindingV1>,
961) {
962 let Some(expected) = &plan.deployment_identity.canonical_runtime_config_digest else {
963 return;
964 };
965 match &inventory.local_config.canonical_embedded_config_sha256 {
966 Some(observed) if observed != expected => {
967 embedded_config_diff.push(diff_item(
968 "canonical_config",
969 "deployment",
970 Some(expected.clone()),
971 Some(observed.clone()),
972 SafetySeverityV1::HardFailure,
973 ));
974 hard_failures.push(finding(
975 "canonical_config_mismatch",
976 "canonical runtime config digest differs from the plan",
977 SafetySeverityV1::HardFailure,
978 Some("local_config".to_string()),
979 ));
980 }
981 None => warnings.push(finding(
982 "canonical_config_unobserved",
983 "canonical runtime config digest was not observed",
984 SafetySeverityV1::Warning,
985 Some("local_config".to_string()),
986 )),
987 _ => {}
988 }
989}
990
991fn compare_verifier_readiness(
992 plan: &DeploymentPlanV1,
993 inventory: &DeploymentInventoryV1,
994 verifier_readiness_diff: &mut Vec<DiffItemV1>,
995 hard_failures: &mut Vec<SafetyFindingV1>,
996 warnings: &mut Vec<SafetyFindingV1>,
997) {
998 if !plan.expected_verifier_readiness.required {
999 return;
1000 }
1001 if inventory.observed_verifier_readiness.status == ObservationStatusV1::NotObserved {
1002 verifier_readiness_diff.push(diff_item(
1003 "verifier_readiness",
1004 "deployment",
1005 Some("required".to_string()),
1006 Some("not_observed".to_string()),
1007 SafetySeverityV1::Warning,
1008 ));
1009 warnings.push(finding(
1010 "verifier_readiness_unobserved",
1011 "verifier readiness was required but not observed",
1012 SafetySeverityV1::Warning,
1013 Some("verifier_readiness".to_string()),
1014 ));
1015 }
1016
1017 let observed_by_role = inventory
1018 .observed_verifier_readiness
1019 .role_epochs
1020 .iter()
1021 .map(|epoch| (epoch.role.as_str(), epoch))
1022 .collect::<BTreeMap<_, _>>();
1023 for expected in &plan.expected_verifier_readiness.expected_role_epochs {
1024 match observed_by_role.get(expected.role.as_str()) {
1025 Some(observed)
1026 if observed.status == ObservationStatusV1::Observed
1027 && observed.observed_epoch >= Some(expected.minimum_epoch) => {}
1028 Some(observed)
1029 if observed.status == ObservationStatusV1::Observed
1030 && observed.observed_epoch.is_some() =>
1031 {
1032 let observed_epoch = observed.observed_epoch.expect("checked above");
1033 verifier_readiness_diff.push(diff_item(
1034 "verifier_role_epoch",
1035 &expected.role,
1036 Some(expected.minimum_epoch.to_string()),
1037 Some(observed_epoch.to_string()),
1038 SafetySeverityV1::HardFailure,
1039 ));
1040 hard_failures.push(finding(
1041 "verifier_role_epoch_stale",
1042 format!(
1043 "verifier role {} has epoch {observed_epoch}, expected at least {}",
1044 expected.role, expected.minimum_epoch
1045 ),
1046 SafetySeverityV1::HardFailure,
1047 Some(expected.role.clone()),
1048 ));
1049 }
1050 _ => {
1051 verifier_readiness_diff.push(diff_item(
1052 "verifier_role_epoch",
1053 &expected.role,
1054 Some(expected.minimum_epoch.to_string()),
1055 Some("not_observed".to_string()),
1056 SafetySeverityV1::Warning,
1057 ));
1058 warnings.push(finding(
1059 "verifier_role_epoch_unobserved",
1060 format!("verifier role {} epoch was not observed", expected.role),
1061 SafetySeverityV1::Warning,
1062 Some(expected.role.clone()),
1063 ));
1064 }
1065 }
1066 }
1067}
1068
1069fn finding(
1070 code: impl Into<String>,
1071 message: impl Into<String>,
1072 severity: SafetySeverityV1,
1073 subject: Option<String>,
1074) -> SafetyFindingV1 {
1075 SafetyFindingV1 {
1076 code: code.into(),
1077 message: message.into(),
1078 severity,
1079 subject,
1080 }
1081}
1082
1083fn diff_item(
1084 category: impl Into<String>,
1085 subject: impl Into<String>,
1086 expected: Option<String>,
1087 observed: Option<String>,
1088 severity: SafetySeverityV1,
1089) -> DiffItemV1 {
1090 DiffItemV1 {
1091 category: category.into(),
1092 subject: subject.into(),
1093 expected,
1094 observed,
1095 severity,
1096 }
1097}
1098
1099const fn safety_status(
1100 hard_failures: &[SafetyFindingV1],
1101 warnings: &[SafetyFindingV1],
1102) -> SafetyStatusV1 {
1103 if !hard_failures.is_empty() {
1104 SafetyStatusV1::Blocked
1105 } else if !warnings.is_empty() {
1106 SafetyStatusV1::Warning
1107 } else {
1108 SafetyStatusV1::Safe
1109 }
1110}
1111
1112fn resume_safety_reasons(
1113 hard_failures: &[SafetyFindingV1],
1114 warnings: &[SafetyFindingV1],
1115) -> Vec<String> {
1116 if !hard_failures.is_empty() {
1117 return hard_failures
1118 .iter()
1119 .map(|finding| finding.message.clone())
1120 .collect();
1121 }
1122 if !warnings.is_empty() {
1123 return warnings
1124 .iter()
1125 .map(|finding| finding.message.clone())
1126 .collect();
1127 }
1128 vec!["no blocking deployment truth differences were found".to_string()]
1129}
1130
1131fn safety_summary(
1132 status: SafetyStatusV1,
1133 hard_failure_count: usize,
1134 warning_count: usize,
1135) -> String {
1136 match status {
1137 SafetyStatusV1::Safe => "deployment inventory matches the checked plan".to_string(),
1138 SafetyStatusV1::Warning => {
1139 format!("deployment inventory has {warning_count} warning(s)")
1140 }
1141 SafetyStatusV1::Blocked => {
1142 format!(
1143 "deployment inventory has {hard_failure_count} blocking issue(s) and {warning_count} warning(s)"
1144 )
1145 }
1146 SafetyStatusV1::NotEvaluated => "deployment safety has not been evaluated".to_string(),
1147 }
1148}
1149
1150fn safety_next_actions(status: SafetyStatusV1) -> Vec<String> {
1151 match status {
1152 SafetyStatusV1::Safe => Vec::new(),
1153 SafetyStatusV1::Warning => {
1154 vec!["review deployment warnings before continuing".to_string()]
1155 }
1156 SafetyStatusV1::Blocked => {
1157 vec!["resolve blocking deployment truth differences before mutation".to_string()]
1158 }
1159 SafetyStatusV1::NotEvaluated => vec!["collect deployment inventory".to_string()],
1160 }
1161}