Skip to main content

canic_host/fleet_ensure/workflow/
mod.rs

1//! Module: fleet_ensure::workflow
2//!
3//! Responsibility: plan and advance one durable idempotent Fleet convergence operation.
4//! Does not own: policy decisions, IC transport, or storage mechanics.
5//! Boundary: persists exact intent before each ops effect and reconciles it before any retry.
6
7use crate::fleet_ensure::{
8    inventory::project_current_fleet_inventory,
9    model::{
10        ActualCycleConservation, CanisterDisposition, CycleConservation, EffectRecord, EffectState,
11        EnsureAction, FLEET_ENSURE_SCHEMA_VERSION, FleetEnsureCompletion, FleetEnsureJournalRecord,
12        FleetEnsurePlan, FleetEnsureReport, FleetEnsureStateRecord, FleetObservation,
13    },
14    ops::{
15        EnsurePaths, EnsurePlatform, EnsureStateError, action_sha256, lock_operation, read_journal,
16        read_plan, read_state, resolve_desired_artifacts, write_journal, write_plan, write_state,
17    },
18    policy::{
19        EnsurePolicyError, compile_plan, expected_plan_sha256, operation_id, validate_path_identity,
20    },
21};
22use std::{collections::BTreeMap, path::Path};
23use thiserror::Error as ThisError;
24
25/// Fleet ensure planning or convergence failure.
26
27#[derive(Debug, ThisError)]
28pub enum EnsureWorkflowError<E>
29where
30    E: std::error::Error + 'static,
31{
32    #[error("another reviewed Fleet plan is already in progress: {operation_id}")]
33    ActiveOperation { operation_id: String },
34
35    #[error("reviewed Fleet plan digest changed before its first effect")]
36    DriftedBeforeApply,
37
38    #[error("reviewed Fleet plan {expected} does not match retained plan {actual}")]
39    PlanDigestMismatch { actual: String, expected: String },
40
41    #[error("reviewed Fleet plan is missing; run `canic fleet ensure <fleet>` first")]
42    PlanMissing,
43
44    #[error("retained Fleet plan failed its content digest")]
45    PlanIntegrity,
46
47    #[error("retained Fleet journal does not match the reviewed Fleet operation")]
48    JournalIntegrity,
49
50    #[error(
51        "Fleet ensure made no progress for {observations} consecutive observations; operation remains resumable"
52    )]
53    Stalled { observations: u32 },
54
55    #[error(
56        "selected Cycles Ledger account has {actual} cycles, below reviewed maximum debit {required}"
57    )]
58    InsufficientOperatorCycles { actual: u128, required: u128 },
59
60    #[error("terminal Fleet cycle conservation failed: {0}")]
61    Conservation(String),
62
63    #[error("terminal Fleet inventory failed exact current-authority validation: {0}")]
64    TerminalInventory(String),
65
66    #[error(
67        "reviewed effects completed, but live Fleet state still differs from desired state; review a new plan"
68    )]
69    ConvergenceDrift,
70
71    #[error(transparent)]
72    Policy(#[from] EnsurePolicyError),
73
74    #[error(transparent)]
75    Inventory(#[from] crate::fleet_ensure::CurrentFleetInventoryError),
76
77    #[error("Fleet platform operation failed: {0}")]
78    Platform(#[source] E),
79
80    #[error(transparent)]
81    State(#[from] EnsureStateError),
82}
83
84/// Build and retain one read-only plan from current desired state plus live observation.
85pub fn plan<P>(
86    root: &Path,
87    desired: &crate::fleet_ensure::model::DesiredFleet,
88    desired_sha256: &str,
89    requested_fleet: &str,
90    created_at_time: u64,
91    platform: &mut P,
92) -> Result<FleetEnsureReport, EnsureWorkflowError<P::Error>>
93where
94    P: EnsurePlatform,
95{
96    validate_path_identity(desired, requested_fleet)?;
97    let paths = EnsurePaths::under(root, &desired.environment, requested_fleet);
98    let _lock = lock_operation(&paths)?;
99    if let Some(journal) = read_journal(&paths)?
100        && journal.completion == FleetEnsureCompletion::InProgress
101    {
102        let retained = verified_plan(read_plan(&paths)?.ok_or(EnsureWorkflowError::PlanMissing)?)?;
103        verify_journal(&journal, &retained, requested_fleet)?;
104        if retained.desired_sha256 != desired_sha256 {
105            return Err(EnsureWorkflowError::ActiveOperation {
106                operation_id: journal.operation_id,
107            });
108        }
109        return Ok(FleetEnsureReport {
110            actual_conservation: None,
111            effects_applied: applied_count(&journal),
112            plan: retained,
113            terminal: false,
114        });
115    }
116    let state = read_state(&paths, requested_fleet)?;
117    let operation_id = operation_id(desired_sha256, &desired.environment, requested_fleet);
118    let mut observation = platform
119        .observe(&operation_id, &state)
120        .map_err(EnsureWorkflowError::Platform)?;
121    if state.active_registry.is_some() {
122        let prior = verified_plan(read_plan(&paths)?.ok_or(EnsureWorkflowError::PlanMissing)?)?;
123        let inventory = platform
124            .terminal_inventory(&prior.operation_id, &state)
125            .map_err(EnsureWorkflowError::Platform)?;
126        attach_terminal_cycles(&mut observation, inventory.controlled_cycles_by_principal)?;
127    }
128    let protocol_actions = platform
129        .protocol_actions(&operation_id, &state)
130        .map_err(EnsureWorkflowError::Platform)?;
131    let artifacts = resolve_desired_artifacts(root, desired)?;
132    let plan = compile_plan(
133        desired,
134        &artifacts,
135        &protocol_actions,
136        desired_sha256,
137        requested_fleet,
138        &observation,
139        created_at_time,
140    )?;
141    write_plan(&paths, &plan)?;
142    Ok(FleetEnsureReport {
143        actual_conservation: None,
144        effects_applied: 0,
145        plan,
146        terminal: false,
147    })
148}
149
150/// Apply or resume exactly one reviewed plan until it converges or returns a typed blocker.
151#[expect(
152    clippy::too_many_lines,
153    reason = "the sole effect driver keeps intent, reconciliation and terminal publication visibly ordered"
154)]
155pub fn apply<P>(
156    root: &Path,
157    desired: &crate::fleet_ensure::model::DesiredFleet,
158    desired_sha256: &str,
159    requested_fleet: &str,
160    reviewed_plan_sha256: &str,
161    platform: &mut P,
162) -> Result<FleetEnsureReport, EnsureWorkflowError<P::Error>>
163where
164    P: EnsurePlatform,
165{
166    validate_path_identity(desired, requested_fleet)?;
167    let paths = EnsurePaths::under(root, &desired.environment, requested_fleet);
168    let _lock = lock_operation(&paths)?;
169    let retained_plan = verified_plan(read_plan(&paths)?.ok_or(EnsureWorkflowError::PlanMissing)?)?;
170    if retained_plan.plan_sha256 != reviewed_plan_sha256 {
171        return Err(EnsureWorkflowError::PlanDigestMismatch {
172            actual: retained_plan.plan_sha256,
173            expected: reviewed_plan_sha256.to_string(),
174        });
175    }
176    if retained_plan.desired_sha256 != desired_sha256 {
177        return Err(EnsureWorkflowError::DriftedBeforeApply);
178    }
179    let mut state = read_state(&paths, requested_fleet)?;
180    let mut journal = match read_journal(&paths)? {
181        Some(journal) if journal.completion == FleetEnsureCompletion::InProgress => {
182            verify_journal(&journal, &retained_plan, requested_fleet)?;
183            journal
184        }
185        _ => {
186            let observation = verify_fresh_plan(
187                root,
188                desired,
189                desired_sha256,
190                requested_fleet,
191                &retained_plan,
192                platform,
193                &state,
194            )?;
195            let journal = FleetEnsureJournalRecord {
196                completion: FleetEnsureCompletion::InProgress,
197                effects: Vec::new(),
198                fleet: requested_fleet.to_string(),
199                initial_controlled_cycles: retained_plan.conservation.observed_controlled_cycles,
200                initial_operator_cycles: observation.operator_cycles,
201                operation_id: retained_plan.operation_id.clone(),
202                plan_sha256: retained_plan.plan_sha256.clone(),
203                schema_version: FLEET_ENSURE_SCHEMA_VERSION,
204                stalled_observations: 0,
205            };
206            write_journal(&paths, &journal)?;
207            journal
208        }
209    };
210
211    let actions = ordered_actions(&retained_plan);
212    for (index, action) in actions.iter().enumerate() {
213        let action_hash = action_sha256(action);
214        if journal.effects.len() <= index {
215            let pre_cycles = platform
216                .action_cycles(action, &state)
217                .map_err(EnsureWorkflowError::Platform)?;
218            let destination_pre_cycles = platform
219                .action_destination_cycles(action, &state)
220                .map_err(EnsureWorkflowError::Platform)?;
221            journal.effects.push(EffectRecord {
222                action_sha256: action_hash.clone(),
223                created_principal: None,
224                destination_post_cycles: destination_pre_cycles,
225                destination_pre_cycles,
226                post_cycles: None,
227                pre_cycles,
228                progress_identity: None,
229                receipt: None,
230                state: EffectState::Intent,
231            });
232            write_journal(&paths, &journal)?;
233        }
234        loop {
235            let record = journal
236                .effects
237                .get(index)
238                .ok_or(EnsureWorkflowError::JournalIntegrity)?;
239            if record.action_sha256 != action_hash {
240                return Err(EnsureWorkflowError::DriftedBeforeApply);
241            }
242            if matches!(record.state, EffectState::Applied) {
243                break;
244            }
245
246            let observed = platform
247                .observe_effect(&journal.operation_id, action, record, &state)
248                .map_err(EnsureWorkflowError::Platform)?;
249            let source_cycles = platform
250                .action_cycles(action, &state)
251                .map_err(EnsureWorkflowError::Platform)?;
252            let destination_cycles = platform
253                .action_destination_cycles(action, &state)
254                .map_err(EnsureWorkflowError::Platform)?;
255            let record = journal
256                .effects
257                .get_mut(index)
258                .ok_or(EnsureWorkflowError::JournalIntegrity)?;
259            record.post_cycles = source_cycles;
260            record.destination_post_cycles = destination_cycles;
261            if observed.applied {
262                record.progress_identity = Some(observed.progress_identity);
263                record.state = EffectState::Applied;
264                journal.stalled_observations = 0;
265                write_journal(&paths, &journal)?;
266                break;
267            }
268
269            if matches!(record.state, EffectState::Intent) {
270                let outcome = match platform.apply(&journal.operation_id, action, record, &state) {
271                    Ok(outcome) => outcome,
272                    Err(source) => {
273                        journal.stalled_observations =
274                            journal.stalled_observations.saturating_add(1);
275                        write_journal(&paths, &journal)?;
276                        if journal.stalled_observations >= desired.maximum_stalled_observations {
277                            return Err(EnsureWorkflowError::Stalled {
278                                observations: journal.stalled_observations,
279                            });
280                        }
281                        return Err(EnsureWorkflowError::Platform(source));
282                    }
283                };
284                if let Some(created) = &outcome.created_principal {
285                    state
286                        .pending_principals
287                        .insert(action.name().to_string(), created.clone());
288                    write_state(&paths, &state)?;
289                }
290                let record = journal
291                    .effects
292                    .get_mut(index)
293                    .ok_or(EnsureWorkflowError::JournalIntegrity)?;
294                record.created_principal = outcome.created_principal;
295                record.receipt = outcome.receipt;
296                record.post_cycles = outcome.post_cycles;
297                record.progress_identity = Some(observed.progress_identity);
298                record.state = EffectState::Issued;
299                journal.stalled_observations = 0;
300                write_journal(&paths, &journal)?;
301                continue;
302            }
303
304            if record.progress_identity.as_deref() == Some(&observed.progress_identity) {
305                journal.stalled_observations = journal.stalled_observations.saturating_add(1);
306            } else {
307                record.progress_identity = Some(observed.progress_identity);
308                journal.stalled_observations = 0;
309            }
310            write_journal(&paths, &journal)?;
311            if journal.stalled_observations >= desired.maximum_stalled_observations {
312                return Err(EnsureWorkflowError::Stalled {
313                    observations: journal.stalled_observations,
314                });
315            }
316        }
317    }
318
319    let mut terminal_state = state.clone();
320    publish_terminal_state(desired, &retained_plan, &mut terminal_state);
321    project_current_fleet_inventory(&terminal_state)?;
322    let terminal_observation = platform
323        .observe(&retained_plan.operation_id, &terminal_state)
324        .map_err(EnsureWorkflowError::Platform)?;
325    let artifacts = resolve_desired_artifacts(root, desired)?;
326    let protocol_actions = platform
327        .protocol_actions(&retained_plan.operation_id, &terminal_state)
328        .map_err(EnsureWorkflowError::Platform)?;
329    let converged = compile_plan(
330        desired,
331        &artifacts,
332        &protocol_actions,
333        desired_sha256,
334        requested_fleet,
335        &terminal_observation,
336        retained_plan.planned_at_time,
337    )?;
338    if converged
339        .canisters
340        .iter()
341        .any(|canister| !canister.actions.is_empty())
342        || !converged.protocol_actions.is_empty()
343    {
344        journal.completion = FleetEnsureCompletion::ReplanRequired;
345        journal.stalled_observations = 0;
346        write_journal(&paths, &journal)?;
347        return Err(EnsureWorkflowError::ConvergenceDrift);
348    }
349    let terminal_inventory = platform
350        .terminal_inventory(&retained_plan.operation_id, &terminal_state)
351        .map_err(EnsureWorkflowError::Platform)?;
352    let terminal_cycles = terminal_inventory.controlled_cycles_by_principal.clone();
353    merge_terminal_inventory(&mut terminal_state, terminal_inventory)?;
354    project_current_fleet_inventory(&terminal_state)?;
355    let mut final_observation = platform
356        .observe(&retained_plan.operation_id, &terminal_state)
357        .map_err(EnsureWorkflowError::Platform)?;
358    attach_terminal_cycles(&mut final_observation, terminal_cycles)?;
359    let actual_conservation =
360        verify_terminal_conservation(&retained_plan, &journal, &final_observation)?;
361    write_state(&paths, &terminal_state)?;
362    journal.completion = FleetEnsureCompletion::Converged;
363    journal.stalled_observations = 0;
364    write_journal(&paths, &journal)?;
365    Ok(FleetEnsureReport {
366        actual_conservation: Some(actual_conservation),
367        effects_applied: applied_count(&journal),
368        plan: retained_plan,
369        terminal: true,
370    })
371}
372
373fn verify_fresh_plan<P>(
374    root: &Path,
375    desired: &crate::fleet_ensure::model::DesiredFleet,
376    desired_sha256: &str,
377    requested_fleet: &str,
378    retained_plan: &FleetEnsurePlan,
379    platform: &mut P,
380    state: &FleetEnsureStateRecord,
381) -> Result<FleetObservation, EnsureWorkflowError<P::Error>>
382where
383    P: EnsurePlatform,
384{
385    let observation = platform
386        .observe(&retained_plan.operation_id, state)
387        .map_err(EnsureWorkflowError::Platform)?;
388    if observation.operator_cycles < retained_plan.conservation.maximum_operator_debit_cycles {
389        return Err(EnsureWorkflowError::InsufficientOperatorCycles {
390            actual: observation.operator_cycles,
391            required: retained_plan.conservation.maximum_operator_debit_cycles,
392        });
393    }
394    let artifacts = resolve_desired_artifacts(root, desired)?;
395    let protocol_actions = platform
396        .protocol_actions(&retained_plan.operation_id, state)
397        .map_err(EnsureWorkflowError::Platform)?;
398    let current = compile_plan(
399        desired,
400        &artifacts,
401        &protocol_actions,
402        desired_sha256,
403        requested_fleet,
404        &observation,
405        retained_plan.planned_at_time,
406    )?;
407    if current.plan_sha256 != retained_plan.plan_sha256
408        && !compatible_after_bounded_observation(retained_plan, &current, desired, &observation)
409    {
410        return Err(EnsureWorkflowError::DriftedBeforeApply);
411    }
412    Ok(observation)
413}
414
415fn compatible_after_bounded_observation(
416    retained: &FleetEnsurePlan,
417    current: &FleetEnsurePlan,
418    desired: &crate::fleet_ensure::model::DesiredFleet,
419    observation: &FleetObservation,
420) -> bool {
421    let Ok(maximum_observation_burn) = desired.maximum_observation_burn_cycles.parse::<u128>()
422    else {
423        return false;
424    };
425    if retained.canisters.len() != current.canisters.len() {
426        return false;
427    }
428    for (retained_canister, current_canister) in retained.canisters.iter().zip(&current.canisters) {
429        let Some(decrease) = retained_canister
430            .observed_cycles
431            .checked_sub(current_canister.observed_cycles)
432        else {
433            return false;
434        };
435        if decrease > maximum_observation_burn {
436            return false;
437        }
438    }
439    if !retained_funding_remains_sufficient(retained, desired, observation) {
440        return false;
441    }
442    normalized_plan(retained) == normalized_plan(current)
443}
444
445fn retained_funding_remains_sufficient(
446    retained: &FleetEnsurePlan,
447    desired: &crate::fleet_ensure::model::DesiredFleet,
448    observation: &FleetObservation,
449) -> bool {
450    retained
451        .canisters
452        .iter()
453        .flat_map(|canister| &canister.actions)
454        .filter_map(|action| match action {
455            EnsureAction::Fund { amount, name, .. } => Some((amount, name)),
456            _ => None,
457        })
458        .all(|(amount, name)| {
459            let Some(live) = observation.canisters.get(name).and_then(Option::as_ref) else {
460                return false;
461            };
462            let Some(configured) = desired
463                .canisters
464                .iter()
465                .find(|configured| configured.name == *name)
466            else {
467                return false;
468            };
469            let Ok(minimum) = configured.minimum_cycles.parse::<u128>() else {
470                return false;
471            };
472            live.cycles
473                .checked_add(*amount)
474                .is_some_and(|funded| funded >= minimum)
475        })
476}
477
478fn normalized_plan(plan: &FleetEnsurePlan) -> FleetEnsurePlan {
479    let mut normalized = plan.clone();
480    normalized.plan_sha256.clear();
481    normalized.conservation = CycleConservation {
482        expected_post_operation_cycles: 0,
483        maximum_execution_burn_cycles: 0,
484        maximum_new_funding_cycles: 0,
485        maximum_operator_debit_cycles: 0,
486        maximum_unavoidable_fee_cycles: 0,
487        observed_controlled_cycles: 0,
488        retained_in_reused_canisters_cycles: 0,
489        scheduled_transfer_cycles: 0,
490    };
491    for canister in &mut normalized.canisters {
492        canister.observed_cycles = 0;
493        for action in &mut canister.actions {
494            if let EnsureAction::Fund { amount, .. } = action {
495                *amount = 0;
496            }
497        }
498    }
499    normalized
500}
501
502fn attach_terminal_cycles<E>(
503    observation: &mut FleetObservation,
504    additional: BTreeMap<String, u128>,
505) -> Result<(), EnsureWorkflowError<E>>
506where
507    E: std::error::Error + 'static,
508{
509    let mut retained = BTreeMap::new();
510    for (principal, cycles) in additional {
511        let Some(configured) = observation
512            .canisters
513            .values_mut()
514            .filter_map(Option::as_mut)
515            .find(|canister| canister.principal == principal)
516        else {
517            retained.insert(principal, cycles);
518            continue;
519        };
520        if configured.root_owned_lifecycle
521            != Some(crate::fleet_ensure::model::RootOwnedCanisterLifecycle::Workload)
522        {
523            return Err(EnsureWorkflowError::TerminalInventory(
524                "terminal cycle observation duplicates a configured canister outside its exact Root-owned workload lifecycle"
525                    .to_string(),
526            ));
527        }
528        // Both protected observations identify the same Root-owned workload, but execution
529        // may burn cycles between them. Retaining the lower balance is conservative in both
530        // planning and terminal conservation regardless of observation order.
531        configured.cycles = configured.cycles.min(cycles);
532    }
533    observation.additional_controlled_cycles = retained;
534    Ok(())
535}
536
537fn verify_terminal_conservation<E>(
538    plan: &FleetEnsurePlan,
539    journal: &FleetEnsureJournalRecord,
540    terminal: &FleetObservation,
541) -> Result<ActualCycleConservation, EnsureWorkflowError<E>>
542where
543    E: std::error::Error + 'static,
544{
545    let final_controlled_cycles = controlled_cycles(terminal)?;
546    let operator_debit_cycles = journal
547        .initial_operator_cycles
548        .checked_sub(terminal.operator_cycles)
549        .ok_or_else(|| {
550            EnsureWorkflowError::Conservation(
551                "operator balance increased during apply; review a new plan".to_string(),
552            )
553        })?;
554    if operator_debit_cycles > plan.conservation.maximum_operator_debit_cycles {
555        return Err(EnsureWorkflowError::Conservation(format!(
556            "operator debit {operator_debit_cycles} exceeded reviewed maximum {}",
557            plan.conservation.maximum_operator_debit_cycles
558        )));
559    }
560    let received_new_funding_cycles = operator_debit_cycles
561        .checked_sub(plan.conservation.maximum_unavoidable_fee_cycles)
562        .ok_or_else(|| {
563            EnsureWorkflowError::Conservation(
564                "operator debit was below the exact reviewed fee total".to_string(),
565            )
566        })?;
567    if received_new_funding_cycles > plan.conservation.maximum_new_funding_cycles {
568        return Err(EnsureWorkflowError::Conservation(format!(
569            "received funding {received_new_funding_cycles} exceeded reviewed maximum {}",
570            plan.conservation.maximum_new_funding_cycles
571        )));
572    }
573    let available = journal
574        .initial_controlled_cycles
575        .checked_add(received_new_funding_cycles)
576        .ok_or_else(|| {
577            EnsureWorkflowError::Conservation(
578                "terminal controlled-cycle arithmetic overflowed".to_string(),
579            )
580        })?;
581    let measured_execution_burn_cycles = available
582        .checked_sub(final_controlled_cycles)
583        .ok_or_else(|| {
584            EnsureWorkflowError::Conservation(
585                "terminal estate contains more cycles than starting estate plus received funding"
586                    .to_string(),
587            )
588        })?;
589    if measured_execution_burn_cycles > plan.conservation.maximum_execution_burn_cycles {
590        return Err(EnsureWorkflowError::Conservation(format!(
591            "measured execution burn {measured_execution_burn_cycles} exceeded reviewed maximum {}",
592            plan.conservation.maximum_execution_burn_cycles
593        )));
594    }
595    Ok(ActualCycleConservation {
596        exact_unavoidable_fee_cycles: plan.conservation.maximum_unavoidable_fee_cycles,
597        final_controlled_cycles,
598        measured_execution_burn_cycles,
599        observed_starting_cycles: journal.initial_controlled_cycles,
600        operator_debit_cycles,
601        received_new_funding_cycles,
602    })
603}
604
605fn controlled_cycles<E>(observation: &FleetObservation) -> Result<u128, EnsureWorkflowError<E>>
606where
607    E: std::error::Error + 'static,
608{
609    observation
610        .canisters
611        .values()
612        .filter_map(Option::as_ref)
613        .try_fold(0_u128, |total, live| total.checked_add(live.cycles))
614        .and_then(|total| {
615            observation
616                .additional_controlled_cycles
617                .values()
618                .try_fold(total, |subtotal, cycles| subtotal.checked_add(*cycles))
619        })
620        .ok_or_else(|| {
621            EnsureWorkflowError::Conservation("controlled-cycle total overflowed".to_string())
622        })
623}
624
625fn verified_plan<E>(plan: FleetEnsurePlan) -> Result<FleetEnsurePlan, EnsureWorkflowError<E>>
626where
627    E: std::error::Error + 'static,
628{
629    if expected_plan_sha256(&plan) != plan.plan_sha256 {
630        return Err(EnsureWorkflowError::PlanIntegrity);
631    }
632    Ok(plan)
633}
634
635fn verify_journal<E>(
636    journal: &FleetEnsureJournalRecord,
637    plan: &FleetEnsurePlan,
638    requested_fleet: &str,
639) -> Result<(), EnsureWorkflowError<E>>
640where
641    E: std::error::Error + 'static,
642{
643    if journal.fleet != requested_fleet
644        || journal.operation_id != plan.operation_id
645        || journal.plan_sha256 != plan.plan_sha256
646    {
647        return Err(EnsureWorkflowError::JournalIntegrity);
648    }
649    Ok(())
650}
651
652fn publish_terminal_state(
653    desired: &crate::fleet_ensure::model::DesiredFleet,
654    plan: &FleetEnsurePlan,
655    state: &mut FleetEnsureStateRecord,
656) {
657    for canister in &plan.canisters {
658        match canister.disposition {
659            CanisterDisposition::Create | CanisterDisposition::Replace => {
660                if let Some(principal) = state.pending_principals.remove(&canister.name) {
661                    state.principals.insert(canister.name.clone(), principal);
662                }
663            }
664            CanisterDisposition::Reinstall | CanisterDisposition::Reuse => {
665                if let Some(principal) = state
666                    .pending_principals
667                    .remove(&canister.name)
668                    .or_else(|| canister.principal.clone())
669                {
670                    state.principals.insert(canister.name.clone(), principal);
671                }
672            }
673            CanisterDisposition::Delete => {
674                state.principals.remove(&canister.name);
675                state.pending_principals.remove(&canister.name);
676            }
677        }
678    }
679    state.topology = desired
680        .canisters
681        .iter()
682        .filter(|canister| {
683            canister.presence == crate::fleet_ensure::model::DesiredPresence::Present
684        })
685        .map(|canister| {
686            (
687                canister.name.clone(),
688                crate::fleet_ensure::model::FleetEnsureTopologyRecord {
689                    kind: canister.kind,
690                    module_hash: None,
691                    parent: canister.parent.clone(),
692                    protocol_binding: canister.protocol_binding.clone(),
693                    role: canister
694                        .protocol_binding
695                        .as_ref()
696                        .map(|binding| binding.role.to_string()),
697                },
698            )
699        })
700        .collect();
701}
702
703fn merge_terminal_inventory<E>(
704    state: &mut FleetEnsureStateRecord,
705    inventory: crate::fleet_ensure::ops::TerminalFleetInventory,
706) -> Result<(), EnsureWorkflowError<E>>
707where
708    E: std::error::Error + 'static,
709{
710    if inventory.entries.is_empty() {
711        state.active_registry = inventory.active_registry;
712        return Ok(());
713    }
714    let entries = inventory.entries;
715    let mut names_by_principal = state
716        .principals
717        .iter()
718        .map(|(name, principal)| (principal.clone(), name.clone()))
719        .collect::<BTreeMap<_, _>>();
720    if names_by_principal.len() != state.principals.len() {
721        return Err(EnsureWorkflowError::TerminalInventory(
722            "one Principal is retained under more than one desired role".to_string(),
723        ));
724    }
725    for entry in &entries {
726        names_by_principal
727            .entry(entry.pid.clone())
728            .or_insert_with(|| format!("observed:{}", entry.pid));
729    }
730    for entry in entries {
731        let name = names_by_principal.get(&entry.pid).cloned().ok_or_else(|| {
732            EnsureWorkflowError::TerminalInventory(
733                "verified entry lost its Principal binding".to_string(),
734            )
735        })?;
736        let parent = entry
737            .parent_pid
738            .as_ref()
739            .map(|principal| {
740                names_by_principal.get(principal).cloned().ok_or_else(|| {
741                    EnsureWorkflowError::TerminalInventory(format!(
742                        "Canister {} names unknown parent {principal}",
743                        entry.pid
744                    ))
745                })
746            })
747            .transpose()?;
748        let existing = state.topology.get(&name);
749        if existing
750            .and_then(|topology| topology.parent.as_ref())
751            .zip(parent.as_ref())
752            .is_some_and(|(expected, actual)| expected != actual)
753        {
754            return Err(EnsureWorkflowError::TerminalInventory(format!(
755                "Canister {} conflicts with retained parent authority",
756                entry.pid
757            )));
758        }
759        let kind = existing.map_or_else(
760            || {
761                if entry.module_hash.is_some() {
762                    crate::fleet_ensure::model::DesiredCanisterKind::Component
763                } else {
764                    crate::fleet_ensure::model::DesiredCanisterKind::Auxiliary
765                }
766            },
767            |topology| {
768                if topology.kind == crate::fleet_ensure::model::DesiredCanisterKind::Pool
769                    && entry.module_hash.is_some()
770                    && entry.protocol_binding.is_some()
771                {
772                    crate::fleet_ensure::model::DesiredCanisterKind::Component
773                } else {
774                    topology.kind
775                }
776            },
777        );
778        state.principals.insert(name.clone(), entry.pid);
779        state.topology.insert(
780            name,
781            crate::fleet_ensure::model::FleetEnsureTopologyRecord {
782                kind,
783                module_hash: entry.module_hash,
784                parent,
785                protocol_binding: entry.protocol_binding,
786                role: entry.role,
787            },
788        );
789    }
790    state.active_registry = inventory.active_registry;
791    Ok(())
792}
793
794fn applied_count(journal: &FleetEnsureJournalRecord) -> u32 {
795    u32::try_from(
796        journal
797            .effects
798            .iter()
799            .filter(|effect| matches!(effect.state, EffectState::Applied))
800            .count(),
801    )
802    .unwrap_or(u32::MAX)
803}
804
805fn ordered_actions(plan: &FleetEnsurePlan) -> Vec<&EnsureAction> {
806    let mut actions = plan
807        .canisters
808        .iter()
809        .flat_map(|canister| canister.actions.iter())
810        .chain(plan.protocol_actions.iter())
811        .collect::<Vec<_>>();
812    actions.sort_by_key(|action| match action {
813        EnsureAction::Create { .. } => 0,
814        EnsureAction::Fund { .. } => 1,
815        EnsureAction::Install {
816            canic_init: Some(crate::fleet_ensure::model::DesiredCanisterInit::Store { .. }),
817            ..
818        } => 4,
819        EnsureAction::Install { .. } => 2,
820        EnsureAction::FleetProtocol { action, .. }
821            if matches!(
822                action.as_ref(),
823                crate::fleet_ensure::model::CurrentFleetProtocolAction::AdoptStore { .. }
824            ) =>
825        {
826            3
827        }
828        EnsureAction::SetControllers { .. } | EnsureAction::Start { .. } => 5,
829        EnsureAction::FleetProtocol { .. } | EnsureAction::Protocol { .. } => 6,
830        EnsureAction::Transfer { .. } => 7,
831        EnsureAction::Stop { .. } => 8,
832        EnsureAction::Delete { .. } => 9,
833    });
834    actions
835}