Skip to main content

canic_host/deployment_truth/
lifecycle.rs

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