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