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