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::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, EnsurePlatform, TerminalFleetInventory, canic_init,
19            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    dto::pool::{CanisterPoolResponse, CanisterPoolStatusRequest},
27    protocol as canic_protocol,
28};
29use serde::Deserialize;
30use std::{
31    collections::BTreeMap,
32    path::{Path, PathBuf},
33};
34use thiserror::Error as ThisError;
35
36/// Exact input expected by a configured cycle-safe retirement endpoint.
37
38#[derive(CandidType)]
39struct DrainRequest {
40    operation_id: String,
41    destination: Principal,
42    cycles: Nat,
43}
44
45#[derive(CandidType, Deserialize)]
46enum DrainResponse {
47    Accepted { transferred_cycles: Nat },
48    Replayed { transferred_cycles: Nat },
49}
50
51#[derive(CandidType)]
52enum RootPoolStatusRequest {
53    Pool(CanisterPoolStatusRequest),
54}
55
56#[derive(CandidType, Deserialize)]
57enum RootPoolStatusResponse {
58    Pool(Box<CanisterPoolResponse>),
59}
60
61#[derive(CandidType)]
62struct CreateCanisterArgs {
63    amount: Nat,
64    created_at_time: Option<u64>,
65    creation_args: Option<CmcCreateCanisterArgs>,
66    from_subaccount: Option<[u8; 32]>,
67}
68
69#[derive(CandidType)]
70struct CmcCreateCanisterArgs {
71    settings: Option<CanisterSettings>,
72    subnet_selection: Option<SubnetSelection>,
73}
74
75#[derive(CandidType)]
76struct CanisterSettings {
77    compute_allocation: Option<Nat>,
78    controllers: Option<Vec<Principal>>,
79    freezing_threshold: Option<Nat>,
80    memory_allocation: Option<Nat>,
81    reserved_cycles_limit: Option<Nat>,
82}
83
84#[derive(CandidType)]
85enum SubnetSelection {
86    Subnet { subnet: Principal },
87}
88
89#[derive(CandidType, Deserialize)]
90struct CreateCanisterSuccess {
91    block_id: Nat,
92    canister_id: Principal,
93}
94
95#[derive(CandidType, Deserialize)]
96enum CreateCanisterError {
97    CreatedInFuture {
98        ledger_time: u64,
99    },
100    Duplicate {
101        duplicate_of: Nat,
102        canister_id: Option<Principal>,
103    },
104    FailedToCreate {
105        error: String,
106        fee_block: Option<Nat>,
107        refund_block: Option<Nat>,
108    },
109    GenericError {
110        error_code: Nat,
111        message: String,
112    },
113    InsufficientFunds {
114        balance: Nat,
115    },
116    TemporarilyUnavailable,
117    TooOld,
118}
119
120#[derive(CandidType)]
121struct WithdrawArgs {
122    amount: Nat,
123    created_at_time: Option<u64>,
124    from_subaccount: Option<[u8; 32]>,
125    to: Principal,
126}
127
128#[derive(CandidType, Deserialize)]
129enum WithdrawError {
130    BadFee {
131        expected_fee: Nat,
132    },
133    CreatedInFuture {
134        ledger_time: u64,
135    },
136    Duplicate {
137        duplicate_of: Nat,
138    },
139    FailedToWithdraw {
140        fee_block: Option<Nat>,
141        rejection_code: RejectionCode,
142        rejection_reason: String,
143    },
144    GenericError {
145        error_code: Nat,
146        message: String,
147    },
148    InsufficientFunds {
149        balance: Nat,
150    },
151    InvalidReceiver {
152        receiver: Principal,
153    },
154    TemporarilyUnavailable,
155    TooOld,
156}
157
158#[derive(CandidType, Deserialize)]
159enum RejectionCode {
160    CanisterError,
161    CanisterReject,
162    DestinationInvalid,
163    NoError,
164    SysFatal,
165    SysTransient,
166    Unknown,
167}
168
169/// Typed failure at the current IC effect boundary.
170
171#[derive(Debug, ThisError)]
172pub enum IcpEnsurePlatformError {
173    #[error("cycle arithmetic overflow while executing {0}")]
174    Arithmetic(&'static str),
175
176    #[error("configured Candid file is not a regular file: {}", .0.display())]
177    CandidUnavailable(PathBuf),
178
179    #[error("configured effect artifact is unavailable: {}", .0.display())]
180    ArtifactUnavailable(PathBuf),
181
182    #[error("configured {kind} changed after plan review: expected {expected}, observed {actual}")]
183    ArtifactDigestMismatch {
184        actual: String,
185        expected: String,
186        kind: &'static str,
187    },
188
189    #[error("configured Principal is invalid for {field}: {value}")]
190    InvalidPrincipal { field: &'static str, value: String },
191
192    #[error("Cycles Ledger returned an unsupported fee value: {0}")]
193    InvalidLedgerFee(String),
194
195    #[error("active ICP identity is {actual}, but reviewed Fleet operator is {expected}")]
196    OperatorMismatch { actual: String, expected: String },
197
198    #[error("ICP status has invalid cycle balance for {canister}: {value}")]
199    InvalidStatusCycles { canister: String, value: String },
200
201    #[error(transparent)]
202    Candid(#[from] IcpCandidCallError),
203
204    #[error(transparent)]
205    Icp(#[from] IcpCommandError),
206
207    #[error("Cycles Ledger create_canister failed: {0}")]
208    LedgerCreate(String),
209
210    #[error("Cycles Ledger duplicate does not yet identify its created canister")]
211    LedgerCreatePending,
212
213    #[error("Cycles Ledger withdraw failed: {0}")]
214    LedgerWithdraw(String),
215
216    #[error(
217        "canister {canister} retains {actual} cycles above deletion limit {maximum}; left untouched"
218    )]
219    MaterialCycles {
220        actual: u128,
221        canister: String,
222        maximum: u128,
223    },
224
225    #[error("canister {canister} is not stopped; deletion was not attempted")]
226    NotStopped { canister: String },
227
228    #[error("effect references unresolved created canister {0}")]
229    UnresolvedCreated(String),
230
231    #[error("configured retirement endpoint transferred {actual} cycles, expected {expected}")]
232    WrongTransferAmount { actual: Nat, expected: u128 },
233
234    #[error(
235        "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"
236    )]
237    UnbalancedTransfer {
238        canister: String,
239        destination_credit: u128,
240        expected: u128,
241        maximum_source_debit: u128,
242        source_debit: u128,
243    },
244
245    #[error("retirement transfer for {canister} is missing its durable {side} balance")]
246    MissingTransferBalance {
247        canister: String,
248        side: &'static str,
249    },
250
251    #[error("retirement transfer balance for {canister} moved in an invalid direction")]
252    InvalidTransferBalance { canister: String },
253
254    #[error(transparent)]
255    CurrentProtocol(#[from] current_protocol::CurrentProtocolError),
256
257    #[error(transparent)]
258    CanicInit(#[from] canic_init::CanicInitError),
259
260    #[error(transparent)]
261    Protocol(#[from] protocol::ProtocolEffectError),
262}
263
264/// Production ICP adapter for the current desired Fleet.
265pub struct IcpEnsurePlatform {
266    desired: DesiredFleet,
267    icp: IcpCli,
268    root: PathBuf,
269}
270
271impl IcpEnsurePlatform {
272    #[must_use]
273    pub fn new(desired: DesiredFleet, icp_executable: &str, root: &Path) -> Self {
274        let icp = IcpCli::new(icp_executable, Some(desired.environment.clone()))
275            .with_cwd(root.to_path_buf());
276        Self {
277            desired,
278            icp,
279            root: root.to_path_buf(),
280        }
281    }
282
283    fn require_operator(&self) -> Result<(), IcpEnsurePlatformError> {
284        let actual = self.icp.identity_principal_text()?;
285        if actual != self.desired.operator {
286            return Err(IcpEnsurePlatformError::OperatorMismatch {
287                actual,
288                expected: self.desired.operator.clone(),
289            });
290        }
291        Ok(())
292    }
293
294    fn current_principal<'a>(
295        &'a self,
296        state: &'a FleetEnsureStateRecord,
297        name: &str,
298    ) -> Option<&'a str> {
299        state
300            .pending_principals
301            .get(name)
302            .or_else(|| state.principals.get(name))
303            .map(String::as_str)
304            .or_else(|| {
305                self.desired
306                    .canisters
307                    .iter()
308                    .find(|configured| configured.name == name)
309                    .and_then(|configured| configured.principal.as_deref())
310            })
311    }
312
313    fn protocol_principals(&self, state: &FleetEnsureStateRecord) -> BTreeMap<String, String> {
314        self.desired
315            .canisters
316            .iter()
317            .filter_map(|configured| {
318                state
319                    .pending_principals
320                    .get(&configured.name)
321                    .or_else(|| state.principals.get(&configured.name))
322                    .or(configured.principal.as_ref())
323                    .map(|principal| (configured.name.clone(), principal.clone()))
324            })
325            .collect()
326    }
327
328    fn observed_protocol_action(
329        &self,
330        step: &crate::fleet_ensure::model::DesiredProtocolStep,
331        principal: String,
332    ) -> Result<EnsureAction, IcpEnsurePlatformError> {
333        Ok(EnsureAction::Protocol {
334            candid: step.candid.clone(),
335            candid_sha256: artifact_hash(&resolve_path(&self.root, &step.candid))?,
336            command_args: step.command_args.clone(),
337            command_args_sha256: artifact_hash(&resolve_path(&self.root, &step.command_args))?,
338            command_method: step.command_method.clone(),
339            expected_status: step.expected_status.clone(),
340            expected_status_sha256: artifact_hash(&resolve_path(
341                &self.root,
342                &step.expected_status,
343            ))?,
344            maximum_execution_burn_cycles: step
345                .maximum_execution_burn_cycles
346                .parse()
347                .map_err(|_| IcpEnsurePlatformError::Arithmetic("protocol burn"))?,
348            name: step.name.clone(),
349            principal,
350            status_args: step.status_args.clone(),
351            status_args_sha256: artifact_hash(&resolve_path(&self.root, &step.status_args))?,
352            status_method: step.status_method.clone(),
353        })
354    }
355
356    fn action_principal<'a>(
357        state: &'a FleetEnsureStateRecord,
358        principal: &'a str,
359    ) -> Result<&'a str, IcpEnsurePlatformError> {
360        if let Some(name) = principal.strip_prefix("created:") {
361            return state
362                .pending_principals
363                .get(name)
364                .map(String::as_str)
365                .ok_or_else(|| IcpEnsurePlatformError::UnresolvedCreated(name.to_string()));
366        }
367        Ok(principal)
368    }
369
370    fn status_optional(
371        &self,
372        principal: &str,
373    ) -> Result<Option<LiveCanister>, IcpEnsurePlatformError> {
374        let report = match self.icp.canister_status_report(principal) {
375            Ok(report) => report,
376            Err(error)
377                if matches!(
378                    error.diagnostic(),
379                    Some(IcpDiagnostic::CanisterNotFound { .. })
380                ) =>
381            {
382                return Ok(None);
383            }
384            Err(error) => return Err(error.into()),
385        };
386        let cycles_text = report.cycles.unwrap_or_else(|| "0".to_string());
387        let cycles = parse_status_cycles(&cycles_text).ok_or_else(|| {
388            IcpEnsurePlatformError::InvalidStatusCycles {
389                canister: principal.to_string(),
390                value: cycles_text,
391            }
392        })?;
393        let mut controllers = report
394            .settings
395            .map(|settings| settings.controllers)
396            .unwrap_or_default();
397        controllers.sort();
398        let status = match report.status.to_ascii_lowercase().as_str() {
399            "stopped" => CanisterRuntimeStatus::Stopped,
400            "stopping" => CanisterRuntimeStatus::Stopping,
401            _ => CanisterRuntimeStatus::Running,
402        };
403        Ok(Some(LiveCanister {
404            controllers,
405            cycles,
406            module_sha256: report.module_hash.map(|hash| normalize_hash(&hash)),
407            principal: principal.to_string(),
408            root_owned_lifecycle: None,
409            status,
410        }))
411    }
412
413    fn observe_configured_canister(
414        &self,
415        configured: &crate::fleet_ensure::model::DesiredCanister,
416        principal: &str,
417        state: &FleetEnsureStateRecord,
418    ) -> Result<Option<LiveCanister>, IcpEnsurePlatformError> {
419        if configured.kind == DesiredCanisterKind::Pool {
420            return self.observe_root_owned_canister(configured, principal, state);
421        }
422        match self.status_optional(principal) {
423            Ok(observed) => Ok(observed),
424            Err(error)
425                if matches!(
426                    configured.kind,
427                    DesiredCanisterKind::Store | DesiredCanisterKind::Pool
428                ) && !matches!(
429                    error,
430                    IcpEnsurePlatformError::Icp(ref source)
431                        if matches!(
432                            source.diagnostic(),
433                            Some(IcpDiagnostic::CanisterNotFound { .. })
434                        )
435                ) =>
436            {
437                self.observe_root_owned_canister(configured, principal, state)
438            }
439            Err(error) => Err(error),
440        }
441    }
442
443    fn observe_root_owned_canister(
444        &self,
445        configured: &crate::fleet_ensure::model::DesiredCanister,
446        principal: &str,
447        state: &FleetEnsureStateRecord,
448    ) -> Result<Option<LiveCanister>, IcpEnsurePlatformError> {
449        let protocol_intent = self.desired.protocol.as_ref().ok_or_else(|| {
450            current_protocol::CurrentProtocolError::Configuration(
451                "Root-owned observation requires typed Fleet protocol".to_string(),
452            )
453        })?;
454        let parent = configured.parent.as_deref().ok_or_else(|| {
455            current_protocol::CurrentProtocolError::Configuration(format!(
456                "Root-owned canister {} has no Root parent",
457                configured.name
458            ))
459        })?;
460        let root = self.current_principal(state, parent).ok_or_else(|| {
461            current_protocol::CurrentProtocolError::Configuration(format!(
462                "Root-owned canister {} has no resolved Root",
463                configured.name
464            ))
465        })?;
466        let candid = resolve_path(&self.root, &protocol_intent.root_candid);
467        let target = parse_principal("Root-owned canister", principal)?;
468        let mut start_after = None;
469        loop {
470            let response: RootPoolStatusResponse = query_with_candid(
471                &self.icp,
472                &candid,
473                parse_principal("Fleet Subnet Root", root)?,
474                canic_protocol::CANIC_STATUS,
475                &RootPoolStatusRequest::Pool(CanisterPoolStatusRequest {
476                    start_after,
477                    limit: 256,
478                }),
479            )
480            .map_err(current_protocol::CurrentProtocolError::from)?;
481            let RootPoolStatusResponse::Pool(page) = response;
482            if let Some(asset) = page
483                .entries
484                .into_iter()
485                .find(|asset| asset.canister_id == target)
486            {
487                let Some(root_owned_lifecycle) =
488                    root_owned_lifecycle(configured.kind, &asset.status)
489                else {
490                    return Err(current_protocol::CurrentProtocolError::Configuration(format!(
491                        "Root-owned canister {} has a live pool role incompatible with desired {:?}",
492                        configured.name, configured.kind
493                    ))
494                    .into());
495                };
496                return Ok(Some(LiveCanister {
497                    controllers: vec![root.to_string()],
498                    cycles: asset.cycles.to_u128(),
499                    module_sha256: None,
500                    principal: principal.to_string(),
501                    root_owned_lifecycle: Some(root_owned_lifecycle),
502                    status: match root_owned_lifecycle {
503                        RootOwnedCanisterLifecycle::Store
504                        | RootOwnedCanisterLifecycle::Workload => CanisterRuntimeStatus::Running,
505                        RootOwnedCanisterLifecycle::Claimed | RootOwnedCanisterLifecycle::Idle => {
506                            CanisterRuntimeStatus::Stopped
507                        }
508                    },
509                }));
510            }
511            let next = page.next_start_after;
512            if next.is_none() {
513                return Ok(None);
514            }
515            if next == start_after {
516                return Err(
517                    current_protocol::CurrentProtocolError::Configuration(format!(
518                        "Root {parent} pool cursor did not advance"
519                    ))
520                    .into(),
521                );
522            }
523            start_after = next;
524        }
525    }
526
527    fn apply_create(
528        &self,
529        controllers: &[String],
530        created_at_time: u64,
531        ledger: &str,
532        requested_initial_cycles: u128,
533        subnet: &str,
534    ) -> Result<EffectOutcome, IcpEnsurePlatformError> {
535        let creation_fee = self
536            .desired
537            .management_creation_fee_cycles
538            .parse::<u128>()
539            .map_err(|_| IcpEnsurePlatformError::Arithmetic("management creation fee"))?;
540        let amount = requested_initial_cycles
541            .checked_add(creation_fee)
542            .ok_or(IcpEnsurePlatformError::Arithmetic("creation amount"))?;
543        let controllers = controllers
544            .iter()
545            .map(|value| parse_principal("controller", value))
546            .collect::<Result<Vec<_>, _>>()?;
547        let request = CreateCanisterArgs {
548            amount: Nat::from(amount),
549            created_at_time: Some(created_at_time),
550            creation_args: Some(CmcCreateCanisterArgs {
551                settings: Some(CanisterSettings {
552                    compute_allocation: None,
553                    controllers: Some(controllers),
554                    freezing_threshold: None,
555                    memory_allocation: None,
556                    reserved_cycles_limit: None,
557                }),
558                subnet_selection: Some(SubnetSelection::Subnet {
559                    subnet: parse_principal("subnet", subnet)?,
560                }),
561            }),
562            from_subaccount: None,
563        };
564        let response: Result<CreateCanisterSuccess, CreateCanisterError> = self
565            .icp
566            .canister_call_candid(ledger, "create_canister", &request, None)?;
567        match response {
568            Ok(success) => Ok(EffectOutcome {
569                created_principal: Some(success.canister_id.to_text()),
570                post_cycles: None,
571                receipt: Some(success.block_id.to_string()),
572            }),
573            Err(CreateCanisterError::Duplicate {
574                duplicate_of,
575                canister_id: Some(canister_id),
576            }) => Ok(EffectOutcome {
577                created_principal: Some(canister_id.to_text()),
578                post_cycles: None,
579                receipt: Some(duplicate_of.to_string()),
580            }),
581            Err(CreateCanisterError::Duplicate {
582                canister_id: None, ..
583            }) => Err(IcpEnsurePlatformError::LedgerCreatePending),
584            Err(error) => Err(IcpEnsurePlatformError::LedgerCreate(render_create_error(
585                error,
586            ))),
587        }
588    }
589
590    fn apply_fund(
591        &self,
592        amount: u128,
593        created_at_time: u64,
594        ledger: &str,
595        principal: &str,
596    ) -> Result<EffectOutcome, IcpEnsurePlatformError> {
597        let request = WithdrawArgs {
598            amount: Nat::from(amount),
599            created_at_time: Some(created_at_time),
600            from_subaccount: None,
601            to: parse_principal("funding target", principal)?,
602        };
603        let response: Result<Nat, WithdrawError> = self
604            .icp
605            .canister_call_candid(ledger, "withdraw", &request, None)?;
606        match response {
607            Ok(block) => Ok(EffectOutcome {
608                created_principal: None,
609                post_cycles: None,
610                receipt: Some(block.to_string()),
611            }),
612            Err(WithdrawError::Duplicate { duplicate_of }) => Ok(EffectOutcome {
613                created_principal: None,
614                post_cycles: None,
615                receipt: Some(duplicate_of.to_string()),
616            }),
617            Err(error) => Err(IcpEnsurePlatformError::LedgerWithdraw(
618                render_withdraw_error(error),
619            )),
620        }
621    }
622
623    #[expect(
624        clippy::too_many_arguments,
625        reason = "the install boundary verifies one complete immutable action tuple"
626    )]
627    fn apply_install(
628        &self,
629        operation_id: &str,
630        principals: &BTreeMap<String, String>,
631        canic_init: Option<&crate::fleet_ensure::model::DesiredCanisterInit>,
632        init_arg: Option<&str>,
633        init_arg_sha256: Option<&str>,
634        init_candid: Option<&str>,
635        init_candid_sha256: Option<&str>,
636        mode: InstallMode,
637        principal: &str,
638        wasm: &str,
639        wasm_sha256: &str,
640    ) -> Result<EffectOutcome, IcpEnsurePlatformError> {
641        verify_artifact(&resolve_path(&self.root, wasm), wasm_sha256, "Wasm")?;
642        let mut command = self.icp.canister_command();
643        command.args(["install", principal, "--mode"]);
644        command.arg(match mode {
645            InstallMode::Install => "install",
646            InstallMode::Reinstall => "reinstall",
647        });
648        command.args(["--yes", "--wasm"]);
649        command.arg(resolve_path(&self.root, wasm));
650        let generated_init = if let Some(canic_init) = canic_init {
651            let path = canic_init::write_arguments(canic_init::CanicInitRequest {
652                desired: &self.desired,
653                init: canic_init,
654                operation_id,
655                principals,
656                root: &self.root,
657                wasm,
658                wasm_sha256,
659            })?;
660            command.arg("--args-file");
661            command.arg(&path);
662            command.args(["--args-format", "bin"]);
663            Some(path)
664        } else if let Some(init_arg) = init_arg {
665            let expected =
666                init_arg_sha256.ok_or_else(|| IcpEnsurePlatformError::ArtifactDigestMismatch {
667                    actual: "missing".to_string(),
668                    expected: "reviewed init argument identity".to_string(),
669                    kind: "init argument",
670                })?;
671            let init_candid =
672                init_candid.ok_or_else(|| IcpEnsurePlatformError::ArtifactDigestMismatch {
673                    actual: "missing".to_string(),
674                    expected: "reviewed init Candid".to_string(),
675                    kind: "init Candid",
676                })?;
677            let init_candid_sha256 = init_candid_sha256.ok_or_else(|| {
678                IcpEnsurePlatformError::ArtifactDigestMismatch {
679                    actual: "missing".to_string(),
680                    expected: "reviewed init Candid identity".to_string(),
681                    kind: "init Candid",
682                }
683            })?;
684            let path = protocol::write_init_arguments(
685                &self.root,
686                operation_id,
687                principals,
688                init_candid,
689                init_candid_sha256,
690                init_arg,
691                expected,
692            )?;
693            command.arg("--args-file");
694            command.arg(&path);
695            command.args(["--args-format", "bin"]);
696            Some(path)
697        } else {
698            command.args(["--args", "()"]);
699            None
700        };
701        self.icp.add_target_args(&mut command);
702        let result = run_status(&mut command).map_err(IcpEnsurePlatformError::from);
703        if let Some(path) = generated_init {
704            std::fs::remove_file(&path)
705                .map_err(|_| IcpEnsurePlatformError::ArtifactUnavailable(path))?;
706        }
707        result?;
708        Ok(empty_outcome())
709    }
710
711    fn apply_controllers(
712        &self,
713        principal: &str,
714        controllers: &[String],
715    ) -> Result<EffectOutcome, IcpEnsurePlatformError> {
716        let mut command = self.icp.canister_command();
717        command.args([
718            "settings",
719            "update",
720            principal,
721            "--force",
722            "--remove-all-controllers",
723        ]);
724        for controller in controllers {
725            parse_principal("controller", controller)?;
726            command.args(["--add-controller", controller]);
727        }
728        self.icp.add_target_args(&mut command);
729        run_status(&mut command)?;
730        Ok(empty_outcome())
731    }
732
733    #[expect(
734        clippy::too_many_arguments,
735        reason = "one typed transfer effect carries the complete reviewed authority tuple"
736    )]
737    fn apply_transfer(
738        &self,
739        amount: u128,
740        candid: &str,
741        candid_sha256: &str,
742        destination: &str,
743        method: &str,
744        operation_id: &str,
745        principal: &str,
746    ) -> Result<EffectOutcome, IcpEnsurePlatformError> {
747        let candid = resolve_path(&self.root, candid);
748        if !candid.is_file() {
749            return Err(IcpEnsurePlatformError::CandidUnavailable(candid));
750        }
751        verify_artifact(&candid, candid_sha256, "drain Candid")?;
752        let response: DrainResponse = self.icp.canister_call_candid(
753            principal,
754            method,
755            &DrainRequest {
756                operation_id: operation_id.to_string(),
757                destination: parse_principal("treasury", destination)?,
758                cycles: Nat::from(amount),
759            },
760            Some(&candid),
761        )?;
762        let transferred = match response {
763            DrainResponse::Accepted { transferred_cycles }
764            | DrainResponse::Replayed { transferred_cycles } => transferred_cycles,
765        };
766        if transferred != amount {
767            return Err(IcpEnsurePlatformError::WrongTransferAmount {
768                actual: transferred,
769                expected: amount,
770            });
771        }
772        Ok(EffectOutcome {
773            created_principal: None,
774            post_cycles: None,
775            receipt: Some(operation_id.to_string()),
776        })
777    }
778}
779
780impl EnsurePlatform for IcpEnsurePlatform {
781    type Error = IcpEnsurePlatformError;
782
783    fn observe(
784        &mut self,
785        operation_id: &str,
786        state: &FleetEnsureStateRecord,
787    ) -> Result<FleetObservation, Self::Error> {
788        self.require_operator()?;
789        let mut canisters = BTreeMap::new();
790        for configured in &self.desired.canisters {
791            let observed = self
792                .current_principal(state, &configured.name)
793                .map(|principal| self.observe_configured_canister(configured, principal, state))
794                .transpose()?
795                .flatten();
796            canisters.insert(configured.name.clone(), observed);
797        }
798        let principals = self.protocol_principals(state);
799        let protocol_ready = self
800            .desired
801            .protocol_steps
802            .iter()
803            .map(|step| {
804                let Some(principal) = principals.get(&step.canister) else {
805                    return Ok((step.name.clone(), false));
806                };
807                let live = canisters.get(&step.canister).and_then(Option::as_ref);
808                let Some(live) = live else {
809                    return Ok((step.name.clone(), false));
810                };
811                let configured = self
812                    .desired
813                    .canisters
814                    .iter()
815                    .find(|configured| configured.name == step.canister)
816                    .expect("protocol target was validated against desired canisters");
817                if let Some(wasm) = &configured.wasm {
818                    let desired_hash = artifact_hash(&resolve_path(&self.root, wasm))?;
819                    if live.module_sha256.as_deref() != Some(desired_hash.as_str()) {
820                        return Ok((step.name.clone(), false));
821                    }
822                }
823                let action = self.observed_protocol_action(step, principal.clone())?;
824                protocol::observe(&self.icp, &self.root, operation_id, &principals, &action)
825                    .map(|observation| (step.name.clone(), observation.applied))
826                    .map_err(IcpEnsurePlatformError::from)
827            })
828            .collect::<Result<BTreeMap<_, _>, _>>()?;
829        Ok(FleetObservation {
830            additional_controlled_cycles: BTreeMap::new(),
831            canisters,
832            ledger_fee_cycles: ledger_fee_cycles(self.icp.canister_query_candid(
833                &self.desired.cycles_ledger,
834                "icrc1_fee",
835                &(),
836                None,
837            )?)?,
838            operator_cycles: self
839                .icp
840                .identity_cycles_balance()
841                .map_err(|error| IcpEnsurePlatformError::LedgerWithdraw(error.to_string()))?,
842            protocol_ready,
843        })
844    }
845
846    fn protocol_actions(
847        &mut self,
848        operation_id: &str,
849        state: &FleetEnsureStateRecord,
850    ) -> Result<Vec<EnsureAction>, Self::Error> {
851        if self.desired.protocol.is_none() {
852            return Ok(Vec::new());
853        }
854        let store_control = current_protocol::compile_store_control_actions(
855            &self.icp,
856            &self.root,
857            &self.desired,
858            operation_id,
859            state,
860        )?;
861        if !store_control.is_empty() {
862            return Ok(store_control);
863        }
864        let coordinator = self
865            .desired
866            .canisters
867            .iter()
868            .find(|configured| {
869                configured.presence == crate::fleet_ensure::model::DesiredPresence::Present
870                    && configured.kind
871                        == crate::fleet_ensure::model::DesiredCanisterKind::Coordinator
872            })
873            .expect("typed topology validation requires one Coordinator");
874        let Some(principal) = self.current_principal(state, &coordinator.name) else {
875            return Ok(store_control);
876        };
877        let Some(live) = self.status_optional(principal)? else {
878            return Ok(store_control);
879        };
880        if let Some(wasm) = &coordinator.wasm {
881            let expected = artifact_hash(&resolve_path(&self.root, wasm))?;
882            if live.module_sha256.as_deref() != Some(expected.as_str()) {
883                return Ok(store_control);
884            }
885        }
886        current_protocol::compile(&self.icp, &self.root, &self.desired, operation_id, state)
887            .map_err(Into::into)
888    }
889
890    fn terminal_inventory(
891        &mut self,
892        operation_id: &str,
893        state: &FleetEnsureStateRecord,
894    ) -> Result<TerminalFleetInventory, Self::Error> {
895        if self.desired.protocol.is_none() {
896            if state.active_registry.is_some() {
897                return Err(current_protocol::CurrentProtocolError::Configuration(
898                    "managed terminal Fleet cannot drop its typed protocol intent".to_string(),
899                )
900                .into());
901            }
902            return Ok(TerminalFleetInventory::default());
903        }
904        super::current_inventory::terminal_inventory(
905            &self.icp,
906            &self.root,
907            &self.desired,
908            operation_id,
909            state,
910        )
911        .map_err(Into::into)
912    }
913
914    #[expect(
915        clippy::too_many_lines,
916        reason = "the platform keeps every effect's exact live terminal predicate in one exhaustive match"
917    )]
918    fn observe_effect(
919        &mut self,
920        operation_id: &str,
921        action: &EnsureAction,
922        record: &EffectRecord,
923        state: &FleetEnsureStateRecord,
924    ) -> Result<EffectObservation, Self::Error> {
925        let (applied, progress_identity) = match action {
926            EnsureAction::Create { .. } => (
927                record.created_principal.is_some(),
928                format!("created:{:?}", record.created_principal),
929            ),
930            EnsureAction::Delete { principal, .. } => {
931                let live = self.status_optional(Self::action_principal(state, principal)?)?;
932                (live.is_none(), format!("delete:{live:?}"))
933            }
934            EnsureAction::Fund { .. } => (
935                record.receipt.is_some(),
936                format!("fund:{}", record.receipt.as_deref().unwrap_or("pending")),
937            ),
938            EnsureAction::Install {
939                principal,
940                wasm_sha256,
941                ..
942            } => {
943                let live = self.status_optional(Self::action_principal(state, principal)?)?;
944                (
945                    live.as_ref()
946                        .is_some_and(|live| live.module_sha256.as_deref() == Some(wasm_sha256)),
947                    format!("install:{:?}", live.and_then(|live| live.module_sha256)),
948                )
949            }
950            EnsureAction::FleetProtocol { .. } => {
951                let observation = current_protocol::observe(&self.icp, &self.root, action)?;
952                (observation.applied, observation.progress_identity)
953            }
954            EnsureAction::Protocol { .. } => {
955                let observation = protocol::observe(
956                    &self.icp,
957                    &self.root,
958                    operation_id,
959                    &self.protocol_principals(state),
960                    &resolved_protocol_action(action, state)?,
961                )?;
962                (observation.applied, observation.progress_identity)
963            }
964            EnsureAction::SetControllers {
965                controllers,
966                principal,
967                ..
968            } => {
969                let mut expected = controllers.clone();
970                expected.sort();
971                let live = self.status_optional(Self::action_principal(state, principal)?)?;
972                (
973                    live.as_ref()
974                        .is_some_and(|live| live.controllers == expected),
975                    format!("controllers:{:?}", live.map(|live| live.controllers)),
976                )
977            }
978            EnsureAction::Start { principal, .. } => {
979                let live = self.status_optional(Self::action_principal(state, principal)?)?;
980                (
981                    live.as_ref()
982                        .is_some_and(|live| live.status == CanisterRuntimeStatus::Running),
983                    format!("start:{:?}", live.map(|live| live.status)),
984                )
985            }
986            EnsureAction::Stop { principal, .. } => {
987                let live = self.status_optional(Self::action_principal(state, principal)?)?;
988                (
989                    live.as_ref()
990                        .is_some_and(|live| live.status == CanisterRuntimeStatus::Stopped),
991                    format!("stop:{:?}", live.map(|live| live.status)),
992                )
993            }
994            EnsureAction::Transfer {
995                amount,
996                maximum_execution_burn_cycles,
997                name,
998                principal,
999                destination,
1000                ..
1001            } => {
1002                let source = self
1003                    .status_optional(Self::action_principal(state, principal)?)?
1004                    .map(|live| live.cycles)
1005                    .ok_or_else(|| IcpEnsurePlatformError::MissingTransferBalance {
1006                        canister: name.clone(),
1007                        side: "live source",
1008                    })?;
1009                let destination = self
1010                    .status_optional(destination)?
1011                    .map(|live| live.cycles)
1012                    .ok_or_else(|| IcpEnsurePlatformError::MissingTransferBalance {
1013                        canister: name.clone(),
1014                        side: "live treasury",
1015                    })?;
1016                let source_pre = record.pre_cycles.ok_or_else(|| {
1017                    IcpEnsurePlatformError::MissingTransferBalance {
1018                        canister: name.clone(),
1019                        side: "retained source",
1020                    }
1021                })?;
1022                let destination_pre = record.destination_pre_cycles.ok_or_else(|| {
1023                    IcpEnsurePlatformError::MissingTransferBalance {
1024                        canister: name.clone(),
1025                        side: "retained treasury",
1026                    }
1027                })?;
1028                let reconciliation = reconcile_retirement_transfer(RetirementTransferBalances {
1029                    destination_after: destination,
1030                    destination_before: destination_pre,
1031                    maximum_execution_burn: *maximum_execution_burn_cycles,
1032                    source_after: source,
1033                    source_before: source_pre,
1034                    transfer_amount: *amount,
1035                });
1036                let (applied, source_debit, destination_credit) = match reconciliation {
1037                    Ok(RetirementTransferReconciliation::Pending) => (false, 0, 0),
1038                    Ok(RetirementTransferReconciliation::Conserved {
1039                        destination_credit,
1040                        source_debit,
1041                    }) => (true, source_debit, destination_credit),
1042                    Err(RetirementTransferInvariantError::ArithmeticOverflow) => {
1043                        return Err(IcpEnsurePlatformError::Arithmetic(
1044                            "retirement transfer debit",
1045                        ));
1046                    }
1047                    Err(RetirementTransferInvariantError::BalanceMovedInInvalidDirection) => {
1048                        return Err(IcpEnsurePlatformError::InvalidTransferBalance {
1049                            canister: name.clone(),
1050                        });
1051                    }
1052                    Err(RetirementTransferInvariantError::Unbalanced {
1053                        destination_credit,
1054                        maximum_source_debit,
1055                        source_debit,
1056                    }) => {
1057                        return Err(IcpEnsurePlatformError::UnbalancedTransfer {
1058                            canister: name.clone(),
1059                            destination_credit,
1060                            expected: *amount,
1061                            maximum_source_debit,
1062                            source_debit,
1063                        });
1064                    }
1065                };
1066                (
1067                    applied,
1068                    format!("transfer:{source_debit}:{destination_credit}"),
1069                )
1070            }
1071        };
1072        Ok(EffectObservation {
1073            applied,
1074            progress_identity,
1075        })
1076    }
1077
1078    #[expect(
1079        clippy::too_many_lines,
1080        reason = "the platform keeps every approved single-step effect in one exhaustive match"
1081    )]
1082    fn apply(
1083        &mut self,
1084        operation_id: &str,
1085        action: &EnsureAction,
1086        _record: &EffectRecord,
1087        state: &FleetEnsureStateRecord,
1088    ) -> Result<EffectOutcome, Self::Error> {
1089        self.require_operator()?;
1090        match action {
1091            EnsureAction::Create {
1092                controllers,
1093                created_at_time,
1094                ledger,
1095                requested_initial_cycles,
1096                subnet,
1097                ..
1098            } => self.apply_create(
1099                controllers,
1100                *created_at_time,
1101                ledger,
1102                *requested_initial_cycles,
1103                subnet,
1104            ),
1105            EnsureAction::Delete {
1106                maximum_remaining_cycles,
1107                principal,
1108                ..
1109            } => {
1110                let principal = Self::action_principal(state, principal)?;
1111                if let Some(live) = self.status_optional(principal)? {
1112                    if live.cycles > *maximum_remaining_cycles {
1113                        return Err(IcpEnsurePlatformError::MaterialCycles {
1114                            actual: live.cycles,
1115                            canister: principal.to_string(),
1116                            maximum: *maximum_remaining_cycles,
1117                        });
1118                    }
1119                    if live.status != CanisterRuntimeStatus::Stopped {
1120                        return Err(IcpEnsurePlatformError::NotStopped {
1121                            canister: principal.to_string(),
1122                        });
1123                    }
1124                    self.icp.delete_canister_without_cycle_recovery(principal)?;
1125                }
1126                Ok(empty_outcome())
1127            }
1128            EnsureAction::Fund {
1129                amount,
1130                created_at_time,
1131                ledger,
1132                principal,
1133                ..
1134            } => self.apply_fund(
1135                *amount,
1136                *created_at_time,
1137                ledger,
1138                Self::action_principal(state, principal)?,
1139            ),
1140            EnsureAction::Install {
1141                canic_init,
1142                init_arg,
1143                init_arg_sha256,
1144                init_candid,
1145                init_candid_sha256,
1146                mode,
1147                principal,
1148                wasm,
1149                wasm_sha256,
1150                ..
1151            } => self.apply_install(
1152                operation_id,
1153                &self.protocol_principals(state),
1154                canic_init.as_ref(),
1155                init_arg.as_deref(),
1156                init_arg_sha256.as_deref(),
1157                init_candid.as_deref(),
1158                init_candid_sha256.as_deref(),
1159                *mode,
1160                Self::action_principal(state, principal)?,
1161                wasm,
1162                wasm_sha256,
1163            ),
1164            EnsureAction::FleetProtocol { .. } => {
1165                current_protocol::apply(&self.icp, &self.root, action).map_err(Into::into)
1166            }
1167            EnsureAction::Protocol { .. } => {
1168                let action = resolved_protocol_action(action, state)?;
1169                protocol::apply(
1170                    &self.icp,
1171                    &self.root,
1172                    operation_id,
1173                    &self.protocol_principals(state),
1174                    &action,
1175                )?;
1176                Ok(EffectOutcome {
1177                    created_principal: None,
1178                    post_cycles: None,
1179                    receipt: Some(operation_id.to_string()),
1180                })
1181            }
1182            EnsureAction::SetControllers {
1183                controllers,
1184                principal,
1185                ..
1186            } => self.apply_controllers(Self::action_principal(state, principal)?, controllers),
1187            EnsureAction::Start { principal, .. } => {
1188                self.icp
1189                    .start_canister(Self::action_principal(state, principal)?)?;
1190                Ok(empty_outcome())
1191            }
1192            EnsureAction::Stop { principal, .. } => {
1193                self.icp
1194                    .stop_canister(Self::action_principal(state, principal)?)?;
1195                Ok(empty_outcome())
1196            }
1197            EnsureAction::Transfer {
1198                amount,
1199                candid,
1200                candid_sha256,
1201                destination,
1202                method,
1203                principal,
1204                ..
1205            } => self.apply_transfer(
1206                *amount,
1207                candid,
1208                candid_sha256,
1209                destination,
1210                method,
1211                operation_id,
1212                Self::action_principal(state, principal)?,
1213            ),
1214        }
1215    }
1216
1217    fn action_cycles(
1218        &mut self,
1219        action: &EnsureAction,
1220        state: &FleetEnsureStateRecord,
1221    ) -> Result<Option<u128>, Self::Error> {
1222        let (name, principal) = match action {
1223            EnsureAction::Create { .. } => return Ok(None),
1224            EnsureAction::Delete {
1225                name, principal, ..
1226            }
1227            | EnsureAction::FleetProtocol {
1228                name, principal, ..
1229            }
1230            | EnsureAction::Fund {
1231                name, principal, ..
1232            }
1233            | EnsureAction::Install {
1234                name, principal, ..
1235            }
1236            | EnsureAction::Protocol {
1237                name, principal, ..
1238            }
1239            | EnsureAction::SetControllers {
1240                name, principal, ..
1241            }
1242            | EnsureAction::Start {
1243                name, principal, ..
1244            }
1245            | EnsureAction::Stop {
1246                name, principal, ..
1247            }
1248            | EnsureAction::Transfer {
1249                name, principal, ..
1250            } => (name, Self::action_principal(state, principal)?),
1251        };
1252        let observed = self
1253            .desired
1254            .canisters
1255            .iter()
1256            .find(|configured| configured.name == *name)
1257            .map_or_else(
1258                || self.status_optional(principal),
1259                |configured| self.observe_configured_canister(configured, principal, state),
1260            )?;
1261        Ok(observed.map(|live| live.cycles))
1262    }
1263
1264    fn action_destination_cycles(
1265        &mut self,
1266        action: &EnsureAction,
1267        _state: &FleetEnsureStateRecord,
1268    ) -> Result<Option<u128>, Self::Error> {
1269        let EnsureAction::Transfer { destination, .. } = action else {
1270            return Ok(None);
1271        };
1272        Ok(self.status_optional(destination)?.map(|live| live.cycles))
1273    }
1274}
1275
1276fn ledger_fee_cycles(value: Nat) -> Result<u128, IcpEnsurePlatformError> {
1277    let rendered = value.to_string();
1278    u128::try_from(value.0).map_err(|_| IcpEnsurePlatformError::InvalidLedgerFee(rendered))
1279}
1280
1281const fn empty_outcome() -> EffectOutcome {
1282    EffectOutcome {
1283        created_principal: None,
1284        post_cycles: None,
1285        receipt: None,
1286    }
1287}
1288
1289fn parse_status_cycles(value: &str) -> Option<u128> {
1290    value.replace('_', "").trim().parse().ok()
1291}
1292
1293fn normalize_hash(value: &str) -> String {
1294    value
1295        .strip_prefix("0x")
1296        .unwrap_or(value)
1297        .to_ascii_lowercase()
1298}
1299
1300fn parse_principal(field: &'static str, value: &str) -> Result<Principal, IcpEnsurePlatformError> {
1301    Principal::from_text(value).map_err(|_| IcpEnsurePlatformError::InvalidPrincipal {
1302        field,
1303        value: value.to_string(),
1304    })
1305}
1306
1307fn resolve_path(root: &Path, path: &str) -> PathBuf {
1308    let path = Path::new(path);
1309    if path.is_absolute() {
1310        path.to_path_buf()
1311    } else {
1312        root.join(path)
1313    }
1314}
1315
1316fn verify_artifact(
1317    path: &Path,
1318    expected: &str,
1319    kind: &'static str,
1320) -> Result<(), IcpEnsurePlatformError> {
1321    let bytes = std::fs::read(path)
1322        .map_err(|_| IcpEnsurePlatformError::ArtifactUnavailable(path.to_path_buf()))?;
1323    let actual = canic_core::cdk::utils::hash::sha256_hex(&bytes);
1324    if actual != expected {
1325        return Err(IcpEnsurePlatformError::ArtifactDigestMismatch {
1326            actual,
1327            expected: expected.to_string(),
1328            kind,
1329        });
1330    }
1331    Ok(())
1332}
1333
1334fn artifact_hash(path: &Path) -> Result<String, IcpEnsurePlatformError> {
1335    let bytes = std::fs::read(path)
1336        .map_err(|_| IcpEnsurePlatformError::ArtifactUnavailable(path.to_path_buf()))?;
1337    Ok(canic_core::cdk::utils::hash::sha256_hex(&bytes))
1338}
1339
1340fn resolved_protocol_action(
1341    action: &EnsureAction,
1342    state: &FleetEnsureStateRecord,
1343) -> Result<EnsureAction, IcpEnsurePlatformError> {
1344    let mut resolved = action.clone();
1345    if let EnsureAction::Protocol { principal, .. } = &mut resolved {
1346        *principal = IcpEnsurePlatform::action_principal(state, principal)?.to_string();
1347        return Ok(resolved);
1348    }
1349    Err(IcpEnsurePlatformError::Protocol(
1350        protocol::ProtocolEffectError::WrongAction,
1351    ))
1352}
1353
1354fn render_create_error(error: CreateCanisterError) -> String {
1355    match error {
1356        CreateCanisterError::CreatedInFuture { ledger_time } => {
1357            format!("created in future of ledger time {ledger_time}")
1358        }
1359        CreateCanisterError::Duplicate { duplicate_of, .. } => {
1360            format!("duplicate block {duplicate_of}")
1361        }
1362        CreateCanisterError::FailedToCreate {
1363            error,
1364            fee_block,
1365            refund_block,
1366        } => format!("failed: {error}; fee_block={fee_block:?}; refund_block={refund_block:?}"),
1367        CreateCanisterError::GenericError {
1368            error_code,
1369            message,
1370        } => format!("error {error_code}: {message}"),
1371        CreateCanisterError::InsufficientFunds { balance } => {
1372            format!("insufficient funds: balance={balance}")
1373        }
1374        CreateCanisterError::TemporarilyUnavailable => "temporarily unavailable".to_string(),
1375        CreateCanisterError::TooOld => "request is too old".to_string(),
1376    }
1377}
1378
1379fn render_withdraw_error(error: WithdrawError) -> String {
1380    match error {
1381        WithdrawError::BadFee { expected_fee } => format!("bad fee; expected {expected_fee}"),
1382        WithdrawError::CreatedInFuture { ledger_time } => {
1383            format!("created in future of ledger time {ledger_time}")
1384        }
1385        WithdrawError::Duplicate { duplicate_of } => format!("duplicate block {duplicate_of}"),
1386        WithdrawError::FailedToWithdraw {
1387            fee_block,
1388            rejection_reason,
1389            ..
1390        } => format!("withdraw failed: {rejection_reason}; fee_block={fee_block:?}"),
1391        WithdrawError::GenericError {
1392            error_code,
1393            message,
1394        } => format!("error {error_code}: {message}"),
1395        WithdrawError::InsufficientFunds { balance } => {
1396            format!("insufficient funds: balance={balance}")
1397        }
1398        WithdrawError::InvalidReceiver { receiver } => format!("invalid receiver {receiver}"),
1399        WithdrawError::TemporarilyUnavailable => "temporarily unavailable".to_string(),
1400        WithdrawError::TooOld => "request is too old".to_string(),
1401    }
1402}
1403
1404#[expect(
1405    dead_code,
1406    reason = "Candid variant is retained for exact response decoding"
1407)]
1408const fn rejection_code_name(code: RejectionCode) -> &'static str {
1409    match code {
1410        RejectionCode::CanisterError => "canister_error",
1411        RejectionCode::CanisterReject => "canister_reject",
1412        RejectionCode::DestinationInvalid => "destination_invalid",
1413        RejectionCode::NoError => "no_error",
1414        RejectionCode::SysFatal => "sys_fatal",
1415        RejectionCode::SysTransient => "sys_transient",
1416        RejectionCode::Unknown => "unknown",
1417    }
1418}
1419
1420#[cfg(test)]
1421mod tests {
1422    use super::*;
1423    use canic_core::dto::pool::CanisterPoolAssetStatus;
1424
1425    #[test]
1426    fn root_owned_observation_classifies_bootstrap_and_workload_lifecycle() {
1427        assert_eq!(
1428            root_owned_lifecycle(DesiredCanisterKind::Store, &CanisterPoolAssetStatus::Store),
1429            Some(RootOwnedCanisterLifecycle::Store)
1430        );
1431        assert_eq!(
1432            root_owned_lifecycle(DesiredCanisterKind::Pool, &CanisterPoolAssetStatus::Ready),
1433            Some(RootOwnedCanisterLifecycle::Idle)
1434        );
1435        assert_eq!(
1436            root_owned_lifecycle(
1437                DesiredCanisterKind::Pool,
1438                &CanisterPoolAssetStatus::Claimed {
1439                    claim: canic_core::dto::pool::CanisterPoolClaim {
1440                        component: canic_core::ids::ComponentInstanceId::from_generated_bytes(
1441                            [1; 32]
1442                        ),
1443                        operation_id: [2; 32],
1444                    },
1445                }
1446            ),
1447            Some(RootOwnedCanisterLifecycle::Claimed)
1448        );
1449        assert_eq!(
1450            root_owned_lifecycle(
1451                DesiredCanisterKind::Pool,
1452                &CanisterPoolAssetStatus::Workload {
1453                    claim: canic_core::dto::pool::CanisterPoolClaim {
1454                        component: canic_core::ids::ComponentInstanceId::from_generated_bytes(
1455                            [1; 32]
1456                        ),
1457                        operation_id: [2; 32],
1458                    },
1459                }
1460            ),
1461            Some(RootOwnedCanisterLifecycle::Workload)
1462        );
1463        assert_eq!(
1464            root_owned_lifecycle(
1465                DesiredCanisterKind::Pool,
1466                &CanisterPoolAssetStatus::HandingOff {
1467                    recipient: Principal::anonymous(),
1468                }
1469            ),
1470            None
1471        );
1472        assert_eq!(
1473            root_owned_lifecycle(
1474                DesiredCanisterKind::Store,
1475                &CanisterPoolAssetStatus::PendingReset
1476            ),
1477            None
1478        );
1479    }
1480}