Skip to main content

canic_host/deployment_truth/
lifecycle.rs

1use super::*;
2use serde::Serialize;
3use std::collections::BTreeSet;
4
5#[derive(Serialize)]
6struct ExternalLifecyclePlanDigestInput<'a> {
7    lifecycle_authority_report_id: &'a str,
8    deployment_plan_id: &'a str,
9    deployment_plan_digest: &'a str,
10    inventory_id: &'a str,
11    lifecycle_authority_rows: &'a [LifecycleAuthorityV1],
12    directly_executable_role_upgrades: &'a [ExternalLifecycleRoleUpgradeV1],
13    proposed_external_role_upgrades: &'a [ExternalLifecycleRoleUpgradeV1],
14    blocked_role_upgrades: &'a [ExternalLifecycleRoleUpgradeV1],
15    dependency_blockers: &'a [String],
16    protected_call_implications: &'a [String],
17    residual_exposure: &'a [String],
18    status: ExternalLifecyclePlanStatusV1,
19}
20
21#[derive(Serialize)]
22struct ExternalUpgradeProposalDigestInput<'a> {
23    deployment_plan_id: &'a str,
24    deployment_plan_digest: &'a str,
25    lifecycle_plan_id: &'a str,
26    lifecycle_plan_digest: &'a str,
27    promotion_plan_id: &'a Option<String>,
28    promotion_plan_digest: &'a Option<String>,
29    promotion_provenance_id: &'a Option<String>,
30    promotion_provenance_digest: &'a Option<String>,
31    subject: &'a str,
32    canister_id: &'a Option<String>,
33    role: &'a Option<String>,
34    control_class: CanisterControlClassV1,
35    lifecycle_mode: LifecycleModeV1,
36    observed_before_digest: &'a str,
37    current_module_hash: &'a Option<String>,
38    current_canonical_embedded_config_sha256: &'a Option<String>,
39    target_wasm_sha256: &'a Option<String>,
40    target_wasm_gz_sha256: &'a Option<String>,
41    target_installed_module_hash: &'a Option<String>,
42    target_role_artifact_identity: &'a Option<String>,
43    target_canonical_embedded_config_sha256: &'a Option<String>,
44    root_trust_anchor: &'a Option<String>,
45    authority_profile_hash: &'a Option<String>,
46    required_external_action: &'a str,
47    consent_requirements: &'a [ConsentRequirementV1],
48    allowed_authorization_modes: &'a [ExternalUpgradeAuthorizationModeV1],
49    verification_requirements: &'a [LifecycleVerificationRequirementV1],
50    expires_at: &'a Option<String>,
51    supersedes_proposal_id: &'a Option<String>,
52}
53
54#[derive(Serialize)]
55struct ExternalUpgradeReceiptDigestInput<'a> {
56    proposal_id: &'a str,
57    proposal_digest: &'a str,
58    subject: &'a str,
59    canister_id: &'a Option<String>,
60    role: &'a Option<String>,
61    consent_state: ExternalUpgradeConsentStateV1,
62    reported_by: &'a Option<String>,
63    observed_before_module_hash: &'a Option<String>,
64    observed_after_module_hash: &'a Option<String>,
65    observed_after_canonical_embedded_config_sha256: &'a Option<String>,
66    verification_result: ExternalUpgradeVerificationResultV1,
67    verification_notes: &'a [String],
68}
69
70#[derive(Serialize)]
71struct ObservedBeforeDigestInput<'a> {
72    subject: &'a str,
73    canister_id: &'a Option<String>,
74    role: &'a Option<String>,
75    observed_controllers: &'a [String],
76    current_module_hash: Option<&'a String>,
77    current_canonical_embedded_config_sha256: Option<&'a String>,
78}
79
80///
81/// ExternalUpgradeReceiptError
82///
83#[derive(Debug, Eq, thiserror::Error, PartialEq)]
84pub enum ExternalUpgradeReceiptError {
85    #[error("external upgrade receipt schema version {actual} does not match expected {expected}")]
86    SchemaVersionMismatch { expected: u32, actual: u32 },
87    #[error("external upgrade receipt field `{field}` is required")]
88    MissingRequiredField { field: &'static str },
89    #[error("external upgrade receipt verification result does not match observations")]
90    VerificationMismatch,
91    #[error("external upgrade receipt refused consent cannot be verified")]
92    RefusedConsentVerified,
93}
94
95/// Project the existing deployment truth control classifications into the 0.45
96/// lifecycle-authority view. This is observational and must not mutate IC or
97/// local deployment state.
98#[must_use]
99pub fn lifecycle_authority_report_from_check(
100    report_id: impl Into<String>,
101    check: &DeploymentCheckV1,
102) -> LifecycleAuthorityReportV1 {
103    let mut authorities = Vec::new();
104    let mut seen_subjects = BTreeSet::new();
105
106    for expected in &check.plan.expected_canisters {
107        let observed = observed_canister_for_expected(&check.inventory, expected);
108        let authority = lifecycle_authority_for_expected_canister(&check.plan, expected, observed);
109        seen_subjects.insert(authority.subject.clone());
110        authorities.push(authority);
111    }
112
113    for expected in &check.plan.expected_pool {
114        let observed = observed_pool_for_expected(&check.inventory, expected);
115        let authority = lifecycle_authority_for_expected_pool(expected, observed);
116        seen_subjects.insert(authority.subject.clone());
117        authorities.push(authority);
118    }
119
120    for observed in &check.inventory.observed_canisters {
121        let subject = lifecycle_subject(observed.canister_id.as_str(), observed.role.as_deref());
122        if seen_subjects.contains(&subject) {
123            continue;
124        }
125        authorities.push(lifecycle_authority_for_unplanned_canister(observed));
126    }
127
128    for observed in &check.inventory.observed_pool {
129        let subject = lifecycle_subject(observed.canister_id.as_str(), observed.role.as_deref());
130        if seen_subjects.contains(&subject) {
131            continue;
132        }
133        authorities.push(lifecycle_authority_for_unplanned_pool(observed));
134    }
135
136    authorities.sort_by(|left, right| left.subject.cmp(&right.subject));
137    let external_action_required_count = authorities
138        .iter()
139        .filter(|authority| authority.external_action_required)
140        .count();
141    let blocked_count = authorities
142        .iter()
143        .filter(|authority| authority.blocked)
144        .count();
145
146    LifecycleAuthorityReportV1 {
147        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
148        report_id: report_id.into(),
149        check_id: check.check_id.clone(),
150        plan_id: check.plan.plan_id.clone(),
151        inventory_id: check.inventory.inventory_id.clone(),
152        authorities,
153        external_action_required_count,
154        blocked_count,
155    }
156}
157
158/// Build the central 0.45 lifecycle plan from deployment truth.
159///
160/// This partitions roles into directly executable, externally proposed, and
161/// blocked lifecycle rows. It is passive and does not perform proposal
162/// delivery, consent, or execution.
163#[must_use]
164pub fn external_lifecycle_plan_from_check(
165    lifecycle_plan_id: impl Into<String>,
166    lifecycle_authority_report_id: impl Into<String>,
167    check: &DeploymentCheckV1,
168) -> ExternalLifecyclePlanV1 {
169    let lifecycle_authority_report =
170        lifecycle_authority_report_from_check(lifecycle_authority_report_id, check);
171    let lifecycle_authority_rows = lifecycle_authority_report.authorities;
172    let directly_executable_role_upgrades = lifecycle_authority_rows
173        .iter()
174        .filter(|authority| {
175            authority.lifecycle_mode == LifecycleModeV1::DirectDeploymentAuthority
176                && !authority.blocked
177        })
178        .map(external_lifecycle_role_upgrade)
179        .collect::<Vec<_>>();
180    let proposed_external_role_upgrades = lifecycle_authority_rows
181        .iter()
182        .filter(|authority| authority.external_action_required && !authority.blocked)
183        .map(external_lifecycle_role_upgrade)
184        .collect::<Vec<_>>();
185    let blocked_role_upgrades = lifecycle_authority_rows
186        .iter()
187        .filter(|authority| authority.blocked)
188        .map(external_lifecycle_role_upgrade)
189        .collect::<Vec<_>>();
190    let residual_exposure = proposed_external_role_upgrades
191        .iter()
192        .map(|upgrade| {
193            format!(
194                "{} remains pending external lifecycle action",
195                upgrade.subject
196            )
197        })
198        .collect::<Vec<_>>();
199    let status = if !blocked_role_upgrades.is_empty() {
200        ExternalLifecyclePlanStatusV1::Blocked
201    } else if !proposed_external_role_upgrades.is_empty() {
202        ExternalLifecyclePlanStatusV1::PendingExternalAction
203    } else {
204        ExternalLifecyclePlanStatusV1::Ready
205    };
206    let deployment_plan_digest = stable_json_sha256_hex(&check.plan);
207    let mut plan = ExternalLifecyclePlanV1 {
208        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
209        lifecycle_plan_id: lifecycle_plan_id.into(),
210        lifecycle_plan_digest: String::new(),
211        lifecycle_authority_report_id: lifecycle_authority_report.report_id,
212        deployment_plan_id: check.plan.plan_id.clone(),
213        deployment_plan_digest,
214        inventory_id: check.inventory.inventory_id.clone(),
215        lifecycle_authority_rows,
216        directly_executable_role_upgrades,
217        proposed_external_role_upgrades,
218        blocked_role_upgrades,
219        dependency_blockers: Vec::new(),
220        protected_call_implications: protected_call_implications_for_check(check),
221        residual_exposure,
222        status,
223    };
224    plan.lifecycle_plan_digest = external_lifecycle_plan_digest(&plan);
225    plan
226}
227
228/// Build a passive external-upgrade receipt from post-action observation.
229///
230/// The receipt records what an external controller claims or completed. It does
231/// not verify live state by itself and does not grant deployment authority.
232#[must_use]
233pub fn external_upgrade_receipt_from_observation(
234    receipt_id: impl Into<String>,
235    proposal: &ExternalUpgradeProposalV1,
236    consent_state: ExternalUpgradeConsentStateV1,
237    reported_by: Option<String>,
238    observed_after: Option<&ObservedCanisterV1>,
239) -> ExternalUpgradeReceiptV1 {
240    let observed_after_module_hash =
241        observed_after.and_then(|observed| observed.module_hash.clone());
242    let observed_after_canonical_embedded_config_sha256 =
243        observed_after.and_then(|observed| observed.canonical_embedded_config_digest.clone());
244    let verification_result = external_upgrade_verification_result(
245        consent_state,
246        proposal,
247        observed_after_module_hash.as_deref(),
248        observed_after_canonical_embedded_config_sha256.as_deref(),
249    );
250    let verification_notes = external_upgrade_verification_notes(
251        verification_result,
252        proposal,
253        observed_after_module_hash.as_deref(),
254        observed_after_canonical_embedded_config_sha256.as_deref(),
255    );
256
257    let mut receipt = ExternalUpgradeReceiptV1 {
258        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
259        receipt_id: receipt_id.into(),
260        proposal_id: proposal.proposal_id.clone(),
261        proposal_digest: proposal.proposal_digest.clone(),
262        subject: proposal.subject.clone(),
263        canister_id: proposal.canister_id.clone(),
264        role: proposal.role.clone(),
265        consent_state,
266        reported_by,
267        observed_before_module_hash: proposal.current_module_hash.clone(),
268        observed_after_module_hash,
269        observed_after_canonical_embedded_config_sha256,
270        verification_result,
271        verification_notes,
272        receipt_digest: String::new(),
273    };
274    receipt.receipt_digest = external_upgrade_receipt_digest(&receipt);
275    receipt
276}
277
278/// Validate the internal consistency of an external-upgrade receipt.
279///
280/// This is structural validation only. Live inventory remains the source of
281/// truth for whether the external upgrade actually completed.
282pub fn validate_external_upgrade_receipt(
283    receipt: &ExternalUpgradeReceiptV1,
284) -> Result<(), ExternalUpgradeReceiptError> {
285    if receipt.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
286        return Err(ExternalUpgradeReceiptError::SchemaVersionMismatch {
287            expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
288            actual: receipt.schema_version,
289        });
290    }
291    ensure_external_receipt_field("receipt_id", receipt.receipt_id.as_str())?;
292    ensure_external_receipt_field("proposal_id", receipt.proposal_id.as_str())?;
293    ensure_external_receipt_field("subject", receipt.subject.as_str())?;
294
295    if receipt.consent_state == ExternalUpgradeConsentStateV1::Refused
296        && receipt.verification_result == ExternalUpgradeVerificationResultV1::Verified
297    {
298        return Err(ExternalUpgradeReceiptError::RefusedConsentVerified);
299    }
300    let has_observation = receipt.observed_after_module_hash.is_some()
301        || receipt
302            .observed_after_canonical_embedded_config_sha256
303            .is_some();
304    if matches!(
305        receipt.verification_result,
306        ExternalUpgradeVerificationResultV1::Verified
307            | ExternalUpgradeVerificationResultV1::Mismatch
308    ) && !has_observation
309    {
310        return Err(ExternalUpgradeReceiptError::VerificationMismatch);
311    }
312    Ok(())
313}
314
315/// Build passive external-upgrade proposal artifacts from a lifecycle plan.
316///
317/// This binds current observations to target artifact facts, but does not
318/// grant consent, execute installs, or verify completion.
319#[must_use]
320pub fn external_upgrade_proposal_report_from_lifecycle_plan(
321    report_id: impl Into<String>,
322    lifecycle_plan: &ExternalLifecyclePlanV1,
323    check: &DeploymentCheckV1,
324) -> ExternalUpgradeProposalReportV1 {
325    let report_id = report_id.into();
326    let mut proposals = Vec::new();
327    for authority in lifecycle_plan
328        .lifecycle_authority_rows
329        .iter()
330        .filter(|authority| authority.external_action_required && !authority.blocked)
331    {
332        proposals.push(external_upgrade_proposal(
333            &report_id,
334            lifecycle_plan,
335            check,
336            authority,
337            observed_canister_for_authority(&check.inventory, authority),
338            target_artifact_for_authority(&check.plan, authority),
339        ));
340    }
341
342    proposals.sort_by(|left, right| left.subject.cmp(&right.subject));
343
344    ExternalUpgradeProposalReportV1 {
345        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
346        report_id,
347        lifecycle_plan_id: lifecycle_plan.lifecycle_plan_id.clone(),
348        lifecycle_plan_digest: lifecycle_plan.lifecycle_plan_digest.clone(),
349        deployment_plan_id: lifecycle_plan.deployment_plan_id.clone(),
350        deployment_plan_digest: lifecycle_plan.deployment_plan_digest.clone(),
351        inventory_id: check.inventory.inventory_id.clone(),
352        proposals,
353        blocked_subjects: lifecycle_plan
354            .blocked_role_upgrades
355            .iter()
356            .map(|upgrade| upgrade.subject.clone())
357            .collect(),
358    }
359}
360
361fn external_upgrade_proposal(
362    report_id: &str,
363    lifecycle_plan: &ExternalLifecyclePlanV1,
364    check: &DeploymentCheckV1,
365    authority: &LifecycleAuthorityV1,
366    observed: Option<&ObservedCanisterV1>,
367    target_artifact: Option<&RoleArtifactV1>,
368) -> ExternalUpgradeProposalV1 {
369    let current_module_hash = observed.and_then(|observed| observed.module_hash.clone());
370    let current_canonical_embedded_config_sha256 =
371        observed.and_then(|observed| observed.canonical_embedded_config_digest.clone());
372    let mut proposal = ExternalUpgradeProposalV1 {
373        proposal_id: external_upgrade_proposal_id(report_id, authority.subject.as_str()),
374        proposal_digest: String::new(),
375        deployment_plan_id: lifecycle_plan.deployment_plan_id.clone(),
376        deployment_plan_digest: lifecycle_plan.deployment_plan_digest.clone(),
377        lifecycle_plan_id: lifecycle_plan.lifecycle_plan_id.clone(),
378        lifecycle_plan_digest: lifecycle_plan.lifecycle_plan_digest.clone(),
379        promotion_plan_id: None,
380        promotion_plan_digest: None,
381        promotion_provenance_id: None,
382        promotion_provenance_digest: None,
383        subject: authority.subject.clone(),
384        canister_id: authority.canister_id.clone(),
385        role: authority.role.clone(),
386        control_class: authority.control_class,
387        lifecycle_mode: authority.lifecycle_mode,
388        observed_before_digest: observed_before_digest(
389            authority,
390            current_module_hash.as_ref(),
391            current_canonical_embedded_config_sha256.as_ref(),
392        ),
393        current_module_hash,
394        current_canonical_embedded_config_sha256,
395        target_wasm_sha256: target_artifact.and_then(|artifact| artifact.wasm_sha256.clone()),
396        target_wasm_gz_sha256: target_artifact.and_then(|artifact| artifact.wasm_gz_sha256.clone()),
397        target_installed_module_hash: target_artifact
398            .and_then(|artifact| artifact.installed_module_hash.clone()),
399        target_role_artifact_identity: target_artifact.map(role_artifact_identity),
400        target_canonical_embedded_config_sha256: target_artifact
401            .and_then(|artifact| artifact.canonical_embedded_config_sha256.clone()),
402        root_trust_anchor: check.plan.trust_domain.root_trust_anchor.clone(),
403        authority_profile_hash: check
404            .plan
405            .deployment_identity
406            .authority_profile_hash
407            .clone(),
408        required_external_action: required_external_action(authority.lifecycle_mode).to_string(),
409        consent_requirements: authority.consent_requirements.clone(),
410        allowed_authorization_modes: external_upgrade_authorization_modes(authority.control_class),
411        verification_requirements: authority.verification_requirements.clone(),
412        expires_at: None,
413        supersedes_proposal_id: None,
414    };
415    proposal.proposal_digest = external_upgrade_proposal_digest(&proposal);
416    proposal
417}
418
419fn lifecycle_authority_for_expected_canister(
420    plan: &DeploymentPlanV1,
421    expected: &ExpectedCanisterV1,
422    observed: Option<&ObservedCanisterV1>,
423) -> LifecycleAuthorityV1 {
424    let canister_id = expected
425        .canister_id
426        .clone()
427        .or_else(|| observed.map(|observed| observed.canister_id.clone()));
428    let role = Some(expected.role.clone());
429    let control_class = observed.map_or(expected.control_class, |observed| observed.control_class);
430    let observed_controllers =
431        observed.map_or_else(Vec::new, |observed| observed.controllers.clone());
432    lifecycle_authority(
433        lifecycle_subject_for_parts(canister_id.as_deref(), role.as_deref()),
434        canister_id,
435        role,
436        control_class,
437        observed_controllers,
438        &plan.authority_profile.expected_controllers,
439        plan.expected_verifier_readiness.required,
440    )
441}
442
443fn lifecycle_authority_for_expected_pool(
444    expected: &ExpectedPoolCanisterV1,
445    observed: Option<&ObservedPoolCanisterV1>,
446) -> LifecycleAuthorityV1 {
447    let canister_id = expected
448        .canister_id
449        .clone()
450        .or_else(|| observed.map(|observed| observed.canister_id.clone()));
451    let role = expected
452        .role
453        .clone()
454        .or_else(|| observed.and_then(|observed| observed.role.clone()));
455    let control_class = observed.map_or(CanisterControlClassV1::CanicManagedPool, |observed| {
456        observed.control_class
457    });
458    lifecycle_authority(
459        lifecycle_subject_for_parts(canister_id.as_deref(), role.as_deref()),
460        canister_id,
461        role,
462        control_class,
463        Vec::new(),
464        &[],
465        false,
466    )
467}
468
469fn lifecycle_authority_for_unplanned_canister(
470    observed: &ObservedCanisterV1,
471) -> LifecycleAuthorityV1 {
472    lifecycle_authority(
473        lifecycle_subject(observed.canister_id.as_str(), observed.role.as_deref()),
474        Some(observed.canister_id.clone()),
475        observed.role.clone(),
476        observed.control_class,
477        observed.controllers.clone(),
478        &[],
479        false,
480    )
481}
482
483fn lifecycle_authority_for_unplanned_pool(
484    observed: &ObservedPoolCanisterV1,
485) -> LifecycleAuthorityV1 {
486    lifecycle_authority(
487        lifecycle_subject(observed.canister_id.as_str(), observed.role.as_deref()),
488        Some(observed.canister_id.clone()),
489        observed.role.clone(),
490        observed.control_class,
491        Vec::new(),
492        &[],
493        false,
494    )
495}
496
497fn lifecycle_authority(
498    subject: String,
499    canister_id: Option<String>,
500    role: Option<String>,
501    control_class: CanisterControlClassV1,
502    observed_controllers: Vec<String>,
503    expected_controllers: &[String],
504    verifier_required: bool,
505) -> LifecycleAuthorityV1 {
506    let required_controllers = required_lifecycle_controllers(control_class, expected_controllers);
507    let external_controllers =
508        external_lifecycle_controllers(control_class, &observed_controllers, &required_controllers);
509    let consent_requirements = lifecycle_consent_requirements(control_class, &external_controllers);
510    let allowed_upgrade_modes = lifecycle_upgrade_modes(control_class);
511    let verification_requirements = lifecycle_verification_requirements(verifier_required);
512    let external_action_required = lifecycle_external_action_required(control_class);
513    let blocked = control_class == CanisterControlClassV1::UnknownUnsafe;
514    let lifecycle_mode = lifecycle_mode(control_class);
515    let blockers = lifecycle_blockers(control_class);
516    let warnings = lifecycle_warnings(control_class);
517    let reason = lifecycle_reason(control_class);
518    LifecycleAuthorityV1 {
519        subject,
520        canister_id,
521        role,
522        control_class,
523        lifecycle_mode,
524        observed_controllers,
525        expected_deployment_controllers: sorted_unique(expected_controllers.to_vec()),
526        external_controllers,
527        required_controllers,
528        consent_requirements,
529        allowed_upgrade_modes,
530        verification_requirements,
531        external_action_required,
532        blocked,
533        blockers,
534        warnings,
535        reason,
536    }
537}
538
539fn required_lifecycle_controllers(
540    control_class: CanisterControlClassV1,
541    expected_controllers: &[String],
542) -> Vec<String> {
543    match control_class {
544        CanisterControlClassV1::DeploymentControlled
545        | CanisterControlClassV1::JointlyControlled => sorted_unique(expected_controllers.to_vec()),
546        CanisterControlClassV1::CanicManagedPool
547        | CanisterControlClassV1::ExternallyImported
548        | CanisterControlClassV1::UserControlled
549        | CanisterControlClassV1::UnknownUnsafe => Vec::new(),
550    }
551}
552
553fn external_lifecycle_controllers(
554    control_class: CanisterControlClassV1,
555    observed_controllers: &[String],
556    required_controllers: &[String],
557) -> Vec<String> {
558    match control_class {
559        CanisterControlClassV1::DeploymentControlled | CanisterControlClassV1::UnknownUnsafe => {
560            Vec::new()
561        }
562        CanisterControlClassV1::JointlyControlled => {
563            let required = required_controllers.iter().collect::<BTreeSet<_>>();
564            sorted_unique(
565                observed_controllers
566                    .iter()
567                    .filter(|controller| !required.contains(controller))
568                    .cloned()
569                    .collect(),
570            )
571        }
572        CanisterControlClassV1::CanicManagedPool
573        | CanisterControlClassV1::ExternallyImported
574        | CanisterControlClassV1::UserControlled => sorted_unique(observed_controllers.to_vec()),
575    }
576}
577
578fn lifecycle_consent_requirements(
579    control_class: CanisterControlClassV1,
580    external_controllers: &[String],
581) -> Vec<ConsentRequirementV1> {
582    if !lifecycle_external_action_required(control_class) {
583        return Vec::new();
584    }
585    vec![ConsentRequirementV1 {
586        consent_subject_kind: consent_subject_kind(control_class),
587        required_principals: sorted_unique(external_controllers.to_vec()),
588        required_controller_set_digest: Some(stable_json_sha256_hex(&external_controllers)),
589        consent_channel_kind: consent_channel_kind(control_class),
590        required_action: required_consent_action(control_class),
591    }]
592}
593
594const fn consent_subject_kind(control_class: CanisterControlClassV1) -> ConsentSubjectKindV1 {
595    match control_class {
596        CanisterControlClassV1::CanicManagedPool => ConsentSubjectKindV1::ProjectHub,
597        CanisterControlClassV1::ExternallyImported | CanisterControlClassV1::JointlyControlled => {
598            ConsentSubjectKindV1::CustomerController
599        }
600        CanisterControlClassV1::UserControlled => ConsentSubjectKindV1::UserPrincipal,
601        CanisterControlClassV1::DeploymentControlled | CanisterControlClassV1::UnknownUnsafe => {
602            ConsentSubjectKindV1::UnknownExternalController
603        }
604    }
605}
606
607const fn consent_channel_kind(control_class: CanisterControlClassV1) -> ConsentChannelKindV1 {
608    match control_class {
609        CanisterControlClassV1::CanicManagedPool => ConsentChannelKindV1::DelegatedInstall,
610        CanisterControlClassV1::ExternallyImported
611        | CanisterControlClassV1::JointlyControlled
612        | CanisterControlClassV1::UserControlled => ConsentChannelKindV1::GeneratedCommand,
613        CanisterControlClassV1::DeploymentControlled | CanisterControlClassV1::UnknownUnsafe => {
614            ConsentChannelKindV1::OutOfBand
615        }
616    }
617}
618
619const fn required_consent_action(
620    control_class: CanisterControlClassV1,
621) -> ExternalUpgradeAuthorizationModeV1 {
622    match control_class {
623        CanisterControlClassV1::JointlyControlled => {
624            ExternalUpgradeAuthorizationModeV1::ConsentForDirectInstall
625        }
626        CanisterControlClassV1::CanicManagedPool => {
627            ExternalUpgradeAuthorizationModeV1::DelegatedInstallAuthority
628        }
629        CanisterControlClassV1::ExternallyImported | CanisterControlClassV1::UserControlled => {
630            ExternalUpgradeAuthorizationModeV1::ExternalControllerExecution
631        }
632        CanisterControlClassV1::DeploymentControlled | CanisterControlClassV1::UnknownUnsafe => {
633            ExternalUpgradeAuthorizationModeV1::ObserveAndVerifyOnly
634        }
635    }
636}
637
638const fn lifecycle_mode(control_class: CanisterControlClassV1) -> LifecycleModeV1 {
639    match control_class {
640        CanisterControlClassV1::DeploymentControlled => LifecycleModeV1::DirectDeploymentAuthority,
641        CanisterControlClassV1::CanicManagedPool => LifecycleModeV1::DelegatedInstallRequired,
642        CanisterControlClassV1::ExternallyImported | CanisterControlClassV1::UserControlled => {
643            LifecycleModeV1::ExternalCompletionOnly
644        }
645        CanisterControlClassV1::JointlyControlled => LifecycleModeV1::ProposalRequired,
646        CanisterControlClassV1::UnknownUnsafe => LifecycleModeV1::UnknownUnsafeBlocked,
647    }
648}
649
650fn lifecycle_blockers(control_class: CanisterControlClassV1) -> Vec<String> {
651    if control_class == CanisterControlClassV1::UnknownUnsafe {
652        vec!["unknown unsafe controller state blocks lifecycle action".to_string()]
653    } else {
654        Vec::new()
655    }
656}
657
658fn lifecycle_warnings(control_class: CanisterControlClassV1) -> Vec<String> {
659    match control_class {
660        CanisterControlClassV1::CanicManagedPool => {
661            vec!["pool-aware lifecycle policy is required before mutation".to_string()]
662        }
663        CanisterControlClassV1::ExternallyImported => {
664            vec!["external controller action or verification is required".to_string()]
665        }
666        CanisterControlClassV1::JointlyControlled => {
667            vec!["joint controller consent or delegation is required".to_string()]
668        }
669        CanisterControlClassV1::UserControlled => {
670            vec!["user or delegated lifecycle action is required".to_string()]
671        }
672        CanisterControlClassV1::DeploymentControlled | CanisterControlClassV1::UnknownUnsafe => {
673            Vec::new()
674        }
675    }
676}
677
678fn lifecycle_upgrade_modes(control_class: CanisterControlClassV1) -> Vec<LifecycleUpgradeModeV1> {
679    match control_class {
680        CanisterControlClassV1::DeploymentControlled => vec![
681            LifecycleUpgradeModeV1::DirectByDeploymentAuthority,
682            LifecycleUpgradeModeV1::VerifyExternalCompletion,
683        ],
684        CanisterControlClassV1::CanicManagedPool
685        | CanisterControlClassV1::ExternallyImported
686        | CanisterControlClassV1::JointlyControlled
687        | CanisterControlClassV1::UserControlled => vec![
688            LifecycleUpgradeModeV1::ExternalProposal,
689            LifecycleUpgradeModeV1::ExternalExecution,
690            LifecycleUpgradeModeV1::VerifyExternalCompletion,
691            LifecycleUpgradeModeV1::ObserveOnly,
692        ],
693        CanisterControlClassV1::UnknownUnsafe => vec![LifecycleUpgradeModeV1::Blocked],
694    }
695}
696
697fn lifecycle_verification_requirements(
698    verifier_required: bool,
699) -> Vec<LifecycleVerificationRequirementV1> {
700    let mut requirements = vec![
701        LifecycleVerificationRequirementV1::LiveInventory,
702        LifecycleVerificationRequirementV1::ControllerObservation,
703        LifecycleVerificationRequirementV1::ModuleHash,
704        LifecycleVerificationRequirementV1::CanonicalEmbeddedConfig,
705    ];
706    if verifier_required {
707        requirements.push(LifecycleVerificationRequirementV1::ProtectedCallReadiness);
708    }
709    requirements
710}
711
712const fn lifecycle_external_action_required(control_class: CanisterControlClassV1) -> bool {
713    matches!(
714        control_class,
715        CanisterControlClassV1::CanicManagedPool
716            | CanisterControlClassV1::ExternallyImported
717            | CanisterControlClassV1::JointlyControlled
718            | CanisterControlClassV1::UserControlled
719    )
720}
721
722fn lifecycle_reason(control_class: CanisterControlClassV1) -> String {
723    match control_class {
724        CanisterControlClassV1::DeploymentControlled => {
725            "deployment authority can execute lifecycle directly".to_string()
726        }
727        CanisterControlClassV1::CanicManagedPool => {
728            "Canic-managed pool lifecycle requires pool-aware external action".to_string()
729        }
730        CanisterControlClassV1::ExternallyImported => {
731            "externally imported canister requires external controller action".to_string()
732        }
733        CanisterControlClassV1::JointlyControlled => {
734            "jointly controlled canister requires non-deployment-controller consent".to_string()
735        }
736        CanisterControlClassV1::UserControlled => {
737            "user-controlled canister requires user or delegated lifecycle action".to_string()
738        }
739        CanisterControlClassV1::UnknownUnsafe => {
740            "unknown or unsafe controller state blocks lifecycle action".to_string()
741        }
742    }
743}
744
745fn observed_canister_for_expected<'a>(
746    inventory: &'a DeploymentInventoryV1,
747    expected: &ExpectedCanisterV1,
748) -> Option<&'a ObservedCanisterV1> {
749    if let Some(canister_id) = &expected.canister_id
750        && let Some(observed) = inventory
751            .observed_canisters
752            .iter()
753            .find(|observed| &observed.canister_id == canister_id)
754    {
755        return Some(observed);
756    }
757    inventory
758        .observed_canisters
759        .iter()
760        .find(|observed| observed.role.as_deref() == Some(expected.role.as_str()))
761}
762
763fn observed_pool_for_expected<'a>(
764    inventory: &'a DeploymentInventoryV1,
765    expected: &ExpectedPoolCanisterV1,
766) -> Option<&'a ObservedPoolCanisterV1> {
767    if let Some(canister_id) = &expected.canister_id
768        && let Some(observed) = inventory
769            .observed_pool
770            .iter()
771            .find(|observed| &observed.canister_id == canister_id)
772    {
773        return Some(observed);
774    }
775    inventory.observed_pool.iter().find(|observed| {
776        observed.pool == expected.pool && observed.role.as_deref() == expected.role.as_deref()
777    })
778}
779
780fn lifecycle_subject(canister_id: &str, role: Option<&str>) -> String {
781    lifecycle_subject_for_parts(Some(canister_id), role)
782}
783
784fn lifecycle_subject_for_parts(canister_id: Option<&str>, role: Option<&str>) -> String {
785    match (role, canister_id) {
786        (Some(role), Some(canister_id)) => format!("{role}:{canister_id}"),
787        (Some(role), None) => format!("{role}:unassigned"),
788        (None, Some(canister_id)) => canister_id.to_string(),
789        (None, None) => "unknown".to_string(),
790    }
791}
792
793fn observed_canister_for_authority<'a>(
794    inventory: &'a DeploymentInventoryV1,
795    authority: &LifecycleAuthorityV1,
796) -> Option<&'a ObservedCanisterV1> {
797    if let Some(canister_id) = &authority.canister_id
798        && let Some(observed) = inventory
799            .observed_canisters
800            .iter()
801            .find(|observed| &observed.canister_id == canister_id)
802    {
803        return Some(observed);
804    }
805    inventory
806        .observed_canisters
807        .iter()
808        .find(|observed| observed.role == authority.role)
809}
810
811fn target_artifact_for_authority<'a>(
812    plan: &'a DeploymentPlanV1,
813    authority: &LifecycleAuthorityV1,
814) -> Option<&'a RoleArtifactV1> {
815    let role = authority.role.as_ref()?;
816    plan.role_artifacts
817        .iter()
818        .find(|artifact| &artifact.role == role)
819}
820
821fn external_lifecycle_role_upgrade(
822    authority: &LifecycleAuthorityV1,
823) -> ExternalLifecycleRoleUpgradeV1 {
824    ExternalLifecycleRoleUpgradeV1 {
825        subject: authority.subject.clone(),
826        canister_id: authority.canister_id.clone(),
827        role: authority.role.clone(),
828        control_class: authority.control_class,
829        lifecycle_mode: authority.lifecycle_mode,
830        required_external_action: authority
831            .external_action_required
832            .then(|| required_external_action(authority.lifecycle_mode).to_string()),
833        blockers: authority.blockers.clone(),
834        warnings: authority.warnings.clone(),
835    }
836}
837
838fn protected_call_implications_for_check(check: &DeploymentCheckV1) -> Vec<String> {
839    if check.plan.expected_verifier_readiness.required {
840        vec!["protected-call verifier readiness must be checked before completion".to_string()]
841    } else {
842        Vec::new()
843    }
844}
845
846const fn required_external_action(lifecycle_mode: LifecycleModeV1) -> &'static str {
847    match lifecycle_mode {
848        LifecycleModeV1::DirectDeploymentAuthority => "none",
849        LifecycleModeV1::ProposalRequired => "proposal_and_consent",
850        LifecycleModeV1::DelegatedInstallRequired => "delegated_install_or_pool_policy",
851        LifecycleModeV1::ExternalCompletionOnly => "external_controller_execution",
852        LifecycleModeV1::VerifyOnly => "verify_external_completion",
853        LifecycleModeV1::MustNotTouch | LifecycleModeV1::UnknownUnsafeBlocked => "blocked",
854    }
855}
856
857fn role_artifact_identity(artifact: &RoleArtifactV1) -> String {
858    stable_json_sha256_hex(&(
859        artifact.role.as_str(),
860        artifact.wasm_sha256.as_deref(),
861        artifact.wasm_gz_sha256.as_deref(),
862        artifact.installed_module_hash.as_deref(),
863        artifact.candid_sha256.as_deref(),
864        artifact.canonical_embedded_config_sha256.as_deref(),
865    ))
866}
867
868fn external_upgrade_authorization_modes(
869    control_class: CanisterControlClassV1,
870) -> Vec<ExternalUpgradeAuthorizationModeV1> {
871    match control_class {
872        CanisterControlClassV1::JointlyControlled => vec![
873            ExternalUpgradeAuthorizationModeV1::ConsentForDirectInstall,
874            ExternalUpgradeAuthorizationModeV1::DelegatedInstallAuthority,
875            ExternalUpgradeAuthorizationModeV1::ExternalControllerExecution,
876            ExternalUpgradeAuthorizationModeV1::ObserveAndVerifyOnly,
877        ],
878        CanisterControlClassV1::CanicManagedPool
879        | CanisterControlClassV1::ExternallyImported
880        | CanisterControlClassV1::UserControlled => vec![
881            ExternalUpgradeAuthorizationModeV1::DelegatedInstallAuthority,
882            ExternalUpgradeAuthorizationModeV1::ExternalControllerExecution,
883            ExternalUpgradeAuthorizationModeV1::ObserveAndVerifyOnly,
884        ],
885        CanisterControlClassV1::DeploymentControlled | CanisterControlClassV1::UnknownUnsafe => {
886            Vec::new()
887        }
888    }
889}
890
891fn external_upgrade_proposal_id(report_id: &str, subject: &str) -> String {
892    let subject = subject.replace([':', '/'], "-");
893    format!("{report_id}:{subject}")
894}
895
896fn external_lifecycle_plan_digest(plan: &ExternalLifecyclePlanV1) -> String {
897    stable_json_sha256_hex(&ExternalLifecyclePlanDigestInput {
898        lifecycle_authority_report_id: &plan.lifecycle_authority_report_id,
899        deployment_plan_id: &plan.deployment_plan_id,
900        deployment_plan_digest: &plan.deployment_plan_digest,
901        inventory_id: &plan.inventory_id,
902        lifecycle_authority_rows: &plan.lifecycle_authority_rows,
903        directly_executable_role_upgrades: &plan.directly_executable_role_upgrades,
904        proposed_external_role_upgrades: &plan.proposed_external_role_upgrades,
905        blocked_role_upgrades: &plan.blocked_role_upgrades,
906        dependency_blockers: &plan.dependency_blockers,
907        protected_call_implications: &plan.protected_call_implications,
908        residual_exposure: &plan.residual_exposure,
909        status: plan.status,
910    })
911}
912
913fn external_upgrade_proposal_digest(proposal: &ExternalUpgradeProposalV1) -> String {
914    stable_json_sha256_hex(&ExternalUpgradeProposalDigestInput {
915        deployment_plan_id: &proposal.deployment_plan_id,
916        deployment_plan_digest: &proposal.deployment_plan_digest,
917        lifecycle_plan_id: &proposal.lifecycle_plan_id,
918        lifecycle_plan_digest: &proposal.lifecycle_plan_digest,
919        promotion_plan_id: &proposal.promotion_plan_id,
920        promotion_plan_digest: &proposal.promotion_plan_digest,
921        promotion_provenance_id: &proposal.promotion_provenance_id,
922        promotion_provenance_digest: &proposal.promotion_provenance_digest,
923        subject: &proposal.subject,
924        canister_id: &proposal.canister_id,
925        role: &proposal.role,
926        control_class: proposal.control_class,
927        lifecycle_mode: proposal.lifecycle_mode,
928        observed_before_digest: &proposal.observed_before_digest,
929        current_module_hash: &proposal.current_module_hash,
930        current_canonical_embedded_config_sha256: &proposal
931            .current_canonical_embedded_config_sha256,
932        target_wasm_sha256: &proposal.target_wasm_sha256,
933        target_wasm_gz_sha256: &proposal.target_wasm_gz_sha256,
934        target_installed_module_hash: &proposal.target_installed_module_hash,
935        target_role_artifact_identity: &proposal.target_role_artifact_identity,
936        target_canonical_embedded_config_sha256: &proposal.target_canonical_embedded_config_sha256,
937        root_trust_anchor: &proposal.root_trust_anchor,
938        authority_profile_hash: &proposal.authority_profile_hash,
939        required_external_action: &proposal.required_external_action,
940        consent_requirements: &proposal.consent_requirements,
941        allowed_authorization_modes: &proposal.allowed_authorization_modes,
942        verification_requirements: &proposal.verification_requirements,
943        expires_at: &proposal.expires_at,
944        supersedes_proposal_id: &proposal.supersedes_proposal_id,
945    })
946}
947
948fn external_upgrade_receipt_digest(receipt: &ExternalUpgradeReceiptV1) -> String {
949    stable_json_sha256_hex(&ExternalUpgradeReceiptDigestInput {
950        proposal_id: &receipt.proposal_id,
951        proposal_digest: &receipt.proposal_digest,
952        subject: &receipt.subject,
953        canister_id: &receipt.canister_id,
954        role: &receipt.role,
955        consent_state: receipt.consent_state,
956        reported_by: &receipt.reported_by,
957        observed_before_module_hash: &receipt.observed_before_module_hash,
958        observed_after_module_hash: &receipt.observed_after_module_hash,
959        observed_after_canonical_embedded_config_sha256: &receipt
960            .observed_after_canonical_embedded_config_sha256,
961        verification_result: receipt.verification_result,
962        verification_notes: &receipt.verification_notes,
963    })
964}
965
966fn observed_before_digest(
967    authority: &LifecycleAuthorityV1,
968    current_module_hash: Option<&String>,
969    current_config_hash: Option<&String>,
970) -> String {
971    stable_json_sha256_hex(&ObservedBeforeDigestInput {
972        subject: &authority.subject,
973        canister_id: &authority.canister_id,
974        role: &authority.role,
975        observed_controllers: &authority.observed_controllers,
976        current_module_hash,
977        current_canonical_embedded_config_sha256: current_config_hash,
978    })
979}
980
981fn external_upgrade_verification_result(
982    consent_state: ExternalUpgradeConsentStateV1,
983    proposal: &ExternalUpgradeProposalV1,
984    observed_after_module_hash: Option<&str>,
985    observed_after_config: Option<&str>,
986) -> ExternalUpgradeVerificationResultV1 {
987    match consent_state {
988        ExternalUpgradeConsentStateV1::Pending => ExternalUpgradeVerificationResultV1::Pending,
989        ExternalUpgradeConsentStateV1::Refused => ExternalUpgradeVerificationResultV1::Refused,
990        ExternalUpgradeConsentStateV1::Delegated
991        | ExternalUpgradeConsentStateV1::ExecutedExternally => {
992            if external_upgrade_observation_matches(
993                proposal.target_installed_module_hash.as_deref(),
994                observed_after_module_hash,
995            ) && external_upgrade_observation_matches(
996                proposal.target_canonical_embedded_config_sha256.as_deref(),
997                observed_after_config,
998            ) {
999                ExternalUpgradeVerificationResultV1::Verified
1000            } else {
1001                ExternalUpgradeVerificationResultV1::Mismatch
1002            }
1003        }
1004    }
1005}
1006
1007fn external_upgrade_verification_notes(
1008    verification_result: ExternalUpgradeVerificationResultV1,
1009    proposal: &ExternalUpgradeProposalV1,
1010    observed_after_module_hash: Option<&str>,
1011    observed_after_config: Option<&str>,
1012) -> Vec<String> {
1013    let mut notes = Vec::new();
1014    if verification_result == ExternalUpgradeVerificationResultV1::Mismatch {
1015        if !external_upgrade_observation_matches(
1016            proposal.target_installed_module_hash.as_deref(),
1017            observed_after_module_hash,
1018        ) {
1019            notes.push("observed module hash does not match proposal target".to_string());
1020        }
1021        if !external_upgrade_observation_matches(
1022            proposal.target_canonical_embedded_config_sha256.as_deref(),
1023            observed_after_config,
1024        ) {
1025            notes.push("observed embedded config does not match proposal target".to_string());
1026        }
1027    }
1028    notes
1029}
1030
1031fn external_upgrade_observation_matches(expected: Option<&str>, observed: Option<&str>) -> bool {
1032    expected.is_none_or(|expected| observed == Some(expected))
1033}
1034
1035fn ensure_external_receipt_field(
1036    field: &'static str,
1037    value: &str,
1038) -> Result<(), ExternalUpgradeReceiptError> {
1039    if value.trim().is_empty() {
1040        return Err(ExternalUpgradeReceiptError::MissingRequiredField { field });
1041    }
1042    Ok(())
1043}
1044
1045fn sorted_unique(values: Vec<String>) -> Vec<String> {
1046    values
1047        .into_iter()
1048        .collect::<BTreeSet<_>>()
1049        .into_iter()
1050        .collect()
1051}