Skip to main content

canic_host/fleet_ensure/ops/
platform.rs

1//! Module: fleet_ensure::ops::platform
2//!
3//! Responsibility: mechanically observe and mutate the exact current Fleet through ICP CLI.
4//! Does not own: effect ordering, durable intent, retry policy, or plan approval.
5//! Boundary: the workflow calls one method only after persisting its exact action identity.
6
7use crate::{
8    canister_protocol::{CanisterProtocolError, call_with_candid, query_with_candid},
9    fleet_ensure::{
10        dto::{FleetEnsureProgress, FleetObservationStage, FleetObservationTiming},
11        model::{
12            CanisterRuntimeStatus, DesiredCanisterKind, DesiredFleet, EffectRecord, EnsureAction,
13            EstateFundingDomainObservation, EstatePoolAssetLifecycle, EstatePoolAssetObservation,
14            EstatePoolAssetOrigin, EstatePoolCreationDiagnostic,
15            EstatePoolCreationReceiptObservation, EstatePoolInventoryObservation,
16            EstatePoolPendingCreationObservation, FleetEnsureStateRecord, FleetObservation,
17            InstallMode, LiveCanister, MAX_FLEET_ENSURE_CANISTERS, RetirementTransferBalances,
18            RetirementTransferInvariantError, RetirementTransferReconciliation,
19            RootManagementCanisterObservation, RootManagementObservation,
20            RootOwnedCanisterLifecycle, create_balance_is_terminal, reconcile_retirement_transfer,
21        },
22        ops::{
23            EffectObservation, EffectOutcome, EffectRetry, EnsurePlatform, EnsureStateError,
24            TerminalFleetInventory, canic_init, current_protocol, protocol, root_owned_lifecycle,
25        },
26    },
27    icp::{
28        IcpCandidCallError, IcpCanisterStatusReport, IcpCli, IcpCommandError, IcpDiagnostic,
29        IcpManagementCallError, LocalReplicaTarget, run_status,
30    },
31    icp_config::resolve_icp_build_network_from_root,
32    subnet_catalog::load_mainnet_subnet_catalog,
33};
34use candid::{CandidType, Nat, Principal};
35use canic_core::{
36    cdk::{types::Cycles, utils::hash::hex_bytes},
37    dto::canister::{CanisterInspectionRequest, CanisterStatusResponse},
38    dto::pool::{
39        CanisterPoolAsset, CanisterPoolAssetOrigin, CanisterPoolAssetStatus,
40        CanisterPoolCreationFailure, CanisterPoolCreationProgress, CanisterPoolHandoff,
41        CanisterPoolResponse, CanisterPoolStatusRequest,
42    },
43    ids::BuildNetwork,
44    protocol as canic_protocol,
45};
46use serde::Deserialize;
47use std::{
48    cell::RefCell,
49    collections::{BTreeMap, BTreeSet},
50    path::{Path, PathBuf},
51    thread,
52    time::{Duration, SystemTime, UNIX_EPOCH},
53};
54use thiserror::Error as ThisError;
55
56#[derive(CandidType)]
57struct ManagementCanisterStatusRequest {
58    canister_id: Principal,
59}
60
61#[derive(CandidType)]
62struct CyclesLedgerAccount {
63    owner: Principal,
64    subaccount: Option<[u8; 32]>,
65}
66
67#[derive(CandidType)]
68struct CyclesLedgerTransferArgs {
69    amount: Nat,
70    created_at_time: Option<u64>,
71    fee: Option<Nat>,
72    from_subaccount: Option<[u8; 32]>,
73    memo: Option<Vec<u8>>,
74    to: CyclesLedgerAccount,
75}
76
77#[derive(CandidType, Deserialize)]
78enum CyclesLedgerTransferError {
79    BadBurn { min_burn_amount: Nat },
80    BadFee { expected_fee: Nat },
81    CreatedInFuture { ledger_time: u64 },
82    Duplicate { duplicate_of: Nat },
83    GenericError { error_code: Nat, message: String },
84    InsufficientFunds { balance: Nat },
85    TemporarilyUnavailable,
86    TooOld,
87}
88
89#[derive(CandidType, Deserialize)]
90struct ManagementCanisterStatusResponse {
91    version: u64,
92    module_hash: Option<Vec<u8>>,
93}
94
95#[derive(CandidType, Deserialize)]
96struct ManagementCanisterObservationSettings {
97    controllers: Vec<Principal>,
98}
99
100#[derive(CandidType, Deserialize)]
101enum ManagementCanisterRuntimeStatus {
102    #[serde(rename = "running")]
103    Running,
104    #[serde(rename = "stopping")]
105    Stopping,
106    #[serde(rename = "stopped")]
107    Stopped,
108}
109
110#[derive(CandidType, Deserialize)]
111struct ManagementCanisterObservationResponse {
112    status: ManagementCanisterRuntimeStatus,
113    settings: ManagementCanisterObservationSettings,
114    module_hash: Option<Vec<u8>>,
115    cycles: Nat,
116    version: u64,
117}
118
119#[derive(Debug, Eq, PartialEq)]
120struct ExactInstallCanisterStatus {
121    canister_version: u64,
122    module_sha256: Option<String>,
123}
124
125fn is_unallocated_fresh_root(
126    desired: &DesiredFleet,
127    configured: &crate::fleet_ensure::model::DesiredCanister,
128    reviewed_targets: &BTreeSet<String>,
129) -> bool {
130    reviewed_targets.is_empty()
131        && configured.principal.is_none()
132        && desired.bootstrap.as_ref().is_some_and(|bootstrap| {
133            bootstrap.fresh_estate
134                && bootstrap
135                    .roots
136                    .iter()
137                    .any(|root| root.root == configured.name)
138        })
139}
140
141/// Exact input expected by a configured cycle-safe retirement endpoint.
142
143#[derive(CandidType)]
144struct DrainRequest {
145    operation_id: String,
146    destination: Principal,
147    cycles: Nat,
148}
149
150#[derive(CandidType, Deserialize)]
151enum DrainResponse {
152    Accepted { transferred_cycles: Nat },
153    Replayed { transferred_cycles: Nat },
154}
155
156#[derive(CandidType)]
157enum RootPoolStatusRequest {
158    Pool(CanisterPoolStatusRequest),
159}
160
161#[derive(CandidType, Deserialize)]
162enum RootPoolStatusResponse {
163    Pool(Box<CanisterPoolResponse>),
164}
165
166#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
167struct EstatePoolLifecycleCounts {
168    claimed: u32,
169    failed: u32,
170    handing_off: u32,
171    pending_reset: u32,
172    ready: u32,
173    recycling: u32,
174    store: u32,
175    store_deletion_pending: u32,
176    tracked: u32,
177    workload: u32,
178}
179
180impl EstatePoolLifecycleCounts {
181    const fn declared(response: &CanisterPoolResponse) -> Self {
182        Self {
183            claimed: response.claimed,
184            failed: response.failed,
185            handing_off: response.handing_off,
186            pending_reset: response.pending_reset,
187            ready: response.ready,
188            recycling: response.recycling,
189            store: response.store,
190            store_deletion_pending: response.store_deletion_pending,
191            tracked: response.tracked,
192            workload: response.workload,
193        }
194    }
195
196    fn observe(&mut self, status: &CanisterPoolAssetStatus) -> Option<()> {
197        self.tracked = self.tracked.checked_add(1)?;
198        let count = match status {
199            CanisterPoolAssetStatus::Store => &mut self.store,
200            CanisterPoolAssetStatus::StoreDeletionPending { .. } => {
201                &mut self.store_deletion_pending
202            }
203            CanisterPoolAssetStatus::PendingReset => &mut self.pending_reset,
204            CanisterPoolAssetStatus::Ready => &mut self.ready,
205            CanisterPoolAssetStatus::Claimed { .. } => &mut self.claimed,
206            CanisterPoolAssetStatus::Workload { .. } => &mut self.workload,
207            CanisterPoolAssetStatus::Recycling { .. } => &mut self.recycling,
208            CanisterPoolAssetStatus::HandingOff { .. } => &mut self.handing_off,
209            CanisterPoolAssetStatus::Failed { .. } => &mut self.failed,
210        };
211        *count = count.checked_add(1)?;
212        Some(())
213    }
214
215    fn pooled(self) -> Option<u32> {
216        self.pending_reset
217            .checked_add(self.ready)?
218            .checked_add(self.handing_off)?
219            .checked_add(self.failed)
220    }
221}
222
223#[derive(Clone, Debug, Eq, PartialEq)]
224struct EstatePoolPageAuthority {
225    completed_handoffs: u64,
226    counts: EstatePoolLifecycleCounts,
227    pending_handoff: Option<CanisterPoolHandoff>,
228    pooled: u32,
229    surplus: u32,
230}
231
232impl EstatePoolPageAuthority {
233    fn from_response(response: &CanisterPoolResponse) -> Self {
234        Self {
235            completed_handoffs: response.completed_handoffs,
236            counts: EstatePoolLifecycleCounts::declared(response),
237            pending_handoff: response.pending_handoff.clone(),
238            pooled: response.pooled,
239            surplus: response.surplus,
240        }
241    }
242
243    fn matches_complete_inventory(
244        &self,
245        observed: EstatePoolLifecycleCounts,
246        maximum_size: u32,
247    ) -> bool {
248        let Some(pooled) = observed.pooled() else {
249            return false;
250        };
251        self.counts == observed
252            && self.pooled == pooled
253            && self.surplus == pooled.saturating_sub(maximum_size)
254    }
255}
256
257#[derive(Default)]
258struct EstatePoolInventoryAccumulator {
259    assets: Vec<EstatePoolAssetObservation>,
260    expected_config: Option<canic_core::ids::FleetSubnetCanisterPoolConfig>,
261    expected_page_authority: Option<EstatePoolPageAuthority>,
262    expected_pending: Option<canic_core::dto::pool::CanisterPoolCreation>,
263    observed_counts: EstatePoolLifecycleCounts,
264    seen: BTreeSet<Principal>,
265}
266
267impl EstatePoolInventoryAccumulator {
268    fn observe_page(
269        &mut self,
270        root_name: &str,
271        page: CanisterPoolResponse,
272    ) -> Result<Option<Principal>, IcpEnsurePlatformError> {
273        let page_authority = EstatePoolPageAuthority::from_response(&page);
274        let first_page = self.expected_config.is_none();
275        if !first_page
276            && (self.expected_config.as_ref() != Some(&page.config)
277                || self.expected_pending != page.pending_creation
278                || self.expected_page_authority.as_ref() != Some(&page_authority))
279        {
280            return Err(pool_configuration_error(format!(
281                "Root {root_name} pool authority changed during pagination"
282            )));
283        }
284        if first_page {
285            self.expected_config = Some(page.config.clone());
286            self.expected_pending.clone_from(&page.pending_creation);
287            self.expected_page_authority = Some(page_authority);
288        }
289
290        for asset in page.entries {
291            if !self.seen.insert(asset.canister_id) {
292                return Err(pool_configuration_error(format!(
293                    "Root {root_name} pool repeats canister {}",
294                    asset.canister_id
295                )));
296            }
297            if self.seen.len() > MAX_FLEET_ENSURE_CANISTERS
298                || self.observed_counts.observe(&asset.status).is_none()
299            {
300                return Err(pool_configuration_error(format!(
301                    "Root {root_name} pool exceeds the Fleet observation bound"
302                )));
303            }
304            if let Some(lifecycle) = estate_pool_lifecycle(&asset.status) {
305                self.assets.push(EstatePoolAssetObservation {
306                    creation_receipt: asset.creation_receipt.map(|receipt| {
307                        EstatePoolCreationReceiptObservation {
308                            block_index: receipt.block_index,
309                            operation_id: hex_bytes(receipt.operation_id),
310                            cycles_ledger: receipt.cycles_ledger.to_text(),
311                            ledger_amount_cycles: receipt.ledger_amount.to_u128(),
312                            ledger_fee_cycles: receipt.ledger_fee.to_u128(),
313                            readiness_floor_cycles: receipt.readiness_floor.to_u128(),
314                            creation_execution_margin_cycles: receipt
315                                .creation_execution_margin
316                                .to_u128(),
317                            management_creation_fee_cycles: receipt
318                                .management_creation_fee
319                                .to_u128(),
320                            first_observed_cycles: receipt
321                                .first_observed_cycles
322                                .map(|cycles| cycles.to_u128()),
323                        }
324                    }),
325                    cycles: asset.cycles.to_u128(),
326                    lifecycle,
327                    origin: match asset.origin {
328                        CanisterPoolAssetOrigin::InfrastructureStore => {
329                            EstatePoolAssetOrigin::InfrastructureStore
330                        }
331                        CanisterPoolAssetOrigin::Created => EstatePoolAssetOrigin::Created,
332                        CanisterPoolAssetOrigin::Imported => EstatePoolAssetOrigin::Imported,
333                        CanisterPoolAssetOrigin::Recycled => EstatePoolAssetOrigin::Recycled,
334                    },
335                    principal: asset.canister_id.to_text(),
336                });
337            }
338        }
339        Ok(page.next_start_after)
340    }
341
342    fn finish(
343        self,
344        root_name: &str,
345    ) -> Result<EstatePoolInventoryObservation, IcpEnsurePlatformError> {
346        let config = self.expected_config.ok_or_else(|| {
347            pool_configuration_error(format!("Root {root_name} returned no pool authority"))
348        })?;
349        if !self.expected_page_authority.is_some_and(|authority| {
350            authority.matches_complete_inventory(self.observed_counts, config.maximum_size)
351        }) {
352            return Err(pool_configuration_error(format!(
353                "Root {root_name} pool lifecycle totals differ from its complete paged inventory"
354            )));
355        }
356        Ok(EstatePoolInventoryObservation {
357            assets: self.assets,
358            maximum_size: config.maximum_size,
359            minimum_size: config.minimum_size,
360            pending_creation: self.expected_pending.map(estate_pool_pending_creation),
361            readiness_floor_cycles: config.canister_cycles.to_u128(),
362            creation_execution_margin_cycles: config.creation_execution_margin.to_u128(),
363        })
364    }
365}
366
367fn pool_configuration_error(reason: String) -> IcpEnsurePlatformError {
368    current_protocol::CurrentProtocolError::Configuration(reason).into()
369}
370
371// Inventory is not callback-settlement evidence: an expired maintenance lease can be taken over.
372fn inactive_source_pool_has_full_inventory(
373    pool: &CanisterPoolResponse,
374    root: &crate::fleet_ensure::model::RootActivationResetRecord,
375) -> bool {
376    [
377        pool.config.maximum_size > 0,
378        pool.ready.checked_add(pool.workload) == Some(pool.config.maximum_size),
379        pool.config.maximum_size.checked_add(1) == Some(pool.tracked),
380        pool.ready >= pool.config.minimum_size,
381        root.component_count.checked_add(root.managed_descendants) == Some(pool.workload),
382        pool.store == 1,
383        pool.store_deletion_pending == 0,
384        pool.pending_reset == 0,
385        pool.claimed == 0,
386        pool.recycling == 0,
387        pool.handing_off == 0,
388        pool.failed == 0,
389        pool.pending_creation.is_none(),
390        pool.pending_handoff.is_none(),
391    ]
392    .into_iter()
393    .all(|fact| fact)
394}
395
396const fn pool_policy_is_current(
397    observed: &EstatePoolInventoryObservation,
398    desired: &canic_core::ids::FleetSubnetCanisterPoolConfig,
399) -> bool {
400    observed.maximum_size == desired.maximum_size
401        && observed.minimum_size == desired.minimum_size
402        && observed.readiness_floor_cycles == desired.canister_cycles.to_u128()
403        && observed.creation_execution_margin_cycles == desired.creation_execution_margin.to_u128()
404}
405
406fn estate_pool_pending_creation(
407    creation: canic_core::dto::pool::CanisterPoolCreation,
408) -> EstatePoolPendingCreationObservation {
409    let (
410        available_cycles,
411        created_principal,
412        diagnostic,
413        required_cycles,
414        retry_at_ns,
415        shortfall_cycles,
416        uncertain_result,
417    ) = match creation.progress {
418        CanisterPoolCreationProgress::Intent { uncertain_result } => {
419            (None, None, None, None, None, None, uncertain_result)
420        }
421        CanisterPoolCreationProgress::Created { canister_id, .. } => (
422            None,
423            Some(canister_id.to_text()),
424            None,
425            None,
426            None,
427            None,
428            false,
429        ),
430        CanisterPoolCreationProgress::WaitingForFunding {
431            available,
432            required,
433            retry_at_ns,
434            shortfall,
435            ..
436        } => (
437            Some(available.to_u128()),
438            None,
439            Some(EstatePoolCreationDiagnostic::FundingRequired),
440            Some(required.to_u128()),
441            Some(retry_at_ns),
442            Some(shortfall.to_u128()),
443            false,
444        ),
445        CanisterPoolCreationProgress::Blocked { failure } => (
446            None,
447            None,
448            Some(match failure {
449                CanisterPoolCreationFailure::UnresolvedAfterLedgerWindow => {
450                    EstatePoolCreationDiagnostic::UnresolvedAfterLedgerWindow
451                }
452                CanisterPoolCreationFailure::LedgerCreationFailed => {
453                    EstatePoolCreationDiagnostic::LedgerCreationFailed
454                }
455                CanisterPoolCreationFailure::LedgerRejected => {
456                    EstatePoolCreationDiagnostic::LedgerRejected
457                }
458            }),
459            None,
460            None,
461            None,
462            false,
463        ),
464    };
465    EstatePoolPendingCreationObservation {
466        attempt_count: creation.attempt_count,
467        available_cycles,
468        creation_amount_cycles: creation.ledger_amount.to_u128(),
469        created_principal,
470        diagnostic,
471        last_attempt_at_ns: creation.last_attempt_at_ns,
472        operation_id: hex_bytes(creation.operation_id),
473        required_cycles,
474        retry_at_ns,
475        shortfall_cycles,
476        uncertain_result,
477    }
478}
479
480const fn estate_pool_lifecycle(
481    status: &CanisterPoolAssetStatus,
482) -> Option<EstatePoolAssetLifecycle> {
483    match status {
484        CanisterPoolAssetStatus::Store | CanisterPoolAssetStatus::StoreDeletionPending { .. } => {
485            None
486        }
487        CanisterPoolAssetStatus::PendingReset => Some(EstatePoolAssetLifecycle::PendingReset),
488        CanisterPoolAssetStatus::Ready => Some(EstatePoolAssetLifecycle::Ready),
489        CanisterPoolAssetStatus::Claimed { .. } => Some(EstatePoolAssetLifecycle::Claimed),
490        CanisterPoolAssetStatus::Workload { .. } => Some(EstatePoolAssetLifecycle::Workload),
491        CanisterPoolAssetStatus::Recycling { .. } => Some(EstatePoolAssetLifecycle::Recycling),
492        CanisterPoolAssetStatus::HandingOff { .. } => Some(EstatePoolAssetLifecycle::HandingOff),
493        CanisterPoolAssetStatus::Failed { .. } => Some(EstatePoolAssetLifecycle::Failed),
494    }
495}
496
497#[derive(CandidType)]
498enum RootInspectionCommand {
499    InspectCanister(CanisterInspectionRequest),
500}
501
502#[derive(CandidType, Deserialize)]
503enum RootInspectionResponse {
504    InspectCanister(CanisterStatusResponse),
505}
506
507/// Minimal protected management projection needed to authorize native pool funding.
508#[derive(CandidType, Deserialize)]
509enum RootFundingInspectionResponse {
510    InspectCanister(RootFundingInspectionStatus),
511}
512
513#[derive(CandidType, Deserialize)]
514struct RootFundingInspectionStatus {
515    status: canic_core::dto::canister::CanisterStatusType,
516    settings: ManagementCanisterObservationSettings,
517    module_hash: Option<Vec<u8>>,
518    cycles: Nat,
519}
520
521#[derive(CandidType)]
522enum ManagedCanisterStatusRequest {
523    CycleBalance,
524}
525
526#[derive(CandidType, Deserialize)]
527enum ManagedCanisterStatusResponse {
528    CycleBalance(canic_core::dto::role::CycleBalanceStatusResponse),
529}
530
531#[derive(CandidType)]
532struct CreateCanisterArgs {
533    amount: Nat,
534    created_at_time: Option<u64>,
535    creation_args: Option<CmcCreateCanisterArgs>,
536    from_subaccount: Option<[u8; 32]>,
537}
538
539#[derive(CandidType)]
540struct CmcCreateCanisterArgs {
541    settings: Option<CanisterSettings>,
542    subnet_selection: Option<SubnetSelection>,
543}
544
545#[derive(CandidType)]
546struct CanisterSettings {
547    compute_allocation: Option<Nat>,
548    controllers: Option<Vec<Principal>>,
549    freezing_threshold: Option<Nat>,
550    memory_allocation: Option<Nat>,
551    reserved_cycles_limit: Option<Nat>,
552}
553
554struct CreateCanisterAuthority<'a> {
555    controller_canisters: &'a [String],
556    controllers: &'a [String],
557    created_at_time: u64,
558    ledger: &'a str,
559    requested_initial_cycles: u128,
560    subnet: &'a str,
561}
562
563#[derive(CandidType)]
564enum SubnetSelection {
565    Subnet { subnet: Principal },
566}
567
568#[derive(CandidType, Deserialize)]
569struct CreateCanisterSuccess {
570    block_id: Nat,
571    canister_id: Principal,
572}
573
574#[derive(CandidType, Deserialize)]
575enum CreateCanisterError {
576    CreatedInFuture {
577        ledger_time: u64,
578    },
579    Duplicate {
580        duplicate_of: Nat,
581        canister_id: Option<Principal>,
582    },
583    FailedToCreate {
584        error: String,
585        fee_block: Option<Nat>,
586        refund_block: Option<Nat>,
587    },
588    GenericError {
589        error_code: Nat,
590        message: String,
591    },
592    InsufficientFunds {
593        balance: Nat,
594    },
595    TemporarilyUnavailable,
596    TooOld,
597}
598
599#[derive(CandidType)]
600struct WithdrawArgs {
601    amount: Nat,
602    created_at_time: Option<u64>,
603    from_subaccount: Option<[u8; 32]>,
604    to: Principal,
605}
606
607#[derive(CandidType, Deserialize)]
608enum WithdrawError {
609    BadFee {
610        expected_fee: Nat,
611    },
612    CreatedInFuture {
613        ledger_time: u64,
614    },
615    Duplicate {
616        duplicate_of: Nat,
617    },
618    FailedToWithdraw {
619        fee_block: Option<Nat>,
620        rejection_code: RejectionCode,
621        rejection_reason: String,
622    },
623    GenericError {
624        error_code: Nat,
625        message: String,
626    },
627    InsufficientFunds {
628        balance: Nat,
629    },
630    InvalidReceiver {
631        receiver: Principal,
632    },
633    TemporarilyUnavailable,
634    TooOld,
635}
636
637#[derive(CandidType, Deserialize)]
638enum RejectionCode {
639    CanisterError,
640    CanisterReject,
641    DestinationInvalid,
642    NoError,
643    SysFatal,
644    SysTransient,
645    Unknown,
646}
647
648/// Typed failure at the current IC effect boundary.
649
650#[derive(Debug, ThisError)]
651pub enum IcpEnsurePlatformError {
652    #[error("cycle arithmetic overflow while executing {0}")]
653    Arithmetic(&'static str),
654
655    #[error(
656        "management history does not prove the issued reinstall of {canister}; refusing another reset"
657    )]
658    ReinstallHistoryConflict { canister: String },
659
660    #[error("configured Candid file is not a regular file: {}", .0.display())]
661    CandidUnavailable(PathBuf),
662
663    #[error("configured effect artifact is unavailable: {}", .0.display())]
664    ArtifactUnavailable(PathBuf),
665
666    #[error("configured {kind} changed after plan review: expected {expected}, observed {actual}")]
667    ArtifactDigestMismatch {
668        actual: String,
669        expected: String,
670        kind: &'static str,
671    },
672
673    #[error("configured Principal is invalid for {field}: {value}")]
674    InvalidPrincipal { field: &'static str, value: String },
675
676    #[error("Cycles Ledger returned an unsupported fee value: {0}")]
677    InvalidLedgerFee(String),
678
679    #[error("active ICP identity is {actual}, but reviewed Fleet operator is {expected}")]
680    OperatorMismatch { actual: String, expected: String },
681
682    #[error("management status for configured canister {expected} returned identity {actual}")]
683    StatusIdentityMismatch { actual: String, expected: String },
684
685    #[error("retained Root management observation failed: {0}")]
686    RootManagement(String),
687
688    #[error("ICP status has invalid cycle balance for {canister}: {value}")]
689    InvalidStatusCycles { canister: String, value: String },
690
691    #[error("ICP status omitted exact {field} required for controlled canister {canister}")]
692    IncompleteCanisterStatus {
693        canister: String,
694        field: &'static str,
695    },
696
697    #[error(
698        "ICP returned only public non-controller status for {canister}; exact runtime and cycle evidence is unavailable"
699    )]
700    PublicCanisterStatusUnavailable { canister: String },
701
702    #[error(
703        "Root-owned observation for {canister} conflicts with reviewed {field}; no paid effect was repeated"
704    )]
705    RootOwnedObservationAuthorityConflict {
706        canister: String,
707        field: &'static str,
708    },
709
710    #[error(
711        "ICP CLI status JSON omitted canister_version for {canister}, and the exact typed \
712         effective-ID-routed management-canister status call failed: {source}; no install was \
713         issued. Restore the selected controller identity and management-status access, then \
714         resume the same reviewed plan"
715    )]
716    InstallVersionProofUnavailable {
717        canister: String,
718        #[source]
719        source: Box<IcpManagementCallError>,
720    },
721
722    #[error("typed local management-status observation failed: {0}")]
723    LocalManagementStatus(#[source] Box<IcpManagementCallError>),
724
725    #[error(
726        "completed reinstall proof for {canister} conflicts with current {field}; no install was authorized"
727    )]
728    CompletedReinstallAuthorityConflict {
729        canister: String,
730        field: &'static str,
731    },
732
733    #[error(transparent)]
734    Candid(#[from] IcpCandidCallError),
735
736    #[error(transparent)]
737    Icp(#[from] IcpCommandError),
738
739    #[error("Cycles Ledger create_canister failed: {0}")]
740    LedgerCreate(String),
741
742    #[error("Cycles Ledger duplicate does not yet identify its created canister")]
743    LedgerCreatePending,
744
745    #[error(
746        "Root {root} pool policy differs from desired input; explicitly review infrastructure reinstall before resetting it"
747    )]
748    PoolPolicyReinstallReviewRequired { root: String },
749
750    #[error("Cycles Ledger withdraw failed: {0}")]
751    LedgerWithdraw(String),
752
753    #[error("completed withdrawal balance conflicts with reviewed funding bounds: {observation:?}")]
754    NativeFundingBalanceDrift {
755        observation: Box<NativeFundingObservation>,
756    },
757
758    #[error("Cycles Ledger estate funding transfer failed: {0}")]
759    LedgerTransfer(String),
760
761    #[error(
762        "Cycles Ledger transfer fee changed from reviewed {reviewed_fee_cycles} to {expected_fee_cycles}; no transfer was accepted"
763    )]
764    LedgerTransferFeeChanged {
765        expected_fee_cycles: u128,
766        reviewed_fee_cycles: u128,
767    },
768
769    #[error(
770        "Root-authorized funding inspection for {canister} conflicts with reviewed {field}; no Ledger withdrawal was repeated"
771    )]
772    FundingInspectionAuthorityConflict {
773        canister: String,
774        field: &'static str,
775    },
776
777    #[error(
778        "canister {canister} retains {actual} cycles above deletion limit {maximum}; left untouched"
779    )]
780    MaterialCycles {
781        actual: u128,
782        canister: String,
783        maximum: u128,
784    },
785
786    #[error("canister {canister} is not stopped; deletion was not attempted")]
787    NotStopped { canister: String },
788
789    #[error("effect references unresolved created canister {0}")]
790    UnresolvedCreated(String),
791
792    #[error("configured retirement endpoint transferred {actual} cycles, expected {expected}")]
793    WrongTransferAmount { actual: Nat, expected: u128 },
794
795    #[error(
796        "retirement transfer for {canister} is not conserved: source debit {source_debit}, treasury credit {destination_credit}, expected credit {expected}, maximum source debit {maximum_source_debit}; source and treasury were left untouched by later retirement steps"
797    )]
798    UnbalancedTransfer {
799        canister: String,
800        destination_credit: u128,
801        expected: u128,
802        maximum_source_debit: u128,
803        source_debit: u128,
804    },
805
806    #[error("retirement transfer for {canister} is missing its durable {side} balance")]
807    MissingTransferBalance {
808        canister: String,
809        side: &'static str,
810    },
811
812    #[error("retirement transfer balance for {canister} moved in an invalid direction")]
813    InvalidTransferBalance { canister: String },
814
815    #[error(transparent)]
816    CurrentProtocol(#[from] current_protocol::CurrentProtocolError),
817
818    #[error(transparent)]
819    CanicInit(#[from] canic_init::CanicInitError),
820
821    #[error(transparent)]
822    Protocol(#[from] protocol::ProtocolEffectError),
823
824    #[error("retained Root status authority is invalid: {0}")]
825    RetainedRootStatusAuthority(#[source] Box<EnsureStateError>),
826}
827
828/// Read evidence shared only by projections of one Fleet observation.
829#[derive(Default)]
830struct FleetObservationSnapshot {
831    pool_pages: BTreeMap<(Principal, Option<Principal>), CanisterPoolResponse>,
832    statuses: BTreeMap<String, Option<LiveCanister>>,
833}
834
835/// Distinguish an unobserved identity from an observed missing canister.
836enum StatusCacheEntry {
837    Miss,
838    Hit(Option<LiveCanister>),
839}
840
841/// Production ICP adapter for the current desired Fleet.
842pub struct IcpEnsurePlatform {
843    desired: DesiredFleet,
844    icp: IcpCli,
845    initial_observation_delay: Duration,
846    maximum_observation_delay: Duration,
847    observation_snapshot: RefCell<Option<FleetObservationSnapshot>>,
848    progress_handler: Option<Box<dyn FnMut(FleetEnsureProgress)>>,
849    observation_handler: Option<Box<dyn FnMut(FleetObservationTiming)>>,
850    estate_observations: BTreeMap<String, EstateFundingDomainObservation>,
851    root: PathBuf,
852}
853
854const INITIAL_PROTOCOL_OBSERVATION_DELAY: Duration = Duration::from_millis(250);
855const MAXIMUM_PROTOCOL_OBSERVATION_DELAY: Duration = Duration::from_secs(5);
856
857fn protocol_observation_delay(
858    consecutive_unchanged_observations: u32,
859    initial_observation_delay: Duration,
860    maximum_observation_delay: Duration,
861) -> Duration {
862    let exponent = consecutive_unchanged_observations.saturating_sub(1).min(5);
863    let multiplier = 1_u32.checked_shl(exponent).unwrap_or(u32::MAX);
864    initial_observation_delay
865        .checked_mul(multiplier)
866        .unwrap_or(MAXIMUM_PROTOCOL_OBSERVATION_DELAY)
867        .min(MAXIMUM_PROTOCOL_OBSERVATION_DELAY)
868        .min(maximum_observation_delay)
869}
870
871impl IcpEnsurePlatform {
872    #[must_use]
873    pub fn new(desired: DesiredFleet, icp_executable: &str, root: &Path) -> Self {
874        let icp = IcpCli::new(icp_executable, Some(desired.environment.clone()))
875            .with_cwd(root.to_path_buf());
876        Self {
877            desired,
878            icp,
879            initial_observation_delay: INITIAL_PROTOCOL_OBSERVATION_DELAY,
880            maximum_observation_delay: MAXIMUM_PROTOCOL_OBSERVATION_DELAY,
881            observation_snapshot: RefCell::new(None),
882            progress_handler: None,
883            observation_handler: None,
884            estate_observations: BTreeMap::new(),
885            root: root.to_path_buf(),
886        }
887    }
888
889    /// Attach an informational progress sink to this operation.
890    #[must_use]
891    pub fn with_progress_handler(
892        mut self,
893        handler: impl FnMut(FleetEnsureProgress) + 'static,
894    ) -> Self {
895        self.progress_handler = Some(Box::new(handler));
896        self
897    }
898
899    /// Attach informational stage timings without retaining observations across boundaries.
900    #[must_use]
901    pub fn with_observation_handler(
902        mut self,
903        handler: impl FnMut(FleetObservationTiming) + 'static,
904    ) -> Self {
905        self.observation_handler = Some(Box::new(handler));
906        self
907    }
908
909    fn finish_observation_timing(
910        &mut self,
911        stage: FleetObservationStage,
912        started: std::time::Instant,
913        calls_before: u64,
914        succeeded: bool,
915    ) {
916        let timing = FleetObservationTiming {
917            stage,
918            elapsed_millis: started.elapsed().as_millis(),
919            remote_call_attempts: self.icp.remote_call_count().saturating_sub(calls_before),
920            succeeded,
921        };
922        if let Some(handler) = &mut self.observation_handler {
923            handler(timing);
924        }
925    }
926
927    fn timed_observation<T>(
928        &mut self,
929        stage: FleetObservationStage,
930        observe: impl FnOnce(&mut Self) -> Result<T, IcpEnsurePlatformError>,
931    ) -> Result<T, IcpEnsurePlatformError> {
932        let started = std::time::Instant::now();
933        let calls = self.icp.remote_call_count();
934        let result = observe(self);
935        self.finish_observation_timing(stage, started, calls, result.is_ok());
936        result
937    }
938
939    /// Return this adapter bound to one explicit local replica endpoint.
940    ///
941    /// This keeps every ordinary ICP CLI call and effective-ID management
942    /// observation on the same replica when no named ICP project network owns
943    /// the test or operator session.
944    #[must_use]
945    pub fn with_local_replica(mut self, target: LocalReplicaTarget) -> Self {
946        self.icp = self.icp.with_local_replica(Some(target));
947        self
948    }
949
950    /// Select passive observation pacing without changing retry or stall bounds.
951    ///
952    /// Production callers retain the default five-second cap. Deterministic
953    /// test replicas may select shorter fixed delays while retaining every
954    /// production observation and terminal predicate.
955    #[doc(hidden)]
956    #[must_use]
957    pub fn with_observation_delay_bounds(mut self, initial: Duration, maximum: Duration) -> Self {
958        self.initial_observation_delay = initial.min(maximum);
959        self.maximum_observation_delay = maximum;
960        self
961    }
962
963    // The scope ends on every Result path before workflow can issue an effect.
964    // Action observations and subsequent retries therefore always read live state.
965    fn with_observation_snapshot<T>(
966        &mut self,
967        observe: impl FnOnce(&mut Self) -> Result<T, IcpEnsurePlatformError>,
968    ) -> Result<T, IcpEnsurePlatformError> {
969        self.observation_snapshot
970            .replace(Some(FleetObservationSnapshot::default()));
971        let result = observe(self);
972        self.observation_snapshot.take();
973        result
974    }
975
976    fn require_operator(&self) -> Result<(), IcpEnsurePlatformError> {
977        let actual = self.icp.identity_principal_text()?;
978        if actual != self.desired.operator {
979            return Err(IcpEnsurePlatformError::OperatorMismatch {
980                actual,
981                expected: self.desired.operator.clone(),
982            });
983        }
984        Ok(())
985    }
986
987    fn current_principal<'a>(
988        &'a self,
989        state: &'a FleetEnsureStateRecord,
990        name: &str,
991    ) -> Option<&'a str> {
992        state
993            .pending_principals
994            .get(name)
995            .or_else(|| state.principals.get(name))
996            .map(String::as_str)
997            .or_else(|| {
998                self.desired
999                    .canisters
1000                    .iter()
1001                    .find(|configured| configured.name == name)
1002                    .and_then(|configured| configured.principal.as_deref())
1003            })
1004    }
1005
1006    fn protocol_principals(&self, state: &FleetEnsureStateRecord) -> BTreeMap<String, String> {
1007        self.desired
1008            .canisters
1009            .iter()
1010            .filter_map(|configured| {
1011                state
1012                    .pending_principals
1013                    .get(&configured.name)
1014                    .or_else(|| state.principals.get(&configured.name))
1015                    .or(configured.principal.as_ref())
1016                    .map(|principal| (configured.name.clone(), principal.clone()))
1017            })
1018            .collect()
1019    }
1020
1021    fn current_protocol_owners_are_ready(
1022        &self,
1023        state: &FleetEnsureStateRecord,
1024    ) -> Result<bool, IcpEnsurePlatformError> {
1025        let mut owners = Vec::new();
1026        for configured in self.desired.canisters.iter().filter(|configured| {
1027            configured.presence == crate::fleet_ensure::model::DesiredPresence::Present
1028                && matches!(
1029                    configured.kind,
1030                    DesiredCanisterKind::Coordinator
1031                        | DesiredCanisterKind::Root
1032                        | DesiredCanisterKind::Store
1033                )
1034        }) {
1035            let live = self
1036                .current_principal(state, &configured.name)
1037                .map(|principal| self.status_optional(principal))
1038                .transpose()?
1039                .flatten();
1040            if live
1041                .as_ref()
1042                .is_some_and(|live| live.status != CanisterRuntimeStatus::Running)
1043            {
1044                return Ok(false);
1045            }
1046            owners.push((configured, live));
1047        }
1048        // Check running state and module identity from the same fresh status read.
1049        // Keep missing-owner/module decisions after the status scan so transport
1050        // failures retain their existing precedence.
1051        for (configured, live) in owners {
1052            let Some(live) = live else {
1053                return Ok(false);
1054            };
1055            let Some(wasm) = configured.wasm.as_deref() else {
1056                return Ok(false);
1057            };
1058            let expected = artifact_hash(&resolve_path(&self.root, wasm))?;
1059            if live.module_sha256.as_deref() != Some(expected.as_str()) {
1060                return Ok(false);
1061            }
1062        }
1063        Ok(true)
1064    }
1065
1066    fn observe_estate_funding_domains(
1067        &self,
1068        state: &FleetEnsureStateRecord,
1069    ) -> Result<BTreeMap<String, EstateFundingDomainObservation>, IcpEnsurePlatformError> {
1070        let Some(bootstrap) = self.desired.bootstrap.as_ref() else {
1071            return Ok(BTreeMap::new());
1072        };
1073        bootstrap
1074            .roots
1075            .iter()
1076            .map(|root| {
1077                let root_principal = self.current_principal(state, &root.root);
1078                let balance_cycles = root_principal
1079                    .map(|principal| self.cycles_ledger_balance(principal))
1080                    .transpose()?;
1081                let pool = root_principal
1082                    .map(|principal| {
1083                        self.observe_estate_pool_inventory(&root.root, principal, state)
1084                    })
1085                    .transpose()?
1086                    .flatten();
1087                Ok((
1088                    root.root.clone(),
1089                    EstateFundingDomainObservation {
1090                        balance_cycles,
1091                        cycles_ledger: self.desired.cycles_ledger.clone(),
1092                        pool,
1093                        root_principal: root_principal.map(str::to_string),
1094                    },
1095                ))
1096            })
1097            .collect()
1098    }
1099
1100    fn cycles_ledger_balance(&self, owner: &str) -> Result<u128, IcpEnsurePlatformError> {
1101        let balance: Nat = self.icp.canister_query_candid(
1102            &self.desired.cycles_ledger,
1103            "icrc1_balance_of",
1104            &CyclesLedgerAccount {
1105                owner: parse_principal("Cycles Ledger account owner", owner)?,
1106                subaccount: None,
1107            },
1108            None,
1109        )?;
1110        ledger_fee_cycles(balance)
1111    }
1112
1113    fn refresh_estate_funding_observation(
1114        &self,
1115        observation: &mut EffectObservation,
1116        state: &FleetEnsureStateRecord,
1117    ) -> Result<(), IcpEnsurePlatformError> {
1118        let Some(required) = observation.estate_funding_required.as_mut() else {
1119            return Ok(());
1120        };
1121        let root = required.root.to_text();
1122        let known_root = self.desired.canisters.iter().any(|canister| {
1123            canister.kind == DesiredCanisterKind::Root
1124                && self.current_principal(state, &canister.name) == Some(root.as_str())
1125        });
1126        if !known_root || required.cycles_ledger.to_text() != self.desired.cycles_ledger {
1127            return Err(current_protocol::CurrentProtocolError::ResponseMismatch.into());
1128        }
1129        // Root retains the balance from its last creation attempt until its next
1130        // retry. A completed host transfer can therefore precede this status.
1131        let available = self.cycles_ledger_balance(&root)?;
1132        let debit = required.required.to_u128();
1133        if available >= debit {
1134            observation.estate_funding_required = None;
1135        } else {
1136            required.available = Cycles::new(available);
1137            required.shortfall = Cycles::new(debit - available);
1138        }
1139        Ok(())
1140    }
1141
1142    fn require_current_pool_policy(
1143        &self,
1144        domains: &BTreeMap<String, EstateFundingDomainObservation>,
1145    ) -> Result<(), IcpEnsurePlatformError> {
1146        let Some(bootstrap) = self.desired.bootstrap.as_ref() else {
1147            return Ok(());
1148        };
1149        for root in &bootstrap.roots {
1150            let Some(pool) = domains
1151                .get(&root.root)
1152                .and_then(|domain| domain.pool.as_ref())
1153            else {
1154                continue;
1155            };
1156            if !pool_policy_is_current(pool, &root.limits.canister_pool) {
1157                return Err(IcpEnsurePlatformError::PoolPolicyReinstallReviewRequired {
1158                    root: root.root.clone(),
1159                });
1160            }
1161        }
1162        Ok(())
1163    }
1164
1165    fn observe_estate_pool_inventory(
1166        &self,
1167        root_name: &str,
1168        root: &str,
1169        _state: &FleetEnsureStateRecord,
1170    ) -> Result<Option<EstatePoolInventoryObservation>, IcpEnsurePlatformError> {
1171        if self.required_root_status(root_name, root)? != CanisterRuntimeStatus::Running {
1172            return Ok(None);
1173        }
1174        let candid = self.root_protocol_candid()?;
1175        let root_principal = parse_principal("Fleet Subnet Root", root)?;
1176        let mut start_after = None;
1177        let mut inventory = EstatePoolInventoryAccumulator::default();
1178        loop {
1179            let page = self.query_estate_pool_page(&candid, root_principal, start_after)?;
1180            let next = inventory.observe_page(root_name, page)?;
1181            if next.is_none() {
1182                break;
1183            }
1184            if next == start_after {
1185                return Err(pool_configuration_error(format!(
1186                    "Root {root_name} pool cursor did not advance"
1187                )));
1188            }
1189            start_after = next;
1190        }
1191        inventory.finish(root_name).map(Some)
1192    }
1193
1194    fn query_estate_pool_page(
1195        &self,
1196        candid: &Path,
1197        root: Principal,
1198        start_after: Option<Principal>,
1199    ) -> Result<CanisterPoolResponse, IcpEnsurePlatformError> {
1200        let key = (root, start_after);
1201        if let Some(page) = self
1202            .observation_snapshot
1203            .borrow()
1204            .as_ref()
1205            .and_then(|snapshot| snapshot.pool_pages.get(&key).cloned())
1206        {
1207            return Ok(page);
1208        }
1209        let response: RootPoolStatusResponse = query_with_candid(
1210            &self.icp,
1211            candid,
1212            root,
1213            canic_protocol::CANIC_ROOT_STATUS,
1214            &RootPoolStatusRequest::Pool(CanisterPoolStatusRequest {
1215                start_after,
1216                limit: 256,
1217            }),
1218        )
1219        .map_err(current_protocol::CurrentProtocolError::from)?;
1220        let RootPoolStatusResponse::Pool(page) = response;
1221        if let Some(snapshot) = self.observation_snapshot.borrow_mut().as_mut() {
1222            snapshot.pool_pages.insert(key, (*page).clone());
1223        }
1224        Ok(*page)
1225    }
1226
1227    #[expect(
1228        clippy::too_many_lines,
1229        reason = "one inspection binds each source asset to exact management authority and cycles"
1230    )]
1231    fn inspect_reinstall_assets(
1232        &self,
1233        state: &FleetEnsureStateRecord,
1234        authorities: &BTreeMap<String, RootManagementCanisterObservation>,
1235        observation: &mut FleetObservation,
1236        candid_by_root: &BTreeMap<String, PathBuf>,
1237        allow_pending_reset: bool,
1238    ) -> Result<Vec<crate::fleet_ensure::model::FleetReinstallAssetRecord>, IcpEnsurePlatformError>
1239    {
1240        let mut assets = Vec::new();
1241        let mut seen = BTreeSet::new();
1242        for (root_name, domain) in &observation.estate_funding_domains {
1243            let root = domain.root_principal.as_deref().ok_or_else(|| {
1244                IcpEnsurePlatformError::RootManagement("missing Root principal".to_string())
1245            })?;
1246            let pool = domain.pool.as_ref().ok_or_else(|| {
1247                IcpEnsurePlatformError::RootManagement("missing complete pool".to_string())
1248            })?;
1249            if pool.pending_creation.is_some() {
1250                return Err(IcpEnsurePlatformError::RootManagement(
1251                    "pool creation is still in flight".to_string(),
1252                ));
1253            }
1254            let root_authority = authorities.get(root_name).ok_or_else(|| {
1255                IcpEnsurePlatformError::RootManagement("missing Root binding".to_string())
1256            })?;
1257            for asset in &pool.assets {
1258                if !seen.insert(asset.principal.clone())
1259                    || !(matches!(
1260                        asset.lifecycle,
1261                        EstatePoolAssetLifecycle::Ready | EstatePoolAssetLifecycle::Workload
1262                    ) || (allow_pending_reset
1263                        && matches!(
1264                            asset.lifecycle,
1265                            EstatePoolAssetLifecycle::PendingReset
1266                                | EstatePoolAssetLifecycle::Failed
1267                        )))
1268                {
1269                    return Err(IcpEnsurePlatformError::RootManagement(
1270                        "pool asset is duplicated or not terminal".to_string(),
1271                    ));
1272                }
1273                let target = parse_principal("reset pool asset", &asset.principal)?;
1274                let RootFundingInspectionResponse::InspectCanister(response) = call_with_candid(
1275                    &self.icp,
1276                    candid_by_root.get(root_name).ok_or_else(|| {
1277                        pool_configuration_error("missing reset read contract".to_string())
1278                    })?,
1279                    parse_principal("reset Root", root)?,
1280                    canic_protocol::CANIC_ROOT_COMMAND,
1281                    &RootInspectionCommand::InspectCanister(CanisterInspectionRequest {
1282                        canister_id: target,
1283                    }),
1284                )
1285                .map_err(current_protocol::CurrentProtocolError::from)?;
1286                if !response
1287                    .settings
1288                    .controllers
1289                    .iter()
1290                    .any(|controller| controller.to_text() == root)
1291                {
1292                    return Err(IcpEnsurePlatformError::FundingInspectionAuthorityConflict {
1293                        canister: asset.principal.clone(),
1294                        field: "Root controller",
1295                    });
1296                }
1297                let cycles = validate_inspected_cycles(
1298                    &asset.principal,
1299                    InspectedModule::Any,
1300                    response.module_hash.as_deref(),
1301                    &response.cycles,
1302                )?;
1303                let mut controllers = response
1304                    .settings
1305                    .controllers
1306                    .iter()
1307                    .map(Principal::to_text)
1308                    .collect::<Vec<_>>();
1309                controllers.sort();
1310                if controllers != [root.to_string()] {
1311                    return Err(IcpEnsurePlatformError::FundingInspectionAuthorityConflict {
1312                        canister: asset.principal.clone(),
1313                        field: "exact Root controllers",
1314                    });
1315                }
1316
1317                let module_sha256 = response
1318                    .module_hash
1319                    .as_ref()
1320                    .map(canic_core::cdk::utils::hash::hex_bytes);
1321                let configured_name = self
1322                    .desired
1323                    .canisters
1324                    .iter()
1325                    .find(|c| {
1326                        c.principal.as_deref() == Some(&asset.principal)
1327                            || self.current_principal(state, &c.name) == Some(&asset.principal)
1328                    })
1329                    .map(|c| c.name.clone());
1330                let name = configured_name
1331                    .unwrap_or_else(|| format!("reinstall-pool-{}", asset.principal));
1332                observation.canisters.insert(
1333                    name,
1334                    Some(LiveCanister {
1335                        canister_version: None,
1336                        controllers: controllers.clone(),
1337                        cycles,
1338                        module_sha256: module_sha256.clone(),
1339                        principal: asset.principal.clone(),
1340                        reinstall_required: false,
1341                        root_owned_lifecycle: Some(match asset.lifecycle {
1342                            EstatePoolAssetLifecycle::Ready => RootOwnedCanisterLifecycle::Idle,
1343                            EstatePoolAssetLifecycle::Workload => {
1344                                RootOwnedCanisterLifecycle::Workload
1345                            }
1346                            _ => RootOwnedCanisterLifecycle::Retained,
1347                        }),
1348                        status: match response.status {
1349                            canic_core::dto::canister::CanisterStatusType::Running => {
1350                                CanisterRuntimeStatus::Running
1351                            }
1352                            canic_core::dto::canister::CanisterStatusType::Stopped => {
1353                                CanisterRuntimeStatus::Stopped
1354                            }
1355                            canic_core::dto::canister::CanisterStatusType::Stopping => {
1356                                CanisterRuntimeStatus::Stopping
1357                            }
1358                        },
1359                    }),
1360                );
1361                observation
1362                    .additional_controlled_cycles
1363                    .remove(&asset.principal);
1364                assets.push(crate::fleet_ensure::model::FleetReinstallAssetRecord {
1365                    controllers,
1366                    module_sha256,
1367                    principal: asset.principal.clone(),
1368                    root: root_name.clone(),
1369                    subnet: root_authority.subnet.clone(),
1370                });
1371            }
1372        }
1373        assets.sort_by(|a, b| a.principal.cmp(&b.principal));
1374        Ok(assets)
1375    }
1376
1377    fn resolved_controllers(
1378        &self,
1379        state: &FleetEnsureStateRecord,
1380        controllers: &[String],
1381        controller_canisters: &[String],
1382    ) -> Result<Vec<String>, IcpEnsurePlatformError> {
1383        let mut resolved = controllers.to_vec();
1384        for name in controller_canisters {
1385            resolved.push(
1386                self.current_principal(state, name)
1387                    .ok_or_else(|| IcpEnsurePlatformError::UnresolvedCreated(name.clone()))?
1388                    .to_string(),
1389            );
1390        }
1391        resolved.sort();
1392        resolved.dedup();
1393        Ok(resolved)
1394    }
1395
1396    fn observed_protocol_action(
1397        &self,
1398        step: &crate::fleet_ensure::model::DesiredProtocolStep,
1399        principal: String,
1400    ) -> Result<EnsureAction, IcpEnsurePlatformError> {
1401        Ok(EnsureAction::Protocol {
1402            candid: step.candid.clone(),
1403            candid_sha256: artifact_hash(&resolve_path(&self.root, &step.candid))?,
1404            command_args: step.command_args.clone(),
1405            command_args_sha256: artifact_hash(&resolve_path(&self.root, &step.command_args))?,
1406            command_method: step.command_method.clone(),
1407            expected_status: step.expected_status.clone(),
1408            expected_status_sha256: artifact_hash(&resolve_path(
1409                &self.root,
1410                &step.expected_status,
1411            ))?,
1412            maximum_execution_burn_cycles: step
1413                .maximum_execution_burn_cycles
1414                .parse()
1415                .map_err(|_| IcpEnsurePlatformError::Arithmetic("protocol burn"))?,
1416            name: step.name.clone(),
1417            principal,
1418            status_args: step.status_args.clone(),
1419            status_args_sha256: artifact_hash(&resolve_path(&self.root, &step.status_args))?,
1420            status_method: step.status_method.clone(),
1421        })
1422    }
1423
1424    fn action_principal<'a>(
1425        state: &'a FleetEnsureStateRecord,
1426        principal: &'a str,
1427    ) -> Result<&'a str, IcpEnsurePlatformError> {
1428        if let Some(name) = principal.strip_prefix("created:") {
1429            return state
1430                .pending_principals
1431                .get(name)
1432                .map(String::as_str)
1433                .ok_or_else(|| IcpEnsurePlatformError::UnresolvedCreated(name.to_string()));
1434        }
1435        Ok(principal)
1436    }
1437
1438    fn status_optional(
1439        &self,
1440        principal: &str,
1441    ) -> Result<Option<LiveCanister>, IcpEnsurePlatformError> {
1442        if let Some(cached) = self
1443            .observation_snapshot
1444            .borrow()
1445            .as_ref()
1446            .and_then(|snapshot| snapshot.statuses.get(principal).cloned())
1447        {
1448            return Ok(cached);
1449        }
1450        let observed = self.read_status_optional(principal)?;
1451        if let Some(snapshot) = self.observation_snapshot.borrow_mut().as_mut() {
1452            snapshot
1453                .statuses
1454                .insert(principal.to_string(), observed.clone());
1455        }
1456        Ok(observed)
1457    }
1458
1459    fn read_status_optional(
1460        &self,
1461        principal: &str,
1462    ) -> Result<Option<LiveCanister>, IcpEnsurePlatformError> {
1463        Self::read_status_with(&self.icp, principal)
1464    }
1465
1466    fn read_status_with(
1467        icp: &IcpCli,
1468        principal: &str,
1469    ) -> Result<Option<LiveCanister>, IcpEnsurePlatformError> {
1470        if icp.uses_direct_local_replica() {
1471            return Self::direct_local_status_optional(icp, principal);
1472        }
1473        let report = match icp.canister_status_report(principal) {
1474            Ok(report) => report,
1475            Err(error)
1476                if matches!(
1477                    error.diagnostic(),
1478                    Some(IcpDiagnostic::CanisterNotFound { .. })
1479                ) =>
1480            {
1481                return Ok(None);
1482            }
1483            Err(error) => return Err(error.into()),
1484        };
1485        if report.id != principal {
1486            return Err(IcpEnsurePlatformError::StatusIdentityMismatch {
1487                actual: report.id,
1488                expected: principal.to_string(),
1489            });
1490        }
1491        reject_public_status_projection(&report, principal)?;
1492        let status_text =
1493            report
1494                .status
1495                .ok_or_else(|| IcpEnsurePlatformError::IncompleteCanisterStatus {
1496                    canister: principal.to_string(),
1497                    field: "status",
1498                })?;
1499        let cycles_text =
1500            report
1501                .cycles
1502                .ok_or_else(|| IcpEnsurePlatformError::IncompleteCanisterStatus {
1503                    canister: principal.to_string(),
1504                    field: "cycles",
1505                })?;
1506        let cycles = parse_status_cycles(&cycles_text).ok_or_else(|| {
1507            IcpEnsurePlatformError::InvalidStatusCycles {
1508                canister: principal.to_string(),
1509                value: cycles_text,
1510            }
1511        })?;
1512        let mut controllers = report
1513            .settings
1514            .ok_or_else(|| IcpEnsurePlatformError::IncompleteCanisterStatus {
1515                canister: principal.to_string(),
1516                field: "controllers",
1517            })?
1518            .controllers;
1519        controllers.sort();
1520        let status = match status_text.to_ascii_lowercase().as_str() {
1521            "stopped" => CanisterRuntimeStatus::Stopped,
1522            "stopping" => CanisterRuntimeStatus::Stopping,
1523            _ => CanisterRuntimeStatus::Running,
1524        };
1525        Ok(Some(LiveCanister {
1526            canister_version: report.canister_version,
1527            controllers,
1528            cycles,
1529            module_sha256: report.module_hash.map(|hash| normalize_hash(&hash)),
1530            principal: principal.to_string(),
1531            reinstall_required: false,
1532            root_owned_lifecycle: None,
1533            status,
1534        }))
1535    }
1536
1537    fn direct_local_status_optional(
1538        icp: &IcpCli,
1539        principal: &str,
1540    ) -> Result<Option<LiveCanister>, IcpEnsurePlatformError> {
1541        let canister_id = parse_principal("local canister status target", principal)?;
1542        let response = icp
1543            .management_canister_status_candid::<_, ManagementCanisterObservationResponse>(
1544                canister_id,
1545                &ManagementCanisterStatusRequest { canister_id },
1546            );
1547        let response = match response {
1548            Ok(response) => response,
1549            Err(error)
1550                if matches!(
1551                    crate::icp::classify_icp_diagnostic(&error.to_string()),
1552                    Some(IcpDiagnostic::CanisterNotFound { .. })
1553                ) =>
1554            {
1555                return Ok(None);
1556            }
1557            Err(error) => {
1558                return Err(IcpEnsurePlatformError::LocalManagementStatus(Box::new(
1559                    error,
1560                )));
1561            }
1562        };
1563        let cycles_text = response.cycles.to_string();
1564        let cycles = u128::try_from(response.cycles.0).map_err(|_| {
1565            IcpEnsurePlatformError::InvalidStatusCycles {
1566                canister: principal.to_string(),
1567                value: cycles_text,
1568            }
1569        })?;
1570        let mut controllers = response
1571            .settings
1572            .controllers
1573            .into_iter()
1574            .map(|controller| controller.to_text())
1575            .collect::<Vec<_>>();
1576        controllers.sort();
1577        controllers.dedup();
1578        let status = match response.status {
1579            ManagementCanisterRuntimeStatus::Running => CanisterRuntimeStatus::Running,
1580            ManagementCanisterRuntimeStatus::Stopping => CanisterRuntimeStatus::Stopping,
1581            ManagementCanisterRuntimeStatus::Stopped => CanisterRuntimeStatus::Stopped,
1582        };
1583        Ok(Some(LiveCanister {
1584            canister_version: Some(response.version),
1585            controllers,
1586            cycles,
1587            module_sha256: response
1588                .module_hash
1589                .map(|hash| canic_core::cdk::utils::hash::hex_bytes(&hash)),
1590            principal: principal.to_string(),
1591            reinstall_required: false,
1592            root_owned_lifecycle: None,
1593            status,
1594        }))
1595    }
1596
1597    fn inspect_root_owned_canister(
1598        &self,
1599        configured: &crate::fleet_ensure::model::DesiredCanister,
1600        principal: &str,
1601        state: &FleetEnsureStateRecord,
1602    ) -> Result<Option<CanisterStatusResponse>, IcpEnsurePlatformError> {
1603        let parent = configured.parent.as_deref().ok_or_else(|| {
1604            IcpEnsurePlatformError::RootOwnedObservationAuthorityConflict {
1605                canister: configured.name.clone(),
1606                field: "Root parent",
1607            }
1608        })?;
1609        let root = self.current_principal(state, parent).ok_or_else(|| {
1610            IcpEnsurePlatformError::RootOwnedObservationAuthorityConflict {
1611                canister: configured.name.clone(),
1612                field: "Root Principal",
1613            }
1614        })?;
1615        let root_configured = self
1616            .desired
1617            .canisters
1618            .iter()
1619            .find(|candidate| {
1620                candidate.name == parent && candidate.kind == DesiredCanisterKind::Root
1621            })
1622            .ok_or_else(
1623                || IcpEnsurePlatformError::RootOwnedObservationAuthorityConflict {
1624                    canister: configured.name.clone(),
1625                    field: "Root desired authority",
1626                },
1627            )?;
1628        let root_wasm = root_configured.wasm.as_deref().ok_or_else(|| {
1629            IcpEnsurePlatformError::RootOwnedObservationAuthorityConflict {
1630                canister: configured.name.clone(),
1631                field: "Root artifact",
1632            }
1633        })?;
1634        let expected_root_module = artifact_hash(&resolve_path(&self.root, root_wasm))?;
1635        let root_live = self.status_optional(root)?.ok_or_else(|| {
1636            IcpEnsurePlatformError::RootOwnedObservationAuthorityConflict {
1637                canister: configured.name.clone(),
1638                field: "live Root",
1639            }
1640        })?;
1641        let expected_root_controllers = self.resolved_controllers(
1642            state,
1643            &root_configured.controllers,
1644            &root_configured.controller_canisters,
1645        )?;
1646        if root_live.controllers != expected_root_controllers {
1647            return Err(
1648                IcpEnsurePlatformError::RootOwnedObservationAuthorityConflict {
1649                    canister: configured.name.clone(),
1650                    field: "Root controllers",
1651                },
1652            );
1653        }
1654        let Some(root_module) = root_live.module_sha256.as_deref() else {
1655            return Ok(None);
1656        };
1657        if root_module != expected_root_module {
1658            return Err(
1659                IcpEnsurePlatformError::RootOwnedObservationAuthorityConflict {
1660                    canister: configured.name.clone(),
1661                    field: "Root module SHA-256",
1662                },
1663            );
1664        }
1665        if root_live.status != CanisterRuntimeStatus::Running {
1666            return Ok(None);
1667        }
1668        self.require_operator()?;
1669        let response: RootInspectionResponse = call_with_candid(
1670            &self.icp,
1671            &self.root_protocol_candid()?,
1672            parse_principal("Fleet Subnet Root", root)?,
1673            canic_protocol::CANIC_ROOT_COMMAND,
1674            &RootInspectionCommand::InspectCanister(CanisterInspectionRequest {
1675                canister_id: parse_principal("Root-owned canister", principal)?,
1676            }),
1677        )
1678        .map_err(current_protocol::CurrentProtocolError::from)?;
1679        let RootInspectionResponse::InspectCanister(response) = response;
1680        Ok(Some(response))
1681    }
1682
1683    fn created_canister_cycles(
1684        &self,
1685        name: &str,
1686        principal: &str,
1687        state: &FleetEnsureStateRecord,
1688    ) -> Result<(Option<u128>, bool), IcpEnsurePlatformError> {
1689        match self.status_optional(principal) {
1690            Ok(live) => return Ok((live.map(|live| live.cycles), false)),
1691            Err(IcpEnsurePlatformError::PublicCanisterStatusUnavailable { .. }) => {}
1692            Err(error) => return Err(error),
1693        }
1694        let configured = self
1695            .desired
1696            .canisters
1697            .iter()
1698            .find(|configured| configured.name == name)
1699            .ok_or_else(
1700                || IcpEnsurePlatformError::RootOwnedObservationAuthorityConflict {
1701                    canister: name.to_string(),
1702                    field: "desired canister",
1703                },
1704            )?;
1705        if configured.kind != DesiredCanisterKind::Pool {
1706            return Err(IcpEnsurePlatformError::PublicCanisterStatusUnavailable {
1707                canister: principal.to_string(),
1708            });
1709        }
1710        let Some(response) = self.inspect_root_owned_canister(configured, principal, state)? else {
1711            return Ok((None, true));
1712        };
1713        let parent = configured.parent.as_deref().ok_or_else(|| {
1714            IcpEnsurePlatformError::RootOwnedObservationAuthorityConflict {
1715                canister: name.to_string(),
1716                field: "Root parent",
1717            }
1718        })?;
1719        let root = self.current_principal(state, parent).ok_or_else(|| {
1720            IcpEnsurePlatformError::RootOwnedObservationAuthorityConflict {
1721                canister: name.to_string(),
1722                field: "Root Principal",
1723            }
1724        })?;
1725        let cycles = validate_root_controlled_inspection(
1726            name,
1727            InspectedModule::Empty,
1728            root,
1729            &response.settings.controllers,
1730            response.module_hash.as_deref(),
1731            &response.cycles,
1732        )?;
1733        Ok((Some(cycles), false))
1734    }
1735
1736    fn install_status_optional(
1737        &self,
1738        principal: &str,
1739    ) -> Result<Option<LiveCanister>, IcpEnsurePlatformError> {
1740        let Some(mut live) = self.status_optional(principal)? else {
1741            return Ok(None);
1742        };
1743        let exact = exact_install_canister_status(
1744            &self.icp,
1745            principal,
1746            live.canister_version,
1747            live.module_sha256.clone(),
1748        )?;
1749        live.canister_version = Some(exact.canister_version);
1750        live.module_sha256 = exact.module_sha256;
1751        Ok(Some(live))
1752    }
1753
1754    fn observe_configured_canister(
1755        &self,
1756        configured: &crate::fleet_ensure::model::DesiredCanister,
1757        principal: &str,
1758        state: &FleetEnsureStateRecord,
1759    ) -> Result<Option<LiveCanister>, IcpEnsurePlatformError> {
1760        if configured.kind == DesiredCanisterKind::Pool {
1761            return self.observe_root_owned_canister(configured, principal, state);
1762        }
1763        let status = self.status_optional(principal);
1764        self.configured_status_or_root(configured, principal, state, status)
1765    }
1766
1767    fn configured_status_or_root(
1768        &self,
1769        configured: &crate::fleet_ensure::model::DesiredCanister,
1770        principal: &str,
1771        state: &FleetEnsureStateRecord,
1772        status: Result<Option<LiveCanister>, IcpEnsurePlatformError>,
1773    ) -> Result<Option<LiveCanister>, IcpEnsurePlatformError> {
1774        match status {
1775            Ok(observed) => Ok(observed),
1776            Err(error)
1777                if matches!(
1778                    configured.kind,
1779                    DesiredCanisterKind::Store | DesiredCanisterKind::Pool
1780                ) && !matches!(
1781                    error,
1782                    IcpEnsurePlatformError::Icp(ref source)
1783                        if matches!(
1784                            source.diagnostic(),
1785                            Some(IcpDiagnostic::CanisterNotFound { .. })
1786                        )
1787                ) =>
1788            {
1789                self.observe_root_owned_canister(configured, principal, state)
1790            }
1791            Err(error) => Err(error),
1792        }
1793    }
1794
1795    fn observe_root_owned_canister(
1796        &self,
1797        configured: &crate::fleet_ensure::model::DesiredCanister,
1798        principal: &str,
1799        state: &FleetEnsureStateRecord,
1800    ) -> Result<Option<LiveCanister>, IcpEnsurePlatformError> {
1801        let candid = self.root_protocol_candid()?;
1802        let parent = configured.parent.as_deref().ok_or_else(|| {
1803            current_protocol::CurrentProtocolError::Configuration(format!(
1804                "Root-owned canister {} has no Root parent",
1805                configured.name
1806            ))
1807        })?;
1808        let root = self.current_principal(state, parent).ok_or_else(|| {
1809            current_protocol::CurrentProtocolError::Configuration(format!(
1810                "Root-owned canister {} has no resolved Root",
1811                configured.name
1812            ))
1813        })?;
1814        if self.required_root_status(&configured.name, root)? == CanisterRuntimeStatus::Stopped {
1815            return self
1816                .retained_root_owned_observation(configured, principal, parent, root, state);
1817        }
1818        let target = parse_principal("Root-owned canister", principal)?;
1819        let mut start_after = None;
1820        loop {
1821            let page = self.query_estate_pool_page(
1822                &candid,
1823                parse_principal("Fleet Subnet Root", root)?,
1824                start_after,
1825            )?;
1826            if let Some(mut asset) = page
1827                .entries
1828                .into_iter()
1829                .find(|asset| asset.canister_id == target)
1830            {
1831                if matches!(
1832                    asset.status,
1833                    canic_core::dto::pool::CanisterPoolAssetStatus::PendingReset
1834                ) && asset.cycles.to_u128() == 0
1835                {
1836                    let cycles = self.inspect_pending_pool_balance(configured, principal, state)?;
1837                    asset.cycles = Cycles::new(cycles);
1838                }
1839                return Self::observed_root_owned_asset(configured, principal, root, asset);
1840            }
1841            let next = page.next_start_after;
1842            if next.is_none() {
1843                return Ok(None);
1844            }
1845            if next == start_after {
1846                return Err(
1847                    current_protocol::CurrentProtocolError::Configuration(format!(
1848                        "Root {parent} pool cursor did not advance"
1849                    ))
1850                    .into(),
1851                );
1852            }
1853            start_after = next;
1854        }
1855    }
1856
1857    fn inspect_pending_pool_balance(
1858        &self,
1859        configured: &crate::fleet_ensure::model::DesiredCanister,
1860        principal: &str,
1861        state: &FleetEnsureStateRecord,
1862    ) -> Result<u128, IcpEnsurePlatformError> {
1863        let parent = configured.parent.as_deref().ok_or_else(|| {
1864            IcpEnsurePlatformError::FundingInspectionAuthorityConflict {
1865                canister: configured.name.clone(),
1866                field: "Root parent",
1867            }
1868        })?;
1869        let root = self.current_principal(state, parent).ok_or_else(|| {
1870            IcpEnsurePlatformError::FundingInspectionAuthorityConflict {
1871                canister: configured.name.clone(),
1872                field: "Root Principal",
1873            }
1874        })?;
1875        let pending_fresh_pool = self
1876            .desired
1877            .bootstrap
1878            .as_ref()
1879            .is_some_and(|bootstrap| bootstrap.fresh_estate)
1880            && configured.principal.is_none()
1881            && state
1882                .pending_principals
1883                .get(&configured.name)
1884                .map(String::as_str)
1885                == Some(principal);
1886        if pending_fresh_pool {
1887            let response = self
1888                .inspect_root_owned_canister(configured, principal, state)?
1889                .ok_or_else(
1890                    || IcpEnsurePlatformError::FundingInspectionAuthorityConflict {
1891                        canister: configured.name.clone(),
1892                        field: "installed Root",
1893                    },
1894                )?;
1895            return validate_pending_fresh_pool_inspection(
1896                &configured.name,
1897                root,
1898                &self.desired.operator,
1899                &response.settings.controllers,
1900                response.module_hash.as_deref(),
1901                &response.cycles,
1902            );
1903        }
1904        self.inspect_pool_balance(&configured.name, root, principal, InspectedModule::Any)
1905    }
1906
1907    fn inspect_pool_balance(
1908        &self,
1909        name: &str,
1910        root: &str,
1911        principal: &str,
1912        module: InspectedModule,
1913    ) -> Result<u128, IcpEnsurePlatformError> {
1914        if self.required_root_status(name, root)? != CanisterRuntimeStatus::Running {
1915            return Err(IcpEnsurePlatformError::FundingInspectionAuthorityConflict {
1916                canister: name.to_string(),
1917                field: "running Root",
1918            });
1919        }
1920        self.require_operator()?;
1921        let target = parse_principal("Root-owned funding target", principal)?;
1922        let response: RootFundingInspectionResponse = call_with_candid(
1923            &self.icp,
1924            &self.root_protocol_candid()?,
1925            parse_principal("Fleet Subnet Root", root)?,
1926            canic_protocol::CANIC_ROOT_COMMAND,
1927            &RootInspectionCommand::InspectCanister(CanisterInspectionRequest {
1928                canister_id: target,
1929            }),
1930        )
1931        .map_err(current_protocol::CurrentProtocolError::from)?;
1932        let RootFundingInspectionResponse::InspectCanister(response) = response;
1933        validate_root_controlled_inspection(
1934            name,
1935            module,
1936            root,
1937            &response.settings.controllers,
1938            response.module_hash.as_deref(),
1939            &response.cycles,
1940        )
1941    }
1942
1943    fn pool_funding_module(
1944        &self,
1945        name: &str,
1946        authority: &crate::fleet_ensure::model::PoolFundingAuthority,
1947        principal: &str,
1948    ) -> Result<InspectedModule, IcpEnsurePlatformError> {
1949        let candid = self.root_protocol_candid()?;
1950        let root = parse_principal("Fleet Subnet Root", &authority.root)?;
1951        let target = parse_principal("pool funding target", principal)?;
1952        let mut start_after = None;
1953        loop {
1954            let page = self.query_estate_pool_page(&candid, root, start_after)?;
1955            if let Some(asset) = page
1956                .entries
1957                .iter()
1958                .find(|asset| asset.canister_id == target)
1959            {
1960                return pool_funding_inspected_module(name, authority.lifecycle, &asset.status);
1961            }
1962            if page.next_start_after.is_none() || page.next_start_after == start_after {
1963                return Err(IcpEnsurePlatformError::FundingInspectionAuthorityConflict {
1964                    canister: name.to_string(),
1965                    field: "reviewed pool membership",
1966                });
1967            }
1968            start_after = page.next_start_after;
1969        }
1970    }
1971
1972    fn observed_root_owned_asset(
1973        configured: &crate::fleet_ensure::model::DesiredCanister,
1974        principal: &str,
1975        root: &str,
1976        asset: CanisterPoolAsset,
1977    ) -> Result<Option<LiveCanister>, IcpEnsurePlatformError> {
1978        let Some(root_owned_lifecycle) = root_owned_lifecycle(configured.kind, &asset.status)
1979        else {
1980            return Err(
1981                current_protocol::CurrentProtocolError::Configuration(format!(
1982                    "Root-owned canister {} has a live pool role incompatible with desired {:?}",
1983                    configured.name, configured.kind
1984                ))
1985                .into(),
1986            );
1987        };
1988        let status = match root_owned_lifecycle {
1989            RootOwnedCanisterLifecycle::Store | RootOwnedCanisterLifecycle::Workload => {
1990                CanisterRuntimeStatus::Running
1991            }
1992            RootOwnedCanisterLifecycle::Claimed
1993            | RootOwnedCanisterLifecycle::Idle
1994            | RootOwnedCanisterLifecycle::Retained => CanisterRuntimeStatus::Stopped,
1995        };
1996        Ok(Some(LiveCanister {
1997            canister_version: None,
1998            controllers: vec![root.to_string()],
1999            cycles: asset.cycles.to_u128(),
2000            module_sha256: None,
2001            principal: principal.to_string(),
2002            reinstall_required: false,
2003            root_owned_lifecycle: Some(root_owned_lifecycle),
2004            status,
2005        }))
2006    }
2007
2008    fn root_protocol_candid(&self) -> Result<PathBuf, IcpEnsurePlatformError> {
2009        let protocol = self.desired.protocol.as_ref().ok_or_else(|| {
2010            current_protocol::CurrentProtocolError::Configuration(
2011                "Root-owned observation requires typed Fleet protocol".to_string(),
2012            )
2013        })?;
2014        Ok(resolve_path(&self.root, &protocol.root_candid))
2015    }
2016
2017    fn required_root_status(
2018        &self,
2019        configured_name: &str,
2020        root: &str,
2021    ) -> Result<CanisterRuntimeStatus, IcpEnsurePlatformError> {
2022        self.status_optional(root)?
2023            .map(|live| live.status)
2024            .ok_or_else(|| {
2025                current_protocol::CurrentProtocolError::Configuration(format!(
2026                    "Root-owned canister {configured_name} has no live Root"
2027                ))
2028                .into()
2029            })
2030    }
2031
2032    fn retained_root_owned_observation(
2033        &self,
2034        configured: &crate::fleet_ensure::model::DesiredCanister,
2035        principal: &str,
2036        parent: &str,
2037        root: &str,
2038        state: &FleetEnsureStateRecord,
2039    ) -> Result<Option<LiveCanister>, IcpEnsurePlatformError> {
2040        let Some(retained_topology) = exact_retained_root_owned_topology(
2041            state,
2042            &configured.name,
2043            configured.kind,
2044            parent,
2045            principal,
2046            root,
2047        ) else {
2048            return Err(
2049                current_protocol::CurrentProtocolError::Configuration(format!(
2050                    "Root-owned canister {} has no exact retained topology authority",
2051                    configured.name
2052                ))
2053                .into(),
2054            );
2055        };
2056        let cycles = self
2057            .controller_cycle_balance(principal)
2058            .or_else(|| state.retained_cycles_by_principal.get(principal).copied())
2059            .ok_or_else(|| {
2060                current_protocol::CurrentProtocolError::Configuration(format!(
2061                    "Root-owned canister {} has no current or retained exact native balance",
2062                    configured.name
2063                ))
2064            })?;
2065        if !matches!(
2066            configured.kind,
2067            DesiredCanisterKind::Pool | DesiredCanisterKind::Store
2068        ) || configured.presence != crate::fleet_ensure::model::DesiredPresence::Present
2069            || configured.replace
2070            || configured.drain.is_some()
2071            || (configured.kind == DesiredCanisterKind::Pool && configured.wasm.is_some())
2072            || (configured.kind == DesiredCanisterKind::Store && configured.wasm.is_none())
2073        {
2074            return Err(
2075                current_protocol::CurrentProtocolError::Configuration(format!(
2076                    "Root-owned recovery evidence for {} cannot authorize a mutation",
2077                    configured.name
2078                ))
2079                .into(),
2080            );
2081        }
2082        let root_config = self
2083            .desired
2084            .canisters
2085            .iter()
2086            .find(|canister| canister.name == parent && canister.kind == DesiredCanisterKind::Root)
2087            .ok_or_else(|| {
2088                current_protocol::CurrentProtocolError::Configuration(format!(
2089                    "Root-owned canister {} has no exact configured Root",
2090                    configured.name
2091                ))
2092            })?;
2093        let live_root = self.status_optional(root)?.ok_or_else(|| {
2094            current_protocol::CurrentProtocolError::Configuration(format!(
2095                "Root-owned canister {} has no live Root",
2096                configured.name
2097            ))
2098        })?;
2099        let mut expected_controllers = root_config.controllers.clone();
2100        expected_controllers.sort();
2101        if live_root.controllers != expected_controllers {
2102            return Err(
2103                current_protocol::CurrentProtocolError::Configuration(format!(
2104                    "Root-owned canister {} Root controllers drifted",
2105                    configured.name
2106                ))
2107                .into(),
2108            );
2109        }
2110        Ok(Some(LiveCanister {
2111            canister_version: None,
2112            controllers: vec![root.to_string()],
2113            cycles,
2114            module_sha256: retained_topology.module_hash.clone(),
2115            principal: principal.to_string(),
2116            reinstall_required: false,
2117            root_owned_lifecycle: Some(RootOwnedCanisterLifecycle::Retained),
2118            status: if configured.kind == DesiredCanisterKind::Store {
2119                CanisterRuntimeStatus::Running
2120            } else {
2121                CanisterRuntimeStatus::Stopped
2122            },
2123        }))
2124    }
2125
2126    fn completed_reinstall_is_current(
2127        &self,
2128        state: &FleetEnsureStateRecord,
2129        name: &str,
2130        live: &LiveCanister,
2131    ) -> Result<bool, IcpEnsurePlatformError> {
2132        if state.completed_reinstall_operation_id.is_none()
2133            || !state.completed_reinstall_action_sha256.contains_key(name)
2134        {
2135            return Ok(false);
2136        }
2137        let Some(pre_canister_version) = state.completed_reinstalls.get(name) else {
2138            return Ok(false);
2139        };
2140        let Some(configured) = self
2141            .desired
2142            .canisters
2143            .iter()
2144            .find(|configured| configured.name == name)
2145        else {
2146            return Ok(false);
2147        };
2148        let Some(wasm) = configured.wasm.as_deref() else {
2149            return Ok(false);
2150        };
2151        let principal_matches = state
2152            .principals
2153            .get(name)
2154            .is_some_and(|principal| principal == &live.principal);
2155        let retained_topology = state.topology.get(name);
2156        let topology_matches = retained_topology.is_some_and(|topology| {
2157            topology.kind == configured.kind && topology.parent == configured.parent
2158        });
2159        let desired_module_sha256 = artifact_hash(&resolve_path(&self.root, wasm))?;
2160        let root_owned_store_module = (live.module_sha256.is_none()
2161            && live.root_owned_lifecycle == Some(RootOwnedCanisterLifecycle::Store))
2162        .then(|| {
2163            configured
2164                .parent
2165                .as_deref()
2166                .and_then(|parent| state.principals.get(parent))
2167                .filter(|root| live.controllers.as_slice() == [root.as_str()])
2168                .and(retained_topology)
2169                .and_then(|topology| topology.module_hash.as_deref())
2170        })
2171        .flatten();
2172        completed_reinstall_continuity(
2173            *pre_canister_version,
2174            principal_matches,
2175            topology_matches,
2176            live.module_sha256.as_deref().or(root_owned_store_module),
2177            &desired_module_sha256,
2178            live.canister_version,
2179        )
2180        .map_err(
2181            |field| IcpEnsurePlatformError::CompletedReinstallAuthorityConflict {
2182                canister: name.to_string(),
2183                field,
2184            },
2185        )?;
2186        Ok(true)
2187    }
2188
2189    fn controller_cycle_balance(&self, principal: &str) -> Option<u128> {
2190        let response: Result<ManagedCanisterStatusResponse, canic_core::dto::error::Error> = self
2191            .icp
2192            .canister_query_candid(
2193                principal,
2194                canic_protocol::CANIC_OBSERVABILITY,
2195                &ManagedCanisterStatusRequest::CycleBalance,
2196                None,
2197            )
2198            .ok()?;
2199        let ManagedCanisterStatusResponse::CycleBalance(balance) = response.ok()?;
2200        Some(balance.cycles)
2201    }
2202
2203    fn apply_create(
2204        &self,
2205        authority: CreateCanisterAuthority<'_>,
2206        state: &FleetEnsureStateRecord,
2207    ) -> Result<EffectOutcome, IcpEnsurePlatformError> {
2208        let creation_fee = self
2209            .desired
2210            .management_creation_fee_cycles
2211            .parse::<Cycles>()
2212            .map(|cycles| cycles.to_u128())
2213            .map_err(|_| IcpEnsurePlatformError::Arithmetic("management creation fee"))?;
2214        let amount = authority
2215            .requested_initial_cycles
2216            .checked_add(creation_fee)
2217            .ok_or(IcpEnsurePlatformError::Arithmetic("creation amount"))?;
2218        let controllers = self
2219            .resolved_controllers(state, authority.controllers, authority.controller_canisters)?
2220            .iter()
2221            .map(|value| parse_principal("controller", value))
2222            .collect::<Result<Vec<_>, _>>()?;
2223        let request = CreateCanisterArgs {
2224            amount: Nat::from(amount),
2225            created_at_time: Some(authority.created_at_time),
2226            creation_args: Some(CmcCreateCanisterArgs {
2227                settings: Some(CanisterSettings {
2228                    compute_allocation: None,
2229                    controllers: Some(controllers),
2230                    freezing_threshold: None,
2231                    memory_allocation: None,
2232                    reserved_cycles_limit: None,
2233                }),
2234                subnet_selection: Some(SubnetSelection::Subnet {
2235                    subnet: parse_principal("subnet", authority.subnet)?,
2236                }),
2237            }),
2238            from_subaccount: None,
2239        };
2240        let response: Result<CreateCanisterSuccess, CreateCanisterError> = self
2241            .icp
2242            .canister_call_candid(authority.ledger, "create_canister", &request, None)?;
2243        create_canister_response_outcome(response, authority.requested_initial_cycles)
2244    }
2245
2246    fn apply_fund(
2247        &self,
2248        amount: u128,
2249        created_at_time: u64,
2250        ledger: &str,
2251        principal: &str,
2252    ) -> Result<EffectOutcome, IcpEnsurePlatformError> {
2253        let request = WithdrawArgs {
2254            amount: Nat::from(amount),
2255            created_at_time: Some(created_at_time),
2256            from_subaccount: None,
2257            to: parse_principal("funding target", principal)?,
2258        };
2259        let response: Result<Nat, WithdrawError> = self
2260            .icp
2261            .canister_call_candid(ledger, "withdraw", &request, None)?;
2262        match response {
2263            Ok(block) => Ok(EffectOutcome {
2264                created_principal: None,
2265                post_cycles: None,
2266                receipt: Some(block.to_string()),
2267            }),
2268            Err(WithdrawError::Duplicate { duplicate_of }) => Ok(EffectOutcome {
2269                created_principal: None,
2270                post_cycles: None,
2271                receipt: Some(duplicate_of.to_string()),
2272            }),
2273            Err(error) => Err(IcpEnsurePlatformError::LedgerWithdraw(
2274                render_withdraw_error(error),
2275            )),
2276        }
2277    }
2278
2279    fn apply_estate_fund(
2280        &self,
2281        amount: u128,
2282        created_at_time: u64,
2283        ledger: &str,
2284        principal: &str,
2285        reviewed_fee_cycles: u128,
2286    ) -> Result<EffectOutcome, IcpEnsurePlatformError> {
2287        let request = CyclesLedgerTransferArgs {
2288            amount: Nat::from(amount),
2289            created_at_time: Some(created_at_time),
2290            fee: Some(Nat::from(reviewed_fee_cycles)),
2291            from_subaccount: None,
2292            memo: None,
2293            to: CyclesLedgerAccount {
2294                owner: parse_principal("estate funding target", principal)?,
2295                subaccount: None,
2296            },
2297        };
2298        let response: Result<Nat, CyclesLedgerTransferError> =
2299            self.icp
2300                .canister_call_candid(ledger, "icrc1_transfer", &request, None)?;
2301        match response {
2302            Ok(block) => Ok(EffectOutcome {
2303                created_principal: None,
2304                post_cycles: None,
2305                receipt: Some(block.to_string()),
2306            }),
2307            Err(CyclesLedgerTransferError::Duplicate { duplicate_of }) => Ok(EffectOutcome {
2308                created_principal: None,
2309                post_cycles: None,
2310                receipt: Some(duplicate_of.to_string()),
2311            }),
2312            Err(CyclesLedgerTransferError::BadFee { expected_fee }) => {
2313                Err(IcpEnsurePlatformError::LedgerTransferFeeChanged {
2314                    expected_fee_cycles: ledger_fee_cycles(expected_fee)?,
2315                    reviewed_fee_cycles,
2316                })
2317            }
2318            Err(error) => Err(IcpEnsurePlatformError::LedgerTransfer(
2319                render_ledger_transfer_error(error),
2320            )),
2321        }
2322    }
2323
2324    #[expect(
2325        clippy::too_many_arguments,
2326        reason = "the install boundary verifies one complete immutable action tuple"
2327    )]
2328    fn apply_install(
2329        &self,
2330        operation_id: &str,
2331        principals: &BTreeMap<String, String>,
2332        canic_init: Option<&crate::fleet_ensure::model::DesiredCanisterInit>,
2333        init_arg: Option<&str>,
2334        init_arg_sha256: Option<&str>,
2335        init_candid: Option<&str>,
2336        init_candid_sha256: Option<&str>,
2337        mode: InstallMode,
2338        principal: &str,
2339        wasm: &str,
2340        wasm_sha256: &str,
2341    ) -> Result<EffectOutcome, IcpEnsurePlatformError> {
2342        verify_artifact(&resolve_path(&self.root, wasm), wasm_sha256, "Wasm")?;
2343        let mut command = self.icp.canister_command();
2344        command.args(["install", principal, "--mode"]);
2345        command.arg(match mode {
2346            InstallMode::Install => "install",
2347            InstallMode::Reinstall => "reinstall",
2348        });
2349        command.args(["--yes", "--wasm"]);
2350        command.arg(resolve_path(&self.root, wasm));
2351        let generated_init = if let Some(canic_init) = canic_init {
2352            let path = canic_init::write_arguments(canic_init::CanicInitRequest {
2353                desired: &self.desired,
2354                init: canic_init,
2355                operation_id,
2356                principals,
2357                root: &self.root,
2358                wasm,
2359                wasm_sha256,
2360            })?;
2361            command.arg("--args-file");
2362            command.arg(&path);
2363            command.args(["--args-format", "bin"]);
2364            Some(path)
2365        } else if let Some(init_arg) = init_arg {
2366            let expected =
2367                init_arg_sha256.ok_or_else(|| IcpEnsurePlatformError::ArtifactDigestMismatch {
2368                    actual: "missing".to_string(),
2369                    expected: "reviewed init argument identity".to_string(),
2370                    kind: "init argument",
2371                })?;
2372            let init_candid =
2373                init_candid.ok_or_else(|| IcpEnsurePlatformError::ArtifactDigestMismatch {
2374                    actual: "missing".to_string(),
2375                    expected: "reviewed init Candid".to_string(),
2376                    kind: "init Candid",
2377                })?;
2378            let init_candid_sha256 = init_candid_sha256.ok_or_else(|| {
2379                IcpEnsurePlatformError::ArtifactDigestMismatch {
2380                    actual: "missing".to_string(),
2381                    expected: "reviewed init Candid identity".to_string(),
2382                    kind: "init Candid",
2383                }
2384            })?;
2385            let path = protocol::write_init_arguments(
2386                &self.root,
2387                operation_id,
2388                principals,
2389                init_candid,
2390                init_candid_sha256,
2391                init_arg,
2392                expected,
2393            )?;
2394            command.arg("--args-file");
2395            command.arg(&path);
2396            command.args(["--args-format", "bin"]);
2397            Some(path)
2398        } else {
2399            command.args(["--args", "()"]);
2400            None
2401        };
2402        self.icp.add_target_args(&mut command);
2403        let result = run_status(&mut command).map_err(IcpEnsurePlatformError::from);
2404        if let Some(path) = generated_init {
2405            std::fs::remove_file(&path)
2406                .map_err(|_| IcpEnsurePlatformError::ArtifactUnavailable(path))?;
2407        }
2408        result?;
2409        Ok(empty_outcome())
2410    }
2411
2412    fn apply_controllers(
2413        &self,
2414        principal: &str,
2415        controllers: &[String],
2416    ) -> Result<EffectOutcome, IcpEnsurePlatformError> {
2417        let mut command = self.icp.canister_command();
2418        command.args([
2419            "settings",
2420            "update",
2421            principal,
2422            "--force",
2423            "--remove-all-controllers",
2424        ]);
2425        for controller in controllers {
2426            parse_principal("controller", controller)?;
2427            command.args(["--add-controller", controller]);
2428        }
2429        self.icp.add_target_args(&mut command);
2430        run_status(&mut command)?;
2431        Ok(empty_outcome())
2432    }
2433
2434    fn observe_fleet_snapshot(
2435        &mut self,
2436        operation_id: &str,
2437        state: &FleetEnsureStateRecord,
2438    ) -> Result<FleetObservation, IcpEnsurePlatformError> {
2439        self.require_operator()?;
2440        let mut canisters = self
2441            .timed_observation(FleetObservationStage::ConfiguredCanisters, |platform| {
2442                platform.observe_configured_canisters(state)
2443            })?;
2444        self.reconcile_completed_reinstalls(state, &mut canisters)?;
2445        let mut estate_funding_domains = self
2446            .timed_observation(FleetObservationStage::EstateFunding, |platform| {
2447                platform.observe_estate_funding_domains(state)
2448            })?;
2449        self.timed_observation(FleetObservationStage::PoolBalances, |platform| {
2450            for domain in estate_funding_domains.values_mut() {
2451                if let (Some(root), Some(pool)) = (&domain.root_principal, &mut domain.pool) {
2452                    for asset in &mut pool.assets {
2453                        if matches!(
2454                            asset.lifecycle,
2455                            EstatePoolAssetLifecycle::PendingReset
2456                                | EstatePoolAssetLifecycle::Failed
2457                        ) {
2458                            asset.cycles = platform.inspect_pool_balance(
2459                                &asset.principal,
2460                                root,
2461                                &asset.principal,
2462                                InspectedModule::Any,
2463                            )?;
2464                            for live in canisters.values_mut().filter_map(Option::as_mut) {
2465                                if live.principal == asset.principal {
2466                                    live.cycles = asset.cycles;
2467                                }
2468                            }
2469                        }
2470                    }
2471                }
2472            }
2473            Ok(())
2474        })?;
2475        self.estate_observations.clone_from(&estate_funding_domains);
2476        self.require_current_pool_policy(&estate_funding_domains)?;
2477        self.reconcile_completed_reinstalls(state, &mut canisters)?;
2478        let additional_controlled_cycles =
2479            Self::additional_pool_cycles(&canisters, &estate_funding_domains)?;
2480        let protocol_ready = self
2481            .timed_observation(FleetObservationStage::ProtocolReadiness, |platform| {
2482                platform.observe_protocol_readiness(operation_id, state, &canisters)
2483            })?;
2484        let ledger_fee_cycles =
2485            self.timed_observation(FleetObservationStage::LedgerFee, |platform| {
2486                ledger_fee_cycles(platform.icp.canister_query_candid(
2487                    &platform.desired.cycles_ledger,
2488                    "icrc1_fee",
2489                    &(),
2490                    None,
2491                )?)
2492            })?;
2493        let operator_cycles =
2494            self.timed_observation(FleetObservationStage::OperatorBalance, |platform| {
2495                platform
2496                    .icp
2497                    .identity_cycles_balance()
2498                    .map_err(|error| IcpEnsurePlatformError::LedgerWithdraw(error.to_string()))
2499            })?;
2500        Ok(FleetObservation {
2501            additional_controlled_cycles,
2502            canisters,
2503            estate_funding_domains,
2504            ledger_fee_cycles,
2505            operator_cycles,
2506            protocol_ready,
2507        })
2508    }
2509
2510    fn observe_configured_canisters(
2511        &self,
2512        state: &FleetEnsureStateRecord,
2513    ) -> Result<BTreeMap<String, Option<LiveCanister>>, IcpEnsurePlatformError> {
2514        let configured = &self.desired.canisters;
2515        let mut observed = BTreeMap::new();
2516        let mut cursor = 0;
2517        while cursor < configured.len() {
2518            if configured[cursor].kind == DesiredCanisterKind::Pool {
2519                let canister = &configured[cursor];
2520                let live = self
2521                    .current_principal(state, &canister.name)
2522                    .map(|principal| self.observe_root_owned_canister(canister, principal, state))
2523                    .transpose()?
2524                    .flatten();
2525                observed.insert(canister.name.clone(), live);
2526                cursor += 1;
2527                continue;
2528            }
2529            // Only consecutive independent management reads may overlap. Pool dependencies
2530            // remain ordered, and every started batch drains before exposing an error.
2531            let end = configured[cursor..]
2532                .iter()
2533                .take(super::bounded_observations::MAX_IN_FLIGHT)
2534                .take_while(|canister| canister.kind != DesiredCanisterKind::Pool)
2535                .count()
2536                + cursor;
2537            self.observe_configured_status_batch(&configured[cursor..end], state, &mut observed)?;
2538            cursor = end;
2539        }
2540        Ok(observed)
2541    }
2542
2543    fn observe_configured_status_batch(
2544        &self,
2545        configured: &[crate::fleet_ensure::model::DesiredCanister],
2546        state: &FleetEnsureStateRecord,
2547        observed: &mut BTreeMap<String, Option<LiveCanister>>,
2548    ) -> Result<(), IcpEnsurePlatformError> {
2549        let mut seen = BTreeSet::new();
2550        let requests = configured
2551            .iter()
2552            .filter_map(|canister| self.current_principal(state, &canister.name))
2553            .filter(|principal| seen.insert(*principal))
2554            .filter(|principal| matches!(self.cached_status(principal), StatusCacheEntry::Miss))
2555            .collect::<Vec<_>>();
2556        let icp = &self.icp;
2557        let mut results = super::bounded_observations::collect(&requests, |principal| {
2558            Ok::<_, std::convert::Infallible>((*principal, Self::read_status_with(icp, principal)))
2559        })
2560        .expect("read outcomes are retained for ordered authority decisions")
2561        .into_iter()
2562        .collect::<BTreeMap<_, _>>();
2563        for canister in configured {
2564            let Some(principal) = self.current_principal(state, &canister.name) else {
2565                observed.insert(canister.name.clone(), None);
2566                continue;
2567            };
2568            let status = if let StatusCacheEntry::Hit(cached) = self.cached_status(principal) {
2569                Ok(cached)
2570            } else {
2571                results
2572                    .remove(principal)
2573                    .unwrap_or_else(|| self.status_optional(principal))
2574            };
2575            if let Ok(live) = &status
2576                && let Some(snapshot) = self.observation_snapshot.borrow_mut().as_mut()
2577            {
2578                snapshot
2579                    .statuses
2580                    .insert(principal.to_string(), live.clone());
2581            }
2582            let live = self.configured_status_or_root(canister, principal, state, status)?;
2583            observed.insert(canister.name.clone(), live);
2584        }
2585        Ok(())
2586    }
2587
2588    fn cached_status(&self, principal: &str) -> StatusCacheEntry {
2589        self.observation_snapshot
2590            .borrow()
2591            .as_ref()
2592            .and_then(|snapshot| snapshot.statuses.get(principal).cloned())
2593            .map_or(StatusCacheEntry::Miss, StatusCacheEntry::Hit)
2594    }
2595
2596    fn reconcile_completed_reinstalls(
2597        &self,
2598        state: &FleetEnsureStateRecord,
2599        canisters: &mut BTreeMap<String, Option<LiveCanister>>,
2600    ) -> Result<(), IcpEnsurePlatformError> {
2601        for (name, live) in canisters.iter_mut() {
2602            let Some(live) = live.as_mut() else {
2603                continue;
2604            };
2605            let completed = self.completed_reinstall_is_current(state, name, live)?;
2606            let store_module_unobserved = live.module_sha256.is_none()
2607                && live.root_owned_lifecycle == Some(RootOwnedCanisterLifecycle::Store);
2608            if completed && store_module_unobserved {
2609                live.module_sha256 = state
2610                    .topology
2611                    .get(name)
2612                    .and_then(|topology| topology.module_hash.clone());
2613            }
2614        }
2615        Ok(())
2616    }
2617
2618    fn additional_pool_cycles(
2619        canisters: &BTreeMap<String, Option<LiveCanister>>,
2620        domains: &BTreeMap<String, EstateFundingDomainObservation>,
2621    ) -> Result<BTreeMap<String, u128>, IcpEnsurePlatformError> {
2622        let configured = canisters
2623            .values()
2624            .filter_map(|live| live.as_ref().map(|live| live.principal.as_str()))
2625            .collect::<BTreeSet<_>>();
2626        let mut additional = BTreeMap::new();
2627        for asset in domains
2628            .values()
2629            .filter_map(|domain| domain.pool.as_ref())
2630            .flat_map(|pool| &pool.assets)
2631        {
2632            if configured.contains(asset.principal.as_str()) {
2633                continue;
2634            }
2635            if additional
2636                .insert(asset.principal.clone(), asset.cycles)
2637                .is_some()
2638            {
2639                return Err(pool_configuration_error(format!(
2640                    "controlled pool canister {} is retained by more than one Root",
2641                    asset.principal
2642                )));
2643            }
2644        }
2645        Ok(additional)
2646    }
2647
2648    fn management_roots_are_current(
2649        &self,
2650        roots: &[(&crate::fleet_ensure::model::DesiredCanister, LiveCanister)],
2651        reviewed_targets: &BTreeSet<String>,
2652    ) -> Result<bool, IcpEnsurePlatformError> {
2653        let mut all_current = true;
2654        for (configured, live) in roots {
2655            if live.status != CanisterRuntimeStatus::Running || !reviewed_targets.is_empty() {
2656                all_current = false;
2657                continue;
2658            }
2659            if live.module_sha256.is_none()
2660                && is_unallocated_fresh_root(&self.desired, configured, reviewed_targets)
2661            {
2662                continue;
2663            }
2664            if let Some(wasm) = &configured.wasm {
2665                let expected = artifact_hash(&resolve_path(&self.root, wasm))?;
2666                if live.module_sha256.as_deref() != Some(expected.as_str()) {
2667                    all_current = false;
2668                }
2669            }
2670        }
2671        Ok(all_current)
2672    }
2673
2674    fn observe_protocol_readiness(
2675        &self,
2676        operation_id: &str,
2677        state: &FleetEnsureStateRecord,
2678        canisters: &BTreeMap<String, Option<LiveCanister>>,
2679    ) -> Result<BTreeMap<String, bool>, IcpEnsurePlatformError> {
2680        let principals = self.protocol_principals(state);
2681        self.desired
2682            .protocol_steps
2683            .iter()
2684            .map(|step| {
2685                let Some(principal) = principals.get(&step.canister) else {
2686                    return Ok((step.name.clone(), false));
2687                };
2688                let Some(live) = canisters.get(&step.canister).and_then(Option::as_ref) else {
2689                    return Ok((step.name.clone(), false));
2690                };
2691                let configured = self
2692                    .desired
2693                    .canisters
2694                    .iter()
2695                    .find(|configured| configured.name == step.canister)
2696                    .expect("protocol target was validated against desired canisters");
2697                if live.reinstall_required {
2698                    return Ok((step.name.clone(), false));
2699                }
2700                if let Some(wasm) = &configured.wasm {
2701                    let desired_hash = artifact_hash(&resolve_path(&self.root, wasm))?;
2702                    if live.module_sha256.as_deref() != Some(desired_hash.as_str()) {
2703                        return Ok((step.name.clone(), false));
2704                    }
2705                }
2706                let action = self.observed_protocol_action(step, principal.clone())?;
2707                protocol::observe(&self.icp, &self.root, operation_id, &principals, &action)
2708                    .map(|observation| (step.name.clone(), observation.applied))
2709                    .map_err(IcpEnsurePlatformError::from)
2710            })
2711            .collect()
2712    }
2713
2714    #[expect(
2715        clippy::too_many_arguments,
2716        reason = "one typed transfer effect carries the complete reviewed authority tuple"
2717    )]
2718    fn apply_transfer(
2719        &self,
2720        amount: u128,
2721        candid: &str,
2722        candid_sha256: &str,
2723        destination: &str,
2724        method: &str,
2725        operation_id: &str,
2726        principal: &str,
2727    ) -> Result<EffectOutcome, IcpEnsurePlatformError> {
2728        let candid = resolve_path(&self.root, candid);
2729        if !candid.is_file() {
2730            return Err(IcpEnsurePlatformError::CandidUnavailable(candid));
2731        }
2732        verify_artifact(&candid, candid_sha256, "drain Candid")?;
2733        let response: DrainResponse = self.icp.canister_call_candid(
2734            principal,
2735            method,
2736            &DrainRequest {
2737                operation_id: operation_id.to_string(),
2738                destination: parse_principal("treasury", destination)?,
2739                cycles: Nat::from(amount),
2740            },
2741            Some(&candid),
2742        )?;
2743        let transferred = match response {
2744            DrainResponse::Accepted { transferred_cycles }
2745            | DrainResponse::Replayed { transferred_cycles } => transferred_cycles,
2746        };
2747        if transferred != amount {
2748            return Err(IcpEnsurePlatformError::WrongTransferAmount {
2749                actual: transferred,
2750                expected: amount,
2751            });
2752        }
2753        Ok(EffectOutcome {
2754            created_principal: None,
2755            post_cycles: None,
2756            receipt: Some(operation_id.to_string()),
2757        })
2758    }
2759}
2760
2761impl EnsurePlatform for IcpEnsurePlatform {
2762    type Error = IcpEnsurePlatformError;
2763
2764    fn report_progress(&mut self, progress: FleetEnsureProgress) {
2765        if let Some(handler) = self.progress_handler.as_mut() {
2766            handler(progress);
2767        }
2768    }
2769
2770    fn require_retained_root_activation(
2771        &mut self,
2772        operation_id: &str,
2773        root: &str,
2774        state: &FleetEnsureStateRecord,
2775    ) -> Result<(), Self::Error> {
2776        let authorities = canic_init::compile_root_authorities(
2777            &self.root,
2778            &self.desired,
2779            &self.protocol_principals(state),
2780        )?;
2781        let (_, authority) = authorities
2782            .iter()
2783            .find(|(name, _)| name == root)
2784            .ok_or_else(|| canic_init::CanicInitError::MissingRoot(root.to_string()))?;
2785        current_protocol::require_store_installation_binding(
2786            &self.icp,
2787            &self.root_protocol_candid()?,
2788            root,
2789            authority,
2790            operation_id,
2791        )?;
2792        Ok(())
2793    }
2794
2795    fn bind_reviewed_desired(&mut self, desired: &DesiredFleet) -> Result<(), Self::Error> {
2796        self.desired = desired.clone();
2797        Ok(())
2798    }
2799
2800    fn pace_effect_observation(
2801        &mut self,
2802        _action: &EnsureAction,
2803        consecutive_unchanged_observations: u32,
2804    ) {
2805        thread::sleep(protocol_observation_delay(
2806            consecutive_unchanged_observations,
2807            self.initial_observation_delay,
2808            self.maximum_observation_delay,
2809        ));
2810    }
2811
2812    fn pace_root_owned_observation(
2813        &mut self,
2814        _target: &str,
2815        consecutive_retained_observations: u32,
2816    ) {
2817        thread::sleep(protocol_observation_delay(
2818            consecutive_retained_observations,
2819            self.initial_observation_delay,
2820            self.maximum_observation_delay,
2821        ));
2822    }
2823
2824    fn authority_sealed(
2825        &mut self,
2826        operation_id: &str,
2827        action: &EnsureAction,
2828    ) -> Result<bool, Self::Error> {
2829        Ok(super::authority_seal::observe(&self.icp, &self.root, operation_id, action)?.applied)
2830    }
2831
2832    fn reinstall_assets_match(
2833        &mut self,
2834        intent: &crate::fleet_ensure::model::FleetReinstallRecord,
2835        root_name: &str,
2836        check: super::ReinstallAssetCheck,
2837    ) -> Result<bool, Self::Error> {
2838        let binding = intent
2839            .authorities
2840            .iter()
2841            .find(|binding| binding.name == root_name)
2842            .ok_or_else(|| {
2843                IcpEnsurePlatformError::RootManagement("missing reset Root".to_string())
2844            })?;
2845        let principal = parse_principal("reset Root", &binding.principal)?;
2846        let candid = self.root_protocol_candid()?;
2847        for asset in intent.assets.iter().filter(|asset| asset.root == root_name) {
2848            let RootFundingInspectionResponse::InspectCanister(status) = call_with_candid(
2849                &self.icp,
2850                &candid,
2851                principal,
2852                canic_protocol::CANIC_ROOT_COMMAND,
2853                &RootInspectionCommand::InspectCanister(CanisterInspectionRequest {
2854                    canister_id: parse_principal("reset asset", &asset.principal)?,
2855                }),
2856            )
2857            .map_err(current_protocol::CurrentProtocolError::from)?;
2858            let mut controllers = status
2859                .settings
2860                .controllers
2861                .iter()
2862                .map(Principal::to_text)
2863                .collect::<Vec<_>>();
2864            controllers.sort();
2865            let module = status
2866                .module_hash
2867                .as_ref()
2868                .map(canic_core::cdk::utils::hash::hex_bytes);
2869            let module_matches =
2870                check == super::ReinstallAssetCheck::Terminal || module == asset.module_sha256;
2871            if controllers != asset.controllers || !module_matches {
2872                return Ok(false);
2873            }
2874        }
2875        Ok(true)
2876    }
2877
2878    fn reinstall_authorities(
2879        &mut self,
2880        state: &FleetEnsureStateRecord,
2881    ) -> Result<Option<BTreeMap<String, RootManagementCanisterObservation>>, Self::Error> {
2882        self.require_operator()?;
2883        let network = resolve_icp_build_network_from_root(&self.root, &self.desired.environment)
2884            .map_err(|error| IcpEnsurePlatformError::RootManagement(error.to_string()))?;
2885        let catalog = if network == BuildNetwork::Ic {
2886            let now = SystemTime::now()
2887                .duration_since(UNIX_EPOCH)
2888                .map_err(|_| {
2889                    IcpEnsurePlatformError::RootManagement(
2890                        "system clock is before the Unix epoch".to_string(),
2891                    )
2892                })?
2893                .as_secs();
2894            Some(
2895                load_mainnet_subnet_catalog(&self.root, now)
2896                    .map_err(|error| IcpEnsurePlatformError::RootManagement(error.to_string()))?,
2897            )
2898        } else {
2899            None
2900        };
2901        let mut authorities = BTreeMap::new();
2902        let root_targets = self
2903            .desired
2904            .canisters
2905            .iter()
2906            .filter(|c| c.kind == DesiredCanisterKind::Root)
2907            .map(|c| c.name.clone())
2908            .collect();
2909        let roots = self
2910            .observe_root_management(state, &root_targets)?
2911            .ok_or_else(|| {
2912                IcpEnsurePlatformError::RootManagement("missing Root authority".to_string())
2913            })?;
2914        for configured in &self.desired.canisters {
2915            if configured.kind == DesiredCanisterKind::Pool {
2916                continue;
2917            }
2918            let principal = self
2919                .current_principal(state, &configured.name)
2920                .ok_or_else(|| {
2921                    IcpEnsurePlatformError::UnresolvedCreated(configured.name.clone())
2922                })?;
2923            let live = self.install_status_optional(principal)?.ok_or_else(|| {
2924                IcpEnsurePlatformError::RootManagement("missing infrastructure".to_string())
2925            })?;
2926            let subnet = if let Some(root) = roots.roots.get(&configured.name) {
2927                root.subnet.clone()
2928            } else if let Some(catalog) = &catalog {
2929                catalog
2930                    .catalog
2931                    .resolve_canister_route(principal)
2932                    .map(|route| route.subnet.to_text())
2933                    .map_err(|error| IcpEnsurePlatformError::RootManagement(error.to_string()))?
2934            } else {
2935                configured.subnet.clone()
2936            };
2937            authorities.insert(
2938                configured.name.clone(),
2939                RootManagementCanisterObservation {
2940                    live,
2941                    name: configured.name.clone(),
2942                    subnet,
2943                },
2944            );
2945        }
2946        Ok(Some(authorities))
2947    }
2948
2949    fn reinstall_inventory(
2950        &mut self,
2951        source_operation_id: &str,
2952        state: &FleetEnsureStateRecord,
2953    ) -> Result<Option<super::FleetReinstallObservation>, Self::Error> {
2954        let authorities = self.reinstall_authorities(state)?.ok_or_else(|| {
2955            IcpEnsurePlatformError::RootManagement("missing infrastructure".to_string())
2956        })?;
2957        let terminal = self.terminal_inventory(source_operation_id, state)?;
2958        let mut observation = self.observe(source_operation_id, state)?;
2959        for (name, observed) in &authorities {
2960            observation
2961                .canisters
2962                .insert(name.clone(), Some(observed.live.clone()));
2963        }
2964        let candid = self.root_protocol_candid()?;
2965        let candid_by_root = observation
2966            .estate_funding_domains
2967            .keys()
2968            .map(|name| (name.clone(), candid.clone()))
2969            .collect();
2970        let assets = self.inspect_reinstall_assets(
2971            state,
2972            &authorities,
2973            &mut observation,
2974            &candid_by_root,
2975            false,
2976        )?;
2977        let seen = assets
2978            .iter()
2979            .map(|asset| asset.principal.clone())
2980            .collect::<BTreeSet<_>>();
2981        let infrastructure = authorities
2982            .values()
2983            .map(|a| a.live.principal.as_str())
2984            .collect::<BTreeSet<_>>();
2985        if terminal
2986            .controlled_cycles_by_principal
2987            .keys()
2988            .any(|pid| !infrastructure.contains(pid.as_str()) && !seen.contains(pid))
2989        {
2990            return Err(IcpEnsurePlatformError::RootManagement(
2991                "terminal inventory escapes the reviewed reset estate".to_string(),
2992            ));
2993        }
2994        Ok(Some(super::FleetReinstallObservation {
2995            authorities,
2996            assets,
2997            observation,
2998        }))
2999    }
3000
3001    #[expect(
3002        clippy::too_many_lines,
3003        reason = "one source observation binds old protocol receipts, full pagination and physical management inspection"
3004    )]
3005    fn activation_reset_inventory(
3006        &mut self,
3007        source: &crate::fleet_ensure::model::FleetActivationSourceRecord,
3008        state: &FleetEnsureStateRecord,
3009    ) -> Result<Option<super::FleetActivationResetObservation>, Self::Error> {
3010        if source.operator != self.desired.operator
3011            || source.cycles_ledger != self.desired.cycles_ledger
3012        {
3013            return Err(pool_configuration_error(
3014                "source operator or Cycles Ledger differs from the requested reset".to_string(),
3015            ));
3016        }
3017        let authorities = self
3018            .reinstall_authorities(state)?
3019            .ok_or_else(|| pool_configuration_error("missing source infrastructure".to_string()))?;
3020        let mut observation = FleetObservation {
3021            additional_controlled_cycles: BTreeMap::new(),
3022            canisters: authorities
3023                .iter()
3024                .map(|(name, observed)| (name.clone(), Some(observed.live.clone())))
3025                .collect(),
3026            estate_funding_domains: BTreeMap::new(),
3027            ledger_fee_cycles: self
3028                .desired
3029                .ledger_fee_cycles
3030                .parse::<Cycles>()
3031                .map_err(|_| pool_configuration_error("invalid source fee bound".to_string()))?
3032                .to_u128(),
3033            operator_cycles: 0,
3034            protocol_ready: BTreeMap::new(),
3035        };
3036        let mut roots = Vec::new();
3037        let mut candid_by_root = BTreeMap::new();
3038        for configured in &self.desired.canisters {
3039            if configured.kind != DesiredCanisterKind::Root {
3040                continue;
3041            }
3042            let live = &authorities
3043                .get(&configured.name)
3044                .ok_or_else(|| pool_configuration_error("missing source Root".to_string()))?
3045                .live;
3046            let root = parse_principal("source Root", &live.principal)?;
3047            let evidence = current_protocol::observe_inactive_activation(
3048                &self.icp,
3049                &self.root,
3050                source,
3051                &configured.name,
3052                root,
3053            )?;
3054            let candid = source
3055                .registry_preparations
3056                .iter()
3057                .find_map(|action| match action {
3058                    EnsureAction::FleetProtocol {
3059                        principal, candid, ..
3060                    } if principal == &live.principal => Some(self.root.join(candid)),
3061                    _ => None,
3062                })
3063                .ok_or_else(|| {
3064                    pool_configuration_error("missing source Root read contract".to_string())
3065                })?;
3066            let mut inventory = EstatePoolInventoryAccumulator::default();
3067            let mut cursor = None;
3068            loop {
3069                let page = self.query_estate_pool_page(&candid, root, cursor)?;
3070                if !inactive_source_pool_has_full_inventory(&page, &evidence) {
3071                    return Err(pool_configuration_error(
3072                        "source pool has unsettled work or spare creation capacity".to_string(),
3073                    ));
3074                }
3075                let next = inventory.observe_page(&configured.name, page)?;
3076                if next.is_none() {
3077                    break;
3078                }
3079                if next == cursor {
3080                    return Err(pool_configuration_error(
3081                        "source pool cursor did not advance".to_string(),
3082                    ));
3083                }
3084                cursor = next;
3085            }
3086            observation.estate_funding_domains.insert(
3087                configured.name.clone(),
3088                EstateFundingDomainObservation {
3089                    balance_cycles: Some(self.cycles_ledger_balance(&live.principal)?),
3090                    cycles_ledger: self.desired.cycles_ledger.clone(),
3091                    pool: Some(inventory.finish(&configured.name)?),
3092                    root_principal: Some(live.principal.clone()),
3093                },
3094            );
3095            candid_by_root.insert(configured.name.clone(), candid);
3096            roots.push(evidence);
3097        }
3098        if roots.is_empty() || roots.len() != source.registry_preparations.len() {
3099            return Err(pool_configuration_error(
3100                "source Root inventory is incomplete".to_string(),
3101            ));
3102        }
3103        let assets = self.inspect_reinstall_assets(
3104            state,
3105            &authorities,
3106            &mut observation,
3107            &candid_by_root,
3108            false,
3109        )?;
3110        Ok(Some(super::FleetActivationResetObservation {
3111            inventory: super::FleetReinstallObservation {
3112                authorities,
3113                assets,
3114                observation,
3115            },
3116            roots,
3117        }))
3118    }
3119
3120    fn activation_reset_inventory_after_reset(
3121        &mut self,
3122        intent: &crate::fleet_ensure::model::FleetReinstallRecord,
3123        state: &FleetEnsureStateRecord,
3124    ) -> Result<Option<super::FleetReinstallObservation>, Self::Error> {
3125        let authorities = self
3126            .reinstall_authorities(state)?
3127            .ok_or_else(|| pool_configuration_error("missing reset infrastructure".to_string()))?;
3128        let mut observation = FleetObservation {
3129            canisters: authorities
3130                .iter()
3131                .map(|(name, entry)| (name.clone(), Some(entry.live.clone())))
3132                .collect(),
3133            additional_controlled_cycles: BTreeMap::new(),
3134            estate_funding_domains: BTreeMap::new(),
3135            ledger_fee_cycles: self
3136                .desired
3137                .ledger_fee_cycles
3138                .parse::<Cycles>()
3139                .map_err(|_| pool_configuration_error("invalid reset Ledger fee".to_string()))?
3140                .to_u128(),
3141            operator_cycles: 0,
3142            protocol_ready: BTreeMap::new(),
3143        };
3144        let candid = self.root_protocol_candid()?;
3145        let mut candid_by_root = BTreeMap::new();
3146        for configured in &self.desired.canisters {
3147            if configured.kind != DesiredCanisterKind::Root {
3148                continue;
3149            }
3150            let binding = authorities
3151                .get(&configured.name)
3152                .ok_or_else(|| pool_configuration_error("missing reset Root".to_string()))?;
3153            let root = parse_principal("reset Root", &binding.live.principal)?;
3154            let mut inventory = EstatePoolInventoryAccumulator::default();
3155            let mut cursor = None;
3156            loop {
3157                let page = self.query_estate_pool_page(&candid, root, cursor)?;
3158                let next = inventory.observe_page(&configured.name, page)?;
3159                if next.is_none() {
3160                    break;
3161                }
3162                if next == cursor {
3163                    return Err(pool_configuration_error(
3164                        "reset inventory cursor did not advance".to_string(),
3165                    ));
3166                }
3167                cursor = next;
3168            }
3169            let pool = inventory.finish(&configured.name)?;
3170            let expected = intent
3171                .assets
3172                .iter()
3173                .filter(|asset| asset.root == configured.name)
3174                .map(|asset| asset.principal.as_str())
3175                .collect::<BTreeSet<_>>();
3176            let observed = pool
3177                .assets
3178                .iter()
3179                .map(|asset| asset.principal.as_str())
3180                .collect::<BTreeSet<_>>();
3181            if observed != expected || pool.pending_creation.is_some() {
3182                return Err(pool_configuration_error(
3183                    "reset changed the complete physical estate".to_string(),
3184                ));
3185            }
3186            observation.estate_funding_domains.insert(
3187                configured.name.clone(),
3188                EstateFundingDomainObservation {
3189                    balance_cycles: Some(self.cycles_ledger_balance(&binding.live.principal)?),
3190                    cycles_ledger: self.desired.cycles_ledger.clone(),
3191                    pool: Some(pool),
3192                    root_principal: Some(binding.live.principal.clone()),
3193                },
3194            );
3195            candid_by_root.insert(configured.name.clone(), candid.clone());
3196        }
3197        let assets = self.inspect_reinstall_assets(
3198            state,
3199            &authorities,
3200            &mut observation,
3201            &candid_by_root,
3202            true,
3203        )?;
3204        Ok(Some(super::FleetReinstallObservation {
3205            authorities,
3206            assets,
3207            observation,
3208        }))
3209    }
3210
3211    fn observe_root_management(
3212        &mut self,
3213        state: &FleetEnsureStateRecord,
3214        reviewed_targets: &BTreeSet<String>,
3215    ) -> Result<Option<RootManagementObservation>, Self::Error> {
3216        self.timed_observation(FleetObservationStage::RootManagement, |platform| {
3217            let configured_roots = platform
3218                .desired
3219                .canisters
3220                .iter()
3221                .filter(|configured| {
3222                    configured.kind == DesiredCanisterKind::Root
3223                        && configured.presence
3224                            == crate::fleet_ensure::model::DesiredPresence::Present
3225                })
3226                .collect::<Vec<_>>();
3227            if configured_roots.is_empty() {
3228                return Ok(None);
3229            }
3230            platform.require_operator()?;
3231            let mut observed_roots = Vec::new();
3232            for configured in configured_roots {
3233                let Some(principal) = platform.current_principal(state, &configured.name) else {
3234                    if is_unallocated_fresh_root(&platform.desired, configured, reviewed_targets) {
3235                        continue;
3236                    }
3237                    return Err(IcpEnsurePlatformError::RootManagement(format!(
3238                        "configured Root {} has no exact Principal",
3239                        configured.name
3240                    )));
3241                };
3242                let live = platform.status_optional(principal)?.ok_or_else(|| {
3243                    IcpEnsurePlatformError::RootManagement(format!(
3244                        "configured Root {} is unavailable",
3245                        configured.name
3246                    ))
3247                })?;
3248                observed_roots.push((configured, live));
3249            }
3250            let all_current =
3251                platform.management_roots_are_current(&observed_roots, reviewed_targets)?;
3252            if reviewed_targets.is_empty() && all_current {
3253                return Ok(None);
3254            }
3255            let network =
3256                resolve_icp_build_network_from_root(&platform.root, &platform.desired.environment)
3257                    .map_err(|error| IcpEnsurePlatformError::RootManagement(error.to_string()))?;
3258            let catalog = if network == BuildNetwork::Ic {
3259                let now = SystemTime::now()
3260                    .duration_since(UNIX_EPOCH)
3261                    .map_err(|_| {
3262                        IcpEnsurePlatformError::RootManagement(
3263                            "system clock is before the Unix epoch".to_string(),
3264                        )
3265                    })?
3266                    .as_secs();
3267                Some(
3268                    load_mainnet_subnet_catalog(&platform.root, now).map_err(|error| {
3269                        IcpEnsurePlatformError::RootManagement(error.to_string())
3270                    })?,
3271                )
3272            } else {
3273                None
3274            };
3275            let mut roots = BTreeMap::new();
3276            for (configured, live) in observed_roots {
3277                let principal = live.principal.clone();
3278                let subnet = catalog.as_ref().map_or_else(
3279                    || Ok(configured.subnet.clone()),
3280                    |catalog| {
3281                        catalog
3282                            .catalog
3283                            .resolve_canister_route(&principal)
3284                            .map(|route| route.subnet.to_text())
3285                            .map_err(|error| {
3286                                IcpEnsurePlatformError::RootManagement(error.to_string())
3287                            })
3288                    },
3289                )?;
3290                let name = configured.name.clone();
3291                if roots
3292                    .insert(
3293                        name.clone(),
3294                        RootManagementCanisterObservation { live, name, subnet },
3295                    )
3296                    .is_some()
3297                {
3298                    return Err(IcpEnsurePlatformError::RootManagement(
3299                        "configured Root names are not unique".to_string(),
3300                    ));
3301                }
3302            }
3303            let operator_cycles = platform
3304                .icp
3305                .identity_cycles_balance()
3306                .map_err(|error| IcpEnsurePlatformError::LedgerWithdraw(error.to_string()))?;
3307            Ok(Some(RootManagementObservation {
3308                operator_cycles,
3309                roots,
3310            }))
3311        })
3312    }
3313
3314    fn observe(
3315        &mut self,
3316        operation_id: &str,
3317        state: &FleetEnsureStateRecord,
3318    ) -> Result<FleetObservation, Self::Error> {
3319        self.with_observation_snapshot(|platform| {
3320            platform.observe_fleet_snapshot(operation_id, state)
3321        })
3322    }
3323
3324    fn protocol_actions(
3325        &mut self,
3326        operation_id: &str,
3327        state: &FleetEnsureStateRecord,
3328    ) -> Result<Vec<EnsureAction>, Self::Error> {
3329        self.timed_observation(FleetObservationStage::ProtocolActions, |platform| {
3330            if platform.desired.protocol.is_none() {
3331                return Ok(Vec::new());
3332            }
3333            current_protocol::validate_component_pool_capacity(&platform.root, &platform.desired)?;
3334            if !platform.current_protocol_owners_are_ready(state)? {
3335                return Ok(Vec::new());
3336            }
3337            let reconciliation = current_protocol::compile_pool_reconciliation(
3338                &platform.root,
3339                &platform.desired,
3340                state,
3341                &platform.estate_observations,
3342            )?;
3343            if !reconciliation.is_empty() {
3344                return Ok(reconciliation);
3345            }
3346            current_protocol::compile(
3347                &platform.icp,
3348                &platform.root,
3349                &platform.desired,
3350                operation_id,
3351                state,
3352            )
3353            .map_err(Into::into)
3354        })
3355    }
3356
3357    fn fresh_protocol_actions(
3358        &mut self,
3359        operation_id: &str,
3360        state: &FleetEnsureStateRecord,
3361    ) -> Result<Vec<EnsureAction>, Self::Error> {
3362        current_protocol::compile_fresh_protocol(&self.root, &self.desired, state, operation_id)
3363            .map_err(Into::into)
3364    }
3365
3366    fn terminal_inventory(
3367        &mut self,
3368        operation_id: &str,
3369        state: &FleetEnsureStateRecord,
3370    ) -> Result<TerminalFleetInventory, Self::Error> {
3371        self.timed_observation(FleetObservationStage::TerminalInventory, |platform| {
3372            if platform.desired.protocol.is_none() {
3373                if state.active_registry.is_some() {
3374                    return Err(current_protocol::CurrentProtocolError::Configuration(
3375                        "managed terminal Fleet cannot drop its typed protocol intent".to_string(),
3376                    )
3377                    .into());
3378                }
3379                return Ok(TerminalFleetInventory::default());
3380            }
3381            super::current_inventory::terminal_inventory(
3382                &platform.icp,
3383                &platform.root,
3384                &platform.desired,
3385                operation_id,
3386                state,
3387            )
3388            .map_err(Into::into)
3389        })
3390    }
3391
3392    #[expect(
3393        clippy::too_many_lines,
3394        reason = "the platform keeps every effect's exact live terminal predicate in one exhaustive match"
3395    )]
3396    fn observe_effect(
3397        &mut self,
3398        operation_id: &str,
3399        action: &EnsureAction,
3400        record: &EffectRecord,
3401        state: &FleetEnsureStateRecord,
3402    ) -> Result<EffectObservation, Self::Error> {
3403        if matches!(action, EnsureAction::SealAuthority { .. }) {
3404            return super::authority_seal::observe(&self.icp, &self.root, operation_id, action)
3405                .map_err(Into::into);
3406        }
3407        let mut retry = EffectRetry::None;
3408        let mut post_cycles = None;
3409        let (applied, progress_identity) = match action {
3410            EnsureAction::SealAuthority { .. } => {
3411                unreachable!("authority seals use their typed observer")
3412            }
3413            EnsureAction::Create {
3414                name,
3415                requested_initial_cycles,
3416                ..
3417            } => {
3418                let (live_cycles, deferred) =
3419                    if record.created_principal.is_some() && record.receipt.is_some() {
3420                        self.created_canister_cycles(
3421                            name,
3422                            record
3423                                .created_principal
3424                                .as_deref()
3425                                .ok_or(IcpEnsurePlatformError::LedgerCreatePending)?,
3426                            state,
3427                        )?
3428                    } else {
3429                        (None, false)
3430                    };
3431                post_cycles = live_cycles;
3432                let maximum_observation_burn_cycles =
3433                    super::maximum_creation_observation_burn(&self.desired).ok_or(
3434                        IcpEnsurePlatformError::Arithmetic("Create execution and observation burn"),
3435                    )?;
3436                let applied = create_balance_is_terminal(
3437                    live_cycles,
3438                    *requested_initial_cycles,
3439                    maximum_observation_burn_cycles,
3440                );
3441                if live_cycles.is_some() && !applied {
3442                    retry = EffectRetry::ReplanRequiredAfterCreateBalanceDrift;
3443                } else if deferred {
3444                    retry = EffectRetry::DeferUntilControllerObservation;
3445                }
3446                (
3447                    applied,
3448                    format!(
3449                        "created:{:?}:actual:{live_cycles:?}:requested:{requested_initial_cycles}",
3450                        record.created_principal,
3451                    ),
3452                )
3453            }
3454            EnsureAction::Delete { principal, .. } => {
3455                let live = self.status_optional(Self::action_principal(state, principal)?)?;
3456                (live.is_none(), format!("delete:{live:?}"))
3457            }
3458            EnsureAction::Fund {
3459                amount,
3460                expected_post_cycles,
3461                funding_deficit_cycles,
3462                funding_margin_cycles,
3463                ..
3464            } => {
3465                let live_cycles = self.action_cycles(action, state)?;
3466                post_cycles = live_cycles;
3467                let observation = NativeFundingObservation {
3468                    amount: *amount,
3469                    expected_post_cycles: *expected_post_cycles,
3470                    funding_deficit_cycles: *funding_deficit_cycles,
3471                    funding_margin_cycles: *funding_margin_cycles,
3472                    live_cycles,
3473                    pre_cycles: record.pre_cycles,
3474                };
3475                (
3476                    native_funding_completion(observation, record.receipt.is_some())?,
3477                    format!(
3478                        "native-topup:ledger-withdraw:{}:actual:{live_cycles:?}:expected:{expected_post_cycles}:margin:{funding_margin_cycles}",
3479                        record.receipt.as_deref().unwrap_or("pending"),
3480                    ),
3481                )
3482            }
3483            EnsureAction::FundEstate {
3484                amount,
3485                expected_post_cycles,
3486                ledger_fee_cycles,
3487                principal,
3488                ..
3489            } => {
3490                let source_cycles = self
3491                    .icp
3492                    .identity_cycles_balance()
3493                    .map_err(|error| IcpEnsurePlatformError::LedgerTransfer(error.to_string()))?;
3494                let target = Self::action_principal(state, principal)?;
3495                let destination_cycles = self.cycles_ledger_balance(target)?;
3496                post_cycles = Some(source_cycles);
3497                (
3498                    record.receipt.is_some()
3499                        && estate_funding_applied(EstateFundingObservation {
3500                            amount: *amount,
3501                            destination_after: destination_cycles,
3502                            destination_before: record.destination_pre_cycles,
3503                            expected_destination_after: *expected_post_cycles,
3504                            ledger_fee_cycles: *ledger_fee_cycles,
3505                            source_after: source_cycles,
3506                            source_before: record.pre_cycles,
3507                        }),
3508                    format!(
3509                        "estate-funding:ledger-transfer:{}:source:{source_cycles}:destination:{destination_cycles}:expected:{expected_post_cycles}",
3510                        record.receipt.as_deref().unwrap_or("pending"),
3511                    ),
3512                )
3513            }
3514            EnsureAction::Install {
3515                mode,
3516                reinstall_witness,
3517                principal,
3518                wasm_sha256,
3519                ..
3520            } => {
3521                let live =
3522                    self.install_status_optional(Self::action_principal(state, principal)?)?;
3523                let applied = if let Some(witness) = reinstall_witness {
3524                    let current = self.reinstall_authorities(state)?;
3525                    let authority = current
3526                        .as_ref()
3527                        .and_then(|a| a.get(&witness.authority.name))
3528                        .and_then(super::reinstall::authority_binding);
3529                    if authority.as_ref() != Some(&witness.authority) {
3530                        return Err(IcpEnsurePlatformError::ReinstallHistoryConflict {
3531                            canister: witness.authority.principal.clone(),
3532                        });
3533                    }
3534                    let live = live.as_ref().ok_or_else(|| {
3535                        IcpEnsurePlatformError::ReinstallHistoryConflict {
3536                            canister: principal.clone(),
3537                        }
3538                    })?;
3539                    let before = record.pre_canister_version.ok_or_else(|| {
3540                        IcpEnsurePlatformError::ReinstallHistoryConflict {
3541                            canister: principal.clone(),
3542                        }
3543                    })?;
3544                    let history = super::install_history::observe(
3545                        &self.icp,
3546                        &self.root,
3547                        witness,
3548                        parse_principal("operator", &self.desired.operator)?,
3549                        before,
3550                        live,
3551                    )?;
3552                    match super::install_history::reconcile(
3553                        history,
3554                        wasm_sha256,
3555                        &witness.prior_module_sha256,
3556                        before,
3557                        live,
3558                    ) {
3559                        super::install_history::HistoryEffect::Applied => true,
3560                        super::install_history::HistoryEffect::NotApplied => false,
3561                        super::install_history::HistoryEffect::Conflict => {
3562                            return Err(IcpEnsurePlatformError::ReinstallHistoryConflict {
3563                                canister: live.principal.clone(),
3564                            });
3565                        }
3566                    }
3567                } else {
3568                    live.as_ref().is_some_and(|live| {
3569                        install_effect_applied(
3570                            *mode,
3571                            wasm_sha256,
3572                            live.module_sha256.as_deref(),
3573                            record.pre_canister_version,
3574                            live.canister_version,
3575                        )
3576                    })
3577                };
3578                let progress_identity = live.as_ref().map_or_else(
3579                    || "install:missing".to_string(),
3580                    |live| {
3581                        format!(
3582                            "install:{:?}:version:{:?}",
3583                            live.module_sha256, live.canister_version
3584                        )
3585                    },
3586                );
3587                (applied, progress_identity)
3588            }
3589            EnsureAction::FleetProtocol {
3590                action: current_action,
3591                ..
3592            } => {
3593                let mut observation = match current_protocol::observe(&self.icp, &self.root, action)
3594                {
3595                    Ok(observation) => observation,
3596                    Err(error)
3597                        if matches!(
3598                            current_action.as_ref(),
3599                            crate::fleet_ensure::model::CurrentFleetProtocolAction::AdoptStore { .. }
3600                        ) && recoverable_current_protocol_error(&error) =>
3601                    {
3602                        EffectObservation {
3603                            provisioning_failure: None,
3604                            applied: false,
3605                            estate_funding_required: None,
3606                            post_cycles: None,
3607                            progress_identity: "store-adoption:protected-status-unavailable"
3608                                .to_string(),
3609                            retry: EffectRetry::None,
3610                        }
3611                    }
3612                    Err(error) => return Err(error.into()),
3613                };
3614                self.refresh_estate_funding_observation(&mut observation, state)?;
3615                return Ok(observation);
3616            }
3617            EnsureAction::Protocol { .. } => {
3618                let observation = protocol::observe(
3619                    &self.icp,
3620                    &self.root,
3621                    operation_id,
3622                    &self.protocol_principals(state),
3623                    &resolved_protocol_action(action, state)?,
3624                )?;
3625                (observation.applied, observation.progress_identity)
3626            }
3627            EnsureAction::SetControllers {
3628                controller_canisters,
3629                controllers,
3630                name,
3631                principal,
3632                ..
3633            } => {
3634                let expected =
3635                    self.resolved_controllers(state, controllers, controller_canisters)?;
3636                let principal = Self::action_principal(state, principal)?;
3637                let configured = self
3638                    .desired
3639                    .canisters
3640                    .iter()
3641                    .find(|configured| configured.name == *name);
3642                let mut observed_controllers = if let Some(configured) =
3643                    configured.filter(|configured| configured.kind == DesiredCanisterKind::Pool)
3644                {
3645                    self.inspect_root_owned_canister(configured, principal, state)?
3646                        .map(|response| {
3647                            response
3648                                .settings
3649                                .controllers
3650                                .into_iter()
3651                                .map(|controller| controller.to_text())
3652                                .collect::<Vec<_>>()
3653                        })
3654                } else {
3655                    self.status_optional(principal)?
3656                        .map(|live| live.controllers)
3657                };
3658                if let Some(controllers) = &mut observed_controllers {
3659                    controllers.sort();
3660                    controllers.dedup();
3661                }
3662                (
3663                    observed_controllers.as_ref() == Some(&expected),
3664                    format!("controllers:{observed_controllers:?}"),
3665                )
3666            }
3667            EnsureAction::Start { principal, .. } => {
3668                let live = self.status_optional(Self::action_principal(state, principal)?)?;
3669                (
3670                    live.as_ref()
3671                        .is_some_and(|live| live.status == CanisterRuntimeStatus::Running),
3672                    format!("start:{:?}", live.map(|live| live.status)),
3673                )
3674            }
3675            EnsureAction::Stop { principal, .. } => {
3676                let live = self.status_optional(Self::action_principal(state, principal)?)?;
3677                (
3678                    live.as_ref()
3679                        .is_some_and(|live| live.status == CanisterRuntimeStatus::Stopped),
3680                    format!("stop:{:?}", live.map(|live| live.status)),
3681                )
3682            }
3683            EnsureAction::Transfer {
3684                amount,
3685                maximum_execution_burn_cycles,
3686                name,
3687                principal,
3688                destination,
3689                ..
3690            } => {
3691                let source = self
3692                    .status_optional(Self::action_principal(state, principal)?)?
3693                    .map(|live| live.cycles)
3694                    .ok_or_else(|| IcpEnsurePlatformError::MissingTransferBalance {
3695                        canister: name.clone(),
3696                        side: "live source",
3697                    })?;
3698                let destination = self
3699                    .status_optional(self.current_principal(state, destination).ok_or_else(
3700                        || IcpEnsurePlatformError::UnresolvedCreated(destination.clone()),
3701                    )?)?
3702                    .map(|live| live.cycles)
3703                    .ok_or_else(|| IcpEnsurePlatformError::MissingTransferBalance {
3704                        canister: name.clone(),
3705                        side: "live treasury",
3706                    })?;
3707                let source_pre = record.pre_cycles.ok_or_else(|| {
3708                    IcpEnsurePlatformError::MissingTransferBalance {
3709                        canister: name.clone(),
3710                        side: "retained source",
3711                    }
3712                })?;
3713                let destination_pre = record.destination_pre_cycles.ok_or_else(|| {
3714                    IcpEnsurePlatformError::MissingTransferBalance {
3715                        canister: name.clone(),
3716                        side: "retained treasury",
3717                    }
3718                })?;
3719                let reconciliation = reconcile_retirement_transfer(RetirementTransferBalances {
3720                    destination_after: destination,
3721                    destination_before: destination_pre,
3722                    maximum_execution_burn: *maximum_execution_burn_cycles,
3723                    source_after: source,
3724                    source_before: source_pre,
3725                    transfer_amount: *amount,
3726                });
3727                let (applied, source_debit, destination_credit) = match reconciliation {
3728                    Ok(RetirementTransferReconciliation::Pending) => (false, 0, 0),
3729                    Ok(RetirementTransferReconciliation::Conserved {
3730                        destination_credit,
3731                        source_debit,
3732                    }) => (true, source_debit, destination_credit),
3733                    Err(RetirementTransferInvariantError::ArithmeticOverflow) => {
3734                        return Err(IcpEnsurePlatformError::Arithmetic(
3735                            "retirement transfer debit",
3736                        ));
3737                    }
3738                    Err(RetirementTransferInvariantError::BalanceMovedInInvalidDirection) => {
3739                        return Err(IcpEnsurePlatformError::InvalidTransferBalance {
3740                            canister: name.clone(),
3741                        });
3742                    }
3743                    Err(RetirementTransferInvariantError::Unbalanced {
3744                        destination_credit,
3745                        maximum_source_debit,
3746                        source_debit,
3747                    }) => {
3748                        return Err(IcpEnsurePlatformError::UnbalancedTransfer {
3749                            canister: name.clone(),
3750                            destination_credit,
3751                            expected: *amount,
3752                            maximum_source_debit,
3753                            source_debit,
3754                        });
3755                    }
3756                };
3757                (
3758                    applied,
3759                    format!("transfer:{source_debit}:{destination_credit}"),
3760                )
3761            }
3762        };
3763        Ok(EffectObservation {
3764            provisioning_failure: None,
3765            applied,
3766            estate_funding_required: None,
3767            post_cycles,
3768            progress_identity,
3769            retry,
3770        })
3771    }
3772
3773    #[expect(
3774        clippy::too_many_lines,
3775        reason = "the platform keeps every approved single-step effect in one exhaustive match"
3776    )]
3777    fn apply(
3778        &mut self,
3779        operation_id: &str,
3780        action: &EnsureAction,
3781        _record: &EffectRecord,
3782        state: &FleetEnsureStateRecord,
3783    ) -> Result<EffectOutcome, Self::Error> {
3784        self.require_operator()?;
3785        match action {
3786            EnsureAction::SealAuthority { .. } => {
3787                super::authority_seal::apply(&self.icp, &self.root, operation_id, action)
3788                    .map_err(Into::into)
3789            }
3790            EnsureAction::Create {
3791                controller_canisters,
3792                controllers,
3793                created_at_time,
3794                ledger,
3795                requested_initial_cycles,
3796                subnet,
3797                ..
3798            } => self.apply_create(
3799                CreateCanisterAuthority {
3800                    controller_canisters,
3801                    controllers,
3802                    created_at_time: *created_at_time,
3803                    ledger,
3804                    requested_initial_cycles: *requested_initial_cycles,
3805                    subnet,
3806                },
3807                state,
3808            ),
3809            EnsureAction::Delete {
3810                maximum_remaining_cycles,
3811                principal,
3812                ..
3813            } => {
3814                let principal = Self::action_principal(state, principal)?;
3815                if let Some(live) = self.status_optional(principal)? {
3816                    if live.cycles > *maximum_remaining_cycles {
3817                        return Err(IcpEnsurePlatformError::MaterialCycles {
3818                            actual: live.cycles,
3819                            canister: principal.to_string(),
3820                            maximum: *maximum_remaining_cycles,
3821                        });
3822                    }
3823                    if live.status != CanisterRuntimeStatus::Stopped {
3824                        return Err(IcpEnsurePlatformError::NotStopped {
3825                            canister: principal.to_string(),
3826                        });
3827                    }
3828                    self.icp.delete_canister_without_cycle_recovery(principal)?;
3829                }
3830                Ok(empty_outcome())
3831            }
3832            EnsureAction::Fund {
3833                amount,
3834                created_at_time,
3835                ledger,
3836                principal,
3837                ..
3838            } => self.apply_fund(
3839                *amount,
3840                *created_at_time,
3841                ledger,
3842                Self::action_principal(state, principal)?,
3843            ),
3844            EnsureAction::FundEstate {
3845                amount,
3846                created_at_time,
3847                ledger,
3848                ledger_fee_cycles,
3849                principal,
3850                ..
3851            } => self.apply_estate_fund(
3852                *amount,
3853                *created_at_time,
3854                ledger,
3855                Self::action_principal(state, principal)?,
3856                *ledger_fee_cycles,
3857            ),
3858            EnsureAction::Install {
3859                canic_init,
3860                init_arg,
3861                init_arg_sha256,
3862                init_candid,
3863                init_candid_sha256,
3864                mode,
3865                principal,
3866                wasm,
3867                wasm_sha256,
3868                ..
3869            } => self.apply_install(
3870                operation_id,
3871                &self.protocol_principals(state),
3872                canic_init.as_ref(),
3873                init_arg.as_deref(),
3874                init_arg_sha256.as_deref(),
3875                init_candid.as_deref(),
3876                init_candid_sha256.as_deref(),
3877                *mode,
3878                Self::action_principal(state, principal)?,
3879                wasm,
3880                wasm_sha256,
3881            ),
3882            EnsureAction::FleetProtocol { .. } => {
3883                current_protocol::apply(&self.icp, &self.root, action).map_err(Into::into)
3884            }
3885            EnsureAction::Protocol { .. } => {
3886                let action = resolved_protocol_action(action, state)?;
3887                protocol::apply(
3888                    &self.icp,
3889                    &self.root,
3890                    operation_id,
3891                    &self.protocol_principals(state),
3892                    &action,
3893                )?;
3894                Ok(EffectOutcome {
3895                    created_principal: None,
3896                    post_cycles: None,
3897                    receipt: Some(operation_id.to_string()),
3898                })
3899            }
3900            EnsureAction::SetControllers {
3901                controller_canisters,
3902                controllers,
3903                principal,
3904                ..
3905            } => self.apply_controllers(
3906                Self::action_principal(state, principal)?,
3907                &self.resolved_controllers(state, controllers, controller_canisters)?,
3908            ),
3909            EnsureAction::Start { principal, .. } => {
3910                self.icp
3911                    .start_canister(Self::action_principal(state, principal)?)?;
3912                Ok(empty_outcome())
3913            }
3914            EnsureAction::Stop { principal, .. } => {
3915                self.icp
3916                    .stop_canister(Self::action_principal(state, principal)?)?;
3917                Ok(empty_outcome())
3918            }
3919            EnsureAction::Transfer {
3920                amount,
3921                candid,
3922                candid_sha256,
3923                destination,
3924                method,
3925                principal,
3926                ..
3927            } => self.apply_transfer(
3928                *amount,
3929                candid,
3930                candid_sha256,
3931                self.current_principal(state, destination).ok_or_else(|| {
3932                    IcpEnsurePlatformError::UnresolvedCreated(destination.clone())
3933                })?,
3934                method,
3935                operation_id,
3936                Self::action_principal(state, principal)?,
3937            ),
3938        }
3939    }
3940
3941    fn action_cycles(
3942        &mut self,
3943        action: &EnsureAction,
3944        state: &FleetEnsureStateRecord,
3945    ) -> Result<Option<u128>, Self::Error> {
3946        if matches!(action, EnsureAction::FundEstate { .. }) {
3947            return self
3948                .icp
3949                .identity_cycles_balance()
3950                .map(Some)
3951                .map_err(|error| IcpEnsurePlatformError::LedgerTransfer(error.to_string()));
3952        }
3953        if let EnsureAction::Fund {
3954            pool_funding: Some(authority),
3955            name,
3956            principal,
3957            ..
3958        } = action
3959        {
3960            let principal = Self::action_principal(state, principal)?;
3961            let module = self.pool_funding_module(name, authority, principal)?;
3962            return self
3963                .inspect_pool_balance(name, &authority.root, principal, module)
3964                .map(Some);
3965        }
3966        let (name, principal) = match action {
3967            EnsureAction::Create { .. } => return Ok(None),
3968            EnsureAction::SealAuthority {
3969                name, principal, ..
3970            }
3971            | EnsureAction::Delete {
3972                name, principal, ..
3973            }
3974            | EnsureAction::FleetProtocol {
3975                name, principal, ..
3976            }
3977            | EnsureAction::Fund {
3978                name, principal, ..
3979            }
3980            | EnsureAction::FundEstate {
3981                name, principal, ..
3982            }
3983            | EnsureAction::Install {
3984                name, principal, ..
3985            }
3986            | EnsureAction::Protocol {
3987                name, principal, ..
3988            }
3989            | EnsureAction::SetControllers {
3990                name, principal, ..
3991            }
3992            | EnsureAction::Start {
3993                name, principal, ..
3994            }
3995            | EnsureAction::Stop {
3996                name, principal, ..
3997            }
3998            | EnsureAction::Transfer {
3999                name, principal, ..
4000            } => (name, Self::action_principal(state, principal)?),
4001        };
4002        let observed = self
4003            .desired
4004            .canisters
4005            .iter()
4006            .find(|configured| configured.name == *name)
4007            .map_or_else(
4008                || self.status_optional(principal),
4009                |configured| self.observe_configured_canister(configured, principal, state),
4010            )?;
4011        Ok(observed.map(|live| live.cycles))
4012    }
4013
4014    fn action_destination_cycles(
4015        &mut self,
4016        action: &EnsureAction,
4017        state: &FleetEnsureStateRecord,
4018    ) -> Result<Option<u128>, Self::Error> {
4019        if let EnsureAction::FundEstate { principal, .. } = action {
4020            let owner = Self::action_principal(state, principal)?;
4021            return self.cycles_ledger_balance(owner).map(Some);
4022        }
4023        let EnsureAction::Transfer { destination, .. } = action else {
4024            return Ok(None);
4025        };
4026        let destination = self
4027            .current_principal(state, destination)
4028            .ok_or_else(|| IcpEnsurePlatformError::UnresolvedCreated(destination.clone()))?;
4029        Ok(self.status_optional(destination)?.map(|live| live.cycles))
4030    }
4031
4032    fn action_canister_version(
4033        &mut self,
4034        action: &EnsureAction,
4035        state: &FleetEnsureStateRecord,
4036    ) -> Result<Option<u64>, Self::Error> {
4037        let EnsureAction::Install { principal, .. } = action else {
4038            return Ok(None);
4039        };
4040        let principal = Self::action_principal(state, principal)?;
4041        let version = self
4042            .install_status_optional(principal)?
4043            .and_then(|live| live.canister_version)
4044            .ok_or_else(|| IcpEnsurePlatformError::IncompleteCanisterStatus {
4045                canister: principal.to_string(),
4046                field: "canister_version",
4047            })?;
4048        Ok(Some(version))
4049    }
4050}
4051
4052fn exact_install_canister_status(
4053    icp: &IcpCli,
4054    canister: &str,
4055    projected_canister_version: Option<u64>,
4056    projected_module_sha256: Option<String>,
4057) -> Result<ExactInstallCanisterStatus, IcpEnsurePlatformError> {
4058    exact_install_canister_status_with(
4059        canister,
4060        projected_canister_version,
4061        projected_module_sha256,
4062        |canister_id| {
4063            icp.management_canister_status_candid::<_, ManagementCanisterStatusResponse>(
4064                canister_id,
4065                &ManagementCanisterStatusRequest { canister_id },
4066            )
4067        },
4068    )
4069}
4070
4071fn exact_install_canister_status_with(
4072    canister: &str,
4073    projected_canister_version: Option<u64>,
4074    projected_module_sha256: Option<String>,
4075    management_status: impl FnOnce(
4076        Principal,
4077    )
4078        -> Result<ManagementCanisterStatusResponse, IcpManagementCallError>,
4079) -> Result<ExactInstallCanisterStatus, IcpEnsurePlatformError> {
4080    if let Some(canister_version) = projected_canister_version {
4081        return Ok(ExactInstallCanisterStatus {
4082            canister_version,
4083            module_sha256: projected_module_sha256,
4084        });
4085    }
4086    let canister_id = parse_principal("install target", canister)?;
4087    let response = management_status(canister_id).map_err(|source| {
4088        IcpEnsurePlatformError::InstallVersionProofUnavailable {
4089            canister: canister.to_string(),
4090            source: Box::new(source),
4091        }
4092    })?;
4093    Ok(ExactInstallCanisterStatus {
4094        canister_version: response.version,
4095        module_sha256: response
4096            .module_hash
4097            .map(|hash| canic_core::cdk::utils::hash::hex_bytes(&hash)),
4098    })
4099}
4100
4101fn exact_retained_root_owned_topology<'a>(
4102    state: &'a FleetEnsureStateRecord,
4103    name: &str,
4104    kind: DesiredCanisterKind,
4105    parent: &str,
4106    principal: &str,
4107    root: &str,
4108) -> Option<&'a crate::fleet_ensure::model::FleetEnsureTopologyRecord> {
4109    let child_identity_matches = retained_state_principal_matches(state, name, principal);
4110    let root_identity_matches = retained_state_principal_matches(state, parent, root);
4111    let topology = state
4112        .topology
4113        .get(name)
4114        .filter(|topology| topology.kind == kind && topology.parent.as_deref() == Some(parent));
4115    (child_identity_matches && root_identity_matches)
4116        .then_some(topology)
4117        .flatten()
4118}
4119
4120fn retained_state_principal_matches(
4121    state: &FleetEnsureStateRecord,
4122    name: &str,
4123    principal: &str,
4124) -> bool {
4125    let pending = state.pending_principals.get(name).map(String::as_str);
4126    let terminal = state.principals.get(name).map(String::as_str);
4127    let retained = pending.is_some() || terminal.is_some();
4128    retained
4129        && pending.is_none_or(|retained| retained == principal)
4130        && terminal.is_none_or(|retained| retained == principal)
4131}
4132
4133fn completed_reinstall_continuity(
4134    pre_canister_version: u64,
4135    principal_matches: bool,
4136    topology_matches: bool,
4137    live_module_sha256: Option<&str>,
4138    desired_module_sha256: &str,
4139    live_canister_version: Option<u64>,
4140) -> Result<(), &'static str> {
4141    if !principal_matches {
4142        return Err("Principal");
4143    }
4144    if !topology_matches {
4145        return Err("Root/parent/kind topology");
4146    }
4147    if live_module_sha256 != Some(desired_module_sha256) {
4148        return Err("module SHA-256");
4149    }
4150    if live_canister_version.is_some_and(|version| version <= pre_canister_version) {
4151        return Err("canister version");
4152    }
4153    Ok(())
4154}
4155
4156fn recoverable_root_status_error(error: &CanisterProtocolError) -> bool {
4157    error.is_rejected_with(canic_core::diagnostics::codes::STATE_CONFLICT)
4158        || error.is_rejected_with(canic_core::diagnostics::codes::STATE_UNAVAILABLE)
4159}
4160
4161fn recoverable_current_protocol_error(error: &current_protocol::CurrentProtocolError) -> bool {
4162    matches!(
4163        error,
4164        current_protocol::CurrentProtocolError::Transport(source)
4165            if recoverable_root_status_error(source)
4166    )
4167}
4168
4169pub fn install_effect_applied(
4170    mode: InstallMode,
4171    expected_hash: &str,
4172    live_hash: Option<&str>,
4173    pre_canister_version: Option<u64>,
4174    live_canister_version: Option<u64>,
4175) -> bool {
4176    if live_hash != Some(expected_hash) {
4177        return false;
4178    }
4179    match mode {
4180        InstallMode::Install => true,
4181        InstallMode::Reinstall => pre_canister_version
4182            .zip(live_canister_version)
4183            .is_some_and(|(before, after)| after > before),
4184    }
4185}
4186
4187/// Exact retained and live evidence for one Cycles Ledger withdrawal.
4188#[derive(Clone, Copy, Debug, Eq, PartialEq)]
4189pub struct NativeFundingObservation {
4190    pub amount: u128,
4191    pub expected_post_cycles: u128,
4192    pub funding_deficit_cycles: u128,
4193    pub funding_margin_cycles: u128,
4194    pub live_cycles: Option<u128>,
4195    pub pre_cycles: Option<u128>,
4196}
4197
4198/// Require the reviewed floor after all planning-to-completion burn within its margin.
4199pub const fn native_funding_applied(observation: NativeFundingObservation) -> bool {
4200    let Some(pre_cycles) = observation.pre_cycles else {
4201        return false;
4202    };
4203    let Some(live_cycles) = observation.live_cycles else {
4204        return false;
4205    };
4206    let Some(maximum_live_cycles) = pre_cycles.checked_add(observation.amount) else {
4207        return false;
4208    };
4209    let Some(reviewed_pre_cycles) = observation
4210        .expected_post_cycles
4211        .checked_sub(observation.amount)
4212    else {
4213        return false;
4214    };
4215    let Some(minimum_live_cycles) = observation
4216        .expected_post_cycles
4217        .checked_sub(observation.funding_margin_cycles)
4218    else {
4219        return false;
4220    };
4221    let Some(minimum_from_deficit) =
4222        reviewed_pre_cycles.checked_add(observation.funding_deficit_cycles)
4223    else {
4224        return false;
4225    };
4226    let reviewed_amount_matches =
4227        observation.funding_deficit_cycles > 0 && minimum_live_cycles == minimum_from_deficit;
4228    let observation_is_bounded = pre_cycles <= reviewed_pre_cycles
4229        && live_cycles >= minimum_live_cycles
4230        && live_cycles <= maximum_live_cycles;
4231    reviewed_amount_matches && observation_is_bounded
4232}
4233
4234fn native_funding_completion(
4235    observation: NativeFundingObservation,
4236    has_receipt: bool,
4237) -> Result<bool, IcpEnsurePlatformError> {
4238    if !has_receipt || observation.live_cycles.is_none() {
4239        return Ok(false);
4240    }
4241    if !native_funding_applied(observation) {
4242        return Err(IcpEnsurePlatformError::NativeFundingBalanceDrift {
4243            observation: Box::new(observation),
4244        });
4245    }
4246    Ok(true)
4247}
4248
4249/// Exact retained and live evidence for one operator-to-Root Ledger transfer.
4250#[derive(Clone, Copy, Debug, Eq, PartialEq)]
4251pub struct EstateFundingObservation {
4252    pub amount: u128,
4253    pub destination_after: u128,
4254    pub destination_before: Option<u128>,
4255    pub expected_destination_after: u128,
4256    pub ledger_fee_cycles: u128,
4257    pub source_after: u128,
4258    pub source_before: Option<u128>,
4259}
4260
4261/// Require both sides of an estate-funding transfer to match its reviewed debit.
4262#[must_use]
4263pub const fn estate_funding_applied(observation: EstateFundingObservation) -> bool {
4264    let Some(source_before) = observation.source_before else {
4265        return false;
4266    };
4267    let Some(destination_before) = observation.destination_before else {
4268        return false;
4269    };
4270    let Some(expected_source_debit) = observation
4271        .amount
4272        .checked_add(observation.ledger_fee_cycles)
4273    else {
4274        return false;
4275    };
4276    let Some(source_debit) = source_before.checked_sub(observation.source_after) else {
4277        return false;
4278    };
4279    let Some(destination_credit) = observation
4280        .destination_after
4281        .checked_sub(destination_before)
4282    else {
4283        return false;
4284    };
4285    source_debit == expected_source_debit
4286        && destination_credit == observation.amount
4287        && observation.destination_after == observation.expected_destination_after
4288}
4289
4290/// Creation and Ready funding require empty modules; reviewed reset funding accepts installed assets.
4291#[derive(Clone, Copy, Debug, Eq, PartialEq)]
4292enum InspectedModule {
4293    Any,
4294    Empty,
4295}
4296
4297fn pool_funding_inspected_module(
4298    canister: &str,
4299    reviewed: EstatePoolAssetLifecycle,
4300    observed: &CanisterPoolAssetStatus,
4301) -> Result<InspectedModule, IcpEnsurePlatformError> {
4302    match (reviewed, observed) {
4303        (EstatePoolAssetLifecycle::Ready, CanisterPoolAssetStatus::Ready) => {
4304            Ok(InspectedModule::Empty)
4305        }
4306        (EstatePoolAssetLifecycle::PendingReset, CanisterPoolAssetStatus::PendingReset)
4307        | (EstatePoolAssetLifecycle::Failed, CanisterPoolAssetStatus::Failed { .. }) => {
4308            Ok(InspectedModule::Any)
4309        }
4310        _ => Err(IcpEnsurePlatformError::FundingInspectionAuthorityConflict {
4311            canister: canister.to_string(),
4312            field: "reviewed pool lifecycle",
4313        }),
4314    }
4315}
4316
4317fn validate_pending_fresh_pool_inspection(
4318    canister: &str,
4319    root: &str,
4320    operator: &str,
4321    controllers: &[Principal],
4322    module_hash: Option<&[u8]>,
4323    cycles: &Nat,
4324) -> Result<u128, IcpEnsurePlatformError> {
4325    let mut actual = controllers
4326        .iter()
4327        .map(Principal::to_text)
4328        .collect::<Vec<_>>();
4329    actual.sort();
4330    let mut temporary = vec![root.to_string(), operator.to_string()];
4331    temporary.sort();
4332    if actual != [root] && actual != temporary {
4333        return Err(IcpEnsurePlatformError::FundingInspectionAuthorityConflict {
4334            canister: canister.to_string(),
4335            field: "reviewed fresh pool controllers",
4336        });
4337    }
4338    // Controller finalization is itself journalled. Until it completes, the exact
4339    // temporary operator remains a controller of this issued fresh Create only.
4340    validate_inspected_cycles(canister, InspectedModule::Empty, module_hash, cycles)
4341}
4342
4343fn validate_root_controlled_inspection(
4344    canister: &str,
4345    module: InspectedModule,
4346    root: &str,
4347    controllers: &[Principal],
4348    module_hash: Option<&[u8]>,
4349    cycles: &Nat,
4350) -> Result<u128, IcpEnsurePlatformError> {
4351    if controllers.len() != 1 || controllers[0].to_text() != root {
4352        return Err(IcpEnsurePlatformError::FundingInspectionAuthorityConflict {
4353            canister: canister.to_string(),
4354            field: "Root-only controllers",
4355        });
4356    }
4357    validate_inspected_cycles(canister, module, module_hash, cycles)
4358}
4359
4360fn validate_inspected_cycles(
4361    canister: &str,
4362    module: InspectedModule,
4363    module_hash: Option<&[u8]>,
4364    cycles: &Nat,
4365) -> Result<u128, IcpEnsurePlatformError> {
4366    if matches!(module, InspectedModule::Empty) && module_hash.is_some() {
4367        return Err(IcpEnsurePlatformError::FundingInspectionAuthorityConflict {
4368            canister: canister.to_string(),
4369            field: "module-free pool asset",
4370        });
4371    }
4372    u128::try_from(cycles.0.clone()).map_err(|_| IcpEnsurePlatformError::InvalidStatusCycles {
4373        canister: canister.to_string(),
4374        value: cycles.to_string(),
4375    })
4376}
4377
4378fn created_canister_outcome(
4379    canister_id: Principal,
4380    receipt: String,
4381    requested_initial_cycles: u128,
4382) -> EffectOutcome {
4383    EffectOutcome {
4384        created_principal: Some(canister_id.to_text()),
4385        post_cycles: Some(requested_initial_cycles),
4386        receipt: Some(receipt),
4387    }
4388}
4389
4390fn create_canister_response_outcome(
4391    response: Result<CreateCanisterSuccess, CreateCanisterError>,
4392    requested_initial_cycles: u128,
4393) -> Result<EffectOutcome, IcpEnsurePlatformError> {
4394    match response {
4395        Ok(success) => Ok(created_canister_outcome(
4396            success.canister_id,
4397            success.block_id.to_string(),
4398            requested_initial_cycles,
4399        )),
4400        Err(CreateCanisterError::Duplicate {
4401            duplicate_of,
4402            canister_id: Some(canister_id),
4403        }) => Ok(created_canister_outcome(
4404            canister_id,
4405            duplicate_of.to_string(),
4406            requested_initial_cycles,
4407        )),
4408        Err(CreateCanisterError::Duplicate {
4409            canister_id: None, ..
4410        }) => Err(IcpEnsurePlatformError::LedgerCreatePending),
4411        Err(error) => Err(IcpEnsurePlatformError::LedgerCreate(render_create_error(
4412            error,
4413        ))),
4414    }
4415}
4416
4417fn ledger_fee_cycles(value: Nat) -> Result<u128, IcpEnsurePlatformError> {
4418    let rendered = value.to_string();
4419    u128::try_from(value.0).map_err(|_| IcpEnsurePlatformError::InvalidLedgerFee(rendered))
4420}
4421
4422const fn empty_outcome() -> EffectOutcome {
4423    EffectOutcome {
4424        created_principal: None,
4425        post_cycles: None,
4426        receipt: None,
4427    }
4428}
4429
4430fn parse_status_cycles(value: &str) -> Option<u128> {
4431    value.replace('_', "").trim().parse().ok()
4432}
4433
4434fn normalize_hash(value: &str) -> String {
4435    value
4436        .strip_prefix("0x")
4437        .unwrap_or(value)
4438        .to_ascii_lowercase()
4439}
4440
4441fn parse_principal(field: &'static str, value: &str) -> Result<Principal, IcpEnsurePlatformError> {
4442    Principal::from_text(value).map_err(|_| IcpEnsurePlatformError::InvalidPrincipal {
4443        field,
4444        value: value.to_string(),
4445    })
4446}
4447
4448fn reject_public_status_projection(
4449    report: &IcpCanisterStatusReport,
4450    principal: &str,
4451) -> Result<(), IcpEnsurePlatformError> {
4452    if report.status.is_none()
4453        && report.settings.is_none()
4454        && report.cycles.is_none()
4455        && report.public_controllers.is_some()
4456    {
4457        return Err(IcpEnsurePlatformError::PublicCanisterStatusUnavailable {
4458            canister: principal.to_string(),
4459        });
4460    }
4461    Ok(())
4462}
4463
4464fn resolve_path(root: &Path, path: &str) -> PathBuf {
4465    let path = Path::new(path);
4466    if path.is_absolute() {
4467        path.to_path_buf()
4468    } else {
4469        root.join(path)
4470    }
4471}
4472
4473fn verify_artifact(
4474    path: &Path,
4475    expected: &str,
4476    kind: &'static str,
4477) -> Result<(), IcpEnsurePlatformError> {
4478    let bytes = std::fs::read(path)
4479        .map_err(|_| IcpEnsurePlatformError::ArtifactUnavailable(path.to_path_buf()))?;
4480    let actual = canic_core::cdk::utils::hash::sha256_hex(&bytes);
4481    if actual != expected {
4482        return Err(IcpEnsurePlatformError::ArtifactDigestMismatch {
4483            actual,
4484            expected: expected.to_string(),
4485            kind,
4486        });
4487    }
4488    Ok(())
4489}
4490
4491fn artifact_hash(path: &Path) -> Result<String, IcpEnsurePlatformError> {
4492    let bytes = std::fs::read(path)
4493        .map_err(|_| IcpEnsurePlatformError::ArtifactUnavailable(path.to_path_buf()))?;
4494    Ok(canic_core::cdk::utils::hash::sha256_hex(&bytes))
4495}
4496
4497fn resolved_protocol_action(
4498    action: &EnsureAction,
4499    state: &FleetEnsureStateRecord,
4500) -> Result<EnsureAction, IcpEnsurePlatformError> {
4501    let mut resolved = action.clone();
4502    if let EnsureAction::Protocol { principal, .. } = &mut resolved {
4503        *principal = IcpEnsurePlatform::action_principal(state, principal)?.to_string();
4504        return Ok(resolved);
4505    }
4506    Err(IcpEnsurePlatformError::Protocol(
4507        protocol::ProtocolEffectError::WrongAction,
4508    ))
4509}
4510
4511fn render_create_error(error: CreateCanisterError) -> String {
4512    match error {
4513        CreateCanisterError::CreatedInFuture { ledger_time } => {
4514            format!("created in future of ledger time {ledger_time}")
4515        }
4516        CreateCanisterError::Duplicate { duplicate_of, .. } => {
4517            format!("duplicate block {duplicate_of}")
4518        }
4519        CreateCanisterError::FailedToCreate {
4520            error,
4521            fee_block,
4522            refund_block,
4523        } => format!("failed: {error}; fee_block={fee_block:?}; refund_block={refund_block:?}"),
4524        CreateCanisterError::GenericError {
4525            error_code,
4526            message,
4527        } => format!("error {error_code}: {message}"),
4528        CreateCanisterError::InsufficientFunds { balance } => {
4529            format!("insufficient funds: balance={balance}")
4530        }
4531        CreateCanisterError::TemporarilyUnavailable => "temporarily unavailable".to_string(),
4532        CreateCanisterError::TooOld => "request is too old".to_string(),
4533    }
4534}
4535
4536fn render_withdraw_error(error: WithdrawError) -> String {
4537    match error {
4538        WithdrawError::BadFee { expected_fee } => format!("bad fee; expected {expected_fee}"),
4539        WithdrawError::CreatedInFuture { ledger_time } => {
4540            format!("created in future of ledger time {ledger_time}")
4541        }
4542        WithdrawError::Duplicate { duplicate_of } => format!("duplicate block {duplicate_of}"),
4543        WithdrawError::FailedToWithdraw {
4544            fee_block,
4545            rejection_reason,
4546            ..
4547        } => format!("withdraw failed: {rejection_reason}; fee_block={fee_block:?}"),
4548        WithdrawError::GenericError {
4549            error_code,
4550            message,
4551        } => format!("error {error_code}: {message}"),
4552        WithdrawError::InsufficientFunds { balance } => {
4553            format!("insufficient funds: balance={balance}")
4554        }
4555        WithdrawError::InvalidReceiver { receiver } => format!("invalid receiver {receiver}"),
4556        WithdrawError::TemporarilyUnavailable => "temporarily unavailable".to_string(),
4557        WithdrawError::TooOld => "request is too old".to_string(),
4558    }
4559}
4560
4561fn render_ledger_transfer_error(error: CyclesLedgerTransferError) -> String {
4562    match error {
4563        CyclesLedgerTransferError::BadBurn { min_burn_amount } => {
4564            format!("bad burn; minimum {min_burn_amount}")
4565        }
4566        CyclesLedgerTransferError::BadFee { expected_fee } => {
4567            format!("bad fee; expected {expected_fee}")
4568        }
4569        CyclesLedgerTransferError::CreatedInFuture { ledger_time } => {
4570            format!("created in future of ledger time {ledger_time}")
4571        }
4572        CyclesLedgerTransferError::Duplicate { duplicate_of } => {
4573            format!("duplicate block {duplicate_of}")
4574        }
4575        CyclesLedgerTransferError::GenericError {
4576            error_code,
4577            message,
4578        } => format!("error {error_code}: {message}"),
4579        CyclesLedgerTransferError::InsufficientFunds { balance } => {
4580            format!("insufficient funds: balance={balance}")
4581        }
4582        CyclesLedgerTransferError::TemporarilyUnavailable => "temporarily unavailable".to_string(),
4583        CyclesLedgerTransferError::TooOld => "request is too old".to_string(),
4584    }
4585}
4586
4587#[expect(
4588    dead_code,
4589    reason = "Candid variant is retained for exact response decoding"
4590)]
4591const fn rejection_code_name(code: RejectionCode) -> &'static str {
4592    match code {
4593        RejectionCode::CanisterError => "canister_error",
4594        RejectionCode::CanisterReject => "canister_reject",
4595        RejectionCode::DestinationInvalid => "destination_invalid",
4596        RejectionCode::NoError => "no_error",
4597        RejectionCode::SysFatal => "sys_fatal",
4598        RejectionCode::SysTransient => "sys_transient",
4599        RejectionCode::Unknown => "unknown",
4600    }
4601}
4602
4603#[cfg(test)]
4604mod tests {
4605    use super::*;
4606    use canic_core::dto::pool::CanisterPoolAssetStatus;
4607
4608    #[test]
4609    fn inactive_source_inventory_rejects_spare_capacity_pending_work_and_omissions() {
4610        let root = crate::fleet_ensure::model::RootActivationResetRecord {
4611            root: "root".to_string(),
4612            activation_operation_id: [1; 32],
4613            inventory_hash: [2; 32],
4614            provisioning_receipt_hash: [3; 32],
4615            component_count: 1,
4616            managed_descendants: 2,
4617        };
4618        let page = inactive_source_pool_page();
4619        assert!(inactive_source_pool_has_full_inventory(&page, &root));
4620        let mut complete = EstatePoolInventoryAccumulator::default();
4621        assert_eq!(
4622            complete.observe_page("root", page.clone()).expect("page"),
4623            None
4624        );
4625        assert_eq!(complete.finish("root").expect("complete").assets.len(), 4);
4626
4627        let mut spare = page.clone();
4628        spare.config.maximum_size += 1;
4629        let mut insufficient_reserve = page.clone();
4630        insufficient_reserve.config.minimum_size = 2;
4631        let mut pending = page.clone();
4632        pending.pending_reset = 1;
4633        let mut wrong_workload = page.clone();
4634        wrong_workload.workload -= 1;
4635        wrong_workload.ready += 1;
4636        let mut overflow = page.clone();
4637        overflow.workload = u32::MAX;
4638        for changed in [
4639            spare,
4640            insufficient_reserve,
4641            pending,
4642            wrong_workload,
4643            overflow,
4644        ] {
4645            assert!(!inactive_source_pool_has_full_inventory(&changed, &root));
4646        }
4647
4648        let mut omitted = page;
4649        omitted.entries.pop();
4650        let mut inventory = EstatePoolInventoryAccumulator::default();
4651        inventory
4652            .observe_page("root", omitted)
4653            .expect("bounded page");
4654        assert!(matches!(
4655            inventory.finish("root"),
4656            Err(IcpEnsurePlatformError::CurrentProtocol(
4657                current_protocol::CurrentProtocolError::Configuration(_)
4658            ))
4659        ));
4660    }
4661
4662    fn inactive_source_pool_page() -> CanisterPoolResponse {
4663        let claim = canic_core::dto::pool::CanisterPoolClaim {
4664            component: canic_core::ids::ComponentInstanceId::from_generated_bytes([4; 32]),
4665            operation_id: [5; 32],
4666        };
4667        let entries = (0..5)
4668            .map(|index| CanisterPoolAsset {
4669                canister_id: Principal::from_slice(&[index]),
4670                creation_receipt: None,
4671                cycles: Cycles::new(100),
4672                origin: if index == 0 {
4673                    CanisterPoolAssetOrigin::InfrastructureStore
4674                } else {
4675                    CanisterPoolAssetOrigin::Imported
4676                },
4677                status: match index {
4678                    0 => CanisterPoolAssetStatus::Store,
4679                    1 => CanisterPoolAssetStatus::Ready,
4680                    _ => CanisterPoolAssetStatus::Workload {
4681                        claim: claim.clone(),
4682                    },
4683                },
4684                added_at_ns: 1,
4685                updated_at_ns: 1,
4686            })
4687            .collect();
4688        CanisterPoolResponse {
4689            config: canic_core::ids::FleetSubnetCanisterPoolConfig {
4690                minimum_size: 1,
4691                maximum_size: 4,
4692                canister_cycles: Cycles::new(100),
4693                creation_execution_margin: Cycles::new(1),
4694            },
4695            tracked: 5,
4696            store: 1,
4697            store_deletion_pending: 0,
4698            pooled: 1,
4699            workload: 3,
4700            surplus: 0,
4701            ready: 1,
4702            pending_reset: 0,
4703            claimed: 0,
4704            recycling: 0,
4705            handing_off: 0,
4706            failed: 0,
4707            completed_handoffs: 0,
4708            pending_creation: None,
4709            pending_handoff: None,
4710            entries,
4711            next_start_after: None,
4712        }
4713    }
4714
4715    #[test]
4716    fn pool_funding_binds_reset_lifecycle_without_weakening_ready_inspection() {
4717        let failed = CanisterPoolAssetStatus::Failed {
4718            reason: "reset stopped".to_string(),
4719        };
4720        for (lifecycle, status, expected) in [
4721            (
4722                EstatePoolAssetLifecycle::Ready,
4723                CanisterPoolAssetStatus::Ready,
4724                InspectedModule::Empty,
4725            ),
4726            (
4727                EstatePoolAssetLifecycle::PendingReset,
4728                CanisterPoolAssetStatus::PendingReset,
4729                InspectedModule::Any,
4730            ),
4731            (
4732                EstatePoolAssetLifecycle::Failed,
4733                failed,
4734                InspectedModule::Any,
4735            ),
4736        ] {
4737            let module = pool_funding_inspected_module("pool", lifecycle, &status).unwrap();
4738            assert_eq!(module, expected);
4739            let installed =
4740                validate_inspected_cycles("pool", module, Some(&[1; 32]), &Nat::from(100_u8));
4741            assert_eq!(installed.is_ok(), module == InspectedModule::Any);
4742            for wrong in [
4743                EstatePoolAssetLifecycle::Ready,
4744                EstatePoolAssetLifecycle::PendingReset,
4745                EstatePoolAssetLifecycle::Failed,
4746                EstatePoolAssetLifecycle::Workload,
4747            ] {
4748                if wrong != lifecycle {
4749                    assert!(matches!(
4750                        pool_funding_inspected_module("pool", wrong, &status),
4751                        Err(IcpEnsurePlatformError::FundingInspectionAuthorityConflict { .. })
4752                    ));
4753                }
4754            }
4755        }
4756    }
4757
4758    #[cfg(unix)]
4759    #[test]
4760    fn funding_pause_uses_fresh_ledger_balance_without_completing_protocol_work() {
4761        let mut fixture = ProtocolOwnersFixture::new();
4762        let root = Principal::from_slice(&[1]);
4763        let ledger = Principal::from_slice(&[2]);
4764        fixture.platform.desired.cycles_ledger = ledger.to_text();
4765        fixture.platform.desired.canisters[1].principal = Some(root.to_text());
4766        std::fs::write(
4767            fixture.root.join("icp"),
4768            format!(
4769                "#!/bin/sh\nif [ \"$1\" = '--version' ]; then echo 'icp 1.3.0'; exit 0; fi\ncat '{}'/balance.json\n",
4770                fixture.root.display(),
4771            ),
4772        )
4773        .unwrap();
4774        let pause = canic_core::dto::component_provisioning::RootEstateFundingRequired {
4775            available: Cycles::new(0),
4776            attempt_count: 0,
4777            creation_amount: Cycles::new(100),
4778            cycles_ledger: ledger,
4779            execution_margin: Cycles::new(10),
4780            last_attempt_at_ns: None,
4781            ledger_fee: Cycles::new(1),
4782            management_creation_fee: Cycles::new(20),
4783            operation_id: [1; 32],
4784            readiness_floor: Cycles::new(70),
4785            required: Cycles::new(101),
4786            retry_at_ns: 60,
4787            root,
4788            shortfall: Cycles::new(101),
4789        };
4790        for available in [0_u128, 40, 101, 404] {
4791            std::fs::write(
4792                fixture.root.join("balance.json"),
4793                serde_json::json!({
4794                    "response_bytes": hex_bytes(candid::encode_one(Nat::from(available)).unwrap()),
4795                })
4796                .to_string(),
4797            )
4798            .unwrap();
4799            let mut observed = EffectObservation {
4800                provisioning_failure: None,
4801                applied: false,
4802                estate_funding_required: Some(pause.clone()),
4803                post_cycles: None,
4804                progress_identity: "pending-creation".to_string(),
4805                retry: EffectRetry::ContinuePoolMaintenance,
4806            };
4807            fixture
4808                .platform
4809                .refresh_estate_funding_observation(&mut observed, &fixture.state)
4810                .unwrap();
4811            assert!(!observed.applied);
4812            assert_eq!(observed.retry, EffectRetry::ContinuePoolMaintenance);
4813            assert_eq!(observed.progress_identity, "pending-creation");
4814            let expected = (available < 101).then(|| {
4815                let mut expected = pause.clone();
4816                expected.available = Cycles::new(available);
4817                expected.shortfall = Cycles::new(101 - available);
4818                expected
4819            });
4820            assert_eq!(observed.estate_funding_required, expected);
4821        }
4822        for (root, ledger) in [
4823            (Principal::anonymous(), ledger),
4824            (root, Principal::anonymous()),
4825        ] {
4826            let mut changed = pause.clone();
4827            changed.root = root;
4828            changed.cycles_ledger = ledger;
4829            let mut observed = EffectObservation {
4830                provisioning_failure: None,
4831                applied: false,
4832                estate_funding_required: Some(changed),
4833                post_cycles: None,
4834                progress_identity: String::new(),
4835                retry: EffectRetry::None,
4836            };
4837            assert!(matches!(
4838                fixture
4839                    .platform
4840                    .refresh_estate_funding_observation(&mut observed, &fixture.state),
4841                Err(IcpEnsurePlatformError::CurrentProtocol(
4842                    current_protocol::CurrentProtocolError::ResponseMismatch
4843                ))
4844            ));
4845        }
4846        std::fs::remove_dir_all(&fixture.root).unwrap();
4847    }
4848
4849    #[test]
4850    fn completed_withdrawal_outside_bounds_fails_without_endless_observation() {
4851        let observation = NativeFundingObservation {
4852            amount: 350,
4853            expected_post_cycles: 800,
4854            funding_deficit_cycles: 150,
4855            funding_margin_cycles: 200,
4856            live_cycles: Some(599),
4857            pre_cycles: Some(449),
4858        };
4859        assert!(!native_funding_completion(observation, false).unwrap());
4860        assert!(matches!(
4861            native_funding_completion(observation, true),
4862            Err(IcpEnsurePlatformError::NativeFundingBalanceDrift {
4863                observation: actual,
4864            }) if *actual == observation
4865        ));
4866        assert!(
4867            native_funding_completion(
4868                NativeFundingObservation {
4869                    live_cycles: Some(600),
4870                    ..observation
4871                },
4872                true
4873            )
4874            .unwrap()
4875        );
4876    }
4877
4878    #[cfg(unix)]
4879    struct ProtocolOwnersFixture {
4880        root: PathBuf,
4881        platform: IcpEnsurePlatform,
4882        state: FleetEnsureStateRecord,
4883    }
4884
4885    #[cfg(unix)]
4886    impl ProtocolOwnersFixture {
4887        fn new() -> Self {
4888            use std::{fs, os::unix::fs::PermissionsExt};
4889
4890            let root = crate::test_support::temp_dir("canic-protocol-owner-observations");
4891            fs::create_dir_all(&root).unwrap();
4892            fs::write(root.join("owner.wasm"), b"current-owner-module").unwrap();
4893            let executable = root.join("icp");
4894            fs::write(&executable, format!(
4895                "#!/bin/sh\nif [ \"$1\" = '--version' ]; then echo 'icp 1.3.0'; exit 0; fi\nwhile [ \"$#\" -gt 0 ] && [ \"$1\" != status ]; do shift; done\nshift\nprintf '%s\\n' \"$1\" >> '{}'/calls\ncat '{}'/\"$1\".json\n",
4896                root.display(), root.display(),
4897            )).unwrap();
4898            fs::set_permissions(&executable, fs::Permissions::from_mode(0o755)).unwrap();
4899            let canisters = ["coordinator", "root", "store"].map(|name| {
4900                serde_json::json!({
4901                    "controller_canisters": [], "controllers": [], "drain": null,
4902                    "initial_cycles": "1T", "init_arg": null, "init_candid": null,
4903                    "kind": name, "minimum_cycles": "1T", "name": name,
4904                    "parent": null, "presence": "present", "principal": name,
4905                    "replace": false, "subnet": "subnet", "wasm": "owner.wasm",
4906                })
4907            });
4908            let desired = serde_json::from_value(serde_json::json!({
4909                "bootstrap": null, "canisters": canisters, "cycles_ledger": "ledger",
4910                "environment": "local", "fleet": "owners", "ledger_fee_cycles": "100M",
4911                "management_creation_fee_cycles": "500B", "material_cycle_threshold": "1B",
4912                "maximum_observation_burn_cycles": "1B", "maximum_stalled_observations": 4,
4913                "maximum_update_burn_cycles": "1B", "operator": "operator", "protocol": null,
4914                "schema_version": 1, "treasury": "treasury",
4915            }))
4916            .unwrap();
4917            let platform = IcpEnsurePlatform::new(desired, executable.to_str().unwrap(), &root);
4918            let paths = crate::fleet_ensure::ops::EnsurePaths::under(&root, "local", "owners");
4919            let state = crate::fleet_ensure::ops::read_state(&paths, "owners").unwrap();
4920            let fixture = Self {
4921                root,
4922                platform,
4923                state,
4924            };
4925            for name in ["coordinator", "root", "store"] {
4926                fixture.status(name, "Running", true);
4927            }
4928            fixture
4929        }
4930
4931        fn status(&self, name: &str, status: &str, current_module: bool) {
4932            let module = if current_module {
4933                artifact_hash(&self.root.join("owner.wasm")).unwrap()
4934            } else {
4935                "ff".repeat(32)
4936            };
4937            std::fs::write(
4938                self.root.join(format!("{name}.json")),
4939                serde_json::json!({
4940                    "id": name, "status": status, "settings": { "controllers": [] },
4941                    "module_hash": module, "cycles": "1000000000000",
4942                })
4943                .to_string(),
4944            )
4945            .unwrap();
4946        }
4947    }
4948
4949    #[cfg(unix)]
4950    #[test]
4951    #[expect(
4952        clippy::too_many_lines,
4953        reason = "one measured transport fixture keeps equivalent retained inventories and call accounting together"
4954    )]
4955    fn configured_observation_benchmark_retains_snapshot_call_bound() {
4956        use std::{
4957            fs,
4958            os::unix::fs::PermissionsExt,
4959            sync::{Arc, Mutex},
4960        };
4961        for pool_count in [0_u32, 8, 24, 96] {
4962            let mut fixture = ProtocolOwnersFixture::new();
4963            let root_pid = Principal::from_slice(&[2]);
4964            for (index, canister) in fixture.platform.desired.canisters.iter_mut().enumerate() {
4965                let pid = Principal::from_slice(&[u8::try_from(index + 1).unwrap()]).to_text();
4966                let mut status: serde_json::Value = serde_json::from_slice(
4967                    &fs::read(fixture.root.join(format!("{}.json", canister.name))).unwrap(),
4968                )
4969                .unwrap();
4970                status["id"] = serde_json::Value::String(pid.clone());
4971                fs::write(fixture.root.join(format!("{pid}.json")), status.to_string()).unwrap();
4972                canister.principal = Some(pid);
4973            }
4974            fixture.platform.desired.protocol =
4975                Some(crate::fleet_ensure::model::DesiredFleetProtocol {
4976                    app_config: "unused.toml".to_string(),
4977                    component_group_placements: Vec::new(),
4978                    root_candid: "root.did".to_string(),
4979                    coordinator_candid: "unused.did".to_string(),
4980                    store_candid: "unused.did".to_string(),
4981                });
4982            fs::write(fixture.root.join("root.did"), "service : {}").unwrap();
4983            let mut entries = Vec::new();
4984            for index in 0..pool_count {
4985                let pid = Principal::from_slice(&[u8::try_from(index + 10).unwrap()]);
4986                let mut configured = fixture.platform.desired.canisters[0].clone();
4987                configured.name = format!("pool-{index:03}");
4988                configured.kind = DesiredCanisterKind::Pool;
4989                configured.principal = Some(pid.to_text());
4990                configured.parent = Some("root".to_string());
4991                fixture.platform.desired.canisters.push(configured);
4992                entries.push(CanisterPoolAsset {
4993                    canister_id: pid,
4994                    creation_receipt: None,
4995                    cycles: Cycles::new(1_000_000_000_000),
4996                    origin: CanisterPoolAssetOrigin::Imported,
4997                    status: CanisterPoolAssetStatus::Ready,
4998                    added_at_ns: 1,
4999                    updated_at_ns: 1,
5000                });
5001            }
5002            let page = CanisterPoolResponse {
5003                config: canic_core::ids::FleetSubnetCanisterPoolConfig {
5004                    minimum_size: 5,
5005                    maximum_size: 128,
5006                    canister_cycles: Cycles::new(1_000_000_000_000),
5007                    creation_execution_margin: Cycles::new(1_000_000_000),
5008                },
5009                tracked: pool_count,
5010                store: 0,
5011                store_deletion_pending: 0,
5012                pooled: pool_count,
5013                workload: 0,
5014                surplus: 0,
5015                ready: pool_count,
5016                pending_reset: 0,
5017                claimed: 0,
5018                recycling: 0,
5019                handing_off: 0,
5020                failed: 0,
5021                completed_handoffs: 0,
5022                pending_creation: None,
5023                pending_handoff: None,
5024                entries,
5025                next_start_after: None,
5026            };
5027            let response = Ok::<_, canic_core::dto::error::Error>(RootPoolStatusResponse::Pool(
5028                Box::new(page),
5029            ));
5030            fs::write(fixture.root.join("pool.json"), serde_json::json!({"response_bytes": hex_bytes(candid::encode_one(response).unwrap())}).to_string()).unwrap();
5031            let executable = fixture.root.join("icp");
5032            fs::write(&executable, format!(
5033                "#!/bin/sh\nif [ \"$1\" = '--version' ]; then echo 'icp 1.3.0'; exit 0; fi\nwhile [ \"$#\" -gt 0 ] && [ \"$1\" != canister ]; do shift; done\nshift\nsleep 0.05\ncase \"$1\" in\nstatus) shift; cat '{}'/\"$1\".json;;\ncall) cat '{}'/pool.json;;\n*) exit 1;;\nesac\n",
5034                fixture.root.display(), fixture.root.display())).unwrap();
5035            fs::set_permissions(&executable, fs::Permissions::from_mode(0o755)).unwrap();
5036            let timings = Arc::new(Mutex::new(Vec::new()));
5037            let sink = Arc::clone(&timings);
5038            fixture.platform = fixture
5039                .platform
5040                .with_observation_handler(move |timing| sink.lock().unwrap().push(timing));
5041            let observed = fixture
5042                .platform
5043                .with_observation_snapshot(|platform| {
5044                    platform
5045                        .timed_observation(FleetObservationStage::ConfiguredCanisters, |platform| {
5046                            platform.observe_configured_canisters(&fixture.state)
5047                        })
5048                })
5049                .unwrap();
5050            assert_eq!(observed.len(), usize::try_from(pool_count).unwrap() + 3);
5051            assert!(observed.values().all(Option::is_some));
5052            assert_eq!(
5053                observed["root"].as_ref().unwrap().principal,
5054                root_pid.to_text()
5055            );
5056            let timing = timings.lock().unwrap().pop().unwrap();
5057            assert_eq!(timing.remote_call_attempts, 3 + u64::from(pool_count > 0));
5058            assert!(timing.succeeded);
5059            println!(
5060                "configured_observation pool_assets={pool_count} elapsed_ms={} calls={}",
5061                timing.elapsed_millis, timing.remote_call_attempts
5062            );
5063            fs::remove_dir_all(fixture.root).unwrap();
5064        }
5065    }
5066
5067    #[cfg(unix)]
5068    #[test]
5069    fn configured_observation_failure_drains_batch_and_keeps_first_error() {
5070        use std::{
5071            fs,
5072            sync::{Arc, Mutex},
5073        };
5074        let mut fixture = ProtocolOwnersFixture::new();
5075        let timings = Arc::new(Mutex::new(Vec::new()));
5076        let sink = Arc::clone(&timings);
5077        fixture.platform = fixture
5078            .platform
5079            .with_observation_handler(move |timing| sink.lock().unwrap().push(timing));
5080        let mut first: serde_json::Value =
5081            serde_json::from_slice(&fs::read(fixture.root.join("coordinator.json")).unwrap())
5082                .unwrap();
5083        first["id"] = serde_json::json!("different-principal");
5084        fs::write(fixture.root.join("coordinator.json"), first.to_string()).unwrap();
5085        fs::remove_file(fixture.root.join("root.json")).unwrap();
5086        let failure = fixture.platform.with_observation_snapshot(|platform| {
5087            platform.timed_observation(FleetObservationStage::ConfiguredCanisters, |platform| {
5088                platform.observe_configured_canisters(&fixture.state)
5089            })
5090        });
5091        assert!(
5092            matches!(failure, Err(IcpEnsurePlatformError::StatusIdentityMismatch { expected, .. }) if expected == "coordinator")
5093        );
5094        let timing = timings.lock().unwrap().pop().unwrap();
5095        assert!(!timing.succeeded);
5096        assert_eq!(timing.remote_call_attempts, 3);
5097        assert!(fixture.platform.observation_snapshot.borrow().is_none());
5098        fixture.status("coordinator", "Running", true);
5099        fixture.status("root", "Running", true);
5100        assert_eq!(
5101            fixture
5102                .platform
5103                .with_observation_snapshot(
5104                    |platform| platform.observe_configured_canisters(&fixture.state)
5105                )
5106                .unwrap()
5107                .len(),
5108            3
5109        );
5110        assert_eq!(fixture.platform.icp.remote_call_count(), 6);
5111        fs::remove_dir_all(fixture.root).unwrap();
5112    }
5113
5114    #[cfg(unix)]
5115    #[test]
5116    fn protocol_owner_observations_read_each_owner_once_and_refresh_between_calls() {
5117        let fixture = ProtocolOwnersFixture::new();
5118        assert!(
5119            fixture
5120                .platform
5121                .current_protocol_owners_are_ready(&fixture.state)
5122                .unwrap()
5123        );
5124        assert_eq!(
5125            std::fs::read_to_string(fixture.root.join("calls")).unwrap(),
5126            "coordinator\nroot\nstore\n"
5127        );
5128        fixture.status("store", "Stopping", true);
5129        assert!(
5130            !fixture
5131                .platform
5132                .current_protocol_owners_are_ready(&fixture.state)
5133                .unwrap()
5134        );
5135        fixture.status("store", "Stopped", true);
5136        assert!(
5137            !fixture
5138                .platform
5139                .current_protocol_owners_are_ready(&fixture.state)
5140                .unwrap()
5141        );
5142        fixture.status("store", "Running", false);
5143        assert!(
5144            !fixture
5145                .platform
5146                .current_protocol_owners_are_ready(&fixture.state)
5147                .unwrap()
5148        );
5149        fixture.status("store", "Running", true);
5150        assert!(
5151            fixture
5152                .platform
5153                .current_protocol_owners_are_ready(&fixture.state)
5154                .unwrap()
5155        );
5156        assert_eq!(
5157            std::fs::read_to_string(fixture.root.join("calls"))
5158                .unwrap()
5159                .lines()
5160                .count(),
5161            15
5162        );
5163        std::fs::remove_dir_all(fixture.root).unwrap();
5164    }
5165
5166    #[cfg(unix)]
5167    #[test]
5168    fn protocol_owner_observations_preserve_missing_owner_and_transport_failures() {
5169        let mut fixture = ProtocolOwnersFixture::new();
5170        fixture.platform.desired.canisters[0].principal = None;
5171        assert!(
5172            !fixture
5173                .platform
5174                .current_protocol_owners_are_ready(&fixture.state)
5175                .unwrap()
5176        );
5177        std::fs::remove_file(fixture.root.join("store.json")).unwrap();
5178        assert!(matches!(
5179            fixture
5180                .platform
5181                .current_protocol_owners_are_ready(&fixture.state),
5182            Err(IcpEnsurePlatformError::Icp(_))
5183        ));
5184        fixture.platform.desired.canisters[0].principal = Some("coordinator".to_string());
5185        fixture.status("coordinator", "Running", false);
5186        assert!(matches!(
5187            fixture
5188                .platform
5189                .current_protocol_owners_are_ready(&fixture.state),
5190            Err(IcpEnsurePlatformError::Icp(_))
5191        ));
5192        std::fs::remove_dir_all(fixture.root).unwrap();
5193    }
5194
5195    #[cfg(unix)]
5196    #[test]
5197    #[expect(
5198        clippy::too_many_lines,
5199        reason = "one transport sequence proves snapshot reuse and expiry across success, failure and effects"
5200    )]
5201    fn observation_snapshot_expires_before_effects_and_after_failed_observation() {
5202        use std::{fs, os::unix::fs::PermissionsExt};
5203
5204        let root = crate::test_support::temp_dir("canic-observation-snapshot");
5205        fs::create_dir_all(&root).expect("create observation fixture");
5206        let executable = root.join("icp");
5207        let commands = root.join("commands.log");
5208        let response = root.join("status.json");
5209        let canister = "rrkah-fqaaa-aaaaa-aaaaq-cai";
5210        let operator = "rdmx6-jaaaa-aaaaa-aaadq-cai";
5211        let status = |cycles: u128| {
5212            serde_json::json!({
5213                "id": canister,
5214                "status": "Running",
5215                "settings": { "controllers": [operator] },
5216                "module_hash": null,
5217                "cycles": cycles.to_string(),
5218            })
5219            .to_string()
5220        };
5221        fs::write(&response, status(1_000)).expect("write initial live balance");
5222        fs::write(&executable, format!(
5223            "#!/bin/sh\nif [ \"$1\" = '--version' ]; then echo 'icp 1.3.0'; exit 0; fi\nprintf '%s\\n' \"$*\" >> '{}'\ncat '{}'\n",
5224            commands.display(), response.display(),
5225        )).expect("write observation transport");
5226        fs::set_permissions(&executable, fs::Permissions::from_mode(0o755))
5227            .expect("make observation transport executable");
5228        let desired = DesiredFleet {
5229            bootstrap: None,
5230            canisters: Vec::new(),
5231            cycles_ledger: "um5iw-rqaaa-aaaaq-qaaba-cai".to_string(),
5232            environment: "local".to_string(),
5233            fleet: "observation-snapshot".to_string(),
5234            ledger_fee_cycles: "100M".to_string(),
5235            management_creation_fee_cycles: "500B".to_string(),
5236            material_cycle_threshold: "1B".to_string(),
5237            maximum_observation_burn_cycles: "1B".to_string(),
5238            maximum_stalled_observations: 4,
5239            maximum_update_burn_cycles: "1B".to_string(),
5240            operator: operator.to_string(),
5241            protocol: None,
5242            protocol_steps: Vec::new(),
5243            schema_version: 1,
5244            treasury: "treasury".to_string(),
5245        };
5246        let mut platform =
5247            IcpEnsurePlatform::new(desired, executable.to_str().expect("transport path"), &root);
5248        let failed: Result<(), IcpEnsurePlatformError> =
5249            platform.with_observation_snapshot(|platform| {
5250                assert_eq!(
5251                    platform
5252                        .status_optional(canister)?
5253                        .expect("live canister")
5254                        .cycles,
5255                    1_000
5256                );
5257                fs::write(&response, status(900)).expect("advance simulated live balance");
5258                assert_eq!(
5259                    platform
5260                        .status_optional(canister)?
5261                        .expect("same snapshot")
5262                        .cycles,
5263                    1_000
5264                );
5265                Err(IcpEnsurePlatformError::Arithmetic(
5266                    "injected observation failure",
5267                ))
5268            });
5269        assert!(matches!(failed, Err(IcpEnsurePlatformError::Arithmetic(_))));
5270        assert_eq!(
5271            fs::read_to_string(&commands)
5272                .expect("first observation calls")
5273                .lines()
5274                .count(),
5275            1
5276        );
5277        assert_eq!(
5278            platform
5279                .status_optional(canister)
5280                .expect("fresh action read")
5281                .expect("live canister")
5282                .cycles,
5283            900
5284        );
5285        platform
5286            .with_observation_snapshot(|platform| {
5287                assert_eq!(
5288                    platform
5289                        .status_optional(canister)?
5290                        .expect("next observation")
5291                        .cycles,
5292                    900
5293                );
5294                fs::write(&response, status(800)).expect("advance live balance again");
5295                assert_eq!(
5296                    platform
5297                        .status_optional(canister)?
5298                        .expect("next snapshot")
5299                        .cycles,
5300                    900
5301                );
5302                Ok(())
5303            })
5304            .expect("next observation succeeds");
5305        assert_eq!(
5306            platform
5307                .status_optional(canister)
5308                .expect("post-observation action read")
5309                .expect("live canister")
5310                .cycles,
5311            800
5312        );
5313        assert_eq!(
5314            fs::read_to_string(&commands)
5315                .expect("all observation calls")
5316                .lines()
5317                .count(),
5318            4
5319        );
5320        fs::remove_dir_all(root).expect("remove observation fixture");
5321    }
5322
5323    #[test]
5324    fn protocol_observation_delay_uses_bounded_exponential_backoff() {
5325        let production_cap = MAXIMUM_PROTOCOL_OBSERVATION_DELAY;
5326        assert_eq!(
5327            protocol_observation_delay(0, INITIAL_PROTOCOL_OBSERVATION_DELAY, production_cap),
5328            Duration::from_millis(250)
5329        );
5330        assert_eq!(
5331            protocol_observation_delay(1, INITIAL_PROTOCOL_OBSERVATION_DELAY, production_cap),
5332            Duration::from_millis(250)
5333        );
5334        assert_eq!(
5335            protocol_observation_delay(2, INITIAL_PROTOCOL_OBSERVATION_DELAY, production_cap),
5336            Duration::from_millis(500)
5337        );
5338        assert_eq!(
5339            protocol_observation_delay(5, INITIAL_PROTOCOL_OBSERVATION_DELAY, production_cap),
5340            Duration::from_secs(4)
5341        );
5342        assert_eq!(
5343            protocol_observation_delay(6, INITIAL_PROTOCOL_OBSERVATION_DELAY, production_cap),
5344            Duration::from_secs(5)
5345        );
5346        assert_eq!(
5347            protocol_observation_delay(
5348                u32::MAX,
5349                INITIAL_PROTOCOL_OBSERVATION_DELAY,
5350                production_cap
5351            ),
5352            Duration::from_secs(5)
5353        );
5354        assert_eq!(
5355            protocol_observation_delay(u32::MAX, Duration::from_secs(1), Duration::from_secs(1)),
5356            Duration::from_secs(1)
5357        );
5358    }
5359
5360    #[cfg(unix)]
5361    #[test]
5362    fn icp_1_3_public_non_controller_status_is_typed_unavailable_evidence() {
5363        use std::{fs, os::unix::fs::PermissionsExt};
5364
5365        let canister = "rrkah-fqaaa-aaaaa-aaaaq-cai";
5366        let root = crate::test_support::temp_dir("canic-public-status-projection");
5367        fs::create_dir_all(&root).expect("create public status fixture");
5368        let executable = root.join("icp");
5369        let commands = root.join("commands.log");
5370        let script = format!(
5371            "#!/bin/sh\n\
5372             if [ \"$1\" = \"--version\" ]; then echo 'icp 1.3.0'; exit 0; fi\n\
5373             printf '%s\\n' \"$*\" >> '{}'\n\
5374             printf '%s\\n' '{{\"id\":\"{canister}\",\"controllers\":[\"rdmx6-jaaaa-aaaaa-aaadq-cai\"],\"module_hash\":null}}'\n",
5375            commands.display(),
5376        );
5377        fs::write(&executable, script).expect("write fake ICP 1.3.0");
5378        fs::set_permissions(&executable, fs::Permissions::from_mode(0o755))
5379            .expect("make fake ICP executable");
5380        let desired = DesiredFleet {
5381            bootstrap: None,
5382            canisters: Vec::new(),
5383            cycles_ledger: "um5iw-rqaaa-aaaaq-qaaba-cai".to_string(),
5384            environment: "local".to_string(),
5385            fleet: "public-status".to_string(),
5386            ledger_fee_cycles: "100M".to_string(),
5387            management_creation_fee_cycles: "500B".to_string(),
5388            material_cycle_threshold: "1B".to_string(),
5389            maximum_observation_burn_cycles: "1B".to_string(),
5390            maximum_stalled_observations: 4,
5391            maximum_update_burn_cycles: "1B".to_string(),
5392            operator: "rdmx6-jaaaa-aaaaa-aaadq-cai".to_string(),
5393            protocol: None,
5394            protocol_steps: Vec::new(),
5395            schema_version: 1,
5396            treasury: "treasury".to_string(),
5397        };
5398        let platform =
5399            IcpEnsurePlatform::new(desired, executable.to_string_lossy().as_ref(), &root);
5400
5401        assert!(matches!(
5402            platform.status_optional(canister),
5403            Err(IcpEnsurePlatformError::PublicCanisterStatusUnavailable {
5404                canister: unavailable,
5405            }) if unavailable == canister
5406        ));
5407        assert!(
5408            fs::read_to_string(commands)
5409                .expect("read fake ICP commands")
5410                .contains("canister status rrkah-fqaaa-aaaaa-aaaaq-cai --json")
5411        );
5412    }
5413
5414    #[cfg(unix)]
5415    #[test]
5416    fn versionless_icp_status_uses_exact_typed_management_version() {
5417        #[derive(CandidType)]
5418        struct CanonicalManagementCanisterStatusFixture {
5419            version: u64,
5420            module_hash: Option<Vec<u8>>,
5421        }
5422
5423        use std::{fs, os::unix::fs::PermissionsExt};
5424
5425        let root = crate::test_support::temp_dir("canic-install-version-fallback");
5426        fs::create_dir_all(&root).expect("create version fallback fixture");
5427        let executable = root.join("icp");
5428        let commands = root.join("commands.log");
5429        let canister = "rrkah-fqaaa-aaaaa-aaaaq-cai";
5430        let status_json = format!(
5431            r#"{{"id":"{canister}","name":"coordinator","status":"Running","settings":{{"controllers":["rdmx6-jaaaa-aaaaa-aaadq-cai"]}},"module_hash":"0x{}","memory_size":"1","cycles":"1000000000000","query_stats":{{}}}}"#,
5432            "11".repeat(32),
5433        );
5434        let response_bytes = candid::encode_one(CanonicalManagementCanisterStatusFixture {
5435            version: 42,
5436            module_hash: Some(vec![0x22; 32]),
5437        })
5438        .expect("encode independently modelled management status");
5439        let script = format!(
5440            "#!/bin/sh\n\
5441             if [ \"$1\" = \"--version\" ]; then printf '%s\\n' 'icp 1.3.0'; exit 0; fi\n\
5442             printf '%s\\n' \"$*\" >> '{}'\n\
5443             case \"$*\" in\n\
5444               *\"canister status {canister}\"*) printf '%s\\n' '{}' ;;\n\
5445               *) printf '%s\\n' 'unexpected fake ICP command' >&2; exit 23 ;;\n\
5446             esac\n",
5447            commands.display(),
5448            status_json,
5449        );
5450        fs::write(&executable, script).expect("write fake ICP 1.3.0");
5451        fs::set_permissions(&executable, fs::Permissions::from_mode(0o755))
5452            .expect("make fake ICP executable");
5453        let icp = IcpCli::new(executable.to_string_lossy(), Some("ic".to_string()));
5454
5455        let projected = icp
5456            .canister_status_report(canister)
5457            .expect("read ICP 1.3.0 status projection");
5458        assert_eq!(projected.canister_version, None);
5459        let exact = exact_install_canister_status_with(
5460            canister,
5461            projected.canister_version,
5462            projected.module_hash.map(|hash| normalize_hash(&hash)),
5463            |effective_canister_id| {
5464                assert_eq!(
5465                    effective_canister_id,
5466                    canister.parse().expect("target Principal")
5467                );
5468                candid::decode_one(&response_bytes).map_err(IcpManagementCallError::CandidResponse)
5469            },
5470        )
5471        .expect("obtain exact typed management status");
5472        assert_eq!(exact.canister_version, 42);
5473        assert_eq!(exact.module_sha256, Some("22".repeat(32)));
5474        let commands = fs::read_to_string(&commands).expect("read fake ICP commands");
5475        assert!(commands.contains("canister status rrkah-fqaaa-aaaaa-aaaaq-cai"));
5476        assert!(!commands.contains("canister call aaaaa-aa canister_status"));
5477
5478        fs::write(
5479            &executable,
5480            "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then echo 'icp 1.3.0'; exit 0; fi\necho '{\"not_response_bytes\":true}'\n",
5481        )
5482        .expect("replace unavailable management fixture");
5483        let error = exact_install_canister_status_with(canister, None, None, |_| {
5484            Err(IcpManagementCallError::MissingEnvironment)
5485        })
5486        .expect_err("missing projected and typed version must fail closed");
5487        assert!(matches!(
5488            error,
5489            IcpEnsurePlatformError::InstallVersionProofUnavailable {
5490                canister: observed_canister,
5491                source,
5492            } if observed_canister == canister
5493                && matches!(source.as_ref(), IcpManagementCallError::MissingEnvironment)
5494        ));
5495
5496        fs::remove_dir_all(root).expect("remove version fallback fixture");
5497    }
5498
5499    #[test]
5500    fn root_owned_observation_classifies_bootstrap_and_workload_lifecycle() {
5501        assert_eq!(
5502            root_owned_lifecycle(DesiredCanisterKind::Store, &CanisterPoolAssetStatus::Store),
5503            Some(RootOwnedCanisterLifecycle::Store)
5504        );
5505        assert_eq!(
5506            root_owned_lifecycle(DesiredCanisterKind::Pool, &CanisterPoolAssetStatus::Ready),
5507            Some(RootOwnedCanisterLifecycle::Idle)
5508        );
5509        assert_eq!(
5510            root_owned_lifecycle(
5511                DesiredCanisterKind::Pool,
5512                &CanisterPoolAssetStatus::Claimed {
5513                    claim: canic_core::dto::pool::CanisterPoolClaim {
5514                        component: canic_core::ids::ComponentInstanceId::from_generated_bytes(
5515                            [1; 32]
5516                        ),
5517                        operation_id: [2; 32],
5518                    },
5519                }
5520            ),
5521            Some(RootOwnedCanisterLifecycle::Claimed)
5522        );
5523        assert_eq!(
5524            root_owned_lifecycle(
5525                DesiredCanisterKind::Pool,
5526                &CanisterPoolAssetStatus::Workload {
5527                    claim: canic_core::dto::pool::CanisterPoolClaim {
5528                        component: canic_core::ids::ComponentInstanceId::from_generated_bytes(
5529                            [1; 32]
5530                        ),
5531                        operation_id: [2; 32],
5532                    },
5533                }
5534            ),
5535            Some(RootOwnedCanisterLifecycle::Workload)
5536        );
5537        assert_eq!(
5538            root_owned_lifecycle(
5539                DesiredCanisterKind::Pool,
5540                &CanisterPoolAssetStatus::HandingOff {
5541                    recipient: Principal::anonymous(),
5542                }
5543            ),
5544            None
5545        );
5546        assert_eq!(
5547            root_owned_lifecycle(
5548                DesiredCanisterKind::Store,
5549                &CanisterPoolAssetStatus::PendingReset
5550            ),
5551            None
5552        );
5553    }
5554
5555    #[test]
5556    fn estate_pool_inventory_reconciles_every_lifecycle_and_declared_total() {
5557        let claim = canic_core::dto::pool::CanisterPoolClaim {
5558            component: canic_core::ids::ComponentInstanceId::from_generated_bytes([1; 32]),
5559            operation_id: [2; 32],
5560        };
5561        let statuses = [
5562            CanisterPoolAssetStatus::Store,
5563            CanisterPoolAssetStatus::StoreDeletionPending {
5564                operation_id: [3; 32],
5565            },
5566            CanisterPoolAssetStatus::PendingReset,
5567            CanisterPoolAssetStatus::Ready,
5568            CanisterPoolAssetStatus::Claimed {
5569                claim: claim.clone(),
5570            },
5571            CanisterPoolAssetStatus::Workload {
5572                claim: claim.clone(),
5573            },
5574            CanisterPoolAssetStatus::Recycling {
5575                claim,
5576                reset: canic_core::dto::pool::CanisterPoolRecycleReset::Pending,
5577            },
5578            CanisterPoolAssetStatus::HandingOff {
5579                recipient: Principal::anonymous(),
5580            },
5581            CanisterPoolAssetStatus::Failed {
5582                reason: "retained failure".to_string(),
5583            },
5584        ];
5585        let mut observed = EstatePoolLifecycleCounts::default();
5586        for status in &statuses {
5587            observed.observe(status).expect("bounded lifecycle count");
5588        }
5589        let authority = EstatePoolPageAuthority {
5590            completed_handoffs: 0,
5591            counts: observed,
5592            pending_handoff: None,
5593            pooled: 4,
5594            surplus: 1,
5595        };
5596
5597        assert!(authority.matches_complete_inventory(observed, 3));
5598        let mut incomplete = observed;
5599        incomplete.failed -= 1;
5600        incomplete.tracked -= 1;
5601        assert!(!authority.matches_complete_inventory(incomplete, 3));
5602
5603        assert_eq!(estate_pool_lifecycle(&statuses[0]), None);
5604        assert_eq!(estate_pool_lifecycle(&statuses[1]), None);
5605        assert_eq!(
5606            statuses[2..]
5607                .iter()
5608                .map(estate_pool_lifecycle)
5609                .collect::<Vec<_>>(),
5610            vec![
5611                Some(EstatePoolAssetLifecycle::PendingReset),
5612                Some(EstatePoolAssetLifecycle::Ready),
5613                Some(EstatePoolAssetLifecycle::Claimed),
5614                Some(EstatePoolAssetLifecycle::Workload),
5615                Some(EstatePoolAssetLifecycle::Recycling),
5616                Some(EstatePoolAssetLifecycle::HandingOff),
5617                Some(EstatePoolAssetLifecycle::Failed),
5618            ]
5619        );
5620    }
5621
5622    #[test]
5623    fn estate_funding_requires_exact_source_debit_and_destination_credit() {
5624        let exact = EstateFundingObservation {
5625            amount: 60,
5626            destination_after: 100,
5627            destination_before: Some(40),
5628            expected_destination_after: 100,
5629            ledger_fee_cycles: 5,
5630            source_after: 135,
5631            source_before: Some(200),
5632        };
5633        assert!(estate_funding_applied(exact));
5634
5635        for drifted in [
5636            EstateFundingObservation {
5637                source_after: 136,
5638                ..exact
5639            },
5640            EstateFundingObservation {
5641                destination_after: 99,
5642                ..exact
5643            },
5644            EstateFundingObservation {
5645                source_before: None,
5646                ..exact
5647            },
5648            EstateFundingObservation {
5649                destination_before: None,
5650                ..exact
5651            },
5652        ] {
5653            assert!(!estate_funding_applied(drifted));
5654        }
5655    }
5656
5657    #[test]
5658    #[expect(
5659        clippy::too_many_lines,
5660        reason = "the binding regression mutates each authority field independently"
5661    )]
5662    fn retained_root_owned_topology_requires_exact_child_root_and_parent_binding() {
5663        let mut state = FleetEnsureStateRecord {
5664            active_registry: None,
5665            completed_reinstall_action_sha256: BTreeMap::new(),
5666            completed_reinstall_operation_id: None,
5667            completed_reinstalls: BTreeMap::new(),
5668            fleet: "fleet".to_string(),
5669            pending_principals: BTreeMap::new(),
5670            principals: BTreeMap::from([
5671                ("root".to_string(), "root-principal".to_string()),
5672                ("store".to_string(), "store-principal".to_string()),
5673            ]),
5674            retained_cycles_by_principal: BTreeMap::new(),
5675            schema_version: crate::fleet_ensure::model::FLEET_ENSURE_SCHEMA_VERSION,
5676            topology: BTreeMap::from([(
5677                "store".to_string(),
5678                crate::fleet_ensure::model::FleetEnsureTopologyRecord {
5679                    kind: DesiredCanisterKind::Store,
5680                    module_hash: Some("11".repeat(32)),
5681                    parent: Some("root".to_string()),
5682                    protocol_binding: None,
5683                    role: None,
5684                },
5685            )]),
5686        };
5687
5688        assert!(
5689            exact_retained_root_owned_topology(
5690                &state,
5691                "store",
5692                DesiredCanisterKind::Store,
5693                "root",
5694                "store-principal",
5695                "root-principal",
5696            )
5697            .is_some()
5698        );
5699        assert!(
5700            exact_retained_root_owned_topology(
5701                &state,
5702                "store",
5703                DesiredCanisterKind::Store,
5704                "root",
5705                "foreign-store",
5706                "root-principal",
5707            )
5708            .is_none()
5709        );
5710        state
5711            .principals
5712            .insert("store".to_string(), "store-principal".to_string());
5713        state
5714            .principals
5715            .insert("root".to_string(), "foreign-root".to_string());
5716        assert!(
5717            exact_retained_root_owned_topology(
5718                &state,
5719                "store",
5720                DesiredCanisterKind::Store,
5721                "root",
5722                "store-principal",
5723                "root-principal",
5724            )
5725            .is_none()
5726        );
5727        state
5728            .principals
5729            .insert("root".to_string(), "root-principal".to_string());
5730        state
5731            .topology
5732            .get_mut("store")
5733            .expect("Store topology")
5734            .kind = DesiredCanisterKind::Pool;
5735        assert!(
5736            exact_retained_root_owned_topology(
5737                &state,
5738                "store",
5739                DesiredCanisterKind::Store,
5740                "root",
5741                "store-principal",
5742                "root-principal",
5743            )
5744            .is_none()
5745        );
5746        state
5747            .topology
5748            .get_mut("store")
5749            .expect("Store topology")
5750            .kind = DesiredCanisterKind::Store;
5751        state
5752            .topology
5753            .get_mut("store")
5754            .expect("Store topology")
5755            .parent = Some("foreign-root".to_string());
5756        assert!(
5757            exact_retained_root_owned_topology(
5758                &state,
5759                "store",
5760                DesiredCanisterKind::Store,
5761                "root",
5762                "store-principal",
5763                "root-principal",
5764            )
5765            .is_none()
5766        );
5767        state
5768            .topology
5769            .get_mut("store")
5770            .expect("Store topology")
5771            .parent = Some("root".to_string());
5772        state.principals.clear();
5773        state.pending_principals = BTreeMap::from([
5774            ("root".to_string(), "root-principal".to_string()),
5775            ("store".to_string(), "store-principal".to_string()),
5776        ]);
5777        assert!(
5778            exact_retained_root_owned_topology(
5779                &state,
5780                "store",
5781                DesiredCanisterKind::Store,
5782                "root",
5783                "store-principal",
5784                "root-principal",
5785            )
5786            .is_some()
5787        );
5788        state
5789            .principals
5790            .insert("store".to_string(), "foreign-store".to_string());
5791        assert!(
5792            exact_retained_root_owned_topology(
5793                &state,
5794                "store",
5795                DesiredCanisterKind::Store,
5796                "root",
5797                "store-principal",
5798                "root-principal",
5799            )
5800            .is_none()
5801        );
5802    }
5803
5804    #[test]
5805    fn completed_reinstall_requires_exact_continuity_when_ordinary_status_has_no_version() {
5806        let desired = "11".repeat(32);
5807        assert_eq!(
5808            completed_reinstall_continuity(7, true, true, Some(&desired), &desired, None,),
5809            Ok(())
5810        );
5811        assert_eq!(
5812            completed_reinstall_continuity(7, true, true, Some(&desired), &desired, Some(8),),
5813            Ok(())
5814        );
5815        for (rejected, field) in [
5816            (
5817                completed_reinstall_continuity(7, false, true, Some(&desired), &desired, None),
5818                "Principal",
5819            ),
5820            (
5821                completed_reinstall_continuity(7, true, false, Some(&desired), &desired, None),
5822                "Root/parent/kind topology",
5823            ),
5824            (
5825                completed_reinstall_continuity(
5826                    7,
5827                    true,
5828                    true,
5829                    Some(&"22".repeat(32)),
5830                    &desired,
5831                    None,
5832                ),
5833                "module SHA-256",
5834            ),
5835            (
5836                completed_reinstall_continuity(7, true, true, Some(&desired), &desired, Some(7)),
5837                "canister version",
5838            ),
5839        ] {
5840            assert_eq!(rejected, Err(field));
5841        }
5842    }
5843
5844    #[test]
5845    fn fresh_fleet_create_responses_retain_the_exact_requested_balance() {
5846        let canister = Principal::from_slice(&[9; 29]);
5847        let canister_text = canister.to_text();
5848        for (response, receipt) in [
5849            (
5850                Ok(CreateCanisterSuccess {
5851                    block_id: Nat::from(41_u8),
5852                    canister_id: canister,
5853                }),
5854                "41",
5855            ),
5856            (
5857                Err(CreateCanisterError::Duplicate {
5858                    duplicate_of: Nat::from(41_u8),
5859                    canister_id: Some(canister),
5860                }),
5861                "41",
5862            ),
5863        ] {
5864            let outcome = create_canister_response_outcome(response, 5_000)
5865                .expect("successful or duplicate-with-Principal Create response");
5866            assert_eq!(
5867                outcome.created_principal.as_deref(),
5868                Some(canister_text.as_str())
5869            );
5870            assert_eq!(outcome.post_cycles, Some(5_000));
5871            assert_eq!(outcome.receipt.as_deref(), Some(receipt));
5872        }
5873    }
5874
5875    #[test]
5876    fn root_funding_inspection_requires_exact_controller_and_pool_shape() {
5877        let root = Principal::from_slice(&[7; 29]);
5878        let root_text = root.to_text();
5879        let cycles = Nat::from(2_898_749_313_788_u128);
5880        assert_eq!(
5881            validate_root_controlled_inspection(
5882                "pool-0",
5883                InspectedModule::Empty,
5884                &root_text,
5885                &[root],
5886                None,
5887                &cycles,
5888            )
5889            .expect("exact Root-authorized pool inspection"),
5890            2_898_749_313_788,
5891        );
5892
5893        assert_eq!(
5894            validate_root_controlled_inspection(
5895                "pending-reset",
5896                InspectedModule::Any,
5897                &root_text,
5898                &[root],
5899                Some(&[1]),
5900                &cycles,
5901            )
5902            .expect("installed controlled asset remains part of conservation"),
5903            2_898_749_313_788,
5904        );
5905        let foreign = Principal::from_slice(&[8; 29]);
5906        for rejected in [
5907            validate_root_controlled_inspection(
5908                "pending-reset",
5909                InspectedModule::Any,
5910                &root_text,
5911                &[foreign],
5912                Some(&[1]),
5913                &cycles,
5914            ),
5915            validate_root_controlled_inspection(
5916                "pool-0",
5917                InspectedModule::Empty,
5918                &root_text,
5919                &[foreign],
5920                None,
5921                &cycles,
5922            ),
5923            validate_root_controlled_inspection(
5924                "pool-0",
5925                InspectedModule::Empty,
5926                &root_text,
5927                &[root],
5928                Some(&[1]),
5929                &cycles,
5930            ),
5931        ] {
5932            assert!(matches!(
5933                rejected,
5934                Err(IcpEnsurePlatformError::FundingInspectionAuthorityConflict { .. })
5935            ));
5936        }
5937    }
5938
5939    #[test]
5940    fn pending_fresh_pool_observation_accepts_only_its_reviewed_controller_transition() {
5941        let root = Principal::from_slice(&[7; 29]);
5942        let operator = Principal::from_slice(&[8; 29]);
5943        let foreign = Principal::from_slice(&[9; 29]);
5944        for controllers in [vec![root, operator], vec![root]] {
5945            assert_eq!(
5946                validate_pending_fresh_pool_inspection(
5947                    "pool",
5948                    &root.to_text(),
5949                    &operator.to_text(),
5950                    &controllers,
5951                    None,
5952                    &Nat::from(17_u8),
5953                )
5954                .expect("issued fresh controller transition"),
5955                17,
5956            );
5957        }
5958        for controllers in [
5959            vec![root, foreign],
5960            vec![operator],
5961            vec![root, operator, foreign],
5962            vec![root, root],
5963        ] {
5964            assert!(matches!(
5965                validate_pending_fresh_pool_inspection(
5966                    "pool",
5967                    &root.to_text(),
5968                    &operator.to_text(),
5969                    &controllers,
5970                    None,
5971                    &Nat::from(17_u8),
5972                ),
5973                Err(IcpEnsurePlatformError::FundingInspectionAuthorityConflict { .. })
5974            ));
5975        }
5976    }
5977}