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, query_with_candid},
9    fleet_ensure::{
10        model::{
11            CanisterRuntimeStatus, DesiredCanisterKind, DesiredFleet, EffectRecord, EnsureAction,
12            FleetEnsureStateRecord, FleetObservation, InstallMode, LiveCanister,
13            RetirementTransferBalances, RetirementTransferInvariantError,
14            RetirementTransferReconciliation, RootOwnedCanisterLifecycle,
15            reconcile_retirement_transfer,
16        },
17        ops::{
18            EffectObservation, EffectOutcome, EffectRetry, EnsurePlatform, TerminalFleetInventory,
19            canic_init, current_protocol, protocol, root_owned_lifecycle,
20        },
21    },
22    icp::{IcpCandidCallError, IcpCli, IcpCommandError, IcpDiagnostic, run_status},
23};
24use candid::{CandidType, Nat, Principal};
25use canic_core::{
26    cdk::types::Cycles,
27    dto::pool::{CanisterPoolResponse, CanisterPoolStatusRequest},
28    protocol as canic_protocol,
29};
30use serde::Deserialize;
31use std::{
32    cell::RefCell,
33    collections::{BTreeMap, BTreeSet},
34    path::{Path, PathBuf},
35};
36use thiserror::Error as ThisError;
37
38/// Exact input expected by a configured cycle-safe retirement endpoint.
39
40#[derive(CandidType)]
41struct DrainRequest {
42    operation_id: String,
43    destination: Principal,
44    cycles: Nat,
45}
46
47#[derive(CandidType, Deserialize)]
48enum DrainResponse {
49    Accepted { transferred_cycles: Nat },
50    Replayed { transferred_cycles: Nat },
51}
52
53#[derive(CandidType)]
54enum RootPoolStatusRequest {
55    Pool(CanisterPoolStatusRequest),
56}
57
58#[derive(CandidType, Deserialize)]
59enum RootPoolStatusResponse {
60    Pool(Box<CanisterPoolResponse>),
61}
62
63#[derive(CandidType)]
64enum ManagedCanisterStatusRequest {
65    CycleBalance,
66}
67
68#[derive(CandidType, Deserialize)]
69enum ManagedCanisterStatusResponse {
70    CycleBalance(canic_core::dto::role::CycleBalanceStatusResponse),
71}
72
73#[derive(CandidType)]
74struct CreateCanisterArgs {
75    amount: Nat,
76    created_at_time: Option<u64>,
77    creation_args: Option<CmcCreateCanisterArgs>,
78    from_subaccount: Option<[u8; 32]>,
79}
80
81#[derive(CandidType)]
82struct CmcCreateCanisterArgs {
83    settings: Option<CanisterSettings>,
84    subnet_selection: Option<SubnetSelection>,
85}
86
87#[derive(CandidType)]
88struct CanisterSettings {
89    compute_allocation: Option<Nat>,
90    controllers: Option<Vec<Principal>>,
91    freezing_threshold: Option<Nat>,
92    memory_allocation: Option<Nat>,
93    reserved_cycles_limit: Option<Nat>,
94}
95
96struct CreateCanisterAuthority<'a> {
97    controller_canisters: &'a [String],
98    controllers: &'a [String],
99    created_at_time: u64,
100    ledger: &'a str,
101    requested_initial_cycles: u128,
102    subnet: &'a str,
103}
104
105#[derive(CandidType)]
106enum SubnetSelection {
107    Subnet { subnet: Principal },
108}
109
110#[derive(CandidType, Deserialize)]
111struct CreateCanisterSuccess {
112    block_id: Nat,
113    canister_id: Principal,
114}
115
116#[derive(CandidType, Deserialize)]
117enum CreateCanisterError {
118    CreatedInFuture {
119        ledger_time: u64,
120    },
121    Duplicate {
122        duplicate_of: Nat,
123        canister_id: Option<Principal>,
124    },
125    FailedToCreate {
126        error: String,
127        fee_block: Option<Nat>,
128        refund_block: Option<Nat>,
129    },
130    GenericError {
131        error_code: Nat,
132        message: String,
133    },
134    InsufficientFunds {
135        balance: Nat,
136    },
137    TemporarilyUnavailable,
138    TooOld,
139}
140
141#[derive(CandidType)]
142struct WithdrawArgs {
143    amount: Nat,
144    created_at_time: Option<u64>,
145    from_subaccount: Option<[u8; 32]>,
146    to: Principal,
147}
148
149#[derive(CandidType, Deserialize)]
150enum WithdrawError {
151    BadFee {
152        expected_fee: Nat,
153    },
154    CreatedInFuture {
155        ledger_time: u64,
156    },
157    Duplicate {
158        duplicate_of: Nat,
159    },
160    FailedToWithdraw {
161        fee_block: Option<Nat>,
162        rejection_code: RejectionCode,
163        rejection_reason: String,
164    },
165    GenericError {
166        error_code: Nat,
167        message: String,
168    },
169    InsufficientFunds {
170        balance: Nat,
171    },
172    InvalidReceiver {
173        receiver: Principal,
174    },
175    TemporarilyUnavailable,
176    TooOld,
177}
178
179#[derive(CandidType, Deserialize)]
180enum RejectionCode {
181    CanisterError,
182    CanisterReject,
183    DestinationInvalid,
184    NoError,
185    SysFatal,
186    SysTransient,
187    Unknown,
188}
189
190/// Typed failure at the current IC effect boundary.
191
192#[derive(Debug, ThisError)]
193pub enum IcpEnsurePlatformError {
194    #[error("cycle arithmetic overflow while executing {0}")]
195    Arithmetic(&'static str),
196
197    #[error("configured Candid file is not a regular file: {}", .0.display())]
198    CandidUnavailable(PathBuf),
199
200    #[error("configured effect artifact is unavailable: {}", .0.display())]
201    ArtifactUnavailable(PathBuf),
202
203    #[error("configured {kind} changed after plan review: expected {expected}, observed {actual}")]
204    ArtifactDigestMismatch {
205        actual: String,
206        expected: String,
207        kind: &'static str,
208    },
209
210    #[error("configured Principal is invalid for {field}: {value}")]
211    InvalidPrincipal { field: &'static str, value: String },
212
213    #[error("Cycles Ledger returned an unsupported fee value: {0}")]
214    InvalidLedgerFee(String),
215
216    #[error("active ICP identity is {actual}, but reviewed Fleet operator is {expected}")]
217    OperatorMismatch { actual: String, expected: String },
218
219    #[error("ICP status has invalid cycle balance for {canister}: {value}")]
220    InvalidStatusCycles { canister: String, value: String },
221
222    #[error("ICP status omitted exact {field} required for controlled canister {canister}")]
223    IncompleteCanisterStatus {
224        canister: String,
225        field: &'static str,
226    },
227
228    #[error("ICP status omitted the canister version required to prove install on {canister}")]
229    MissingCanisterVersion { canister: String },
230
231    #[error(transparent)]
232    Candid(#[from] IcpCandidCallError),
233
234    #[error(transparent)]
235    Icp(#[from] IcpCommandError),
236
237    #[error("Cycles Ledger create_canister failed: {0}")]
238    LedgerCreate(String),
239
240    #[error("Cycles Ledger duplicate does not yet identify its created canister")]
241    LedgerCreatePending,
242
243    #[error("Cycles Ledger withdraw failed: {0}")]
244    LedgerWithdraw(String),
245
246    #[error(
247        "canister {canister} retains {actual} cycles above deletion limit {maximum}; left untouched"
248    )]
249    MaterialCycles {
250        actual: u128,
251        canister: String,
252        maximum: u128,
253    },
254
255    #[error("canister {canister} is not stopped; deletion was not attempted")]
256    NotStopped { canister: String },
257
258    #[error("effect references unresolved created canister {0}")]
259    UnresolvedCreated(String),
260
261    #[error("configured retirement endpoint transferred {actual} cycles, expected {expected}")]
262    WrongTransferAmount { actual: Nat, expected: u128 },
263
264    #[error(
265        "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"
266    )]
267    UnbalancedTransfer {
268        canister: String,
269        destination_credit: u128,
270        expected: u128,
271        maximum_source_debit: u128,
272        source_debit: u128,
273    },
274
275    #[error("retirement transfer for {canister} is missing its durable {side} balance")]
276    MissingTransferBalance {
277        canister: String,
278        side: &'static str,
279    },
280
281    #[error("retirement transfer balance for {canister} moved in an invalid direction")]
282    InvalidTransferBalance { canister: String },
283
284    #[error(transparent)]
285    CurrentProtocol(#[from] current_protocol::CurrentProtocolError),
286
287    #[error(transparent)]
288    CanicInit(#[from] canic_init::CanicInitError),
289
290    #[error(transparent)]
291    Protocol(#[from] protocol::ProtocolEffectError),
292}
293
294/// Production ICP adapter for the current desired Fleet.
295pub struct IcpEnsurePlatform {
296    desired: DesiredFleet,
297    icp: IcpCli,
298    recovery_reinstalls: RefCell<BTreeSet<String>>,
299    root: PathBuf,
300}
301
302#[derive(Clone, Copy)]
303enum RetainedRootOwnedObservationMode {
304    DeferredUntilRootStart,
305    ReinstallRecovery,
306}
307
308impl IcpEnsurePlatform {
309    #[must_use]
310    pub fn new(desired: DesiredFleet, icp_executable: &str, root: &Path) -> Self {
311        let icp = IcpCli::new(icp_executable, Some(desired.environment.clone()))
312            .with_cwd(root.to_path_buf());
313        Self {
314            desired,
315            icp,
316            recovery_reinstalls: RefCell::new(BTreeSet::new()),
317            root: root.to_path_buf(),
318        }
319    }
320
321    fn require_operator(&self) -> Result<(), IcpEnsurePlatformError> {
322        let actual = self.icp.identity_principal_text()?;
323        if actual != self.desired.operator {
324            return Err(IcpEnsurePlatformError::OperatorMismatch {
325                actual,
326                expected: self.desired.operator.clone(),
327            });
328        }
329        Ok(())
330    }
331
332    fn current_principal<'a>(
333        &'a self,
334        state: &'a FleetEnsureStateRecord,
335        name: &str,
336    ) -> Option<&'a str> {
337        state
338            .pending_principals
339            .get(name)
340            .or_else(|| state.principals.get(name))
341            .map(String::as_str)
342            .or_else(|| {
343                self.desired
344                    .canisters
345                    .iter()
346                    .find(|configured| configured.name == name)
347                    .and_then(|configured| configured.principal.as_deref())
348            })
349    }
350
351    fn protocol_principals(&self, state: &FleetEnsureStateRecord) -> BTreeMap<String, String> {
352        self.desired
353            .canisters
354            .iter()
355            .filter_map(|configured| {
356                state
357                    .pending_principals
358                    .get(&configured.name)
359                    .or_else(|| state.principals.get(&configured.name))
360                    .or(configured.principal.as_ref())
361                    .map(|principal| (configured.name.clone(), principal.clone()))
362            })
363            .collect()
364    }
365
366    fn resolved_controllers(
367        &self,
368        state: &FleetEnsureStateRecord,
369        controllers: &[String],
370        controller_canisters: &[String],
371    ) -> Result<Vec<String>, IcpEnsurePlatformError> {
372        let mut resolved = controllers.to_vec();
373        for name in controller_canisters {
374            resolved.push(
375                self.current_principal(state, name)
376                    .ok_or_else(|| IcpEnsurePlatformError::UnresolvedCreated(name.clone()))?
377                    .to_string(),
378            );
379        }
380        resolved.sort();
381        resolved.dedup();
382        Ok(resolved)
383    }
384
385    fn observed_protocol_action(
386        &self,
387        step: &crate::fleet_ensure::model::DesiredProtocolStep,
388        principal: String,
389    ) -> Result<EnsureAction, IcpEnsurePlatformError> {
390        Ok(EnsureAction::Protocol {
391            candid: step.candid.clone(),
392            candid_sha256: artifact_hash(&resolve_path(&self.root, &step.candid))?,
393            command_args: step.command_args.clone(),
394            command_args_sha256: artifact_hash(&resolve_path(&self.root, &step.command_args))?,
395            command_method: step.command_method.clone(),
396            expected_status: step.expected_status.clone(),
397            expected_status_sha256: artifact_hash(&resolve_path(
398                &self.root,
399                &step.expected_status,
400            ))?,
401            maximum_execution_burn_cycles: step
402                .maximum_execution_burn_cycles
403                .parse()
404                .map_err(|_| IcpEnsurePlatformError::Arithmetic("protocol burn"))?,
405            name: step.name.clone(),
406            principal,
407            status_args: step.status_args.clone(),
408            status_args_sha256: artifact_hash(&resolve_path(&self.root, &step.status_args))?,
409            status_method: step.status_method.clone(),
410        })
411    }
412
413    fn action_principal<'a>(
414        state: &'a FleetEnsureStateRecord,
415        principal: &'a str,
416    ) -> Result<&'a str, IcpEnsurePlatformError> {
417        if let Some(name) = principal.strip_prefix("created:") {
418            return state
419                .pending_principals
420                .get(name)
421                .map(String::as_str)
422                .ok_or_else(|| IcpEnsurePlatformError::UnresolvedCreated(name.to_string()));
423        }
424        Ok(principal)
425    }
426
427    fn status_optional(
428        &self,
429        principal: &str,
430    ) -> Result<Option<LiveCanister>, IcpEnsurePlatformError> {
431        let report = match self.icp.canister_status_report(principal) {
432            Ok(report) => report,
433            Err(error)
434                if matches!(
435                    error.diagnostic(),
436                    Some(IcpDiagnostic::CanisterNotFound { .. })
437                ) =>
438            {
439                return Ok(None);
440            }
441            Err(error) => return Err(error.into()),
442        };
443        let cycles_text =
444            report
445                .cycles
446                .ok_or_else(|| IcpEnsurePlatformError::IncompleteCanisterStatus {
447                    canister: principal.to_string(),
448                    field: "cycles",
449                })?;
450        let cycles = parse_status_cycles(&cycles_text).ok_or_else(|| {
451            IcpEnsurePlatformError::InvalidStatusCycles {
452                canister: principal.to_string(),
453                value: cycles_text,
454            }
455        })?;
456        let mut controllers = report
457            .settings
458            .ok_or_else(|| IcpEnsurePlatformError::IncompleteCanisterStatus {
459                canister: principal.to_string(),
460                field: "controllers",
461            })?
462            .controllers;
463        controllers.sort();
464        let status = match report.status.to_ascii_lowercase().as_str() {
465            "stopped" => CanisterRuntimeStatus::Stopped,
466            "stopping" => CanisterRuntimeStatus::Stopping,
467            _ => CanisterRuntimeStatus::Running,
468        };
469        Ok(Some(LiveCanister {
470            canister_version: report.canister_version,
471            controllers,
472            cycles,
473            module_sha256: report.module_hash.map(|hash| normalize_hash(&hash)),
474            principal: principal.to_string(),
475            reinstall_required: false,
476            root_owned_lifecycle: None,
477            status,
478        }))
479    }
480
481    fn has_stopped_retained_protocol_owner(
482        &self,
483        state: &FleetEnsureStateRecord,
484    ) -> Result<bool, IcpEnsurePlatformError> {
485        for configured in self.desired.canisters.iter().filter(|configured| {
486            configured.presence == crate::fleet_ensure::model::DesiredPresence::Present
487                && matches!(
488                    configured.kind,
489                    DesiredCanisterKind::Coordinator
490                        | DesiredCanisterKind::Root
491                        | DesiredCanisterKind::Store
492                )
493        }) {
494            let Some(principal) = self.current_principal(state, &configured.name) else {
495                continue;
496            };
497            let Some(live) = self.status_optional(principal)? else {
498                continue;
499            };
500            if live.status != CanisterRuntimeStatus::Running {
501                return Ok(true);
502            }
503        }
504        Ok(false)
505    }
506
507    fn observe_configured_canister(
508        &self,
509        configured: &crate::fleet_ensure::model::DesiredCanister,
510        principal: &str,
511        state: &FleetEnsureStateRecord,
512    ) -> Result<Option<LiveCanister>, IcpEnsurePlatformError> {
513        if configured.kind == DesiredCanisterKind::Pool {
514            return self.observe_root_owned_canister(configured, principal, state);
515        }
516        match self.status_optional(principal) {
517            Ok(observed) => Ok(observed),
518            Err(error)
519                if matches!(
520                    configured.kind,
521                    DesiredCanisterKind::Store | DesiredCanisterKind::Pool
522                ) && !matches!(
523                    error,
524                    IcpEnsurePlatformError::Icp(ref source)
525                        if matches!(
526                            source.diagnostic(),
527                            Some(IcpDiagnostic::CanisterNotFound { .. })
528                        )
529                ) =>
530            {
531                self.observe_root_owned_canister(configured, principal, state)
532            }
533            Err(error) => Err(error),
534        }
535    }
536
537    fn observe_root_owned_canister(
538        &self,
539        configured: &crate::fleet_ensure::model::DesiredCanister,
540        principal: &str,
541        state: &FleetEnsureStateRecord,
542    ) -> Result<Option<LiveCanister>, IcpEnsurePlatformError> {
543        let candid = self.root_protocol_candid()?;
544        let parent = configured.parent.as_deref().ok_or_else(|| {
545            current_protocol::CurrentProtocolError::Configuration(format!(
546                "Root-owned canister {} has no Root parent",
547                configured.name
548            ))
549        })?;
550        let root = self.current_principal(state, parent).ok_or_else(|| {
551            current_protocol::CurrentProtocolError::Configuration(format!(
552                "Root-owned canister {} has no resolved Root",
553                configured.name
554            ))
555        })?;
556        let retained_observation = |mode| {
557            self.retained_root_owned_observation(configured, principal, parent, root, state, mode)
558        };
559        if self.required_root_status(&configured.name, root)? == CanisterRuntimeStatus::Stopped {
560            return retained_observation(RetainedRootOwnedObservationMode::DeferredUntilRootStart);
561        }
562        let target = parse_principal("Root-owned canister", principal)?;
563        let mut start_after = None;
564        loop {
565            let response: Result<RootPoolStatusResponse, CanisterProtocolError> = query_with_candid(
566                &self.icp,
567                &candid,
568                parse_principal("Fleet Subnet Root", root)?,
569                canic_protocol::CANIC_STATUS,
570                &RootPoolStatusRequest::Pool(CanisterPoolStatusRequest {
571                    start_after,
572                    limit: 256,
573                }),
574            );
575            let response = match response {
576                Ok(response) => response,
577                Err(error) if recoverable_root_status_error(&error) => {
578                    return retained_observation(
579                        RetainedRootOwnedObservationMode::ReinstallRecovery,
580                    );
581                }
582                Err(error) => {
583                    return Err(current_protocol::CurrentProtocolError::from(error).into());
584                }
585            };
586            let RootPoolStatusResponse::Pool(page) = response;
587            if let Some(asset) = page
588                .entries
589                .into_iter()
590                .find(|asset| asset.canister_id == target)
591            {
592                if matches!(
593                    asset.status,
594                    canic_core::dto::pool::CanisterPoolAssetStatus::PendingReset
595                ) && asset.cycles.to_u128() == 0
596                {
597                    return retained_observation(
598                        RetainedRootOwnedObservationMode::ReinstallRecovery,
599                    );
600                }
601                let Some(root_owned_lifecycle) =
602                    root_owned_lifecycle(configured.kind, &asset.status)
603                else {
604                    return Err(current_protocol::CurrentProtocolError::Configuration(format!(
605                        "Root-owned canister {} has a live pool role incompatible with desired {:?}",
606                        configured.name, configured.kind
607                    ))
608                    .into());
609                };
610                return Ok(Some(LiveCanister {
611                    canister_version: None,
612                    controllers: vec![root.to_string()],
613                    cycles: asset.cycles.to_u128(),
614                    module_sha256: None,
615                    principal: principal.to_string(),
616                    reinstall_required: false,
617                    root_owned_lifecycle: Some(root_owned_lifecycle),
618                    status: match root_owned_lifecycle {
619                        RootOwnedCanisterLifecycle::Store
620                        | RootOwnedCanisterLifecycle::Workload => CanisterRuntimeStatus::Running,
621                        RootOwnedCanisterLifecycle::Claimed
622                        | RootOwnedCanisterLifecycle::Idle
623                        | RootOwnedCanisterLifecycle::Retained => CanisterRuntimeStatus::Stopped,
624                    },
625                }));
626            }
627            let next = page.next_start_after;
628            if next.is_none() {
629                return Ok(None);
630            }
631            if next == start_after {
632                return Err(
633                    current_protocol::CurrentProtocolError::Configuration(format!(
634                        "Root {parent} pool cursor did not advance"
635                    ))
636                    .into(),
637                );
638            }
639            start_after = next;
640        }
641    }
642
643    fn root_protocol_candid(&self) -> Result<PathBuf, IcpEnsurePlatformError> {
644        let protocol = self.desired.protocol.as_ref().ok_or_else(|| {
645            current_protocol::CurrentProtocolError::Configuration(
646                "Root-owned observation requires typed Fleet protocol".to_string(),
647            )
648        })?;
649        Ok(resolve_path(&self.root, &protocol.root_candid))
650    }
651
652    fn required_root_status(
653        &self,
654        configured_name: &str,
655        root: &str,
656    ) -> Result<CanisterRuntimeStatus, IcpEnsurePlatformError> {
657        self.status_optional(root)?
658            .map(|live| live.status)
659            .ok_or_else(|| {
660                current_protocol::CurrentProtocolError::Configuration(format!(
661                    "Root-owned canister {configured_name} has no live Root"
662                ))
663                .into()
664            })
665    }
666
667    fn retained_root_owned_observation(
668        &self,
669        configured: &crate::fleet_ensure::model::DesiredCanister,
670        principal: &str,
671        parent: &str,
672        root: &str,
673        state: &FleetEnsureStateRecord,
674        mode: RetainedRootOwnedObservationMode,
675    ) -> Result<Option<LiveCanister>, IcpEnsurePlatformError> {
676        let Some(retained_topology) = exact_retained_root_owned_topology(
677            state,
678            &configured.name,
679            configured.kind,
680            parent,
681            principal,
682            root,
683        ) else {
684            return Err(
685                current_protocol::CurrentProtocolError::Configuration(format!(
686                    "Root-owned canister {} has no exact retained topology authority",
687                    configured.name
688                ))
689                .into(),
690            );
691        };
692        let cycles = self
693            .public_cycle_balance(principal)
694            .or_else(|| state.retained_cycles_by_principal.get(principal).copied())
695            .ok_or_else(|| {
696                current_protocol::CurrentProtocolError::Configuration(format!(
697                    "Root-owned canister {} has no current or retained exact native balance",
698                    configured.name
699                ))
700            })?;
701        if !matches!(
702            configured.kind,
703            DesiredCanisterKind::Pool | DesiredCanisterKind::Store
704        ) || configured.presence != crate::fleet_ensure::model::DesiredPresence::Present
705            || configured.replace
706            || configured.drain.is_some()
707            || (configured.kind == DesiredCanisterKind::Pool && configured.wasm.is_some())
708            || (configured.kind == DesiredCanisterKind::Store && configured.wasm.is_none())
709        {
710            return Err(
711                current_protocol::CurrentProtocolError::Configuration(format!(
712                    "Root-owned recovery evidence for {} cannot authorize a mutation",
713                    configured.name
714                ))
715                .into(),
716            );
717        }
718        let root_config = self
719            .desired
720            .canisters
721            .iter()
722            .find(|canister| canister.name == parent && canister.kind == DesiredCanisterKind::Root)
723            .ok_or_else(|| {
724                current_protocol::CurrentProtocolError::Configuration(format!(
725                    "Root-owned canister {} has no exact configured Root",
726                    configured.name
727                ))
728            })?;
729        let live_root = self.status_optional(root)?.ok_or_else(|| {
730            current_protocol::CurrentProtocolError::Configuration(format!(
731                "Root-owned canister {} has no live Root",
732                configured.name
733            ))
734        })?;
735        let mut expected_controllers = root_config.controllers.clone();
736        expected_controllers.sort();
737        if live_root.controllers != expected_controllers {
738            return Err(
739                current_protocol::CurrentProtocolError::Configuration(format!(
740                    "Root-owned canister {} Root controllers drifted",
741                    configured.name
742                ))
743                .into(),
744            );
745        }
746        if matches!(mode, RetainedRootOwnedObservationMode::ReinstallRecovery) {
747            self.record_recovery_reinstalls(parent);
748        }
749        Ok(Some(LiveCanister {
750            canister_version: None,
751            controllers: vec![root.to_string()],
752            cycles,
753            module_sha256: retained_topology.module_hash.clone(),
754            principal: principal.to_string(),
755            reinstall_required: false,
756            root_owned_lifecycle: Some(RootOwnedCanisterLifecycle::Retained),
757            status: if configured.kind == DesiredCanisterKind::Store {
758                CanisterRuntimeStatus::Running
759            } else {
760                CanisterRuntimeStatus::Stopped
761            },
762        }))
763    }
764
765    fn record_recovery_reinstalls(&self, root_name: &str) {
766        let mut recovery = self.recovery_reinstalls.borrow_mut();
767        recovery.insert(root_name.to_string());
768        recovery.extend(
769            self.desired
770                .canisters
771                .iter()
772                .filter(|canister| {
773                    canister.kind == DesiredCanisterKind::Coordinator
774                        || (canister.kind == DesiredCanisterKind::Store
775                            && canister.parent.as_deref() == Some(root_name))
776                })
777                .map(|canister| canister.name.clone()),
778        );
779    }
780
781    fn public_cycle_balance(&self, principal: &str) -> Option<u128> {
782        let response: Result<ManagedCanisterStatusResponse, canic_core::dto::error::Error> = self
783            .icp
784            .canister_query_candid(
785                principal,
786                canic_protocol::CANIC_STATUS,
787                &ManagedCanisterStatusRequest::CycleBalance,
788                None,
789            )
790            .ok()?;
791        let ManagedCanisterStatusResponse::CycleBalance(balance) = response.ok()?;
792        Some(balance.cycles)
793    }
794
795    fn apply_create(
796        &self,
797        authority: CreateCanisterAuthority<'_>,
798        state: &FleetEnsureStateRecord,
799    ) -> Result<EffectOutcome, IcpEnsurePlatformError> {
800        let creation_fee = self
801            .desired
802            .management_creation_fee_cycles
803            .parse::<Cycles>()
804            .map(|cycles| cycles.to_u128())
805            .map_err(|_| IcpEnsurePlatformError::Arithmetic("management creation fee"))?;
806        let amount = authority
807            .requested_initial_cycles
808            .checked_add(creation_fee)
809            .ok_or(IcpEnsurePlatformError::Arithmetic("creation amount"))?;
810        let controllers = self
811            .resolved_controllers(state, authority.controllers, authority.controller_canisters)?
812            .iter()
813            .map(|value| parse_principal("controller", value))
814            .collect::<Result<Vec<_>, _>>()?;
815        let request = CreateCanisterArgs {
816            amount: Nat::from(amount),
817            created_at_time: Some(authority.created_at_time),
818            creation_args: Some(CmcCreateCanisterArgs {
819                settings: Some(CanisterSettings {
820                    compute_allocation: None,
821                    controllers: Some(controllers),
822                    freezing_threshold: None,
823                    memory_allocation: None,
824                    reserved_cycles_limit: None,
825                }),
826                subnet_selection: Some(SubnetSelection::Subnet {
827                    subnet: parse_principal("subnet", authority.subnet)?,
828                }),
829            }),
830            from_subaccount: None,
831        };
832        let response: Result<CreateCanisterSuccess, CreateCanisterError> = self
833            .icp
834            .canister_call_candid(authority.ledger, "create_canister", &request, None)?;
835        match response {
836            Ok(success) => Ok(EffectOutcome {
837                created_principal: Some(success.canister_id.to_text()),
838                post_cycles: None,
839                receipt: Some(success.block_id.to_string()),
840            }),
841            Err(CreateCanisterError::Duplicate {
842                duplicate_of,
843                canister_id: Some(canister_id),
844            }) => Ok(EffectOutcome {
845                created_principal: Some(canister_id.to_text()),
846                post_cycles: None,
847                receipt: Some(duplicate_of.to_string()),
848            }),
849            Err(CreateCanisterError::Duplicate {
850                canister_id: None, ..
851            }) => Err(IcpEnsurePlatformError::LedgerCreatePending),
852            Err(error) => Err(IcpEnsurePlatformError::LedgerCreate(render_create_error(
853                error,
854            ))),
855        }
856    }
857
858    fn apply_fund(
859        &self,
860        amount: u128,
861        created_at_time: u64,
862        ledger: &str,
863        principal: &str,
864    ) -> Result<EffectOutcome, IcpEnsurePlatformError> {
865        let request = WithdrawArgs {
866            amount: Nat::from(amount),
867            created_at_time: Some(created_at_time),
868            from_subaccount: None,
869            to: parse_principal("funding target", principal)?,
870        };
871        let response: Result<Nat, WithdrawError> = self
872            .icp
873            .canister_call_candid(ledger, "withdraw", &request, None)?;
874        match response {
875            Ok(block) => Ok(EffectOutcome {
876                created_principal: None,
877                post_cycles: None,
878                receipt: Some(block.to_string()),
879            }),
880            Err(WithdrawError::Duplicate { duplicate_of }) => Ok(EffectOutcome {
881                created_principal: None,
882                post_cycles: None,
883                receipt: Some(duplicate_of.to_string()),
884            }),
885            Err(error) => Err(IcpEnsurePlatformError::LedgerWithdraw(
886                render_withdraw_error(error),
887            )),
888        }
889    }
890
891    #[expect(
892        clippy::too_many_arguments,
893        reason = "the install boundary verifies one complete immutable action tuple"
894    )]
895    fn apply_install(
896        &self,
897        operation_id: &str,
898        principals: &BTreeMap<String, String>,
899        canic_init: Option<&crate::fleet_ensure::model::DesiredCanisterInit>,
900        init_arg: Option<&str>,
901        init_arg_sha256: Option<&str>,
902        init_candid: Option<&str>,
903        init_candid_sha256: Option<&str>,
904        mode: InstallMode,
905        principal: &str,
906        wasm: &str,
907        wasm_sha256: &str,
908    ) -> Result<EffectOutcome, IcpEnsurePlatformError> {
909        verify_artifact(&resolve_path(&self.root, wasm), wasm_sha256, "Wasm")?;
910        let mut command = self.icp.canister_command();
911        command.args(["install", principal, "--mode"]);
912        command.arg(match mode {
913            InstallMode::Install => "install",
914            InstallMode::Reinstall => "reinstall",
915        });
916        command.args(["--yes", "--wasm"]);
917        command.arg(resolve_path(&self.root, wasm));
918        let generated_init = if let Some(canic_init) = canic_init {
919            let path = canic_init::write_arguments(canic_init::CanicInitRequest {
920                desired: &self.desired,
921                init: canic_init,
922                operation_id,
923                principals,
924                root: &self.root,
925                wasm,
926                wasm_sha256,
927            })?;
928            command.arg("--args-file");
929            command.arg(&path);
930            command.args(["--args-format", "bin"]);
931            Some(path)
932        } else if let Some(init_arg) = init_arg {
933            let expected =
934                init_arg_sha256.ok_or_else(|| IcpEnsurePlatformError::ArtifactDigestMismatch {
935                    actual: "missing".to_string(),
936                    expected: "reviewed init argument identity".to_string(),
937                    kind: "init argument",
938                })?;
939            let init_candid =
940                init_candid.ok_or_else(|| IcpEnsurePlatformError::ArtifactDigestMismatch {
941                    actual: "missing".to_string(),
942                    expected: "reviewed init Candid".to_string(),
943                    kind: "init Candid",
944                })?;
945            let init_candid_sha256 = init_candid_sha256.ok_or_else(|| {
946                IcpEnsurePlatformError::ArtifactDigestMismatch {
947                    actual: "missing".to_string(),
948                    expected: "reviewed init Candid identity".to_string(),
949                    kind: "init Candid",
950                }
951            })?;
952            let path = protocol::write_init_arguments(
953                &self.root,
954                operation_id,
955                principals,
956                init_candid,
957                init_candid_sha256,
958                init_arg,
959                expected,
960            )?;
961            command.arg("--args-file");
962            command.arg(&path);
963            command.args(["--args-format", "bin"]);
964            Some(path)
965        } else {
966            command.args(["--args", "()"]);
967            None
968        };
969        self.icp.add_target_args(&mut command);
970        let result = run_status(&mut command).map_err(IcpEnsurePlatformError::from);
971        if let Some(path) = generated_init {
972            std::fs::remove_file(&path)
973                .map_err(|_| IcpEnsurePlatformError::ArtifactUnavailable(path))?;
974        }
975        result?;
976        Ok(empty_outcome())
977    }
978
979    fn apply_controllers(
980        &self,
981        principal: &str,
982        controllers: &[String],
983    ) -> Result<EffectOutcome, IcpEnsurePlatformError> {
984        let mut command = self.icp.canister_command();
985        command.args([
986            "settings",
987            "update",
988            principal,
989            "--force",
990            "--remove-all-controllers",
991        ]);
992        for controller in controllers {
993            parse_principal("controller", controller)?;
994            command.args(["--add-controller", controller]);
995        }
996        self.icp.add_target_args(&mut command);
997        run_status(&mut command)?;
998        Ok(empty_outcome())
999    }
1000
1001    #[expect(
1002        clippy::too_many_arguments,
1003        reason = "one typed transfer effect carries the complete reviewed authority tuple"
1004    )]
1005    fn apply_transfer(
1006        &self,
1007        amount: u128,
1008        candid: &str,
1009        candid_sha256: &str,
1010        destination: &str,
1011        method: &str,
1012        operation_id: &str,
1013        principal: &str,
1014    ) -> Result<EffectOutcome, IcpEnsurePlatformError> {
1015        let candid = resolve_path(&self.root, candid);
1016        if !candid.is_file() {
1017            return Err(IcpEnsurePlatformError::CandidUnavailable(candid));
1018        }
1019        verify_artifact(&candid, candid_sha256, "drain Candid")?;
1020        let response: DrainResponse = self.icp.canister_call_candid(
1021            principal,
1022            method,
1023            &DrainRequest {
1024                operation_id: operation_id.to_string(),
1025                destination: parse_principal("treasury", destination)?,
1026                cycles: Nat::from(amount),
1027            },
1028            Some(&candid),
1029        )?;
1030        let transferred = match response {
1031            DrainResponse::Accepted { transferred_cycles }
1032            | DrainResponse::Replayed { transferred_cycles } => transferred_cycles,
1033        };
1034        if transferred != amount {
1035            return Err(IcpEnsurePlatformError::WrongTransferAmount {
1036                actual: transferred,
1037                expected: amount,
1038            });
1039        }
1040        Ok(EffectOutcome {
1041            created_principal: None,
1042            post_cycles: None,
1043            receipt: Some(operation_id.to_string()),
1044        })
1045    }
1046}
1047
1048impl EnsurePlatform for IcpEnsurePlatform {
1049    type Error = IcpEnsurePlatformError;
1050
1051    fn bind_reviewed_desired(&mut self, desired: &DesiredFleet) -> Result<(), Self::Error> {
1052        self.desired = desired.clone();
1053        Ok(())
1054    }
1055
1056    fn observe(
1057        &mut self,
1058        operation_id: &str,
1059        state: &FleetEnsureStateRecord,
1060    ) -> Result<FleetObservation, Self::Error> {
1061        self.require_operator()?;
1062        self.recovery_reinstalls.borrow_mut().clear();
1063        let mut canisters = BTreeMap::new();
1064        for configured in &self.desired.canisters {
1065            let observed = self
1066                .current_principal(state, &configured.name)
1067                .map(|principal| self.observe_configured_canister(configured, principal, state))
1068                .transpose()?
1069                .flatten();
1070            canisters.insert(configured.name.clone(), observed);
1071        }
1072        self.recovery_reinstalls.borrow_mut().retain(|name| {
1073            let Some(live) = canisters.get(name).and_then(Option::as_ref) else {
1074                return true;
1075            };
1076            state
1077                .completed_reinstalls
1078                .get(name)
1079                .zip(live.canister_version)
1080                .is_none_or(|(before, after)| after <= *before)
1081        });
1082        for name in self.recovery_reinstalls.borrow().iter() {
1083            if let Some(live) = canisters.get_mut(name).and_then(Option::as_mut) {
1084                live.reinstall_required = true;
1085            }
1086        }
1087        let principals = self.protocol_principals(state);
1088        let protocol_ready = self
1089            .desired
1090            .protocol_steps
1091            .iter()
1092            .map(|step| {
1093                let Some(principal) = principals.get(&step.canister) else {
1094                    return Ok((step.name.clone(), false));
1095                };
1096                let live = canisters.get(&step.canister).and_then(Option::as_ref);
1097                let Some(live) = live else {
1098                    return Ok((step.name.clone(), false));
1099                };
1100                let configured = self
1101                    .desired
1102                    .canisters
1103                    .iter()
1104                    .find(|configured| configured.name == step.canister)
1105                    .expect("protocol target was validated against desired canisters");
1106                if let Some(wasm) = &configured.wasm {
1107                    let desired_hash = artifact_hash(&resolve_path(&self.root, wasm))?;
1108                    if live.module_sha256.as_deref() != Some(desired_hash.as_str()) {
1109                        return Ok((step.name.clone(), false));
1110                    }
1111                }
1112                let action = self.observed_protocol_action(step, principal.clone())?;
1113                protocol::observe(&self.icp, &self.root, operation_id, &principals, &action)
1114                    .map(|observation| (step.name.clone(), observation.applied))
1115                    .map_err(IcpEnsurePlatformError::from)
1116            })
1117            .collect::<Result<BTreeMap<_, _>, _>>()?;
1118        Ok(FleetObservation {
1119            additional_controlled_cycles: BTreeMap::new(),
1120            canisters,
1121            ledger_fee_cycles: ledger_fee_cycles(self.icp.canister_query_candid(
1122                &self.desired.cycles_ledger,
1123                "icrc1_fee",
1124                &(),
1125                None,
1126            )?)?,
1127            operator_cycles: self
1128                .icp
1129                .identity_cycles_balance()
1130                .map_err(|error| IcpEnsurePlatformError::LedgerWithdraw(error.to_string()))?,
1131            protocol_ready,
1132        })
1133    }
1134
1135    fn protocol_actions(
1136        &mut self,
1137        operation_id: &str,
1138        state: &FleetEnsureStateRecord,
1139    ) -> Result<Vec<EnsureAction>, Self::Error> {
1140        if self.desired.protocol.is_none() {
1141            return Ok(Vec::new());
1142        }
1143        current_protocol::validate_component_pool_capacity(&self.root, &self.desired)?;
1144        if self.has_stopped_retained_protocol_owner(state)? {
1145            return Ok(Vec::new());
1146        }
1147        if !self.recovery_reinstalls.borrow().is_empty() {
1148            let recovery = self.recovery_reinstalls.borrow();
1149            let mut actions = current_protocol::compile_store_control_actions_unobserved(
1150                &self.root,
1151                &self.desired,
1152                operation_id,
1153                state,
1154            )?;
1155            actions.retain(|action| {
1156                action
1157                    .name()
1158                    .strip_prefix("root-store-control:")
1159                    .is_some_and(|root| recovery.contains(root))
1160            });
1161            return Ok(actions);
1162        }
1163        let store_control = current_protocol::compile_store_control_actions(
1164            &self.icp,
1165            &self.root,
1166            &self.desired,
1167            operation_id,
1168            state,
1169        )?;
1170        if !store_control.is_empty() {
1171            return Ok(store_control);
1172        }
1173        let coordinator = self
1174            .desired
1175            .canisters
1176            .iter()
1177            .find(|configured| {
1178                configured.presence == crate::fleet_ensure::model::DesiredPresence::Present
1179                    && configured.kind
1180                        == crate::fleet_ensure::model::DesiredCanisterKind::Coordinator
1181            })
1182            .expect("typed topology validation requires one Coordinator");
1183        let Some(principal) = self.current_principal(state, &coordinator.name) else {
1184            return Ok(store_control);
1185        };
1186        let Some(live) = self.status_optional(principal)? else {
1187            return Ok(store_control);
1188        };
1189        if let Some(wasm) = &coordinator.wasm {
1190            let expected = artifact_hash(&resolve_path(&self.root, wasm))?;
1191            if live.module_sha256.as_deref() != Some(expected.as_str()) {
1192                return Ok(store_control);
1193            }
1194        }
1195        current_protocol::compile(&self.icp, &self.root, &self.desired, operation_id, state)
1196            .map_err(Into::into)
1197    }
1198
1199    fn terminal_inventory(
1200        &mut self,
1201        operation_id: &str,
1202        state: &FleetEnsureStateRecord,
1203    ) -> Result<TerminalFleetInventory, Self::Error> {
1204        if self.desired.protocol.is_none() {
1205            if state.active_registry.is_some() {
1206                return Err(current_protocol::CurrentProtocolError::Configuration(
1207                    "managed terminal Fleet cannot drop its typed protocol intent".to_string(),
1208                )
1209                .into());
1210            }
1211            return Ok(TerminalFleetInventory::default());
1212        }
1213        super::current_inventory::terminal_inventory(
1214            &self.icp,
1215            &self.root,
1216            &self.desired,
1217            operation_id,
1218            state,
1219        )
1220        .map_err(Into::into)
1221    }
1222
1223    #[expect(
1224        clippy::too_many_lines,
1225        reason = "the platform keeps every effect's exact live terminal predicate in one exhaustive match"
1226    )]
1227    fn observe_effect(
1228        &mut self,
1229        operation_id: &str,
1230        action: &EnsureAction,
1231        record: &EffectRecord,
1232        state: &FleetEnsureStateRecord,
1233    ) -> Result<EffectObservation, Self::Error> {
1234        let mut retry = EffectRetry::None;
1235        let (applied, progress_identity) = match action {
1236            EnsureAction::Create { .. } => (
1237                record.created_principal.is_some(),
1238                format!("created:{:?}", record.created_principal),
1239            ),
1240            EnsureAction::Delete { principal, .. } => {
1241                let live = self.status_optional(Self::action_principal(state, principal)?)?;
1242                (live.is_none(), format!("delete:{live:?}"))
1243            }
1244            EnsureAction::Fund {
1245                expected_post_cycles,
1246                ..
1247            } => {
1248                let live_cycles = self.action_cycles(action, state)?;
1249                (
1250                    native_funding_applied(*expected_post_cycles, live_cycles),
1251                    format!(
1252                        "native-topup:ledger-withdraw:{}:actual:{live_cycles:?}:required:{expected_post_cycles}",
1253                        record.receipt.as_deref().unwrap_or("pending")
1254                    ),
1255                )
1256            }
1257            EnsureAction::Install {
1258                mode,
1259                principal,
1260                wasm_sha256,
1261                ..
1262            } => {
1263                let live = self.status_optional(Self::action_principal(state, principal)?)?;
1264                let applied = live.as_ref().is_some_and(|live| {
1265                    install_effect_applied(
1266                        *mode,
1267                        wasm_sha256,
1268                        live.module_sha256.as_deref(),
1269                        record.pre_canister_version,
1270                        live.canister_version,
1271                    )
1272                });
1273                let progress_identity = live.as_ref().map_or_else(
1274                    || "install:missing".to_string(),
1275                    |live| {
1276                        format!(
1277                            "install:{:?}:version:{:?}",
1278                            live.module_sha256, live.canister_version
1279                        )
1280                    },
1281                );
1282                (applied, progress_identity)
1283            }
1284            EnsureAction::FleetProtocol {
1285                action: current_action,
1286                ..
1287            } => {
1288                let observation = match current_protocol::observe(&self.icp, &self.root, action) {
1289                    Ok(observation) => observation,
1290                    Err(error)
1291                        if matches!(
1292                            current_action.as_ref(),
1293                            crate::fleet_ensure::model::CurrentFleetProtocolAction::AdoptStore { .. }
1294                        ) && recoverable_current_protocol_error(&error) =>
1295                    {
1296                        EffectObservation {
1297                            applied: false,
1298                            progress_identity: "store-adoption:protected-status-unavailable"
1299                                .to_string(),
1300                            retry: EffectRetry::None,
1301                        }
1302                    }
1303                    Err(error) => return Err(error.into()),
1304                };
1305                retry = observation.retry;
1306                (observation.applied, observation.progress_identity)
1307            }
1308            EnsureAction::Protocol { .. } => {
1309                let observation = protocol::observe(
1310                    &self.icp,
1311                    &self.root,
1312                    operation_id,
1313                    &self.protocol_principals(state),
1314                    &resolved_protocol_action(action, state)?,
1315                )?;
1316                (observation.applied, observation.progress_identity)
1317            }
1318            EnsureAction::SetControllers {
1319                controller_canisters,
1320                controllers,
1321                principal,
1322                ..
1323            } => {
1324                let expected =
1325                    self.resolved_controllers(state, controllers, controller_canisters)?;
1326                let live = self.status_optional(Self::action_principal(state, principal)?)?;
1327                (
1328                    live.as_ref()
1329                        .is_some_and(|live| live.controllers == expected),
1330                    format!("controllers:{:?}", live.map(|live| live.controllers)),
1331                )
1332            }
1333            EnsureAction::Start { principal, .. } => {
1334                let live = self.status_optional(Self::action_principal(state, principal)?)?;
1335                (
1336                    live.as_ref()
1337                        .is_some_and(|live| live.status == CanisterRuntimeStatus::Running),
1338                    format!("start:{:?}", live.map(|live| live.status)),
1339                )
1340            }
1341            EnsureAction::Stop { principal, .. } => {
1342                let live = self.status_optional(Self::action_principal(state, principal)?)?;
1343                (
1344                    live.as_ref()
1345                        .is_some_and(|live| live.status == CanisterRuntimeStatus::Stopped),
1346                    format!("stop:{:?}", live.map(|live| live.status)),
1347                )
1348            }
1349            EnsureAction::Transfer {
1350                amount,
1351                maximum_execution_burn_cycles,
1352                name,
1353                principal,
1354                destination,
1355                ..
1356            } => {
1357                let source = self
1358                    .status_optional(Self::action_principal(state, principal)?)?
1359                    .map(|live| live.cycles)
1360                    .ok_or_else(|| IcpEnsurePlatformError::MissingTransferBalance {
1361                        canister: name.clone(),
1362                        side: "live source",
1363                    })?;
1364                let destination = self
1365                    .status_optional(self.current_principal(state, destination).ok_or_else(
1366                        || IcpEnsurePlatformError::UnresolvedCreated(destination.clone()),
1367                    )?)?
1368                    .map(|live| live.cycles)
1369                    .ok_or_else(|| IcpEnsurePlatformError::MissingTransferBalance {
1370                        canister: name.clone(),
1371                        side: "live treasury",
1372                    })?;
1373                let source_pre = record.pre_cycles.ok_or_else(|| {
1374                    IcpEnsurePlatformError::MissingTransferBalance {
1375                        canister: name.clone(),
1376                        side: "retained source",
1377                    }
1378                })?;
1379                let destination_pre = record.destination_pre_cycles.ok_or_else(|| {
1380                    IcpEnsurePlatformError::MissingTransferBalance {
1381                        canister: name.clone(),
1382                        side: "retained treasury",
1383                    }
1384                })?;
1385                let reconciliation = reconcile_retirement_transfer(RetirementTransferBalances {
1386                    destination_after: destination,
1387                    destination_before: destination_pre,
1388                    maximum_execution_burn: *maximum_execution_burn_cycles,
1389                    source_after: source,
1390                    source_before: source_pre,
1391                    transfer_amount: *amount,
1392                });
1393                let (applied, source_debit, destination_credit) = match reconciliation {
1394                    Ok(RetirementTransferReconciliation::Pending) => (false, 0, 0),
1395                    Ok(RetirementTransferReconciliation::Conserved {
1396                        destination_credit,
1397                        source_debit,
1398                    }) => (true, source_debit, destination_credit),
1399                    Err(RetirementTransferInvariantError::ArithmeticOverflow) => {
1400                        return Err(IcpEnsurePlatformError::Arithmetic(
1401                            "retirement transfer debit",
1402                        ));
1403                    }
1404                    Err(RetirementTransferInvariantError::BalanceMovedInInvalidDirection) => {
1405                        return Err(IcpEnsurePlatformError::InvalidTransferBalance {
1406                            canister: name.clone(),
1407                        });
1408                    }
1409                    Err(RetirementTransferInvariantError::Unbalanced {
1410                        destination_credit,
1411                        maximum_source_debit,
1412                        source_debit,
1413                    }) => {
1414                        return Err(IcpEnsurePlatformError::UnbalancedTransfer {
1415                            canister: name.clone(),
1416                            destination_credit,
1417                            expected: *amount,
1418                            maximum_source_debit,
1419                            source_debit,
1420                        });
1421                    }
1422                };
1423                (
1424                    applied,
1425                    format!("transfer:{source_debit}:{destination_credit}"),
1426                )
1427            }
1428        };
1429        Ok(EffectObservation {
1430            applied,
1431            progress_identity,
1432            retry,
1433        })
1434    }
1435
1436    #[expect(
1437        clippy::too_many_lines,
1438        reason = "the platform keeps every approved single-step effect in one exhaustive match"
1439    )]
1440    fn apply(
1441        &mut self,
1442        operation_id: &str,
1443        action: &EnsureAction,
1444        _record: &EffectRecord,
1445        state: &FleetEnsureStateRecord,
1446    ) -> Result<EffectOutcome, Self::Error> {
1447        self.require_operator()?;
1448        match action {
1449            EnsureAction::Create {
1450                controller_canisters,
1451                controllers,
1452                created_at_time,
1453                ledger,
1454                requested_initial_cycles,
1455                subnet,
1456                ..
1457            } => self.apply_create(
1458                CreateCanisterAuthority {
1459                    controller_canisters,
1460                    controllers,
1461                    created_at_time: *created_at_time,
1462                    ledger,
1463                    requested_initial_cycles: *requested_initial_cycles,
1464                    subnet,
1465                },
1466                state,
1467            ),
1468            EnsureAction::Delete {
1469                maximum_remaining_cycles,
1470                principal,
1471                ..
1472            } => {
1473                let principal = Self::action_principal(state, principal)?;
1474                if let Some(live) = self.status_optional(principal)? {
1475                    if live.cycles > *maximum_remaining_cycles {
1476                        return Err(IcpEnsurePlatformError::MaterialCycles {
1477                            actual: live.cycles,
1478                            canister: principal.to_string(),
1479                            maximum: *maximum_remaining_cycles,
1480                        });
1481                    }
1482                    if live.status != CanisterRuntimeStatus::Stopped {
1483                        return Err(IcpEnsurePlatformError::NotStopped {
1484                            canister: principal.to_string(),
1485                        });
1486                    }
1487                    self.icp.delete_canister_without_cycle_recovery(principal)?;
1488                }
1489                Ok(empty_outcome())
1490            }
1491            EnsureAction::Fund {
1492                amount,
1493                created_at_time,
1494                ledger,
1495                principal,
1496                ..
1497            } => self.apply_fund(
1498                *amount,
1499                *created_at_time,
1500                ledger,
1501                Self::action_principal(state, principal)?,
1502            ),
1503            EnsureAction::Install {
1504                canic_init,
1505                init_arg,
1506                init_arg_sha256,
1507                init_candid,
1508                init_candid_sha256,
1509                mode,
1510                principal,
1511                wasm,
1512                wasm_sha256,
1513                ..
1514            } => self.apply_install(
1515                operation_id,
1516                &self.protocol_principals(state),
1517                canic_init.as_ref(),
1518                init_arg.as_deref(),
1519                init_arg_sha256.as_deref(),
1520                init_candid.as_deref(),
1521                init_candid_sha256.as_deref(),
1522                *mode,
1523                Self::action_principal(state, principal)?,
1524                wasm,
1525                wasm_sha256,
1526            ),
1527            EnsureAction::FleetProtocol { .. } => {
1528                current_protocol::apply(&self.icp, &self.root, action).map_err(Into::into)
1529            }
1530            EnsureAction::Protocol { .. } => {
1531                let action = resolved_protocol_action(action, state)?;
1532                protocol::apply(
1533                    &self.icp,
1534                    &self.root,
1535                    operation_id,
1536                    &self.protocol_principals(state),
1537                    &action,
1538                )?;
1539                Ok(EffectOutcome {
1540                    created_principal: None,
1541                    post_cycles: None,
1542                    receipt: Some(operation_id.to_string()),
1543                })
1544            }
1545            EnsureAction::SetControllers {
1546                controller_canisters,
1547                controllers,
1548                principal,
1549                ..
1550            } => self.apply_controllers(
1551                Self::action_principal(state, principal)?,
1552                &self.resolved_controllers(state, controllers, controller_canisters)?,
1553            ),
1554            EnsureAction::Start { principal, .. } => {
1555                self.icp
1556                    .start_canister(Self::action_principal(state, principal)?)?;
1557                Ok(empty_outcome())
1558            }
1559            EnsureAction::Stop { principal, .. } => {
1560                self.icp
1561                    .stop_canister(Self::action_principal(state, principal)?)?;
1562                Ok(empty_outcome())
1563            }
1564            EnsureAction::Transfer {
1565                amount,
1566                candid,
1567                candid_sha256,
1568                destination,
1569                method,
1570                principal,
1571                ..
1572            } => self.apply_transfer(
1573                *amount,
1574                candid,
1575                candid_sha256,
1576                self.current_principal(state, destination).ok_or_else(|| {
1577                    IcpEnsurePlatformError::UnresolvedCreated(destination.clone())
1578                })?,
1579                method,
1580                operation_id,
1581                Self::action_principal(state, principal)?,
1582            ),
1583        }
1584    }
1585
1586    fn action_cycles(
1587        &mut self,
1588        action: &EnsureAction,
1589        state: &FleetEnsureStateRecord,
1590    ) -> Result<Option<u128>, Self::Error> {
1591        let (name, principal) = match action {
1592            EnsureAction::Create { .. } => return Ok(None),
1593            EnsureAction::Delete {
1594                name, principal, ..
1595            }
1596            | EnsureAction::FleetProtocol {
1597                name, principal, ..
1598            }
1599            | EnsureAction::Fund {
1600                name, principal, ..
1601            }
1602            | EnsureAction::Install {
1603                name, principal, ..
1604            }
1605            | EnsureAction::Protocol {
1606                name, principal, ..
1607            }
1608            | EnsureAction::SetControllers {
1609                name, principal, ..
1610            }
1611            | EnsureAction::Start {
1612                name, principal, ..
1613            }
1614            | EnsureAction::Stop {
1615                name, principal, ..
1616            }
1617            | EnsureAction::Transfer {
1618                name, principal, ..
1619            } => (name, Self::action_principal(state, principal)?),
1620        };
1621        let observed = self
1622            .desired
1623            .canisters
1624            .iter()
1625            .find(|configured| configured.name == *name)
1626            .map_or_else(
1627                || self.status_optional(principal),
1628                |configured| self.observe_configured_canister(configured, principal, state),
1629            )?;
1630        Ok(observed.map(|live| live.cycles))
1631    }
1632
1633    fn action_destination_cycles(
1634        &mut self,
1635        action: &EnsureAction,
1636        state: &FleetEnsureStateRecord,
1637    ) -> Result<Option<u128>, Self::Error> {
1638        let EnsureAction::Transfer { destination, .. } = action else {
1639            return Ok(None);
1640        };
1641        let destination = self
1642            .current_principal(state, destination)
1643            .ok_or_else(|| IcpEnsurePlatformError::UnresolvedCreated(destination.clone()))?;
1644        Ok(self.status_optional(destination)?.map(|live| live.cycles))
1645    }
1646
1647    fn action_canister_version(
1648        &mut self,
1649        action: &EnsureAction,
1650        state: &FleetEnsureStateRecord,
1651    ) -> Result<Option<u64>, Self::Error> {
1652        let EnsureAction::Install { principal, .. } = action else {
1653            return Ok(None);
1654        };
1655        let principal = Self::action_principal(state, principal)?;
1656        let version = self
1657            .icp
1658            .canister_status_report(principal)?
1659            .canister_version
1660            .ok_or_else(|| IcpEnsurePlatformError::MissingCanisterVersion {
1661                canister: principal.to_string(),
1662            })?;
1663        Ok(Some(version))
1664    }
1665}
1666
1667fn exact_retained_root_owned_topology<'a>(
1668    state: &'a FleetEnsureStateRecord,
1669    name: &str,
1670    kind: DesiredCanisterKind,
1671    parent: &str,
1672    principal: &str,
1673    root: &str,
1674) -> Option<&'a crate::fleet_ensure::model::FleetEnsureTopologyRecord> {
1675    let child_identity_matches = state
1676        .principals
1677        .get(name)
1678        .is_some_and(|retained| retained == principal);
1679    let root_identity_matches = state
1680        .principals
1681        .get(parent)
1682        .is_some_and(|retained| retained == root);
1683    let topology = state
1684        .topology
1685        .get(name)
1686        .filter(|topology| topology.kind == kind && topology.parent.as_deref() == Some(parent));
1687    (child_identity_matches && root_identity_matches)
1688        .then_some(topology)
1689        .flatten()
1690}
1691
1692fn recoverable_root_status_error(error: &CanisterProtocolError) -> bool {
1693    error.is_rejected_with(canic_core::diagnostics::codes::STATE_CONFLICT)
1694        || error.is_rejected_with(canic_core::diagnostics::codes::STATE_UNAVAILABLE)
1695}
1696
1697fn recoverable_current_protocol_error(error: &current_protocol::CurrentProtocolError) -> bool {
1698    matches!(
1699        error,
1700        current_protocol::CurrentProtocolError::Transport(source)
1701            if recoverable_root_status_error(source)
1702    )
1703}
1704
1705pub fn install_effect_applied(
1706    mode: InstallMode,
1707    expected_hash: &str,
1708    live_hash: Option<&str>,
1709    pre_canister_version: Option<u64>,
1710    live_canister_version: Option<u64>,
1711) -> bool {
1712    if live_hash != Some(expected_hash) {
1713        return false;
1714    }
1715    match mode {
1716        InstallMode::Install => true,
1717        InstallMode::Reinstall => pre_canister_version
1718            .zip(live_canister_version)
1719            .is_some_and(|(before, after)| after > before),
1720    }
1721}
1722
1723pub fn native_funding_applied(expected_post_cycles: u128, live_cycles: Option<u128>) -> bool {
1724    expected_post_cycles > 0 && live_cycles.is_some_and(|actual| actual >= expected_post_cycles)
1725}
1726
1727fn ledger_fee_cycles(value: Nat) -> Result<u128, IcpEnsurePlatformError> {
1728    let rendered = value.to_string();
1729    u128::try_from(value.0).map_err(|_| IcpEnsurePlatformError::InvalidLedgerFee(rendered))
1730}
1731
1732const fn empty_outcome() -> EffectOutcome {
1733    EffectOutcome {
1734        created_principal: None,
1735        post_cycles: None,
1736        receipt: None,
1737    }
1738}
1739
1740fn parse_status_cycles(value: &str) -> Option<u128> {
1741    value.replace('_', "").trim().parse().ok()
1742}
1743
1744fn normalize_hash(value: &str) -> String {
1745    value
1746        .strip_prefix("0x")
1747        .unwrap_or(value)
1748        .to_ascii_lowercase()
1749}
1750
1751fn parse_principal(field: &'static str, value: &str) -> Result<Principal, IcpEnsurePlatformError> {
1752    Principal::from_text(value).map_err(|_| IcpEnsurePlatformError::InvalidPrincipal {
1753        field,
1754        value: value.to_string(),
1755    })
1756}
1757
1758fn resolve_path(root: &Path, path: &str) -> PathBuf {
1759    let path = Path::new(path);
1760    if path.is_absolute() {
1761        path.to_path_buf()
1762    } else {
1763        root.join(path)
1764    }
1765}
1766
1767fn verify_artifact(
1768    path: &Path,
1769    expected: &str,
1770    kind: &'static str,
1771) -> Result<(), IcpEnsurePlatformError> {
1772    let bytes = std::fs::read(path)
1773        .map_err(|_| IcpEnsurePlatformError::ArtifactUnavailable(path.to_path_buf()))?;
1774    let actual = canic_core::cdk::utils::hash::sha256_hex(&bytes);
1775    if actual != expected {
1776        return Err(IcpEnsurePlatformError::ArtifactDigestMismatch {
1777            actual,
1778            expected: expected.to_string(),
1779            kind,
1780        });
1781    }
1782    Ok(())
1783}
1784
1785fn artifact_hash(path: &Path) -> Result<String, IcpEnsurePlatformError> {
1786    let bytes = std::fs::read(path)
1787        .map_err(|_| IcpEnsurePlatformError::ArtifactUnavailable(path.to_path_buf()))?;
1788    Ok(canic_core::cdk::utils::hash::sha256_hex(&bytes))
1789}
1790
1791fn resolved_protocol_action(
1792    action: &EnsureAction,
1793    state: &FleetEnsureStateRecord,
1794) -> Result<EnsureAction, IcpEnsurePlatformError> {
1795    let mut resolved = action.clone();
1796    if let EnsureAction::Protocol { principal, .. } = &mut resolved {
1797        *principal = IcpEnsurePlatform::action_principal(state, principal)?.to_string();
1798        return Ok(resolved);
1799    }
1800    Err(IcpEnsurePlatformError::Protocol(
1801        protocol::ProtocolEffectError::WrongAction,
1802    ))
1803}
1804
1805fn render_create_error(error: CreateCanisterError) -> String {
1806    match error {
1807        CreateCanisterError::CreatedInFuture { ledger_time } => {
1808            format!("created in future of ledger time {ledger_time}")
1809        }
1810        CreateCanisterError::Duplicate { duplicate_of, .. } => {
1811            format!("duplicate block {duplicate_of}")
1812        }
1813        CreateCanisterError::FailedToCreate {
1814            error,
1815            fee_block,
1816            refund_block,
1817        } => format!("failed: {error}; fee_block={fee_block:?}; refund_block={refund_block:?}"),
1818        CreateCanisterError::GenericError {
1819            error_code,
1820            message,
1821        } => format!("error {error_code}: {message}"),
1822        CreateCanisterError::InsufficientFunds { balance } => {
1823            format!("insufficient funds: balance={balance}")
1824        }
1825        CreateCanisterError::TemporarilyUnavailable => "temporarily unavailable".to_string(),
1826        CreateCanisterError::TooOld => "request is too old".to_string(),
1827    }
1828}
1829
1830fn render_withdraw_error(error: WithdrawError) -> String {
1831    match error {
1832        WithdrawError::BadFee { expected_fee } => format!("bad fee; expected {expected_fee}"),
1833        WithdrawError::CreatedInFuture { ledger_time } => {
1834            format!("created in future of ledger time {ledger_time}")
1835        }
1836        WithdrawError::Duplicate { duplicate_of } => format!("duplicate block {duplicate_of}"),
1837        WithdrawError::FailedToWithdraw {
1838            fee_block,
1839            rejection_reason,
1840            ..
1841        } => format!("withdraw failed: {rejection_reason}; fee_block={fee_block:?}"),
1842        WithdrawError::GenericError {
1843            error_code,
1844            message,
1845        } => format!("error {error_code}: {message}"),
1846        WithdrawError::InsufficientFunds { balance } => {
1847            format!("insufficient funds: balance={balance}")
1848        }
1849        WithdrawError::InvalidReceiver { receiver } => format!("invalid receiver {receiver}"),
1850        WithdrawError::TemporarilyUnavailable => "temporarily unavailable".to_string(),
1851        WithdrawError::TooOld => "request is too old".to_string(),
1852    }
1853}
1854
1855#[expect(
1856    dead_code,
1857    reason = "Candid variant is retained for exact response decoding"
1858)]
1859const fn rejection_code_name(code: RejectionCode) -> &'static str {
1860    match code {
1861        RejectionCode::CanisterError => "canister_error",
1862        RejectionCode::CanisterReject => "canister_reject",
1863        RejectionCode::DestinationInvalid => "destination_invalid",
1864        RejectionCode::NoError => "no_error",
1865        RejectionCode::SysFatal => "sys_fatal",
1866        RejectionCode::SysTransient => "sys_transient",
1867        RejectionCode::Unknown => "unknown",
1868    }
1869}
1870
1871#[cfg(test)]
1872mod tests {
1873    use super::*;
1874    use canic_core::dto::pool::CanisterPoolAssetStatus;
1875
1876    #[test]
1877    fn root_owned_observation_classifies_bootstrap_and_workload_lifecycle() {
1878        assert_eq!(
1879            root_owned_lifecycle(DesiredCanisterKind::Store, &CanisterPoolAssetStatus::Store),
1880            Some(RootOwnedCanisterLifecycle::Store)
1881        );
1882        assert_eq!(
1883            root_owned_lifecycle(DesiredCanisterKind::Pool, &CanisterPoolAssetStatus::Ready),
1884            Some(RootOwnedCanisterLifecycle::Idle)
1885        );
1886        assert_eq!(
1887            root_owned_lifecycle(
1888                DesiredCanisterKind::Pool,
1889                &CanisterPoolAssetStatus::Claimed {
1890                    claim: canic_core::dto::pool::CanisterPoolClaim {
1891                        component: canic_core::ids::ComponentInstanceId::from_generated_bytes(
1892                            [1; 32]
1893                        ),
1894                        operation_id: [2; 32],
1895                    },
1896                }
1897            ),
1898            Some(RootOwnedCanisterLifecycle::Claimed)
1899        );
1900        assert_eq!(
1901            root_owned_lifecycle(
1902                DesiredCanisterKind::Pool,
1903                &CanisterPoolAssetStatus::Workload {
1904                    claim: canic_core::dto::pool::CanisterPoolClaim {
1905                        component: canic_core::ids::ComponentInstanceId::from_generated_bytes(
1906                            [1; 32]
1907                        ),
1908                        operation_id: [2; 32],
1909                    },
1910                }
1911            ),
1912            Some(RootOwnedCanisterLifecycle::Workload)
1913        );
1914        assert_eq!(
1915            root_owned_lifecycle(
1916                DesiredCanisterKind::Pool,
1917                &CanisterPoolAssetStatus::HandingOff {
1918                    recipient: Principal::anonymous(),
1919                }
1920            ),
1921            None
1922        );
1923        assert_eq!(
1924            root_owned_lifecycle(
1925                DesiredCanisterKind::Store,
1926                &CanisterPoolAssetStatus::PendingReset
1927            ),
1928            None
1929        );
1930    }
1931
1932    #[test]
1933    fn retained_root_owned_topology_requires_exact_child_root_and_parent_binding() {
1934        let mut state = FleetEnsureStateRecord {
1935            active_registry: None,
1936            completed_reinstalls: BTreeMap::new(),
1937            fleet: "fleet".to_string(),
1938            pending_principals: BTreeMap::new(),
1939            principals: BTreeMap::from([
1940                ("root".to_string(), "root-principal".to_string()),
1941                ("store".to_string(), "store-principal".to_string()),
1942            ]),
1943            retained_cycles_by_principal: BTreeMap::new(),
1944            schema_version: crate::fleet_ensure::model::FLEET_ENSURE_SCHEMA_VERSION,
1945            topology: BTreeMap::from([(
1946                "store".to_string(),
1947                crate::fleet_ensure::model::FleetEnsureTopologyRecord {
1948                    kind: DesiredCanisterKind::Store,
1949                    module_hash: Some("11".repeat(32)),
1950                    parent: Some("root".to_string()),
1951                    protocol_binding: None,
1952                    role: None,
1953                },
1954            )]),
1955        };
1956
1957        assert!(
1958            exact_retained_root_owned_topology(
1959                &state,
1960                "store",
1961                DesiredCanisterKind::Store,
1962                "root",
1963                "store-principal",
1964                "root-principal",
1965            )
1966            .is_some()
1967        );
1968        assert!(
1969            exact_retained_root_owned_topology(
1970                &state,
1971                "store",
1972                DesiredCanisterKind::Store,
1973                "root",
1974                "foreign-store",
1975                "root-principal",
1976            )
1977            .is_none()
1978        );
1979        state
1980            .topology
1981            .get_mut("store")
1982            .expect("Store topology")
1983            .parent = Some("foreign-root".to_string());
1984        assert!(
1985            exact_retained_root_owned_topology(
1986                &state,
1987                "store",
1988                DesiredCanisterKind::Store,
1989                "root",
1990                "store-principal",
1991                "root-principal",
1992            )
1993            .is_none()
1994        );
1995    }
1996}