Skip to main content

canic_host/deployment_truth/
lifecycle.rs

1use super::*;
2use serde::Serialize;
3use std::collections::BTreeSet;
4
5#[derive(Serialize)]
6struct LifecycleAuthorityReportDigestInput<'a> {
7    report_id: &'a str,
8    check_id: &'a str,
9    plan_id: &'a str,
10    inventory_id: &'a str,
11    authorities: &'a [LifecycleAuthorityV1],
12    external_action_required_count: usize,
13    blocked_count: usize,
14}
15
16#[derive(Serialize)]
17struct ExternalLifecyclePlanDigestInput<'a> {
18    lifecycle_authority_report_id: &'a str,
19    deployment_plan_id: &'a str,
20    deployment_plan_digest: &'a str,
21    inventory_id: &'a str,
22    lifecycle_authority_rows: &'a [LifecycleAuthorityV1],
23    directly_executable_role_upgrades: &'a [ExternalLifecycleRoleUpgradeV1],
24    proposed_external_role_upgrades: &'a [ExternalLifecycleRoleUpgradeV1],
25    blocked_role_upgrades: &'a [ExternalLifecycleRoleUpgradeV1],
26    dependency_blockers: &'a [String],
27    protected_call_implications: &'a [String],
28    residual_exposure: &'a [String],
29    status: ExternalLifecyclePlanStatusV1,
30}
31
32#[derive(Serialize)]
33struct ExternalUpgradeProposalReportDigestInput<'a> {
34    report_id: &'a str,
35    lifecycle_plan_id: &'a str,
36    lifecycle_plan_digest: &'a str,
37    deployment_plan_id: &'a str,
38    deployment_plan_digest: &'a str,
39    inventory_id: &'a str,
40    proposals: &'a [ExternalUpgradeProposalV1],
41    blocked_subjects: &'a [String],
42}
43
44#[derive(Serialize)]
45struct ExternalUpgradeProposalDigestInput<'a> {
46    deployment_plan_id: &'a str,
47    deployment_plan_digest: &'a str,
48    lifecycle_plan_id: &'a str,
49    lifecycle_plan_digest: &'a str,
50    promotion_plan_id: &'a Option<String>,
51    promotion_plan_digest: &'a Option<String>,
52    promotion_provenance_id: &'a Option<String>,
53    promotion_provenance_digest: &'a Option<String>,
54    subject: &'a str,
55    canister_id: &'a Option<String>,
56    role: &'a Option<String>,
57    control_class: CanisterControlClassV1,
58    lifecycle_mode: LifecycleModeV1,
59    observed_before_digest: &'a str,
60    current_module_hash: &'a Option<String>,
61    current_canonical_embedded_config_sha256: &'a Option<String>,
62    target_wasm_sha256: &'a Option<String>,
63    target_wasm_gz_sha256: &'a Option<String>,
64    target_installed_module_hash: &'a Option<String>,
65    target_role_artifact_identity: &'a Option<String>,
66    target_canonical_embedded_config_sha256: &'a Option<String>,
67    root_trust_anchor: &'a Option<String>,
68    authority_profile_hash: &'a Option<String>,
69    required_external_action: &'a str,
70    consent_requirements: &'a [ConsentRequirementV1],
71    allowed_authorization_modes: &'a [ExternalUpgradeAuthorizationModeV1],
72    verification_requirements: &'a [LifecycleVerificationRequirementV1],
73    expires_at: &'a Option<String>,
74    supersedes_proposal_id: &'a Option<String>,
75}
76
77#[derive(Serialize)]
78struct ExternalUpgradeReceiptDigestInput<'a> {
79    proposal_id: &'a str,
80    proposal_digest: &'a str,
81    subject: &'a str,
82    canister_id: &'a Option<String>,
83    role: &'a Option<String>,
84    consent_state: ExternalUpgradeConsentStateV1,
85    reported_by: &'a Option<String>,
86    observed_before_module_hash: &'a Option<String>,
87    observed_after_module_hash: &'a Option<String>,
88    observed_after_canonical_embedded_config_sha256: &'a Option<String>,
89    verification_result: ExternalUpgradeVerificationResultV1,
90    verification_notes: &'a [String],
91}
92
93#[derive(Serialize)]
94struct ObservedBeforeDigestInput<'a> {
95    subject: &'a str,
96    canister_id: &'a Option<String>,
97    role: &'a Option<String>,
98    observed_controllers: &'a [String],
99    current_module_hash: Option<&'a String>,
100    current_canonical_embedded_config_sha256: Option<&'a String>,
101}
102
103///
104/// ExternalUpgradeReceiptError
105///
106#[derive(Debug, Eq, thiserror::Error, PartialEq)]
107pub enum ExternalUpgradeReceiptError {
108    #[error("external upgrade receipt schema version {actual} does not match expected {expected}")]
109    SchemaVersionMismatch { expected: u32, actual: u32 },
110    #[error("external upgrade receipt field `{field}` is required")]
111    MissingRequiredField { field: &'static str },
112    #[error("external upgrade receipt field `{field}` digest is stale")]
113    DigestMismatch { field: &'static str },
114    #[error("external upgrade receipt verification result does not match observations")]
115    VerificationMismatch,
116    #[error("external upgrade receipt refused consent cannot be verified")]
117    RefusedConsentVerified,
118}
119
120///
121/// LifecycleAuthorityReportError
122///
123#[derive(Debug, Eq, thiserror::Error, PartialEq)]
124pub enum LifecycleAuthorityReportError {
125    #[error(
126        "lifecycle authority report schema version {actual} does not match expected {expected}"
127    )]
128    SchemaVersionMismatch { expected: u32, actual: u32 },
129    #[error("lifecycle authority report field `{field}` is required")]
130    MissingRequiredField { field: &'static str },
131    #[error("lifecycle authority report field `{field}` digest is stale")]
132    DigestMismatch { field: &'static str },
133    #[error("lifecycle authority report contains duplicate subject `{subject}`")]
134    DuplicateSubject { subject: String },
135    #[error("lifecycle authority report counters do not match authority rows")]
136    CountMismatch,
137}
138
139///
140/// ExternalLifecyclePlanError
141///
142#[derive(Debug, Eq, thiserror::Error, PartialEq)]
143pub enum ExternalLifecyclePlanError {
144    #[error("external lifecycle plan schema version {actual} does not match expected {expected}")]
145    SchemaVersionMismatch { expected: u32, actual: u32 },
146    #[error("external lifecycle plan field `{field}` is required")]
147    MissingRequiredField { field: &'static str },
148    #[error("external lifecycle plan field `{field}` digest is stale")]
149    DigestMismatch { field: &'static str },
150    #[error("external lifecycle plan field `{field}` does not match deployment truth source")]
151    SourceMismatch { field: &'static str },
152    #[error("external lifecycle plan status does not match role partitioning")]
153    StatusMismatch,
154    #[error("external lifecycle plan contains duplicate subject `{subject}`")]
155    DuplicateSubject { subject: String },
156}
157
158///
159/// ExternalUpgradeProposalReportError
160///
161#[derive(Debug, Eq, thiserror::Error, PartialEq)]
162pub enum ExternalUpgradeProposalReportError {
163    #[error(
164        "external upgrade proposal report schema version {actual} does not match expected {expected}"
165    )]
166    SchemaVersionMismatch { expected: u32, actual: u32 },
167    #[error("external upgrade proposal report field `{field}` is required")]
168    MissingRequiredField { field: &'static str },
169    #[error("external upgrade proposal report field `{field}` digest is stale")]
170    DigestMismatch { field: &'static str },
171    #[error("external upgrade proposal report field `{field}` does not match lifecycle source")]
172    SourceMismatch { field: &'static str },
173    #[error(
174        "external upgrade proposal report contains proposal for directly controlled row `{subject}`"
175    )]
176    DirectLifecycleProposal { subject: String },
177    #[error("external upgrade proposal report contains duplicate subject `{subject}`")]
178    DuplicateSubject { subject: String },
179}
180
181/// Project the existing deployment truth control classifications into the 0.45
182/// lifecycle-authority view. This is observational and must not mutate IC or
183/// local deployment state.
184#[must_use]
185pub fn lifecycle_authority_report_from_check(
186    report_id: impl Into<String>,
187    check: &DeploymentCheckV1,
188) -> LifecycleAuthorityReportV1 {
189    let mut authorities = Vec::new();
190    let mut seen_subjects = BTreeSet::new();
191
192    for expected in &check.plan.expected_canisters {
193        let observed = observed_canister_for_expected(&check.inventory, expected);
194        let authority = lifecycle_authority_for_expected_canister(&check.plan, expected, observed);
195        seen_subjects.insert(authority.subject.clone());
196        authorities.push(authority);
197    }
198
199    for expected in &check.plan.expected_pool {
200        let observed = observed_pool_for_expected(&check.inventory, expected);
201        let authority = lifecycle_authority_for_expected_pool(expected, observed);
202        seen_subjects.insert(authority.subject.clone());
203        authorities.push(authority);
204    }
205
206    for observed in &check.inventory.observed_canisters {
207        let subject = lifecycle_subject(observed.canister_id.as_str(), observed.role.as_deref());
208        if seen_subjects.contains(&subject) {
209            continue;
210        }
211        authorities.push(lifecycle_authority_for_unplanned_canister(observed));
212    }
213
214    for observed in &check.inventory.observed_pool {
215        let subject = lifecycle_subject(observed.canister_id.as_str(), observed.role.as_deref());
216        if seen_subjects.contains(&subject) {
217            continue;
218        }
219        authorities.push(lifecycle_authority_for_unplanned_pool(observed));
220    }
221
222    authorities.sort_by(|left, right| left.subject.cmp(&right.subject));
223    let external_action_required_count = authorities
224        .iter()
225        .filter(|authority| authority.external_action_required)
226        .count();
227    let blocked_count = authorities
228        .iter()
229        .filter(|authority| authority.blocked)
230        .count();
231
232    let mut report = LifecycleAuthorityReportV1 {
233        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
234        report_id: report_id.into(),
235        report_digest: String::new(),
236        check_id: check.check_id.clone(),
237        plan_id: check.plan.plan_id.clone(),
238        inventory_id: check.inventory.inventory_id.clone(),
239        authorities,
240        external_action_required_count,
241        blocked_count,
242    };
243    report.report_digest = lifecycle_authority_report_digest(&report);
244    report
245}
246
247/// Validate archived lifecycle authority report consistency and digests.
248pub fn validate_lifecycle_authority_report(
249    report: &LifecycleAuthorityReportV1,
250) -> Result<(), LifecycleAuthorityReportError> {
251    if report.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
252        return Err(LifecycleAuthorityReportError::SchemaVersionMismatch {
253            expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
254            actual: report.schema_version,
255        });
256    }
257    ensure_lifecycle_authority_report_field("report_id", report.report_id.as_str())?;
258    ensure_lifecycle_authority_report_field("report_digest", report.report_digest.as_str())?;
259    ensure_lifecycle_authority_report_field("check_id", report.check_id.as_str())?;
260    ensure_lifecycle_authority_report_field("plan_id", report.plan_id.as_str())?;
261    ensure_lifecycle_authority_report_field("inventory_id", report.inventory_id.as_str())?;
262    ensure_unique_authority_subjects(&report.authorities)?;
263    if report.external_action_required_count
264        != report
265            .authorities
266            .iter()
267            .filter(|authority| authority.external_action_required)
268            .count()
269        || report.blocked_count
270            != report
271                .authorities
272                .iter()
273                .filter(|authority| authority.blocked)
274                .count()
275    {
276        return Err(LifecycleAuthorityReportError::CountMismatch);
277    }
278    if report.report_digest != lifecycle_authority_report_digest(report) {
279        return Err(LifecycleAuthorityReportError::DigestMismatch {
280            field: "report_digest",
281        });
282    }
283    Ok(())
284}
285
286/// Build the central 0.45 lifecycle plan from deployment truth.
287///
288/// This partitions roles into directly executable, externally proposed, and
289/// blocked lifecycle rows. It is passive and does not perform proposal
290/// delivery, consent, or execution.
291#[must_use]
292pub fn external_lifecycle_plan_from_check(
293    lifecycle_plan_id: impl Into<String>,
294    lifecycle_authority_report_id: impl Into<String>,
295    check: &DeploymentCheckV1,
296) -> ExternalLifecyclePlanV1 {
297    let lifecycle_authority_report =
298        lifecycle_authority_report_from_check(lifecycle_authority_report_id, check);
299    let lifecycle_authority_rows = lifecycle_authority_report.authorities;
300    let directly_executable_role_upgrades = lifecycle_authority_rows
301        .iter()
302        .filter(|authority| {
303            authority.lifecycle_mode == LifecycleModeV1::DirectDeploymentAuthority
304                && !authority.blocked
305        })
306        .map(external_lifecycle_role_upgrade)
307        .collect::<Vec<_>>();
308    let proposed_external_role_upgrades = lifecycle_authority_rows
309        .iter()
310        .filter(|authority| authority.external_action_required && !authority.blocked)
311        .map(external_lifecycle_role_upgrade)
312        .collect::<Vec<_>>();
313    let blocked_role_upgrades = lifecycle_authority_rows
314        .iter()
315        .filter(|authority| authority.blocked)
316        .map(external_lifecycle_role_upgrade)
317        .collect::<Vec<_>>();
318    let residual_exposure = proposed_external_role_upgrades
319        .iter()
320        .map(|upgrade| {
321            format!(
322                "{} remains pending external lifecycle action",
323                upgrade.subject
324            )
325        })
326        .collect::<Vec<_>>();
327    let status = if !blocked_role_upgrades.is_empty() {
328        ExternalLifecyclePlanStatusV1::Blocked
329    } else if !proposed_external_role_upgrades.is_empty() {
330        ExternalLifecyclePlanStatusV1::PendingExternalAction
331    } else {
332        ExternalLifecyclePlanStatusV1::Ready
333    };
334    let deployment_plan_digest = stable_json_sha256_hex(&check.plan);
335    let mut plan = ExternalLifecyclePlanV1 {
336        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
337        lifecycle_plan_id: lifecycle_plan_id.into(),
338        lifecycle_plan_digest: String::new(),
339        lifecycle_authority_report_id: lifecycle_authority_report.report_id,
340        deployment_plan_id: check.plan.plan_id.clone(),
341        deployment_plan_digest,
342        inventory_id: check.inventory.inventory_id.clone(),
343        lifecycle_authority_rows,
344        directly_executable_role_upgrades,
345        proposed_external_role_upgrades,
346        blocked_role_upgrades,
347        dependency_blockers: Vec::new(),
348        protected_call_implications: protected_call_implications_for_check(check),
349        residual_exposure,
350        status,
351    };
352    plan.lifecycle_plan_digest = external_lifecycle_plan_digest(&plan);
353    plan
354}
355
356/// Validate archived external lifecycle plan consistency and digests.
357pub fn validate_external_lifecycle_plan(
358    plan: &ExternalLifecyclePlanV1,
359) -> Result<(), ExternalLifecyclePlanError> {
360    if plan.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
361        return Err(ExternalLifecyclePlanError::SchemaVersionMismatch {
362            expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
363            actual: plan.schema_version,
364        });
365    }
366    ensure_external_lifecycle_plan_field("lifecycle_plan_id", plan.lifecycle_plan_id.as_str())?;
367    ensure_external_lifecycle_plan_field(
368        "lifecycle_authority_report_id",
369        plan.lifecycle_authority_report_id.as_str(),
370    )?;
371    ensure_external_lifecycle_plan_field("deployment_plan_id", plan.deployment_plan_id.as_str())?;
372    ensure_external_lifecycle_plan_field("inventory_id", plan.inventory_id.as_str())?;
373    if plan.lifecycle_plan_digest != external_lifecycle_plan_digest(plan) {
374        return Err(ExternalLifecyclePlanError::DigestMismatch {
375            field: "lifecycle_plan_digest",
376        });
377    }
378    if plan.status != expected_lifecycle_plan_status(plan) {
379        return Err(ExternalLifecyclePlanError::StatusMismatch);
380    }
381    ensure_unique_lifecycle_subjects(&plan.lifecycle_authority_rows)?;
382    ensure_unique_role_upgrade_subjects(&plan.directly_executable_role_upgrades)?;
383    ensure_unique_role_upgrade_subjects(&plan.proposed_external_role_upgrades)?;
384    ensure_unique_role_upgrade_subjects(&plan.blocked_role_upgrades)?;
385    Ok(())
386}
387
388/// Validate that an archived external lifecycle plan still matches its source
389/// deployment truth check.
390pub fn validate_external_lifecycle_plan_for_check(
391    plan: &ExternalLifecyclePlanV1,
392    check: &DeploymentCheckV1,
393) -> Result<(), ExternalLifecyclePlanError> {
394    validate_external_lifecycle_plan(plan)?;
395    let expected = external_lifecycle_plan_from_check(
396        plan.lifecycle_plan_id.clone(),
397        plan.lifecycle_authority_report_id.clone(),
398        check,
399    );
400    if plan != &expected {
401        return Err(ExternalLifecyclePlanError::SourceMismatch {
402            field: "deployment_check",
403        });
404    }
405    Ok(())
406}
407
408/// Build a passive external-upgrade receipt from post-action observation.
409///
410/// The receipt records what an external controller claims or completed. It does
411/// not verify live state by itself and does not grant deployment authority.
412#[must_use]
413pub fn external_upgrade_receipt_from_observation(
414    receipt_id: impl Into<String>,
415    proposal: &ExternalUpgradeProposalV1,
416    consent_state: ExternalUpgradeConsentStateV1,
417    reported_by: Option<String>,
418    observed_after: Option<&ObservedCanisterV1>,
419) -> ExternalUpgradeReceiptV1 {
420    let observed_after_module_hash =
421        observed_after.and_then(|observed| observed.module_hash.clone());
422    let observed_after_canonical_embedded_config_sha256 =
423        observed_after.and_then(|observed| observed.canonical_embedded_config_digest.clone());
424    let verification_result = external_upgrade_verification_result(
425        consent_state,
426        proposal,
427        observed_after_module_hash.as_deref(),
428        observed_after_canonical_embedded_config_sha256.as_deref(),
429    );
430    let verification_notes = external_upgrade_verification_notes(
431        verification_result,
432        proposal,
433        observed_after_module_hash.as_deref(),
434        observed_after_canonical_embedded_config_sha256.as_deref(),
435    );
436
437    let mut receipt = ExternalUpgradeReceiptV1 {
438        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
439        receipt_id: receipt_id.into(),
440        proposal_id: proposal.proposal_id.clone(),
441        proposal_digest: proposal.proposal_digest.clone(),
442        subject: proposal.subject.clone(),
443        canister_id: proposal.canister_id.clone(),
444        role: proposal.role.clone(),
445        consent_state,
446        reported_by,
447        observed_before_module_hash: proposal.current_module_hash.clone(),
448        observed_after_module_hash,
449        observed_after_canonical_embedded_config_sha256,
450        verification_result,
451        verification_notes,
452        receipt_digest: String::new(),
453    };
454    receipt.receipt_digest = external_upgrade_receipt_digest(&receipt);
455    receipt
456}
457
458/// Validate the internal consistency of an external-upgrade receipt.
459///
460/// This is structural validation only. Live inventory remains the source of
461/// truth for whether the external upgrade actually completed.
462pub fn validate_external_upgrade_receipt(
463    receipt: &ExternalUpgradeReceiptV1,
464) -> Result<(), ExternalUpgradeReceiptError> {
465    if receipt.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
466        return Err(ExternalUpgradeReceiptError::SchemaVersionMismatch {
467            expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
468            actual: receipt.schema_version,
469        });
470    }
471    ensure_external_receipt_field("receipt_id", receipt.receipt_id.as_str())?;
472    ensure_external_receipt_field("proposal_id", receipt.proposal_id.as_str())?;
473    ensure_external_receipt_field("proposal_digest", receipt.proposal_digest.as_str())?;
474    ensure_external_receipt_field("subject", receipt.subject.as_str())?;
475    ensure_external_receipt_field("receipt_digest", receipt.receipt_digest.as_str())?;
476
477    if receipt.consent_state == ExternalUpgradeConsentStateV1::Refused
478        && receipt.verification_result == ExternalUpgradeVerificationResultV1::Verified
479    {
480        return Err(ExternalUpgradeReceiptError::RefusedConsentVerified);
481    }
482    let has_observation = receipt.observed_after_module_hash.is_some()
483        || receipt
484            .observed_after_canonical_embedded_config_sha256
485            .is_some();
486    if matches!(
487        receipt.verification_result,
488        ExternalUpgradeVerificationResultV1::Verified
489            | ExternalUpgradeVerificationResultV1::Mismatch
490    ) && !has_observation
491    {
492        return Err(ExternalUpgradeReceiptError::VerificationMismatch);
493    }
494    if receipt.receipt_digest != external_upgrade_receipt_digest(receipt) {
495        return Err(ExternalUpgradeReceiptError::DigestMismatch {
496            field: "receipt_digest",
497        });
498    }
499    Ok(())
500}
501
502/// Build passive external-upgrade proposal artifacts from a lifecycle plan.
503///
504/// This binds current observations to target artifact facts, but does not
505/// grant consent, execute installs, or verify completion.
506#[must_use]
507pub fn external_upgrade_proposal_report_from_lifecycle_plan(
508    report_id: impl Into<String>,
509    lifecycle_plan: &ExternalLifecyclePlanV1,
510    check: &DeploymentCheckV1,
511) -> ExternalUpgradeProposalReportV1 {
512    let report_id = report_id.into();
513    let mut proposals = Vec::new();
514    for authority in lifecycle_plan
515        .lifecycle_authority_rows
516        .iter()
517        .filter(|authority| authority.external_action_required && !authority.blocked)
518    {
519        proposals.push(external_upgrade_proposal(
520            &report_id,
521            lifecycle_plan,
522            check,
523            authority,
524            observed_canister_for_authority(&check.inventory, authority),
525            target_artifact_for_authority(&check.plan, authority),
526        ));
527    }
528
529    proposals.sort_by(|left, right| left.subject.cmp(&right.subject));
530
531    let mut report = ExternalUpgradeProposalReportV1 {
532        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
533        report_id,
534        report_digest: String::new(),
535        lifecycle_plan_id: lifecycle_plan.lifecycle_plan_id.clone(),
536        lifecycle_plan_digest: lifecycle_plan.lifecycle_plan_digest.clone(),
537        deployment_plan_id: lifecycle_plan.deployment_plan_id.clone(),
538        deployment_plan_digest: lifecycle_plan.deployment_plan_digest.clone(),
539        inventory_id: check.inventory.inventory_id.clone(),
540        proposals,
541        blocked_subjects: lifecycle_plan
542            .blocked_role_upgrades
543            .iter()
544            .map(|upgrade| upgrade.subject.clone())
545            .collect(),
546    };
547    report.report_digest = external_upgrade_proposal_report_digest(&report);
548    report
549}
550
551/// Validate archived external-upgrade proposal report consistency and digests.
552pub fn validate_external_upgrade_proposal_report(
553    report: &ExternalUpgradeProposalReportV1,
554) -> Result<(), ExternalUpgradeProposalReportError> {
555    if report.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
556        return Err(ExternalUpgradeProposalReportError::SchemaVersionMismatch {
557            expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
558            actual: report.schema_version,
559        });
560    }
561    ensure_external_proposal_report_field("report_id", report.report_id.as_str())?;
562    ensure_external_proposal_report_field("report_digest", report.report_digest.as_str())?;
563    ensure_external_proposal_report_field("lifecycle_plan_id", report.lifecycle_plan_id.as_str())?;
564    ensure_external_proposal_report_field(
565        "lifecycle_plan_digest",
566        report.lifecycle_plan_digest.as_str(),
567    )?;
568    ensure_external_proposal_report_field(
569        "deployment_plan_id",
570        report.deployment_plan_id.as_str(),
571    )?;
572    ensure_external_proposal_report_field(
573        "deployment_plan_digest",
574        report.deployment_plan_digest.as_str(),
575    )?;
576    ensure_external_proposal_report_field("inventory_id", report.inventory_id.as_str())?;
577
578    let mut subjects = BTreeSet::new();
579    for proposal in &report.proposals {
580        if !subjects.insert(proposal.subject.clone()) {
581            return Err(ExternalUpgradeProposalReportError::DuplicateSubject {
582                subject: proposal.subject.clone(),
583            });
584        }
585        validate_external_upgrade_proposal(proposal)?;
586    }
587    if report.report_digest != external_upgrade_proposal_report_digest(report) {
588        return Err(ExternalUpgradeProposalReportError::DigestMismatch {
589            field: "report_digest",
590        });
591    }
592    Ok(())
593}
594
595/// Validate that an archived external-upgrade proposal report still matches
596/// the lifecycle plan and deployment truth check it claims to derive from.
597pub fn validate_external_upgrade_proposal_report_for_lifecycle_plan(
598    report: &ExternalUpgradeProposalReportV1,
599    lifecycle_plan: &ExternalLifecyclePlanV1,
600    check: &DeploymentCheckV1,
601) -> Result<(), ExternalUpgradeProposalReportError> {
602    validate_external_upgrade_proposal_report(report)?;
603    if report.lifecycle_plan_id != lifecycle_plan.lifecycle_plan_id {
604        return Err(ExternalUpgradeProposalReportError::SourceMismatch {
605            field: "lifecycle_plan_id",
606        });
607    }
608    if report.lifecycle_plan_digest != lifecycle_plan.lifecycle_plan_digest {
609        return Err(ExternalUpgradeProposalReportError::SourceMismatch {
610            field: "lifecycle_plan_digest",
611        });
612    }
613    let expected = external_upgrade_proposal_report_from_lifecycle_plan(
614        report.report_id.clone(),
615        lifecycle_plan,
616        check,
617    );
618    if report != &expected {
619        return Err(ExternalUpgradeProposalReportError::SourceMismatch {
620            field: "deployment_check",
621        });
622    }
623    Ok(())
624}
625
626fn external_upgrade_proposal(
627    report_id: &str,
628    lifecycle_plan: &ExternalLifecyclePlanV1,
629    check: &DeploymentCheckV1,
630    authority: &LifecycleAuthorityV1,
631    observed: Option<&ObservedCanisterV1>,
632    target_artifact: Option<&RoleArtifactV1>,
633) -> ExternalUpgradeProposalV1 {
634    let current_module_hash = observed.and_then(|observed| observed.module_hash.clone());
635    let current_canonical_embedded_config_sha256 =
636        observed.and_then(|observed| observed.canonical_embedded_config_digest.clone());
637    let mut proposal = ExternalUpgradeProposalV1 {
638        proposal_id: external_upgrade_proposal_id(report_id, authority.subject.as_str()),
639        proposal_digest: String::new(),
640        deployment_plan_id: lifecycle_plan.deployment_plan_id.clone(),
641        deployment_plan_digest: lifecycle_plan.deployment_plan_digest.clone(),
642        lifecycle_plan_id: lifecycle_plan.lifecycle_plan_id.clone(),
643        lifecycle_plan_digest: lifecycle_plan.lifecycle_plan_digest.clone(),
644        promotion_plan_id: None,
645        promotion_plan_digest: None,
646        promotion_provenance_id: None,
647        promotion_provenance_digest: None,
648        subject: authority.subject.clone(),
649        canister_id: authority.canister_id.clone(),
650        role: authority.role.clone(),
651        control_class: authority.control_class,
652        lifecycle_mode: authority.lifecycle_mode,
653        observed_before_digest: observed_before_digest(
654            authority,
655            current_module_hash.as_ref(),
656            current_canonical_embedded_config_sha256.as_ref(),
657        ),
658        current_module_hash,
659        current_canonical_embedded_config_sha256,
660        target_wasm_sha256: target_artifact.and_then(|artifact| artifact.wasm_sha256.clone()),
661        target_wasm_gz_sha256: target_artifact.and_then(|artifact| artifact.wasm_gz_sha256.clone()),
662        target_installed_module_hash: target_artifact
663            .and_then(|artifact| artifact.installed_module_hash.clone()),
664        target_role_artifact_identity: target_artifact.map(role_artifact_identity),
665        target_canonical_embedded_config_sha256: target_artifact
666            .and_then(|artifact| artifact.canonical_embedded_config_sha256.clone()),
667        root_trust_anchor: check.plan.trust_domain.root_trust_anchor.clone(),
668        authority_profile_hash: check
669            .plan
670            .deployment_identity
671            .authority_profile_hash
672            .clone(),
673        required_external_action: required_external_action(authority.lifecycle_mode).to_string(),
674        consent_requirements: authority.consent_requirements.clone(),
675        allowed_authorization_modes: external_upgrade_authorization_modes(authority.control_class),
676        verification_requirements: authority.verification_requirements.clone(),
677        expires_at: None,
678        supersedes_proposal_id: None,
679    };
680    proposal.proposal_digest = external_upgrade_proposal_digest(&proposal);
681    proposal
682}
683
684fn validate_external_upgrade_proposal(
685    proposal: &ExternalUpgradeProposalV1,
686) -> Result<(), ExternalUpgradeProposalReportError> {
687    ensure_external_proposal_report_field("proposal_id", proposal.proposal_id.as_str())?;
688    ensure_external_proposal_report_field("proposal_digest", proposal.proposal_digest.as_str())?;
689    ensure_external_proposal_report_field(
690        "proposal.deployment_plan_id",
691        proposal.deployment_plan_id.as_str(),
692    )?;
693    ensure_external_proposal_report_field(
694        "proposal.deployment_plan_digest",
695        proposal.deployment_plan_digest.as_str(),
696    )?;
697    ensure_external_proposal_report_field(
698        "proposal.lifecycle_plan_id",
699        proposal.lifecycle_plan_id.as_str(),
700    )?;
701    ensure_external_proposal_report_field(
702        "proposal.lifecycle_plan_digest",
703        proposal.lifecycle_plan_digest.as_str(),
704    )?;
705    ensure_external_proposal_report_field(
706        "proposal.observed_before_digest",
707        proposal.observed_before_digest.as_str(),
708    )?;
709    ensure_external_proposal_report_field("proposal.subject", proposal.subject.as_str())?;
710    if proposal.lifecycle_mode == LifecycleModeV1::DirectDeploymentAuthority {
711        return Err(
712            ExternalUpgradeProposalReportError::DirectLifecycleProposal {
713                subject: proposal.subject.clone(),
714            },
715        );
716    }
717    if proposal.proposal_digest != external_upgrade_proposal_digest(proposal) {
718        return Err(ExternalUpgradeProposalReportError::DigestMismatch {
719            field: "proposal_digest",
720        });
721    }
722    Ok(())
723}
724
725fn lifecycle_authority_for_expected_canister(
726    plan: &DeploymentPlanV1,
727    expected: &ExpectedCanisterV1,
728    observed: Option<&ObservedCanisterV1>,
729) -> LifecycleAuthorityV1 {
730    let canister_id = expected
731        .canister_id
732        .clone()
733        .or_else(|| observed.map(|observed| observed.canister_id.clone()));
734    let role = Some(expected.role.clone());
735    let control_class = observed.map_or(expected.control_class, |observed| observed.control_class);
736    let observed_controllers =
737        observed.map_or_else(Vec::new, |observed| observed.controllers.clone());
738    lifecycle_authority(
739        lifecycle_subject_for_parts(canister_id.as_deref(), role.as_deref()),
740        canister_id,
741        role,
742        control_class,
743        observed_controllers,
744        &plan.authority_profile.expected_controllers,
745        plan.expected_verifier_readiness.required,
746    )
747}
748
749fn lifecycle_authority_for_expected_pool(
750    expected: &ExpectedPoolCanisterV1,
751    observed: Option<&ObservedPoolCanisterV1>,
752) -> LifecycleAuthorityV1 {
753    let canister_id = expected
754        .canister_id
755        .clone()
756        .or_else(|| observed.map(|observed| observed.canister_id.clone()));
757    let role = expected
758        .role
759        .clone()
760        .or_else(|| observed.and_then(|observed| observed.role.clone()));
761    let control_class = observed.map_or(CanisterControlClassV1::CanicManagedPool, |observed| {
762        observed.control_class
763    });
764    lifecycle_authority(
765        lifecycle_subject_for_parts(canister_id.as_deref(), role.as_deref()),
766        canister_id,
767        role,
768        control_class,
769        Vec::new(),
770        &[],
771        false,
772    )
773}
774
775fn lifecycle_authority_for_unplanned_canister(
776    observed: &ObservedCanisterV1,
777) -> LifecycleAuthorityV1 {
778    lifecycle_authority(
779        lifecycle_subject(observed.canister_id.as_str(), observed.role.as_deref()),
780        Some(observed.canister_id.clone()),
781        observed.role.clone(),
782        observed.control_class,
783        observed.controllers.clone(),
784        &[],
785        false,
786    )
787}
788
789fn lifecycle_authority_for_unplanned_pool(
790    observed: &ObservedPoolCanisterV1,
791) -> LifecycleAuthorityV1 {
792    lifecycle_authority(
793        lifecycle_subject(observed.canister_id.as_str(), observed.role.as_deref()),
794        Some(observed.canister_id.clone()),
795        observed.role.clone(),
796        observed.control_class,
797        Vec::new(),
798        &[],
799        false,
800    )
801}
802
803fn lifecycle_authority(
804    subject: String,
805    canister_id: Option<String>,
806    role: Option<String>,
807    control_class: CanisterControlClassV1,
808    observed_controllers: Vec<String>,
809    expected_controllers: &[String],
810    verifier_required: bool,
811) -> LifecycleAuthorityV1 {
812    let required_controllers = required_lifecycle_controllers(control_class, expected_controllers);
813    let external_controllers =
814        external_lifecycle_controllers(control_class, &observed_controllers, &required_controllers);
815    let consent_requirements = lifecycle_consent_requirements(control_class, &external_controllers);
816    let allowed_upgrade_modes = lifecycle_upgrade_modes(control_class);
817    let verification_requirements = lifecycle_verification_requirements(verifier_required);
818    let external_action_required = lifecycle_external_action_required(control_class);
819    let blocked = control_class == CanisterControlClassV1::UnknownUnsafe;
820    let lifecycle_mode = lifecycle_mode(control_class);
821    let blockers = lifecycle_blockers(control_class);
822    let warnings = lifecycle_warnings(control_class);
823    let reason = lifecycle_reason(control_class);
824    LifecycleAuthorityV1 {
825        subject,
826        canister_id,
827        role,
828        control_class,
829        lifecycle_mode,
830        observed_controllers,
831        expected_deployment_controllers: sorted_unique(expected_controllers.to_vec()),
832        external_controllers,
833        required_controllers,
834        consent_requirements,
835        allowed_upgrade_modes,
836        verification_requirements,
837        external_action_required,
838        blocked,
839        blockers,
840        warnings,
841        reason,
842    }
843}
844
845fn required_lifecycle_controllers(
846    control_class: CanisterControlClassV1,
847    expected_controllers: &[String],
848) -> Vec<String> {
849    match control_class {
850        CanisterControlClassV1::DeploymentControlled
851        | CanisterControlClassV1::JointlyControlled => sorted_unique(expected_controllers.to_vec()),
852        CanisterControlClassV1::CanicManagedPool
853        | CanisterControlClassV1::ExternallyImported
854        | CanisterControlClassV1::UserControlled
855        | CanisterControlClassV1::UnknownUnsafe => Vec::new(),
856    }
857}
858
859fn external_lifecycle_controllers(
860    control_class: CanisterControlClassV1,
861    observed_controllers: &[String],
862    required_controllers: &[String],
863) -> Vec<String> {
864    match control_class {
865        CanisterControlClassV1::DeploymentControlled | CanisterControlClassV1::UnknownUnsafe => {
866            Vec::new()
867        }
868        CanisterControlClassV1::JointlyControlled => {
869            let required = required_controllers.iter().collect::<BTreeSet<_>>();
870            sorted_unique(
871                observed_controllers
872                    .iter()
873                    .filter(|controller| !required.contains(controller))
874                    .cloned()
875                    .collect(),
876            )
877        }
878        CanisterControlClassV1::CanicManagedPool
879        | CanisterControlClassV1::ExternallyImported
880        | CanisterControlClassV1::UserControlled => sorted_unique(observed_controllers.to_vec()),
881    }
882}
883
884fn lifecycle_consent_requirements(
885    control_class: CanisterControlClassV1,
886    external_controllers: &[String],
887) -> Vec<ConsentRequirementV1> {
888    if !lifecycle_external_action_required(control_class) {
889        return Vec::new();
890    }
891    vec![ConsentRequirementV1 {
892        consent_subject_kind: consent_subject_kind(control_class),
893        required_principals: sorted_unique(external_controllers.to_vec()),
894        required_controller_set_digest: Some(stable_json_sha256_hex(&external_controllers)),
895        consent_channel_kind: consent_channel_kind(control_class),
896        required_action: required_consent_action(control_class),
897    }]
898}
899
900const fn consent_subject_kind(control_class: CanisterControlClassV1) -> ConsentSubjectKindV1 {
901    match control_class {
902        CanisterControlClassV1::CanicManagedPool => ConsentSubjectKindV1::ProjectHub,
903        CanisterControlClassV1::ExternallyImported | CanisterControlClassV1::JointlyControlled => {
904            ConsentSubjectKindV1::CustomerController
905        }
906        CanisterControlClassV1::UserControlled => ConsentSubjectKindV1::UserPrincipal,
907        CanisterControlClassV1::DeploymentControlled | CanisterControlClassV1::UnknownUnsafe => {
908            ConsentSubjectKindV1::UnknownExternalController
909        }
910    }
911}
912
913const fn consent_channel_kind(control_class: CanisterControlClassV1) -> ConsentChannelKindV1 {
914    match control_class {
915        CanisterControlClassV1::CanicManagedPool => ConsentChannelKindV1::DelegatedInstall,
916        CanisterControlClassV1::ExternallyImported
917        | CanisterControlClassV1::JointlyControlled
918        | CanisterControlClassV1::UserControlled => ConsentChannelKindV1::GeneratedCommand,
919        CanisterControlClassV1::DeploymentControlled | CanisterControlClassV1::UnknownUnsafe => {
920            ConsentChannelKindV1::OutOfBand
921        }
922    }
923}
924
925const fn required_consent_action(
926    control_class: CanisterControlClassV1,
927) -> ExternalUpgradeAuthorizationModeV1 {
928    match control_class {
929        CanisterControlClassV1::JointlyControlled => {
930            ExternalUpgradeAuthorizationModeV1::ConsentForDirectInstall
931        }
932        CanisterControlClassV1::CanicManagedPool => {
933            ExternalUpgradeAuthorizationModeV1::DelegatedInstallAuthority
934        }
935        CanisterControlClassV1::ExternallyImported | CanisterControlClassV1::UserControlled => {
936            ExternalUpgradeAuthorizationModeV1::ExternalControllerExecution
937        }
938        CanisterControlClassV1::DeploymentControlled | CanisterControlClassV1::UnknownUnsafe => {
939            ExternalUpgradeAuthorizationModeV1::ObserveAndVerifyOnly
940        }
941    }
942}
943
944const fn lifecycle_mode(control_class: CanisterControlClassV1) -> LifecycleModeV1 {
945    match control_class {
946        CanisterControlClassV1::DeploymentControlled => LifecycleModeV1::DirectDeploymentAuthority,
947        CanisterControlClassV1::CanicManagedPool => LifecycleModeV1::DelegatedInstallRequired,
948        CanisterControlClassV1::ExternallyImported | CanisterControlClassV1::UserControlled => {
949            LifecycleModeV1::ExternalCompletionOnly
950        }
951        CanisterControlClassV1::JointlyControlled => LifecycleModeV1::ProposalRequired,
952        CanisterControlClassV1::UnknownUnsafe => LifecycleModeV1::UnknownUnsafeBlocked,
953    }
954}
955
956fn lifecycle_blockers(control_class: CanisterControlClassV1) -> Vec<String> {
957    if control_class == CanisterControlClassV1::UnknownUnsafe {
958        vec!["unknown unsafe controller state blocks lifecycle action".to_string()]
959    } else {
960        Vec::new()
961    }
962}
963
964fn lifecycle_warnings(control_class: CanisterControlClassV1) -> Vec<String> {
965    match control_class {
966        CanisterControlClassV1::CanicManagedPool => {
967            vec!["pool-aware lifecycle policy is required before mutation".to_string()]
968        }
969        CanisterControlClassV1::ExternallyImported => {
970            vec!["external controller action or verification is required".to_string()]
971        }
972        CanisterControlClassV1::JointlyControlled => {
973            vec!["joint controller consent or delegation is required".to_string()]
974        }
975        CanisterControlClassV1::UserControlled => {
976            vec!["user or delegated lifecycle action is required".to_string()]
977        }
978        CanisterControlClassV1::DeploymentControlled | CanisterControlClassV1::UnknownUnsafe => {
979            Vec::new()
980        }
981    }
982}
983
984fn lifecycle_upgrade_modes(control_class: CanisterControlClassV1) -> Vec<LifecycleUpgradeModeV1> {
985    match control_class {
986        CanisterControlClassV1::DeploymentControlled => vec![
987            LifecycleUpgradeModeV1::DirectByDeploymentAuthority,
988            LifecycleUpgradeModeV1::VerifyExternalCompletion,
989        ],
990        CanisterControlClassV1::CanicManagedPool
991        | CanisterControlClassV1::ExternallyImported
992        | CanisterControlClassV1::JointlyControlled
993        | CanisterControlClassV1::UserControlled => vec![
994            LifecycleUpgradeModeV1::ExternalProposal,
995            LifecycleUpgradeModeV1::ExternalExecution,
996            LifecycleUpgradeModeV1::VerifyExternalCompletion,
997            LifecycleUpgradeModeV1::ObserveOnly,
998        ],
999        CanisterControlClassV1::UnknownUnsafe => vec![LifecycleUpgradeModeV1::Blocked],
1000    }
1001}
1002
1003fn lifecycle_verification_requirements(
1004    verifier_required: bool,
1005) -> Vec<LifecycleVerificationRequirementV1> {
1006    let mut requirements = vec![
1007        LifecycleVerificationRequirementV1::LiveInventory,
1008        LifecycleVerificationRequirementV1::ControllerObservation,
1009        LifecycleVerificationRequirementV1::ModuleHash,
1010        LifecycleVerificationRequirementV1::CanonicalEmbeddedConfig,
1011    ];
1012    if verifier_required {
1013        requirements.push(LifecycleVerificationRequirementV1::ProtectedCallReadiness);
1014    }
1015    requirements
1016}
1017
1018const fn lifecycle_external_action_required(control_class: CanisterControlClassV1) -> bool {
1019    matches!(
1020        control_class,
1021        CanisterControlClassV1::CanicManagedPool
1022            | CanisterControlClassV1::ExternallyImported
1023            | CanisterControlClassV1::JointlyControlled
1024            | CanisterControlClassV1::UserControlled
1025    )
1026}
1027
1028fn lifecycle_reason(control_class: CanisterControlClassV1) -> String {
1029    match control_class {
1030        CanisterControlClassV1::DeploymentControlled => {
1031            "deployment authority can execute lifecycle directly".to_string()
1032        }
1033        CanisterControlClassV1::CanicManagedPool => {
1034            "Canic-managed pool lifecycle requires pool-aware external action".to_string()
1035        }
1036        CanisterControlClassV1::ExternallyImported => {
1037            "externally imported canister requires external controller action".to_string()
1038        }
1039        CanisterControlClassV1::JointlyControlled => {
1040            "jointly controlled canister requires non-deployment-controller consent".to_string()
1041        }
1042        CanisterControlClassV1::UserControlled => {
1043            "user-controlled canister requires user or delegated lifecycle action".to_string()
1044        }
1045        CanisterControlClassV1::UnknownUnsafe => {
1046            "unknown or unsafe controller state blocks lifecycle action".to_string()
1047        }
1048    }
1049}
1050
1051fn observed_canister_for_expected<'a>(
1052    inventory: &'a DeploymentInventoryV1,
1053    expected: &ExpectedCanisterV1,
1054) -> Option<&'a ObservedCanisterV1> {
1055    if let Some(canister_id) = &expected.canister_id
1056        && let Some(observed) = inventory
1057            .observed_canisters
1058            .iter()
1059            .find(|observed| &observed.canister_id == canister_id)
1060    {
1061        return Some(observed);
1062    }
1063    inventory
1064        .observed_canisters
1065        .iter()
1066        .find(|observed| observed.role.as_deref() == Some(expected.role.as_str()))
1067}
1068
1069fn observed_pool_for_expected<'a>(
1070    inventory: &'a DeploymentInventoryV1,
1071    expected: &ExpectedPoolCanisterV1,
1072) -> Option<&'a ObservedPoolCanisterV1> {
1073    if let Some(canister_id) = &expected.canister_id
1074        && let Some(observed) = inventory
1075            .observed_pool
1076            .iter()
1077            .find(|observed| &observed.canister_id == canister_id)
1078    {
1079        return Some(observed);
1080    }
1081    inventory.observed_pool.iter().find(|observed| {
1082        observed.pool == expected.pool && observed.role.as_deref() == expected.role.as_deref()
1083    })
1084}
1085
1086fn lifecycle_subject(canister_id: &str, role: Option<&str>) -> String {
1087    lifecycle_subject_for_parts(Some(canister_id), role)
1088}
1089
1090fn lifecycle_subject_for_parts(canister_id: Option<&str>, role: Option<&str>) -> String {
1091    match (role, canister_id) {
1092        (Some(role), Some(canister_id)) => format!("{role}:{canister_id}"),
1093        (Some(role), None) => format!("{role}:unassigned"),
1094        (None, Some(canister_id)) => canister_id.to_string(),
1095        (None, None) => "unknown".to_string(),
1096    }
1097}
1098
1099fn observed_canister_for_authority<'a>(
1100    inventory: &'a DeploymentInventoryV1,
1101    authority: &LifecycleAuthorityV1,
1102) -> Option<&'a ObservedCanisterV1> {
1103    if let Some(canister_id) = &authority.canister_id
1104        && let Some(observed) = inventory
1105            .observed_canisters
1106            .iter()
1107            .find(|observed| &observed.canister_id == canister_id)
1108    {
1109        return Some(observed);
1110    }
1111    inventory
1112        .observed_canisters
1113        .iter()
1114        .find(|observed| observed.role == authority.role)
1115}
1116
1117fn target_artifact_for_authority<'a>(
1118    plan: &'a DeploymentPlanV1,
1119    authority: &LifecycleAuthorityV1,
1120) -> Option<&'a RoleArtifactV1> {
1121    let role = authority.role.as_ref()?;
1122    plan.role_artifacts
1123        .iter()
1124        .find(|artifact| &artifact.role == role)
1125}
1126
1127fn external_lifecycle_role_upgrade(
1128    authority: &LifecycleAuthorityV1,
1129) -> ExternalLifecycleRoleUpgradeV1 {
1130    ExternalLifecycleRoleUpgradeV1 {
1131        subject: authority.subject.clone(),
1132        canister_id: authority.canister_id.clone(),
1133        role: authority.role.clone(),
1134        control_class: authority.control_class,
1135        lifecycle_mode: authority.lifecycle_mode,
1136        required_external_action: authority
1137            .external_action_required
1138            .then(|| required_external_action(authority.lifecycle_mode).to_string()),
1139        blockers: authority.blockers.clone(),
1140        warnings: authority.warnings.clone(),
1141    }
1142}
1143
1144fn protected_call_implications_for_check(check: &DeploymentCheckV1) -> Vec<String> {
1145    if check.plan.expected_verifier_readiness.required {
1146        vec!["protected-call verifier readiness must be checked before completion".to_string()]
1147    } else {
1148        Vec::new()
1149    }
1150}
1151
1152const fn required_external_action(lifecycle_mode: LifecycleModeV1) -> &'static str {
1153    match lifecycle_mode {
1154        LifecycleModeV1::DirectDeploymentAuthority => "none",
1155        LifecycleModeV1::ProposalRequired => "proposal_and_consent",
1156        LifecycleModeV1::DelegatedInstallRequired => "delegated_install_or_pool_policy",
1157        LifecycleModeV1::ExternalCompletionOnly => "external_controller_execution",
1158        LifecycleModeV1::VerifyOnly => "verify_external_completion",
1159        LifecycleModeV1::MustNotTouch | LifecycleModeV1::UnknownUnsafeBlocked => "blocked",
1160    }
1161}
1162
1163fn role_artifact_identity(artifact: &RoleArtifactV1) -> String {
1164    stable_json_sha256_hex(&(
1165        artifact.role.as_str(),
1166        artifact.wasm_sha256.as_deref(),
1167        artifact.wasm_gz_sha256.as_deref(),
1168        artifact.installed_module_hash.as_deref(),
1169        artifact.candid_sha256.as_deref(),
1170        artifact.canonical_embedded_config_sha256.as_deref(),
1171    ))
1172}
1173
1174fn external_upgrade_authorization_modes(
1175    control_class: CanisterControlClassV1,
1176) -> Vec<ExternalUpgradeAuthorizationModeV1> {
1177    match control_class {
1178        CanisterControlClassV1::JointlyControlled => vec![
1179            ExternalUpgradeAuthorizationModeV1::ConsentForDirectInstall,
1180            ExternalUpgradeAuthorizationModeV1::DelegatedInstallAuthority,
1181            ExternalUpgradeAuthorizationModeV1::ExternalControllerExecution,
1182            ExternalUpgradeAuthorizationModeV1::ObserveAndVerifyOnly,
1183        ],
1184        CanisterControlClassV1::CanicManagedPool
1185        | CanisterControlClassV1::ExternallyImported
1186        | CanisterControlClassV1::UserControlled => vec![
1187            ExternalUpgradeAuthorizationModeV1::DelegatedInstallAuthority,
1188            ExternalUpgradeAuthorizationModeV1::ExternalControllerExecution,
1189            ExternalUpgradeAuthorizationModeV1::ObserveAndVerifyOnly,
1190        ],
1191        CanisterControlClassV1::DeploymentControlled | CanisterControlClassV1::UnknownUnsafe => {
1192            Vec::new()
1193        }
1194    }
1195}
1196
1197fn external_upgrade_proposal_id(report_id: &str, subject: &str) -> String {
1198    let subject = subject.replace([':', '/'], "-");
1199    format!("{report_id}:{subject}")
1200}
1201
1202fn external_lifecycle_plan_digest(plan: &ExternalLifecyclePlanV1) -> String {
1203    stable_json_sha256_hex(&ExternalLifecyclePlanDigestInput {
1204        lifecycle_authority_report_id: &plan.lifecycle_authority_report_id,
1205        deployment_plan_id: &plan.deployment_plan_id,
1206        deployment_plan_digest: &plan.deployment_plan_digest,
1207        inventory_id: &plan.inventory_id,
1208        lifecycle_authority_rows: &plan.lifecycle_authority_rows,
1209        directly_executable_role_upgrades: &plan.directly_executable_role_upgrades,
1210        proposed_external_role_upgrades: &plan.proposed_external_role_upgrades,
1211        blocked_role_upgrades: &plan.blocked_role_upgrades,
1212        dependency_blockers: &plan.dependency_blockers,
1213        protected_call_implications: &plan.protected_call_implications,
1214        residual_exposure: &plan.residual_exposure,
1215        status: plan.status,
1216    })
1217}
1218
1219fn lifecycle_authority_report_digest(report: &LifecycleAuthorityReportV1) -> String {
1220    stable_json_sha256_hex(&LifecycleAuthorityReportDigestInput {
1221        report_id: &report.report_id,
1222        check_id: &report.check_id,
1223        plan_id: &report.plan_id,
1224        inventory_id: &report.inventory_id,
1225        authorities: &report.authorities,
1226        external_action_required_count: report.external_action_required_count,
1227        blocked_count: report.blocked_count,
1228    })
1229}
1230
1231const fn expected_lifecycle_plan_status(
1232    plan: &ExternalLifecyclePlanV1,
1233) -> ExternalLifecyclePlanStatusV1 {
1234    if !plan.blocked_role_upgrades.is_empty() {
1235        ExternalLifecyclePlanStatusV1::Blocked
1236    } else if !plan.proposed_external_role_upgrades.is_empty() {
1237        ExternalLifecyclePlanStatusV1::PendingExternalAction
1238    } else {
1239        ExternalLifecyclePlanStatusV1::Ready
1240    }
1241}
1242
1243fn ensure_unique_lifecycle_subjects(
1244    rows: &[LifecycleAuthorityV1],
1245) -> Result<(), ExternalLifecyclePlanError> {
1246    let mut subjects = BTreeSet::new();
1247    for row in rows {
1248        if !subjects.insert(row.subject.clone()) {
1249            return Err(ExternalLifecyclePlanError::DuplicateSubject {
1250                subject: row.subject.clone(),
1251            });
1252        }
1253    }
1254    Ok(())
1255}
1256
1257fn ensure_unique_authority_subjects(
1258    rows: &[LifecycleAuthorityV1],
1259) -> Result<(), LifecycleAuthorityReportError> {
1260    let mut subjects = BTreeSet::new();
1261    for row in rows {
1262        if !subjects.insert(row.subject.clone()) {
1263            return Err(LifecycleAuthorityReportError::DuplicateSubject {
1264                subject: row.subject.clone(),
1265            });
1266        }
1267    }
1268    Ok(())
1269}
1270
1271fn ensure_unique_role_upgrade_subjects(
1272    rows: &[ExternalLifecycleRoleUpgradeV1],
1273) -> Result<(), ExternalLifecyclePlanError> {
1274    let mut subjects = BTreeSet::new();
1275    for row in rows {
1276        if !subjects.insert(row.subject.clone()) {
1277            return Err(ExternalLifecyclePlanError::DuplicateSubject {
1278                subject: row.subject.clone(),
1279            });
1280        }
1281    }
1282    Ok(())
1283}
1284
1285fn external_upgrade_proposal_digest(proposal: &ExternalUpgradeProposalV1) -> String {
1286    stable_json_sha256_hex(&ExternalUpgradeProposalDigestInput {
1287        deployment_plan_id: &proposal.deployment_plan_id,
1288        deployment_plan_digest: &proposal.deployment_plan_digest,
1289        lifecycle_plan_id: &proposal.lifecycle_plan_id,
1290        lifecycle_plan_digest: &proposal.lifecycle_plan_digest,
1291        promotion_plan_id: &proposal.promotion_plan_id,
1292        promotion_plan_digest: &proposal.promotion_plan_digest,
1293        promotion_provenance_id: &proposal.promotion_provenance_id,
1294        promotion_provenance_digest: &proposal.promotion_provenance_digest,
1295        subject: &proposal.subject,
1296        canister_id: &proposal.canister_id,
1297        role: &proposal.role,
1298        control_class: proposal.control_class,
1299        lifecycle_mode: proposal.lifecycle_mode,
1300        observed_before_digest: &proposal.observed_before_digest,
1301        current_module_hash: &proposal.current_module_hash,
1302        current_canonical_embedded_config_sha256: &proposal
1303            .current_canonical_embedded_config_sha256,
1304        target_wasm_sha256: &proposal.target_wasm_sha256,
1305        target_wasm_gz_sha256: &proposal.target_wasm_gz_sha256,
1306        target_installed_module_hash: &proposal.target_installed_module_hash,
1307        target_role_artifact_identity: &proposal.target_role_artifact_identity,
1308        target_canonical_embedded_config_sha256: &proposal.target_canonical_embedded_config_sha256,
1309        root_trust_anchor: &proposal.root_trust_anchor,
1310        authority_profile_hash: &proposal.authority_profile_hash,
1311        required_external_action: &proposal.required_external_action,
1312        consent_requirements: &proposal.consent_requirements,
1313        allowed_authorization_modes: &proposal.allowed_authorization_modes,
1314        verification_requirements: &proposal.verification_requirements,
1315        expires_at: &proposal.expires_at,
1316        supersedes_proposal_id: &proposal.supersedes_proposal_id,
1317    })
1318}
1319
1320fn external_upgrade_proposal_report_digest(report: &ExternalUpgradeProposalReportV1) -> String {
1321    stable_json_sha256_hex(&ExternalUpgradeProposalReportDigestInput {
1322        report_id: &report.report_id,
1323        lifecycle_plan_id: &report.lifecycle_plan_id,
1324        lifecycle_plan_digest: &report.lifecycle_plan_digest,
1325        deployment_plan_id: &report.deployment_plan_id,
1326        deployment_plan_digest: &report.deployment_plan_digest,
1327        inventory_id: &report.inventory_id,
1328        proposals: &report.proposals,
1329        blocked_subjects: &report.blocked_subjects,
1330    })
1331}
1332
1333fn external_upgrade_receipt_digest(receipt: &ExternalUpgradeReceiptV1) -> String {
1334    stable_json_sha256_hex(&ExternalUpgradeReceiptDigestInput {
1335        proposal_id: &receipt.proposal_id,
1336        proposal_digest: &receipt.proposal_digest,
1337        subject: &receipt.subject,
1338        canister_id: &receipt.canister_id,
1339        role: &receipt.role,
1340        consent_state: receipt.consent_state,
1341        reported_by: &receipt.reported_by,
1342        observed_before_module_hash: &receipt.observed_before_module_hash,
1343        observed_after_module_hash: &receipt.observed_after_module_hash,
1344        observed_after_canonical_embedded_config_sha256: &receipt
1345            .observed_after_canonical_embedded_config_sha256,
1346        verification_result: receipt.verification_result,
1347        verification_notes: &receipt.verification_notes,
1348    })
1349}
1350
1351fn observed_before_digest(
1352    authority: &LifecycleAuthorityV1,
1353    current_module_hash: Option<&String>,
1354    current_config_hash: Option<&String>,
1355) -> String {
1356    stable_json_sha256_hex(&ObservedBeforeDigestInput {
1357        subject: &authority.subject,
1358        canister_id: &authority.canister_id,
1359        role: &authority.role,
1360        observed_controllers: &authority.observed_controllers,
1361        current_module_hash,
1362        current_canonical_embedded_config_sha256: current_config_hash,
1363    })
1364}
1365
1366fn external_upgrade_verification_result(
1367    consent_state: ExternalUpgradeConsentStateV1,
1368    proposal: &ExternalUpgradeProposalV1,
1369    observed_after_module_hash: Option<&str>,
1370    observed_after_config: Option<&str>,
1371) -> ExternalUpgradeVerificationResultV1 {
1372    match consent_state {
1373        ExternalUpgradeConsentStateV1::Pending => ExternalUpgradeVerificationResultV1::Pending,
1374        ExternalUpgradeConsentStateV1::Refused => ExternalUpgradeVerificationResultV1::Refused,
1375        ExternalUpgradeConsentStateV1::Delegated
1376        | ExternalUpgradeConsentStateV1::ExecutedExternally => {
1377            if external_upgrade_observation_matches(
1378                proposal.target_installed_module_hash.as_deref(),
1379                observed_after_module_hash,
1380            ) && external_upgrade_observation_matches(
1381                proposal.target_canonical_embedded_config_sha256.as_deref(),
1382                observed_after_config,
1383            ) {
1384                ExternalUpgradeVerificationResultV1::Verified
1385            } else {
1386                ExternalUpgradeVerificationResultV1::Mismatch
1387            }
1388        }
1389    }
1390}
1391
1392fn external_upgrade_verification_notes(
1393    verification_result: ExternalUpgradeVerificationResultV1,
1394    proposal: &ExternalUpgradeProposalV1,
1395    observed_after_module_hash: Option<&str>,
1396    observed_after_config: Option<&str>,
1397) -> Vec<String> {
1398    let mut notes = Vec::new();
1399    if verification_result == ExternalUpgradeVerificationResultV1::Mismatch {
1400        if !external_upgrade_observation_matches(
1401            proposal.target_installed_module_hash.as_deref(),
1402            observed_after_module_hash,
1403        ) {
1404            notes.push("observed module hash does not match proposal target".to_string());
1405        }
1406        if !external_upgrade_observation_matches(
1407            proposal.target_canonical_embedded_config_sha256.as_deref(),
1408            observed_after_config,
1409        ) {
1410            notes.push("observed embedded config does not match proposal target".to_string());
1411        }
1412    }
1413    notes
1414}
1415
1416fn external_upgrade_observation_matches(expected: Option<&str>, observed: Option<&str>) -> bool {
1417    expected.is_none_or(|expected| observed == Some(expected))
1418}
1419
1420fn ensure_external_receipt_field(
1421    field: &'static str,
1422    value: &str,
1423) -> Result<(), ExternalUpgradeReceiptError> {
1424    if value.trim().is_empty() {
1425        return Err(ExternalUpgradeReceiptError::MissingRequiredField { field });
1426    }
1427    Ok(())
1428}
1429
1430fn ensure_external_lifecycle_plan_field(
1431    field: &'static str,
1432    value: &str,
1433) -> Result<(), ExternalLifecyclePlanError> {
1434    if value.trim().is_empty() {
1435        return Err(ExternalLifecyclePlanError::MissingRequiredField { field });
1436    }
1437    Ok(())
1438}
1439
1440fn ensure_external_proposal_report_field(
1441    field: &'static str,
1442    value: &str,
1443) -> Result<(), ExternalUpgradeProposalReportError> {
1444    if value.trim().is_empty() {
1445        return Err(ExternalUpgradeProposalReportError::MissingRequiredField { field });
1446    }
1447    Ok(())
1448}
1449
1450fn ensure_lifecycle_authority_report_field(
1451    field: &'static str,
1452    value: &str,
1453) -> Result<(), LifecycleAuthorityReportError> {
1454    if value.trim().is_empty() {
1455        return Err(LifecycleAuthorityReportError::MissingRequiredField { field });
1456    }
1457    Ok(())
1458}
1459
1460fn sorted_unique(values: Vec<String>) -> Vec<String> {
1461    values
1462        .into_iter()
1463        .collect::<BTreeSet<_>>()
1464        .into_iter()
1465        .collect()
1466}