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