Skip to main content

canic_host/deployment_truth/
lifecycle.rs

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