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    deployment_plan_id: &'a str,
214    deployment_plan_digest: &'a str,
215    subject: &'a str,
216    canister_id: &'a Option<String>,
217    role: &'a Option<String>,
218    required_verification: &'a [LifecycleVerificationRequirementV1],
219    verification_requirements: &'a [ExternalUpgradeVerificationPolicyRequirementV1],
220    max_observation_age_seconds: Option<u64>,
221    status_summary: &'a str,
222}
223
224#[derive(Serialize)]
225struct ExternalUpgradeVerificationCheckDigestInput<'a> {
226    check_id: &'a str,
227    policy_id: &'a str,
228    policy_digest: &'a str,
229    proposal_id: &'a str,
230    proposal_digest: &'a str,
231    subject: &'a str,
232    canister_id: &'a Option<String>,
233    role: &'a Option<String>,
234    observation: &'a ExternalUpgradeVerificationObservationV1,
235    requirement_results: &'a [ExternalUpgradeVerificationCheckRequirementV1],
236    verification_result: ExternalUpgradeVerificationResultV1,
237    status_summary: &'a str,
238}
239
240#[derive(Serialize)]
241struct ExternalUpgradeCompletionReportDigestInput<'a> {
242    report_id: &'a str,
243    proposal_id: &'a str,
244    proposal_digest: &'a str,
245    consent_evidence_id: &'a str,
246    consent_evidence_digest: &'a str,
247    verification_check_id: &'a str,
248    verification_check_digest: &'a str,
249    subject: &'a str,
250    canister_id: &'a Option<String>,
251    role: &'a Option<String>,
252    consent_state: ExternalUpgradeConsentStateV1,
253    verification_result: ExternalUpgradeVerificationResultV1,
254    verification_observation_source: ExternalVerificationObservationSourceV1,
255    completion_status: ExternalUpgradeCompletionStatusV1,
256    blockers: &'a [String],
257    next_actions: &'a [String],
258    status_summary: &'a str,
259}
260
261#[derive(Serialize)]
262struct ObservedBeforeDigestInput<'a> {
263    subject: &'a str,
264    canister_id: &'a Option<String>,
265    role: &'a Option<String>,
266    observed_controllers: &'a [String],
267    current_module_hash: Option<&'a String>,
268    current_canonical_embedded_config_sha256: Option<&'a String>,
269}
270
271///
272/// ExternalUpgradeReceiptError
273///
274#[derive(Debug, Eq, thiserror::Error, PartialEq)]
275pub enum ExternalUpgradeReceiptError {
276    #[error("external upgrade receipt schema version {actual} does not match expected {expected}")]
277    SchemaVersionMismatch { expected: u32, actual: u32 },
278    #[error("external upgrade receipt field `{field}` is required")]
279    MissingRequiredField { field: &'static str },
280    #[error("external upgrade receipt field `{field}` digest is stale")]
281    DigestMismatch { field: &'static str },
282    #[error("external upgrade receipt field `{field}` does not match proposal source")]
283    SourceMismatch { field: &'static str },
284    #[error("external upgrade receipt verification result does not match observations")]
285    VerificationMismatch,
286    #[error("external upgrade receipt refused consent cannot be verified")]
287    RefusedConsentVerified,
288}
289
290///
291/// ExternalUpgradeConsentEvidenceError
292///
293#[derive(Debug, Eq, thiserror::Error, PartialEq)]
294pub enum ExternalUpgradeConsentEvidenceError {
295    #[error(
296        "external upgrade consent evidence schema version {actual} does not match expected {expected}"
297    )]
298    SchemaVersionMismatch { expected: u32, actual: u32 },
299    #[error("external upgrade consent evidence field `{field}` is required")]
300    MissingRequiredField { field: &'static str },
301    #[error("external upgrade consent evidence field `{field}` digest is stale")]
302    DigestMismatch { field: &'static str },
303    #[error("external upgrade consent evidence field `{field}` no longer matches source receipt")]
304    SourceMismatch { field: &'static str },
305    #[error(transparent)]
306    Receipt(#[from] ExternalUpgradeReceiptError),
307}
308
309///
310/// ExternalUpgradeVerificationReportError
311///
312#[derive(Debug, Eq, thiserror::Error, PartialEq)]
313pub enum ExternalUpgradeVerificationReportError {
314    #[error(
315        "external upgrade verification report schema version {actual} does not match expected {expected}"
316    )]
317    SchemaVersionMismatch { expected: u32, actual: u32 },
318    #[error("external upgrade verification report field `{field}` is required")]
319    MissingRequiredField { field: &'static str },
320    #[error("external upgrade verification report field `{field}` digest is stale")]
321    DigestMismatch { field: &'static str },
322    #[error("external upgrade verification report field `{field}` does not match source evidence")]
323    SourceMismatch { field: &'static str },
324    #[error(transparent)]
325    Receipt(#[from] ExternalUpgradeReceiptError),
326}
327
328///
329/// ExternalUpgradeVerificationPolicyError
330///
331#[derive(Debug, Eq, thiserror::Error, PartialEq)]
332pub enum ExternalUpgradeVerificationPolicyError {
333    #[error(
334        "external upgrade verification policy schema version {actual} does not match expected {expected}"
335    )]
336    SchemaVersionMismatch { expected: u32, actual: u32 },
337    #[error("external upgrade verification policy field `{field}` is required")]
338    MissingRequiredField { field: &'static str },
339    #[error("external upgrade verification policy field `{field}` digest is stale")]
340    DigestMismatch { field: &'static str },
341    #[error("external upgrade verification policy field `{field}` does not match proposal source")]
342    SourceMismatch { field: &'static str },
343}
344
345///
346/// ExternalUpgradeVerificationCheckError
347///
348#[derive(Debug, Eq, thiserror::Error, PartialEq)]
349pub enum ExternalUpgradeVerificationCheckError {
350    #[error(
351        "external upgrade verification check schema version {actual} does not match expected {expected}"
352    )]
353    SchemaVersionMismatch { expected: u32, actual: u32 },
354    #[error("external upgrade verification check field `{field}` is required")]
355    MissingRequiredField { field: &'static str },
356    #[error("external upgrade verification check field `{field}` digest is stale")]
357    DigestMismatch { field: &'static str },
358    #[error("external upgrade verification check field `{field}` does not match policy source")]
359    SourceMismatch { field: &'static str },
360    #[error("external upgrade verification check contains duplicate requirement `{requirement:?}`")]
361    DuplicateRequirement {
362        requirement: LifecycleVerificationRequirementV1,
363    },
364    #[error(
365        "external upgrade verification check requirement `{requirement:?}` has invalid satisfaction state"
366    )]
367    RequirementStatusMismatch {
368        requirement: LifecycleVerificationRequirementV1,
369    },
370}
371
372///
373/// ExternalUpgradeCompletionReportError
374///
375#[derive(Debug, Eq, thiserror::Error, PartialEq)]
376pub enum ExternalUpgradeCompletionReportError {
377    #[error(
378        "external upgrade completion report schema version {actual} does not match expected {expected}"
379    )]
380    SchemaVersionMismatch { expected: u32, actual: u32 },
381    #[error("external upgrade completion report field `{field}` is required")]
382    MissingRequiredField { field: &'static str },
383    #[error("external upgrade completion report field `{field}` digest is stale")]
384    DigestMismatch { field: &'static str },
385    #[error("external upgrade completion report field `{field}` does not match source evidence")]
386    SourceMismatch { field: &'static str },
387    #[error(transparent)]
388    Proposal(#[from] ExternalUpgradeProposalReportError),
389    #[error(transparent)]
390    ConsentEvidence(#[from] ExternalUpgradeConsentEvidenceError),
391    #[error(transparent)]
392    VerificationCheck(#[from] ExternalUpgradeVerificationCheckError),
393}
394
395///
396/// LifecycleAuthorityReportError
397///
398#[derive(Debug, Eq, thiserror::Error, PartialEq)]
399pub enum LifecycleAuthorityReportError {
400    #[error(
401        "lifecycle authority report schema version {actual} does not match expected {expected}"
402    )]
403    SchemaVersionMismatch { expected: u32, actual: u32 },
404    #[error("lifecycle authority report field `{field}` is required")]
405    MissingRequiredField { field: &'static str },
406    #[error("lifecycle authority report field `{field}` digest is stale")]
407    DigestMismatch { field: &'static str },
408    #[error("lifecycle authority report contains duplicate subject `{subject}`")]
409    DuplicateSubject { subject: String },
410    #[error("lifecycle authority report counters do not match authority rows")]
411    CountMismatch,
412}
413
414///
415/// ExternalLifecyclePlanError
416///
417#[derive(Debug, Eq, thiserror::Error, PartialEq)]
418pub enum ExternalLifecyclePlanError {
419    #[error("external lifecycle plan schema version {actual} does not match expected {expected}")]
420    SchemaVersionMismatch { expected: u32, actual: u32 },
421    #[error("external lifecycle plan field `{field}` is required")]
422    MissingRequiredField { field: &'static str },
423    #[error("external lifecycle plan field `{field}` digest is stale")]
424    DigestMismatch { field: &'static str },
425    #[error("external lifecycle plan field `{field}` does not match deployment truth source")]
426    SourceMismatch { field: &'static str },
427    #[error("external lifecycle plan status does not match role partitioning")]
428    StatusMismatch,
429    #[error("external lifecycle plan contains duplicate subject `{subject}`")]
430    DuplicateSubject { subject: String },
431}
432
433///
434/// ExternalUpgradeProposalReportError
435///
436#[derive(Debug, Eq, thiserror::Error, PartialEq)]
437pub enum ExternalUpgradeProposalReportError {
438    #[error(
439        "external upgrade proposal report schema version {actual} does not match expected {expected}"
440    )]
441    SchemaVersionMismatch { expected: u32, actual: u32 },
442    #[error("external upgrade proposal report field `{field}` is required")]
443    MissingRequiredField { field: &'static str },
444    #[error("external upgrade proposal report field `{field}` digest is stale")]
445    DigestMismatch { field: &'static str },
446    #[error("external upgrade proposal report field `{field}` does not match lifecycle source")]
447    SourceMismatch { field: &'static str },
448    #[error(
449        "external upgrade proposal report contains proposal for directly controlled row `{subject}`"
450    )]
451    DirectLifecycleProposal { subject: String },
452    #[error("external upgrade proposal report contains duplicate subject `{subject}`")]
453    DuplicateSubject { subject: String },
454}
455
456///
457/// ExternalLifecyclePendingReportError
458///
459#[derive(Debug, Eq, thiserror::Error, PartialEq)]
460pub enum ExternalLifecyclePendingReportError {
461    #[error(
462        "external lifecycle pending report schema version {actual} does not match expected {expected}"
463    )]
464    SchemaVersionMismatch { expected: u32, actual: u32 },
465    #[error("external lifecycle pending report field `{field}` is required")]
466    MissingRequiredField { field: &'static str },
467    #[error("external lifecycle pending report field `{field}` digest is stale")]
468    DigestMismatch { field: &'static str },
469    #[error("external lifecycle pending report field `{field}` does not match lifecycle source")]
470    SourceMismatch { field: &'static str },
471    #[error("external lifecycle pending report counters do not match action rows")]
472    CountMismatch,
473    #[error("external lifecycle pending report contains duplicate subject `{subject}`")]
474    DuplicateSubject { subject: String },
475}
476
477///
478/// ExternalLifecycleCheckError
479///
480#[derive(Debug, Eq, thiserror::Error, PartialEq)]
481pub enum ExternalLifecycleCheckError {
482    #[error("external lifecycle check schema version {actual} does not match expected {expected}")]
483    SchemaVersionMismatch { expected: u32, actual: u32 },
484    #[error("external lifecycle check field `{field}` is required")]
485    MissingRequiredField { field: &'static str },
486    #[error("external lifecycle check field `{field}` digest is stale")]
487    DigestMismatch { field: &'static str },
488    #[error("external lifecycle check field `{field}` does not match lifecycle source")]
489    SourceMismatch { field: &'static str },
490    #[error("external lifecycle check counters do not match source reports")]
491    CountMismatch,
492}
493
494///
495/// ExternalLifecycleHandoffError
496///
497#[derive(Debug, Eq, thiserror::Error, PartialEq)]
498pub enum ExternalLifecycleHandoffError {
499    #[error(
500        "external lifecycle handoff schema version {actual} does not match expected {expected}"
501    )]
502    SchemaVersionMismatch { expected: u32, actual: u32 },
503    #[error("external lifecycle handoff field `{field}` is required")]
504    MissingRequiredField { field: &'static str },
505    #[error("external lifecycle handoff field `{field}` digest is stale")]
506    DigestMismatch { field: &'static str },
507    #[error("external lifecycle handoff field `{field}` does not match lifecycle source")]
508    SourceMismatch { field: &'static str },
509    #[error("external lifecycle handoff contains duplicate subject `{subject}`")]
510    DuplicateSubject { subject: String },
511}
512
513///
514/// CriticalExternalFixReportError
515///
516#[derive(Debug, Eq, thiserror::Error, PartialEq)]
517pub enum CriticalExternalFixReportError {
518    #[error(
519        "critical external fix report schema version {actual} does not match expected {expected}"
520    )]
521    SchemaVersionMismatch { expected: u32, actual: u32 },
522    #[error("critical external fix report field `{field}` is required")]
523    MissingRequiredField { field: &'static str },
524    #[error("critical external fix report field `{field}` digest is stale")]
525    DigestMismatch { field: &'static str },
526    #[error("critical external fix report field `{field}` does not match lifecycle source")]
527    SourceMismatch { field: &'static str },
528}
529
530/// Project the existing deployment truth control classifications into the 0.45
531/// lifecycle-authority view. This is observational and must not mutate IC or
532/// local deployment state.
533#[must_use]
534pub fn lifecycle_authority_report_from_check(
535    report_id: impl Into<String>,
536    check: &DeploymentCheckV1,
537) -> LifecycleAuthorityReportV1 {
538    let mut authorities = Vec::new();
539    let mut seen_subjects = BTreeSet::new();
540
541    for expected in &check.plan.expected_canisters {
542        let observed = observed_canister_for_expected(&check.inventory, expected);
543        let authority = lifecycle_authority_for_expected_canister(&check.plan, expected, observed);
544        seen_subjects.insert(authority.subject.clone());
545        authorities.push(authority);
546    }
547
548    for expected in &check.plan.expected_pool {
549        let observed = observed_pool_for_expected(&check.inventory, expected);
550        let authority = lifecycle_authority_for_expected_pool(expected, observed);
551        seen_subjects.insert(authority.subject.clone());
552        authorities.push(authority);
553    }
554
555    for observed in &check.inventory.observed_canisters {
556        let subject = lifecycle_subject(observed.canister_id.as_str(), observed.role.as_deref());
557        if seen_subjects.contains(&subject) {
558            continue;
559        }
560        authorities.push(lifecycle_authority_for_unplanned_canister(observed));
561    }
562
563    for observed in &check.inventory.observed_pool {
564        let subject = lifecycle_subject(observed.canister_id.as_str(), observed.role.as_deref());
565        if seen_subjects.contains(&subject) {
566            continue;
567        }
568        authorities.push(lifecycle_authority_for_unplanned_pool(observed));
569    }
570
571    authorities.sort_by(|left, right| left.subject.cmp(&right.subject));
572    let external_action_required_count = authorities
573        .iter()
574        .filter(|authority| authority.external_action_required)
575        .count();
576    let blocked_count = authorities
577        .iter()
578        .filter(|authority| authority.blocked)
579        .count();
580
581    let mut report = LifecycleAuthorityReportV1 {
582        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
583        report_id: report_id.into(),
584        report_digest: String::new(),
585        check_id: check.check_id.clone(),
586        plan_id: check.plan.plan_id.clone(),
587        inventory_id: check.inventory.inventory_id.clone(),
588        authorities,
589        external_action_required_count,
590        blocked_count,
591    };
592    report.report_digest = lifecycle_authority_report_digest(&report);
593    report
594}
595
596/// Validate archived lifecycle authority report consistency and digests.
597pub fn validate_lifecycle_authority_report(
598    report: &LifecycleAuthorityReportV1,
599) -> Result<(), LifecycleAuthorityReportError> {
600    if report.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
601        return Err(LifecycleAuthorityReportError::SchemaVersionMismatch {
602            expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
603            actual: report.schema_version,
604        });
605    }
606    ensure_lifecycle_authority_report_field("report_id", report.report_id.as_str())?;
607    ensure_lifecycle_authority_report_field("report_digest", report.report_digest.as_str())?;
608    ensure_lifecycle_authority_report_field("check_id", report.check_id.as_str())?;
609    ensure_lifecycle_authority_report_field("plan_id", report.plan_id.as_str())?;
610    ensure_lifecycle_authority_report_field("inventory_id", report.inventory_id.as_str())?;
611    ensure_unique_authority_subjects(&report.authorities)?;
612    if report.external_action_required_count
613        != report
614            .authorities
615            .iter()
616            .filter(|authority| authority.external_action_required)
617            .count()
618        || report.blocked_count
619            != report
620                .authorities
621                .iter()
622                .filter(|authority| authority.blocked)
623                .count()
624    {
625        return Err(LifecycleAuthorityReportError::CountMismatch);
626    }
627    if report.report_digest != lifecycle_authority_report_digest(report) {
628        return Err(LifecycleAuthorityReportError::DigestMismatch {
629            field: "report_digest",
630        });
631    }
632    Ok(())
633}
634
635/// Build the central 0.45 lifecycle plan from deployment truth.
636///
637/// This partitions roles into directly executable, externally proposed, and
638/// blocked lifecycle rows. It is passive and does not perform proposal
639/// delivery, consent, or execution.
640#[must_use]
641pub fn external_lifecycle_plan_from_check(
642    lifecycle_plan_id: impl Into<String>,
643    lifecycle_authority_report_id: impl Into<String>,
644    check: &DeploymentCheckV1,
645) -> ExternalLifecyclePlanV1 {
646    let lifecycle_authority_report =
647        lifecycle_authority_report_from_check(lifecycle_authority_report_id, check);
648    let lifecycle_authority_rows = lifecycle_authority_report.authorities;
649    let directly_executable_role_upgrades = lifecycle_authority_rows
650        .iter()
651        .filter(|authority| {
652            authority.lifecycle_mode == LifecycleModeV1::DirectDeploymentAuthority
653                && !authority.blocked
654        })
655        .map(external_lifecycle_role_upgrade)
656        .collect::<Vec<_>>();
657    let proposed_external_role_upgrades = lifecycle_authority_rows
658        .iter()
659        .filter(|authority| authority.external_action_required && !authority.blocked)
660        .map(external_lifecycle_role_upgrade)
661        .collect::<Vec<_>>();
662    let blocked_role_upgrades = lifecycle_authority_rows
663        .iter()
664        .filter(|authority| authority.blocked)
665        .map(external_lifecycle_role_upgrade)
666        .collect::<Vec<_>>();
667    let residual_exposure = proposed_external_role_upgrades
668        .iter()
669        .map(|upgrade| {
670            format!(
671                "{} remains pending external lifecycle action",
672                upgrade.subject
673            )
674        })
675        .collect::<Vec<_>>();
676    let status = if !blocked_role_upgrades.is_empty() {
677        ExternalLifecyclePlanStatusV1::Blocked
678    } else if !proposed_external_role_upgrades.is_empty() {
679        ExternalLifecyclePlanStatusV1::PendingExternalAction
680    } else {
681        ExternalLifecyclePlanStatusV1::Ready
682    };
683    let deployment_plan_digest = stable_json_sha256_hex(&check.plan);
684    let mut plan = ExternalLifecyclePlanV1 {
685        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
686        lifecycle_plan_id: lifecycle_plan_id.into(),
687        lifecycle_plan_digest: String::new(),
688        lifecycle_authority_report_id: lifecycle_authority_report.report_id,
689        deployment_plan_id: check.plan.plan_id.clone(),
690        deployment_plan_digest,
691        inventory_id: check.inventory.inventory_id.clone(),
692        lifecycle_authority_rows,
693        directly_executable_role_upgrades,
694        proposed_external_role_upgrades,
695        blocked_role_upgrades,
696        dependency_blockers: Vec::new(),
697        protected_call_implications: protected_call_implications_for_check(check),
698        residual_exposure,
699        status,
700    };
701    plan.lifecycle_plan_digest = external_lifecycle_plan_digest(&plan);
702    plan
703}
704
705/// Validate archived external lifecycle plan consistency and digests.
706pub fn validate_external_lifecycle_plan(
707    plan: &ExternalLifecyclePlanV1,
708) -> Result<(), ExternalLifecyclePlanError> {
709    if plan.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
710        return Err(ExternalLifecyclePlanError::SchemaVersionMismatch {
711            expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
712            actual: plan.schema_version,
713        });
714    }
715    ensure_external_lifecycle_plan_field("lifecycle_plan_id", plan.lifecycle_plan_id.as_str())?;
716    ensure_external_lifecycle_plan_field(
717        "lifecycle_authority_report_id",
718        plan.lifecycle_authority_report_id.as_str(),
719    )?;
720    ensure_external_lifecycle_plan_field("deployment_plan_id", plan.deployment_plan_id.as_str())?;
721    ensure_external_lifecycle_plan_field("inventory_id", plan.inventory_id.as_str())?;
722    if plan.lifecycle_plan_digest != external_lifecycle_plan_digest(plan) {
723        return Err(ExternalLifecyclePlanError::DigestMismatch {
724            field: "lifecycle_plan_digest",
725        });
726    }
727    if plan.status != expected_lifecycle_plan_status(plan) {
728        return Err(ExternalLifecyclePlanError::StatusMismatch);
729    }
730    ensure_unique_lifecycle_subjects(&plan.lifecycle_authority_rows)?;
731    ensure_unique_role_upgrade_subjects(&plan.directly_executable_role_upgrades)?;
732    ensure_unique_role_upgrade_subjects(&plan.proposed_external_role_upgrades)?;
733    ensure_unique_role_upgrade_subjects(&plan.blocked_role_upgrades)?;
734    Ok(())
735}
736
737/// Validate that an archived external lifecycle plan still matches its source
738/// deployment truth check.
739pub fn validate_external_lifecycle_plan_for_check(
740    plan: &ExternalLifecyclePlanV1,
741    check: &DeploymentCheckV1,
742) -> Result<(), ExternalLifecyclePlanError> {
743    validate_external_lifecycle_plan(plan)?;
744    let expected = external_lifecycle_plan_from_check(
745        plan.lifecycle_plan_id.clone(),
746        plan.lifecycle_authority_report_id.clone(),
747        check,
748    );
749    if plan != &expected {
750        return Err(ExternalLifecyclePlanError::SourceMismatch {
751            field: "deployment_check",
752        });
753    }
754    Ok(())
755}
756
757/// Build a passive external-upgrade receipt from post-action observation.
758///
759/// The receipt records what an external controller claims or completed. It does
760/// not verify live state by itself and does not grant deployment authority.
761#[must_use]
762pub fn external_upgrade_receipt_from_observation(
763    receipt_id: impl Into<String>,
764    proposal: &ExternalUpgradeProposalV1,
765    consent_state: ExternalUpgradeConsentStateV1,
766    reported_by: Option<String>,
767    observed_after: Option<&ObservedCanisterV1>,
768) -> ExternalUpgradeReceiptV1 {
769    let observed_after_module_hash =
770        observed_after.and_then(|observed| observed.module_hash.clone());
771    let observed_after_canonical_embedded_config_sha256 =
772        observed_after.and_then(|observed| observed.canonical_embedded_config_digest.clone());
773    let verification_result = external_upgrade_verification_result(
774        consent_state,
775        proposal,
776        observed_after_module_hash.as_deref(),
777        observed_after_canonical_embedded_config_sha256.as_deref(),
778    );
779    let verification_notes = external_upgrade_verification_notes(
780        verification_result,
781        proposal,
782        observed_after_module_hash.as_deref(),
783        observed_after_canonical_embedded_config_sha256.as_deref(),
784    );
785
786    let mut receipt = ExternalUpgradeReceiptV1 {
787        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
788        receipt_id: receipt_id.into(),
789        proposal_id: proposal.proposal_id.clone(),
790        proposal_digest: proposal.proposal_digest.clone(),
791        subject: proposal.subject.clone(),
792        canister_id: proposal.canister_id.clone(),
793        role: proposal.role.clone(),
794        consent_state,
795        reported_by,
796        observed_before_module_hash: proposal.current_module_hash.clone(),
797        observed_after_module_hash,
798        observed_after_canonical_embedded_config_sha256,
799        verification_result,
800        verification_notes,
801        receipt_digest: String::new(),
802    };
803    receipt.receipt_digest = external_upgrade_receipt_digest(&receipt);
804    receipt
805}
806
807/// Validate the internal consistency of an external-upgrade receipt.
808///
809/// This is structural validation only. Live inventory remains the source of
810/// truth for whether the external upgrade actually completed.
811pub fn validate_external_upgrade_receipt(
812    receipt: &ExternalUpgradeReceiptV1,
813) -> Result<(), ExternalUpgradeReceiptError> {
814    if receipt.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
815        return Err(ExternalUpgradeReceiptError::SchemaVersionMismatch {
816            expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
817            actual: receipt.schema_version,
818        });
819    }
820    ensure_external_receipt_field("receipt_id", receipt.receipt_id.as_str())?;
821    ensure_external_receipt_field("proposal_id", receipt.proposal_id.as_str())?;
822    ensure_external_receipt_field("proposal_digest", receipt.proposal_digest.as_str())?;
823    ensure_external_receipt_field("subject", receipt.subject.as_str())?;
824    ensure_external_receipt_field("receipt_digest", receipt.receipt_digest.as_str())?;
825
826    if receipt.consent_state == ExternalUpgradeConsentStateV1::Refused
827        && receipt.verification_result == ExternalUpgradeVerificationResultV1::Verified
828    {
829        return Err(ExternalUpgradeReceiptError::RefusedConsentVerified);
830    }
831    let has_observation = receipt.observed_after_module_hash.is_some()
832        || receipt
833            .observed_after_canonical_embedded_config_sha256
834            .is_some();
835    if matches!(
836        receipt.verification_result,
837        ExternalUpgradeVerificationResultV1::Verified
838            | ExternalUpgradeVerificationResultV1::Mismatch
839    ) && !has_observation
840    {
841        return Err(ExternalUpgradeReceiptError::VerificationMismatch);
842    }
843    if receipt.receipt_digest != external_upgrade_receipt_digest(receipt) {
844        return Err(ExternalUpgradeReceiptError::DigestMismatch {
845            field: "receipt_digest",
846        });
847    }
848    Ok(())
849}
850
851/// Validate an external-upgrade receipt against the proposal it claims to
852/// satisfy.
853///
854/// This remains structural verification. It proves the receipt is linked to the
855/// supplied proposal and that its verification result matches the proposal's
856/// target facts, but live inventory remains the source of deployment truth.
857pub fn validate_external_upgrade_receipt_for_proposal(
858    receipt: &ExternalUpgradeReceiptV1,
859    proposal: &ExternalUpgradeProposalV1,
860) -> Result<(), ExternalUpgradeReceiptError> {
861    validate_external_upgrade_receipt(receipt)?;
862    ensure_external_receipt_matches_proposal(
863        "proposal_id",
864        receipt.proposal_id.as_str(),
865        proposal.proposal_id.as_str(),
866    )?;
867    ensure_external_receipt_matches_proposal(
868        "proposal_digest",
869        receipt.proposal_digest.as_str(),
870        proposal.proposal_digest.as_str(),
871    )?;
872    ensure_external_receipt_matches_proposal(
873        "subject",
874        receipt.subject.as_str(),
875        proposal.subject.as_str(),
876    )?;
877    ensure_external_receipt_option_matches_proposal(
878        "canister_id",
879        receipt.canister_id.as_deref(),
880        proposal.canister_id.as_deref(),
881    )?;
882    ensure_external_receipt_option_matches_proposal(
883        "role",
884        receipt.role.as_deref(),
885        proposal.role.as_deref(),
886    )?;
887    ensure_external_receipt_option_matches_proposal(
888        "observed_before_module_hash",
889        receipt.observed_before_module_hash.as_deref(),
890        proposal.current_module_hash.as_deref(),
891    )?;
892
893    let expected_result = external_upgrade_verification_result(
894        receipt.consent_state,
895        proposal,
896        receipt.observed_after_module_hash.as_deref(),
897        receipt
898            .observed_after_canonical_embedded_config_sha256
899            .as_deref(),
900    );
901    if receipt.verification_result != expected_result {
902        return Err(ExternalUpgradeReceiptError::VerificationMismatch);
903    }
904    let expected_notes = external_upgrade_verification_notes(
905        expected_result,
906        proposal,
907        receipt.observed_after_module_hash.as_deref(),
908        receipt
909            .observed_after_canonical_embedded_config_sha256
910            .as_deref(),
911    );
912    if receipt.verification_notes != expected_notes {
913        return Err(ExternalUpgradeReceiptError::SourceMismatch {
914            field: "verification_notes",
915        });
916    }
917
918    Ok(())
919}
920
921/// Build passive consent/action evidence from a proposal/receipt pair.
922///
923/// This records the reported consent or external action state only. It is not
924/// completion proof; verification remains separate and live inventory remains
925/// the source of deployment truth.
926pub fn external_upgrade_consent_evidence_from_receipt(
927    evidence_id: impl Into<String>,
928    proposal: &ExternalUpgradeProposalV1,
929    receipt: &ExternalUpgradeReceiptV1,
930) -> Result<ExternalUpgradeConsentEvidenceV1, ExternalUpgradeReceiptError> {
931    validate_external_upgrade_receipt_for_proposal(receipt, proposal)?;
932    let consent_state = receipt.consent_state;
933    let mut evidence = ExternalUpgradeConsentEvidenceV1 {
934        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
935        evidence_id: evidence_id.into(),
936        evidence_digest: String::new(),
937        proposal_id: proposal.proposal_id.clone(),
938        proposal_digest: proposal.proposal_digest.clone(),
939        receipt_id: receipt.receipt_id.clone(),
940        receipt_digest: receipt.receipt_digest.clone(),
941        subject: proposal.subject.clone(),
942        canister_id: proposal.canister_id.clone(),
943        role: proposal.role.clone(),
944        consent_state,
945        reported_by: receipt.reported_by.clone(),
946        consent_requirements: proposal.consent_requirements.clone(),
947        allowed_authorization_modes: proposal.allowed_authorization_modes.clone(),
948        status_summary: external_upgrade_consent_summary(consent_state).to_string(),
949    };
950    evidence.evidence_digest = external_upgrade_consent_evidence_digest(&evidence);
951    Ok(evidence)
952}
953
954/// Validate archived consent evidence consistency and digest.
955pub fn validate_external_upgrade_consent_evidence(
956    evidence: &ExternalUpgradeConsentEvidenceV1,
957) -> Result<(), ExternalUpgradeConsentEvidenceError> {
958    if evidence.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
959        return Err(ExternalUpgradeConsentEvidenceError::SchemaVersionMismatch {
960            expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
961            actual: evidence.schema_version,
962        });
963    }
964    ensure_external_consent_evidence_field("evidence_id", evidence.evidence_id.as_str())?;
965    ensure_external_consent_evidence_field("evidence_digest", evidence.evidence_digest.as_str())?;
966    ensure_external_consent_evidence_field("proposal_id", evidence.proposal_id.as_str())?;
967    ensure_external_consent_evidence_field("proposal_digest", evidence.proposal_digest.as_str())?;
968    ensure_external_consent_evidence_field("receipt_id", evidence.receipt_id.as_str())?;
969    ensure_external_consent_evidence_field("receipt_digest", evidence.receipt_digest.as_str())?;
970    ensure_external_consent_evidence_field("subject", evidence.subject.as_str())?;
971    ensure_external_consent_evidence_field("status_summary", evidence.status_summary.as_str())?;
972    if evidence.status_summary != external_upgrade_consent_summary(evidence.consent_state) {
973        return Err(ExternalUpgradeConsentEvidenceError::SourceMismatch {
974            field: "status_summary",
975        });
976    }
977    if evidence.evidence_digest != external_upgrade_consent_evidence_digest(evidence) {
978        return Err(ExternalUpgradeConsentEvidenceError::DigestMismatch {
979            field: "evidence_digest",
980        });
981    }
982    Ok(())
983}
984
985/// Validate that archived consent evidence still matches the proposal/receipt
986/// pair it claims to summarize.
987pub fn validate_external_upgrade_consent_evidence_for_receipt(
988    evidence: &ExternalUpgradeConsentEvidenceV1,
989    proposal: &ExternalUpgradeProposalV1,
990    receipt: &ExternalUpgradeReceiptV1,
991) -> Result<(), ExternalUpgradeConsentEvidenceError> {
992    validate_external_upgrade_consent_evidence(evidence)?;
993    let expected = external_upgrade_consent_evidence_from_receipt(
994        evidence.evidence_id.clone(),
995        proposal,
996        receipt,
997    )?;
998    if evidence != &expected {
999        return Err(ExternalUpgradeConsentEvidenceError::SourceMismatch { field: "receipt" });
1000    }
1001    Ok(())
1002}
1003
1004/// Build a passive verification report for a proposal/receipt pair.
1005///
1006/// This packages structural verification evidence only. Live inventory remains
1007/// the source of truth for deployment state.
1008pub fn external_upgrade_verification_report_from_receipt(
1009    report_id: impl Into<String>,
1010    proposal: &ExternalUpgradeProposalV1,
1011    receipt: &ExternalUpgradeReceiptV1,
1012) -> Result<ExternalUpgradeVerificationReportV1, ExternalUpgradeReceiptError> {
1013    validate_external_upgrade_receipt_for_proposal(receipt, proposal)?;
1014    let verification_result = receipt.verification_result;
1015    let mut report = ExternalUpgradeVerificationReportV1 {
1016        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
1017        report_id: report_id.into(),
1018        report_digest: String::new(),
1019        proposal_id: proposal.proposal_id.clone(),
1020        proposal_digest: proposal.proposal_digest.clone(),
1021        receipt_id: receipt.receipt_id.clone(),
1022        receipt_digest: receipt.receipt_digest.clone(),
1023        subject: proposal.subject.clone(),
1024        canister_id: proposal.canister_id.clone(),
1025        role: proposal.role.clone(),
1026        verification_result,
1027        verification_notes: receipt.verification_notes.clone(),
1028        live_inventory_required: verification_result
1029            != ExternalUpgradeVerificationResultV1::Pending
1030            && verification_result != ExternalUpgradeVerificationResultV1::Refused,
1031        status_summary: external_upgrade_verification_summary(verification_result).to_string(),
1032    };
1033    report.report_digest = external_upgrade_verification_report_digest(&report);
1034    Ok(report)
1035}
1036
1037/// Validate archived external-upgrade verification report consistency and
1038/// digest.
1039pub fn validate_external_upgrade_verification_report(
1040    report: &ExternalUpgradeVerificationReportV1,
1041) -> Result<(), ExternalUpgradeVerificationReportError> {
1042    if report.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
1043        return Err(
1044            ExternalUpgradeVerificationReportError::SchemaVersionMismatch {
1045                expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
1046                actual: report.schema_version,
1047            },
1048        );
1049    }
1050    ensure_external_verification_report_field("report_id", report.report_id.as_str())?;
1051    ensure_external_verification_report_field("report_digest", report.report_digest.as_str())?;
1052    ensure_external_verification_report_field("proposal_id", report.proposal_id.as_str())?;
1053    ensure_external_verification_report_field("proposal_digest", report.proposal_digest.as_str())?;
1054    ensure_external_verification_report_field("receipt_id", report.receipt_id.as_str())?;
1055    ensure_external_verification_report_field("receipt_digest", report.receipt_digest.as_str())?;
1056    ensure_external_verification_report_field("subject", report.subject.as_str())?;
1057    ensure_external_verification_report_field("status_summary", report.status_summary.as_str())?;
1058    if report.status_summary != external_upgrade_verification_summary(report.verification_result) {
1059        return Err(ExternalUpgradeVerificationReportError::SourceMismatch {
1060            field: "status_summary",
1061        });
1062    }
1063    if report.report_digest != external_upgrade_verification_report_digest(report) {
1064        return Err(ExternalUpgradeVerificationReportError::DigestMismatch {
1065            field: "report_digest",
1066        });
1067    }
1068    Ok(())
1069}
1070
1071/// Validate that an archived verification report still matches the
1072/// proposal/receipt pair it claims to summarize.
1073pub fn validate_external_upgrade_verification_report_for_receipt(
1074    report: &ExternalUpgradeVerificationReportV1,
1075    proposal: &ExternalUpgradeProposalV1,
1076    receipt: &ExternalUpgradeReceiptV1,
1077) -> Result<(), ExternalUpgradeVerificationReportError> {
1078    validate_external_upgrade_verification_report(report)?;
1079    let expected = external_upgrade_verification_report_from_receipt(
1080        report.report_id.clone(),
1081        proposal,
1082        receipt,
1083    )?;
1084    if report != &expected {
1085        return Err(ExternalUpgradeVerificationReportError::SourceMismatch { field: "receipt" });
1086    }
1087    Ok(())
1088}
1089
1090/// Build a passive live-inventory verification policy from an external
1091/// lifecycle proposal.
1092#[must_use]
1093pub fn external_upgrade_verification_policy_from_proposal(
1094    policy_id: impl Into<String>,
1095    proposal: &ExternalUpgradeProposalV1,
1096) -> ExternalUpgradeVerificationPolicyV1 {
1097    let mut policy = ExternalUpgradeVerificationPolicyV1 {
1098        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
1099        policy_id: policy_id.into(),
1100        policy_digest: String::new(),
1101        proposal_id: proposal.proposal_id.clone(),
1102        proposal_digest: proposal.proposal_digest.clone(),
1103        deployment_plan_id: proposal.deployment_plan_id.clone(),
1104        deployment_plan_digest: proposal.deployment_plan_digest.clone(),
1105        subject: proposal.subject.clone(),
1106        canister_id: proposal.canister_id.clone(),
1107        role: proposal.role.clone(),
1108        required_verification: proposal.verification_requirements.clone(),
1109        verification_requirements: external_upgrade_verification_policy_requirements(proposal),
1110        max_observation_age_seconds: None,
1111        status_summary: external_upgrade_verification_policy_summary(proposal).to_string(),
1112    };
1113    policy.policy_digest = external_upgrade_verification_policy_digest(&policy);
1114    policy
1115}
1116
1117/// Validate archived external-upgrade verification policy consistency and
1118/// digest.
1119pub fn validate_external_upgrade_verification_policy(
1120    policy: &ExternalUpgradeVerificationPolicyV1,
1121) -> Result<(), ExternalUpgradeVerificationPolicyError> {
1122    if policy.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
1123        return Err(
1124            ExternalUpgradeVerificationPolicyError::SchemaVersionMismatch {
1125                expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
1126                actual: policy.schema_version,
1127            },
1128        );
1129    }
1130    ensure_external_verification_policy_field("policy_id", policy.policy_id.as_str())?;
1131    ensure_external_verification_policy_field("policy_digest", policy.policy_digest.as_str())?;
1132    ensure_external_verification_policy_field("proposal_id", policy.proposal_id.as_str())?;
1133    ensure_external_verification_policy_field("proposal_digest", policy.proposal_digest.as_str())?;
1134    ensure_external_verification_policy_field(
1135        "deployment_plan_id",
1136        policy.deployment_plan_id.as_str(),
1137    )?;
1138    ensure_external_verification_policy_field(
1139        "deployment_plan_digest",
1140        policy.deployment_plan_digest.as_str(),
1141    )?;
1142    ensure_external_verification_policy_field("subject", policy.subject.as_str())?;
1143    ensure_external_verification_policy_field("status_summary", policy.status_summary.as_str())?;
1144    if policy.policy_digest != external_upgrade_verification_policy_digest(policy) {
1145        return Err(ExternalUpgradeVerificationPolicyError::DigestMismatch {
1146            field: "policy_digest",
1147        });
1148    }
1149    Ok(())
1150}
1151
1152/// Validate that an archived verification policy still matches its source
1153/// proposal.
1154pub fn validate_external_upgrade_verification_policy_for_proposal(
1155    policy: &ExternalUpgradeVerificationPolicyV1,
1156    proposal: &ExternalUpgradeProposalV1,
1157) -> Result<(), ExternalUpgradeVerificationPolicyError> {
1158    validate_external_upgrade_verification_policy(policy)?;
1159    let expected =
1160        external_upgrade_verification_policy_from_proposal(policy.policy_id.clone(), proposal);
1161    if policy != &expected {
1162        return Err(ExternalUpgradeVerificationPolicyError::SourceMismatch { field: "proposal" });
1163    }
1164    Ok(())
1165}
1166
1167/// Build a passive verification check from a policy and supplied observation.
1168///
1169/// This evaluates caller-supplied observation facts only. It does not query IC
1170/// state, deliver consent, execute upgrades, or prove live completion.
1171#[must_use]
1172pub fn external_upgrade_verification_check_from_policy(
1173    check_id: impl Into<String>,
1174    policy: &ExternalUpgradeVerificationPolicyV1,
1175    observation: ExternalUpgradeVerificationObservationV1,
1176) -> ExternalUpgradeVerificationCheckV1 {
1177    let observation_source = observation.source;
1178    let requirement_results =
1179        external_upgrade_verification_check_requirements(policy, &observation);
1180    let verification_result =
1181        external_upgrade_verification_check_result(observation_source, &requirement_results);
1182    let mut check = ExternalUpgradeVerificationCheckV1 {
1183        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
1184        check_id: check_id.into(),
1185        check_digest: String::new(),
1186        policy_id: policy.policy_id.clone(),
1187        policy_digest: policy.policy_digest.clone(),
1188        proposal_id: policy.proposal_id.clone(),
1189        proposal_digest: policy.proposal_digest.clone(),
1190        subject: policy.subject.clone(),
1191        canister_id: policy.canister_id.clone(),
1192        role: policy.role.clone(),
1193        observation,
1194        requirement_results,
1195        verification_result,
1196        status_summary: external_upgrade_verification_check_summary(
1197            observation_source,
1198            verification_result,
1199        )
1200        .to_string(),
1201    };
1202    check.check_digest = external_upgrade_verification_check_digest(&check);
1203    check
1204}
1205
1206/// Build a verification observation from an existing deployment-truth check.
1207///
1208/// This does not crawl IC state. It reuses the inventory already carried by the
1209/// deployment-truth check and binds the observation to that check's digest so
1210/// stale archived inventory fails closed when validated against the policy.
1211pub fn external_upgrade_verification_observation_from_check(
1212    policy: &ExternalUpgradeVerificationPolicyV1,
1213    check: &DeploymentCheckV1,
1214) -> Result<ExternalUpgradeVerificationObservationV1, ExternalUpgradeVerificationCheckError> {
1215    validate_external_upgrade_verification_policy(policy)
1216        .map_err(|_| ExternalUpgradeVerificationCheckError::SourceMismatch { field: "policy" })?;
1217    if policy.deployment_plan_id != check.plan.plan_id
1218        || policy.deployment_plan_digest != stable_json_sha256_hex(&check.plan)
1219    {
1220        return Err(ExternalUpgradeVerificationCheckError::SourceMismatch {
1221            field: "deployment_plan",
1222        });
1223    }
1224
1225    let observed = observed_canister_for_verification_policy(&check.inventory, policy);
1226    Ok(ExternalUpgradeVerificationObservationV1 {
1227        source: ExternalVerificationObservationSourceV1::DeploymentTruthInventory,
1228        deployment_check_id: Some(check.check_id.clone()),
1229        deployment_check_digest: Some(stable_json_sha256_hex(check)),
1230        inventory_id: Some(check.inventory.inventory_id.clone()),
1231        observed_at: Some(check.inventory.observed_at.clone()),
1232        live_inventory_observed: true,
1233        controller_observation_present: observed.is_some_and(|item| !item.controllers.is_empty()),
1234        observed_control_class: observed.map(|item| item.control_class),
1235        observed_module_hash: observed.and_then(|item| item.module_hash.clone()),
1236        observed_canonical_embedded_config_sha256: observed
1237            .and_then(|item| item.canonical_embedded_config_digest.clone()),
1238        protected_call_ready: external_upgrade_protected_call_ready(policy, check),
1239    })
1240}
1241
1242/// Validate archived external-upgrade verification check consistency and
1243/// digest.
1244pub fn validate_external_upgrade_verification_check(
1245    check: &ExternalUpgradeVerificationCheckV1,
1246) -> Result<(), ExternalUpgradeVerificationCheckError> {
1247    if check.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
1248        return Err(
1249            ExternalUpgradeVerificationCheckError::SchemaVersionMismatch {
1250                expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
1251                actual: check.schema_version,
1252            },
1253        );
1254    }
1255    ensure_external_verification_check_field("check_id", check.check_id.as_str())?;
1256    ensure_external_verification_check_field("check_digest", check.check_digest.as_str())?;
1257    ensure_external_verification_check_field("policy_id", check.policy_id.as_str())?;
1258    ensure_external_verification_check_field("policy_digest", check.policy_digest.as_str())?;
1259    ensure_external_verification_check_field("proposal_id", check.proposal_id.as_str())?;
1260    ensure_external_verification_check_field("proposal_digest", check.proposal_digest.as_str())?;
1261    ensure_external_verification_check_field("subject", check.subject.as_str())?;
1262    ensure_external_verification_check_field("status_summary", check.status_summary.as_str())?;
1263    if check.observation.source == ExternalVerificationObservationSourceV1::DeploymentTruthInventory
1264    {
1265        ensure_external_verification_check_option_field(
1266            "observation.deployment_check_id",
1267            check.observation.deployment_check_id.as_deref(),
1268        )?;
1269        ensure_external_verification_check_option_field(
1270            "observation.deployment_check_digest",
1271            check.observation.deployment_check_digest.as_deref(),
1272        )?;
1273        ensure_external_verification_check_option_field(
1274            "observation.inventory_id",
1275            check.observation.inventory_id.as_deref(),
1276        )?;
1277        ensure_external_verification_check_option_field(
1278            "observation.observed_at",
1279            check.observation.observed_at.as_deref(),
1280        )?;
1281    }
1282    validate_external_upgrade_verification_check_requirements(
1283        check.observation.source,
1284        &check.requirement_results,
1285        check.verification_result,
1286    )?;
1287    if check.status_summary
1288        != external_upgrade_verification_check_summary(
1289            check.observation.source,
1290            check.verification_result,
1291        )
1292    {
1293        return Err(ExternalUpgradeVerificationCheckError::SourceMismatch {
1294            field: "status_summary",
1295        });
1296    }
1297    if check.check_digest != external_upgrade_verification_check_digest(check) {
1298        return Err(ExternalUpgradeVerificationCheckError::DigestMismatch {
1299            field: "check_digest",
1300        });
1301    }
1302    Ok(())
1303}
1304
1305/// Validate that an archived verification check still matches the policy and
1306/// observation it claims to evaluate.
1307pub fn validate_external_upgrade_verification_check_for_policy(
1308    check: &ExternalUpgradeVerificationCheckV1,
1309    policy: &ExternalUpgradeVerificationPolicyV1,
1310) -> Result<(), ExternalUpgradeVerificationCheckError> {
1311    validate_external_upgrade_verification_check(check)?;
1312    let expected = external_upgrade_verification_check_from_policy(
1313        check.check_id.clone(),
1314        policy,
1315        check.observation.clone(),
1316    );
1317    if check != &expected {
1318        return Err(ExternalUpgradeVerificationCheckError::SourceMismatch { field: "policy" });
1319    }
1320    Ok(())
1321}
1322
1323/// Validate that a deployment-truth inventory verification check still matches
1324/// the exact deployment check it claims to use.
1325pub fn validate_external_upgrade_verification_check_for_deployment_check(
1326    check: &ExternalUpgradeVerificationCheckV1,
1327    policy: &ExternalUpgradeVerificationPolicyV1,
1328    deployment_check: &DeploymentCheckV1,
1329) -> Result<(), ExternalUpgradeVerificationCheckError> {
1330    validate_external_upgrade_verification_check_for_policy(check, policy)?;
1331    let observation =
1332        external_upgrade_verification_observation_from_check(policy, deployment_check)?;
1333    let expected = external_upgrade_verification_check_from_policy(
1334        check.check_id.clone(),
1335        policy,
1336        observation,
1337    );
1338    if check != &expected {
1339        return Err(ExternalUpgradeVerificationCheckError::SourceMismatch {
1340            field: "deployment_check",
1341        });
1342    }
1343    Ok(())
1344}
1345
1346/// Build a passive completion report for an external lifecycle proposal.
1347///
1348/// This report only combines structural evidence. It does not deliver consent,
1349/// execute upgrades, query live inventory, or mutate deployment state.
1350pub fn external_upgrade_completion_report_from_evidence(
1351    report_id: impl Into<String>,
1352    proposal: &ExternalUpgradeProposalV1,
1353    consent_evidence: &ExternalUpgradeConsentEvidenceV1,
1354    verification_check: &ExternalUpgradeVerificationCheckV1,
1355) -> Result<ExternalUpgradeCompletionReportV1, ExternalUpgradeCompletionReportError> {
1356    validate_external_upgrade_proposal(proposal)?;
1357    validate_external_upgrade_consent_evidence(consent_evidence)?;
1358    validate_external_upgrade_verification_check(verification_check)?;
1359    ensure_completion_sources_match_proposal(proposal, consent_evidence, verification_check)?;
1360
1361    let completion_status = external_upgrade_completion_status(
1362        consent_evidence.consent_state,
1363        verification_check.verification_result,
1364        verification_check.observation.source,
1365    );
1366    let blockers = external_upgrade_completion_blockers(completion_status);
1367    let next_actions = external_upgrade_completion_next_actions(completion_status);
1368    let mut report = ExternalUpgradeCompletionReportV1 {
1369        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
1370        report_id: report_id.into(),
1371        report_digest: String::new(),
1372        proposal_id: proposal.proposal_id.clone(),
1373        proposal_digest: proposal.proposal_digest.clone(),
1374        consent_evidence_id: consent_evidence.evidence_id.clone(),
1375        consent_evidence_digest: consent_evidence.evidence_digest.clone(),
1376        verification_check_id: verification_check.check_id.clone(),
1377        verification_check_digest: verification_check.check_digest.clone(),
1378        subject: proposal.subject.clone(),
1379        canister_id: proposal.canister_id.clone(),
1380        role: proposal.role.clone(),
1381        consent_state: consent_evidence.consent_state,
1382        verification_result: verification_check.verification_result,
1383        verification_observation_source: verification_check.observation.source,
1384        completion_status,
1385        blockers,
1386        next_actions,
1387        status_summary: external_upgrade_completion_summary(completion_status).to_string(),
1388    };
1389    report.report_digest = external_upgrade_completion_report_digest(&report);
1390    Ok(report)
1391}
1392
1393/// Validate archived completion report consistency and digest.
1394pub fn validate_external_upgrade_completion_report(
1395    report: &ExternalUpgradeCompletionReportV1,
1396) -> Result<(), ExternalUpgradeCompletionReportError> {
1397    if report.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
1398        return Err(
1399            ExternalUpgradeCompletionReportError::SchemaVersionMismatch {
1400                expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
1401                actual: report.schema_version,
1402            },
1403        );
1404    }
1405    ensure_external_completion_report_field("report_id", report.report_id.as_str())?;
1406    ensure_external_completion_report_field("report_digest", report.report_digest.as_str())?;
1407    ensure_external_completion_report_field("proposal_id", report.proposal_id.as_str())?;
1408    ensure_external_completion_report_field("proposal_digest", report.proposal_digest.as_str())?;
1409    ensure_external_completion_report_field(
1410        "consent_evidence_id",
1411        report.consent_evidence_id.as_str(),
1412    )?;
1413    ensure_external_completion_report_field(
1414        "consent_evidence_digest",
1415        report.consent_evidence_digest.as_str(),
1416    )?;
1417    ensure_external_completion_report_field(
1418        "verification_check_id",
1419        report.verification_check_id.as_str(),
1420    )?;
1421    ensure_external_completion_report_field(
1422        "verification_check_digest",
1423        report.verification_check_digest.as_str(),
1424    )?;
1425    ensure_external_completion_report_field("subject", report.subject.as_str())?;
1426    ensure_external_completion_report_field("status_summary", report.status_summary.as_str())?;
1427    if report.completion_status
1428        != external_upgrade_completion_status(
1429            report.consent_state,
1430            report.verification_result,
1431            report.verification_observation_source,
1432        )
1433    {
1434        return Err(ExternalUpgradeCompletionReportError::SourceMismatch {
1435            field: "completion_status",
1436        });
1437    }
1438    if report.status_summary != external_upgrade_completion_summary(report.completion_status) {
1439        return Err(ExternalUpgradeCompletionReportError::SourceMismatch {
1440            field: "status_summary",
1441        });
1442    }
1443    if report.blockers != external_upgrade_completion_blockers(report.completion_status) {
1444        return Err(ExternalUpgradeCompletionReportError::SourceMismatch { field: "blockers" });
1445    }
1446    if report.next_actions != external_upgrade_completion_next_actions(report.completion_status) {
1447        return Err(ExternalUpgradeCompletionReportError::SourceMismatch {
1448            field: "next_actions",
1449        });
1450    }
1451    if report.report_digest != external_upgrade_completion_report_digest(report) {
1452        return Err(ExternalUpgradeCompletionReportError::DigestMismatch {
1453            field: "report_digest",
1454        });
1455    }
1456    Ok(())
1457}
1458
1459/// Validate that an archived completion report still matches its source
1460/// proposal, consent evidence, and verification check.
1461pub fn validate_external_upgrade_completion_report_for_evidence(
1462    report: &ExternalUpgradeCompletionReportV1,
1463    proposal: &ExternalUpgradeProposalV1,
1464    consent_evidence: &ExternalUpgradeConsentEvidenceV1,
1465    verification_check: &ExternalUpgradeVerificationCheckV1,
1466) -> Result<(), ExternalUpgradeCompletionReportError> {
1467    validate_external_upgrade_completion_report(report)?;
1468    let expected = external_upgrade_completion_report_from_evidence(
1469        report.report_id.clone(),
1470        proposal,
1471        consent_evidence,
1472        verification_check,
1473    )?;
1474    if report != &expected {
1475        return Err(ExternalUpgradeCompletionReportError::SourceMismatch {
1476            field: "source_evidence",
1477        });
1478    }
1479    Ok(())
1480}
1481
1482/// Build passive external-upgrade proposal artifacts from a lifecycle plan.
1483///
1484/// This binds current observations to target artifact facts, but does not
1485/// grant consent, execute installs, or verify completion.
1486#[must_use]
1487pub fn external_upgrade_proposal_report_from_lifecycle_plan(
1488    report_id: impl Into<String>,
1489    lifecycle_plan: &ExternalLifecyclePlanV1,
1490    check: &DeploymentCheckV1,
1491) -> ExternalUpgradeProposalReportV1 {
1492    let report_id = report_id.into();
1493    let mut proposals = Vec::new();
1494    for authority in lifecycle_plan
1495        .lifecycle_authority_rows
1496        .iter()
1497        .filter(|authority| authority.external_action_required && !authority.blocked)
1498    {
1499        proposals.push(external_upgrade_proposal(
1500            &report_id,
1501            lifecycle_plan,
1502            check,
1503            authority,
1504            observed_canister_for_authority(&check.inventory, authority),
1505            target_artifact_for_authority(&check.plan, authority),
1506        ));
1507    }
1508
1509    proposals.sort_by(|left, right| left.subject.cmp(&right.subject));
1510
1511    let mut report = ExternalUpgradeProposalReportV1 {
1512        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
1513        report_id,
1514        report_digest: String::new(),
1515        lifecycle_plan_id: lifecycle_plan.lifecycle_plan_id.clone(),
1516        lifecycle_plan_digest: lifecycle_plan.lifecycle_plan_digest.clone(),
1517        deployment_plan_id: lifecycle_plan.deployment_plan_id.clone(),
1518        deployment_plan_digest: lifecycle_plan.deployment_plan_digest.clone(),
1519        inventory_id: check.inventory.inventory_id.clone(),
1520        proposals,
1521        blocked_subjects: lifecycle_plan
1522            .blocked_role_upgrades
1523            .iter()
1524            .map(|upgrade| upgrade.subject.clone())
1525            .collect(),
1526    };
1527    report.report_digest = external_upgrade_proposal_report_digest(&report);
1528    report
1529}
1530
1531/// Validate archived external-upgrade proposal report consistency and digests.
1532pub fn validate_external_upgrade_proposal_report(
1533    report: &ExternalUpgradeProposalReportV1,
1534) -> Result<(), ExternalUpgradeProposalReportError> {
1535    if report.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
1536        return Err(ExternalUpgradeProposalReportError::SchemaVersionMismatch {
1537            expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
1538            actual: report.schema_version,
1539        });
1540    }
1541    ensure_external_proposal_report_field("report_id", report.report_id.as_str())?;
1542    ensure_external_proposal_report_field("report_digest", report.report_digest.as_str())?;
1543    ensure_external_proposal_report_field("lifecycle_plan_id", report.lifecycle_plan_id.as_str())?;
1544    ensure_external_proposal_report_field(
1545        "lifecycle_plan_digest",
1546        report.lifecycle_plan_digest.as_str(),
1547    )?;
1548    ensure_external_proposal_report_field(
1549        "deployment_plan_id",
1550        report.deployment_plan_id.as_str(),
1551    )?;
1552    ensure_external_proposal_report_field(
1553        "deployment_plan_digest",
1554        report.deployment_plan_digest.as_str(),
1555    )?;
1556    ensure_external_proposal_report_field("inventory_id", report.inventory_id.as_str())?;
1557
1558    let mut subjects = BTreeSet::new();
1559    for proposal in &report.proposals {
1560        if !subjects.insert(proposal.subject.clone()) {
1561            return Err(ExternalUpgradeProposalReportError::DuplicateSubject {
1562                subject: proposal.subject.clone(),
1563            });
1564        }
1565        validate_external_upgrade_proposal(proposal)?;
1566    }
1567    if report.report_digest != external_upgrade_proposal_report_digest(report) {
1568        return Err(ExternalUpgradeProposalReportError::DigestMismatch {
1569            field: "report_digest",
1570        });
1571    }
1572    Ok(())
1573}
1574
1575/// Validate that an archived external-upgrade proposal report still matches
1576/// the lifecycle plan and deployment truth check it claims to derive from.
1577pub fn validate_external_upgrade_proposal_report_for_lifecycle_plan(
1578    report: &ExternalUpgradeProposalReportV1,
1579    lifecycle_plan: &ExternalLifecyclePlanV1,
1580    check: &DeploymentCheckV1,
1581) -> Result<(), ExternalUpgradeProposalReportError> {
1582    validate_external_upgrade_proposal_report(report)?;
1583    if report.lifecycle_plan_id != lifecycle_plan.lifecycle_plan_id {
1584        return Err(ExternalUpgradeProposalReportError::SourceMismatch {
1585            field: "lifecycle_plan_id",
1586        });
1587    }
1588    if report.lifecycle_plan_digest != lifecycle_plan.lifecycle_plan_digest {
1589        return Err(ExternalUpgradeProposalReportError::SourceMismatch {
1590            field: "lifecycle_plan_digest",
1591        });
1592    }
1593    let expected = external_upgrade_proposal_report_from_lifecycle_plan(
1594        report.report_id.clone(),
1595        lifecycle_plan,
1596        check,
1597    );
1598    if report != &expected {
1599        return Err(ExternalUpgradeProposalReportError::SourceMismatch {
1600            field: "deployment_check",
1601        });
1602    }
1603    Ok(())
1604}
1605
1606/// Build a passive summary of external lifecycle work still pending after a
1607/// plan/proposal pass.
1608#[must_use]
1609pub fn external_lifecycle_pending_report_from_plan(
1610    report_id: impl Into<String>,
1611    lifecycle_plan: &ExternalLifecyclePlanV1,
1612    proposal_report: &ExternalUpgradeProposalReportV1,
1613) -> ExternalLifecyclePendingReportV1 {
1614    let report_id = report_id.into();
1615    let pending_external_actions = proposal_report
1616        .proposals
1617        .iter()
1618        .map(external_lifecycle_pending_action)
1619        .collect::<Vec<_>>();
1620    let blocked_subjects = lifecycle_plan
1621        .blocked_role_upgrades
1622        .iter()
1623        .map(|upgrade| upgrade.subject.clone())
1624        .collect::<Vec<_>>();
1625    let mut report = ExternalLifecyclePendingReportV1 {
1626        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
1627        report_id,
1628        report_digest: String::new(),
1629        lifecycle_plan_id: lifecycle_plan.lifecycle_plan_id.clone(),
1630        lifecycle_plan_digest: lifecycle_plan.lifecycle_plan_digest.clone(),
1631        proposal_report_id: proposal_report.report_id.clone(),
1632        proposal_report_digest: proposal_report.report_digest.clone(),
1633        deployment_plan_id: lifecycle_plan.deployment_plan_id.clone(),
1634        deployment_plan_digest: lifecycle_plan.deployment_plan_digest.clone(),
1635        inventory_id: lifecycle_plan.inventory_id.clone(),
1636        direct_upgrade_count: lifecycle_plan.directly_executable_role_upgrades.len(),
1637        pending_external_count: pending_external_actions.len(),
1638        blocked_count: blocked_subjects.len(),
1639        pending_external_actions,
1640        blocked_subjects,
1641        residual_exposure: lifecycle_plan.residual_exposure.clone(),
1642        status: lifecycle_plan.status,
1643    };
1644    report.report_digest = external_lifecycle_pending_report_digest(&report);
1645    report
1646}
1647
1648/// Validate archived external lifecycle pending report consistency and digest.
1649pub fn validate_external_lifecycle_pending_report(
1650    report: &ExternalLifecyclePendingReportV1,
1651) -> Result<(), ExternalLifecyclePendingReportError> {
1652    if report.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
1653        return Err(ExternalLifecyclePendingReportError::SchemaVersionMismatch {
1654            expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
1655            actual: report.schema_version,
1656        });
1657    }
1658    ensure_external_pending_report_field("report_id", report.report_id.as_str())?;
1659    ensure_external_pending_report_field("report_digest", report.report_digest.as_str())?;
1660    ensure_external_pending_report_field("lifecycle_plan_id", report.lifecycle_plan_id.as_str())?;
1661    ensure_external_pending_report_field(
1662        "lifecycle_plan_digest",
1663        report.lifecycle_plan_digest.as_str(),
1664    )?;
1665    ensure_external_pending_report_field("proposal_report_id", report.proposal_report_id.as_str())?;
1666    ensure_external_pending_report_field(
1667        "proposal_report_digest",
1668        report.proposal_report_digest.as_str(),
1669    )?;
1670    ensure_external_pending_report_field("deployment_plan_id", report.deployment_plan_id.as_str())?;
1671    ensure_external_pending_report_field(
1672        "deployment_plan_digest",
1673        report.deployment_plan_digest.as_str(),
1674    )?;
1675    ensure_external_pending_report_field("inventory_id", report.inventory_id.as_str())?;
1676    if report.pending_external_count != report.pending_external_actions.len()
1677        || report.blocked_count != report.blocked_subjects.len()
1678    {
1679        return Err(ExternalLifecyclePendingReportError::CountMismatch);
1680    }
1681    let mut subjects = BTreeSet::new();
1682    for action in &report.pending_external_actions {
1683        ensure_external_pending_report_field("pending_action.subject", action.subject.as_str())?;
1684        ensure_external_pending_report_field(
1685            "pending_action.proposal_id",
1686            action.proposal_id.as_str(),
1687        )?;
1688        ensure_external_pending_report_field(
1689            "pending_action.proposal_digest",
1690            action.proposal_digest.as_str(),
1691        )?;
1692        ensure_external_pending_report_field(
1693            "pending_action.required_external_action",
1694            action.required_external_action.as_str(),
1695        )?;
1696        if !subjects.insert(action.subject.clone()) {
1697            return Err(ExternalLifecyclePendingReportError::DuplicateSubject {
1698                subject: action.subject.clone(),
1699            });
1700        }
1701    }
1702    if report.report_digest != external_lifecycle_pending_report_digest(report) {
1703        return Err(ExternalLifecyclePendingReportError::DigestMismatch {
1704            field: "report_digest",
1705        });
1706    }
1707    Ok(())
1708}
1709
1710/// Validate that an archived external lifecycle pending report still matches
1711/// the lifecycle and proposal artifacts it claims to derive from.
1712pub fn validate_external_lifecycle_pending_report_for_plan(
1713    report: &ExternalLifecyclePendingReportV1,
1714    lifecycle_plan: &ExternalLifecyclePlanV1,
1715    proposal_report: &ExternalUpgradeProposalReportV1,
1716) -> Result<(), ExternalLifecyclePendingReportError> {
1717    validate_external_lifecycle_pending_report(report)?;
1718    if report.lifecycle_plan_id != lifecycle_plan.lifecycle_plan_id {
1719        return Err(ExternalLifecyclePendingReportError::SourceMismatch {
1720            field: "lifecycle_plan_id",
1721        });
1722    }
1723    if report.lifecycle_plan_digest != lifecycle_plan.lifecycle_plan_digest {
1724        return Err(ExternalLifecyclePendingReportError::SourceMismatch {
1725            field: "lifecycle_plan_digest",
1726        });
1727    }
1728    if report.proposal_report_id != proposal_report.report_id {
1729        return Err(ExternalLifecyclePendingReportError::SourceMismatch {
1730            field: "proposal_report_id",
1731        });
1732    }
1733    if report.proposal_report_digest != proposal_report.report_digest {
1734        return Err(ExternalLifecyclePendingReportError::SourceMismatch {
1735            field: "proposal_report_digest",
1736        });
1737    }
1738    let expected = external_lifecycle_pending_report_from_plan(
1739        report.report_id.clone(),
1740        lifecycle_plan,
1741        proposal_report,
1742    );
1743    if report != &expected {
1744        return Err(ExternalLifecyclePendingReportError::SourceMismatch {
1745            field: "lifecycle_plan",
1746        });
1747    }
1748    Ok(())
1749}
1750
1751/// Build a passive operator check over external lifecycle work.
1752#[must_use]
1753pub fn external_lifecycle_check_from_reports(
1754    check_id: impl Into<String>,
1755    lifecycle_plan: &ExternalLifecyclePlanV1,
1756    proposal_report: &ExternalUpgradeProposalReportV1,
1757    pending_report: &ExternalLifecyclePendingReportV1,
1758) -> ExternalLifecycleCheckV1 {
1759    let check_id = check_id.into();
1760    let status = pending_report.status;
1761    let mut check = ExternalLifecycleCheckV1 {
1762        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
1763        check_id,
1764        check_digest: String::new(),
1765        lifecycle_plan_id: lifecycle_plan.lifecycle_plan_id.clone(),
1766        lifecycle_plan_digest: lifecycle_plan.lifecycle_plan_digest.clone(),
1767        proposal_report_id: proposal_report.report_id.clone(),
1768        proposal_report_digest: proposal_report.report_digest.clone(),
1769        pending_report_id: pending_report.report_id.clone(),
1770        pending_report_digest: pending_report.report_digest.clone(),
1771        deployment_plan_id: lifecycle_plan.deployment_plan_id.clone(),
1772        deployment_plan_digest: lifecycle_plan.deployment_plan_digest.clone(),
1773        inventory_id: lifecycle_plan.inventory_id.clone(),
1774        status,
1775        direct_upgrade_count: pending_report.direct_upgrade_count,
1776        pending_external_count: pending_report.pending_external_count,
1777        blocked_count: pending_report.blocked_count,
1778        residual_exposure_count: pending_report.residual_exposure.len(),
1779        summary: external_lifecycle_check_summary(status, pending_report),
1780        next_actions: external_lifecycle_check_next_actions(status, pending_report),
1781    };
1782    check.check_digest = external_lifecycle_check_digest(&check);
1783    check
1784}
1785
1786/// Validate archived external lifecycle check consistency and digest.
1787pub fn validate_external_lifecycle_check(
1788    check: &ExternalLifecycleCheckV1,
1789) -> Result<(), ExternalLifecycleCheckError> {
1790    if check.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
1791        return Err(ExternalLifecycleCheckError::SchemaVersionMismatch {
1792            expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
1793            actual: check.schema_version,
1794        });
1795    }
1796    ensure_external_lifecycle_check_field("check_id", check.check_id.as_str())?;
1797    ensure_external_lifecycle_check_field("check_digest", check.check_digest.as_str())?;
1798    ensure_external_lifecycle_check_field("lifecycle_plan_id", check.lifecycle_plan_id.as_str())?;
1799    ensure_external_lifecycle_check_field(
1800        "lifecycle_plan_digest",
1801        check.lifecycle_plan_digest.as_str(),
1802    )?;
1803    ensure_external_lifecycle_check_field("proposal_report_id", check.proposal_report_id.as_str())?;
1804    ensure_external_lifecycle_check_field(
1805        "proposal_report_digest",
1806        check.proposal_report_digest.as_str(),
1807    )?;
1808    ensure_external_lifecycle_check_field("pending_report_id", check.pending_report_id.as_str())?;
1809    ensure_external_lifecycle_check_field(
1810        "pending_report_digest",
1811        check.pending_report_digest.as_str(),
1812    )?;
1813    ensure_external_lifecycle_check_field("deployment_plan_id", check.deployment_plan_id.as_str())?;
1814    ensure_external_lifecycle_check_field(
1815        "deployment_plan_digest",
1816        check.deployment_plan_digest.as_str(),
1817    )?;
1818    ensure_external_lifecycle_check_field("inventory_id", check.inventory_id.as_str())?;
1819    ensure_external_lifecycle_check_field("summary", check.summary.as_str())?;
1820    if check.check_digest != external_lifecycle_check_digest(check) {
1821        return Err(ExternalLifecycleCheckError::DigestMismatch {
1822            field: "check_digest",
1823        });
1824    }
1825    Ok(())
1826}
1827
1828/// Validate that an archived external lifecycle check still matches the
1829/// lifecycle/proposal/pending artifacts it claims to summarize.
1830pub fn validate_external_lifecycle_check_for_reports(
1831    check: &ExternalLifecycleCheckV1,
1832    lifecycle_plan: &ExternalLifecyclePlanV1,
1833    proposal_report: &ExternalUpgradeProposalReportV1,
1834    pending_report: &ExternalLifecyclePendingReportV1,
1835) -> Result<(), ExternalLifecycleCheckError> {
1836    validate_external_lifecycle_check(check)?;
1837    if check.lifecycle_plan_id != lifecycle_plan.lifecycle_plan_id {
1838        return Err(ExternalLifecycleCheckError::SourceMismatch {
1839            field: "lifecycle_plan_id",
1840        });
1841    }
1842    if check.lifecycle_plan_digest != lifecycle_plan.lifecycle_plan_digest {
1843        return Err(ExternalLifecycleCheckError::SourceMismatch {
1844            field: "lifecycle_plan_digest",
1845        });
1846    }
1847    if check.proposal_report_id != proposal_report.report_id {
1848        return Err(ExternalLifecycleCheckError::SourceMismatch {
1849            field: "proposal_report_id",
1850        });
1851    }
1852    if check.proposal_report_digest != proposal_report.report_digest {
1853        return Err(ExternalLifecycleCheckError::SourceMismatch {
1854            field: "proposal_report_digest",
1855        });
1856    }
1857    if check.pending_report_id != pending_report.report_id {
1858        return Err(ExternalLifecycleCheckError::SourceMismatch {
1859            field: "pending_report_id",
1860        });
1861    }
1862    if check.pending_report_digest != pending_report.report_digest {
1863        return Err(ExternalLifecycleCheckError::SourceMismatch {
1864            field: "pending_report_digest",
1865        });
1866    }
1867    if check.direct_upgrade_count != pending_report.direct_upgrade_count
1868        || check.pending_external_count != pending_report.pending_external_count
1869        || check.blocked_count != pending_report.blocked_count
1870        || check.residual_exposure_count != pending_report.residual_exposure.len()
1871    {
1872        return Err(ExternalLifecycleCheckError::CountMismatch);
1873    }
1874    let expected = external_lifecycle_check_from_reports(
1875        check.check_id.clone(),
1876        lifecycle_plan,
1877        proposal_report,
1878        pending_report,
1879    );
1880    if check != &expected {
1881        return Err(ExternalLifecycleCheckError::SourceMismatch {
1882            field: "pending_report",
1883        });
1884    }
1885    Ok(())
1886}
1887
1888/// Build a passive handoff packet for external lifecycle operators.
1889#[must_use]
1890pub fn external_lifecycle_handoff_from_reports(
1891    handoff_id: impl Into<String>,
1892    lifecycle_check: &ExternalLifecycleCheckV1,
1893    proposal_report: &ExternalUpgradeProposalReportV1,
1894    pending_report: &ExternalLifecyclePendingReportV1,
1895) -> ExternalLifecycleHandoffV1 {
1896    let proposal_by_id = proposal_report
1897        .proposals
1898        .iter()
1899        .map(|proposal| (proposal.proposal_id.as_str(), proposal))
1900        .collect::<BTreeMap<_, _>>();
1901    let handoff_actions = pending_report
1902        .pending_external_actions
1903        .iter()
1904        .filter_map(|action| proposal_by_id.get(action.proposal_id.as_str()))
1905        .map(|proposal| external_lifecycle_handoff_action(proposal))
1906        .collect::<Vec<_>>();
1907    let mut handoff = ExternalLifecycleHandoffV1 {
1908        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
1909        handoff_id: handoff_id.into(),
1910        handoff_digest: String::new(),
1911        lifecycle_check_id: lifecycle_check.check_id.clone(),
1912        lifecycle_check_digest: lifecycle_check.check_digest.clone(),
1913        pending_report_id: pending_report.report_id.clone(),
1914        pending_report_digest: pending_report.report_digest.clone(),
1915        proposal_report_id: proposal_report.report_id.clone(),
1916        proposal_report_digest: proposal_report.report_digest.clone(),
1917        deployment_plan_id: pending_report.deployment_plan_id.clone(),
1918        deployment_plan_digest: pending_report.deployment_plan_digest.clone(),
1919        inventory_id: pending_report.inventory_id.clone(),
1920        status: pending_report.status,
1921        handoff_actions,
1922        blocked_subjects: pending_report.blocked_subjects.clone(),
1923        residual_exposure: pending_report.residual_exposure.clone(),
1924        operator_summary: external_lifecycle_handoff_summary(pending_report),
1925    };
1926    handoff.handoff_digest = external_lifecycle_handoff_digest(&handoff);
1927    handoff
1928}
1929
1930/// Validate archived external lifecycle handoff consistency and digest.
1931pub fn validate_external_lifecycle_handoff(
1932    handoff: &ExternalLifecycleHandoffV1,
1933) -> Result<(), ExternalLifecycleHandoffError> {
1934    if handoff.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
1935        return Err(ExternalLifecycleHandoffError::SchemaVersionMismatch {
1936            expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
1937            actual: handoff.schema_version,
1938        });
1939    }
1940    ensure_external_lifecycle_handoff_field("handoff_id", handoff.handoff_id.as_str())?;
1941    ensure_external_lifecycle_handoff_field("handoff_digest", handoff.handoff_digest.as_str())?;
1942    ensure_external_lifecycle_handoff_field(
1943        "lifecycle_check_id",
1944        handoff.lifecycle_check_id.as_str(),
1945    )?;
1946    ensure_external_lifecycle_handoff_field(
1947        "lifecycle_check_digest",
1948        handoff.lifecycle_check_digest.as_str(),
1949    )?;
1950    ensure_external_lifecycle_handoff_field(
1951        "pending_report_id",
1952        handoff.pending_report_id.as_str(),
1953    )?;
1954    ensure_external_lifecycle_handoff_field(
1955        "pending_report_digest",
1956        handoff.pending_report_digest.as_str(),
1957    )?;
1958    ensure_external_lifecycle_handoff_field(
1959        "proposal_report_id",
1960        handoff.proposal_report_id.as_str(),
1961    )?;
1962    ensure_external_lifecycle_handoff_field(
1963        "proposal_report_digest",
1964        handoff.proposal_report_digest.as_str(),
1965    )?;
1966    ensure_external_lifecycle_handoff_field(
1967        "deployment_plan_id",
1968        handoff.deployment_plan_id.as_str(),
1969    )?;
1970    ensure_external_lifecycle_handoff_field(
1971        "deployment_plan_digest",
1972        handoff.deployment_plan_digest.as_str(),
1973    )?;
1974    ensure_external_lifecycle_handoff_field("inventory_id", handoff.inventory_id.as_str())?;
1975    ensure_external_lifecycle_handoff_field("operator_summary", handoff.operator_summary.as_str())?;
1976    let mut subjects = BTreeSet::new();
1977    for action in &handoff.handoff_actions {
1978        ensure_external_lifecycle_handoff_field("handoff_action.subject", action.subject.as_str())?;
1979        ensure_external_lifecycle_handoff_field(
1980            "handoff_action.proposal_id",
1981            action.proposal_id.as_str(),
1982        )?;
1983        ensure_external_lifecycle_handoff_field(
1984            "handoff_action.proposal_digest",
1985            action.proposal_digest.as_str(),
1986        )?;
1987        ensure_external_lifecycle_handoff_field(
1988            "handoff_action.required_external_action",
1989            action.required_external_action.as_str(),
1990        )?;
1991        if !subjects.insert(action.subject.clone()) {
1992            return Err(ExternalLifecycleHandoffError::DuplicateSubject {
1993                subject: action.subject.clone(),
1994            });
1995        }
1996    }
1997    if handoff.handoff_digest != external_lifecycle_handoff_digest(handoff) {
1998        return Err(ExternalLifecycleHandoffError::DigestMismatch {
1999            field: "handoff_digest",
2000        });
2001    }
2002    Ok(())
2003}
2004
2005/// Validate that an archived handoff still matches the check/proposal/pending
2006/// evidence it claims to package.
2007pub fn validate_external_lifecycle_handoff_for_reports(
2008    handoff: &ExternalLifecycleHandoffV1,
2009    lifecycle_check: &ExternalLifecycleCheckV1,
2010    proposal_report: &ExternalUpgradeProposalReportV1,
2011    pending_report: &ExternalLifecyclePendingReportV1,
2012) -> Result<(), ExternalLifecycleHandoffError> {
2013    validate_external_lifecycle_handoff(handoff)?;
2014    if handoff.lifecycle_check_id != lifecycle_check.check_id {
2015        return Err(ExternalLifecycleHandoffError::SourceMismatch {
2016            field: "lifecycle_check_id",
2017        });
2018    }
2019    if handoff.lifecycle_check_digest != lifecycle_check.check_digest {
2020        return Err(ExternalLifecycleHandoffError::SourceMismatch {
2021            field: "lifecycle_check_digest",
2022        });
2023    }
2024    if handoff.pending_report_id != pending_report.report_id {
2025        return Err(ExternalLifecycleHandoffError::SourceMismatch {
2026            field: "pending_report_id",
2027        });
2028    }
2029    if handoff.pending_report_digest != pending_report.report_digest {
2030        return Err(ExternalLifecycleHandoffError::SourceMismatch {
2031            field: "pending_report_digest",
2032        });
2033    }
2034    if handoff.proposal_report_id != proposal_report.report_id {
2035        return Err(ExternalLifecycleHandoffError::SourceMismatch {
2036            field: "proposal_report_id",
2037        });
2038    }
2039    if handoff.proposal_report_digest != proposal_report.report_digest {
2040        return Err(ExternalLifecycleHandoffError::SourceMismatch {
2041            field: "proposal_report_digest",
2042        });
2043    }
2044    let expected = external_lifecycle_handoff_from_reports(
2045        handoff.handoff_id.clone(),
2046        lifecycle_check,
2047        proposal_report,
2048        pending_report,
2049    );
2050    if handoff != &expected {
2051        return Err(ExternalLifecycleHandoffError::SourceMismatch {
2052            field: "pending_report",
2053        });
2054    }
2055    Ok(())
2056}
2057
2058fn external_lifecycle_pending_action(
2059    proposal: &ExternalUpgradeProposalV1,
2060) -> ExternalLifecyclePendingActionV1 {
2061    ExternalLifecyclePendingActionV1 {
2062        subject: proposal.subject.clone(),
2063        proposal_id: proposal.proposal_id.clone(),
2064        proposal_digest: proposal.proposal_digest.clone(),
2065        canister_id: proposal.canister_id.clone(),
2066        role: proposal.role.clone(),
2067        control_class: proposal.control_class,
2068        lifecycle_mode: proposal.lifecycle_mode,
2069        required_external_action: proposal.required_external_action.clone(),
2070        consent_requirements: proposal.consent_requirements.clone(),
2071        verification_requirements: proposal.verification_requirements.clone(),
2072    }
2073}
2074
2075fn external_lifecycle_handoff_action(
2076    proposal: &ExternalUpgradeProposalV1,
2077) -> ExternalLifecycleHandoffActionV1 {
2078    let primary_requirement = proposal.consent_requirements.first();
2079    ExternalLifecycleHandoffActionV1 {
2080        subject: proposal.subject.clone(),
2081        proposal_id: proposal.proposal_id.clone(),
2082        proposal_digest: proposal.proposal_digest.clone(),
2083        canister_id: proposal.canister_id.clone(),
2084        role: proposal.role.clone(),
2085        control_class: proposal.control_class,
2086        lifecycle_mode: proposal.lifecycle_mode,
2087        required_external_action: proposal.required_external_action.clone(),
2088        consent_channel_kind: primary_requirement
2089            .map_or(ConsentChannelKindV1::OutOfBand, |requirement| {
2090                requirement.consent_channel_kind
2091            }),
2092        consent_subject_kind: primary_requirement.map_or(
2093            ConsentSubjectKindV1::UnknownExternalController,
2094            |requirement| requirement.consent_subject_kind,
2095        ),
2096        required_principals: primary_requirement.map_or_else(Vec::new, |requirement| {
2097            requirement.required_principals.clone()
2098        }),
2099        current_module_hash: proposal.current_module_hash.clone(),
2100        target_installed_module_hash: proposal.target_installed_module_hash.clone(),
2101        target_canonical_embedded_config_sha256: proposal
2102            .target_canonical_embedded_config_sha256
2103            .clone(),
2104        verification_requirements: proposal.verification_requirements.clone(),
2105        operator_instructions: external_lifecycle_handoff_instructions(proposal),
2106    }
2107}
2108
2109fn lifecycle_roles(lifecycle_plan: &ExternalLifecyclePlanV1) -> Vec<String> {
2110    lifecycle_plan
2111        .lifecycle_authority_rows
2112        .iter()
2113        .filter_map(|authority| authority.role.clone())
2114        .collect::<BTreeSet<_>>()
2115        .into_iter()
2116        .collect()
2117}
2118
2119fn lifecycle_canisters(lifecycle_plan: &ExternalLifecyclePlanV1) -> Vec<String> {
2120    lifecycle_plan
2121        .lifecycle_authority_rows
2122        .iter()
2123        .filter_map(|authority| authority.canister_id.clone())
2124        .collect::<BTreeSet<_>>()
2125        .into_iter()
2126        .collect()
2127}
2128
2129fn role_names(upgrades: &[ExternalLifecycleRoleUpgradeV1]) -> Vec<String> {
2130    upgrades
2131        .iter()
2132        .filter_map(|upgrade| upgrade.role.clone())
2133        .collect::<BTreeSet<_>>()
2134        .into_iter()
2135        .collect()
2136}
2137
2138fn critical_fix_next_steps(
2139    pending_external_count: usize,
2140    blocked_count: usize,
2141    protected_call_implications: &[String],
2142) -> Vec<String> {
2143    let mut steps = Vec::new();
2144    if pending_external_count > 0 {
2145        steps.push(
2146            "request external consent or completion for externally controlled roles".to_string(),
2147        );
2148    }
2149    if blocked_count > 0 {
2150        steps.push(
2151            "resolve blocked lifecycle rows before reporting the deployment fully patched"
2152                .to_string(),
2153        );
2154    }
2155    if !protected_call_implications.is_empty() {
2156        steps.push(
2157            "review protected-call readiness and role epoch implications before closure"
2158                .to_string(),
2159        );
2160    }
2161    if steps.is_empty() {
2162        steps.push("no external lifecycle work remains for this critical fix".to_string());
2163    }
2164    steps
2165}
2166
2167/// Build a passive critical-fix residual exposure report from lifecycle
2168/// evidence.
2169#[must_use]
2170pub fn critical_external_fix_report_from_pending(
2171    report_id: impl Into<String>,
2172    fix_id: impl Into<String>,
2173    severity: impl Into<String>,
2174    lifecycle_plan: &ExternalLifecyclePlanV1,
2175    pending_report: &ExternalLifecyclePendingReportV1,
2176) -> CriticalExternalFixReportV1 {
2177    let report_id = report_id.into();
2178    let fix_id = fix_id.into();
2179    let severity = severity.into();
2180    let affected_roles = lifecycle_roles(lifecycle_plan);
2181    let affected_canisters = lifecycle_canisters(lifecycle_plan);
2182    let directly_patchable_roles = role_names(&lifecycle_plan.directly_executable_role_upgrades);
2183    let externally_blocked_roles = pending_report
2184        .pending_external_actions
2185        .iter()
2186        .filter_map(|action| action.role.clone())
2187        .collect::<BTreeSet<_>>()
2188        .into_iter()
2189        .collect::<Vec<_>>();
2190    let dependency_blocked_roles = role_names(&lifecycle_plan.blocked_role_upgrades);
2191    let required_external_actions = pending_report
2192        .pending_external_actions
2193        .iter()
2194        .map(|action| format!("{}: {}", action.subject, action.required_external_action))
2195        .collect::<Vec<_>>();
2196    let operator_next_steps = critical_fix_next_steps(
2197        pending_report.pending_external_count,
2198        pending_report.blocked_count,
2199        lifecycle_plan.protected_call_implications.as_slice(),
2200    );
2201    let mut report = CriticalExternalFixReportV1 {
2202        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
2203        report_id,
2204        report_digest: String::new(),
2205        fix_id,
2206        severity,
2207        lifecycle_plan_id: lifecycle_plan.lifecycle_plan_id.clone(),
2208        lifecycle_plan_digest: lifecycle_plan.lifecycle_plan_digest.clone(),
2209        pending_report_id: pending_report.report_id.clone(),
2210        pending_report_digest: pending_report.report_digest.clone(),
2211        deployment_plan_id: lifecycle_plan.deployment_plan_id.clone(),
2212        deployment_plan_digest: lifecycle_plan.deployment_plan_digest.clone(),
2213        inventory_id: lifecycle_plan.inventory_id.clone(),
2214        affected_roles,
2215        affected_canisters,
2216        directly_patchable_roles,
2217        externally_blocked_roles,
2218        dependency_blocked_roles,
2219        required_external_actions,
2220        protected_call_implications: lifecycle_plan.protected_call_implications.clone(),
2221        residual_exposure: pending_report.residual_exposure.clone(),
2222        operator_next_steps,
2223    };
2224    report.report_digest = critical_external_fix_report_digest(&report);
2225    report
2226}
2227
2228/// Validate archived critical external fix report consistency and digest.
2229pub fn validate_critical_external_fix_report(
2230    report: &CriticalExternalFixReportV1,
2231) -> Result<(), CriticalExternalFixReportError> {
2232    if report.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
2233        return Err(CriticalExternalFixReportError::SchemaVersionMismatch {
2234            expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
2235            actual: report.schema_version,
2236        });
2237    }
2238    ensure_critical_fix_report_field("report_id", report.report_id.as_str())?;
2239    ensure_critical_fix_report_field("report_digest", report.report_digest.as_str())?;
2240    ensure_critical_fix_report_field("fix_id", report.fix_id.as_str())?;
2241    ensure_critical_fix_report_field("severity", report.severity.as_str())?;
2242    ensure_critical_fix_report_field("lifecycle_plan_id", report.lifecycle_plan_id.as_str())?;
2243    ensure_critical_fix_report_field(
2244        "lifecycle_plan_digest",
2245        report.lifecycle_plan_digest.as_str(),
2246    )?;
2247    ensure_critical_fix_report_field("pending_report_id", report.pending_report_id.as_str())?;
2248    ensure_critical_fix_report_field(
2249        "pending_report_digest",
2250        report.pending_report_digest.as_str(),
2251    )?;
2252    ensure_critical_fix_report_field("deployment_plan_id", report.deployment_plan_id.as_str())?;
2253    ensure_critical_fix_report_field(
2254        "deployment_plan_digest",
2255        report.deployment_plan_digest.as_str(),
2256    )?;
2257    ensure_critical_fix_report_field("inventory_id", report.inventory_id.as_str())?;
2258    if report.report_digest != critical_external_fix_report_digest(report) {
2259        return Err(CriticalExternalFixReportError::DigestMismatch {
2260            field: "report_digest",
2261        });
2262    }
2263    Ok(())
2264}
2265
2266/// Validate that an archived critical external fix report still matches the
2267/// lifecycle artifacts it claims to summarize.
2268pub fn validate_critical_external_fix_report_for_pending(
2269    report: &CriticalExternalFixReportV1,
2270    lifecycle_plan: &ExternalLifecyclePlanV1,
2271    pending_report: &ExternalLifecyclePendingReportV1,
2272) -> Result<(), CriticalExternalFixReportError> {
2273    validate_critical_external_fix_report(report)?;
2274    if report.lifecycle_plan_id != lifecycle_plan.lifecycle_plan_id {
2275        return Err(CriticalExternalFixReportError::SourceMismatch {
2276            field: "lifecycle_plan_id",
2277        });
2278    }
2279    if report.lifecycle_plan_digest != lifecycle_plan.lifecycle_plan_digest {
2280        return Err(CriticalExternalFixReportError::SourceMismatch {
2281            field: "lifecycle_plan_digest",
2282        });
2283    }
2284    if report.pending_report_id != pending_report.report_id {
2285        return Err(CriticalExternalFixReportError::SourceMismatch {
2286            field: "pending_report_id",
2287        });
2288    }
2289    if report.pending_report_digest != pending_report.report_digest {
2290        return Err(CriticalExternalFixReportError::SourceMismatch {
2291            field: "pending_report_digest",
2292        });
2293    }
2294    let expected = critical_external_fix_report_from_pending(
2295        report.report_id.clone(),
2296        report.fix_id.clone(),
2297        report.severity.clone(),
2298        lifecycle_plan,
2299        pending_report,
2300    );
2301    if report != &expected {
2302        return Err(CriticalExternalFixReportError::SourceMismatch {
2303            field: "lifecycle_plan",
2304        });
2305    }
2306    Ok(())
2307}
2308
2309fn external_upgrade_proposal(
2310    report_id: &str,
2311    lifecycle_plan: &ExternalLifecyclePlanV1,
2312    check: &DeploymentCheckV1,
2313    authority: &LifecycleAuthorityV1,
2314    observed: Option<&ObservedCanisterV1>,
2315    target_artifact: Option<&RoleArtifactV1>,
2316) -> ExternalUpgradeProposalV1 {
2317    let current_module_hash = observed.and_then(|observed| observed.module_hash.clone());
2318    let current_canonical_embedded_config_sha256 =
2319        observed.and_then(|observed| observed.canonical_embedded_config_digest.clone());
2320    let mut proposal = ExternalUpgradeProposalV1 {
2321        proposal_id: external_upgrade_proposal_id(report_id, authority.subject.as_str()),
2322        proposal_digest: String::new(),
2323        deployment_plan_id: lifecycle_plan.deployment_plan_id.clone(),
2324        deployment_plan_digest: lifecycle_plan.deployment_plan_digest.clone(),
2325        lifecycle_plan_id: lifecycle_plan.lifecycle_plan_id.clone(),
2326        lifecycle_plan_digest: lifecycle_plan.lifecycle_plan_digest.clone(),
2327        promotion_plan_id: None,
2328        promotion_plan_digest: None,
2329        promotion_provenance_id: None,
2330        promotion_provenance_digest: None,
2331        subject: authority.subject.clone(),
2332        canister_id: authority.canister_id.clone(),
2333        role: authority.role.clone(),
2334        control_class: authority.control_class,
2335        lifecycle_mode: authority.lifecycle_mode,
2336        observed_before_digest: observed_before_digest(
2337            authority,
2338            current_module_hash.as_ref(),
2339            current_canonical_embedded_config_sha256.as_ref(),
2340        ),
2341        current_module_hash,
2342        current_canonical_embedded_config_sha256,
2343        target_wasm_sha256: target_artifact.and_then(|artifact| artifact.wasm_sha256.clone()),
2344        target_wasm_gz_sha256: target_artifact.and_then(|artifact| artifact.wasm_gz_sha256.clone()),
2345        target_installed_module_hash: target_artifact
2346            .and_then(|artifact| artifact.installed_module_hash.clone()),
2347        target_role_artifact_identity: target_artifact.map(role_artifact_identity),
2348        target_canonical_embedded_config_sha256: target_artifact
2349            .and_then(|artifact| artifact.canonical_embedded_config_sha256.clone()),
2350        root_trust_anchor: check.plan.trust_domain.root_trust_anchor.clone(),
2351        authority_profile_hash: check
2352            .plan
2353            .deployment_identity
2354            .authority_profile_hash
2355            .clone(),
2356        required_external_action: required_external_action(authority.lifecycle_mode).to_string(),
2357        consent_requirements: authority.consent_requirements.clone(),
2358        allowed_authorization_modes: external_upgrade_authorization_modes(authority.control_class),
2359        verification_requirements: authority.verification_requirements.clone(),
2360        expires_at: None,
2361        supersedes_proposal_id: None,
2362    };
2363    proposal.proposal_digest = external_upgrade_proposal_digest(&proposal);
2364    proposal
2365}
2366
2367fn validate_external_upgrade_proposal(
2368    proposal: &ExternalUpgradeProposalV1,
2369) -> Result<(), ExternalUpgradeProposalReportError> {
2370    ensure_external_proposal_report_field("proposal_id", proposal.proposal_id.as_str())?;
2371    ensure_external_proposal_report_field("proposal_digest", proposal.proposal_digest.as_str())?;
2372    ensure_external_proposal_report_field(
2373        "proposal.deployment_plan_id",
2374        proposal.deployment_plan_id.as_str(),
2375    )?;
2376    ensure_external_proposal_report_field(
2377        "proposal.deployment_plan_digest",
2378        proposal.deployment_plan_digest.as_str(),
2379    )?;
2380    ensure_external_proposal_report_field(
2381        "proposal.lifecycle_plan_id",
2382        proposal.lifecycle_plan_id.as_str(),
2383    )?;
2384    ensure_external_proposal_report_field(
2385        "proposal.lifecycle_plan_digest",
2386        proposal.lifecycle_plan_digest.as_str(),
2387    )?;
2388    ensure_external_proposal_report_field(
2389        "proposal.observed_before_digest",
2390        proposal.observed_before_digest.as_str(),
2391    )?;
2392    ensure_external_proposal_report_field("proposal.subject", proposal.subject.as_str())?;
2393    if proposal.lifecycle_mode == LifecycleModeV1::DirectDeploymentAuthority {
2394        return Err(
2395            ExternalUpgradeProposalReportError::DirectLifecycleProposal {
2396                subject: proposal.subject.clone(),
2397            },
2398        );
2399    }
2400    if proposal.proposal_digest != external_upgrade_proposal_digest(proposal) {
2401        return Err(ExternalUpgradeProposalReportError::DigestMismatch {
2402            field: "proposal_digest",
2403        });
2404    }
2405    Ok(())
2406}
2407
2408fn lifecycle_authority_for_expected_canister(
2409    plan: &DeploymentPlanV1,
2410    expected: &ExpectedCanisterV1,
2411    observed: Option<&ObservedCanisterV1>,
2412) -> LifecycleAuthorityV1 {
2413    let canister_id = expected
2414        .canister_id
2415        .clone()
2416        .or_else(|| observed.map(|observed| observed.canister_id.clone()));
2417    let role = Some(expected.role.clone());
2418    let control_class = observed.map_or(expected.control_class, |observed| observed.control_class);
2419    let observed_controllers =
2420        observed.map_or_else(Vec::new, |observed| observed.controllers.clone());
2421    lifecycle_authority(
2422        lifecycle_subject_for_parts(canister_id.as_deref(), role.as_deref()),
2423        canister_id,
2424        role,
2425        control_class,
2426        observed_controllers,
2427        &plan.authority_profile.expected_controllers,
2428        plan.expected_verifier_readiness.required,
2429    )
2430}
2431
2432fn lifecycle_authority_for_expected_pool(
2433    expected: &ExpectedPoolCanisterV1,
2434    observed: Option<&ObservedPoolCanisterV1>,
2435) -> LifecycleAuthorityV1 {
2436    let canister_id = expected
2437        .canister_id
2438        .clone()
2439        .or_else(|| observed.map(|observed| observed.canister_id.clone()));
2440    let role = expected
2441        .role
2442        .clone()
2443        .or_else(|| observed.and_then(|observed| observed.role.clone()));
2444    let control_class = observed.map_or(CanisterControlClassV1::CanicManagedPool, |observed| {
2445        observed.control_class
2446    });
2447    lifecycle_authority(
2448        lifecycle_subject_for_parts(canister_id.as_deref(), role.as_deref()),
2449        canister_id,
2450        role,
2451        control_class,
2452        Vec::new(),
2453        &[],
2454        false,
2455    )
2456}
2457
2458fn lifecycle_authority_for_unplanned_canister(
2459    observed: &ObservedCanisterV1,
2460) -> LifecycleAuthorityV1 {
2461    lifecycle_authority(
2462        lifecycle_subject(observed.canister_id.as_str(), observed.role.as_deref()),
2463        Some(observed.canister_id.clone()),
2464        observed.role.clone(),
2465        observed.control_class,
2466        observed.controllers.clone(),
2467        &[],
2468        false,
2469    )
2470}
2471
2472fn lifecycle_authority_for_unplanned_pool(
2473    observed: &ObservedPoolCanisterV1,
2474) -> LifecycleAuthorityV1 {
2475    lifecycle_authority(
2476        lifecycle_subject(observed.canister_id.as_str(), observed.role.as_deref()),
2477        Some(observed.canister_id.clone()),
2478        observed.role.clone(),
2479        observed.control_class,
2480        Vec::new(),
2481        &[],
2482        false,
2483    )
2484}
2485
2486fn lifecycle_authority(
2487    subject: String,
2488    canister_id: Option<String>,
2489    role: Option<String>,
2490    control_class: CanisterControlClassV1,
2491    observed_controllers: Vec<String>,
2492    expected_controllers: &[String],
2493    verifier_required: bool,
2494) -> LifecycleAuthorityV1 {
2495    let required_controllers = required_lifecycle_controllers(control_class, expected_controllers);
2496    let external_controllers =
2497        external_lifecycle_controllers(control_class, &observed_controllers, &required_controllers);
2498    let consent_requirements = lifecycle_consent_requirements(control_class, &external_controllers);
2499    let allowed_upgrade_modes = lifecycle_upgrade_modes(control_class);
2500    let verification_requirements = lifecycle_verification_requirements(verifier_required);
2501    let external_action_required = lifecycle_external_action_required(control_class);
2502    let blocked = control_class == CanisterControlClassV1::UnknownUnsafe;
2503    let lifecycle_mode = lifecycle_mode(control_class);
2504    let blockers = lifecycle_blockers(control_class);
2505    let warnings = lifecycle_warnings(control_class);
2506    let reason = lifecycle_reason(control_class);
2507    LifecycleAuthorityV1 {
2508        subject,
2509        canister_id,
2510        role,
2511        control_class,
2512        lifecycle_mode,
2513        observed_controllers,
2514        expected_deployment_controllers: sorted_unique(expected_controllers.to_vec()),
2515        external_controllers,
2516        required_controllers,
2517        consent_requirements,
2518        allowed_upgrade_modes,
2519        verification_requirements,
2520        external_action_required,
2521        blocked,
2522        blockers,
2523        warnings,
2524        reason,
2525    }
2526}
2527
2528fn required_lifecycle_controllers(
2529    control_class: CanisterControlClassV1,
2530    expected_controllers: &[String],
2531) -> Vec<String> {
2532    match control_class {
2533        CanisterControlClassV1::DeploymentControlled
2534        | CanisterControlClassV1::JointlyControlled => sorted_unique(expected_controllers.to_vec()),
2535        CanisterControlClassV1::CanicManagedPool
2536        | CanisterControlClassV1::ExternallyImported
2537        | CanisterControlClassV1::UserControlled
2538        | CanisterControlClassV1::UnknownUnsafe => Vec::new(),
2539    }
2540}
2541
2542fn external_lifecycle_controllers(
2543    control_class: CanisterControlClassV1,
2544    observed_controllers: &[String],
2545    required_controllers: &[String],
2546) -> Vec<String> {
2547    match control_class {
2548        CanisterControlClassV1::DeploymentControlled | CanisterControlClassV1::UnknownUnsafe => {
2549            Vec::new()
2550        }
2551        CanisterControlClassV1::JointlyControlled => {
2552            let required = required_controllers.iter().collect::<BTreeSet<_>>();
2553            sorted_unique(
2554                observed_controllers
2555                    .iter()
2556                    .filter(|controller| !required.contains(controller))
2557                    .cloned()
2558                    .collect(),
2559            )
2560        }
2561        CanisterControlClassV1::CanicManagedPool
2562        | CanisterControlClassV1::ExternallyImported
2563        | CanisterControlClassV1::UserControlled => sorted_unique(observed_controllers.to_vec()),
2564    }
2565}
2566
2567fn lifecycle_consent_requirements(
2568    control_class: CanisterControlClassV1,
2569    external_controllers: &[String],
2570) -> Vec<ConsentRequirementV1> {
2571    if !lifecycle_external_action_required(control_class) {
2572        return Vec::new();
2573    }
2574    vec![ConsentRequirementV1 {
2575        consent_subject_kind: consent_subject_kind(control_class),
2576        required_principals: sorted_unique(external_controllers.to_vec()),
2577        required_controller_set_digest: Some(stable_json_sha256_hex(&external_controllers)),
2578        consent_channel_kind: consent_channel_kind(control_class),
2579        required_action: required_consent_action(control_class),
2580    }]
2581}
2582
2583const fn consent_subject_kind(control_class: CanisterControlClassV1) -> ConsentSubjectKindV1 {
2584    match control_class {
2585        CanisterControlClassV1::CanicManagedPool => ConsentSubjectKindV1::ProjectHub,
2586        CanisterControlClassV1::ExternallyImported | CanisterControlClassV1::JointlyControlled => {
2587            ConsentSubjectKindV1::CustomerController
2588        }
2589        CanisterControlClassV1::UserControlled => ConsentSubjectKindV1::UserPrincipal,
2590        CanisterControlClassV1::DeploymentControlled | CanisterControlClassV1::UnknownUnsafe => {
2591            ConsentSubjectKindV1::UnknownExternalController
2592        }
2593    }
2594}
2595
2596const fn consent_channel_kind(control_class: CanisterControlClassV1) -> ConsentChannelKindV1 {
2597    match control_class {
2598        CanisterControlClassV1::CanicManagedPool => ConsentChannelKindV1::DelegatedInstall,
2599        CanisterControlClassV1::ExternallyImported
2600        | CanisterControlClassV1::JointlyControlled
2601        | CanisterControlClassV1::UserControlled => ConsentChannelKindV1::GeneratedCommand,
2602        CanisterControlClassV1::DeploymentControlled | CanisterControlClassV1::UnknownUnsafe => {
2603            ConsentChannelKindV1::OutOfBand
2604        }
2605    }
2606}
2607
2608const fn required_consent_action(
2609    control_class: CanisterControlClassV1,
2610) -> ExternalUpgradeAuthorizationModeV1 {
2611    match control_class {
2612        CanisterControlClassV1::JointlyControlled => {
2613            ExternalUpgradeAuthorizationModeV1::ConsentForDirectInstall
2614        }
2615        CanisterControlClassV1::CanicManagedPool => {
2616            ExternalUpgradeAuthorizationModeV1::DelegatedInstallAuthority
2617        }
2618        CanisterControlClassV1::ExternallyImported | CanisterControlClassV1::UserControlled => {
2619            ExternalUpgradeAuthorizationModeV1::ExternalControllerExecution
2620        }
2621        CanisterControlClassV1::DeploymentControlled | CanisterControlClassV1::UnknownUnsafe => {
2622            ExternalUpgradeAuthorizationModeV1::ObserveAndVerifyOnly
2623        }
2624    }
2625}
2626
2627const fn lifecycle_mode(control_class: CanisterControlClassV1) -> LifecycleModeV1 {
2628    match control_class {
2629        CanisterControlClassV1::DeploymentControlled => LifecycleModeV1::DirectDeploymentAuthority,
2630        CanisterControlClassV1::CanicManagedPool => LifecycleModeV1::DelegatedInstallRequired,
2631        CanisterControlClassV1::ExternallyImported | CanisterControlClassV1::UserControlled => {
2632            LifecycleModeV1::ExternalCompletionOnly
2633        }
2634        CanisterControlClassV1::JointlyControlled => LifecycleModeV1::ProposalRequired,
2635        CanisterControlClassV1::UnknownUnsafe => LifecycleModeV1::UnknownUnsafeBlocked,
2636    }
2637}
2638
2639fn lifecycle_blockers(control_class: CanisterControlClassV1) -> Vec<String> {
2640    if control_class == CanisterControlClassV1::UnknownUnsafe {
2641        vec!["unknown unsafe controller state blocks lifecycle action".to_string()]
2642    } else {
2643        Vec::new()
2644    }
2645}
2646
2647fn lifecycle_warnings(control_class: CanisterControlClassV1) -> Vec<String> {
2648    match control_class {
2649        CanisterControlClassV1::CanicManagedPool => {
2650            vec!["pool-aware lifecycle policy is required before mutation".to_string()]
2651        }
2652        CanisterControlClassV1::ExternallyImported => {
2653            vec!["external controller action or verification is required".to_string()]
2654        }
2655        CanisterControlClassV1::JointlyControlled => {
2656            vec!["joint controller consent or delegation is required".to_string()]
2657        }
2658        CanisterControlClassV1::UserControlled => {
2659            vec!["user or delegated lifecycle action is required".to_string()]
2660        }
2661        CanisterControlClassV1::DeploymentControlled | CanisterControlClassV1::UnknownUnsafe => {
2662            Vec::new()
2663        }
2664    }
2665}
2666
2667fn lifecycle_upgrade_modes(control_class: CanisterControlClassV1) -> Vec<LifecycleUpgradeModeV1> {
2668    match control_class {
2669        CanisterControlClassV1::DeploymentControlled => vec![
2670            LifecycleUpgradeModeV1::DirectByDeploymentAuthority,
2671            LifecycleUpgradeModeV1::VerifyExternalCompletion,
2672        ],
2673        CanisterControlClassV1::CanicManagedPool
2674        | CanisterControlClassV1::ExternallyImported
2675        | CanisterControlClassV1::JointlyControlled
2676        | CanisterControlClassV1::UserControlled => vec![
2677            LifecycleUpgradeModeV1::ExternalProposal,
2678            LifecycleUpgradeModeV1::ExternalExecution,
2679            LifecycleUpgradeModeV1::VerifyExternalCompletion,
2680            LifecycleUpgradeModeV1::ObserveOnly,
2681        ],
2682        CanisterControlClassV1::UnknownUnsafe => vec![LifecycleUpgradeModeV1::Blocked],
2683    }
2684}
2685
2686fn lifecycle_verification_requirements(
2687    verifier_required: bool,
2688) -> Vec<LifecycleVerificationRequirementV1> {
2689    let mut requirements = vec![
2690        LifecycleVerificationRequirementV1::LiveInventory,
2691        LifecycleVerificationRequirementV1::ControllerObservation,
2692        LifecycleVerificationRequirementV1::ModuleHash,
2693        LifecycleVerificationRequirementV1::CanonicalEmbeddedConfig,
2694    ];
2695    if verifier_required {
2696        requirements.push(LifecycleVerificationRequirementV1::ProtectedCallReadiness);
2697    }
2698    requirements
2699}
2700
2701const fn lifecycle_external_action_required(control_class: CanisterControlClassV1) -> bool {
2702    matches!(
2703        control_class,
2704        CanisterControlClassV1::CanicManagedPool
2705            | CanisterControlClassV1::ExternallyImported
2706            | CanisterControlClassV1::JointlyControlled
2707            | CanisterControlClassV1::UserControlled
2708    )
2709}
2710
2711fn lifecycle_reason(control_class: CanisterControlClassV1) -> String {
2712    match control_class {
2713        CanisterControlClassV1::DeploymentControlled => {
2714            "deployment authority can execute lifecycle directly".to_string()
2715        }
2716        CanisterControlClassV1::CanicManagedPool => {
2717            "Canic-managed pool lifecycle requires pool-aware external action".to_string()
2718        }
2719        CanisterControlClassV1::ExternallyImported => {
2720            "externally imported canister requires external controller action".to_string()
2721        }
2722        CanisterControlClassV1::JointlyControlled => {
2723            "jointly controlled canister requires non-deployment-controller consent".to_string()
2724        }
2725        CanisterControlClassV1::UserControlled => {
2726            "user-controlled canister requires user or delegated lifecycle action".to_string()
2727        }
2728        CanisterControlClassV1::UnknownUnsafe => {
2729            "unknown or unsafe controller state blocks lifecycle action".to_string()
2730        }
2731    }
2732}
2733
2734fn observed_canister_for_expected<'a>(
2735    inventory: &'a DeploymentInventoryV1,
2736    expected: &ExpectedCanisterV1,
2737) -> Option<&'a ObservedCanisterV1> {
2738    if let Some(canister_id) = &expected.canister_id
2739        && let Some(observed) = inventory
2740            .observed_canisters
2741            .iter()
2742            .find(|observed| &observed.canister_id == canister_id)
2743    {
2744        return Some(observed);
2745    }
2746    inventory
2747        .observed_canisters
2748        .iter()
2749        .find(|observed| observed.role.as_deref() == Some(expected.role.as_str()))
2750}
2751
2752fn observed_pool_for_expected<'a>(
2753    inventory: &'a DeploymentInventoryV1,
2754    expected: &ExpectedPoolCanisterV1,
2755) -> Option<&'a ObservedPoolCanisterV1> {
2756    if let Some(canister_id) = &expected.canister_id
2757        && let Some(observed) = inventory
2758            .observed_pool
2759            .iter()
2760            .find(|observed| &observed.canister_id == canister_id)
2761    {
2762        return Some(observed);
2763    }
2764    inventory.observed_pool.iter().find(|observed| {
2765        observed.pool == expected.pool && observed.role.as_deref() == expected.role.as_deref()
2766    })
2767}
2768
2769fn lifecycle_subject(canister_id: &str, role: Option<&str>) -> String {
2770    lifecycle_subject_for_parts(Some(canister_id), role)
2771}
2772
2773fn lifecycle_subject_for_parts(canister_id: Option<&str>, role: Option<&str>) -> String {
2774    match (role, canister_id) {
2775        (Some(role), Some(canister_id)) => format!("{role}:{canister_id}"),
2776        (Some(role), None) => format!("{role}:unassigned"),
2777        (None, Some(canister_id)) => canister_id.to_string(),
2778        (None, None) => "unknown".to_string(),
2779    }
2780}
2781
2782fn observed_canister_for_authority<'a>(
2783    inventory: &'a DeploymentInventoryV1,
2784    authority: &LifecycleAuthorityV1,
2785) -> Option<&'a ObservedCanisterV1> {
2786    if let Some(canister_id) = &authority.canister_id
2787        && let Some(observed) = inventory
2788            .observed_canisters
2789            .iter()
2790            .find(|observed| &observed.canister_id == canister_id)
2791    {
2792        return Some(observed);
2793    }
2794    inventory
2795        .observed_canisters
2796        .iter()
2797        .find(|observed| observed.role == authority.role)
2798}
2799
2800fn observed_canister_for_verification_policy<'a>(
2801    inventory: &'a DeploymentInventoryV1,
2802    policy: &ExternalUpgradeVerificationPolicyV1,
2803) -> Option<&'a ObservedCanisterV1> {
2804    if let Some(canister_id) = &policy.canister_id
2805        && let Some(observed) = inventory
2806            .observed_canisters
2807            .iter()
2808            .find(|observed| &observed.canister_id == canister_id)
2809    {
2810        return Some(observed);
2811    }
2812    inventory
2813        .observed_canisters
2814        .iter()
2815        .find(|observed| observed.role == policy.role)
2816}
2817
2818fn external_upgrade_protected_call_ready(
2819    policy: &ExternalUpgradeVerificationPolicyV1,
2820    check: &DeploymentCheckV1,
2821) -> Option<bool> {
2822    policy
2823        .required_verification
2824        .contains(&LifecycleVerificationRequirementV1::ProtectedCallReadiness)
2825        .then_some(
2826            check.inventory.observed_verifier_readiness.status == ObservationStatusV1::Observed,
2827        )
2828}
2829
2830fn target_artifact_for_authority<'a>(
2831    plan: &'a DeploymentPlanV1,
2832    authority: &LifecycleAuthorityV1,
2833) -> Option<&'a RoleArtifactV1> {
2834    let role = authority.role.as_ref()?;
2835    plan.role_artifacts
2836        .iter()
2837        .find(|artifact| &artifact.role == role)
2838}
2839
2840fn external_lifecycle_role_upgrade(
2841    authority: &LifecycleAuthorityV1,
2842) -> ExternalLifecycleRoleUpgradeV1 {
2843    ExternalLifecycleRoleUpgradeV1 {
2844        subject: authority.subject.clone(),
2845        canister_id: authority.canister_id.clone(),
2846        role: authority.role.clone(),
2847        control_class: authority.control_class,
2848        lifecycle_mode: authority.lifecycle_mode,
2849        required_external_action: authority
2850            .external_action_required
2851            .then(|| required_external_action(authority.lifecycle_mode).to_string()),
2852        blockers: authority.blockers.clone(),
2853        warnings: authority.warnings.clone(),
2854    }
2855}
2856
2857fn protected_call_implications_for_check(check: &DeploymentCheckV1) -> Vec<String> {
2858    if check.plan.expected_verifier_readiness.required {
2859        vec!["protected-call verifier readiness must be checked before completion".to_string()]
2860    } else {
2861        Vec::new()
2862    }
2863}
2864
2865const fn required_external_action(lifecycle_mode: LifecycleModeV1) -> &'static str {
2866    match lifecycle_mode {
2867        LifecycleModeV1::DirectDeploymentAuthority => "none",
2868        LifecycleModeV1::ProposalRequired => "proposal_and_consent",
2869        LifecycleModeV1::DelegatedInstallRequired => "delegated_install_or_pool_policy",
2870        LifecycleModeV1::ExternalCompletionOnly => "external_controller_execution",
2871        LifecycleModeV1::VerifyOnly => "verify_external_completion",
2872        LifecycleModeV1::MustNotTouch | LifecycleModeV1::UnknownUnsafeBlocked => "blocked",
2873    }
2874}
2875
2876fn role_artifact_identity(artifact: &RoleArtifactV1) -> String {
2877    stable_json_sha256_hex(&(
2878        artifact.role.as_str(),
2879        artifact.wasm_sha256.as_deref(),
2880        artifact.wasm_gz_sha256.as_deref(),
2881        artifact.installed_module_hash.as_deref(),
2882        artifact.candid_sha256.as_deref(),
2883        artifact.canonical_embedded_config_sha256.as_deref(),
2884    ))
2885}
2886
2887fn external_upgrade_authorization_modes(
2888    control_class: CanisterControlClassV1,
2889) -> Vec<ExternalUpgradeAuthorizationModeV1> {
2890    match control_class {
2891        CanisterControlClassV1::JointlyControlled => vec![
2892            ExternalUpgradeAuthorizationModeV1::ConsentForDirectInstall,
2893            ExternalUpgradeAuthorizationModeV1::DelegatedInstallAuthority,
2894            ExternalUpgradeAuthorizationModeV1::ExternalControllerExecution,
2895            ExternalUpgradeAuthorizationModeV1::ObserveAndVerifyOnly,
2896        ],
2897        CanisterControlClassV1::CanicManagedPool
2898        | CanisterControlClassV1::ExternallyImported
2899        | CanisterControlClassV1::UserControlled => vec![
2900            ExternalUpgradeAuthorizationModeV1::DelegatedInstallAuthority,
2901            ExternalUpgradeAuthorizationModeV1::ExternalControllerExecution,
2902            ExternalUpgradeAuthorizationModeV1::ObserveAndVerifyOnly,
2903        ],
2904        CanisterControlClassV1::DeploymentControlled | CanisterControlClassV1::UnknownUnsafe => {
2905            Vec::new()
2906        }
2907    }
2908}
2909
2910fn external_upgrade_proposal_id(report_id: &str, subject: &str) -> String {
2911    let subject = subject.replace([':', '/'], "-");
2912    format!("{report_id}:{subject}")
2913}
2914
2915fn external_lifecycle_plan_digest(plan: &ExternalLifecyclePlanV1) -> String {
2916    stable_json_sha256_hex(&ExternalLifecyclePlanDigestInput {
2917        lifecycle_authority_report_id: &plan.lifecycle_authority_report_id,
2918        deployment_plan_id: &plan.deployment_plan_id,
2919        deployment_plan_digest: &plan.deployment_plan_digest,
2920        inventory_id: &plan.inventory_id,
2921        lifecycle_authority_rows: &plan.lifecycle_authority_rows,
2922        directly_executable_role_upgrades: &plan.directly_executable_role_upgrades,
2923        proposed_external_role_upgrades: &plan.proposed_external_role_upgrades,
2924        blocked_role_upgrades: &plan.blocked_role_upgrades,
2925        dependency_blockers: &plan.dependency_blockers,
2926        protected_call_implications: &plan.protected_call_implications,
2927        residual_exposure: &plan.residual_exposure,
2928        status: plan.status,
2929    })
2930}
2931
2932fn lifecycle_authority_report_digest(report: &LifecycleAuthorityReportV1) -> String {
2933    stable_json_sha256_hex(&LifecycleAuthorityReportDigestInput {
2934        report_id: &report.report_id,
2935        check_id: &report.check_id,
2936        plan_id: &report.plan_id,
2937        inventory_id: &report.inventory_id,
2938        authorities: &report.authorities,
2939        external_action_required_count: report.external_action_required_count,
2940        blocked_count: report.blocked_count,
2941    })
2942}
2943
2944const fn expected_lifecycle_plan_status(
2945    plan: &ExternalLifecyclePlanV1,
2946) -> ExternalLifecyclePlanStatusV1 {
2947    if !plan.blocked_role_upgrades.is_empty() {
2948        ExternalLifecyclePlanStatusV1::Blocked
2949    } else if !plan.proposed_external_role_upgrades.is_empty() {
2950        ExternalLifecyclePlanStatusV1::PendingExternalAction
2951    } else {
2952        ExternalLifecyclePlanStatusV1::Ready
2953    }
2954}
2955
2956fn ensure_unique_lifecycle_subjects(
2957    rows: &[LifecycleAuthorityV1],
2958) -> Result<(), ExternalLifecyclePlanError> {
2959    let mut subjects = BTreeSet::new();
2960    for row in rows {
2961        if !subjects.insert(row.subject.clone()) {
2962            return Err(ExternalLifecyclePlanError::DuplicateSubject {
2963                subject: row.subject.clone(),
2964            });
2965        }
2966    }
2967    Ok(())
2968}
2969
2970fn ensure_unique_authority_subjects(
2971    rows: &[LifecycleAuthorityV1],
2972) -> Result<(), LifecycleAuthorityReportError> {
2973    let mut subjects = BTreeSet::new();
2974    for row in rows {
2975        if !subjects.insert(row.subject.clone()) {
2976            return Err(LifecycleAuthorityReportError::DuplicateSubject {
2977                subject: row.subject.clone(),
2978            });
2979        }
2980    }
2981    Ok(())
2982}
2983
2984fn ensure_unique_role_upgrade_subjects(
2985    rows: &[ExternalLifecycleRoleUpgradeV1],
2986) -> Result<(), ExternalLifecyclePlanError> {
2987    let mut subjects = BTreeSet::new();
2988    for row in rows {
2989        if !subjects.insert(row.subject.clone()) {
2990            return Err(ExternalLifecyclePlanError::DuplicateSubject {
2991                subject: row.subject.clone(),
2992            });
2993        }
2994    }
2995    Ok(())
2996}
2997
2998fn external_upgrade_proposal_digest(proposal: &ExternalUpgradeProposalV1) -> String {
2999    stable_json_sha256_hex(&ExternalUpgradeProposalDigestInput {
3000        deployment_plan_id: &proposal.deployment_plan_id,
3001        deployment_plan_digest: &proposal.deployment_plan_digest,
3002        lifecycle_plan_id: &proposal.lifecycle_plan_id,
3003        lifecycle_plan_digest: &proposal.lifecycle_plan_digest,
3004        promotion_plan_id: &proposal.promotion_plan_id,
3005        promotion_plan_digest: &proposal.promotion_plan_digest,
3006        promotion_provenance_id: &proposal.promotion_provenance_id,
3007        promotion_provenance_digest: &proposal.promotion_provenance_digest,
3008        subject: &proposal.subject,
3009        canister_id: &proposal.canister_id,
3010        role: &proposal.role,
3011        control_class: proposal.control_class,
3012        lifecycle_mode: proposal.lifecycle_mode,
3013        observed_before_digest: &proposal.observed_before_digest,
3014        current_module_hash: &proposal.current_module_hash,
3015        current_canonical_embedded_config_sha256: &proposal
3016            .current_canonical_embedded_config_sha256,
3017        target_wasm_sha256: &proposal.target_wasm_sha256,
3018        target_wasm_gz_sha256: &proposal.target_wasm_gz_sha256,
3019        target_installed_module_hash: &proposal.target_installed_module_hash,
3020        target_role_artifact_identity: &proposal.target_role_artifact_identity,
3021        target_canonical_embedded_config_sha256: &proposal.target_canonical_embedded_config_sha256,
3022        root_trust_anchor: &proposal.root_trust_anchor,
3023        authority_profile_hash: &proposal.authority_profile_hash,
3024        required_external_action: &proposal.required_external_action,
3025        consent_requirements: &proposal.consent_requirements,
3026        allowed_authorization_modes: &proposal.allowed_authorization_modes,
3027        verification_requirements: &proposal.verification_requirements,
3028        expires_at: &proposal.expires_at,
3029        supersedes_proposal_id: &proposal.supersedes_proposal_id,
3030    })
3031}
3032
3033fn external_upgrade_proposal_report_digest(report: &ExternalUpgradeProposalReportV1) -> String {
3034    stable_json_sha256_hex(&ExternalUpgradeProposalReportDigestInput {
3035        report_id: &report.report_id,
3036        lifecycle_plan_id: &report.lifecycle_plan_id,
3037        lifecycle_plan_digest: &report.lifecycle_plan_digest,
3038        deployment_plan_id: &report.deployment_plan_id,
3039        deployment_plan_digest: &report.deployment_plan_digest,
3040        inventory_id: &report.inventory_id,
3041        proposals: &report.proposals,
3042        blocked_subjects: &report.blocked_subjects,
3043    })
3044}
3045
3046fn external_lifecycle_pending_report_digest(report: &ExternalLifecyclePendingReportV1) -> String {
3047    stable_json_sha256_hex(&ExternalLifecyclePendingReportDigestInput {
3048        report_id: &report.report_id,
3049        lifecycle_plan_id: &report.lifecycle_plan_id,
3050        lifecycle_plan_digest: &report.lifecycle_plan_digest,
3051        proposal_report_id: &report.proposal_report_id,
3052        proposal_report_digest: &report.proposal_report_digest,
3053        deployment_plan_id: &report.deployment_plan_id,
3054        deployment_plan_digest: &report.deployment_plan_digest,
3055        inventory_id: &report.inventory_id,
3056        direct_upgrade_count: report.direct_upgrade_count,
3057        pending_external_count: report.pending_external_count,
3058        blocked_count: report.blocked_count,
3059        pending_external_actions: &report.pending_external_actions,
3060        blocked_subjects: &report.blocked_subjects,
3061        residual_exposure: &report.residual_exposure,
3062        status: report.status,
3063    })
3064}
3065
3066fn external_lifecycle_check_digest(check: &ExternalLifecycleCheckV1) -> String {
3067    stable_json_sha256_hex(&ExternalLifecycleCheckDigestInput {
3068        check_id: &check.check_id,
3069        lifecycle_plan_id: &check.lifecycle_plan_id,
3070        lifecycle_plan_digest: &check.lifecycle_plan_digest,
3071        proposal_report_id: &check.proposal_report_id,
3072        proposal_report_digest: &check.proposal_report_digest,
3073        pending_report_id: &check.pending_report_id,
3074        pending_report_digest: &check.pending_report_digest,
3075        deployment_plan_id: &check.deployment_plan_id,
3076        deployment_plan_digest: &check.deployment_plan_digest,
3077        inventory_id: &check.inventory_id,
3078        status: check.status,
3079        direct_upgrade_count: check.direct_upgrade_count,
3080        pending_external_count: check.pending_external_count,
3081        blocked_count: check.blocked_count,
3082        residual_exposure_count: check.residual_exposure_count,
3083        summary: &check.summary,
3084        next_actions: &check.next_actions,
3085    })
3086}
3087
3088fn external_lifecycle_handoff_digest(handoff: &ExternalLifecycleHandoffV1) -> String {
3089    stable_json_sha256_hex(&ExternalLifecycleHandoffDigestInput {
3090        handoff_id: &handoff.handoff_id,
3091        lifecycle_check_id: &handoff.lifecycle_check_id,
3092        lifecycle_check_digest: &handoff.lifecycle_check_digest,
3093        pending_report_id: &handoff.pending_report_id,
3094        pending_report_digest: &handoff.pending_report_digest,
3095        proposal_report_id: &handoff.proposal_report_id,
3096        proposal_report_digest: &handoff.proposal_report_digest,
3097        deployment_plan_id: &handoff.deployment_plan_id,
3098        deployment_plan_digest: &handoff.deployment_plan_digest,
3099        inventory_id: &handoff.inventory_id,
3100        status: handoff.status,
3101        handoff_actions: &handoff.handoff_actions,
3102        blocked_subjects: &handoff.blocked_subjects,
3103        residual_exposure: &handoff.residual_exposure,
3104        operator_summary: &handoff.operator_summary,
3105    })
3106}
3107
3108fn critical_external_fix_report_digest(report: &CriticalExternalFixReportV1) -> String {
3109    stable_json_sha256_hex(&CriticalExternalFixReportDigestInput {
3110        report_id: &report.report_id,
3111        fix_id: &report.fix_id,
3112        severity: &report.severity,
3113        lifecycle_plan_id: &report.lifecycle_plan_id,
3114        lifecycle_plan_digest: &report.lifecycle_plan_digest,
3115        pending_report_id: &report.pending_report_id,
3116        pending_report_digest: &report.pending_report_digest,
3117        deployment_plan_id: &report.deployment_plan_id,
3118        deployment_plan_digest: &report.deployment_plan_digest,
3119        inventory_id: &report.inventory_id,
3120        affected_roles: &report.affected_roles,
3121        affected_canisters: &report.affected_canisters,
3122        directly_patchable_roles: &report.directly_patchable_roles,
3123        externally_blocked_roles: &report.externally_blocked_roles,
3124        dependency_blocked_roles: &report.dependency_blocked_roles,
3125        required_external_actions: &report.required_external_actions,
3126        protected_call_implications: &report.protected_call_implications,
3127        residual_exposure: &report.residual_exposure,
3128        operator_next_steps: &report.operator_next_steps,
3129    })
3130}
3131
3132fn external_upgrade_receipt_digest(receipt: &ExternalUpgradeReceiptV1) -> String {
3133    stable_json_sha256_hex(&ExternalUpgradeReceiptDigestInput {
3134        proposal_id: &receipt.proposal_id,
3135        proposal_digest: &receipt.proposal_digest,
3136        subject: &receipt.subject,
3137        canister_id: &receipt.canister_id,
3138        role: &receipt.role,
3139        consent_state: receipt.consent_state,
3140        reported_by: &receipt.reported_by,
3141        observed_before_module_hash: &receipt.observed_before_module_hash,
3142        observed_after_module_hash: &receipt.observed_after_module_hash,
3143        observed_after_canonical_embedded_config_sha256: &receipt
3144            .observed_after_canonical_embedded_config_sha256,
3145        verification_result: receipt.verification_result,
3146        verification_notes: &receipt.verification_notes,
3147    })
3148}
3149
3150fn external_upgrade_consent_evidence_digest(evidence: &ExternalUpgradeConsentEvidenceV1) -> String {
3151    stable_json_sha256_hex(&ExternalUpgradeConsentEvidenceDigestInput {
3152        evidence_id: &evidence.evidence_id,
3153        proposal_id: &evidence.proposal_id,
3154        proposal_digest: &evidence.proposal_digest,
3155        receipt_id: &evidence.receipt_id,
3156        receipt_digest: &evidence.receipt_digest,
3157        subject: &evidence.subject,
3158        canister_id: &evidence.canister_id,
3159        role: &evidence.role,
3160        consent_state: evidence.consent_state,
3161        reported_by: &evidence.reported_by,
3162        consent_requirements: &evidence.consent_requirements,
3163        allowed_authorization_modes: &evidence.allowed_authorization_modes,
3164        status_summary: &evidence.status_summary,
3165    })
3166}
3167
3168fn external_upgrade_verification_report_digest(
3169    report: &ExternalUpgradeVerificationReportV1,
3170) -> String {
3171    stable_json_sha256_hex(&ExternalUpgradeVerificationReportDigestInput {
3172        report_id: &report.report_id,
3173        proposal_id: &report.proposal_id,
3174        proposal_digest: &report.proposal_digest,
3175        receipt_id: &report.receipt_id,
3176        receipt_digest: &report.receipt_digest,
3177        subject: &report.subject,
3178        canister_id: &report.canister_id,
3179        role: &report.role,
3180        verification_result: report.verification_result,
3181        verification_notes: &report.verification_notes,
3182        live_inventory_required: report.live_inventory_required,
3183        status_summary: &report.status_summary,
3184    })
3185}
3186
3187fn external_upgrade_verification_policy_digest(
3188    policy: &ExternalUpgradeVerificationPolicyV1,
3189) -> String {
3190    stable_json_sha256_hex(&ExternalUpgradeVerificationPolicyDigestInput {
3191        policy_id: &policy.policy_id,
3192        proposal_id: &policy.proposal_id,
3193        proposal_digest: &policy.proposal_digest,
3194        deployment_plan_id: &policy.deployment_plan_id,
3195        deployment_plan_digest: &policy.deployment_plan_digest,
3196        subject: &policy.subject,
3197        canister_id: &policy.canister_id,
3198        role: &policy.role,
3199        required_verification: &policy.required_verification,
3200        verification_requirements: &policy.verification_requirements,
3201        max_observation_age_seconds: policy.max_observation_age_seconds,
3202        status_summary: &policy.status_summary,
3203    })
3204}
3205
3206fn external_upgrade_verification_check_digest(
3207    check: &ExternalUpgradeVerificationCheckV1,
3208) -> String {
3209    stable_json_sha256_hex(&ExternalUpgradeVerificationCheckDigestInput {
3210        check_id: &check.check_id,
3211        policy_id: &check.policy_id,
3212        policy_digest: &check.policy_digest,
3213        proposal_id: &check.proposal_id,
3214        proposal_digest: &check.proposal_digest,
3215        subject: &check.subject,
3216        canister_id: &check.canister_id,
3217        role: &check.role,
3218        observation: &check.observation,
3219        requirement_results: &check.requirement_results,
3220        verification_result: check.verification_result,
3221        status_summary: &check.status_summary,
3222    })
3223}
3224
3225fn external_upgrade_completion_report_digest(report: &ExternalUpgradeCompletionReportV1) -> String {
3226    stable_json_sha256_hex(&ExternalUpgradeCompletionReportDigestInput {
3227        report_id: &report.report_id,
3228        proposal_id: &report.proposal_id,
3229        proposal_digest: &report.proposal_digest,
3230        consent_evidence_id: &report.consent_evidence_id,
3231        consent_evidence_digest: &report.consent_evidence_digest,
3232        verification_check_id: &report.verification_check_id,
3233        verification_check_digest: &report.verification_check_digest,
3234        subject: &report.subject,
3235        canister_id: &report.canister_id,
3236        role: &report.role,
3237        consent_state: report.consent_state,
3238        verification_result: report.verification_result,
3239        verification_observation_source: report.verification_observation_source,
3240        completion_status: report.completion_status,
3241        blockers: &report.blockers,
3242        next_actions: &report.next_actions,
3243        status_summary: &report.status_summary,
3244    })
3245}
3246
3247fn observed_before_digest(
3248    authority: &LifecycleAuthorityV1,
3249    current_module_hash: Option<&String>,
3250    current_config_hash: Option<&String>,
3251) -> String {
3252    stable_json_sha256_hex(&ObservedBeforeDigestInput {
3253        subject: &authority.subject,
3254        canister_id: &authority.canister_id,
3255        role: &authority.role,
3256        observed_controllers: &authority.observed_controllers,
3257        current_module_hash,
3258        current_canonical_embedded_config_sha256: current_config_hash,
3259    })
3260}
3261
3262fn external_upgrade_verification_result(
3263    consent_state: ExternalUpgradeConsentStateV1,
3264    proposal: &ExternalUpgradeProposalV1,
3265    observed_after_module_hash: Option<&str>,
3266    observed_after_config: Option<&str>,
3267) -> ExternalUpgradeVerificationResultV1 {
3268    match consent_state {
3269        ExternalUpgradeConsentStateV1::Pending => ExternalUpgradeVerificationResultV1::Pending,
3270        ExternalUpgradeConsentStateV1::Refused => ExternalUpgradeVerificationResultV1::Refused,
3271        ExternalUpgradeConsentStateV1::Delegated
3272        | ExternalUpgradeConsentStateV1::ExecutedExternally => {
3273            if external_upgrade_observation_matches(
3274                proposal.target_installed_module_hash.as_deref(),
3275                observed_after_module_hash,
3276            ) && external_upgrade_observation_matches(
3277                proposal.target_canonical_embedded_config_sha256.as_deref(),
3278                observed_after_config,
3279            ) {
3280                ExternalUpgradeVerificationResultV1::Verified
3281            } else {
3282                ExternalUpgradeVerificationResultV1::Mismatch
3283            }
3284        }
3285    }
3286}
3287
3288fn external_upgrade_verification_notes(
3289    verification_result: ExternalUpgradeVerificationResultV1,
3290    proposal: &ExternalUpgradeProposalV1,
3291    observed_after_module_hash: Option<&str>,
3292    observed_after_config: Option<&str>,
3293) -> Vec<String> {
3294    let mut notes = Vec::new();
3295    if verification_result == ExternalUpgradeVerificationResultV1::Mismatch {
3296        if !external_upgrade_observation_matches(
3297            proposal.target_installed_module_hash.as_deref(),
3298            observed_after_module_hash,
3299        ) {
3300            notes.push("observed module hash does not match proposal target".to_string());
3301        }
3302        if !external_upgrade_observation_matches(
3303            proposal.target_canonical_embedded_config_sha256.as_deref(),
3304            observed_after_config,
3305        ) {
3306            notes.push("observed embedded config does not match proposal target".to_string());
3307        }
3308    }
3309    notes
3310}
3311
3312fn external_lifecycle_check_summary(
3313    status: ExternalLifecyclePlanStatusV1,
3314    pending_report: &ExternalLifecyclePendingReportV1,
3315) -> String {
3316    match status {
3317        ExternalLifecyclePlanStatusV1::Ready => {
3318            format!(
3319                "external lifecycle is ready: {} directly executable role(s), no pending external action",
3320                pending_report.direct_upgrade_count
3321            )
3322        }
3323        ExternalLifecyclePlanStatusV1::PendingExternalAction => {
3324            format!(
3325                "external lifecycle has {} pending external action(s) and {} directly executable role(s)",
3326                pending_report.pending_external_count, pending_report.direct_upgrade_count
3327            )
3328        }
3329        ExternalLifecyclePlanStatusV1::Blocked => {
3330            format!(
3331                "external lifecycle is blocked by {} role/canister subject(s)",
3332                pending_report.blocked_count
3333            )
3334        }
3335    }
3336}
3337
3338fn external_lifecycle_check_next_actions(
3339    status: ExternalLifecyclePlanStatusV1,
3340    pending_report: &ExternalLifecyclePendingReportV1,
3341) -> Vec<String> {
3342    match status {
3343        ExternalLifecyclePlanStatusV1::Ready => {
3344            vec!["continue through the normal guarded deployment path".to_string()]
3345        }
3346        ExternalLifecyclePlanStatusV1::PendingExternalAction => pending_report
3347            .pending_external_actions
3348            .iter()
3349            .map(|action| {
3350                format!(
3351                    "request {} for {}",
3352                    action.required_external_action, action.subject
3353                )
3354            })
3355            .collect(),
3356        ExternalLifecyclePlanStatusV1::Blocked => {
3357            vec!["resolve blocked external lifecycle subjects before execution".to_string()]
3358        }
3359    }
3360}
3361
3362fn external_lifecycle_handoff_summary(report: &ExternalLifecyclePendingReportV1) -> String {
3363    match report.status {
3364        ExternalLifecyclePlanStatusV1::Ready => {
3365            "no external lifecycle handoff is required".to_string()
3366        }
3367        ExternalLifecyclePlanStatusV1::PendingExternalAction => format!(
3368            "{} external lifecycle handoff action(s) require operator coordination",
3369            report.pending_external_count
3370        ),
3371        ExternalLifecyclePlanStatusV1::Blocked => format!(
3372            "external lifecycle handoff is blocked by {} subject(s)",
3373            report.blocked_count
3374        ),
3375    }
3376}
3377
3378fn external_lifecycle_handoff_instructions(proposal: &ExternalUpgradeProposalV1) -> Vec<String> {
3379    let mut instructions = vec![
3380        format!(
3381            "present proposal {} for subject {}",
3382            proposal.proposal_id, proposal.subject
3383        ),
3384        "verify live inventory after any reported external action".to_string(),
3385    ];
3386    if let Some(expires_at) = proposal.expires_at.as_deref() {
3387        instructions.push(format!("do not use this proposal after {expires_at}"));
3388    }
3389    match proposal.lifecycle_mode {
3390        LifecycleModeV1::ProposalRequired => {
3391            instructions.push("collect explicit consent before direct install".to_string());
3392        }
3393        LifecycleModeV1::DelegatedInstallRequired => {
3394            instructions.push("use delegated install authority only if policy allows".to_string());
3395        }
3396        LifecycleModeV1::ExternalCompletionOnly | LifecycleModeV1::VerifyOnly => {
3397            instructions
3398                .push("wait for external completion evidence before verification".to_string());
3399        }
3400        LifecycleModeV1::MustNotTouch | LifecycleModeV1::UnknownUnsafeBlocked => {
3401            instructions.push("do not execute; report blocked lifecycle state".to_string());
3402        }
3403        LifecycleModeV1::DirectDeploymentAuthority => {
3404            instructions.push("no external handoff should be required".to_string());
3405        }
3406    }
3407    instructions
3408}
3409
3410const fn external_upgrade_verification_summary(
3411    result: ExternalUpgradeVerificationResultV1,
3412) -> &'static str {
3413    match result {
3414        ExternalUpgradeVerificationResultV1::Pending => {
3415            "external action has not been reported as complete"
3416        }
3417        ExternalUpgradeVerificationResultV1::Refused => "external consent was refused",
3418        ExternalUpgradeVerificationResultV1::Verified => {
3419            "reported external completion matches proposal target facts"
3420        }
3421        ExternalUpgradeVerificationResultV1::Mismatch => {
3422            "reported external completion does not match proposal target facts"
3423        }
3424    }
3425}
3426
3427fn external_upgrade_verification_policy_summary(
3428    proposal: &ExternalUpgradeProposalV1,
3429) -> &'static str {
3430    if proposal
3431        .verification_requirements
3432        .contains(&LifecycleVerificationRequirementV1::ProtectedCallReadiness)
3433    {
3434        "fresh live inventory, module/config facts, controller observation, and protected-call readiness are required"
3435    } else {
3436        "fresh live inventory, module/config facts, and controller observation are required"
3437    }
3438}
3439
3440fn external_upgrade_verification_policy_requirements(
3441    proposal: &ExternalUpgradeProposalV1,
3442) -> Vec<ExternalUpgradeVerificationPolicyRequirementV1> {
3443    [
3444        (LifecycleVerificationRequirementV1::LiveInventory, None),
3445        (
3446            LifecycleVerificationRequirementV1::ControllerObservation,
3447            Some(control_class_value(proposal.control_class)),
3448        ),
3449        (
3450            LifecycleVerificationRequirementV1::ModuleHash,
3451            proposal.target_installed_module_hash.clone(),
3452        ),
3453        (
3454            LifecycleVerificationRequirementV1::CanonicalEmbeddedConfig,
3455            proposal.target_canonical_embedded_config_sha256.clone(),
3456        ),
3457        (
3458            LifecycleVerificationRequirementV1::ProtectedCallReadiness,
3459            None,
3460        ),
3461    ]
3462    .into_iter()
3463    .map(
3464        |(requirement, expected_value)| ExternalUpgradeVerificationPolicyRequirementV1 {
3465            requirement,
3466            status: if proposal.verification_requirements.contains(&requirement) {
3467                ExternalUpgradeVerificationRequirementStatusV1::Required
3468            } else {
3469                ExternalUpgradeVerificationRequirementStatusV1::NotRequired
3470            },
3471            expected_value,
3472        },
3473    )
3474    .collect()
3475}
3476
3477fn external_upgrade_verification_check_requirements(
3478    policy: &ExternalUpgradeVerificationPolicyV1,
3479    observation: &ExternalUpgradeVerificationObservationV1,
3480) -> Vec<ExternalUpgradeVerificationCheckRequirementV1> {
3481    policy
3482        .verification_requirements
3483        .iter()
3484        .map(|row| {
3485            let observed_value =
3486                external_upgrade_verification_observed_value(row.requirement, observation);
3487            let satisfied =
3488                if row.status == ExternalUpgradeVerificationRequirementStatusV1::Required {
3489                    Some(external_upgrade_verification_requirement_satisfied(
3490                        row.requirement,
3491                        row.expected_value.as_deref(),
3492                        observed_value.as_deref(),
3493                        observation,
3494                    ))
3495                } else {
3496                    None
3497                };
3498            ExternalUpgradeVerificationCheckRequirementV1 {
3499                requirement: row.requirement,
3500                status: row.status,
3501                expected_value: row.expected_value.clone(),
3502                observed_value,
3503                satisfied,
3504            }
3505        })
3506        .collect()
3507}
3508
3509fn external_upgrade_verification_observed_value(
3510    requirement: LifecycleVerificationRequirementV1,
3511    observation: &ExternalUpgradeVerificationObservationV1,
3512) -> Option<String> {
3513    match requirement {
3514        LifecycleVerificationRequirementV1::LiveInventory => {
3515            Some(observation.live_inventory_observed.to_string())
3516        }
3517        LifecycleVerificationRequirementV1::ControllerObservation => {
3518            observation.observed_control_class.map(control_class_value)
3519        }
3520        LifecycleVerificationRequirementV1::ModuleHash => observation.observed_module_hash.clone(),
3521        LifecycleVerificationRequirementV1::CanonicalEmbeddedConfig => observation
3522            .observed_canonical_embedded_config_sha256
3523            .clone(),
3524        LifecycleVerificationRequirementV1::ProtectedCallReadiness => observation
3525            .protected_call_ready
3526            .map(|value| value.to_string()),
3527    }
3528}
3529
3530fn external_upgrade_verification_requirement_satisfied(
3531    requirement: LifecycleVerificationRequirementV1,
3532    expected_value: Option<&str>,
3533    observed_value: Option<&str>,
3534    observation: &ExternalUpgradeVerificationObservationV1,
3535) -> bool {
3536    match requirement {
3537        LifecycleVerificationRequirementV1::LiveInventory => observation.live_inventory_observed,
3538        LifecycleVerificationRequirementV1::ControllerObservation => {
3539            observation.controller_observation_present
3540                && expected_value.is_some_and(|expected| observed_value == Some(expected))
3541        }
3542        LifecycleVerificationRequirementV1::ModuleHash
3543        | LifecycleVerificationRequirementV1::CanonicalEmbeddedConfig => {
3544            expected_value.is_some_and(|expected| observed_value == Some(expected))
3545        }
3546        LifecycleVerificationRequirementV1::ProtectedCallReadiness => {
3547            observation.protected_call_ready == Some(true)
3548        }
3549    }
3550}
3551
3552fn external_upgrade_verification_check_result(
3553    source: ExternalVerificationObservationSourceV1,
3554    requirements: &[ExternalUpgradeVerificationCheckRequirementV1],
3555) -> ExternalUpgradeVerificationResultV1 {
3556    if !requirements
3557        .iter()
3558        .filter(|row| row.status == ExternalUpgradeVerificationRequirementStatusV1::Required)
3559        .all(|row| row.satisfied == Some(true))
3560    {
3561        return ExternalUpgradeVerificationResultV1::Mismatch;
3562    }
3563
3564    match source {
3565        ExternalVerificationObservationSourceV1::DeploymentTruthInventory => {
3566            ExternalUpgradeVerificationResultV1::Verified
3567        }
3568        ExternalVerificationObservationSourceV1::SuppliedObservation => {
3569            ExternalUpgradeVerificationResultV1::Pending
3570        }
3571    }
3572}
3573
3574fn control_class_value(control_class: CanisterControlClassV1) -> String {
3575    format!("{control_class:?}")
3576}
3577
3578fn validate_external_upgrade_verification_check_requirements(
3579    source: ExternalVerificationObservationSourceV1,
3580    requirements: &[ExternalUpgradeVerificationCheckRequirementV1],
3581    result: ExternalUpgradeVerificationResultV1,
3582) -> Result<(), ExternalUpgradeVerificationCheckError> {
3583    if requirements.is_empty() {
3584        return Err(
3585            ExternalUpgradeVerificationCheckError::MissingRequiredField {
3586                field: "requirement_results",
3587            },
3588        );
3589    }
3590    let mut seen = BTreeSet::new();
3591    for row in requirements {
3592        if !seen.insert(row.requirement) {
3593            return Err(
3594                ExternalUpgradeVerificationCheckError::DuplicateRequirement {
3595                    requirement: row.requirement,
3596                },
3597            );
3598        }
3599        match row.status {
3600            ExternalUpgradeVerificationRequirementStatusV1::Required => {
3601                if row.satisfied.is_none() {
3602                    return Err(
3603                        ExternalUpgradeVerificationCheckError::RequirementStatusMismatch {
3604                            requirement: row.requirement,
3605                        },
3606                    );
3607                }
3608            }
3609            ExternalUpgradeVerificationRequirementStatusV1::NotRequired => {
3610                if row.satisfied.is_some() {
3611                    return Err(
3612                        ExternalUpgradeVerificationCheckError::RequirementStatusMismatch {
3613                            requirement: row.requirement,
3614                        },
3615                    );
3616                }
3617            }
3618        }
3619    }
3620    if external_upgrade_verification_check_result(source, requirements) != result {
3621        return Err(ExternalUpgradeVerificationCheckError::SourceMismatch {
3622            field: "verification_result",
3623        });
3624    }
3625    Ok(())
3626}
3627
3628const fn external_upgrade_completion_status(
3629    consent_state: ExternalUpgradeConsentStateV1,
3630    verification_result: ExternalUpgradeVerificationResultV1,
3631    source: ExternalVerificationObservationSourceV1,
3632) -> ExternalUpgradeCompletionStatusV1 {
3633    match consent_state {
3634        ExternalUpgradeConsentStateV1::Pending => {
3635            ExternalUpgradeCompletionStatusV1::AwaitingConsent
3636        }
3637        ExternalUpgradeConsentStateV1::Refused => ExternalUpgradeCompletionStatusV1::ConsentRefused,
3638        ExternalUpgradeConsentStateV1::Delegated
3639        | ExternalUpgradeConsentStateV1::ExecutedExternally => match verification_result {
3640            ExternalUpgradeVerificationResultV1::Verified => match source {
3641                ExternalVerificationObservationSourceV1::DeploymentTruthInventory => {
3642                    ExternalUpgradeCompletionStatusV1::VerifiedComplete
3643                }
3644                ExternalVerificationObservationSourceV1::SuppliedObservation => {
3645                    ExternalUpgradeCompletionStatusV1::SuppliedEvidenceConsistent
3646                }
3647            },
3648            ExternalUpgradeVerificationResultV1::Mismatch => {
3649                ExternalUpgradeCompletionStatusV1::VerificationFailed
3650            }
3651            ExternalUpgradeVerificationResultV1::Pending
3652            | ExternalUpgradeVerificationResultV1::Refused => {
3653                ExternalUpgradeCompletionStatusV1::AwaitingVerification
3654            }
3655        },
3656    }
3657}
3658
3659fn external_upgrade_completion_blockers(status: ExternalUpgradeCompletionStatusV1) -> Vec<String> {
3660    match status {
3661        ExternalUpgradeCompletionStatusV1::AwaitingConsent => {
3662            vec!["external consent or action has not been reported".to_string()]
3663        }
3664        ExternalUpgradeCompletionStatusV1::ConsentRefused => {
3665            vec!["external consent was refused".to_string()]
3666        }
3667        ExternalUpgradeCompletionStatusV1::SuppliedEvidenceConsistent => {
3668            vec!["supplied evidence is consistent but not live inventory proof".to_string()]
3669        }
3670        ExternalUpgradeCompletionStatusV1::AwaitingVerification => {
3671            vec!["external action requires verification against live inventory".to_string()]
3672        }
3673        ExternalUpgradeCompletionStatusV1::VerificationFailed => {
3674            vec!["supplied observation does not satisfy verification policy".to_string()]
3675        }
3676        ExternalUpgradeCompletionStatusV1::VerifiedComplete => Vec::new(),
3677    }
3678}
3679
3680fn external_upgrade_completion_next_actions(
3681    status: ExternalUpgradeCompletionStatusV1,
3682) -> Vec<String> {
3683    match status {
3684        ExternalUpgradeCompletionStatusV1::AwaitingConsent => {
3685            vec!["obtain external consent or reported external execution".to_string()]
3686        }
3687        ExternalUpgradeCompletionStatusV1::ConsentRefused => {
3688            vec!["do not execute; supersede the proposal before retry".to_string()]
3689        }
3690        ExternalUpgradeCompletionStatusV1::SuppliedEvidenceConsistent => {
3691            vec!["collect deployment-truth inventory and run verification check".to_string()]
3692        }
3693        ExternalUpgradeCompletionStatusV1::AwaitingVerification => {
3694            vec!["collect fresh inventory observations and run verification check".to_string()]
3695        }
3696        ExternalUpgradeCompletionStatusV1::VerificationFailed => {
3697            vec!["resolve observed module/config/readiness mismatch".to_string()]
3698        }
3699        ExternalUpgradeCompletionStatusV1::VerifiedComplete => {
3700            vec!["record external lifecycle item as verified complete".to_string()]
3701        }
3702    }
3703}
3704
3705const fn external_upgrade_completion_summary(
3706    status: ExternalUpgradeCompletionStatusV1,
3707) -> &'static str {
3708    match status {
3709        ExternalUpgradeCompletionStatusV1::AwaitingConsent => {
3710            "external lifecycle item is waiting for consent or external action"
3711        }
3712        ExternalUpgradeCompletionStatusV1::ConsentRefused => "external lifecycle item was refused",
3713        ExternalUpgradeCompletionStatusV1::SuppliedEvidenceConsistent => {
3714            "external lifecycle supplied evidence is consistent but awaits inventory verification"
3715        }
3716        ExternalUpgradeCompletionStatusV1::AwaitingVerification => {
3717            "external lifecycle item needs verification before completion"
3718        }
3719        ExternalUpgradeCompletionStatusV1::VerifiedComplete => {
3720            "external lifecycle item is structurally verified complete"
3721        }
3722        ExternalUpgradeCompletionStatusV1::VerificationFailed => {
3723            "external lifecycle item failed supplied verification"
3724        }
3725    }
3726}
3727
3728const fn external_upgrade_verification_check_summary(
3729    source: ExternalVerificationObservationSourceV1,
3730    result: ExternalUpgradeVerificationResultV1,
3731) -> &'static str {
3732    match result {
3733        ExternalUpgradeVerificationResultV1::Verified => {
3734            "deployment-truth inventory satisfies required verification postconditions"
3735        }
3736        ExternalUpgradeVerificationResultV1::Mismatch => {
3737            "verification observation does not satisfy required verification postconditions"
3738        }
3739        ExternalUpgradeVerificationResultV1::Pending => match source {
3740            ExternalVerificationObservationSourceV1::SuppliedObservation => {
3741                "supplied observation is consistent; deployment-truth inventory verification is required"
3742            }
3743            ExternalVerificationObservationSourceV1::DeploymentTruthInventory => {
3744                "external verification check is pending inventory observation"
3745            }
3746        },
3747        ExternalUpgradeVerificationResultV1::Refused => {
3748            "external verification check reflects refused consent"
3749        }
3750    }
3751}
3752
3753const fn external_upgrade_consent_summary(state: ExternalUpgradeConsentStateV1) -> &'static str {
3754    match state {
3755        ExternalUpgradeConsentStateV1::Pending => {
3756            "external consent or action has not been reported"
3757        }
3758        ExternalUpgradeConsentStateV1::Refused => "external consent was refused",
3759        ExternalUpgradeConsentStateV1::Delegated => "delegated install authority was reported",
3760        ExternalUpgradeConsentStateV1::ExecutedExternally => {
3761            "external controller execution was reported"
3762        }
3763    }
3764}
3765
3766fn external_upgrade_observation_matches(expected: Option<&str>, observed: Option<&str>) -> bool {
3767    expected.is_none_or(|expected| observed == Some(expected))
3768}
3769
3770fn ensure_external_receipt_field(
3771    field: &'static str,
3772    value: &str,
3773) -> Result<(), ExternalUpgradeReceiptError> {
3774    if value.trim().is_empty() {
3775        return Err(ExternalUpgradeReceiptError::MissingRequiredField { field });
3776    }
3777    Ok(())
3778}
3779
3780fn ensure_external_receipt_matches_proposal(
3781    field: &'static str,
3782    actual: &str,
3783    expected: &str,
3784) -> Result<(), ExternalUpgradeReceiptError> {
3785    if actual != expected {
3786        return Err(ExternalUpgradeReceiptError::SourceMismatch { field });
3787    }
3788    Ok(())
3789}
3790
3791fn ensure_external_receipt_option_matches_proposal(
3792    field: &'static str,
3793    actual: Option<&str>,
3794    expected: Option<&str>,
3795) -> Result<(), ExternalUpgradeReceiptError> {
3796    if actual != expected {
3797        return Err(ExternalUpgradeReceiptError::SourceMismatch { field });
3798    }
3799    Ok(())
3800}
3801
3802fn ensure_external_consent_evidence_field(
3803    field: &'static str,
3804    value: &str,
3805) -> Result<(), ExternalUpgradeConsentEvidenceError> {
3806    if value.trim().is_empty() {
3807        return Err(ExternalUpgradeConsentEvidenceError::MissingRequiredField { field });
3808    }
3809    Ok(())
3810}
3811
3812fn ensure_external_verification_report_field(
3813    field: &'static str,
3814    value: &str,
3815) -> Result<(), ExternalUpgradeVerificationReportError> {
3816    if value.trim().is_empty() {
3817        return Err(ExternalUpgradeVerificationReportError::MissingRequiredField { field });
3818    }
3819    Ok(())
3820}
3821
3822fn ensure_external_verification_policy_field(
3823    field: &'static str,
3824    value: &str,
3825) -> Result<(), ExternalUpgradeVerificationPolicyError> {
3826    if value.trim().is_empty() {
3827        return Err(ExternalUpgradeVerificationPolicyError::MissingRequiredField { field });
3828    }
3829    Ok(())
3830}
3831
3832fn ensure_external_verification_check_field(
3833    field: &'static str,
3834    value: &str,
3835) -> Result<(), ExternalUpgradeVerificationCheckError> {
3836    if value.trim().is_empty() {
3837        return Err(ExternalUpgradeVerificationCheckError::MissingRequiredField { field });
3838    }
3839    Ok(())
3840}
3841
3842fn ensure_external_verification_check_option_field(
3843    field: &'static str,
3844    value: Option<&str>,
3845) -> Result<(), ExternalUpgradeVerificationCheckError> {
3846    if value.is_none_or(|value| value.trim().is_empty()) {
3847        return Err(ExternalUpgradeVerificationCheckError::MissingRequiredField { field });
3848    }
3849    Ok(())
3850}
3851
3852fn ensure_external_completion_report_field(
3853    field: &'static str,
3854    value: &str,
3855) -> Result<(), ExternalUpgradeCompletionReportError> {
3856    if value.trim().is_empty() {
3857        return Err(ExternalUpgradeCompletionReportError::MissingRequiredField { field });
3858    }
3859    Ok(())
3860}
3861
3862fn ensure_completion_sources_match_proposal(
3863    proposal: &ExternalUpgradeProposalV1,
3864    consent_evidence: &ExternalUpgradeConsentEvidenceV1,
3865    verification_check: &ExternalUpgradeVerificationCheckV1,
3866) -> Result<(), ExternalUpgradeCompletionReportError> {
3867    ensure_completion_source_field(
3868        "consent_evidence.proposal_id",
3869        consent_evidence.proposal_id.as_str(),
3870        proposal.proposal_id.as_str(),
3871    )?;
3872    ensure_completion_source_field(
3873        "consent_evidence.proposal_digest",
3874        consent_evidence.proposal_digest.as_str(),
3875        proposal.proposal_digest.as_str(),
3876    )?;
3877    ensure_completion_source_field(
3878        "verification_check.proposal_id",
3879        verification_check.proposal_id.as_str(),
3880        proposal.proposal_id.as_str(),
3881    )?;
3882    ensure_completion_source_field(
3883        "verification_check.proposal_digest",
3884        verification_check.proposal_digest.as_str(),
3885        proposal.proposal_digest.as_str(),
3886    )?;
3887    ensure_completion_source_field(
3888        "consent_evidence.subject",
3889        consent_evidence.subject.as_str(),
3890        proposal.subject.as_str(),
3891    )?;
3892    ensure_completion_source_field(
3893        "verification_check.subject",
3894        verification_check.subject.as_str(),
3895        proposal.subject.as_str(),
3896    )?;
3897    ensure_completion_option_source_field(
3898        "consent_evidence.canister_id",
3899        consent_evidence.canister_id.as_deref(),
3900        proposal.canister_id.as_deref(),
3901    )?;
3902    ensure_completion_option_source_field(
3903        "verification_check.canister_id",
3904        verification_check.canister_id.as_deref(),
3905        proposal.canister_id.as_deref(),
3906    )?;
3907    ensure_completion_option_source_field(
3908        "consent_evidence.role",
3909        consent_evidence.role.as_deref(),
3910        proposal.role.as_deref(),
3911    )?;
3912    ensure_completion_option_source_field(
3913        "verification_check.role",
3914        verification_check.role.as_deref(),
3915        proposal.role.as_deref(),
3916    )
3917}
3918
3919fn ensure_completion_source_field(
3920    field: &'static str,
3921    actual: &str,
3922    expected: &str,
3923) -> Result<(), ExternalUpgradeCompletionReportError> {
3924    if actual != expected {
3925        return Err(ExternalUpgradeCompletionReportError::SourceMismatch { field });
3926    }
3927    Ok(())
3928}
3929
3930fn ensure_completion_option_source_field(
3931    field: &'static str,
3932    actual: Option<&str>,
3933    expected: Option<&str>,
3934) -> Result<(), ExternalUpgradeCompletionReportError> {
3935    if actual != expected {
3936        return Err(ExternalUpgradeCompletionReportError::SourceMismatch { field });
3937    }
3938    Ok(())
3939}
3940
3941fn ensure_external_lifecycle_plan_field(
3942    field: &'static str,
3943    value: &str,
3944) -> Result<(), ExternalLifecyclePlanError> {
3945    if value.trim().is_empty() {
3946        return Err(ExternalLifecyclePlanError::MissingRequiredField { field });
3947    }
3948    Ok(())
3949}
3950
3951fn ensure_external_proposal_report_field(
3952    field: &'static str,
3953    value: &str,
3954) -> Result<(), ExternalUpgradeProposalReportError> {
3955    if value.trim().is_empty() {
3956        return Err(ExternalUpgradeProposalReportError::MissingRequiredField { field });
3957    }
3958    Ok(())
3959}
3960
3961fn ensure_external_pending_report_field(
3962    field: &'static str,
3963    value: &str,
3964) -> Result<(), ExternalLifecyclePendingReportError> {
3965    if value.trim().is_empty() {
3966        return Err(ExternalLifecyclePendingReportError::MissingRequiredField { field });
3967    }
3968    Ok(())
3969}
3970
3971fn ensure_external_lifecycle_check_field(
3972    field: &'static str,
3973    value: &str,
3974) -> Result<(), ExternalLifecycleCheckError> {
3975    if value.trim().is_empty() {
3976        return Err(ExternalLifecycleCheckError::MissingRequiredField { field });
3977    }
3978    Ok(())
3979}
3980
3981fn ensure_external_lifecycle_handoff_field(
3982    field: &'static str,
3983    value: &str,
3984) -> Result<(), ExternalLifecycleHandoffError> {
3985    if value.trim().is_empty() {
3986        return Err(ExternalLifecycleHandoffError::MissingRequiredField { field });
3987    }
3988    Ok(())
3989}
3990
3991fn ensure_critical_fix_report_field(
3992    field: &'static str,
3993    value: &str,
3994) -> Result<(), CriticalExternalFixReportError> {
3995    if value.trim().is_empty() {
3996        return Err(CriticalExternalFixReportError::MissingRequiredField { field });
3997    }
3998    Ok(())
3999}
4000
4001fn ensure_lifecycle_authority_report_field(
4002    field: &'static str,
4003    value: &str,
4004) -> Result<(), LifecycleAuthorityReportError> {
4005    if value.trim().is_empty() {
4006        return Err(LifecycleAuthorityReportError::MissingRequiredField { field });
4007    }
4008    Ok(())
4009}
4010
4011fn sorted_unique(values: Vec<String>) -> Vec<String> {
4012    values
4013        .into_iter()
4014        .collect::<BTreeSet<_>>()
4015        .into_iter()
4016        .collect()
4017}