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