Skip to main content

canic_host/deployment_truth/
multi.rs

1use super::*;
2use serde::Serialize;
3use std::collections::{BTreeMap, BTreeSet};
4
5#[derive(Serialize)]
6struct DeploymentComparisonReportDigestInput<'a> {
7    report_id: &'a str,
8    compared_at: &'a str,
9    left: &'a DeploymentComparisonTargetV1,
10    right: &'a DeploymentComparisonTargetV1,
11    status: SafetyStatusV1,
12    identity_diff: &'a [DeploymentComparisonDiffV1],
13    artifact_diff: &'a [DeploymentComparisonDiffV1],
14    module_hash_diff: &'a [DeploymentComparisonDiffV1],
15    embedded_config_diff: &'a [DeploymentComparisonDiffV1],
16    authority_diff: &'a [DeploymentComparisonDiffV1],
17    pool_diff: &'a [DeploymentComparisonDiffV1],
18    verifier_readiness_diff: &'a [DeploymentComparisonDiffV1],
19    external_lifecycle_diff: &'a [DeploymentComparisonDiffV1],
20    hard_failures: &'a [SafetyFindingV1],
21    warnings: &'a [SafetyFindingV1],
22    next_actions: &'a [String],
23}
24
25///
26/// DeploymentComparisonReportError
27///
28#[derive(Debug, Eq, thiserror::Error, PartialEq)]
29pub enum DeploymentComparisonReportError {
30    #[error(
31        "deployment comparison report schema version {actual} does not match expected {expected}"
32    )]
33    SchemaVersionMismatch { expected: u32, actual: u32 },
34    #[error("deployment comparison report field `{field}` is required")]
35    MissingRequiredField { field: &'static str },
36    #[error("deployment comparison report field `{field}` digest is stale")]
37    DigestMismatch { field: &'static str },
38    #[error("deployment comparison report status does not match report findings")]
39    StatusMismatch,
40}
41
42/// Build a passive 0.46 cross-deployment comparison report from two existing
43/// deployment-truth checks. This is evidence comparison only; it does not
44/// query live inventory or mutate deployment state.
45#[must_use]
46pub fn deployment_comparison_report_from_checks(
47    report_id: impl Into<String>,
48    compared_at: impl Into<String>,
49    left_label: impl Into<String>,
50    right_label: impl Into<String>,
51    left: &DeploymentCheckV1,
52    right: &DeploymentCheckV1,
53) -> DeploymentComparisonReportV1 {
54    let left_label = left_label.into();
55    let right_label = right_label.into();
56    let mut identity_diff = Vec::new();
57    let mut artifact_diff = Vec::new();
58    let mut module_hash_diff = Vec::new();
59    let mut embedded_config_diff = Vec::new();
60    let mut authority_diff = Vec::new();
61    let mut pool_diff = Vec::new();
62    let mut verifier_readiness_diff = Vec::new();
63    let mut external_lifecycle_diff = Vec::new();
64
65    compare_identity(left, right, &mut identity_diff);
66    compare_artifact_evidence(left, right, &mut artifact_diff);
67    compare_observed_module_hashes(left, right, &mut module_hash_diff);
68    compare_embedded_config_evidence(left, right, &mut embedded_config_diff);
69    compare_authority_evidence(left, right, &mut authority_diff);
70    compare_pool_evidence(left, right, &mut pool_diff);
71    compare_verifier_readiness_evidence(left, right, &mut verifier_readiness_diff);
72    compare_external_lifecycle_evidence(left, right, &mut external_lifecycle_diff);
73
74    let mut hard_failures = Vec::new();
75    let mut warnings = Vec::new();
76    compare_input_check_consistency(&left_label, left, &mut hard_failures);
77    compare_input_check_consistency(&right_label, right, &mut hard_failures);
78    compare_input_check_status(&left_label, &left.report, &mut hard_failures, &mut warnings);
79    compare_input_check_status(
80        &right_label,
81        &right.report,
82        &mut hard_failures,
83        &mut warnings,
84    );
85    let diff_groups = [
86        identity_diff.as_slice(),
87        artifact_diff.as_slice(),
88        module_hash_diff.as_slice(),
89        embedded_config_diff.as_slice(),
90        authority_diff.as_slice(),
91        pool_diff.as_slice(),
92        verifier_readiness_diff.as_slice(),
93        external_lifecycle_diff.as_slice(),
94    ];
95    warnings.extend(comparison_warnings(&diff_groups));
96    let status = comparison_status(&hard_failures, &warnings);
97    let next_actions = comparison_next_actions(status);
98
99    let mut report = DeploymentComparisonReportV1 {
100        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
101        report_id: report_id.into(),
102        report_digest: String::new(),
103        compared_at: compared_at.into(),
104        left: comparison_target(left_label, left),
105        right: comparison_target(right_label, right),
106        status,
107        identity_diff,
108        artifact_diff,
109        module_hash_diff,
110        embedded_config_diff,
111        authority_diff,
112        pool_diff,
113        verifier_readiness_diff,
114        external_lifecycle_diff,
115        hard_failures,
116        warnings,
117        next_actions,
118    };
119    report.report_digest = deployment_comparison_report_digest(&report);
120    report
121}
122
123/// Validate archived 0.46 comparison report consistency and digest stability.
124pub fn validate_deployment_comparison_report(
125    report: &DeploymentComparisonReportV1,
126) -> Result<(), DeploymentComparisonReportError> {
127    if report.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
128        return Err(DeploymentComparisonReportError::SchemaVersionMismatch {
129            expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
130            actual: report.schema_version,
131        });
132    }
133    ensure_comparison_field("report_id", report.report_id.as_str())?;
134    ensure_comparison_field("report_digest", report.report_digest.as_str())?;
135    ensure_comparison_field("compared_at", report.compared_at.as_str())?;
136    validate_comparison_target("left", &report.left)?;
137    validate_comparison_target("right", &report.right)?;
138    if report.status != comparison_status(&report.hard_failures, &report.warnings) {
139        return Err(DeploymentComparisonReportError::StatusMismatch);
140    }
141    if report.report_digest != deployment_comparison_report_digest(report) {
142        return Err(DeploymentComparisonReportError::DigestMismatch {
143            field: "report_digest",
144        });
145    }
146    Ok(())
147}
148
149fn comparison_target(label: String, check: &DeploymentCheckV1) -> DeploymentComparisonTargetV1 {
150    DeploymentComparisonTargetV1 {
151        label,
152        check_id: check.check_id.clone(),
153        check_digest: stable_json_sha256_hex(check),
154        plan_id: check.plan.plan_id.clone(),
155        plan_digest: stable_json_sha256_hex(&check.plan),
156        inventory_id: check.inventory.inventory_id.clone(),
157        inventory_digest: stable_json_sha256_hex(&check.inventory),
158        deployment_identity: check.plan.deployment_identity.clone(),
159    }
160}
161
162fn compare_identity(
163    left: &DeploymentCheckV1,
164    right: &DeploymentCheckV1,
165    diffs: &mut Vec<DeploymentComparisonDiffV1>,
166) {
167    compare_identity_names(left, right, diffs);
168    compare_identity_digests(left, right, diffs);
169    compare_identity_plan_shape(left, right, diffs);
170    compare_identity_trust_domain(left, right, diffs);
171}
172
173fn compare_identity_names(
174    left: &DeploymentCheckV1,
175    right: &DeploymentCheckV1,
176    diffs: &mut Vec<DeploymentComparisonDiffV1>,
177) {
178    compare_value(
179        DeploymentComparisonCategoryV1::Identity,
180        "deployment_name",
181        Some(left.plan.deployment_identity.deployment_name.as_str()),
182        Some(right.plan.deployment_identity.deployment_name.as_str()),
183        "deployment names differ",
184        diffs,
185    );
186    compare_value(
187        DeploymentComparisonCategoryV1::Identity,
188        "network",
189        Some(left.plan.deployment_identity.network.as_str()),
190        Some(right.plan.deployment_identity.network.as_str()),
191        "deployment networks differ",
192        diffs,
193    );
194    compare_optional(
195        DeploymentComparisonCategoryV1::Identity,
196        "root_principal",
197        left.plan.deployment_identity.root_principal.as_deref(),
198        right.plan.deployment_identity.root_principal.as_deref(),
199        "root principals differ",
200        diffs,
201    );
202}
203
204fn compare_identity_digests(
205    left: &DeploymentCheckV1,
206    right: &DeploymentCheckV1,
207    diffs: &mut Vec<DeploymentComparisonDiffV1>,
208) {
209    compare_optional(
210        DeploymentComparisonCategoryV1::Identity,
211        "authority_profile_hash",
212        left.plan
213            .deployment_identity
214            .authority_profile_hash
215            .as_deref(),
216        right
217            .plan
218            .deployment_identity
219            .authority_profile_hash
220            .as_deref(),
221        "authority profile hashes differ",
222        diffs,
223    );
224    compare_optional(
225        DeploymentComparisonCategoryV1::Identity,
226        "artifact_set_digest",
227        left.plan.deployment_identity.artifact_set_digest.as_deref(),
228        right
229            .plan
230            .deployment_identity
231            .artifact_set_digest
232            .as_deref(),
233        "artifact set digests differ",
234        diffs,
235    );
236    compare_optional(
237        DeploymentComparisonCategoryV1::Identity,
238        "role_topology_hash",
239        left.plan.deployment_identity.role_topology_hash.as_deref(),
240        right.plan.deployment_identity.role_topology_hash.as_deref(),
241        "role topology hashes differ",
242        diffs,
243    );
244    compare_optional(
245        DeploymentComparisonCategoryV1::Identity,
246        "pool_identity_set_digest",
247        left.plan
248            .deployment_identity
249            .pool_identity_set_digest
250            .as_deref(),
251        right
252            .plan
253            .deployment_identity
254            .pool_identity_set_digest
255            .as_deref(),
256        "pool identity set digests differ",
257        diffs,
258    );
259    compare_optional(
260        DeploymentComparisonCategoryV1::Identity,
261        "canonical_runtime_config_digest",
262        left.plan
263            .deployment_identity
264            .canonical_runtime_config_digest
265            .as_deref(),
266        right
267            .plan
268            .deployment_identity
269            .canonical_runtime_config_digest
270            .as_deref(),
271        "canonical runtime config digests differ",
272        diffs,
273    );
274    compare_optional(
275        DeploymentComparisonCategoryV1::Identity,
276        "role_embedded_config_set_digest",
277        left.plan
278            .deployment_identity
279            .role_embedded_config_set_digest
280            .as_deref(),
281        right
282            .plan
283            .deployment_identity
284            .role_embedded_config_set_digest
285            .as_deref(),
286        "role embedded config set digests differ",
287        diffs,
288    );
289}
290
291fn compare_identity_plan_shape(
292    left: &DeploymentCheckV1,
293    right: &DeploymentCheckV1,
294    diffs: &mut Vec<DeploymentComparisonDiffV1>,
295) {
296    compare_value(
297        DeploymentComparisonCategoryV1::Identity,
298        "fleet_template",
299        Some(left.plan.fleet_template.as_str()),
300        Some(right.plan.fleet_template.as_str()),
301        "fleet templates differ",
302        diffs,
303    );
304    compare_value(
305        DeploymentComparisonCategoryV1::Identity,
306        "runtime_variant",
307        Some(left.plan.runtime_variant.as_str()),
308        Some(right.plan.runtime_variant.as_str()),
309        "runtime variants differ",
310        diffs,
311    );
312}
313
314fn compare_identity_trust_domain(
315    left: &DeploymentCheckV1,
316    right: &DeploymentCheckV1,
317    diffs: &mut Vec<DeploymentComparisonDiffV1>,
318) {
319    compare_optional(
320        DeploymentComparisonCategoryV1::TrustDomain,
321        "root_trust_anchor",
322        left.plan.trust_domain.root_trust_anchor.as_deref(),
323        right.plan.trust_domain.root_trust_anchor.as_deref(),
324        "root trust anchors differ",
325        diffs,
326    );
327    compare_optional(
328        DeploymentComparisonCategoryV1::TrustDomain,
329        "migration_from",
330        left.plan.trust_domain.migration_from.as_deref(),
331        right.plan.trust_domain.migration_from.as_deref(),
332        "migration sources differ",
333        diffs,
334    );
335}
336
337fn compare_artifact_evidence(
338    left: &DeploymentCheckV1,
339    right: &DeploymentCheckV1,
340    diffs: &mut Vec<DeploymentComparisonDiffV1>,
341) {
342    compare_maps(
343        DeploymentComparisonCategoryV1::Artifact,
344        &role_artifact_fingerprints(&left.plan.role_artifacts),
345        &role_artifact_fingerprints(&right.plan.role_artifacts),
346        "role artifact identity differs",
347        diffs,
348    );
349}
350
351fn compare_observed_module_hashes(
352    left: &DeploymentCheckV1,
353    right: &DeploymentCheckV1,
354    diffs: &mut Vec<DeploymentComparisonDiffV1>,
355) {
356    compare_maps(
357        DeploymentComparisonCategoryV1::ModuleHash,
358        &observed_canister_map(&left.inventory, |canister| {
359            canister
360                .module_hash
361                .clone()
362                .unwrap_or_else(|| "missing".into())
363        }),
364        &observed_canister_map(&right.inventory, |canister| {
365            canister
366                .module_hash
367                .clone()
368                .unwrap_or_else(|| "missing".into())
369        }),
370        "observed module hash differs",
371        diffs,
372    );
373}
374
375fn compare_embedded_config_evidence(
376    left: &DeploymentCheckV1,
377    right: &DeploymentCheckV1,
378    diffs: &mut Vec<DeploymentComparisonDiffV1>,
379) {
380    compare_maps(
381        DeploymentComparisonCategoryV1::EmbeddedConfig,
382        &observed_canister_map(&left.inventory, |canister| {
383            canister
384                .canonical_embedded_config_digest
385                .clone()
386                .unwrap_or_else(|| "missing".into())
387        }),
388        &observed_canister_map(&right.inventory, |canister| {
389            canister
390                .canonical_embedded_config_digest
391                .clone()
392                .unwrap_or_else(|| "missing".into())
393        }),
394        "observed embedded config digest differs",
395        diffs,
396    );
397}
398
399fn compare_authority_evidence(
400    left: &DeploymentCheckV1,
401    right: &DeploymentCheckV1,
402    diffs: &mut Vec<DeploymentComparisonDiffV1>,
403) {
404    compare_maps(
405        DeploymentComparisonCategoryV1::Authority,
406        &observed_canister_map(&left.inventory, canister_authority_fingerprint),
407        &observed_canister_map(&right.inventory, canister_authority_fingerprint),
408        "observed authority evidence differs",
409        diffs,
410    );
411}
412
413fn compare_pool_evidence(
414    left: &DeploymentCheckV1,
415    right: &DeploymentCheckV1,
416    diffs: &mut Vec<DeploymentComparisonDiffV1>,
417) {
418    compare_maps(
419        DeploymentComparisonCategoryV1::Pool,
420        &pool_fingerprints(&left.inventory.observed_pool),
421        &pool_fingerprints(&right.inventory.observed_pool),
422        "observed pool evidence differs",
423        diffs,
424    );
425}
426
427fn compare_verifier_readiness_evidence(
428    left: &DeploymentCheckV1,
429    right: &DeploymentCheckV1,
430    diffs: &mut Vec<DeploymentComparisonDiffV1>,
431) {
432    compare_value(
433        DeploymentComparisonCategoryV1::VerifierReadiness,
434        "verifier_readiness",
435        Some(stable_json_sha256_hex(&left.inventory.observed_verifier_readiness).as_str()),
436        Some(stable_json_sha256_hex(&right.inventory.observed_verifier_readiness).as_str()),
437        "verifier readiness observations differ",
438        diffs,
439    );
440}
441
442fn compare_external_lifecycle_evidence(
443    left: &DeploymentCheckV1,
444    right: &DeploymentCheckV1,
445    diffs: &mut Vec<DeploymentComparisonDiffV1>,
446) {
447    compare_maps(
448        DeploymentComparisonCategoryV1::ExternalLifecycle,
449        &control_class_counts(&left.inventory),
450        &control_class_counts(&right.inventory),
451        "external lifecycle control-class evidence differs",
452        diffs,
453    );
454}
455
456fn role_artifact_fingerprints(artifacts: &[RoleArtifactV1]) -> BTreeMap<String, String> {
457    artifacts
458        .iter()
459        .map(|artifact| {
460            (
461                artifact.role.clone(),
462                stable_json_sha256_hex(&(
463                    artifact.source,
464                    artifact.wasm_sha256.as_deref(),
465                    artifact.wasm_gz_sha256.as_deref(),
466                    artifact.installed_module_hash.as_deref(),
467                    artifact.candid_sha256.as_deref(),
468                    artifact.canonical_embedded_config_sha256.as_deref(),
469                    artifact.package_version.as_deref(),
470                )),
471            )
472        })
473        .collect()
474}
475
476fn observed_canister_map(
477    inventory: &DeploymentInventoryV1,
478    value: impl Fn(&ObservedCanisterV1) -> String,
479) -> BTreeMap<String, String> {
480    inventory
481        .observed_canisters
482        .iter()
483        .map(|canister| (canister_subject(canister), value(canister)))
484        .collect()
485}
486
487fn canister_authority_fingerprint(canister: &ObservedCanisterV1) -> String {
488    stable_json_sha256_hex(&(
489        canister.control_class,
490        &canister.controllers,
491        canister.root_trust_anchor.as_deref(),
492    ))
493}
494
495fn pool_fingerprints(pool: &[ObservedPoolCanisterV1]) -> BTreeMap<String, String> {
496    pool.iter()
497        .map(|canister| {
498            (
499                format!("{}:{}", canister.pool, canister.canister_id),
500                stable_json_sha256_hex(&(canister.role.as_deref(), canister.control_class)),
501            )
502        })
503        .collect()
504}
505
506fn control_class_counts(inventory: &DeploymentInventoryV1) -> BTreeMap<String, String> {
507    let mut counts: BTreeMap<String, usize> = BTreeMap::new();
508    for canister in &inventory.observed_canisters {
509        *counts
510            .entry(format!("{:?}", canister.control_class))
511            .or_default() += 1;
512    }
513    counts
514        .into_iter()
515        .map(|(class, count)| (class, count.to_string()))
516        .collect()
517}
518
519fn canister_subject(canister: &ObservedCanisterV1) -> String {
520    canister
521        .role
522        .as_ref()
523        .map_or_else(|| canister.canister_id.clone(), Clone::clone)
524}
525
526fn compare_maps(
527    category: DeploymentComparisonCategoryV1,
528    left: &BTreeMap<String, String>,
529    right: &BTreeMap<String, String>,
530    message: &'static str,
531    diffs: &mut Vec<DeploymentComparisonDiffV1>,
532) {
533    let subjects: BTreeSet<_> = left.keys().chain(right.keys()).cloned().collect();
534    for subject in subjects {
535        compare_optional(
536            category,
537            &subject,
538            left.get(&subject).map(String::as_str),
539            right.get(&subject).map(String::as_str),
540            message,
541            diffs,
542        );
543    }
544}
545
546fn compare_value(
547    category: DeploymentComparisonCategoryV1,
548    subject: &str,
549    left: Option<&str>,
550    right: Option<&str>,
551    message: &'static str,
552    diffs: &mut Vec<DeploymentComparisonDiffV1>,
553) {
554    if left == right {
555        return;
556    }
557    diffs.push(DeploymentComparisonDiffV1 {
558        category,
559        subject: subject.to_string(),
560        left: left.map(str::to_string),
561        right: right.map(str::to_string),
562        severity: SafetySeverityV1::Warning,
563        message: message.to_string(),
564    });
565}
566
567fn compare_optional(
568    category: DeploymentComparisonCategoryV1,
569    subject: &str,
570    left: Option<&str>,
571    right: Option<&str>,
572    message: &'static str,
573    diffs: &mut Vec<DeploymentComparisonDiffV1>,
574) {
575    compare_value(category, subject, left, right, message, diffs);
576}
577
578fn comparison_warnings(diff_groups: &[&[DeploymentComparisonDiffV1]]) -> Vec<SafetyFindingV1> {
579    let diff_count = diff_groups.iter().map(|group| group.len()).sum::<usize>();
580    if diff_count == 0 {
581        return Vec::new();
582    }
583    vec![SafetyFindingV1 {
584        code: "deployment_comparison_drift".to_string(),
585        message: format!("deployment comparison found {diff_count} drift item(s)"),
586        severity: SafetySeverityV1::Warning,
587        subject: None,
588    }]
589}
590
591fn compare_input_check_status(
592    label: &str,
593    report: &SafetyReportV1,
594    hard_failures: &mut Vec<SafetyFindingV1>,
595    warnings: &mut Vec<SafetyFindingV1>,
596) {
597    match report.status {
598        SafetyStatusV1::Safe => {}
599        SafetyStatusV1::Warning => warnings.push(SafetyFindingV1 {
600            code: "deployment_comparison_input_warning".to_string(),
601            message: "input deployment check has warnings; comparison is drift evidence, not whole-deployment safety".to_string(),
602            severity: SafetySeverityV1::Warning,
603            subject: Some(format!("{label}:{}", report.report_id)),
604        }),
605        SafetyStatusV1::Blocked => hard_failures.push(SafetyFindingV1 {
606            code: "deployment_comparison_input_blocked".to_string(),
607            message: "input deployment check is blocked; comparison cannot be used as ready deployment evidence".to_string(),
608            severity: SafetySeverityV1::HardFailure,
609            subject: Some(format!("{label}:{}", report.report_id)),
610        }),
611        SafetyStatusV1::NotEvaluated => hard_failures.push(SafetyFindingV1 {
612            code: "deployment_comparison_input_not_evaluated".to_string(),
613            message: "input deployment check was not evaluated; comparison cannot establish deployment safety".to_string(),
614            severity: SafetySeverityV1::HardFailure,
615            subject: Some(format!("{label}:{}", report.report_id)),
616        }),
617    }
618}
619
620fn compare_input_check_consistency(
621    label: &str,
622    check: &DeploymentCheckV1,
623    hard_failures: &mut Vec<SafetyFindingV1>,
624) {
625    if check.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
626        hard_failures.push(SafetyFindingV1 {
627            code: "deployment_comparison_input_schema_mismatch".to_string(),
628            message: "input deployment check schema version is unsupported".to_string(),
629            severity: SafetySeverityV1::HardFailure,
630            subject: Some(format!("{label}:{}", check.check_id)),
631        });
632        return;
633    }
634
635    let expected_diff = compare_plan_to_inventory(&check.plan, &check.inventory);
636    if check.diff != expected_diff {
637        hard_failures.push(SafetyFindingV1 {
638            code: "deployment_comparison_input_diff_stale".to_string(),
639            message: "input deployment check diff does not match its plan and inventory"
640                .to_string(),
641            severity: SafetySeverityV1::HardFailure,
642            subject: Some(format!("{label}:{}", check.check_id)),
643        });
644        return;
645    }
646
647    let expected_report = safety_report_from_diff(
648        &check.report.report_id,
649        check.report.diff_id.clone(),
650        &check.diff,
651    );
652    if check.report != expected_report {
653        hard_failures.push(SafetyFindingV1 {
654            code: "deployment_comparison_input_report_stale".to_string(),
655            message: "input deployment check report does not match its diff".to_string(),
656            severity: SafetySeverityV1::HardFailure,
657            subject: Some(format!("{label}:{}", check.check_id)),
658        });
659    }
660}
661
662const fn comparison_status(
663    hard_failures: &[SafetyFindingV1],
664    warnings: &[SafetyFindingV1],
665) -> SafetyStatusV1 {
666    if !hard_failures.is_empty() {
667        SafetyStatusV1::Blocked
668    } else if !warnings.is_empty() {
669        SafetyStatusV1::Warning
670    } else {
671        SafetyStatusV1::Safe
672    }
673}
674
675fn comparison_next_actions(status: SafetyStatusV1) -> Vec<String> {
676    match status {
677        SafetyStatusV1::Safe => vec!["no cross-deployment drift detected".to_string()],
678        SafetyStatusV1::Warning => {
679            vec!["review comparison drift before promotion, rebuild, or teardown".to_string()]
680        }
681        SafetyStatusV1::Blocked => {
682            vec!["resolve hard comparison failures before using this evidence".to_string()]
683        }
684        SafetyStatusV1::NotEvaluated => vec!["run deployment comparison".to_string()],
685    }
686}
687
688fn deployment_comparison_report_digest(report: &DeploymentComparisonReportV1) -> String {
689    stable_json_sha256_hex(&DeploymentComparisonReportDigestInput {
690        report_id: &report.report_id,
691        compared_at: &report.compared_at,
692        left: &report.left,
693        right: &report.right,
694        status: report.status,
695        identity_diff: &report.identity_diff,
696        artifact_diff: &report.artifact_diff,
697        module_hash_diff: &report.module_hash_diff,
698        embedded_config_diff: &report.embedded_config_diff,
699        authority_diff: &report.authority_diff,
700        pool_diff: &report.pool_diff,
701        verifier_readiness_diff: &report.verifier_readiness_diff,
702        external_lifecycle_diff: &report.external_lifecycle_diff,
703        hard_failures: &report.hard_failures,
704        warnings: &report.warnings,
705        next_actions: &report.next_actions,
706    })
707}
708
709fn validate_comparison_target(
710    prefix: &'static str,
711    target: &DeploymentComparisonTargetV1,
712) -> Result<(), DeploymentComparisonReportError> {
713    ensure_comparison_field(field_name(prefix, "label"), target.label.as_str())?;
714    ensure_comparison_field(field_name(prefix, "check_id"), target.check_id.as_str())?;
715    ensure_comparison_field(
716        field_name(prefix, "check_digest"),
717        target.check_digest.as_str(),
718    )?;
719    ensure_comparison_field(field_name(prefix, "plan_id"), target.plan_id.as_str())?;
720    ensure_comparison_field(
721        field_name(prefix, "plan_digest"),
722        target.plan_digest.as_str(),
723    )?;
724    ensure_comparison_field(
725        field_name(prefix, "inventory_id"),
726        target.inventory_id.as_str(),
727    )?;
728    ensure_comparison_field(
729        field_name(prefix, "inventory_digest"),
730        target.inventory_digest.as_str(),
731    )?;
732    ensure_comparison_field(
733        field_name(prefix, "deployment_name"),
734        target.deployment_identity.deployment_name.as_str(),
735    )?;
736    ensure_comparison_field(
737        field_name(prefix, "network"),
738        target.deployment_identity.network.as_str(),
739    )?;
740    Ok(())
741}
742
743fn field_name(prefix: &'static str, field: &'static str) -> &'static str {
744    match (prefix, field) {
745        ("left", "label") => "left.label",
746        ("left", "check_id") => "left.check_id",
747        ("left", "check_digest") => "left.check_digest",
748        ("left", "plan_id") => "left.plan_id",
749        ("left", "plan_digest") => "left.plan_digest",
750        ("left", "inventory_id") => "left.inventory_id",
751        ("left", "inventory_digest") => "left.inventory_digest",
752        ("left", "deployment_name") => "left.deployment_identity.deployment_name",
753        ("left", "network") => "left.deployment_identity.network",
754        ("right", "label") => "right.label",
755        ("right", "check_id") => "right.check_id",
756        ("right", "check_digest") => "right.check_digest",
757        ("right", "plan_id") => "right.plan_id",
758        ("right", "plan_digest") => "right.plan_digest",
759        ("right", "inventory_id") => "right.inventory_id",
760        ("right", "inventory_digest") => "right.inventory_digest",
761        ("right", "deployment_name") => "right.deployment_identity.deployment_name",
762        ("right", "network") => "right.deployment_identity.network",
763        _ => field,
764    }
765}
766
767fn ensure_comparison_field(
768    field: &'static str,
769    value: &str,
770) -> Result<(), DeploymentComparisonReportError> {
771    if value.trim().is_empty() {
772        return Err(DeploymentComparisonReportError::MissingRequiredField { field });
773    }
774    Ok(())
775}