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 ExternalLifecyclePendingReportDigestInput<'a> {
46    report_id: &'a str,
47    lifecycle_plan_id: &'a str,
48    lifecycle_plan_digest: &'a str,
49    proposal_report_id: &'a str,
50    proposal_report_digest: &'a str,
51    deployment_plan_id: &'a str,
52    deployment_plan_digest: &'a str,
53    inventory_id: &'a str,
54    direct_upgrade_count: usize,
55    pending_external_count: usize,
56    blocked_count: usize,
57    pending_external_actions: &'a [ExternalLifecyclePendingActionV1],
58    blocked_subjects: &'a [String],
59    residual_exposure: &'a [String],
60    status: ExternalLifecyclePlanStatusV1,
61}
62
63#[derive(Serialize)]
64struct CriticalExternalFixReportDigestInput<'a> {
65    report_id: &'a str,
66    fix_id: &'a str,
67    severity: &'a str,
68    lifecycle_plan_id: &'a str,
69    lifecycle_plan_digest: &'a str,
70    pending_report_id: &'a str,
71    pending_report_digest: &'a str,
72    deployment_plan_id: &'a str,
73    deployment_plan_digest: &'a str,
74    inventory_id: &'a str,
75    affected_roles: &'a [String],
76    affected_canisters: &'a [String],
77    directly_patchable_roles: &'a [String],
78    externally_blocked_roles: &'a [String],
79    dependency_blocked_roles: &'a [String],
80    required_external_actions: &'a [String],
81    protected_call_implications: &'a [String],
82    residual_exposure: &'a [String],
83    operator_next_steps: &'a [String],
84}
85
86#[derive(Serialize)]
87struct ExternalUpgradeProposalDigestInput<'a> {
88    deployment_plan_id: &'a str,
89    deployment_plan_digest: &'a str,
90    lifecycle_plan_id: &'a str,
91    lifecycle_plan_digest: &'a str,
92    promotion_plan_id: &'a Option<String>,
93    promotion_plan_digest: &'a Option<String>,
94    promotion_provenance_id: &'a Option<String>,
95    promotion_provenance_digest: &'a Option<String>,
96    subject: &'a str,
97    canister_id: &'a Option<String>,
98    role: &'a Option<String>,
99    control_class: CanisterControlClassV1,
100    lifecycle_mode: LifecycleModeV1,
101    observed_before_digest: &'a str,
102    current_module_hash: &'a Option<String>,
103    current_canonical_embedded_config_sha256: &'a Option<String>,
104    target_wasm_sha256: &'a Option<String>,
105    target_wasm_gz_sha256: &'a Option<String>,
106    target_installed_module_hash: &'a Option<String>,
107    target_role_artifact_identity: &'a Option<String>,
108    target_canonical_embedded_config_sha256: &'a Option<String>,
109    root_trust_anchor: &'a Option<String>,
110    authority_profile_hash: &'a Option<String>,
111    required_external_action: &'a str,
112    consent_requirements: &'a [ConsentRequirementV1],
113    allowed_authorization_modes: &'a [ExternalUpgradeAuthorizationModeV1],
114    verification_requirements: &'a [LifecycleVerificationRequirementV1],
115    expires_at: &'a Option<String>,
116    supersedes_proposal_id: &'a Option<String>,
117}
118
119#[derive(Serialize)]
120struct ExternalUpgradeReceiptDigestInput<'a> {
121    proposal_id: &'a str,
122    proposal_digest: &'a str,
123    subject: &'a str,
124    canister_id: &'a Option<String>,
125    role: &'a Option<String>,
126    consent_state: ExternalUpgradeConsentStateV1,
127    reported_by: &'a Option<String>,
128    observed_before_module_hash: &'a Option<String>,
129    observed_after_module_hash: &'a Option<String>,
130    observed_after_canonical_embedded_config_sha256: &'a Option<String>,
131    verification_result: ExternalUpgradeVerificationResultV1,
132    verification_notes: &'a [String],
133}
134
135#[derive(Serialize)]
136struct ExternalUpgradeConsentEvidenceDigestInput<'a> {
137    evidence_id: &'a str,
138    proposal_id: &'a str,
139    proposal_digest: &'a str,
140    receipt_id: &'a str,
141    receipt_digest: &'a str,
142    subject: &'a str,
143    canister_id: &'a Option<String>,
144    role: &'a Option<String>,
145    consent_state: ExternalUpgradeConsentStateV1,
146    reported_by: &'a Option<String>,
147    consent_requirements: &'a [ConsentRequirementV1],
148    allowed_authorization_modes: &'a [ExternalUpgradeAuthorizationModeV1],
149    status_summary: &'a str,
150}
151
152#[derive(Serialize)]
153struct ExternalUpgradeVerificationReportDigestInput<'a> {
154    report_id: &'a str,
155    proposal_id: &'a str,
156    proposal_digest: &'a str,
157    receipt_id: &'a str,
158    receipt_digest: &'a str,
159    subject: &'a str,
160    canister_id: &'a Option<String>,
161    role: &'a Option<String>,
162    verification_result: ExternalUpgradeVerificationResultV1,
163    verification_notes: &'a [String],
164    live_inventory_required: bool,
165    status_summary: &'a str,
166}
167
168#[derive(Serialize)]
169struct ObservedBeforeDigestInput<'a> {
170    subject: &'a str,
171    canister_id: &'a Option<String>,
172    role: &'a Option<String>,
173    observed_controllers: &'a [String],
174    current_module_hash: Option<&'a String>,
175    current_canonical_embedded_config_sha256: Option<&'a String>,
176}
177
178///
179/// ExternalUpgradeReceiptError
180///
181#[derive(Debug, Eq, thiserror::Error, PartialEq)]
182pub enum ExternalUpgradeReceiptError {
183    #[error("external upgrade receipt schema version {actual} does not match expected {expected}")]
184    SchemaVersionMismatch { expected: u32, actual: u32 },
185    #[error("external upgrade receipt field `{field}` is required")]
186    MissingRequiredField { field: &'static str },
187    #[error("external upgrade receipt field `{field}` digest is stale")]
188    DigestMismatch { field: &'static str },
189    #[error("external upgrade receipt field `{field}` does not match proposal source")]
190    SourceMismatch { field: &'static str },
191    #[error("external upgrade receipt verification result does not match observations")]
192    VerificationMismatch,
193    #[error("external upgrade receipt refused consent cannot be verified")]
194    RefusedConsentVerified,
195}
196
197///
198/// ExternalUpgradeConsentEvidenceError
199///
200#[derive(Debug, Eq, thiserror::Error, PartialEq)]
201pub enum ExternalUpgradeConsentEvidenceError {
202    #[error(
203        "external upgrade consent evidence schema version {actual} does not match expected {expected}"
204    )]
205    SchemaVersionMismatch { expected: u32, actual: u32 },
206    #[error("external upgrade consent evidence field `{field}` is required")]
207    MissingRequiredField { field: &'static str },
208    #[error("external upgrade consent evidence field `{field}` digest is stale")]
209    DigestMismatch { field: &'static str },
210    #[error("external upgrade consent evidence field `{field}` no longer matches source receipt")]
211    SourceMismatch { field: &'static str },
212    #[error(transparent)]
213    Receipt(#[from] ExternalUpgradeReceiptError),
214}
215
216///
217/// ExternalUpgradeVerificationReportError
218///
219#[derive(Debug, Eq, thiserror::Error, PartialEq)]
220pub enum ExternalUpgradeVerificationReportError {
221    #[error(
222        "external upgrade verification report schema version {actual} does not match expected {expected}"
223    )]
224    SchemaVersionMismatch { expected: u32, actual: u32 },
225    #[error("external upgrade verification report field `{field}` is required")]
226    MissingRequiredField { field: &'static str },
227    #[error("external upgrade verification report field `{field}` digest is stale")]
228    DigestMismatch { field: &'static str },
229    #[error("external upgrade verification report field `{field}` does not match source evidence")]
230    SourceMismatch { field: &'static str },
231    #[error(transparent)]
232    Receipt(#[from] ExternalUpgradeReceiptError),
233}
234
235///
236/// LifecycleAuthorityReportError
237///
238#[derive(Debug, Eq, thiserror::Error, PartialEq)]
239pub enum LifecycleAuthorityReportError {
240    #[error(
241        "lifecycle authority report schema version {actual} does not match expected {expected}"
242    )]
243    SchemaVersionMismatch { expected: u32, actual: u32 },
244    #[error("lifecycle authority report field `{field}` is required")]
245    MissingRequiredField { field: &'static str },
246    #[error("lifecycle authority report field `{field}` digest is stale")]
247    DigestMismatch { field: &'static str },
248    #[error("lifecycle authority report contains duplicate subject `{subject}`")]
249    DuplicateSubject { subject: String },
250    #[error("lifecycle authority report counters do not match authority rows")]
251    CountMismatch,
252}
253
254///
255/// ExternalLifecyclePlanError
256///
257#[derive(Debug, Eq, thiserror::Error, PartialEq)]
258pub enum ExternalLifecyclePlanError {
259    #[error("external lifecycle plan schema version {actual} does not match expected {expected}")]
260    SchemaVersionMismatch { expected: u32, actual: u32 },
261    #[error("external lifecycle plan field `{field}` is required")]
262    MissingRequiredField { field: &'static str },
263    #[error("external lifecycle plan field `{field}` digest is stale")]
264    DigestMismatch { field: &'static str },
265    #[error("external lifecycle plan field `{field}` does not match deployment truth source")]
266    SourceMismatch { field: &'static str },
267    #[error("external lifecycle plan status does not match role partitioning")]
268    StatusMismatch,
269    #[error("external lifecycle plan contains duplicate subject `{subject}`")]
270    DuplicateSubject { subject: String },
271}
272
273///
274/// ExternalUpgradeProposalReportError
275///
276#[derive(Debug, Eq, thiserror::Error, PartialEq)]
277pub enum ExternalUpgradeProposalReportError {
278    #[error(
279        "external upgrade proposal report schema version {actual} does not match expected {expected}"
280    )]
281    SchemaVersionMismatch { expected: u32, actual: u32 },
282    #[error("external upgrade proposal report field `{field}` is required")]
283    MissingRequiredField { field: &'static str },
284    #[error("external upgrade proposal report field `{field}` digest is stale")]
285    DigestMismatch { field: &'static str },
286    #[error("external upgrade proposal report field `{field}` does not match lifecycle source")]
287    SourceMismatch { field: &'static str },
288    #[error(
289        "external upgrade proposal report contains proposal for directly controlled row `{subject}`"
290    )]
291    DirectLifecycleProposal { subject: String },
292    #[error("external upgrade proposal report contains duplicate subject `{subject}`")]
293    DuplicateSubject { subject: String },
294}
295
296///
297/// ExternalLifecyclePendingReportError
298///
299#[derive(Debug, Eq, thiserror::Error, PartialEq)]
300pub enum ExternalLifecyclePendingReportError {
301    #[error(
302        "external lifecycle pending report schema version {actual} does not match expected {expected}"
303    )]
304    SchemaVersionMismatch { expected: u32, actual: u32 },
305    #[error("external lifecycle pending report field `{field}` is required")]
306    MissingRequiredField { field: &'static str },
307    #[error("external lifecycle pending report field `{field}` digest is stale")]
308    DigestMismatch { field: &'static str },
309    #[error("external lifecycle pending report field `{field}` does not match lifecycle source")]
310    SourceMismatch { field: &'static str },
311    #[error("external lifecycle pending report counters do not match action rows")]
312    CountMismatch,
313    #[error("external lifecycle pending report contains duplicate subject `{subject}`")]
314    DuplicateSubject { subject: String },
315}
316
317///
318/// CriticalExternalFixReportError
319///
320#[derive(Debug, Eq, thiserror::Error, PartialEq)]
321pub enum CriticalExternalFixReportError {
322    #[error(
323        "critical external fix report schema version {actual} does not match expected {expected}"
324    )]
325    SchemaVersionMismatch { expected: u32, actual: u32 },
326    #[error("critical external fix report field `{field}` is required")]
327    MissingRequiredField { field: &'static str },
328    #[error("critical external fix report field `{field}` digest is stale")]
329    DigestMismatch { field: &'static str },
330    #[error("critical external fix report field `{field}` does not match lifecycle source")]
331    SourceMismatch { field: &'static str },
332}
333
334/// Project the existing deployment truth control classifications into the 0.45
335/// lifecycle-authority view. This is observational and must not mutate IC or
336/// local deployment state.
337#[must_use]
338pub fn lifecycle_authority_report_from_check(
339    report_id: impl Into<String>,
340    check: &DeploymentCheckV1,
341) -> LifecycleAuthorityReportV1 {
342    let mut authorities = Vec::new();
343    let mut seen_subjects = BTreeSet::new();
344
345    for expected in &check.plan.expected_canisters {
346        let observed = observed_canister_for_expected(&check.inventory, expected);
347        let authority = lifecycle_authority_for_expected_canister(&check.plan, expected, observed);
348        seen_subjects.insert(authority.subject.clone());
349        authorities.push(authority);
350    }
351
352    for expected in &check.plan.expected_pool {
353        let observed = observed_pool_for_expected(&check.inventory, expected);
354        let authority = lifecycle_authority_for_expected_pool(expected, observed);
355        seen_subjects.insert(authority.subject.clone());
356        authorities.push(authority);
357    }
358
359    for observed in &check.inventory.observed_canisters {
360        let subject = lifecycle_subject(observed.canister_id.as_str(), observed.role.as_deref());
361        if seen_subjects.contains(&subject) {
362            continue;
363        }
364        authorities.push(lifecycle_authority_for_unplanned_canister(observed));
365    }
366
367    for observed in &check.inventory.observed_pool {
368        let subject = lifecycle_subject(observed.canister_id.as_str(), observed.role.as_deref());
369        if seen_subjects.contains(&subject) {
370            continue;
371        }
372        authorities.push(lifecycle_authority_for_unplanned_pool(observed));
373    }
374
375    authorities.sort_by(|left, right| left.subject.cmp(&right.subject));
376    let external_action_required_count = authorities
377        .iter()
378        .filter(|authority| authority.external_action_required)
379        .count();
380    let blocked_count = authorities
381        .iter()
382        .filter(|authority| authority.blocked)
383        .count();
384
385    let mut report = LifecycleAuthorityReportV1 {
386        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
387        report_id: report_id.into(),
388        report_digest: String::new(),
389        check_id: check.check_id.clone(),
390        plan_id: check.plan.plan_id.clone(),
391        inventory_id: check.inventory.inventory_id.clone(),
392        authorities,
393        external_action_required_count,
394        blocked_count,
395    };
396    report.report_digest = lifecycle_authority_report_digest(&report);
397    report
398}
399
400/// Validate archived lifecycle authority report consistency and digests.
401pub fn validate_lifecycle_authority_report(
402    report: &LifecycleAuthorityReportV1,
403) -> Result<(), LifecycleAuthorityReportError> {
404    if report.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
405        return Err(LifecycleAuthorityReportError::SchemaVersionMismatch {
406            expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
407            actual: report.schema_version,
408        });
409    }
410    ensure_lifecycle_authority_report_field("report_id", report.report_id.as_str())?;
411    ensure_lifecycle_authority_report_field("report_digest", report.report_digest.as_str())?;
412    ensure_lifecycle_authority_report_field("check_id", report.check_id.as_str())?;
413    ensure_lifecycle_authority_report_field("plan_id", report.plan_id.as_str())?;
414    ensure_lifecycle_authority_report_field("inventory_id", report.inventory_id.as_str())?;
415    ensure_unique_authority_subjects(&report.authorities)?;
416    if report.external_action_required_count
417        != report
418            .authorities
419            .iter()
420            .filter(|authority| authority.external_action_required)
421            .count()
422        || report.blocked_count
423            != report
424                .authorities
425                .iter()
426                .filter(|authority| authority.blocked)
427                .count()
428    {
429        return Err(LifecycleAuthorityReportError::CountMismatch);
430    }
431    if report.report_digest != lifecycle_authority_report_digest(report) {
432        return Err(LifecycleAuthorityReportError::DigestMismatch {
433            field: "report_digest",
434        });
435    }
436    Ok(())
437}
438
439/// Build the central 0.45 lifecycle plan from deployment truth.
440///
441/// This partitions roles into directly executable, externally proposed, and
442/// blocked lifecycle rows. It is passive and does not perform proposal
443/// delivery, consent, or execution.
444#[must_use]
445pub fn external_lifecycle_plan_from_check(
446    lifecycle_plan_id: impl Into<String>,
447    lifecycle_authority_report_id: impl Into<String>,
448    check: &DeploymentCheckV1,
449) -> ExternalLifecyclePlanV1 {
450    let lifecycle_authority_report =
451        lifecycle_authority_report_from_check(lifecycle_authority_report_id, check);
452    let lifecycle_authority_rows = lifecycle_authority_report.authorities;
453    let directly_executable_role_upgrades = lifecycle_authority_rows
454        .iter()
455        .filter(|authority| {
456            authority.lifecycle_mode == LifecycleModeV1::DirectDeploymentAuthority
457                && !authority.blocked
458        })
459        .map(external_lifecycle_role_upgrade)
460        .collect::<Vec<_>>();
461    let proposed_external_role_upgrades = lifecycle_authority_rows
462        .iter()
463        .filter(|authority| authority.external_action_required && !authority.blocked)
464        .map(external_lifecycle_role_upgrade)
465        .collect::<Vec<_>>();
466    let blocked_role_upgrades = lifecycle_authority_rows
467        .iter()
468        .filter(|authority| authority.blocked)
469        .map(external_lifecycle_role_upgrade)
470        .collect::<Vec<_>>();
471    let residual_exposure = proposed_external_role_upgrades
472        .iter()
473        .map(|upgrade| {
474            format!(
475                "{} remains pending external lifecycle action",
476                upgrade.subject
477            )
478        })
479        .collect::<Vec<_>>();
480    let status = if !blocked_role_upgrades.is_empty() {
481        ExternalLifecyclePlanStatusV1::Blocked
482    } else if !proposed_external_role_upgrades.is_empty() {
483        ExternalLifecyclePlanStatusV1::PendingExternalAction
484    } else {
485        ExternalLifecyclePlanStatusV1::Ready
486    };
487    let deployment_plan_digest = stable_json_sha256_hex(&check.plan);
488    let mut plan = ExternalLifecyclePlanV1 {
489        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
490        lifecycle_plan_id: lifecycle_plan_id.into(),
491        lifecycle_plan_digest: String::new(),
492        lifecycle_authority_report_id: lifecycle_authority_report.report_id,
493        deployment_plan_id: check.plan.plan_id.clone(),
494        deployment_plan_digest,
495        inventory_id: check.inventory.inventory_id.clone(),
496        lifecycle_authority_rows,
497        directly_executable_role_upgrades,
498        proposed_external_role_upgrades,
499        blocked_role_upgrades,
500        dependency_blockers: Vec::new(),
501        protected_call_implications: protected_call_implications_for_check(check),
502        residual_exposure,
503        status,
504    };
505    plan.lifecycle_plan_digest = external_lifecycle_plan_digest(&plan);
506    plan
507}
508
509/// Validate archived external lifecycle plan consistency and digests.
510pub fn validate_external_lifecycle_plan(
511    plan: &ExternalLifecyclePlanV1,
512) -> Result<(), ExternalLifecyclePlanError> {
513    if plan.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
514        return Err(ExternalLifecyclePlanError::SchemaVersionMismatch {
515            expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
516            actual: plan.schema_version,
517        });
518    }
519    ensure_external_lifecycle_plan_field("lifecycle_plan_id", plan.lifecycle_plan_id.as_str())?;
520    ensure_external_lifecycle_plan_field(
521        "lifecycle_authority_report_id",
522        plan.lifecycle_authority_report_id.as_str(),
523    )?;
524    ensure_external_lifecycle_plan_field("deployment_plan_id", plan.deployment_plan_id.as_str())?;
525    ensure_external_lifecycle_plan_field("inventory_id", plan.inventory_id.as_str())?;
526    if plan.lifecycle_plan_digest != external_lifecycle_plan_digest(plan) {
527        return Err(ExternalLifecyclePlanError::DigestMismatch {
528            field: "lifecycle_plan_digest",
529        });
530    }
531    if plan.status != expected_lifecycle_plan_status(plan) {
532        return Err(ExternalLifecyclePlanError::StatusMismatch);
533    }
534    ensure_unique_lifecycle_subjects(&plan.lifecycle_authority_rows)?;
535    ensure_unique_role_upgrade_subjects(&plan.directly_executable_role_upgrades)?;
536    ensure_unique_role_upgrade_subjects(&plan.proposed_external_role_upgrades)?;
537    ensure_unique_role_upgrade_subjects(&plan.blocked_role_upgrades)?;
538    Ok(())
539}
540
541/// Validate that an archived external lifecycle plan still matches its source
542/// deployment truth check.
543pub fn validate_external_lifecycle_plan_for_check(
544    plan: &ExternalLifecyclePlanV1,
545    check: &DeploymentCheckV1,
546) -> Result<(), ExternalLifecyclePlanError> {
547    validate_external_lifecycle_plan(plan)?;
548    let expected = external_lifecycle_plan_from_check(
549        plan.lifecycle_plan_id.clone(),
550        plan.lifecycle_authority_report_id.clone(),
551        check,
552    );
553    if plan != &expected {
554        return Err(ExternalLifecyclePlanError::SourceMismatch {
555            field: "deployment_check",
556        });
557    }
558    Ok(())
559}
560
561/// Build a passive external-upgrade receipt from post-action observation.
562///
563/// The receipt records what an external controller claims or completed. It does
564/// not verify live state by itself and does not grant deployment authority.
565#[must_use]
566pub fn external_upgrade_receipt_from_observation(
567    receipt_id: impl Into<String>,
568    proposal: &ExternalUpgradeProposalV1,
569    consent_state: ExternalUpgradeConsentStateV1,
570    reported_by: Option<String>,
571    observed_after: Option<&ObservedCanisterV1>,
572) -> ExternalUpgradeReceiptV1 {
573    let observed_after_module_hash =
574        observed_after.and_then(|observed| observed.module_hash.clone());
575    let observed_after_canonical_embedded_config_sha256 =
576        observed_after.and_then(|observed| observed.canonical_embedded_config_digest.clone());
577    let verification_result = external_upgrade_verification_result(
578        consent_state,
579        proposal,
580        observed_after_module_hash.as_deref(),
581        observed_after_canonical_embedded_config_sha256.as_deref(),
582    );
583    let verification_notes = external_upgrade_verification_notes(
584        verification_result,
585        proposal,
586        observed_after_module_hash.as_deref(),
587        observed_after_canonical_embedded_config_sha256.as_deref(),
588    );
589
590    let mut receipt = ExternalUpgradeReceiptV1 {
591        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
592        receipt_id: receipt_id.into(),
593        proposal_id: proposal.proposal_id.clone(),
594        proposal_digest: proposal.proposal_digest.clone(),
595        subject: proposal.subject.clone(),
596        canister_id: proposal.canister_id.clone(),
597        role: proposal.role.clone(),
598        consent_state,
599        reported_by,
600        observed_before_module_hash: proposal.current_module_hash.clone(),
601        observed_after_module_hash,
602        observed_after_canonical_embedded_config_sha256,
603        verification_result,
604        verification_notes,
605        receipt_digest: String::new(),
606    };
607    receipt.receipt_digest = external_upgrade_receipt_digest(&receipt);
608    receipt
609}
610
611/// Validate the internal consistency of an external-upgrade receipt.
612///
613/// This is structural validation only. Live inventory remains the source of
614/// truth for whether the external upgrade actually completed.
615pub fn validate_external_upgrade_receipt(
616    receipt: &ExternalUpgradeReceiptV1,
617) -> Result<(), ExternalUpgradeReceiptError> {
618    if receipt.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
619        return Err(ExternalUpgradeReceiptError::SchemaVersionMismatch {
620            expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
621            actual: receipt.schema_version,
622        });
623    }
624    ensure_external_receipt_field("receipt_id", receipt.receipt_id.as_str())?;
625    ensure_external_receipt_field("proposal_id", receipt.proposal_id.as_str())?;
626    ensure_external_receipt_field("proposal_digest", receipt.proposal_digest.as_str())?;
627    ensure_external_receipt_field("subject", receipt.subject.as_str())?;
628    ensure_external_receipt_field("receipt_digest", receipt.receipt_digest.as_str())?;
629
630    if receipt.consent_state == ExternalUpgradeConsentStateV1::Refused
631        && receipt.verification_result == ExternalUpgradeVerificationResultV1::Verified
632    {
633        return Err(ExternalUpgradeReceiptError::RefusedConsentVerified);
634    }
635    let has_observation = receipt.observed_after_module_hash.is_some()
636        || receipt
637            .observed_after_canonical_embedded_config_sha256
638            .is_some();
639    if matches!(
640        receipt.verification_result,
641        ExternalUpgradeVerificationResultV1::Verified
642            | ExternalUpgradeVerificationResultV1::Mismatch
643    ) && !has_observation
644    {
645        return Err(ExternalUpgradeReceiptError::VerificationMismatch);
646    }
647    if receipt.receipt_digest != external_upgrade_receipt_digest(receipt) {
648        return Err(ExternalUpgradeReceiptError::DigestMismatch {
649            field: "receipt_digest",
650        });
651    }
652    Ok(())
653}
654
655/// Validate an external-upgrade receipt against the proposal it claims to
656/// satisfy.
657///
658/// This remains structural verification. It proves the receipt is linked to the
659/// supplied proposal and that its verification result matches the proposal's
660/// target facts, but live inventory remains the source of deployment truth.
661pub fn validate_external_upgrade_receipt_for_proposal(
662    receipt: &ExternalUpgradeReceiptV1,
663    proposal: &ExternalUpgradeProposalV1,
664) -> Result<(), ExternalUpgradeReceiptError> {
665    validate_external_upgrade_receipt(receipt)?;
666    ensure_external_receipt_matches_proposal(
667        "proposal_id",
668        receipt.proposal_id.as_str(),
669        proposal.proposal_id.as_str(),
670    )?;
671    ensure_external_receipt_matches_proposal(
672        "proposal_digest",
673        receipt.proposal_digest.as_str(),
674        proposal.proposal_digest.as_str(),
675    )?;
676    ensure_external_receipt_matches_proposal(
677        "subject",
678        receipt.subject.as_str(),
679        proposal.subject.as_str(),
680    )?;
681    ensure_external_receipt_option_matches_proposal(
682        "canister_id",
683        receipt.canister_id.as_deref(),
684        proposal.canister_id.as_deref(),
685    )?;
686    ensure_external_receipt_option_matches_proposal(
687        "role",
688        receipt.role.as_deref(),
689        proposal.role.as_deref(),
690    )?;
691    ensure_external_receipt_option_matches_proposal(
692        "observed_before_module_hash",
693        receipt.observed_before_module_hash.as_deref(),
694        proposal.current_module_hash.as_deref(),
695    )?;
696
697    let expected_result = external_upgrade_verification_result(
698        receipt.consent_state,
699        proposal,
700        receipt.observed_after_module_hash.as_deref(),
701        receipt
702            .observed_after_canonical_embedded_config_sha256
703            .as_deref(),
704    );
705    if receipt.verification_result != expected_result {
706        return Err(ExternalUpgradeReceiptError::VerificationMismatch);
707    }
708    let expected_notes = external_upgrade_verification_notes(
709        expected_result,
710        proposal,
711        receipt.observed_after_module_hash.as_deref(),
712        receipt
713            .observed_after_canonical_embedded_config_sha256
714            .as_deref(),
715    );
716    if receipt.verification_notes != expected_notes {
717        return Err(ExternalUpgradeReceiptError::SourceMismatch {
718            field: "verification_notes",
719        });
720    }
721
722    Ok(())
723}
724
725/// Build passive consent/action evidence from a proposal/receipt pair.
726///
727/// This records the reported consent or external action state only. It is not
728/// completion proof; verification remains separate and live inventory remains
729/// the source of deployment truth.
730pub fn external_upgrade_consent_evidence_from_receipt(
731    evidence_id: impl Into<String>,
732    proposal: &ExternalUpgradeProposalV1,
733    receipt: &ExternalUpgradeReceiptV1,
734) -> Result<ExternalUpgradeConsentEvidenceV1, ExternalUpgradeReceiptError> {
735    validate_external_upgrade_receipt_for_proposal(receipt, proposal)?;
736    let consent_state = receipt.consent_state;
737    let mut evidence = ExternalUpgradeConsentEvidenceV1 {
738        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
739        evidence_id: evidence_id.into(),
740        evidence_digest: String::new(),
741        proposal_id: proposal.proposal_id.clone(),
742        proposal_digest: proposal.proposal_digest.clone(),
743        receipt_id: receipt.receipt_id.clone(),
744        receipt_digest: receipt.receipt_digest.clone(),
745        subject: proposal.subject.clone(),
746        canister_id: proposal.canister_id.clone(),
747        role: proposal.role.clone(),
748        consent_state,
749        reported_by: receipt.reported_by.clone(),
750        consent_requirements: proposal.consent_requirements.clone(),
751        allowed_authorization_modes: proposal.allowed_authorization_modes.clone(),
752        status_summary: external_upgrade_consent_summary(consent_state).to_string(),
753    };
754    evidence.evidence_digest = external_upgrade_consent_evidence_digest(&evidence);
755    Ok(evidence)
756}
757
758/// Validate archived consent evidence consistency and digest.
759pub fn validate_external_upgrade_consent_evidence(
760    evidence: &ExternalUpgradeConsentEvidenceV1,
761) -> Result<(), ExternalUpgradeConsentEvidenceError> {
762    if evidence.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
763        return Err(ExternalUpgradeConsentEvidenceError::SchemaVersionMismatch {
764            expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
765            actual: evidence.schema_version,
766        });
767    }
768    ensure_external_consent_evidence_field("evidence_id", evidence.evidence_id.as_str())?;
769    ensure_external_consent_evidence_field("evidence_digest", evidence.evidence_digest.as_str())?;
770    ensure_external_consent_evidence_field("proposal_id", evidence.proposal_id.as_str())?;
771    ensure_external_consent_evidence_field("proposal_digest", evidence.proposal_digest.as_str())?;
772    ensure_external_consent_evidence_field("receipt_id", evidence.receipt_id.as_str())?;
773    ensure_external_consent_evidence_field("receipt_digest", evidence.receipt_digest.as_str())?;
774    ensure_external_consent_evidence_field("subject", evidence.subject.as_str())?;
775    ensure_external_consent_evidence_field("status_summary", evidence.status_summary.as_str())?;
776    if evidence.status_summary != external_upgrade_consent_summary(evidence.consent_state) {
777        return Err(ExternalUpgradeConsentEvidenceError::SourceMismatch {
778            field: "status_summary",
779        });
780    }
781    if evidence.evidence_digest != external_upgrade_consent_evidence_digest(evidence) {
782        return Err(ExternalUpgradeConsentEvidenceError::DigestMismatch {
783            field: "evidence_digest",
784        });
785    }
786    Ok(())
787}
788
789/// Validate that archived consent evidence still matches the proposal/receipt
790/// pair it claims to summarize.
791pub fn validate_external_upgrade_consent_evidence_for_receipt(
792    evidence: &ExternalUpgradeConsentEvidenceV1,
793    proposal: &ExternalUpgradeProposalV1,
794    receipt: &ExternalUpgradeReceiptV1,
795) -> Result<(), ExternalUpgradeConsentEvidenceError> {
796    validate_external_upgrade_consent_evidence(evidence)?;
797    let expected = external_upgrade_consent_evidence_from_receipt(
798        evidence.evidence_id.clone(),
799        proposal,
800        receipt,
801    )?;
802    if evidence != &expected {
803        return Err(ExternalUpgradeConsentEvidenceError::SourceMismatch { field: "receipt" });
804    }
805    Ok(())
806}
807
808/// Build a passive verification report for a proposal/receipt pair.
809///
810/// This packages structural verification evidence only. Live inventory remains
811/// the source of truth for deployment state.
812pub fn external_upgrade_verification_report_from_receipt(
813    report_id: impl Into<String>,
814    proposal: &ExternalUpgradeProposalV1,
815    receipt: &ExternalUpgradeReceiptV1,
816) -> Result<ExternalUpgradeVerificationReportV1, ExternalUpgradeReceiptError> {
817    validate_external_upgrade_receipt_for_proposal(receipt, proposal)?;
818    let verification_result = receipt.verification_result;
819    let mut report = ExternalUpgradeVerificationReportV1 {
820        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
821        report_id: report_id.into(),
822        report_digest: String::new(),
823        proposal_id: proposal.proposal_id.clone(),
824        proposal_digest: proposal.proposal_digest.clone(),
825        receipt_id: receipt.receipt_id.clone(),
826        receipt_digest: receipt.receipt_digest.clone(),
827        subject: proposal.subject.clone(),
828        canister_id: proposal.canister_id.clone(),
829        role: proposal.role.clone(),
830        verification_result,
831        verification_notes: receipt.verification_notes.clone(),
832        live_inventory_required: verification_result
833            != ExternalUpgradeVerificationResultV1::Pending
834            && verification_result != ExternalUpgradeVerificationResultV1::Refused,
835        status_summary: external_upgrade_verification_summary(verification_result).to_string(),
836    };
837    report.report_digest = external_upgrade_verification_report_digest(&report);
838    Ok(report)
839}
840
841/// Validate archived external-upgrade verification report consistency and
842/// digest.
843pub fn validate_external_upgrade_verification_report(
844    report: &ExternalUpgradeVerificationReportV1,
845) -> Result<(), ExternalUpgradeVerificationReportError> {
846    if report.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
847        return Err(
848            ExternalUpgradeVerificationReportError::SchemaVersionMismatch {
849                expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
850                actual: report.schema_version,
851            },
852        );
853    }
854    ensure_external_verification_report_field("report_id", report.report_id.as_str())?;
855    ensure_external_verification_report_field("report_digest", report.report_digest.as_str())?;
856    ensure_external_verification_report_field("proposal_id", report.proposal_id.as_str())?;
857    ensure_external_verification_report_field("proposal_digest", report.proposal_digest.as_str())?;
858    ensure_external_verification_report_field("receipt_id", report.receipt_id.as_str())?;
859    ensure_external_verification_report_field("receipt_digest", report.receipt_digest.as_str())?;
860    ensure_external_verification_report_field("subject", report.subject.as_str())?;
861    ensure_external_verification_report_field("status_summary", report.status_summary.as_str())?;
862    if report.status_summary != external_upgrade_verification_summary(report.verification_result) {
863        return Err(ExternalUpgradeVerificationReportError::SourceMismatch {
864            field: "status_summary",
865        });
866    }
867    if report.report_digest != external_upgrade_verification_report_digest(report) {
868        return Err(ExternalUpgradeVerificationReportError::DigestMismatch {
869            field: "report_digest",
870        });
871    }
872    Ok(())
873}
874
875/// Validate that an archived verification report still matches the
876/// proposal/receipt pair it claims to summarize.
877pub fn validate_external_upgrade_verification_report_for_receipt(
878    report: &ExternalUpgradeVerificationReportV1,
879    proposal: &ExternalUpgradeProposalV1,
880    receipt: &ExternalUpgradeReceiptV1,
881) -> Result<(), ExternalUpgradeVerificationReportError> {
882    validate_external_upgrade_verification_report(report)?;
883    let expected = external_upgrade_verification_report_from_receipt(
884        report.report_id.clone(),
885        proposal,
886        receipt,
887    )?;
888    if report != &expected {
889        return Err(ExternalUpgradeVerificationReportError::SourceMismatch { field: "receipt" });
890    }
891    Ok(())
892}
893
894/// Build passive external-upgrade proposal artifacts from a lifecycle plan.
895///
896/// This binds current observations to target artifact facts, but does not
897/// grant consent, execute installs, or verify completion.
898#[must_use]
899pub fn external_upgrade_proposal_report_from_lifecycle_plan(
900    report_id: impl Into<String>,
901    lifecycle_plan: &ExternalLifecyclePlanV1,
902    check: &DeploymentCheckV1,
903) -> ExternalUpgradeProposalReportV1 {
904    let report_id = report_id.into();
905    let mut proposals = Vec::new();
906    for authority in lifecycle_plan
907        .lifecycle_authority_rows
908        .iter()
909        .filter(|authority| authority.external_action_required && !authority.blocked)
910    {
911        proposals.push(external_upgrade_proposal(
912            &report_id,
913            lifecycle_plan,
914            check,
915            authority,
916            observed_canister_for_authority(&check.inventory, authority),
917            target_artifact_for_authority(&check.plan, authority),
918        ));
919    }
920
921    proposals.sort_by(|left, right| left.subject.cmp(&right.subject));
922
923    let mut report = ExternalUpgradeProposalReportV1 {
924        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
925        report_id,
926        report_digest: String::new(),
927        lifecycle_plan_id: lifecycle_plan.lifecycle_plan_id.clone(),
928        lifecycle_plan_digest: lifecycle_plan.lifecycle_plan_digest.clone(),
929        deployment_plan_id: lifecycle_plan.deployment_plan_id.clone(),
930        deployment_plan_digest: lifecycle_plan.deployment_plan_digest.clone(),
931        inventory_id: check.inventory.inventory_id.clone(),
932        proposals,
933        blocked_subjects: lifecycle_plan
934            .blocked_role_upgrades
935            .iter()
936            .map(|upgrade| upgrade.subject.clone())
937            .collect(),
938    };
939    report.report_digest = external_upgrade_proposal_report_digest(&report);
940    report
941}
942
943/// Validate archived external-upgrade proposal report consistency and digests.
944pub fn validate_external_upgrade_proposal_report(
945    report: &ExternalUpgradeProposalReportV1,
946) -> Result<(), ExternalUpgradeProposalReportError> {
947    if report.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
948        return Err(ExternalUpgradeProposalReportError::SchemaVersionMismatch {
949            expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
950            actual: report.schema_version,
951        });
952    }
953    ensure_external_proposal_report_field("report_id", report.report_id.as_str())?;
954    ensure_external_proposal_report_field("report_digest", report.report_digest.as_str())?;
955    ensure_external_proposal_report_field("lifecycle_plan_id", report.lifecycle_plan_id.as_str())?;
956    ensure_external_proposal_report_field(
957        "lifecycle_plan_digest",
958        report.lifecycle_plan_digest.as_str(),
959    )?;
960    ensure_external_proposal_report_field(
961        "deployment_plan_id",
962        report.deployment_plan_id.as_str(),
963    )?;
964    ensure_external_proposal_report_field(
965        "deployment_plan_digest",
966        report.deployment_plan_digest.as_str(),
967    )?;
968    ensure_external_proposal_report_field("inventory_id", report.inventory_id.as_str())?;
969
970    let mut subjects = BTreeSet::new();
971    for proposal in &report.proposals {
972        if !subjects.insert(proposal.subject.clone()) {
973            return Err(ExternalUpgradeProposalReportError::DuplicateSubject {
974                subject: proposal.subject.clone(),
975            });
976        }
977        validate_external_upgrade_proposal(proposal)?;
978    }
979    if report.report_digest != external_upgrade_proposal_report_digest(report) {
980        return Err(ExternalUpgradeProposalReportError::DigestMismatch {
981            field: "report_digest",
982        });
983    }
984    Ok(())
985}
986
987/// Validate that an archived external-upgrade proposal report still matches
988/// the lifecycle plan and deployment truth check it claims to derive from.
989pub fn validate_external_upgrade_proposal_report_for_lifecycle_plan(
990    report: &ExternalUpgradeProposalReportV1,
991    lifecycle_plan: &ExternalLifecyclePlanV1,
992    check: &DeploymentCheckV1,
993) -> Result<(), ExternalUpgradeProposalReportError> {
994    validate_external_upgrade_proposal_report(report)?;
995    if report.lifecycle_plan_id != lifecycle_plan.lifecycle_plan_id {
996        return Err(ExternalUpgradeProposalReportError::SourceMismatch {
997            field: "lifecycle_plan_id",
998        });
999    }
1000    if report.lifecycle_plan_digest != lifecycle_plan.lifecycle_plan_digest {
1001        return Err(ExternalUpgradeProposalReportError::SourceMismatch {
1002            field: "lifecycle_plan_digest",
1003        });
1004    }
1005    let expected = external_upgrade_proposal_report_from_lifecycle_plan(
1006        report.report_id.clone(),
1007        lifecycle_plan,
1008        check,
1009    );
1010    if report != &expected {
1011        return Err(ExternalUpgradeProposalReportError::SourceMismatch {
1012            field: "deployment_check",
1013        });
1014    }
1015    Ok(())
1016}
1017
1018/// Build a passive summary of external lifecycle work still pending after a
1019/// plan/proposal pass.
1020#[must_use]
1021pub fn external_lifecycle_pending_report_from_plan(
1022    report_id: impl Into<String>,
1023    lifecycle_plan: &ExternalLifecyclePlanV1,
1024    proposal_report: &ExternalUpgradeProposalReportV1,
1025) -> ExternalLifecyclePendingReportV1 {
1026    let report_id = report_id.into();
1027    let pending_external_actions = proposal_report
1028        .proposals
1029        .iter()
1030        .map(external_lifecycle_pending_action)
1031        .collect::<Vec<_>>();
1032    let blocked_subjects = lifecycle_plan
1033        .blocked_role_upgrades
1034        .iter()
1035        .map(|upgrade| upgrade.subject.clone())
1036        .collect::<Vec<_>>();
1037    let mut report = ExternalLifecyclePendingReportV1 {
1038        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
1039        report_id,
1040        report_digest: String::new(),
1041        lifecycle_plan_id: lifecycle_plan.lifecycle_plan_id.clone(),
1042        lifecycle_plan_digest: lifecycle_plan.lifecycle_plan_digest.clone(),
1043        proposal_report_id: proposal_report.report_id.clone(),
1044        proposal_report_digest: proposal_report.report_digest.clone(),
1045        deployment_plan_id: lifecycle_plan.deployment_plan_id.clone(),
1046        deployment_plan_digest: lifecycle_plan.deployment_plan_digest.clone(),
1047        inventory_id: lifecycle_plan.inventory_id.clone(),
1048        direct_upgrade_count: lifecycle_plan.directly_executable_role_upgrades.len(),
1049        pending_external_count: pending_external_actions.len(),
1050        blocked_count: blocked_subjects.len(),
1051        pending_external_actions,
1052        blocked_subjects,
1053        residual_exposure: lifecycle_plan.residual_exposure.clone(),
1054        status: lifecycle_plan.status,
1055    };
1056    report.report_digest = external_lifecycle_pending_report_digest(&report);
1057    report
1058}
1059
1060/// Validate archived external lifecycle pending report consistency and digest.
1061pub fn validate_external_lifecycle_pending_report(
1062    report: &ExternalLifecyclePendingReportV1,
1063) -> Result<(), ExternalLifecyclePendingReportError> {
1064    if report.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
1065        return Err(ExternalLifecyclePendingReportError::SchemaVersionMismatch {
1066            expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
1067            actual: report.schema_version,
1068        });
1069    }
1070    ensure_external_pending_report_field("report_id", report.report_id.as_str())?;
1071    ensure_external_pending_report_field("report_digest", report.report_digest.as_str())?;
1072    ensure_external_pending_report_field("lifecycle_plan_id", report.lifecycle_plan_id.as_str())?;
1073    ensure_external_pending_report_field(
1074        "lifecycle_plan_digest",
1075        report.lifecycle_plan_digest.as_str(),
1076    )?;
1077    ensure_external_pending_report_field("proposal_report_id", report.proposal_report_id.as_str())?;
1078    ensure_external_pending_report_field(
1079        "proposal_report_digest",
1080        report.proposal_report_digest.as_str(),
1081    )?;
1082    ensure_external_pending_report_field("deployment_plan_id", report.deployment_plan_id.as_str())?;
1083    ensure_external_pending_report_field(
1084        "deployment_plan_digest",
1085        report.deployment_plan_digest.as_str(),
1086    )?;
1087    ensure_external_pending_report_field("inventory_id", report.inventory_id.as_str())?;
1088    if report.pending_external_count != report.pending_external_actions.len()
1089        || report.blocked_count != report.blocked_subjects.len()
1090    {
1091        return Err(ExternalLifecyclePendingReportError::CountMismatch);
1092    }
1093    let mut subjects = BTreeSet::new();
1094    for action in &report.pending_external_actions {
1095        ensure_external_pending_report_field("pending_action.subject", action.subject.as_str())?;
1096        ensure_external_pending_report_field(
1097            "pending_action.proposal_id",
1098            action.proposal_id.as_str(),
1099        )?;
1100        ensure_external_pending_report_field(
1101            "pending_action.proposal_digest",
1102            action.proposal_digest.as_str(),
1103        )?;
1104        ensure_external_pending_report_field(
1105            "pending_action.required_external_action",
1106            action.required_external_action.as_str(),
1107        )?;
1108        if !subjects.insert(action.subject.clone()) {
1109            return Err(ExternalLifecyclePendingReportError::DuplicateSubject {
1110                subject: action.subject.clone(),
1111            });
1112        }
1113    }
1114    if report.report_digest != external_lifecycle_pending_report_digest(report) {
1115        return Err(ExternalLifecyclePendingReportError::DigestMismatch {
1116            field: "report_digest",
1117        });
1118    }
1119    Ok(())
1120}
1121
1122/// Validate that an archived external lifecycle pending report still matches
1123/// the lifecycle and proposal artifacts it claims to derive from.
1124pub fn validate_external_lifecycle_pending_report_for_plan(
1125    report: &ExternalLifecyclePendingReportV1,
1126    lifecycle_plan: &ExternalLifecyclePlanV1,
1127    proposal_report: &ExternalUpgradeProposalReportV1,
1128) -> Result<(), ExternalLifecyclePendingReportError> {
1129    validate_external_lifecycle_pending_report(report)?;
1130    if report.lifecycle_plan_id != lifecycle_plan.lifecycle_plan_id {
1131        return Err(ExternalLifecyclePendingReportError::SourceMismatch {
1132            field: "lifecycle_plan_id",
1133        });
1134    }
1135    if report.lifecycle_plan_digest != lifecycle_plan.lifecycle_plan_digest {
1136        return Err(ExternalLifecyclePendingReportError::SourceMismatch {
1137            field: "lifecycle_plan_digest",
1138        });
1139    }
1140    if report.proposal_report_id != proposal_report.report_id {
1141        return Err(ExternalLifecyclePendingReportError::SourceMismatch {
1142            field: "proposal_report_id",
1143        });
1144    }
1145    if report.proposal_report_digest != proposal_report.report_digest {
1146        return Err(ExternalLifecyclePendingReportError::SourceMismatch {
1147            field: "proposal_report_digest",
1148        });
1149    }
1150    let expected = external_lifecycle_pending_report_from_plan(
1151        report.report_id.clone(),
1152        lifecycle_plan,
1153        proposal_report,
1154    );
1155    if report != &expected {
1156        return Err(ExternalLifecyclePendingReportError::SourceMismatch {
1157            field: "lifecycle_plan",
1158        });
1159    }
1160    Ok(())
1161}
1162
1163fn external_lifecycle_pending_action(
1164    proposal: &ExternalUpgradeProposalV1,
1165) -> ExternalLifecyclePendingActionV1 {
1166    ExternalLifecyclePendingActionV1 {
1167        subject: proposal.subject.clone(),
1168        proposal_id: proposal.proposal_id.clone(),
1169        proposal_digest: proposal.proposal_digest.clone(),
1170        canister_id: proposal.canister_id.clone(),
1171        role: proposal.role.clone(),
1172        control_class: proposal.control_class,
1173        lifecycle_mode: proposal.lifecycle_mode,
1174        required_external_action: proposal.required_external_action.clone(),
1175        consent_requirements: proposal.consent_requirements.clone(),
1176        verification_requirements: proposal.verification_requirements.clone(),
1177    }
1178}
1179
1180fn lifecycle_roles(lifecycle_plan: &ExternalLifecyclePlanV1) -> Vec<String> {
1181    lifecycle_plan
1182        .lifecycle_authority_rows
1183        .iter()
1184        .filter_map(|authority| authority.role.clone())
1185        .collect::<BTreeSet<_>>()
1186        .into_iter()
1187        .collect()
1188}
1189
1190fn lifecycle_canisters(lifecycle_plan: &ExternalLifecyclePlanV1) -> Vec<String> {
1191    lifecycle_plan
1192        .lifecycle_authority_rows
1193        .iter()
1194        .filter_map(|authority| authority.canister_id.clone())
1195        .collect::<BTreeSet<_>>()
1196        .into_iter()
1197        .collect()
1198}
1199
1200fn role_names(upgrades: &[ExternalLifecycleRoleUpgradeV1]) -> Vec<String> {
1201    upgrades
1202        .iter()
1203        .filter_map(|upgrade| upgrade.role.clone())
1204        .collect::<BTreeSet<_>>()
1205        .into_iter()
1206        .collect()
1207}
1208
1209fn critical_fix_next_steps(
1210    pending_external_count: usize,
1211    blocked_count: usize,
1212    protected_call_implications: &[String],
1213) -> Vec<String> {
1214    let mut steps = Vec::new();
1215    if pending_external_count > 0 {
1216        steps.push(
1217            "request external consent or completion for externally controlled roles".to_string(),
1218        );
1219    }
1220    if blocked_count > 0 {
1221        steps.push(
1222            "resolve blocked lifecycle rows before reporting the deployment fully patched"
1223                .to_string(),
1224        );
1225    }
1226    if !protected_call_implications.is_empty() {
1227        steps.push(
1228            "review protected-call readiness and role epoch implications before closure"
1229                .to_string(),
1230        );
1231    }
1232    if steps.is_empty() {
1233        steps.push("no external lifecycle work remains for this critical fix".to_string());
1234    }
1235    steps
1236}
1237
1238/// Build a passive critical-fix residual exposure report from lifecycle
1239/// evidence.
1240#[must_use]
1241pub fn critical_external_fix_report_from_pending(
1242    report_id: impl Into<String>,
1243    fix_id: impl Into<String>,
1244    severity: impl Into<String>,
1245    lifecycle_plan: &ExternalLifecyclePlanV1,
1246    pending_report: &ExternalLifecyclePendingReportV1,
1247) -> CriticalExternalFixReportV1 {
1248    let report_id = report_id.into();
1249    let fix_id = fix_id.into();
1250    let severity = severity.into();
1251    let affected_roles = lifecycle_roles(lifecycle_plan);
1252    let affected_canisters = lifecycle_canisters(lifecycle_plan);
1253    let directly_patchable_roles = role_names(&lifecycle_plan.directly_executable_role_upgrades);
1254    let externally_blocked_roles = pending_report
1255        .pending_external_actions
1256        .iter()
1257        .filter_map(|action| action.role.clone())
1258        .collect::<BTreeSet<_>>()
1259        .into_iter()
1260        .collect::<Vec<_>>();
1261    let dependency_blocked_roles = role_names(&lifecycle_plan.blocked_role_upgrades);
1262    let required_external_actions = pending_report
1263        .pending_external_actions
1264        .iter()
1265        .map(|action| format!("{}: {}", action.subject, action.required_external_action))
1266        .collect::<Vec<_>>();
1267    let operator_next_steps = critical_fix_next_steps(
1268        pending_report.pending_external_count,
1269        pending_report.blocked_count,
1270        lifecycle_plan.protected_call_implications.as_slice(),
1271    );
1272    let mut report = CriticalExternalFixReportV1 {
1273        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
1274        report_id,
1275        report_digest: String::new(),
1276        fix_id,
1277        severity,
1278        lifecycle_plan_id: lifecycle_plan.lifecycle_plan_id.clone(),
1279        lifecycle_plan_digest: lifecycle_plan.lifecycle_plan_digest.clone(),
1280        pending_report_id: pending_report.report_id.clone(),
1281        pending_report_digest: pending_report.report_digest.clone(),
1282        deployment_plan_id: lifecycle_plan.deployment_plan_id.clone(),
1283        deployment_plan_digest: lifecycle_plan.deployment_plan_digest.clone(),
1284        inventory_id: lifecycle_plan.inventory_id.clone(),
1285        affected_roles,
1286        affected_canisters,
1287        directly_patchable_roles,
1288        externally_blocked_roles,
1289        dependency_blocked_roles,
1290        required_external_actions,
1291        protected_call_implications: lifecycle_plan.protected_call_implications.clone(),
1292        residual_exposure: pending_report.residual_exposure.clone(),
1293        operator_next_steps,
1294    };
1295    report.report_digest = critical_external_fix_report_digest(&report);
1296    report
1297}
1298
1299/// Validate archived critical external fix report consistency and digest.
1300pub fn validate_critical_external_fix_report(
1301    report: &CriticalExternalFixReportV1,
1302) -> Result<(), CriticalExternalFixReportError> {
1303    if report.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
1304        return Err(CriticalExternalFixReportError::SchemaVersionMismatch {
1305            expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
1306            actual: report.schema_version,
1307        });
1308    }
1309    ensure_critical_fix_report_field("report_id", report.report_id.as_str())?;
1310    ensure_critical_fix_report_field("report_digest", report.report_digest.as_str())?;
1311    ensure_critical_fix_report_field("fix_id", report.fix_id.as_str())?;
1312    ensure_critical_fix_report_field("severity", report.severity.as_str())?;
1313    ensure_critical_fix_report_field("lifecycle_plan_id", report.lifecycle_plan_id.as_str())?;
1314    ensure_critical_fix_report_field(
1315        "lifecycle_plan_digest",
1316        report.lifecycle_plan_digest.as_str(),
1317    )?;
1318    ensure_critical_fix_report_field("pending_report_id", report.pending_report_id.as_str())?;
1319    ensure_critical_fix_report_field(
1320        "pending_report_digest",
1321        report.pending_report_digest.as_str(),
1322    )?;
1323    ensure_critical_fix_report_field("deployment_plan_id", report.deployment_plan_id.as_str())?;
1324    ensure_critical_fix_report_field(
1325        "deployment_plan_digest",
1326        report.deployment_plan_digest.as_str(),
1327    )?;
1328    ensure_critical_fix_report_field("inventory_id", report.inventory_id.as_str())?;
1329    if report.report_digest != critical_external_fix_report_digest(report) {
1330        return Err(CriticalExternalFixReportError::DigestMismatch {
1331            field: "report_digest",
1332        });
1333    }
1334    Ok(())
1335}
1336
1337/// Validate that an archived critical external fix report still matches the
1338/// lifecycle artifacts it claims to summarize.
1339pub fn validate_critical_external_fix_report_for_pending(
1340    report: &CriticalExternalFixReportV1,
1341    lifecycle_plan: &ExternalLifecyclePlanV1,
1342    pending_report: &ExternalLifecyclePendingReportV1,
1343) -> Result<(), CriticalExternalFixReportError> {
1344    validate_critical_external_fix_report(report)?;
1345    if report.lifecycle_plan_id != lifecycle_plan.lifecycle_plan_id {
1346        return Err(CriticalExternalFixReportError::SourceMismatch {
1347            field: "lifecycle_plan_id",
1348        });
1349    }
1350    if report.lifecycle_plan_digest != lifecycle_plan.lifecycle_plan_digest {
1351        return Err(CriticalExternalFixReportError::SourceMismatch {
1352            field: "lifecycle_plan_digest",
1353        });
1354    }
1355    if report.pending_report_id != pending_report.report_id {
1356        return Err(CriticalExternalFixReportError::SourceMismatch {
1357            field: "pending_report_id",
1358        });
1359    }
1360    if report.pending_report_digest != pending_report.report_digest {
1361        return Err(CriticalExternalFixReportError::SourceMismatch {
1362            field: "pending_report_digest",
1363        });
1364    }
1365    let expected = critical_external_fix_report_from_pending(
1366        report.report_id.clone(),
1367        report.fix_id.clone(),
1368        report.severity.clone(),
1369        lifecycle_plan,
1370        pending_report,
1371    );
1372    if report != &expected {
1373        return Err(CriticalExternalFixReportError::SourceMismatch {
1374            field: "lifecycle_plan",
1375        });
1376    }
1377    Ok(())
1378}
1379
1380fn external_upgrade_proposal(
1381    report_id: &str,
1382    lifecycle_plan: &ExternalLifecyclePlanV1,
1383    check: &DeploymentCheckV1,
1384    authority: &LifecycleAuthorityV1,
1385    observed: Option<&ObservedCanisterV1>,
1386    target_artifact: Option<&RoleArtifactV1>,
1387) -> ExternalUpgradeProposalV1 {
1388    let current_module_hash = observed.and_then(|observed| observed.module_hash.clone());
1389    let current_canonical_embedded_config_sha256 =
1390        observed.and_then(|observed| observed.canonical_embedded_config_digest.clone());
1391    let mut proposal = ExternalUpgradeProposalV1 {
1392        proposal_id: external_upgrade_proposal_id(report_id, authority.subject.as_str()),
1393        proposal_digest: String::new(),
1394        deployment_plan_id: lifecycle_plan.deployment_plan_id.clone(),
1395        deployment_plan_digest: lifecycle_plan.deployment_plan_digest.clone(),
1396        lifecycle_plan_id: lifecycle_plan.lifecycle_plan_id.clone(),
1397        lifecycle_plan_digest: lifecycle_plan.lifecycle_plan_digest.clone(),
1398        promotion_plan_id: None,
1399        promotion_plan_digest: None,
1400        promotion_provenance_id: None,
1401        promotion_provenance_digest: None,
1402        subject: authority.subject.clone(),
1403        canister_id: authority.canister_id.clone(),
1404        role: authority.role.clone(),
1405        control_class: authority.control_class,
1406        lifecycle_mode: authority.lifecycle_mode,
1407        observed_before_digest: observed_before_digest(
1408            authority,
1409            current_module_hash.as_ref(),
1410            current_canonical_embedded_config_sha256.as_ref(),
1411        ),
1412        current_module_hash,
1413        current_canonical_embedded_config_sha256,
1414        target_wasm_sha256: target_artifact.and_then(|artifact| artifact.wasm_sha256.clone()),
1415        target_wasm_gz_sha256: target_artifact.and_then(|artifact| artifact.wasm_gz_sha256.clone()),
1416        target_installed_module_hash: target_artifact
1417            .and_then(|artifact| artifact.installed_module_hash.clone()),
1418        target_role_artifact_identity: target_artifact.map(role_artifact_identity),
1419        target_canonical_embedded_config_sha256: target_artifact
1420            .and_then(|artifact| artifact.canonical_embedded_config_sha256.clone()),
1421        root_trust_anchor: check.plan.trust_domain.root_trust_anchor.clone(),
1422        authority_profile_hash: check
1423            .plan
1424            .deployment_identity
1425            .authority_profile_hash
1426            .clone(),
1427        required_external_action: required_external_action(authority.lifecycle_mode).to_string(),
1428        consent_requirements: authority.consent_requirements.clone(),
1429        allowed_authorization_modes: external_upgrade_authorization_modes(authority.control_class),
1430        verification_requirements: authority.verification_requirements.clone(),
1431        expires_at: None,
1432        supersedes_proposal_id: None,
1433    };
1434    proposal.proposal_digest = external_upgrade_proposal_digest(&proposal);
1435    proposal
1436}
1437
1438fn validate_external_upgrade_proposal(
1439    proposal: &ExternalUpgradeProposalV1,
1440) -> Result<(), ExternalUpgradeProposalReportError> {
1441    ensure_external_proposal_report_field("proposal_id", proposal.proposal_id.as_str())?;
1442    ensure_external_proposal_report_field("proposal_digest", proposal.proposal_digest.as_str())?;
1443    ensure_external_proposal_report_field(
1444        "proposal.deployment_plan_id",
1445        proposal.deployment_plan_id.as_str(),
1446    )?;
1447    ensure_external_proposal_report_field(
1448        "proposal.deployment_plan_digest",
1449        proposal.deployment_plan_digest.as_str(),
1450    )?;
1451    ensure_external_proposal_report_field(
1452        "proposal.lifecycle_plan_id",
1453        proposal.lifecycle_plan_id.as_str(),
1454    )?;
1455    ensure_external_proposal_report_field(
1456        "proposal.lifecycle_plan_digest",
1457        proposal.lifecycle_plan_digest.as_str(),
1458    )?;
1459    ensure_external_proposal_report_field(
1460        "proposal.observed_before_digest",
1461        proposal.observed_before_digest.as_str(),
1462    )?;
1463    ensure_external_proposal_report_field("proposal.subject", proposal.subject.as_str())?;
1464    if proposal.lifecycle_mode == LifecycleModeV1::DirectDeploymentAuthority {
1465        return Err(
1466            ExternalUpgradeProposalReportError::DirectLifecycleProposal {
1467                subject: proposal.subject.clone(),
1468            },
1469        );
1470    }
1471    if proposal.proposal_digest != external_upgrade_proposal_digest(proposal) {
1472        return Err(ExternalUpgradeProposalReportError::DigestMismatch {
1473            field: "proposal_digest",
1474        });
1475    }
1476    Ok(())
1477}
1478
1479fn lifecycle_authority_for_expected_canister(
1480    plan: &DeploymentPlanV1,
1481    expected: &ExpectedCanisterV1,
1482    observed: Option<&ObservedCanisterV1>,
1483) -> LifecycleAuthorityV1 {
1484    let canister_id = expected
1485        .canister_id
1486        .clone()
1487        .or_else(|| observed.map(|observed| observed.canister_id.clone()));
1488    let role = Some(expected.role.clone());
1489    let control_class = observed.map_or(expected.control_class, |observed| observed.control_class);
1490    let observed_controllers =
1491        observed.map_or_else(Vec::new, |observed| observed.controllers.clone());
1492    lifecycle_authority(
1493        lifecycle_subject_for_parts(canister_id.as_deref(), role.as_deref()),
1494        canister_id,
1495        role,
1496        control_class,
1497        observed_controllers,
1498        &plan.authority_profile.expected_controllers,
1499        plan.expected_verifier_readiness.required,
1500    )
1501}
1502
1503fn lifecycle_authority_for_expected_pool(
1504    expected: &ExpectedPoolCanisterV1,
1505    observed: Option<&ObservedPoolCanisterV1>,
1506) -> LifecycleAuthorityV1 {
1507    let canister_id = expected
1508        .canister_id
1509        .clone()
1510        .or_else(|| observed.map(|observed| observed.canister_id.clone()));
1511    let role = expected
1512        .role
1513        .clone()
1514        .or_else(|| observed.and_then(|observed| observed.role.clone()));
1515    let control_class = observed.map_or(CanisterControlClassV1::CanicManagedPool, |observed| {
1516        observed.control_class
1517    });
1518    lifecycle_authority(
1519        lifecycle_subject_for_parts(canister_id.as_deref(), role.as_deref()),
1520        canister_id,
1521        role,
1522        control_class,
1523        Vec::new(),
1524        &[],
1525        false,
1526    )
1527}
1528
1529fn lifecycle_authority_for_unplanned_canister(
1530    observed: &ObservedCanisterV1,
1531) -> LifecycleAuthorityV1 {
1532    lifecycle_authority(
1533        lifecycle_subject(observed.canister_id.as_str(), observed.role.as_deref()),
1534        Some(observed.canister_id.clone()),
1535        observed.role.clone(),
1536        observed.control_class,
1537        observed.controllers.clone(),
1538        &[],
1539        false,
1540    )
1541}
1542
1543fn lifecycle_authority_for_unplanned_pool(
1544    observed: &ObservedPoolCanisterV1,
1545) -> LifecycleAuthorityV1 {
1546    lifecycle_authority(
1547        lifecycle_subject(observed.canister_id.as_str(), observed.role.as_deref()),
1548        Some(observed.canister_id.clone()),
1549        observed.role.clone(),
1550        observed.control_class,
1551        Vec::new(),
1552        &[],
1553        false,
1554    )
1555}
1556
1557fn lifecycle_authority(
1558    subject: String,
1559    canister_id: Option<String>,
1560    role: Option<String>,
1561    control_class: CanisterControlClassV1,
1562    observed_controllers: Vec<String>,
1563    expected_controllers: &[String],
1564    verifier_required: bool,
1565) -> LifecycleAuthorityV1 {
1566    let required_controllers = required_lifecycle_controllers(control_class, expected_controllers);
1567    let external_controllers =
1568        external_lifecycle_controllers(control_class, &observed_controllers, &required_controllers);
1569    let consent_requirements = lifecycle_consent_requirements(control_class, &external_controllers);
1570    let allowed_upgrade_modes = lifecycle_upgrade_modes(control_class);
1571    let verification_requirements = lifecycle_verification_requirements(verifier_required);
1572    let external_action_required = lifecycle_external_action_required(control_class);
1573    let blocked = control_class == CanisterControlClassV1::UnknownUnsafe;
1574    let lifecycle_mode = lifecycle_mode(control_class);
1575    let blockers = lifecycle_blockers(control_class);
1576    let warnings = lifecycle_warnings(control_class);
1577    let reason = lifecycle_reason(control_class);
1578    LifecycleAuthorityV1 {
1579        subject,
1580        canister_id,
1581        role,
1582        control_class,
1583        lifecycle_mode,
1584        observed_controllers,
1585        expected_deployment_controllers: sorted_unique(expected_controllers.to_vec()),
1586        external_controllers,
1587        required_controllers,
1588        consent_requirements,
1589        allowed_upgrade_modes,
1590        verification_requirements,
1591        external_action_required,
1592        blocked,
1593        blockers,
1594        warnings,
1595        reason,
1596    }
1597}
1598
1599fn required_lifecycle_controllers(
1600    control_class: CanisterControlClassV1,
1601    expected_controllers: &[String],
1602) -> Vec<String> {
1603    match control_class {
1604        CanisterControlClassV1::DeploymentControlled
1605        | CanisterControlClassV1::JointlyControlled => sorted_unique(expected_controllers.to_vec()),
1606        CanisterControlClassV1::CanicManagedPool
1607        | CanisterControlClassV1::ExternallyImported
1608        | CanisterControlClassV1::UserControlled
1609        | CanisterControlClassV1::UnknownUnsafe => Vec::new(),
1610    }
1611}
1612
1613fn external_lifecycle_controllers(
1614    control_class: CanisterControlClassV1,
1615    observed_controllers: &[String],
1616    required_controllers: &[String],
1617) -> Vec<String> {
1618    match control_class {
1619        CanisterControlClassV1::DeploymentControlled | CanisterControlClassV1::UnknownUnsafe => {
1620            Vec::new()
1621        }
1622        CanisterControlClassV1::JointlyControlled => {
1623            let required = required_controllers.iter().collect::<BTreeSet<_>>();
1624            sorted_unique(
1625                observed_controllers
1626                    .iter()
1627                    .filter(|controller| !required.contains(controller))
1628                    .cloned()
1629                    .collect(),
1630            )
1631        }
1632        CanisterControlClassV1::CanicManagedPool
1633        | CanisterControlClassV1::ExternallyImported
1634        | CanisterControlClassV1::UserControlled => sorted_unique(observed_controllers.to_vec()),
1635    }
1636}
1637
1638fn lifecycle_consent_requirements(
1639    control_class: CanisterControlClassV1,
1640    external_controllers: &[String],
1641) -> Vec<ConsentRequirementV1> {
1642    if !lifecycle_external_action_required(control_class) {
1643        return Vec::new();
1644    }
1645    vec![ConsentRequirementV1 {
1646        consent_subject_kind: consent_subject_kind(control_class),
1647        required_principals: sorted_unique(external_controllers.to_vec()),
1648        required_controller_set_digest: Some(stable_json_sha256_hex(&external_controllers)),
1649        consent_channel_kind: consent_channel_kind(control_class),
1650        required_action: required_consent_action(control_class),
1651    }]
1652}
1653
1654const fn consent_subject_kind(control_class: CanisterControlClassV1) -> ConsentSubjectKindV1 {
1655    match control_class {
1656        CanisterControlClassV1::CanicManagedPool => ConsentSubjectKindV1::ProjectHub,
1657        CanisterControlClassV1::ExternallyImported | CanisterControlClassV1::JointlyControlled => {
1658            ConsentSubjectKindV1::CustomerController
1659        }
1660        CanisterControlClassV1::UserControlled => ConsentSubjectKindV1::UserPrincipal,
1661        CanisterControlClassV1::DeploymentControlled | CanisterControlClassV1::UnknownUnsafe => {
1662            ConsentSubjectKindV1::UnknownExternalController
1663        }
1664    }
1665}
1666
1667const fn consent_channel_kind(control_class: CanisterControlClassV1) -> ConsentChannelKindV1 {
1668    match control_class {
1669        CanisterControlClassV1::CanicManagedPool => ConsentChannelKindV1::DelegatedInstall,
1670        CanisterControlClassV1::ExternallyImported
1671        | CanisterControlClassV1::JointlyControlled
1672        | CanisterControlClassV1::UserControlled => ConsentChannelKindV1::GeneratedCommand,
1673        CanisterControlClassV1::DeploymentControlled | CanisterControlClassV1::UnknownUnsafe => {
1674            ConsentChannelKindV1::OutOfBand
1675        }
1676    }
1677}
1678
1679const fn required_consent_action(
1680    control_class: CanisterControlClassV1,
1681) -> ExternalUpgradeAuthorizationModeV1 {
1682    match control_class {
1683        CanisterControlClassV1::JointlyControlled => {
1684            ExternalUpgradeAuthorizationModeV1::ConsentForDirectInstall
1685        }
1686        CanisterControlClassV1::CanicManagedPool => {
1687            ExternalUpgradeAuthorizationModeV1::DelegatedInstallAuthority
1688        }
1689        CanisterControlClassV1::ExternallyImported | CanisterControlClassV1::UserControlled => {
1690            ExternalUpgradeAuthorizationModeV1::ExternalControllerExecution
1691        }
1692        CanisterControlClassV1::DeploymentControlled | CanisterControlClassV1::UnknownUnsafe => {
1693            ExternalUpgradeAuthorizationModeV1::ObserveAndVerifyOnly
1694        }
1695    }
1696}
1697
1698const fn lifecycle_mode(control_class: CanisterControlClassV1) -> LifecycleModeV1 {
1699    match control_class {
1700        CanisterControlClassV1::DeploymentControlled => LifecycleModeV1::DirectDeploymentAuthority,
1701        CanisterControlClassV1::CanicManagedPool => LifecycleModeV1::DelegatedInstallRequired,
1702        CanisterControlClassV1::ExternallyImported | CanisterControlClassV1::UserControlled => {
1703            LifecycleModeV1::ExternalCompletionOnly
1704        }
1705        CanisterControlClassV1::JointlyControlled => LifecycleModeV1::ProposalRequired,
1706        CanisterControlClassV1::UnknownUnsafe => LifecycleModeV1::UnknownUnsafeBlocked,
1707    }
1708}
1709
1710fn lifecycle_blockers(control_class: CanisterControlClassV1) -> Vec<String> {
1711    if control_class == CanisterControlClassV1::UnknownUnsafe {
1712        vec!["unknown unsafe controller state blocks lifecycle action".to_string()]
1713    } else {
1714        Vec::new()
1715    }
1716}
1717
1718fn lifecycle_warnings(control_class: CanisterControlClassV1) -> Vec<String> {
1719    match control_class {
1720        CanisterControlClassV1::CanicManagedPool => {
1721            vec!["pool-aware lifecycle policy is required before mutation".to_string()]
1722        }
1723        CanisterControlClassV1::ExternallyImported => {
1724            vec!["external controller action or verification is required".to_string()]
1725        }
1726        CanisterControlClassV1::JointlyControlled => {
1727            vec!["joint controller consent or delegation is required".to_string()]
1728        }
1729        CanisterControlClassV1::UserControlled => {
1730            vec!["user or delegated lifecycle action is required".to_string()]
1731        }
1732        CanisterControlClassV1::DeploymentControlled | CanisterControlClassV1::UnknownUnsafe => {
1733            Vec::new()
1734        }
1735    }
1736}
1737
1738fn lifecycle_upgrade_modes(control_class: CanisterControlClassV1) -> Vec<LifecycleUpgradeModeV1> {
1739    match control_class {
1740        CanisterControlClassV1::DeploymentControlled => vec![
1741            LifecycleUpgradeModeV1::DirectByDeploymentAuthority,
1742            LifecycleUpgradeModeV1::VerifyExternalCompletion,
1743        ],
1744        CanisterControlClassV1::CanicManagedPool
1745        | CanisterControlClassV1::ExternallyImported
1746        | CanisterControlClassV1::JointlyControlled
1747        | CanisterControlClassV1::UserControlled => vec![
1748            LifecycleUpgradeModeV1::ExternalProposal,
1749            LifecycleUpgradeModeV1::ExternalExecution,
1750            LifecycleUpgradeModeV1::VerifyExternalCompletion,
1751            LifecycleUpgradeModeV1::ObserveOnly,
1752        ],
1753        CanisterControlClassV1::UnknownUnsafe => vec![LifecycleUpgradeModeV1::Blocked],
1754    }
1755}
1756
1757fn lifecycle_verification_requirements(
1758    verifier_required: bool,
1759) -> Vec<LifecycleVerificationRequirementV1> {
1760    let mut requirements = vec![
1761        LifecycleVerificationRequirementV1::LiveInventory,
1762        LifecycleVerificationRequirementV1::ControllerObservation,
1763        LifecycleVerificationRequirementV1::ModuleHash,
1764        LifecycleVerificationRequirementV1::CanonicalEmbeddedConfig,
1765    ];
1766    if verifier_required {
1767        requirements.push(LifecycleVerificationRequirementV1::ProtectedCallReadiness);
1768    }
1769    requirements
1770}
1771
1772const fn lifecycle_external_action_required(control_class: CanisterControlClassV1) -> bool {
1773    matches!(
1774        control_class,
1775        CanisterControlClassV1::CanicManagedPool
1776            | CanisterControlClassV1::ExternallyImported
1777            | CanisterControlClassV1::JointlyControlled
1778            | CanisterControlClassV1::UserControlled
1779    )
1780}
1781
1782fn lifecycle_reason(control_class: CanisterControlClassV1) -> String {
1783    match control_class {
1784        CanisterControlClassV1::DeploymentControlled => {
1785            "deployment authority can execute lifecycle directly".to_string()
1786        }
1787        CanisterControlClassV1::CanicManagedPool => {
1788            "Canic-managed pool lifecycle requires pool-aware external action".to_string()
1789        }
1790        CanisterControlClassV1::ExternallyImported => {
1791            "externally imported canister requires external controller action".to_string()
1792        }
1793        CanisterControlClassV1::JointlyControlled => {
1794            "jointly controlled canister requires non-deployment-controller consent".to_string()
1795        }
1796        CanisterControlClassV1::UserControlled => {
1797            "user-controlled canister requires user or delegated lifecycle action".to_string()
1798        }
1799        CanisterControlClassV1::UnknownUnsafe => {
1800            "unknown or unsafe controller state blocks lifecycle action".to_string()
1801        }
1802    }
1803}
1804
1805fn observed_canister_for_expected<'a>(
1806    inventory: &'a DeploymentInventoryV1,
1807    expected: &ExpectedCanisterV1,
1808) -> Option<&'a ObservedCanisterV1> {
1809    if let Some(canister_id) = &expected.canister_id
1810        && let Some(observed) = inventory
1811            .observed_canisters
1812            .iter()
1813            .find(|observed| &observed.canister_id == canister_id)
1814    {
1815        return Some(observed);
1816    }
1817    inventory
1818        .observed_canisters
1819        .iter()
1820        .find(|observed| observed.role.as_deref() == Some(expected.role.as_str()))
1821}
1822
1823fn observed_pool_for_expected<'a>(
1824    inventory: &'a DeploymentInventoryV1,
1825    expected: &ExpectedPoolCanisterV1,
1826) -> Option<&'a ObservedPoolCanisterV1> {
1827    if let Some(canister_id) = &expected.canister_id
1828        && let Some(observed) = inventory
1829            .observed_pool
1830            .iter()
1831            .find(|observed| &observed.canister_id == canister_id)
1832    {
1833        return Some(observed);
1834    }
1835    inventory.observed_pool.iter().find(|observed| {
1836        observed.pool == expected.pool && observed.role.as_deref() == expected.role.as_deref()
1837    })
1838}
1839
1840fn lifecycle_subject(canister_id: &str, role: Option<&str>) -> String {
1841    lifecycle_subject_for_parts(Some(canister_id), role)
1842}
1843
1844fn lifecycle_subject_for_parts(canister_id: Option<&str>, role: Option<&str>) -> String {
1845    match (role, canister_id) {
1846        (Some(role), Some(canister_id)) => format!("{role}:{canister_id}"),
1847        (Some(role), None) => format!("{role}:unassigned"),
1848        (None, Some(canister_id)) => canister_id.to_string(),
1849        (None, None) => "unknown".to_string(),
1850    }
1851}
1852
1853fn observed_canister_for_authority<'a>(
1854    inventory: &'a DeploymentInventoryV1,
1855    authority: &LifecycleAuthorityV1,
1856) -> Option<&'a ObservedCanisterV1> {
1857    if let Some(canister_id) = &authority.canister_id
1858        && let Some(observed) = inventory
1859            .observed_canisters
1860            .iter()
1861            .find(|observed| &observed.canister_id == canister_id)
1862    {
1863        return Some(observed);
1864    }
1865    inventory
1866        .observed_canisters
1867        .iter()
1868        .find(|observed| observed.role == authority.role)
1869}
1870
1871fn target_artifact_for_authority<'a>(
1872    plan: &'a DeploymentPlanV1,
1873    authority: &LifecycleAuthorityV1,
1874) -> Option<&'a RoleArtifactV1> {
1875    let role = authority.role.as_ref()?;
1876    plan.role_artifacts
1877        .iter()
1878        .find(|artifact| &artifact.role == role)
1879}
1880
1881fn external_lifecycle_role_upgrade(
1882    authority: &LifecycleAuthorityV1,
1883) -> ExternalLifecycleRoleUpgradeV1 {
1884    ExternalLifecycleRoleUpgradeV1 {
1885        subject: authority.subject.clone(),
1886        canister_id: authority.canister_id.clone(),
1887        role: authority.role.clone(),
1888        control_class: authority.control_class,
1889        lifecycle_mode: authority.lifecycle_mode,
1890        required_external_action: authority
1891            .external_action_required
1892            .then(|| required_external_action(authority.lifecycle_mode).to_string()),
1893        blockers: authority.blockers.clone(),
1894        warnings: authority.warnings.clone(),
1895    }
1896}
1897
1898fn protected_call_implications_for_check(check: &DeploymentCheckV1) -> Vec<String> {
1899    if check.plan.expected_verifier_readiness.required {
1900        vec!["protected-call verifier readiness must be checked before completion".to_string()]
1901    } else {
1902        Vec::new()
1903    }
1904}
1905
1906const fn required_external_action(lifecycle_mode: LifecycleModeV1) -> &'static str {
1907    match lifecycle_mode {
1908        LifecycleModeV1::DirectDeploymentAuthority => "none",
1909        LifecycleModeV1::ProposalRequired => "proposal_and_consent",
1910        LifecycleModeV1::DelegatedInstallRequired => "delegated_install_or_pool_policy",
1911        LifecycleModeV1::ExternalCompletionOnly => "external_controller_execution",
1912        LifecycleModeV1::VerifyOnly => "verify_external_completion",
1913        LifecycleModeV1::MustNotTouch | LifecycleModeV1::UnknownUnsafeBlocked => "blocked",
1914    }
1915}
1916
1917fn role_artifact_identity(artifact: &RoleArtifactV1) -> String {
1918    stable_json_sha256_hex(&(
1919        artifact.role.as_str(),
1920        artifact.wasm_sha256.as_deref(),
1921        artifact.wasm_gz_sha256.as_deref(),
1922        artifact.installed_module_hash.as_deref(),
1923        artifact.candid_sha256.as_deref(),
1924        artifact.canonical_embedded_config_sha256.as_deref(),
1925    ))
1926}
1927
1928fn external_upgrade_authorization_modes(
1929    control_class: CanisterControlClassV1,
1930) -> Vec<ExternalUpgradeAuthorizationModeV1> {
1931    match control_class {
1932        CanisterControlClassV1::JointlyControlled => vec![
1933            ExternalUpgradeAuthorizationModeV1::ConsentForDirectInstall,
1934            ExternalUpgradeAuthorizationModeV1::DelegatedInstallAuthority,
1935            ExternalUpgradeAuthorizationModeV1::ExternalControllerExecution,
1936            ExternalUpgradeAuthorizationModeV1::ObserveAndVerifyOnly,
1937        ],
1938        CanisterControlClassV1::CanicManagedPool
1939        | CanisterControlClassV1::ExternallyImported
1940        | CanisterControlClassV1::UserControlled => vec![
1941            ExternalUpgradeAuthorizationModeV1::DelegatedInstallAuthority,
1942            ExternalUpgradeAuthorizationModeV1::ExternalControllerExecution,
1943            ExternalUpgradeAuthorizationModeV1::ObserveAndVerifyOnly,
1944        ],
1945        CanisterControlClassV1::DeploymentControlled | CanisterControlClassV1::UnknownUnsafe => {
1946            Vec::new()
1947        }
1948    }
1949}
1950
1951fn external_upgrade_proposal_id(report_id: &str, subject: &str) -> String {
1952    let subject = subject.replace([':', '/'], "-");
1953    format!("{report_id}:{subject}")
1954}
1955
1956fn external_lifecycle_plan_digest(plan: &ExternalLifecyclePlanV1) -> String {
1957    stable_json_sha256_hex(&ExternalLifecyclePlanDigestInput {
1958        lifecycle_authority_report_id: &plan.lifecycle_authority_report_id,
1959        deployment_plan_id: &plan.deployment_plan_id,
1960        deployment_plan_digest: &plan.deployment_plan_digest,
1961        inventory_id: &plan.inventory_id,
1962        lifecycle_authority_rows: &plan.lifecycle_authority_rows,
1963        directly_executable_role_upgrades: &plan.directly_executable_role_upgrades,
1964        proposed_external_role_upgrades: &plan.proposed_external_role_upgrades,
1965        blocked_role_upgrades: &plan.blocked_role_upgrades,
1966        dependency_blockers: &plan.dependency_blockers,
1967        protected_call_implications: &plan.protected_call_implications,
1968        residual_exposure: &plan.residual_exposure,
1969        status: plan.status,
1970    })
1971}
1972
1973fn lifecycle_authority_report_digest(report: &LifecycleAuthorityReportV1) -> String {
1974    stable_json_sha256_hex(&LifecycleAuthorityReportDigestInput {
1975        report_id: &report.report_id,
1976        check_id: &report.check_id,
1977        plan_id: &report.plan_id,
1978        inventory_id: &report.inventory_id,
1979        authorities: &report.authorities,
1980        external_action_required_count: report.external_action_required_count,
1981        blocked_count: report.blocked_count,
1982    })
1983}
1984
1985const fn expected_lifecycle_plan_status(
1986    plan: &ExternalLifecyclePlanV1,
1987) -> ExternalLifecyclePlanStatusV1 {
1988    if !plan.blocked_role_upgrades.is_empty() {
1989        ExternalLifecyclePlanStatusV1::Blocked
1990    } else if !plan.proposed_external_role_upgrades.is_empty() {
1991        ExternalLifecyclePlanStatusV1::PendingExternalAction
1992    } else {
1993        ExternalLifecyclePlanStatusV1::Ready
1994    }
1995}
1996
1997fn ensure_unique_lifecycle_subjects(
1998    rows: &[LifecycleAuthorityV1],
1999) -> Result<(), ExternalLifecyclePlanError> {
2000    let mut subjects = BTreeSet::new();
2001    for row in rows {
2002        if !subjects.insert(row.subject.clone()) {
2003            return Err(ExternalLifecyclePlanError::DuplicateSubject {
2004                subject: row.subject.clone(),
2005            });
2006        }
2007    }
2008    Ok(())
2009}
2010
2011fn ensure_unique_authority_subjects(
2012    rows: &[LifecycleAuthorityV1],
2013) -> Result<(), LifecycleAuthorityReportError> {
2014    let mut subjects = BTreeSet::new();
2015    for row in rows {
2016        if !subjects.insert(row.subject.clone()) {
2017            return Err(LifecycleAuthorityReportError::DuplicateSubject {
2018                subject: row.subject.clone(),
2019            });
2020        }
2021    }
2022    Ok(())
2023}
2024
2025fn ensure_unique_role_upgrade_subjects(
2026    rows: &[ExternalLifecycleRoleUpgradeV1],
2027) -> Result<(), ExternalLifecyclePlanError> {
2028    let mut subjects = BTreeSet::new();
2029    for row in rows {
2030        if !subjects.insert(row.subject.clone()) {
2031            return Err(ExternalLifecyclePlanError::DuplicateSubject {
2032                subject: row.subject.clone(),
2033            });
2034        }
2035    }
2036    Ok(())
2037}
2038
2039fn external_upgrade_proposal_digest(proposal: &ExternalUpgradeProposalV1) -> String {
2040    stable_json_sha256_hex(&ExternalUpgradeProposalDigestInput {
2041        deployment_plan_id: &proposal.deployment_plan_id,
2042        deployment_plan_digest: &proposal.deployment_plan_digest,
2043        lifecycle_plan_id: &proposal.lifecycle_plan_id,
2044        lifecycle_plan_digest: &proposal.lifecycle_plan_digest,
2045        promotion_plan_id: &proposal.promotion_plan_id,
2046        promotion_plan_digest: &proposal.promotion_plan_digest,
2047        promotion_provenance_id: &proposal.promotion_provenance_id,
2048        promotion_provenance_digest: &proposal.promotion_provenance_digest,
2049        subject: &proposal.subject,
2050        canister_id: &proposal.canister_id,
2051        role: &proposal.role,
2052        control_class: proposal.control_class,
2053        lifecycle_mode: proposal.lifecycle_mode,
2054        observed_before_digest: &proposal.observed_before_digest,
2055        current_module_hash: &proposal.current_module_hash,
2056        current_canonical_embedded_config_sha256: &proposal
2057            .current_canonical_embedded_config_sha256,
2058        target_wasm_sha256: &proposal.target_wasm_sha256,
2059        target_wasm_gz_sha256: &proposal.target_wasm_gz_sha256,
2060        target_installed_module_hash: &proposal.target_installed_module_hash,
2061        target_role_artifact_identity: &proposal.target_role_artifact_identity,
2062        target_canonical_embedded_config_sha256: &proposal.target_canonical_embedded_config_sha256,
2063        root_trust_anchor: &proposal.root_trust_anchor,
2064        authority_profile_hash: &proposal.authority_profile_hash,
2065        required_external_action: &proposal.required_external_action,
2066        consent_requirements: &proposal.consent_requirements,
2067        allowed_authorization_modes: &proposal.allowed_authorization_modes,
2068        verification_requirements: &proposal.verification_requirements,
2069        expires_at: &proposal.expires_at,
2070        supersedes_proposal_id: &proposal.supersedes_proposal_id,
2071    })
2072}
2073
2074fn external_upgrade_proposal_report_digest(report: &ExternalUpgradeProposalReportV1) -> String {
2075    stable_json_sha256_hex(&ExternalUpgradeProposalReportDigestInput {
2076        report_id: &report.report_id,
2077        lifecycle_plan_id: &report.lifecycle_plan_id,
2078        lifecycle_plan_digest: &report.lifecycle_plan_digest,
2079        deployment_plan_id: &report.deployment_plan_id,
2080        deployment_plan_digest: &report.deployment_plan_digest,
2081        inventory_id: &report.inventory_id,
2082        proposals: &report.proposals,
2083        blocked_subjects: &report.blocked_subjects,
2084    })
2085}
2086
2087fn external_lifecycle_pending_report_digest(report: &ExternalLifecyclePendingReportV1) -> String {
2088    stable_json_sha256_hex(&ExternalLifecyclePendingReportDigestInput {
2089        report_id: &report.report_id,
2090        lifecycle_plan_id: &report.lifecycle_plan_id,
2091        lifecycle_plan_digest: &report.lifecycle_plan_digest,
2092        proposal_report_id: &report.proposal_report_id,
2093        proposal_report_digest: &report.proposal_report_digest,
2094        deployment_plan_id: &report.deployment_plan_id,
2095        deployment_plan_digest: &report.deployment_plan_digest,
2096        inventory_id: &report.inventory_id,
2097        direct_upgrade_count: report.direct_upgrade_count,
2098        pending_external_count: report.pending_external_count,
2099        blocked_count: report.blocked_count,
2100        pending_external_actions: &report.pending_external_actions,
2101        blocked_subjects: &report.blocked_subjects,
2102        residual_exposure: &report.residual_exposure,
2103        status: report.status,
2104    })
2105}
2106
2107fn critical_external_fix_report_digest(report: &CriticalExternalFixReportV1) -> String {
2108    stable_json_sha256_hex(&CriticalExternalFixReportDigestInput {
2109        report_id: &report.report_id,
2110        fix_id: &report.fix_id,
2111        severity: &report.severity,
2112        lifecycle_plan_id: &report.lifecycle_plan_id,
2113        lifecycle_plan_digest: &report.lifecycle_plan_digest,
2114        pending_report_id: &report.pending_report_id,
2115        pending_report_digest: &report.pending_report_digest,
2116        deployment_plan_id: &report.deployment_plan_id,
2117        deployment_plan_digest: &report.deployment_plan_digest,
2118        inventory_id: &report.inventory_id,
2119        affected_roles: &report.affected_roles,
2120        affected_canisters: &report.affected_canisters,
2121        directly_patchable_roles: &report.directly_patchable_roles,
2122        externally_blocked_roles: &report.externally_blocked_roles,
2123        dependency_blocked_roles: &report.dependency_blocked_roles,
2124        required_external_actions: &report.required_external_actions,
2125        protected_call_implications: &report.protected_call_implications,
2126        residual_exposure: &report.residual_exposure,
2127        operator_next_steps: &report.operator_next_steps,
2128    })
2129}
2130
2131fn external_upgrade_receipt_digest(receipt: &ExternalUpgradeReceiptV1) -> String {
2132    stable_json_sha256_hex(&ExternalUpgradeReceiptDigestInput {
2133        proposal_id: &receipt.proposal_id,
2134        proposal_digest: &receipt.proposal_digest,
2135        subject: &receipt.subject,
2136        canister_id: &receipt.canister_id,
2137        role: &receipt.role,
2138        consent_state: receipt.consent_state,
2139        reported_by: &receipt.reported_by,
2140        observed_before_module_hash: &receipt.observed_before_module_hash,
2141        observed_after_module_hash: &receipt.observed_after_module_hash,
2142        observed_after_canonical_embedded_config_sha256: &receipt
2143            .observed_after_canonical_embedded_config_sha256,
2144        verification_result: receipt.verification_result,
2145        verification_notes: &receipt.verification_notes,
2146    })
2147}
2148
2149fn external_upgrade_consent_evidence_digest(evidence: &ExternalUpgradeConsentEvidenceV1) -> String {
2150    stable_json_sha256_hex(&ExternalUpgradeConsentEvidenceDigestInput {
2151        evidence_id: &evidence.evidence_id,
2152        proposal_id: &evidence.proposal_id,
2153        proposal_digest: &evidence.proposal_digest,
2154        receipt_id: &evidence.receipt_id,
2155        receipt_digest: &evidence.receipt_digest,
2156        subject: &evidence.subject,
2157        canister_id: &evidence.canister_id,
2158        role: &evidence.role,
2159        consent_state: evidence.consent_state,
2160        reported_by: &evidence.reported_by,
2161        consent_requirements: &evidence.consent_requirements,
2162        allowed_authorization_modes: &evidence.allowed_authorization_modes,
2163        status_summary: &evidence.status_summary,
2164    })
2165}
2166
2167fn external_upgrade_verification_report_digest(
2168    report: &ExternalUpgradeVerificationReportV1,
2169) -> String {
2170    stable_json_sha256_hex(&ExternalUpgradeVerificationReportDigestInput {
2171        report_id: &report.report_id,
2172        proposal_id: &report.proposal_id,
2173        proposal_digest: &report.proposal_digest,
2174        receipt_id: &report.receipt_id,
2175        receipt_digest: &report.receipt_digest,
2176        subject: &report.subject,
2177        canister_id: &report.canister_id,
2178        role: &report.role,
2179        verification_result: report.verification_result,
2180        verification_notes: &report.verification_notes,
2181        live_inventory_required: report.live_inventory_required,
2182        status_summary: &report.status_summary,
2183    })
2184}
2185
2186fn observed_before_digest(
2187    authority: &LifecycleAuthorityV1,
2188    current_module_hash: Option<&String>,
2189    current_config_hash: Option<&String>,
2190) -> String {
2191    stable_json_sha256_hex(&ObservedBeforeDigestInput {
2192        subject: &authority.subject,
2193        canister_id: &authority.canister_id,
2194        role: &authority.role,
2195        observed_controllers: &authority.observed_controllers,
2196        current_module_hash,
2197        current_canonical_embedded_config_sha256: current_config_hash,
2198    })
2199}
2200
2201fn external_upgrade_verification_result(
2202    consent_state: ExternalUpgradeConsentStateV1,
2203    proposal: &ExternalUpgradeProposalV1,
2204    observed_after_module_hash: Option<&str>,
2205    observed_after_config: Option<&str>,
2206) -> ExternalUpgradeVerificationResultV1 {
2207    match consent_state {
2208        ExternalUpgradeConsentStateV1::Pending => ExternalUpgradeVerificationResultV1::Pending,
2209        ExternalUpgradeConsentStateV1::Refused => ExternalUpgradeVerificationResultV1::Refused,
2210        ExternalUpgradeConsentStateV1::Delegated
2211        | ExternalUpgradeConsentStateV1::ExecutedExternally => {
2212            if external_upgrade_observation_matches(
2213                proposal.target_installed_module_hash.as_deref(),
2214                observed_after_module_hash,
2215            ) && external_upgrade_observation_matches(
2216                proposal.target_canonical_embedded_config_sha256.as_deref(),
2217                observed_after_config,
2218            ) {
2219                ExternalUpgradeVerificationResultV1::Verified
2220            } else {
2221                ExternalUpgradeVerificationResultV1::Mismatch
2222            }
2223        }
2224    }
2225}
2226
2227fn external_upgrade_verification_notes(
2228    verification_result: ExternalUpgradeVerificationResultV1,
2229    proposal: &ExternalUpgradeProposalV1,
2230    observed_after_module_hash: Option<&str>,
2231    observed_after_config: Option<&str>,
2232) -> Vec<String> {
2233    let mut notes = Vec::new();
2234    if verification_result == ExternalUpgradeVerificationResultV1::Mismatch {
2235        if !external_upgrade_observation_matches(
2236            proposal.target_installed_module_hash.as_deref(),
2237            observed_after_module_hash,
2238        ) {
2239            notes.push("observed module hash does not match proposal target".to_string());
2240        }
2241        if !external_upgrade_observation_matches(
2242            proposal.target_canonical_embedded_config_sha256.as_deref(),
2243            observed_after_config,
2244        ) {
2245            notes.push("observed embedded config does not match proposal target".to_string());
2246        }
2247    }
2248    notes
2249}
2250
2251const fn external_upgrade_verification_summary(
2252    result: ExternalUpgradeVerificationResultV1,
2253) -> &'static str {
2254    match result {
2255        ExternalUpgradeVerificationResultV1::Pending => {
2256            "external action has not been reported as complete"
2257        }
2258        ExternalUpgradeVerificationResultV1::Refused => "external consent was refused",
2259        ExternalUpgradeVerificationResultV1::Verified => {
2260            "reported external completion matches proposal target facts"
2261        }
2262        ExternalUpgradeVerificationResultV1::Mismatch => {
2263            "reported external completion does not match proposal target facts"
2264        }
2265    }
2266}
2267
2268const fn external_upgrade_consent_summary(state: ExternalUpgradeConsentStateV1) -> &'static str {
2269    match state {
2270        ExternalUpgradeConsentStateV1::Pending => {
2271            "external consent or action has not been reported"
2272        }
2273        ExternalUpgradeConsentStateV1::Refused => "external consent was refused",
2274        ExternalUpgradeConsentStateV1::Delegated => "delegated install authority was reported",
2275        ExternalUpgradeConsentStateV1::ExecutedExternally => {
2276            "external controller execution was reported"
2277        }
2278    }
2279}
2280
2281fn external_upgrade_observation_matches(expected: Option<&str>, observed: Option<&str>) -> bool {
2282    expected.is_none_or(|expected| observed == Some(expected))
2283}
2284
2285fn ensure_external_receipt_field(
2286    field: &'static str,
2287    value: &str,
2288) -> Result<(), ExternalUpgradeReceiptError> {
2289    if value.trim().is_empty() {
2290        return Err(ExternalUpgradeReceiptError::MissingRequiredField { field });
2291    }
2292    Ok(())
2293}
2294
2295fn ensure_external_receipt_matches_proposal(
2296    field: &'static str,
2297    actual: &str,
2298    expected: &str,
2299) -> Result<(), ExternalUpgradeReceiptError> {
2300    if actual != expected {
2301        return Err(ExternalUpgradeReceiptError::SourceMismatch { field });
2302    }
2303    Ok(())
2304}
2305
2306fn ensure_external_receipt_option_matches_proposal(
2307    field: &'static str,
2308    actual: Option<&str>,
2309    expected: Option<&str>,
2310) -> Result<(), ExternalUpgradeReceiptError> {
2311    if actual != expected {
2312        return Err(ExternalUpgradeReceiptError::SourceMismatch { field });
2313    }
2314    Ok(())
2315}
2316
2317fn ensure_external_consent_evidence_field(
2318    field: &'static str,
2319    value: &str,
2320) -> Result<(), ExternalUpgradeConsentEvidenceError> {
2321    if value.trim().is_empty() {
2322        return Err(ExternalUpgradeConsentEvidenceError::MissingRequiredField { field });
2323    }
2324    Ok(())
2325}
2326
2327fn ensure_external_verification_report_field(
2328    field: &'static str,
2329    value: &str,
2330) -> Result<(), ExternalUpgradeVerificationReportError> {
2331    if value.trim().is_empty() {
2332        return Err(ExternalUpgradeVerificationReportError::MissingRequiredField { field });
2333    }
2334    Ok(())
2335}
2336
2337fn ensure_external_lifecycle_plan_field(
2338    field: &'static str,
2339    value: &str,
2340) -> Result<(), ExternalLifecyclePlanError> {
2341    if value.trim().is_empty() {
2342        return Err(ExternalLifecyclePlanError::MissingRequiredField { field });
2343    }
2344    Ok(())
2345}
2346
2347fn ensure_external_proposal_report_field(
2348    field: &'static str,
2349    value: &str,
2350) -> Result<(), ExternalUpgradeProposalReportError> {
2351    if value.trim().is_empty() {
2352        return Err(ExternalUpgradeProposalReportError::MissingRequiredField { field });
2353    }
2354    Ok(())
2355}
2356
2357fn ensure_external_pending_report_field(
2358    field: &'static str,
2359    value: &str,
2360) -> Result<(), ExternalLifecyclePendingReportError> {
2361    if value.trim().is_empty() {
2362        return Err(ExternalLifecyclePendingReportError::MissingRequiredField { field });
2363    }
2364    Ok(())
2365}
2366
2367fn ensure_critical_fix_report_field(
2368    field: &'static str,
2369    value: &str,
2370) -> Result<(), CriticalExternalFixReportError> {
2371    if value.trim().is_empty() {
2372        return Err(CriticalExternalFixReportError::MissingRequiredField { field });
2373    }
2374    Ok(())
2375}
2376
2377fn ensure_lifecycle_authority_report_field(
2378    field: &'static str,
2379    value: &str,
2380) -> Result<(), LifecycleAuthorityReportError> {
2381    if value.trim().is_empty() {
2382        return Err(LifecycleAuthorityReportError::MissingRequiredField { field });
2383    }
2384    Ok(())
2385}
2386
2387fn sorted_unique(values: Vec<String>) -> Vec<String> {
2388    values
2389        .into_iter()
2390        .collect::<BTreeSet<_>>()
2391        .into_iter()
2392        .collect()
2393}