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