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        EffectRetry, EnsurePaths, EnsurePlatform, EnsureStateError, action_sha256,
16        compact_inline_plan, lock_operation, read_journal, read_plan, read_state,
17        resolve_desired_artifacts, write_journal, write_plan, write_state,
18    },
19    policy::{
20        EnsurePolicyError, compile_plan, expected_plan_sha256, operation_id,
21        validate_path_identity, validate_path_labels,
22    },
23};
24use canic_core::cdk::types::Cycles;
25use std::{
26    collections::{BTreeMap, BTreeSet},
27    path::Path,
28};
29use thiserror::Error as ThisError;
30
31/// Fleet ensure planning or convergence failure.
32
33#[derive(Debug, ThisError)]
34pub enum EnsureWorkflowError<E>
35where
36    E: std::error::Error + 'static,
37{
38    #[error("reviewed Fleet plan digest changed before its first effect")]
39    DriftedBeforeApply,
40
41    #[error(
42        "in-progress Fleet plan requires desired input {expected}, but the retained plan predates desired-input retention and the supplied input is {actual}; supply the exact reviewed desired document"
43    )]
44    RetainedDesiredUnavailable { actual: String, expected: String },
45
46    #[error("reviewed Fleet plan {expected} does not match retained plan {actual}")]
47    PlanDigestMismatch { actual: String, expected: String },
48
49    #[error("reviewed Fleet plan is missing; run `canic fleet ensure <fleet>` first")]
50    PlanMissing,
51
52    #[error("retained Fleet plan failed its content digest")]
53    PlanIntegrity,
54
55    #[error("retained Fleet journal does not match the reviewed Fleet operation")]
56    JournalIntegrity,
57
58    #[error(
59        "Fleet ensure made no progress for {observations} consecutive observations; operation remains resumable"
60    )]
61    Stalled { observations: u32 },
62
63    #[error(
64        "selected Cycles Ledger account has {actual} cycles, below reviewed maximum debit {required}"
65    )]
66    InsufficientOperatorCycles { actual: u128, required: u128 },
67
68    #[error("terminal Fleet cycle conservation failed: {0}")]
69    Conservation(String),
70
71    #[error("terminal Fleet inventory failed exact current-authority validation: {0}")]
72    TerminalInventory(String),
73
74    #[error(
75        "reviewed effects completed, but live Fleet state still differs from desired state; review a new plan"
76    )]
77    ConvergenceDrift,
78
79    #[error(transparent)]
80    Policy(#[from] EnsurePolicyError),
81
82    #[error(transparent)]
83    Inventory(#[from] crate::fleet_ensure::CurrentFleetInventoryError),
84
85    #[error("Fleet platform operation failed: {0}")]
86    Platform(#[source] E),
87
88    #[error(transparent)]
89    State(#[from] EnsureStateError),
90}
91
92/// Load and verify the sole immutable in-progress operation for one exact Fleet path.
93pub fn retained_in_progress_plan<E>(
94    root: &Path,
95    environment: &str,
96    requested_fleet: &str,
97) -> Result<Option<FleetEnsurePlan>, EnsureWorkflowError<E>>
98where
99    E: std::error::Error + 'static,
100{
101    validate_path_labels(environment, requested_fleet)?;
102    let paths = EnsurePaths::under(root, environment, requested_fleet);
103    let _lock = lock_operation(&paths)?;
104    let Some(journal) = read_journal(&paths)? else {
105        return Ok(None);
106    };
107    if journal.completion != FleetEnsureCompletion::InProgress {
108        return Ok(None);
109    }
110    let retained = verified_plan(read_plan(&paths)?.ok_or(EnsureWorkflowError::PlanMissing)?)?;
111    if retained.environment != environment || retained.fleet != requested_fleet {
112        return Err(EnsureWorkflowError::PlanIntegrity);
113    }
114    verify_journal(&journal, &retained, requested_fleet)?;
115    Ok(Some(retained))
116}
117
118/// Build and retain one read-only plan from current desired state plus live observation.
119pub fn plan<P>(
120    root: &Path,
121    desired: &crate::fleet_ensure::model::DesiredFleet,
122    desired_sha256: &str,
123    requested_fleet: &str,
124    created_at_time: u64,
125    platform: &mut P,
126) -> Result<FleetEnsureReport, EnsureWorkflowError<P::Error>>
127where
128    P: EnsurePlatform,
129{
130    validate_path_identity(desired, requested_fleet)?;
131    let paths = EnsurePaths::under(root, &desired.environment, requested_fleet);
132    let _lock = lock_operation(&paths)?;
133    if let Some(journal) = read_journal(&paths)?
134        && journal.completion == FleetEnsureCompletion::InProgress
135    {
136        let retained = verified_plan(read_plan(&paths)?.ok_or(EnsureWorkflowError::PlanMissing)?)?;
137        verify_journal(&journal, &retained, requested_fleet)?;
138        return Ok(FleetEnsureReport {
139            actual_conservation: None,
140            effects_applied: applied_count(&journal),
141            plan: retained,
142            terminal: false,
143        });
144    }
145    let mut state = read_state(&paths, requested_fleet)?;
146    let prior_plan = read_plan(&paths)?.map(verified_plan).transpose()?;
147    if let Some(prior) = prior_plan.as_ref().filter(|prior| {
148        prior.fleet == requested_fleet
149            && prior.environment == desired.environment
150            && prior.desired_sha256 == desired_sha256
151    }) {
152        retain_plan_cycles(&mut state, prior);
153        if let Some(journal) = read_journal(&paths)? {
154            retain_completed_reinstalls(&mut state, prior, &journal);
155        }
156    }
157    let operation_id = operation_id(desired_sha256, &desired.environment, requested_fleet);
158    let mut observation = platform
159        .observe(&operation_id, &state)
160        .map_err(EnsureWorkflowError::Platform)?;
161    retain_observed_cycles(&mut state, &observation);
162    write_state(&paths, &state)?;
163    if state.active_registry.is_some() {
164        let prior = verified_plan(read_plan(&paths)?.ok_or(EnsureWorkflowError::PlanMissing)?)?;
165        attach_terminal_inventory_cycles(&prior, &state, platform, &mut observation)?;
166    }
167    let protocol_actions = platform
168        .protocol_actions(&operation_id, &state)
169        .map_err(EnsureWorkflowError::Platform)?;
170    let artifacts = resolve_desired_artifacts(root, desired)?;
171    let plan = compile_plan(
172        desired,
173        &artifacts,
174        &protocol_actions,
175        desired_sha256,
176        requested_fleet,
177        &observation,
178        created_at_time,
179    )?;
180    write_plan(&paths, &plan)?;
181    Ok(FleetEnsureReport {
182        actual_conservation: None,
183        effects_applied: 0,
184        plan,
185        terminal: false,
186    })
187}
188
189fn retain_completed_reinstalls(
190    state: &mut FleetEnsureStateRecord,
191    plan: &FleetEnsurePlan,
192    journal: &FleetEnsureJournalRecord,
193) {
194    for (action, effect) in ordered_actions(plan).into_iter().zip(&journal.effects) {
195        let EnsureAction::Install {
196            mode: crate::fleet_ensure::model::InstallMode::Reinstall,
197            name,
198            ..
199        } = action
200        else {
201            continue;
202        };
203        if effect.state == EffectState::Applied
204            && let Some(pre_canister_version) = effect.pre_canister_version
205        {
206            state
207                .completed_reinstalls
208                .insert(name.clone(), pre_canister_version);
209        }
210    }
211}
212
213fn retain_plan_cycles(state: &mut FleetEnsureStateRecord, plan: &FleetEnsurePlan) {
214    for canister in &plan.canisters {
215        if let Some(principal) = &canister.principal {
216            state
217                .retained_cycles_by_principal
218                .entry(principal.clone())
219                .or_insert(canister.observed_cycles);
220        }
221    }
222}
223
224fn retain_observed_cycles(state: &mut FleetEnsureStateRecord, observation: &FleetObservation) {
225    for live in observation.canisters.values().filter_map(Option::as_ref) {
226        state
227            .retained_cycles_by_principal
228            .insert(live.principal.clone(), live.cycles);
229    }
230    state
231        .retained_cycles_by_principal
232        .extend(observation.additional_controlled_cycles.clone());
233}
234
235/// Apply or resume exactly one reviewed plan until it converges or returns a typed blocker.
236#[expect(
237    clippy::too_many_lines,
238    reason = "the sole effect driver keeps intent, reconciliation and terminal publication visibly ordered"
239)]
240pub fn apply<P>(
241    root: &Path,
242    desired: &crate::fleet_ensure::model::DesiredFleet,
243    desired_sha256: &str,
244    requested_fleet: &str,
245    reviewed_plan_sha256: &str,
246    platform: &mut P,
247) -> Result<FleetEnsureReport, EnsureWorkflowError<P::Error>>
248where
249    P: EnsurePlatform,
250{
251    validate_path_identity(desired, requested_fleet)?;
252    let paths = EnsurePaths::under(root, &desired.environment, requested_fleet);
253    let _lock = lock_operation(&paths)?;
254    let retained_plan = verified_plan(read_plan(&paths)?.ok_or(EnsureWorkflowError::PlanMissing)?)?;
255    if retained_plan.plan_sha256 != reviewed_plan_sha256 {
256        return Err(EnsureWorkflowError::PlanDigestMismatch {
257            actual: retained_plan.plan_sha256,
258            expected: reviewed_plan_sha256.to_string(),
259        });
260    }
261    let mut state = read_state(&paths, requested_fleet)?;
262    let retained_journal = read_journal(&paths)?;
263    let in_progress = retained_journal
264        .as_ref()
265        .is_some_and(|journal| journal.completion == FleetEnsureCompletion::InProgress);
266    if let Some(journal) = retained_journal.as_ref().filter(|_| in_progress) {
267        verify_journal(journal, &retained_plan, requested_fleet)?;
268        compact_inline_plan(&paths, &retained_plan)?;
269    }
270    let mut issued_observation_resume = false;
271    let operation_desired = if in_progress {
272        if let Some(reviewed) = retained_plan.reviewed_desired.as_deref() {
273            let reviewed = reviewed.desired();
274            validate_path_identity(reviewed, requested_fleet)?;
275            if reviewed.environment != retained_plan.environment {
276                return Err(EnsureWorkflowError::PlanIntegrity);
277            }
278            reviewed
279        } else if retained_plan.desired_sha256 == desired_sha256 {
280            desired
281        } else if retained_journal
282            .as_ref()
283            .is_some_and(|journal| safe_issued_observation_resume(&retained_plan, journal, desired))
284        {
285            issued_observation_resume = true;
286            desired
287        } else {
288            return Err(EnsureWorkflowError::RetainedDesiredUnavailable {
289                actual: desired_sha256.to_string(),
290                expected: retained_plan.desired_sha256.clone(),
291            });
292        }
293    } else {
294        if retained_plan.desired_sha256 != desired_sha256 {
295            return Err(EnsureWorkflowError::DriftedBeforeApply);
296        }
297        desired
298    };
299    platform
300        .bind_reviewed_desired(operation_desired)
301        .map_err(EnsureWorkflowError::Platform)?;
302    let operation_desired_sha256 = retained_plan.desired_sha256.as_str();
303    let mut journal = match retained_journal {
304        Some(journal) if journal.completion == FleetEnsureCompletion::InProgress => {
305            verify_journal(&journal, &retained_plan, requested_fleet)?;
306            journal
307        }
308        _ => {
309            let (observation, initial_controlled_cycles) = verify_fresh_plan(
310                root,
311                operation_desired,
312                operation_desired_sha256,
313                requested_fleet,
314                &retained_plan,
315                platform,
316                &state,
317            )?;
318            let journal = FleetEnsureJournalRecord {
319                completion: FleetEnsureCompletion::InProgress,
320                effects: Vec::new(),
321                fleet: requested_fleet.to_string(),
322                initial_controlled_cycles,
323                initial_operator_cycles: observation.operator_cycles,
324                operation_id: retained_plan.operation_id.clone(),
325                plan_sha256: retained_plan.plan_sha256.clone(),
326                schema_version: FLEET_ENSURE_SCHEMA_VERSION,
327                stalled_observations: 0,
328            };
329            write_journal(&paths, &journal)?;
330            journal
331        }
332    };
333
334    let actions = ordered_actions(&retained_plan);
335    let mut replayed_issued_commands = BTreeSet::new();
336    for (index, action) in actions.iter().enumerate() {
337        let action_hash = action_sha256(action);
338        if journal.effects.len() <= index {
339            let pre_cycles = platform
340                .action_cycles(action, &state)
341                .map_err(EnsureWorkflowError::Platform)?;
342            let destination_pre_cycles = platform
343                .action_destination_cycles(action, &state)
344                .map_err(EnsureWorkflowError::Platform)?;
345            let pre_canister_version = platform
346                .action_canister_version(action, &state)
347                .map_err(EnsureWorkflowError::Platform)?;
348            journal.effects.push(EffectRecord {
349                action_sha256: action_hash.clone(),
350                created_principal: None,
351                destination_post_cycles: destination_pre_cycles,
352                destination_pre_cycles,
353                post_cycles: None,
354                pre_cycles,
355                pre_canister_version,
356                progress_identity: None,
357                receipt: None,
358                state: EffectState::Intent,
359            });
360            write_journal(&paths, &journal)?;
361        }
362        loop {
363            let record = journal
364                .effects
365                .get(index)
366                .ok_or(EnsureWorkflowError::JournalIntegrity)?;
367            if record.action_sha256 != action_hash {
368                return Err(EnsureWorkflowError::DriftedBeforeApply);
369            }
370            if matches!(record.state, EffectState::Applied) {
371                break;
372            }
373
374            let observed = platform
375                .observe_effect(&journal.operation_id, action, record, &state)
376                .map_err(EnsureWorkflowError::Platform)?;
377            let source_cycles = platform
378                .action_cycles(action, &state)
379                .map_err(EnsureWorkflowError::Platform)?;
380            let destination_cycles = platform
381                .action_destination_cycles(action, &state)
382                .map_err(EnsureWorkflowError::Platform)?;
383            let record = journal
384                .effects
385                .get_mut(index)
386                .ok_or(EnsureWorkflowError::JournalIntegrity)?;
387            record.post_cycles = source_cycles;
388            record.destination_post_cycles = destination_cycles;
389            if observed.applied {
390                record.progress_identity = Some(observed.progress_identity);
391                record.state = EffectState::Applied;
392                journal.stalled_observations = 0;
393                write_journal(&paths, &journal)?;
394                break;
395            }
396
397            if matches!(record.state, EffectState::Intent) {
398                let outcome = match platform.apply(&journal.operation_id, action, record, &state) {
399                    Ok(outcome) => outcome,
400                    Err(source) => {
401                        journal.stalled_observations =
402                            journal.stalled_observations.saturating_add(1);
403                        write_journal(&paths, &journal)?;
404                        if journal.stalled_observations
405                            >= operation_desired.maximum_stalled_observations
406                        {
407                            return Err(EnsureWorkflowError::Stalled {
408                                observations: journal.stalled_observations,
409                            });
410                        }
411                        return Err(EnsureWorkflowError::Platform(source));
412                    }
413                };
414                if let Some(created) = &outcome.created_principal {
415                    state
416                        .pending_principals
417                        .insert(action.name().to_string(), created.clone());
418                    write_state(&paths, &state)?;
419                }
420                let record = journal
421                    .effects
422                    .get_mut(index)
423                    .ok_or(EnsureWorkflowError::JournalIntegrity)?;
424                record.created_principal = outcome.created_principal;
425                record.receipt = outcome.receipt;
426                record.post_cycles = outcome.post_cycles;
427                record.progress_identity = Some(observed.progress_identity);
428                record.state = EffectState::Issued;
429                journal.stalled_observations = 0;
430                write_journal(&paths, &journal)?;
431                continue;
432            }
433
434            if observed.retry == EffectRetry::ReplayExactIssuedCommand
435                && !replayed_issued_commands.contains(&index)
436            {
437                if !matches!(
438                    action,
439                    EnsureAction::FleetProtocol { action, .. }
440                        if matches!(
441                            action.as_ref(),
442                            crate::fleet_ensure::model::CurrentFleetProtocolAction::ProvisionComponents { .. }
443                        )
444                ) {
445                    return Err(EnsureWorkflowError::JournalIntegrity);
446                }
447                let retained_receipt = record.receipt.clone();
448                let outcome = platform
449                    .apply(&journal.operation_id, action, record, &state)
450                    .map_err(EnsureWorkflowError::Platform)?;
451                if outcome.created_principal.is_some()
452                    || outcome.receipt != retained_receipt
453                    || outcome.post_cycles.is_some()
454                {
455                    return Err(EnsureWorkflowError::JournalIntegrity);
456                }
457                let record = journal
458                    .effects
459                    .get_mut(index)
460                    .ok_or(EnsureWorkflowError::JournalIntegrity)?;
461                record.progress_identity = Some(observed.progress_identity);
462                journal.stalled_observations = 0;
463                write_journal(&paths, &journal)?;
464                replayed_issued_commands.insert(index);
465                continue;
466            }
467
468            if record.progress_identity.as_deref() == Some(&observed.progress_identity) {
469                journal.stalled_observations = journal.stalled_observations.saturating_add(1);
470            } else {
471                record.progress_identity = Some(observed.progress_identity);
472                journal.stalled_observations = 0;
473            }
474            write_journal(&paths, &journal)?;
475            if journal.stalled_observations >= operation_desired.maximum_stalled_observations {
476                return Err(EnsureWorkflowError::Stalled {
477                    observations: journal.stalled_observations,
478                });
479            }
480        }
481    }
482
483    let mut terminal_state = state.clone();
484    publish_terminal_state(operation_desired, &retained_plan, &mut terminal_state);
485    project_current_fleet_inventory(&terminal_state)?;
486    let terminal_observation = platform
487        .observe(&retained_plan.operation_id, &terminal_state)
488        .map_err(EnsureWorkflowError::Platform)?;
489    if !issued_observation_resume {
490        let artifacts = resolve_desired_artifacts(root, operation_desired)?;
491        let protocol_actions = platform
492            .protocol_actions(&retained_plan.operation_id, &terminal_state)
493            .map_err(EnsureWorkflowError::Platform)?;
494        let converged = compile_plan(
495            operation_desired,
496            &artifacts,
497            &protocol_actions,
498            operation_desired_sha256,
499            requested_fleet,
500            &terminal_observation,
501            retained_plan.planned_at_time,
502        )?;
503        if converged
504            .canisters
505            .iter()
506            .any(|canister| !canister.actions.is_empty())
507            || !converged.protocol_actions.is_empty()
508        {
509            journal.completion = FleetEnsureCompletion::ReplanRequired;
510            journal.stalled_observations = 0;
511            write_journal(&paths, &journal)?;
512            return Err(EnsureWorkflowError::ConvergenceDrift);
513        }
514    }
515    let terminal_inventory = platform
516        .terminal_inventory(&retained_plan.operation_id, &terminal_state)
517        .map_err(EnsureWorkflowError::Platform)?;
518    let terminal_cycles = terminal_inventory.controlled_cycles_by_principal.clone();
519    merge_terminal_inventory(&mut terminal_state, terminal_inventory)?;
520    project_current_fleet_inventory(&terminal_state)?;
521    let mut final_observation = platform
522        .observe(&retained_plan.operation_id, &terminal_state)
523        .map_err(EnsureWorkflowError::Platform)?;
524    attach_terminal_cycles(&mut final_observation, terminal_cycles)?;
525    let actual_conservation =
526        verify_terminal_conservation(&retained_plan, &journal, &final_observation)?;
527    write_state(&paths, &terminal_state)?;
528    journal.completion = FleetEnsureCompletion::Converged;
529    journal.stalled_observations = 0;
530    write_journal(&paths, &journal)?;
531    Ok(FleetEnsureReport {
532        actual_conservation: Some(actual_conservation),
533        effects_applied: applied_count(&journal),
534        plan: retained_plan,
535        terminal: true,
536    })
537}
538
539fn verify_fresh_plan<P>(
540    root: &Path,
541    desired: &crate::fleet_ensure::model::DesiredFleet,
542    desired_sha256: &str,
543    requested_fleet: &str,
544    retained_plan: &FleetEnsurePlan,
545    platform: &mut P,
546    state: &FleetEnsureStateRecord,
547) -> Result<(FleetObservation, u128), EnsureWorkflowError<P::Error>>
548where
549    P: EnsurePlatform,
550{
551    let mut observation = platform
552        .observe(&retained_plan.operation_id, state)
553        .map_err(EnsureWorkflowError::Platform)?;
554    if state.active_registry.is_some() {
555        attach_terminal_inventory_cycles(retained_plan, state, platform, &mut observation)?;
556    }
557    if observation.operator_cycles < retained_plan.conservation.maximum_operator_debit_cycles {
558        return Err(EnsureWorkflowError::InsufficientOperatorCycles {
559            actual: observation.operator_cycles,
560            required: retained_plan.conservation.maximum_operator_debit_cycles,
561        });
562    }
563    let artifacts = resolve_desired_artifacts(root, desired)?;
564    let protocol_actions = platform
565        .protocol_actions(&retained_plan.operation_id, state)
566        .map_err(EnsureWorkflowError::Platform)?;
567    let current = compile_plan(
568        desired,
569        &artifacts,
570        &protocol_actions,
571        desired_sha256,
572        requested_fleet,
573        &observation,
574        retained_plan.planned_at_time,
575    )?;
576    if current.plan_sha256 != retained_plan.plan_sha256
577        && !compatible_after_bounded_observation(retained_plan, &current, desired, &observation)
578    {
579        return Err(EnsureWorkflowError::DriftedBeforeApply);
580    }
581    Ok((observation, current.conservation.observed_controlled_cycles))
582}
583
584fn attach_terminal_inventory_cycles<P>(
585    plan: &FleetEnsurePlan,
586    state: &FleetEnsureStateRecord,
587    platform: &mut P,
588    observation: &mut FleetObservation,
589) -> Result<(), EnsureWorkflowError<P::Error>>
590where
591    P: EnsurePlatform,
592{
593    let inventory = platform
594        .terminal_inventory(&plan.operation_id, state)
595        .map_err(EnsureWorkflowError::Platform)?;
596    attach_terminal_cycles(observation, inventory.controlled_cycles_by_principal)
597}
598
599fn compatible_after_bounded_observation(
600    retained: &FleetEnsurePlan,
601    current: &FleetEnsurePlan,
602    desired: &crate::fleet_ensure::model::DesiredFleet,
603    observation: &FleetObservation,
604) -> bool {
605    let Ok(maximum_observation_movement) = desired
606        .maximum_observation_burn_cycles
607        .parse::<Cycles>()
608        .map(|cycles| cycles.to_u128())
609    else {
610        return false;
611    };
612    if retained.canisters.len() != current.canisters.len() {
613        return false;
614    }
615    for (retained_canister, current_canister) in retained.canisters.iter().zip(&current.canisters) {
616        let movement = retained_canister
617            .observed_cycles
618            .abs_diff(current_canister.observed_cycles);
619        if movement > maximum_observation_movement {
620            return false;
621        }
622    }
623    if !retained_funding_remains_sufficient(retained, desired, observation) {
624        return false;
625    }
626    normalized_plan(retained) == normalized_plan(current)
627}
628
629fn retained_funding_remains_sufficient(
630    retained: &FleetEnsurePlan,
631    desired: &crate::fleet_ensure::model::DesiredFleet,
632    observation: &FleetObservation,
633) -> bool {
634    retained
635        .canisters
636        .iter()
637        .flat_map(|canister| &canister.actions)
638        .filter_map(|action| match action {
639            EnsureAction::Fund { amount, name, .. } => Some((amount, name)),
640            _ => None,
641        })
642        .all(|(amount, name)| {
643            let Some(live) = observation.canisters.get(name).and_then(Option::as_ref) else {
644                return false;
645            };
646            let Some(configured) = desired
647                .canisters
648                .iter()
649                .find(|configured| configured.name == *name)
650            else {
651                return false;
652            };
653            let Ok(minimum) = configured
654                .minimum_cycles
655                .parse::<Cycles>()
656                .map(|cycles| cycles.to_u128())
657            else {
658                return false;
659            };
660            live.cycles
661                .checked_add(*amount)
662                .is_some_and(|funded| funded >= minimum)
663        })
664}
665
666fn normalized_plan(plan: &FleetEnsurePlan) -> FleetEnsurePlan {
667    let mut normalized = plan.clone();
668    normalized.plan_sha256.clear();
669    normalized.reviewed_desired = None;
670    normalized.conservation = CycleConservation {
671        expected_post_operation_cycles: 0,
672        maximum_execution_burn_cycles: 0,
673        maximum_new_funding_cycles: 0,
674        maximum_operator_debit_cycles: 0,
675        maximum_unavoidable_fee_cycles: 0,
676        observed_controlled_cycles: 0,
677        retained_in_reused_canisters_cycles: 0,
678        scheduled_transfer_cycles: 0,
679    };
680    for canister in &mut normalized.canisters {
681        canister.observed_cycles = 0;
682        for action in &mut canister.actions {
683            if let EnsureAction::Fund {
684                amount,
685                funding_deficit_cycles,
686                ..
687            } = action
688            {
689                *amount = 0;
690                *funding_deficit_cycles = 0;
691            }
692        }
693    }
694    normalized
695}
696
697fn safe_issued_observation_resume(
698    plan: &FleetEnsurePlan,
699    journal: &FleetEnsureJournalRecord,
700    supplied: &crate::fleet_ensure::model::DesiredFleet,
701) -> bool {
702    if plan.conservation.maximum_new_funding_cycles != 0
703        || plan.conservation.maximum_operator_debit_cycles != 0
704        || plan.conservation.maximum_unavoidable_fee_cycles != 0
705        || plan.canisters.iter().any(|canister| {
706            canister.disposition != CanisterDisposition::Reuse
707                || !canister.actions.is_empty()
708                || canister.principal.is_none()
709        })
710    {
711        return false;
712    }
713    let supplied_principals = supplied
714        .canisters
715        .iter()
716        .filter(|canister| {
717            canister.presence == crate::fleet_ensure::model::DesiredPresence::Present
718                && !canister.replace
719        })
720        .filter_map(|canister| {
721            canister
722                .principal
723                .as_ref()
724                .map(|principal| (canister.name.as_str(), principal.as_str()))
725        })
726        .collect::<BTreeMap<_, _>>();
727    if supplied_principals.len() != plan.canisters.len()
728        || plan.canisters.iter().any(|canister| {
729            canister.principal.as_deref().is_none_or(|principal| {
730                supplied_principals.get(canister.name.as_str()).copied() != Some(principal)
731            })
732        })
733    {
734        return false;
735    }
736    let actions = ordered_actions(plan);
737    if actions.len() != journal.effects.len() || actions.is_empty() {
738        return false;
739    }
740    if actions
741        .iter()
742        .zip(&journal.effects)
743        .any(|(action, effect)| action_sha256(action) != effect.action_sha256)
744    {
745        return false;
746    }
747    let Some((last_action, last_effect)) = actions.last().zip(journal.effects.last()) else {
748        return false;
749    };
750    journal.effects[..journal.effects.len() - 1]
751        .iter()
752        .all(|effect| effect.state == EffectState::Applied)
753        && matches!(
754            last_effect.state,
755            EffectState::Issued | EffectState::Applied
756        )
757        && matches!(
758            last_action,
759            EnsureAction::FleetProtocol { action, .. }
760                if matches!(
761                    action.as_ref(),
762                    crate::fleet_ensure::model::CurrentFleetProtocolAction::ProvisionComponents { .. }
763                )
764        )
765}
766
767fn attach_terminal_cycles<E>(
768    observation: &mut FleetObservation,
769    additional: BTreeMap<String, u128>,
770) -> Result<(), EnsureWorkflowError<E>>
771where
772    E: std::error::Error + 'static,
773{
774    let mut retained = BTreeMap::new();
775    for (principal, cycles) in additional {
776        let Some(configured) = observation
777            .canisters
778            .values_mut()
779            .filter_map(Option::as_mut)
780            .find(|canister| canister.principal == principal)
781        else {
782            retained.insert(principal, cycles);
783            continue;
784        };
785        if configured.root_owned_lifecycle
786            != Some(crate::fleet_ensure::model::RootOwnedCanisterLifecycle::Workload)
787        {
788            return Err(EnsureWorkflowError::TerminalInventory(
789                "terminal cycle observation duplicates a configured canister outside its exact Root-owned workload lifecycle"
790                    .to_string(),
791            ));
792        }
793        // Both protected observations identify the same Root-owned workload, but execution
794        // may burn cycles between them. Retaining the lower balance is conservative in both
795        // planning and terminal conservation regardless of observation order.
796        configured.cycles = configured.cycles.min(cycles);
797    }
798    observation.additional_controlled_cycles = retained;
799    Ok(())
800}
801
802fn verify_terminal_conservation<E>(
803    plan: &FleetEnsurePlan,
804    journal: &FleetEnsureJournalRecord,
805    terminal: &FleetObservation,
806) -> Result<ActualCycleConservation, EnsureWorkflowError<E>>
807where
808    E: std::error::Error + 'static,
809{
810    let final_controlled_cycles = controlled_cycles(terminal)?;
811    let operator_debit_cycles = journal
812        .initial_operator_cycles
813        .checked_sub(terminal.operator_cycles)
814        .ok_or_else(|| {
815            EnsureWorkflowError::Conservation(
816                "operator balance increased during apply; review a new plan".to_string(),
817            )
818        })?;
819    if operator_debit_cycles > plan.conservation.maximum_operator_debit_cycles {
820        return Err(EnsureWorkflowError::Conservation(format!(
821            "operator debit {operator_debit_cycles} exceeded reviewed maximum {}",
822            plan.conservation.maximum_operator_debit_cycles
823        )));
824    }
825    let received_new_funding_cycles = operator_debit_cycles
826        .checked_sub(plan.conservation.maximum_unavoidable_fee_cycles)
827        .ok_or_else(|| {
828            EnsureWorkflowError::Conservation(
829                "operator debit was below the exact reviewed fee total".to_string(),
830            )
831        })?;
832    if received_new_funding_cycles > plan.conservation.maximum_new_funding_cycles {
833        return Err(EnsureWorkflowError::Conservation(format!(
834            "received funding {received_new_funding_cycles} exceeded reviewed maximum {}",
835            plan.conservation.maximum_new_funding_cycles
836        )));
837    }
838    let available = journal
839        .initial_controlled_cycles
840        .checked_add(received_new_funding_cycles)
841        .ok_or_else(|| {
842            EnsureWorkflowError::Conservation(
843                "terminal controlled-cycle arithmetic overflowed".to_string(),
844            )
845        })?;
846    let measured_execution_burn_cycles = available
847        .checked_sub(final_controlled_cycles)
848        .ok_or_else(|| {
849            EnsureWorkflowError::Conservation(
850                "terminal estate contains more cycles than starting estate plus received funding"
851                    .to_string(),
852            )
853        })?;
854    if measured_execution_burn_cycles > plan.conservation.maximum_execution_burn_cycles {
855        return Err(EnsureWorkflowError::Conservation(format!(
856            "measured execution burn {measured_execution_burn_cycles} exceeded reviewed maximum {}",
857            plan.conservation.maximum_execution_burn_cycles
858        )));
859    }
860    Ok(ActualCycleConservation {
861        exact_unavoidable_fee_cycles: plan.conservation.maximum_unavoidable_fee_cycles,
862        final_controlled_cycles,
863        measured_execution_burn_cycles,
864        observed_starting_cycles: journal.initial_controlled_cycles,
865        operator_debit_cycles,
866        received_new_funding_cycles,
867    })
868}
869
870fn controlled_cycles<E>(observation: &FleetObservation) -> Result<u128, EnsureWorkflowError<E>>
871where
872    E: std::error::Error + 'static,
873{
874    observation
875        .canisters
876        .values()
877        .filter_map(Option::as_ref)
878        .try_fold(0_u128, |total, live| total.checked_add(live.cycles))
879        .and_then(|total| {
880            observation
881                .additional_controlled_cycles
882                .values()
883                .try_fold(total, |subtotal, cycles| subtotal.checked_add(*cycles))
884        })
885        .ok_or_else(|| {
886            EnsureWorkflowError::Conservation("controlled-cycle total overflowed".to_string())
887        })
888}
889
890fn verified_plan<E>(plan: FleetEnsurePlan) -> Result<FleetEnsurePlan, EnsureWorkflowError<E>>
891where
892    E: std::error::Error + 'static,
893{
894    if expected_plan_sha256(&plan) != plan.plan_sha256 {
895        return Err(EnsureWorkflowError::PlanIntegrity);
896    }
897    Ok(plan)
898}
899
900fn verify_journal<E>(
901    journal: &FleetEnsureJournalRecord,
902    plan: &FleetEnsurePlan,
903    requested_fleet: &str,
904) -> Result<(), EnsureWorkflowError<E>>
905where
906    E: std::error::Error + 'static,
907{
908    if journal.fleet != requested_fleet
909        || journal.operation_id != plan.operation_id
910        || journal.plan_sha256 != plan.plan_sha256
911    {
912        return Err(EnsureWorkflowError::JournalIntegrity);
913    }
914    Ok(())
915}
916
917fn publish_terminal_state(
918    desired: &crate::fleet_ensure::model::DesiredFleet,
919    plan: &FleetEnsurePlan,
920    state: &mut FleetEnsureStateRecord,
921) {
922    for canister in &plan.canisters {
923        match canister.disposition {
924            CanisterDisposition::Create | CanisterDisposition::Replace => {
925                if let Some(principal) = state.pending_principals.remove(&canister.name) {
926                    state.principals.insert(canister.name.clone(), principal);
927                }
928            }
929            CanisterDisposition::Reinstall | CanisterDisposition::Reuse => {
930                if let Some(principal) = state
931                    .pending_principals
932                    .remove(&canister.name)
933                    .or_else(|| canister.principal.clone())
934                {
935                    state.principals.insert(canister.name.clone(), principal);
936                }
937            }
938            CanisterDisposition::Delete => {
939                state.principals.remove(&canister.name);
940                state.pending_principals.remove(&canister.name);
941            }
942        }
943    }
944    state.topology = desired
945        .canisters
946        .iter()
947        .filter(|canister| {
948            canister.presence == crate::fleet_ensure::model::DesiredPresence::Present
949        })
950        .map(|canister| {
951            (
952                canister.name.clone(),
953                crate::fleet_ensure::model::FleetEnsureTopologyRecord {
954                    kind: canister.kind,
955                    module_hash: None,
956                    parent: canister.parent.clone(),
957                    protocol_binding: canister.protocol_binding.clone(),
958                    role: canister
959                        .protocol_binding
960                        .as_ref()
961                        .map(|binding| binding.role.to_string()),
962                },
963            )
964        })
965        .collect();
966}
967
968fn merge_terminal_inventory<E>(
969    state: &mut FleetEnsureStateRecord,
970    inventory: crate::fleet_ensure::ops::TerminalFleetInventory,
971) -> Result<(), EnsureWorkflowError<E>>
972where
973    E: std::error::Error + 'static,
974{
975    if inventory.entries.is_empty() {
976        state.active_registry = inventory.active_registry;
977        return Ok(());
978    }
979    let entries = inventory.entries;
980    let mut names_by_principal = state
981        .principals
982        .iter()
983        .map(|(name, principal)| (principal.clone(), name.clone()))
984        .collect::<BTreeMap<_, _>>();
985    if names_by_principal.len() != state.principals.len() {
986        return Err(EnsureWorkflowError::TerminalInventory(
987            "one Principal is retained under more than one desired role".to_string(),
988        ));
989    }
990    for entry in &entries {
991        names_by_principal
992            .entry(entry.pid.clone())
993            .or_insert_with(|| format!("observed:{}", entry.pid));
994    }
995    for entry in entries {
996        let name = names_by_principal.get(&entry.pid).cloned().ok_or_else(|| {
997            EnsureWorkflowError::TerminalInventory(
998                "verified entry lost its Principal binding".to_string(),
999            )
1000        })?;
1001        let parent = entry
1002            .parent_pid
1003            .as_ref()
1004            .map(|principal| {
1005                names_by_principal.get(principal).cloned().ok_or_else(|| {
1006                    EnsureWorkflowError::TerminalInventory(format!(
1007                        "Canister {} names unknown parent {principal}",
1008                        entry.pid
1009                    ))
1010                })
1011            })
1012            .transpose()?;
1013        let existing = state.topology.get(&name);
1014        if existing
1015            .and_then(|topology| topology.parent.as_ref())
1016            .zip(parent.as_ref())
1017            .is_some_and(|(expected, actual)| expected != actual)
1018        {
1019            return Err(EnsureWorkflowError::TerminalInventory(format!(
1020                "Canister {} conflicts with retained parent authority",
1021                entry.pid
1022            )));
1023        }
1024        let kind = existing.map_or_else(
1025            || {
1026                if entry.module_hash.is_some() {
1027                    crate::fleet_ensure::model::DesiredCanisterKind::Component
1028                } else {
1029                    crate::fleet_ensure::model::DesiredCanisterKind::Auxiliary
1030                }
1031            },
1032            |topology| {
1033                if topology.kind == crate::fleet_ensure::model::DesiredCanisterKind::Pool
1034                    && entry.module_hash.is_some()
1035                    && entry.protocol_binding.is_some()
1036                {
1037                    crate::fleet_ensure::model::DesiredCanisterKind::Component
1038                } else {
1039                    topology.kind
1040                }
1041            },
1042        );
1043        state.principals.insert(name.clone(), entry.pid);
1044        state.topology.insert(
1045            name,
1046            crate::fleet_ensure::model::FleetEnsureTopologyRecord {
1047                kind,
1048                module_hash: entry.module_hash,
1049                parent,
1050                protocol_binding: entry.protocol_binding,
1051                role: entry.role,
1052            },
1053        );
1054    }
1055    state.active_registry = inventory.active_registry;
1056    Ok(())
1057}
1058
1059fn applied_count(journal: &FleetEnsureJournalRecord) -> u32 {
1060    u32::try_from(
1061        journal
1062            .effects
1063            .iter()
1064            .filter(|effect| matches!(effect.state, EffectState::Applied))
1065            .count(),
1066    )
1067    .unwrap_or(u32::MAX)
1068}
1069
1070pub(super) fn ordered_actions(plan: &FleetEnsurePlan) -> Vec<&EnsureAction> {
1071    let mut actions = plan
1072        .canisters
1073        .iter()
1074        .flat_map(|canister| canister.actions.iter())
1075        .chain(plan.protocol_actions.iter())
1076        .collect::<Vec<_>>();
1077    actions.sort_by_key(|action| action_order(action));
1078    actions
1079}
1080
1081pub(super) fn action_order(action: &EnsureAction) -> u8 {
1082    match action {
1083        EnsureAction::Create { .. } => 0,
1084        EnsureAction::Fund { .. } => 1,
1085        EnsureAction::Install {
1086            canic_init: Some(crate::fleet_ensure::model::DesiredCanisterInit::Coordinator),
1087            ..
1088        } => 2,
1089        EnsureAction::FleetProtocol { action, .. }
1090            if matches!(
1091                action.as_ref(),
1092                crate::fleet_ensure::model::CurrentFleetProtocolAction::AdoptStore { .. }
1093            ) =>
1094        {
1095            3
1096        }
1097        EnsureAction::Install {
1098            canic_init: Some(crate::fleet_ensure::model::DesiredCanisterInit::Store { .. }),
1099            ..
1100        } => 4,
1101        EnsureAction::Install { .. } => 5,
1102        EnsureAction::SetControllers { .. } | EnsureAction::Start { .. } => 6,
1103        EnsureAction::FleetProtocol { .. } | EnsureAction::Protocol { .. } => 7,
1104        EnsureAction::Transfer { .. } => 8,
1105        EnsureAction::Stop { .. } => 9,
1106        EnsureAction::Delete { .. } => 10,
1107    }
1108}