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