Skip to main content

canic_host/fleet_ensure/policy/
mod.rs

1//! Module: fleet_ensure::policy
2//!
3//! Responsibility: validate current desired state and compile one conservative convergence plan.
4//! Does not own: storage, clocks, transport, live observation, or effects.
5//! Boundary: workflow supplies exact desired/live inputs and persists the returned immutable plan.
6
7pub(super) mod recovery;
8pub(super) mod reinstall;
9pub(in crate::fleet_ensure) mod root_reinstall;
10
11use crate::{
12    component_topology::{
13        RootPoolImportCapacityError, RootPoolImportCapacityInput,
14        validate_root_pool_import_capacity,
15    },
16    fleet_ensure::model::{
17        CanisterCyclePolicy, CanisterDisposition, CanisterPlan, CanisterRuntimeStatus,
18        CurrentFleetProtocolAction, CycleConservation, DesiredCanisterKind, DesiredFleet,
19        DesiredFleetArtifacts, DesiredFleetBootstrapRoot, DesiredPresence, EnsureAction,
20        EstateFundingDomainPlan, EstatePoolAssetLifecycle, FLEET_ENSURE_SCHEMA_VERSION,
21        FleetEnsurePlan, FleetEnsurePlanScope, FleetEnsureStateRecord, FleetObservation,
22        InstallMode, LiveCanister, MAX_FLEET_ENSURE_CANISTERS, MAX_FLEET_ENSURE_PROTOCOL_STEPS,
23        PoolFundingAuthority, RetainedRootStartAuthorityRecord, RootManagementBinding,
24        RootManagementObservation, RootOwnedCanisterLifecycle,
25    },
26};
27use candid::Principal;
28use canic_core::{
29    cdk::types::Cycles,
30    control_plane_support::model::fleet_funding_policy::{
31        FleetFundingPolicyValidationError, validate_coordinator_root_funding_policy,
32        validate_fleet_root_funding_capacity, validate_fleet_subnet_root_funding_authority,
33    },
34    ids::{FleetName, FleetSubnetCanisterPoolConfig},
35};
36use sha2::{Digest, Sha256};
37use std::collections::{BTreeMap, BTreeSet};
38use thiserror::Error as ThisError;
39
40/// Pure current-contract plan compilation failure.
41
42#[derive(Debug, Eq, PartialEq, ThisError)]
43pub enum EnsurePolicyError {
44    #[error(
45        "Root {root} reset omits known controlled assets {missing_principals:?}; refresh the estate seed and matching policy imports, regenerate desired state, and review before reinstall"
46    )]
47    IncompleteRootEstate {
48        root: String,
49        missing_principals: Vec<String>,
50    },
51
52    #[error(
53        "Root {root} requires {workloads} Workloads plus {ready_floor} Ready assets, exceeding pool maximum {maximum_size}"
54    )]
55    TerminalPoolCapacity {
56        root: String,
57        workloads: u32,
58        ready_floor: u32,
59        maximum_size: u32,
60    },
61    #[error("invalid protected funding policy: {0}")]
62    FundingPolicy(#[from] FleetFundingPolicyValidationError),
63
64    #[error("cycle arithmetic overflow while compiling {field}")]
65    ArithmeticOverflow { field: &'static str },
66
67    #[error(
68        "Fleet cycle-conservation headroom is insufficient: available {available} cycles after reviewed funding, required maximum burn {required}, shortfall {shortfall}, actions {action_count}; no plan or effect was authorized"
69    )]
70    InsufficientCycleConservation {
71        action_count: usize,
72        available: u128,
73        required: u128,
74        shortfall: u128,
75    },
76
77    #[error("controlled canister {name} has duplicate name or principal authority")]
78    DuplicateAuthority { name: String },
79
80    #[error("controlled canister {name} references unavailable controller canister {controller}")]
81    ControllerCanisterUnavailable { controller: String, name: String },
82
83    #[error("desired Fleet has invalid or anonymous Principal for {field}: {value}")]
84    InvalidPrincipal { field: String, value: String },
85
86    #[error("desired Fleet contains an empty canister or drain-method name")]
87    EmptyName,
88
89    #[error("desired Fleet has unsafe {field} path label: {value}")]
90    UnsafePathLabel { field: &'static str, value: String },
91
92    #[error("controlled canister {name} has invalid {field} cycle value: {value}")]
93    InvalidCanisterCycles {
94        field: &'static str,
95        name: String,
96        value: String,
97    },
98
99    #[error("desired Fleet has invalid {field} cycle value: {value}")]
100    InvalidFleetCycles { field: &'static str, value: String },
101
102    #[error("desired Fleet estate-funding topology is invalid: {reason}")]
103    EstateFundingTopology { reason: String },
104
105    #[error(
106        "Root {root} pool cannot satisfy current desired demand: occupied {occupied_assets}/{maximum_size}, eligible Ready {eligible_ready_assets}, allocated Workloads {allocated_workloads}, pending creations {pending_creations}, required new creations {required_creation_count}, available slots {available_slots}, capacity shortfall {capacity_shortfall}; repair or retire retained pool assets before funding"
107    )]
108    EstatePoolCapacity {
109        allocated_workloads: u32,
110        available_slots: u32,
111        capacity_shortfall: u32,
112        eligible_ready_assets: u32,
113        maximum_size: u32,
114        occupied_assets: u32,
115        pending_creations: u32,
116        required_creation_count: u32,
117        root: String,
118    },
119
120    #[error("live Cycles Ledger fee is {actual}, but reviewed desired fee is {expected}")]
121    LedgerFeeDrift { actual: u128, expected: u128 },
122
123    #[error("desired Fleet has {actual} controlled canisters, above limit {maximum}")]
124    TooManyCanisters { actual: usize, maximum: usize },
125
126    #[error("desired Fleet has {actual} protocol steps, above limit {maximum}")]
127    TooManyProtocolSteps { actual: usize, maximum: usize },
128
129    #[error("desired Fleet maximum_stalled_observations must be greater than zero")]
130    InvalidStallBound,
131
132    #[error("controlled canister {name} initial cycles are below its minimum cycles")]
133    InitialCyclesBelowMinimum { name: String },
134
135    #[error(
136        "fresh pool canister {name} requests {creation_funding_cycles} creation cycles for readiness floor {readiness_floor_cycles}, but its bounded pre-import burn is {admissible_burn_cycles} cycles; required funding {required_creation_funding_cycles}, shortfall {shortfall_cycles}; no effect was authorized"
137    )]
138    FreshPoolCreationFundingInsufficient {
139        admissible_burn_cycles: u128,
140        creation_funding_cycles: u128,
141        name: String,
142        readiness_floor_cycles: u128,
143        required_creation_funding_cycles: u128,
144        shortfall_cycles: u128,
145    },
146
147    #[error(
148        "live observation for controlled canister {name} returned Principal {actual}, expected {expected}"
149    )]
150    ObservationAuthorityMismatch {
151        actual: String,
152        expected: String,
153        name: String,
154    },
155
156    #[error("desired Fleet name {actual} does not match requested Fleet {expected}")]
157    FleetMismatch { actual: String, expected: String },
158
159    #[error("controlled canister {name} is missing exact live observation authority")]
160    MissingObservation { name: String },
161
162    #[error("Fleet treasury {treasury} is not an explicitly controlled present canister")]
163    MissingTreasury { treasury: String },
164
165    #[error("Fleet treasury {treasury} must be reused and cannot be replaced")]
166    TreasuryReplacement { treasury: String },
167
168    #[error(
169        "present controlled canister {name} does not retain the configured operator as controller"
170    )]
171    MissingOperatorController { name: String },
172
173    #[error(
174        "controlled canister {name} retains {cycles} transferable cycles but has no exact treasury-bound drain authority; canister left untouched"
175    )]
176    NoSafeDrain { name: String, cycles: u128 },
177
178    #[error("controlled canister {name} names a drain destination other than Fleet treasury")]
179    WrongDrainDestination { name: String },
180
181    #[error("desired Fleet schema {actual} is unsupported; current schema is {expected}")]
182    WrongSchema { actual: u16, expected: u16 },
183
184    #[error("controlled canister {name} has no resolved current Wasm identity")]
185    MissingWasmIdentity { name: String },
186
187    #[error("controlled canister {name} has no resolved {kind} identity")]
188    MissingArtifactIdentity { kind: &'static str, name: String },
189
190    #[error("protocol step {step} targets unknown or absent controlled canister {canister}")]
191    InvalidProtocolTarget { canister: String, step: String },
192
193    #[error("protocol step {0} has duplicate or empty authority")]
194    InvalidProtocolStep(String),
195
196    #[error("protocol step {0} is missing an exact live terminal observation")]
197    MissingProtocolObservation(String),
198
199    #[error("controlled canister {0} must configure init_arg and init_candid together")]
200    IncompleteInitTemplate(String),
201
202    #[error("Canic infrastructure canister {name} requires one exact typed bootstrap initializer")]
203    MissingCanicInitializer { name: String },
204
205    #[error("Canic infrastructure canister {name} has conflicting typed and generic initializers")]
206    ConflictingCanicInitializer { name: String },
207
208    #[error("retained pool asset {name} is missing; no replacement was created")]
209    MissingPoolAsset { name: String },
210
211    #[error("retained pool asset {name} has no exact Root-owned lifecycle observation")]
212    MissingPoolLifecycle { name: String },
213
214    #[error("canister {name} is not an exact present infrastructure reinstall target")]
215    InvalidInfrastructureReinstall { name: String },
216
217    #[error("Root-owned canister {name} is still awaiting exact current balance observation")]
218    PendingRootOwnedBalance { name: String },
219
220    #[error("desired Fleet topology is invalid for {name}: {reason}")]
221    InvalidTopology { name: String, reason: &'static str },
222
223    #[error("management observation for retained Root {name} is missing")]
224    MissingRootManagementObservation { name: String },
225
226    #[error("management observation for retained Root {name} has invalid {field}")]
227    RootManagementAuthorityMismatch { field: &'static str, name: String },
228
229    #[error("retained Root {name} is stopping; wait for terminal Stopped state")]
230    RootStopping { name: String },
231
232    #[error(transparent)]
233    PoolImportCapacity(#[from] RootPoolImportCapacityError),
234}
235
236#[derive(Clone, Copy)]
237struct CycleBounds {
238    ledger_fee: u128,
239    management_creation_fee: u128,
240    material_threshold: u128,
241    observation_burn: u128,
242    update_burn: u128,
243}
244
245struct PlanAccumulator {
246    canisters: Vec<CanisterPlan>,
247    execution_burn: u128,
248    fees: u128,
249    new_funding: u128,
250    retained: u128,
251    transfers: u128,
252}
253
254impl PlanAccumulator {
255    const fn new() -> Self {
256        Self {
257            canisters: Vec::new(),
258            execution_burn: 0,
259            fees: 0,
260            new_funding: 0,
261            retained: 0,
262            transfers: 0,
263        }
264    }
265
266    fn add_burn(&mut self, value: u128) -> Result<(), EnsurePolicyError> {
267        self.execution_burn = checked_add(self.execution_burn, value, "execution burn")?;
268        Ok(())
269    }
270
271    fn add_fee(&mut self, value: u128) -> Result<(), EnsurePolicyError> {
272        self.fees = checked_add(self.fees, value, "unavoidable fees")?;
273        Ok(())
274    }
275
276    fn add_funding(&mut self, value: u128) -> Result<(), EnsurePolicyError> {
277        self.new_funding = checked_add(self.new_funding, value, "new funding")?;
278        Ok(())
279    }
280}
281
282/// Compile one immutable reviewed plan from current desired state and exact live observation.
283#[expect(
284    clippy::too_many_lines,
285    reason = "one pure compiler keeps the complete reviewed conservation and action authority visible"
286)]
287#[expect(
288    clippy::too_many_arguments,
289    reason = "explicit operation identity also binds deliberate same-release resets"
290)]
291pub fn compile_plan(
292    desired: &DesiredFleet,
293    artifacts: &DesiredFleetArtifacts,
294    compiled_protocol_actions: &[EnsureAction],
295    desired_sha256: &str,
296    requested_fleet: &str,
297    observation: &FleetObservation,
298    created_at_time: u64,
299    operation_id: &str,
300    reinstall: Option<&crate::fleet_ensure::model::FleetReinstallRecord>,
301) -> Result<FleetEnsurePlan, EnsurePolicyError> {
302    validate_authority(desired, requested_fleet)?;
303    validate_observation_authority(desired, observation)?;
304    let bounds = cycle_bounds(desired)?;
305    if observation.ledger_fee_cycles != bounds.ledger_fee {
306        return Err(EnsurePolicyError::LedgerFeeDrift {
307            actual: observation.ledger_fee_cycles,
308            expected: bounds.ledger_fee,
309        });
310    }
311    let operation_id = operation_id.to_string();
312    let mut accumulator = PlanAccumulator::new();
313    let reviewed_reinstalls = if let Some(intent) = reinstall {
314        reinstall::validate_reset(desired, artifacts, observation, intent, &operation_id)?
315    } else {
316        reviewed_reinstall_canisters(desired, observation)?
317    };
318
319    for (index, configured) in desired.canisters.iter().enumerate() {
320        let observed = observation
321            .canisters
322            .get(&configured.name)
323            .ok_or_else(|| EnsurePolicyError::MissingObservation {
324                name: configured.name.clone(),
325            })?
326            .as_ref();
327        let cycle_policy = canister_cycle_policy(configured)?;
328        let action_time = created_at_time
329            .checked_add(u64::try_from(index).unwrap_or(u64::MAX))
330            .ok_or(EnsurePolicyError::ArithmeticOverflow {
331                field: "action timestamp",
332            })?;
333        let mut plan = compile_canister(
334            desired,
335            artifacts,
336            configured,
337            observed,
338            observation,
339            cycle_policy,
340            bounds,
341            action_time,
342            reviewed_reinstalls.contains(&configured.name),
343            &mut accumulator,
344        )?;
345        if let Some(intent) = reinstall {
346            reinstall::bind_history_witness(desired, artifacts, intent, configured, &mut plan)?;
347        }
348        accumulator.canisters.push(plan);
349    }
350    for name in &reviewed_reinstalls {
351        let has_exact_reinstall = accumulator.canisters.iter().any(|canister| {
352            canister.name == *name
353                && canister.actions.iter().any(|action| {
354                    matches!(
355                        action,
356                        EnsureAction::Install {
357                            mode: InstallMode::Reinstall,
358                            ..
359                        }
360                    )
361                })
362        });
363        if !has_exact_reinstall {
364            return Err(EnsurePolicyError::InvalidInfrastructureReinstall { name: name.clone() });
365        }
366    }
367
368    let mut protocol_actions = Vec::new();
369    let mut protocol_names = BTreeSet::new();
370    for step in &desired.protocol_steps {
371        let ready = observation
372            .protocol_ready
373            .get(&step.name)
374            .copied()
375            .ok_or_else(|| EnsurePolicyError::MissingProtocolObservation(step.name.clone()))?;
376        if ready {
377            continue;
378        }
379        if !protocol_names.insert(step.name.as_str()) {
380            return Err(EnsurePolicyError::InvalidProtocolStep(step.name.clone()));
381        }
382        let configured = desired
383            .canisters
384            .iter()
385            .find(|canister| canister.name == step.canister)
386            .ok_or_else(|| EnsurePolicyError::InvalidProtocolTarget {
387                canister: step.canister.clone(),
388                step: step.name.clone(),
389            })?;
390        let principal = observation
391            .canisters
392            .get(&step.canister)
393            .and_then(Option::as_ref)
394            .map_or_else(
395                || format!("created:{}", step.canister),
396                |live| live.principal.clone(),
397            );
398        if configured.presence != DesiredPresence::Present {
399            return Err(EnsurePolicyError::InvalidProtocolTarget {
400                canister: step.canister.clone(),
401                step: step.name.clone(),
402            });
403        }
404        let identities = artifacts.protocol_by_step.get(&step.name).ok_or_else(|| {
405            EnsurePolicyError::MissingArtifactIdentity {
406                kind: "protocol contract",
407                name: step.name.clone(),
408            }
409        })?;
410        let maximum_execution_burn_cycles = parse_canister_cycles(
411            &step.canister,
412            "protocol.maximum_execution_burn_cycles",
413            &step.maximum_execution_burn_cycles,
414        )?;
415        protocol_actions.push(EnsureAction::Protocol {
416            candid: step.candid.clone(),
417            candid_sha256: identities.candid_sha256.clone(),
418            command_args: step.command_args.clone(),
419            command_args_sha256: identities.command_args_sha256.clone(),
420            command_method: step.command_method.clone(),
421            expected_status: step.expected_status.clone(),
422            expected_status_sha256: identities.expected_status_sha256.clone(),
423            maximum_execution_burn_cycles,
424            name: step.name.clone(),
425            principal,
426            status_args: step.status_args.clone(),
427            status_args_sha256: identities.status_args_sha256.clone(),
428            status_method: step.status_method.clone(),
429        });
430    }
431    let expected_typed_protocol = desired.protocol.as_ref();
432    for action in compiled_protocol_actions {
433        let EnsureAction::FleetProtocol {
434            action: current_action,
435            candid,
436            name,
437            principal,
438            ..
439        } = action
440        else {
441            return Err(EnsurePolicyError::InvalidProtocolStep(
442                action.name().to_string(),
443            ));
444        };
445        let target_kind = current_action.target_kind();
446        let target_is_exact = desired.canisters.iter().any(|canister| {
447            canister.kind == target_kind
448                && canister.presence == DesiredPresence::Present
449                && observation
450                    .canisters
451                    .get(&canister.name)
452                    .and_then(Option::as_ref)
453                    .is_some_and(|live| live.principal == *principal)
454        });
455        let operation_matches = match current_action.as_ref() {
456            CurrentFleetProtocolAction::MaintainPoolReadiness {
457                maximum_updates,
458                minimum_ready,
459                readiness_floor,
460            } => pool_readiness_authority_matches(
461                desired,
462                observation,
463                principal,
464                *minimum_ready,
465                readiness_floor,
466                Some(*maximum_updates),
467            ),
468            CurrentFleetProtocolAction::ObservePoolReadiness {
469                minimum_ready,
470                readiness_floor,
471            } => pool_readiness_authority_matches(
472                desired,
473                observation,
474                principal,
475                *minimum_ready,
476                readiness_floor,
477                None,
478            ),
479            CurrentFleetProtocolAction::ReconcilePoolAsset {
480                request,
481                minimum_cycles,
482            } => observation
483                .estate_funding_domains
484                .iter()
485                .any(|(_root, domain)| {
486                    domain.root_principal.as_deref() == Some(principal)
487                        && domain.pool.as_ref().is_some_and(|pool| {
488                            minimum_cycles.to_u128() == pool.readiness_floor_cycles
489                                && pool.assets.iter().any(|asset| {
490                                    asset.principal == request.canister_id.to_text()
491                                        && matches!(
492                                            asset.lifecycle,
493                                            EstatePoolAssetLifecycle::PendingReset
494                                                | EstatePoolAssetLifecycle::Failed
495                                        )
496                                })
497                        })
498                }),
499            CurrentFleetProtocolAction::ProvisionComponents { request, .. } => {
500                canic_core::cdk::utils::hash::decode_hex(&operation_id)
501                    .ok()
502                    .and_then(|bytes| <[u8; 32]>::try_from(bytes).ok())
503                    .is_some_and(|operation_id| request.operation_id == operation_id)
504            }
505            current_action => current_action
506                .operation_id()
507                .is_none_or(|identity| identity != [0; 32]),
508        };
509        let protocol_matches = expected_typed_protocol.is_some_and(|protocol| {
510            let expected_candid = match target_kind {
511                DesiredCanisterKind::Coordinator => protocol.coordinator_candid.as_str(),
512                DesiredCanisterKind::Root => protocol.root_candid.as_str(),
513                DesiredCanisterKind::Store => protocol.store_candid.as_str(),
514                DesiredCanisterKind::Auxiliary
515                | DesiredCanisterKind::Component
516                | DesiredCanisterKind::Pool => return false,
517            };
518            candid == expected_candid
519        });
520        if name.is_empty()
521            || !protocol_names.insert(name.as_str())
522            || !target_is_exact
523            || !operation_matches
524            || !protocol_matches
525        {
526            return Err(EnsurePolicyError::InvalidProtocolStep(name.clone()));
527        }
528        protocol_actions.push(action.clone());
529    }
530    if protocol_actions.len() > MAX_FLEET_ENSURE_PROTOCOL_STEPS {
531        return Err(EnsurePolicyError::TooManyProtocolSteps {
532            actual: protocol_actions.len(),
533            maximum: MAX_FLEET_ENSURE_PROTOCOL_STEPS,
534        });
535    }
536    if reviewed_reinstalls.is_empty() && protocol_actions.is_empty() {
537        let pending = desired.canisters.iter().find_map(|configured| {
538            observation
539                .canisters
540                .get(&configured.name)
541                .and_then(Option::as_ref)
542                .is_some_and(|live| {
543                    live.root_owned_lifecycle == Some(RootOwnedCanisterLifecycle::Retained)
544                })
545                .then(|| configured.name.clone())
546        });
547        if let Some(name) = pending
548            && !retained_root_observation_is_start_only(
549                desired,
550                &accumulator.canisters,
551                observation,
552            )
553        {
554            return Err(EnsurePolicyError::PendingRootOwnedBalance { name });
555        }
556    }
557
558    let observed_native_cycles = observation
559        .canisters
560        .values()
561        .filter_map(Option::as_ref)
562        .try_fold(0_u128, |total, live| {
563            checked_add(total, live.cycles, "observed controlled cycles")
564        })?;
565    let observed_native_cycles = observation
566        .additional_controlled_cycles
567        .values()
568        .try_fold(observed_native_cycles, |total, cycles| {
569            checked_add(total, *cycles, "observed controlled cycles")
570        })?;
571    tranche_protocol_actions(
572        desired,
573        observation,
574        bounds,
575        observed_native_cycles,
576        &mut accumulator,
577        &mut protocol_actions,
578    )?;
579
580    let continuation = artifacts.continuation.clone().filter(|_| {
581        !accumulator.canisters.is_empty()
582            && (accumulator
583                .canisters
584                .iter()
585                .all(|canister| canister.disposition == CanisterDisposition::Create)
586                || (accumulator
587                    .canisters
588                    .iter()
589                    .any(|canister| canister.disposition == CanisterDisposition::Reinstall)
590                    && accumulator.canisters.iter().all(|canister| {
591                        matches!(
592                            canister.disposition,
593                            CanisterDisposition::Reuse | CanisterDisposition::Reinstall
594                        )
595                    })))
596            && desired.protocol_steps.is_empty()
597            && protocol_actions.is_empty()
598    });
599    let reconciliation_only = !protocol_actions.is_empty() && protocol_actions.iter().all(|action| matches!(action,
600        EnsureAction::FleetProtocol { action, .. } if matches!(action.as_ref(), CurrentFleetProtocolAction::ReconcilePoolAsset { .. })
601    ));
602    append_pool_reconciliation_funding(
603        desired,
604        observation,
605        &protocol_actions,
606        bounds,
607        created_at_time,
608        &mut accumulator,
609    )?;
610    // Infrastructure replacement must finish before owned imports can become Ready.
611    // Its sealed continuation reviews only protocol effects, never extra creation debit.
612    let defer_pool_creation = reconciliation_only || continuation.is_some();
613    let estate_funding_domains =
614        compile_estate_funding_domains(desired, observation, bounds, defer_pool_creation)?;
615    append_estate_funding_actions(
616        &estate_funding_domains,
617        created_at_time,
618        desired.canisters.len(),
619        &mut accumulator,
620    )?;
621    let observation_count = maximum_observation_count(
622        desired,
623        &accumulator.canisters,
624        &protocol_actions,
625        observation.additional_controlled_cycles.len(),
626    )?;
627    accumulator.add_burn(
628        bounds
629            .observation_burn
630            .checked_mul(observation_count)
631            .ok_or(EnsurePolicyError::ArithmeticOverflow {
632                field: "observation burn",
633            })?,
634    )?;
635
636    let observed_estate_funding_cycles =
637        estate_funding_domains
638            .iter()
639            .try_fold(0_u128, |total, domain| {
640                checked_add(
641                    total,
642                    domain.available_cycles.unwrap_or_default(),
643                    "observed estate funding cycles",
644                )
645            })?;
646    let maximum_estate_creation_fee_cycles =
647        estate_funding_domains
648            .iter()
649            .try_fold(0_u128, |total, domain| {
650                checked_add(
651                    total,
652                    domain.maximum_creation_fee_cycles,
653                    "maximum estate creation fees",
654                )
655            })?;
656    let observed_controlled_cycles = checked_add(
657        observed_native_cycles,
658        observed_estate_funding_cycles,
659        "observed controlled cycles",
660    )?;
661    let maximum_operator_debit_cycles = checked_add(
662        accumulator.new_funding,
663        accumulator.fees,
664        "maximum operator debit",
665    )?;
666    if observation.operator_cycles < maximum_operator_debit_cycles {
667        // Funding sufficiency is deliberately enforced by apply after a reviewed plan is loaded.
668        // The plan remains useful and truthful even when the current account is insufficient.
669    }
670    let available_after_operator_funding = checked_add(
671        observed_controlled_cycles,
672        accumulator.new_funding,
673        "cycle conservation available balance",
674    )?;
675    let available_after_estate_fees = available_after_operator_funding
676        .checked_sub(maximum_estate_creation_fee_cycles)
677        .ok_or(EnsurePolicyError::ArithmeticOverflow {
678            field: "estate creation fees",
679        })?;
680    let recovery_review = continuation
681        .as_ref()
682        .map(|authority| {
683            recovery::review(
684                observation,
685                bounds,
686                authority.maximum_successor_actions,
687                accumulator.execution_burn,
688                available_after_estate_fees,
689            )
690        })
691        .transpose()?
692        .map(Box::new);
693    if let Some(review) = &recovery_review {
694        accumulator.add_burn(review.continuation_reserve_cycles)?;
695    }
696    let expected_post_operation_cycles = available_after_estate_fees
697        .checked_sub(accumulator.execution_burn)
698        .ok_or_else(|| {
699            insufficient_cycle_conservation(
700                &accumulator,
701                protocol_actions.len(),
702                available_after_operator_funding,
703                accumulator.execution_burn,
704            )
705        })?;
706    let conservation = CycleConservation {
707        estate_funding_domains,
708        expected_post_operation_cycles,
709        maximum_execution_burn_cycles: accumulator.execution_burn,
710        maximum_new_funding_cycles: accumulator.new_funding,
711        maximum_operator_debit_cycles,
712        maximum_unavoidable_fee_cycles: accumulator.fees,
713        observed_controlled_cycles,
714        retained_in_reused_canisters_cycles: accumulator.retained,
715        scheduled_transfer_cycles: accumulator.transfers,
716    };
717    let mut plan = FleetEnsurePlan {
718        continuation,
719        canisters: accumulator.canisters,
720        conservation,
721        desired_sha256: desired_sha256.to_string(),
722        environment: desired.environment.clone(),
723        fleet: requested_fleet.to_string(),
724        operation_id,
725        plan_sha256: String::new(),
726        planned_at_time: created_at_time,
727        protocol_actions,
728        recovery_review,
729        reinstall: reinstall.cloned().map(Box::new),
730        root_reinstall_bindings: Vec::new(),
731        root_start_authority: None,
732        reviewed_desired: Some(Box::new(
733            crate::fleet_ensure::model::ReviewedDesiredFleetRecord::capture(desired),
734        )),
735        schema_version: FLEET_ENSURE_SCHEMA_VERSION,
736        scope: FleetEnsurePlanScope::Full,
737        terminal_inventory_operation_id: None,
738    };
739    plan.plan_sha256 = expected_plan_sha256(&plan);
740    Ok(plan)
741}
742
743fn pool_readiness_authority_matches(
744    desired: &DesiredFleet,
745    observation: &FleetObservation,
746    principal: &str,
747    minimum_ready: u32,
748    readiness_floor: &Cycles,
749    maximum_updates: Option<u32>,
750) -> bool {
751    let network_matches = desired.bootstrap.as_ref().is_some_and(|bootstrap| {
752        let mainnet =
753            bootstrap.canonical_network_id == canic_core::ids::CanonicalNetworkId::ic_mainnet();
754        (mainnet && !bootstrap.fresh_estate) == maximum_updates.is_some()
755    });
756    network_matches
757        && observation
758            .estate_funding_domains
759            .iter()
760            .any(|(root, domain)| {
761                domain.root_principal.as_deref() == Some(principal)
762                    && desired_root_pool_policy(desired, root).is_ok_and(|pool| {
763                        pool.minimum_size == minimum_ready
764                            && pool.canister_cycles == *readiness_floor
765                            && maximum_updates.is_none_or(|maximum| {
766                                maximum == pool_maintenance_update_bound(minimum_ready)
767                            })
768                    })
769            })
770}
771
772/// Fund exact retained inventory assets before their separately journalled Root reconciliation.
773fn append_pool_reconciliation_funding(
774    desired: &DesiredFleet,
775    observation: &FleetObservation,
776    protocol_actions: &[EnsureAction],
777    bounds: CycleBounds,
778    created_at_time: u64,
779    accumulator: &mut PlanAccumulator,
780) -> Result<(), EnsurePolicyError> {
781    for (index, action) in protocol_actions.iter().enumerate() {
782        let EnsureAction::FleetProtocol {
783            action,
784            principal: root,
785            name,
786            ..
787        } = action
788        else {
789            continue;
790        };
791        let CurrentFleetProtocolAction::ReconcilePoolAsset { request, .. } = action.as_ref() else {
792            continue;
793        };
794        let principal = request.canister_id.to_text();
795        let pool = observation
796            .estate_funding_domains
797            .values()
798            .find(|domain| domain.root_principal.as_deref() == Some(root))
799            .and_then(|domain| domain.pool.as_ref())
800            .ok_or_else(|| EnsurePolicyError::InvalidProtocolStep(name.clone()))?;
801        let asset = pool
802            .assets
803            .iter()
804            .find(|asset| asset.principal == principal)
805            .ok_or_else(|| EnsurePolicyError::InvalidProtocolStep(name.clone()))?;
806        let Some(funding) = recovery::pool_funding(pool, asset, root, bounds)? else {
807            continue;
808        };
809        let amount = funding.amount_cycles;
810        let deficit = funding.funding_deficit_cycles;
811        let margin = funding.funding_margin_cycles;
812        let expected_post_cycles = funding.expected_post_cycles;
813        let offset = desired
814            .canisters
815            .len()
816            .checked_add(index)
817            .and_then(|value| u64::try_from(value).ok())
818            .ok_or(EnsurePolicyError::ArithmeticOverflow {
819                field: "pool funding timestamp",
820            })?;
821        let action_time =
822            created_at_time
823                .checked_add(offset)
824                .ok_or(EnsurePolicyError::ArithmeticOverflow {
825                    field: "pool funding timestamp",
826                })?;
827        let plan_index = if let Some(index) = accumulator
828            .canisters
829            .iter()
830            .position(|plan| plan.principal.as_deref() == Some(&principal))
831        {
832            index
833        } else {
834            accumulator.canisters.push(CanisterPlan {
835                actions: Vec::new(),
836                disposition: CanisterDisposition::Reuse,
837                name: name.clone(),
838                observed_cycles: asset.cycles,
839                principal: Some(principal.clone()),
840            });
841            accumulator.canisters.len() - 1
842        };
843        let target_name = accumulator.canisters[plan_index].name.clone();
844        // A retained Failed/PendingReset row is fenced from ordinary configured-target funding.
845        if accumulator.canisters[plan_index]
846            .actions
847            .iter()
848            .any(|action| matches!(action, EnsureAction::Fund { .. }))
849        {
850            return Err(EnsurePolicyError::InvalidProtocolStep(name.clone()));
851        }
852        accumulator.canisters[plan_index]
853            .actions
854            .push(EnsureAction::Fund {
855                pool_funding: Some(PoolFundingAuthority {
856                    root: root.clone(),
857                    lifecycle: asset.lifecycle,
858                }),
859                amount,
860                created_at_time: action_time,
861                expected_post_cycles,
862                funding_deficit_cycles: deficit,
863                funding_margin_cycles: margin,
864                ledger: desired.cycles_ledger.clone(),
865                name: target_name,
866                principal,
867            });
868        accumulator.add_funding(amount)?;
869        accumulator.add_fee(bounds.ledger_fee)?;
870    }
871    Ok(())
872}
873
874fn append_estate_funding_actions(
875    domains: &[EstateFundingDomainPlan],
876    created_at_time: u64,
877    canister_count: usize,
878    accumulator: &mut PlanAccumulator,
879) -> Result<(), EnsurePolicyError> {
880    for (index, domain) in domains.iter().enumerate() {
881        if domain.shortfall_cycles == 0 {
882            continue;
883        }
884        let offset = canister_count
885            .checked_add(index)
886            .and_then(|value| u64::try_from(value).ok())
887            .ok_or(EnsurePolicyError::ArithmeticOverflow {
888                field: "estate funding action timestamp",
889            })?;
890        let action_time =
891            created_at_time
892                .checked_add(offset)
893                .ok_or(EnsurePolicyError::ArithmeticOverflow {
894                    field: "estate funding action timestamp",
895                })?;
896        let expected_post_cycles = domain
897            .available_cycles
898            .unwrap_or_default()
899            .checked_add(domain.shortfall_cycles)
900            .ok_or(EnsurePolicyError::ArithmeticOverflow {
901                field: "estate funding destination balance",
902            })?;
903        let principal = domain
904            .root_principal
905            .clone()
906            .unwrap_or_else(|| format!("created:{}", domain.root));
907        let root_plan = accumulator
908            .canisters
909            .iter_mut()
910            .find(|canister| canister.name == domain.root)
911            .ok_or_else(|| EnsurePolicyError::EstateFundingTopology {
912                reason: format!("Root {} has no canister plan", domain.root),
913            })?;
914        root_plan.actions.push(EnsureAction::FundEstate {
915            amount: domain.shortfall_cycles,
916            created_at_time: action_time,
917            expected_post_cycles,
918            ledger: domain.cycles_ledger.clone(),
919            ledger_fee_cycles: domain.ledger_fee_cycles,
920            name: domain.root.clone(),
921            principal,
922        });
923        accumulator.add_funding(domain.shortfall_cycles)?;
924        accumulator.add_fee(domain.ledger_fee_cycles)?;
925    }
926    Ok(())
927}
928
929/// Complete pure authority used to compile one management-only Root Start prerequisite.
930pub(crate) struct RootStartPlanInput<'a> {
931    pub state: &'a FleetEnsureStateRecord,
932    pub authority: Option<&'a RetainedRootStartAuthorityRecord>,
933    pub created_at_time: u64,
934    pub desired: &'a DesiredFleet,
935    pub desired_sha256: &'a str,
936    pub observation: &'a RootManagementObservation,
937    pub requested_fleet: &'a str,
938}
939
940fn root_management_principal<'a>(
941    configured: &'a crate::fleet_ensure::model::DesiredCanister,
942    state: &'a FleetEnsureStateRecord,
943    fleet: &str,
944) -> Result<&'a str, EnsurePolicyError> {
945    let retained = state
946        .pending_principals
947        .get(&configured.name)
948        .or_else(|| state.principals.get(&configured.name))
949        .map(String::as_str);
950    let configured_principal = configured.principal.as_deref();
951    let conflict = state.fleet != fleet
952        || matches!((configured_principal, retained), (Some(configured), Some(retained)) if configured != retained);
953    if conflict {
954        return Err(EnsurePolicyError::RootManagementAuthorityMismatch {
955            field: "retained Principal",
956            name: configured.name.clone(),
957        });
958    }
959    configured_principal.or(retained).ok_or_else(|| {
960        EnsurePolicyError::RootManagementAuthorityMismatch {
961            field: "Principal",
962            name: configured.name.clone(),
963        }
964    })
965}
966
967/// Compile the one management-only prerequisite allowed before protected Root observation.
968pub(crate) fn compile_root_start_prerequisite_plan(
969    input: RootStartPlanInput<'_>,
970) -> Result<Option<FleetEnsurePlan>, EnsurePolicyError> {
971    compile_root_start_plan(&input, None)
972}
973
974/// Recompile one reviewed Root-start prerequisite while accepting an already-started exact target.
975pub(crate) fn recompile_root_start_prerequisite_plan(
976    input: RootStartPlanInput<'_>,
977    reviewed_targets: &BTreeSet<String>,
978) -> Result<FleetEnsurePlan, EnsurePolicyError> {
979    compile_root_start_plan(&input, Some(reviewed_targets))?.ok_or_else(|| {
980        EnsurePolicyError::MissingRootManagementObservation {
981            name: "reviewed Root-start target".to_string(),
982        }
983    })
984}
985
986#[expect(
987    clippy::too_many_lines,
988    reason = "one pure boundary validates and compiles the complete Root-start authority"
989)]
990fn compile_root_start_plan(
991    input: &RootStartPlanInput<'_>,
992    reviewed_targets: Option<&BTreeSet<String>>,
993) -> Result<Option<FleetEnsurePlan>, EnsurePolicyError> {
994    let RootStartPlanInput {
995        state,
996        authority,
997        created_at_time,
998        desired,
999        desired_sha256,
1000        observation,
1001        requested_fleet,
1002    } = *input;
1003    validate_authority(desired, requested_fleet)?;
1004    let bounds = cycle_bounds(desired)?;
1005    let configured_roots = desired
1006        .canisters
1007        .iter()
1008        .filter(|configured| {
1009            configured.kind == DesiredCanisterKind::Root
1010                && configured.presence == DesiredPresence::Present
1011        })
1012        .collect::<Vec<_>>();
1013    if observation.roots.len() != configured_roots.len() {
1014        return Err(EnsurePolicyError::MissingRootManagementObservation {
1015            name: "configured Root set".to_string(),
1016        });
1017    }
1018
1019    let mut canisters = Vec::new();
1020    let mut root_start_bindings = Vec::new();
1021    let mut observed_controlled_cycles = 0_u128;
1022    for configured in configured_roots {
1023        let observed = observation.roots.get(&configured.name).ok_or_else(|| {
1024            EnsurePolicyError::MissingRootManagementObservation {
1025                name: configured.name.clone(),
1026            }
1027        })?;
1028        let expected_principal = root_management_principal(configured, state, requested_fleet)?;
1029        if observed.name != configured.name || observed.live.principal != expected_principal {
1030            return Err(EnsurePolicyError::RootManagementAuthorityMismatch {
1031                field: "Principal",
1032                name: configured.name.clone(),
1033            });
1034        }
1035        if observed.subnet != configured.subnet {
1036            return Err(EnsurePolicyError::RootManagementAuthorityMismatch {
1037                field: "Subnet",
1038                name: configured.name.clone(),
1039            });
1040        }
1041        if observed.live.status == CanisterRuntimeStatus::Stopping {
1042            return Err(EnsurePolicyError::RootStopping {
1043                name: configured.name.clone(),
1044            });
1045        }
1046        let reviewed = reviewed_targets.is_some_and(|targets| targets.contains(&configured.name));
1047        let needs_start = observed.live.status == CanisterRuntimeStatus::Stopped;
1048        if reviewed_targets.is_some() && needs_start && !reviewed {
1049            return Err(EnsurePolicyError::RootManagementAuthorityMismatch {
1050                field: "reviewed runtime transition",
1051                name: configured.name.clone(),
1052            });
1053        }
1054        if !needs_start && !reviewed {
1055            continue;
1056        }
1057        if !configured.controller_canisters.is_empty() {
1058            return Err(EnsurePolicyError::RootManagementAuthorityMismatch {
1059                field: "controller authority",
1060                name: configured.name.clone(),
1061            });
1062        }
1063        let mut expected_controllers = configured.controllers.clone();
1064        expected_controllers.sort();
1065        let mut actual_controllers = observed.live.controllers.clone();
1066        actual_controllers.sort();
1067        if actual_controllers != expected_controllers {
1068            return Err(EnsurePolicyError::RootManagementAuthorityMismatch {
1069                field: "controllers",
1070                name: configured.name.clone(),
1071            });
1072        }
1073        let observed_module = observed.live.module_sha256.as_deref().ok_or_else(|| {
1074            EnsurePolicyError::RootManagementAuthorityMismatch {
1075                field: "module SHA-256",
1076                name: configured.name.clone(),
1077            }
1078        })?;
1079        root_start_bindings.push(RootManagementBinding {
1080            controllers: actual_controllers.clone(),
1081            name: configured.name.clone(),
1082            module_sha256: observed_module.to_string(),
1083            principal: observed.live.principal.clone(),
1084            subnet: observed.subnet.clone(),
1085        });
1086        if observed.live.root_owned_lifecycle.is_some() || observed.live.reinstall_required {
1087            return Err(EnsurePolicyError::RootManagementAuthorityMismatch {
1088                field: "runtime authority",
1089                name: configured.name.clone(),
1090            });
1091        }
1092        observed_controlled_cycles = checked_add(
1093            observed_controlled_cycles,
1094            observed.live.cycles,
1095            "Root-start observed cycles",
1096        )?;
1097        let actions = vec![EnsureAction::Start {
1098            name: configured.name.clone(),
1099            principal: observed.live.principal.clone(),
1100        }];
1101        canisters.push(CanisterPlan {
1102            actions,
1103            disposition: CanisterDisposition::Reuse,
1104            name: configured.name.clone(),
1105            observed_cycles: observed.live.cycles,
1106            principal: Some(observed.live.principal.clone()),
1107        });
1108    }
1109    if canisters.is_empty() {
1110        return Ok(None);
1111    }
1112    let retained_authority = validate_root_start_authority(
1113        desired,
1114        requested_fleet,
1115        authority,
1116        &root_start_bindings,
1117        true,
1118    )?;
1119    if let Some(reviewed_targets) = reviewed_targets
1120        && (reviewed_targets.len() != canisters.len()
1121            || canisters
1122                .iter()
1123                .any(|canister| !reviewed_targets.contains(&canister.name)))
1124    {
1125        return Err(EnsurePolicyError::RootManagementAuthorityMismatch {
1126            field: "reviewed Root set",
1127            name: "Root-start prerequisite".to_string(),
1128        });
1129    }
1130    let target_count = u128::try_from(
1131        canisters
1132            .iter()
1133            .map(|canister| canister.actions.len())
1134            .sum::<usize>(),
1135    )
1136    .map_err(|_| EnsurePolicyError::ArithmeticOverflow {
1137        field: "Root-start target count",
1138    })?;
1139    let maximum_execution_burn_cycles = bounds.update_burn.checked_mul(target_count).ok_or(
1140        EnsurePolicyError::ArithmeticOverflow {
1141            field: "Root-start execution burn",
1142        },
1143    )?;
1144    let expected_post_operation_cycles = observed_controlled_cycles
1145        .checked_sub(maximum_execution_burn_cycles)
1146        .ok_or(EnsurePolicyError::ArithmeticOverflow {
1147            field: "Root-start conservation equation",
1148        })?;
1149    let mut plan = FleetEnsurePlan {
1150        canisters,
1151        continuation: None,
1152        conservation: CycleConservation {
1153            estate_funding_domains: Vec::new(),
1154            expected_post_operation_cycles,
1155            maximum_execution_burn_cycles,
1156            maximum_new_funding_cycles: 0,
1157            maximum_operator_debit_cycles: 0,
1158            maximum_unavoidable_fee_cycles: 0,
1159            observed_controlled_cycles,
1160            retained_in_reused_canisters_cycles: observed_controlled_cycles,
1161            scheduled_transfer_cycles: 0,
1162        },
1163        desired_sha256: desired_sha256.to_string(),
1164        environment: desired.environment.clone(),
1165        fleet: requested_fleet.to_string(),
1166        operation_id: operation_id(desired_sha256, &desired.environment, requested_fleet),
1167        plan_sha256: String::new(),
1168        planned_at_time: created_at_time,
1169        protocol_actions: Vec::new(),
1170        recovery_review: None,
1171        reinstall: None,
1172        root_reinstall_bindings: Vec::new(),
1173        root_start_authority: retained_authority.map(|authority| Box::new(authority.clone())),
1174        reviewed_desired: Some(Box::new(
1175            crate::fleet_ensure::model::ReviewedDesiredFleetRecord::capture(desired),
1176        )),
1177        schema_version: FLEET_ENSURE_SCHEMA_VERSION,
1178        scope: FleetEnsurePlanScope::RootStartPrerequisite,
1179        terminal_inventory_operation_id: None,
1180    };
1181    plan.plan_sha256 = expected_plan_sha256(&plan);
1182    Ok(Some(plan))
1183}
1184
1185fn validate_root_start_authority<'a>(
1186    desired: &DesiredFleet,
1187    requested_fleet: &str,
1188    authority: Option<&'a RetainedRootStartAuthorityRecord>,
1189    root_start_bindings: &[RootManagementBinding],
1190    authority_required: bool,
1191) -> Result<Option<&'a RetainedRootStartAuthorityRecord>, EnsurePolicyError> {
1192    if !authority_required {
1193        return Ok(None);
1194    }
1195    if root_start_bindings.is_empty() {
1196        return Err(EnsurePolicyError::RootManagementAuthorityMismatch {
1197            field: "retained module authority",
1198            name: "Root-start prerequisite".to_string(),
1199        });
1200    }
1201    let authority =
1202        authority.ok_or_else(|| EnsurePolicyError::RootManagementAuthorityMismatch {
1203            field: "retained module authority",
1204            name: root_start_bindings[0].name.clone(),
1205        })?;
1206    let bootstrap = desired.bootstrap.as_ref().ok_or_else(|| {
1207        EnsurePolicyError::RootManagementAuthorityMismatch {
1208            field: "Fleet bootstrap authority",
1209            name: root_start_bindings[0].name.clone(),
1210        }
1211    })?;
1212    let mut expected_bindings = root_start_bindings.to_vec();
1213    expected_bindings.sort_by(|left, right| left.name.cmp(&right.name));
1214    let mut actual_bindings = authority.roots.clone();
1215    actual_bindings.sort_by(|left, right| left.name.cmp(&right.name));
1216    let path_identity_matches =
1217        authority.environment == desired.environment && authority.fleet == requested_fleet;
1218    let fleet_identity_matches = authority.fleet_id == bootstrap.fleet_id;
1219    let record_identity_matches =
1220        authority.schema_version == FLEET_ENSURE_SCHEMA_VERSION && authority.has_valid_digest();
1221    let bindings_match = actual_bindings == expected_bindings;
1222    if !(path_identity_matches
1223        && fleet_identity_matches
1224        && record_identity_matches
1225        && bindings_match)
1226    {
1227        return Err(EnsurePolicyError::RootManagementAuthorityMismatch {
1228            field: "retained module authority",
1229            name: root_start_bindings[0].name.clone(),
1230        });
1231    }
1232    Ok(Some(authority))
1233}
1234
1235fn retained_root_observation_is_start_only(
1236    desired: &DesiredFleet,
1237    plans: &[CanisterPlan],
1238    observation: &FleetObservation,
1239) -> bool {
1240    let only_start_effects = plans
1241        .iter()
1242        .flat_map(|plan| &plan.actions)
1243        .all(|action| matches!(action, EnsureAction::Start { .. }));
1244    only_start_effects
1245        && desired
1246            .canisters
1247            .iter()
1248            .filter(|configured| {
1249                observation
1250                    .canisters
1251                    .get(&configured.name)
1252                    .and_then(Option::as_ref)
1253                    .is_some_and(|live| {
1254                        live.root_owned_lifecycle == Some(RootOwnedCanisterLifecycle::Retained)
1255                    })
1256            })
1257            .all(|configured| {
1258                configured.parent.as_ref().is_some_and(|parent| {
1259                    plans.iter().any(|plan| {
1260                        plan.name == *parent
1261                            && plan.actions.iter().any(|action| {
1262                                matches!(action, EnsureAction::Start { name, .. } if name == parent)
1263                            })
1264                    })
1265                })
1266            })
1267}
1268
1269/// Validate identity labels before they are used to construct operator-state paths.
1270pub(crate) fn validate_path_identity(
1271    desired: &DesiredFleet,
1272    requested_fleet: &str,
1273) -> Result<(), EnsurePolicyError> {
1274    if desired.schema_version != FLEET_ENSURE_SCHEMA_VERSION {
1275        return Err(EnsurePolicyError::WrongSchema {
1276            actual: desired.schema_version,
1277            expected: FLEET_ENSURE_SCHEMA_VERSION,
1278        });
1279    }
1280    if desired.fleet != requested_fleet {
1281        return Err(EnsurePolicyError::FleetMismatch {
1282            actual: desired.fleet.clone(),
1283            expected: requested_fleet.to_string(),
1284        });
1285    }
1286    validate_path_labels(&desired.environment, requested_fleet)
1287}
1288
1289pub(crate) fn validate_path_labels(
1290    environment: &str,
1291    requested_fleet: &str,
1292) -> Result<(), EnsurePolicyError> {
1293    requested_fleet
1294        .parse::<FleetName>()
1295        .map_err(|_| EnsurePolicyError::UnsafePathLabel {
1296            field: "fleet",
1297            value: requested_fleet.to_string(),
1298        })?;
1299    if environment.is_empty()
1300        || environment.len() > 64
1301        || !environment
1302            .bytes()
1303            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
1304    {
1305        return Err(EnsurePolicyError::UnsafePathLabel {
1306            field: "environment",
1307            value: environment.to_string(),
1308        });
1309    }
1310    Ok(())
1311}
1312
1313#[expect(
1314    clippy::too_many_arguments,
1315    reason = "pure compiler receives one complete authority tuple"
1316)]
1317fn compile_canister(
1318    desired: &DesiredFleet,
1319    artifacts: &DesiredFleetArtifacts,
1320    configured: &crate::fleet_ensure::model::DesiredCanister,
1321    observed: Option<&LiveCanister>,
1322    observation: &FleetObservation,
1323    cycle_policy: CanisterCyclePolicy,
1324    bounds: CycleBounds,
1325    created_at_time: u64,
1326    force_reinstall: bool,
1327    accumulator: &mut PlanAccumulator,
1328) -> Result<CanisterPlan, EnsurePolicyError> {
1329    match (configured.presence, observed) {
1330        (DesiredPresence::Absent, None) => Ok(CanisterPlan {
1331            actions: Vec::new(),
1332            disposition: CanisterDisposition::Delete,
1333            name: configured.name.clone(),
1334            observed_cycles: 0,
1335            principal: configured.principal.clone(),
1336        }),
1337        (DesiredPresence::Absent, Some(live)) => retire_plan(
1338            desired,
1339            artifacts,
1340            configured,
1341            live,
1342            bounds,
1343            CanisterDisposition::Delete,
1344            accumulator,
1345        ),
1346        (DesiredPresence::Present, None) => {
1347            let fresh_pool = configured.kind == DesiredCanisterKind::Pool
1348                && desired
1349                    .bootstrap
1350                    .as_ref()
1351                    .is_some_and(|bootstrap| bootstrap.fresh_estate)
1352                && configured.principal.is_none();
1353            if configured.kind == DesiredCanisterKind::Pool && !fresh_pool {
1354                return Err(EnsurePolicyError::MissingPoolAsset {
1355                    name: configured.name.clone(),
1356                });
1357            }
1358            if configured.principal.is_some() {
1359                return Err(EnsurePolicyError::MissingObservation {
1360                    name: configured.name.clone(),
1361                });
1362            }
1363            create_plan(
1364                desired,
1365                artifacts,
1366                configured,
1367                cycle_policy,
1368                bounds,
1369                created_at_time,
1370                CanisterDisposition::Create,
1371                accumulator,
1372                None,
1373            )
1374        }
1375        (DesiredPresence::Present, Some(live))
1376            if configured.replace
1377                && configured.principal.as_deref() == Some(live.principal.as_str()) =>
1378        {
1379            create_plan(
1380                desired,
1381                artifacts,
1382                configured,
1383                cycle_policy,
1384                bounds,
1385                created_at_time,
1386                CanisterDisposition::Replace,
1387                accumulator,
1388                Some(live),
1389            )
1390        }
1391        (DesiredPresence::Present, Some(live)) => reuse_plan(
1392            desired,
1393            artifacts,
1394            configured,
1395            live,
1396            observation,
1397            cycle_policy,
1398            bounds,
1399            created_at_time,
1400            force_reinstall,
1401            accumulator,
1402        ),
1403    }
1404}
1405
1406#[expect(
1407    clippy::too_many_arguments,
1408    reason = "the pure create compiler receives the complete reviewed authority tuple"
1409)]
1410fn create_plan(
1411    desired: &DesiredFleet,
1412    artifacts: &DesiredFleetArtifacts,
1413    configured: &crate::fleet_ensure::model::DesiredCanister,
1414    cycle_policy: CanisterCyclePolicy,
1415    bounds: CycleBounds,
1416    created_at_time: u64,
1417    disposition: CanisterDisposition,
1418    accumulator: &mut PlanAccumulator,
1419    replaced: Option<&LiveCanister>,
1420) -> Result<CanisterPlan, EnsurePolicyError> {
1421    let temporary_pool_observation_controller = configured.kind == DesiredCanisterKind::Pool
1422        && desired
1423            .bootstrap
1424            .as_ref()
1425            .is_some_and(|bootstrap| bootstrap.fresh_estate)
1426        && configured.principal.is_none()
1427        && configured.controllers.is_empty()
1428        && configured.parent.as_ref().is_some_and(|parent| {
1429            configured.controller_canisters.as_slice() == std::slice::from_ref(parent)
1430        });
1431    if temporary_pool_observation_controller {
1432        validate_fresh_pool_creation_funding(&configured.name, cycle_policy, bounds)?;
1433    }
1434    // The first Create balance includes execution as well as observation.
1435    // Observation accounting is added independently for every planned action.
1436    accumulator.add_burn(bounds.update_burn)?;
1437    accumulator.add_funding(cycle_policy.initial_cycles)?;
1438    accumulator.add_fee(bounds.ledger_fee)?;
1439    accumulator.add_fee(bounds.management_creation_fee)?;
1440    let symbolic = format!("created:{}", configured.name);
1441    let mut creation_controllers = configured.controllers.clone();
1442    if temporary_pool_observation_controller {
1443        creation_controllers.push(desired.operator.clone());
1444        creation_controllers.sort();
1445        creation_controllers.dedup();
1446    }
1447    let mut actions = vec![EnsureAction::Create {
1448        controller_canisters: configured.controller_canisters.clone(),
1449        controllers: creation_controllers,
1450        created_at_time,
1451        ledger: desired.cycles_ledger.clone(),
1452        name: configured.name.clone(),
1453        requested_initial_cycles: cycle_policy.initial_cycles,
1454        subnet: configured.subnet.clone(),
1455    }];
1456    if let Some(wasm) = &configured.wasm {
1457        require_install_initializer(desired, configured)?;
1458        let wasm_sha256 = wasm_sha256(artifacts, &configured.name)?;
1459        actions.push(EnsureAction::Install {
1460            canic_init: configured.canic_init.clone(),
1461            reinstall_witness: None,
1462            init_arg: configured.init_arg.clone(),
1463            init_arg_sha256: optional_init_arg_sha256(artifacts, configured)?,
1464            init_candid: configured.init_candid.clone(),
1465            init_candid_sha256: optional_init_candid_sha256(artifacts, configured)?,
1466            mode: InstallMode::Install,
1467            name: configured.name.clone(),
1468            principal: symbolic.clone(),
1469            wasm: wasm.clone(),
1470            wasm_sha256,
1471        });
1472        accumulator.add_burn(bounds.update_burn)?;
1473    }
1474    if temporary_pool_observation_controller {
1475        actions.push(EnsureAction::SetControllers {
1476            controller_canisters: configured.controller_canisters.clone(),
1477            controllers: configured.controllers.clone(),
1478            name: configured.name.clone(),
1479            principal: symbolic,
1480        });
1481        accumulator.add_burn(bounds.update_burn)?;
1482    }
1483    if let Some(live) = replaced {
1484        append_retirement_actions(
1485            desired,
1486            artifacts,
1487            configured,
1488            live,
1489            bounds,
1490            &mut actions,
1491            accumulator,
1492        )?;
1493    }
1494    Ok(CanisterPlan {
1495        actions,
1496        disposition,
1497        name: configured.name.clone(),
1498        observed_cycles: replaced.map_or(0, |live| live.cycles),
1499        principal: replaced
1500            .map(|live| live.principal.clone())
1501            .or_else(|| configured.principal.clone()),
1502    })
1503}
1504
1505fn validate_fresh_pool_creation_funding(
1506    name: &str,
1507    cycle_policy: CanisterCyclePolicy,
1508    bounds: CycleBounds,
1509) -> Result<(), EnsurePolicyError> {
1510    let creation_burn = crate::fleet_ensure::model::creation_observation_burn(
1511        bounds.observation_burn,
1512        bounds.update_burn,
1513    )
1514    .ok_or(EnsurePolicyError::ArithmeticOverflow {
1515        field: "Create execution and observation burn",
1516    })?;
1517    let admissible_burn_cycles = checked_add(
1518        creation_burn,
1519        bounds.update_burn,
1520        "fresh pool pre-import burn",
1521    )?;
1522    let required_creation_funding_cycles = checked_add(
1523        cycle_policy.minimum_cycles,
1524        admissible_burn_cycles,
1525        "fresh pool creation funding",
1526    )?;
1527    if cycle_policy.initial_cycles < required_creation_funding_cycles {
1528        return Err(EnsurePolicyError::FreshPoolCreationFundingInsufficient {
1529            admissible_burn_cycles,
1530            creation_funding_cycles: cycle_policy.initial_cycles,
1531            name: name.to_string(),
1532            readiness_floor_cycles: cycle_policy.minimum_cycles,
1533            required_creation_funding_cycles,
1534            shortfall_cycles: required_creation_funding_cycles - cycle_policy.initial_cycles,
1535        });
1536    }
1537    Ok(())
1538}
1539
1540#[expect(
1541    clippy::too_many_arguments,
1542    reason = "the pure reuse compiler receives the complete reviewed authority tuple"
1543)]
1544fn reuse_plan(
1545    desired: &DesiredFleet,
1546    artifacts: &DesiredFleetArtifacts,
1547    configured: &crate::fleet_ensure::model::DesiredCanister,
1548    live: &LiveCanister,
1549    observation: &FleetObservation,
1550    cycle_policy: CanisterCyclePolicy,
1551    bounds: CycleBounds,
1552    created_at_time: u64,
1553    force_reinstall: bool,
1554    accumulator: &mut PlanAccumulator,
1555) -> Result<CanisterPlan, EnsurePolicyError> {
1556    accumulator.retained = checked_add(accumulator.retained, live.cycles, "retained cycles")?;
1557    let mut actions = Vec::new();
1558    if configured.kind == DesiredCanisterKind::Pool && live.root_owned_lifecycle.is_none() {
1559        return Err(EnsurePolicyError::MissingPoolLifecycle {
1560            name: configured.name.clone(),
1561        });
1562    }
1563    let active_pool_asset = configured.kind == DesiredCanisterKind::Pool
1564        && matches!(
1565            live.root_owned_lifecycle,
1566            Some(RootOwnedCanisterLifecycle::Claimed | RootOwnedCanisterLifecycle::Workload)
1567        );
1568    let retained_balance_evidence =
1569        live.root_owned_lifecycle == Some(RootOwnedCanisterLifecycle::Retained);
1570    let mut disposition = CanisterDisposition::Reuse;
1571    if let Some(wasm) = &configured.wasm {
1572        let wasm_sha256 = wasm_sha256(artifacts, &configured.name)?;
1573        if force_reinstall || live.module_sha256.as_deref() != Some(wasm_sha256.as_str()) {
1574            require_install_initializer(desired, configured)?;
1575            actions.push(EnsureAction::Install {
1576                canic_init: configured.canic_init.clone(),
1577                reinstall_witness: None,
1578                init_arg: configured.init_arg.clone(),
1579                init_arg_sha256: optional_init_arg_sha256(artifacts, configured)?,
1580                init_candid: configured.init_candid.clone(),
1581                init_candid_sha256: optional_init_candid_sha256(artifacts, configured)?,
1582                mode: if force_reinstall || live.module_sha256.is_some() {
1583                    InstallMode::Reinstall
1584                } else {
1585                    InstallMode::Install
1586                },
1587                name: configured.name.clone(),
1588                principal: live.principal.clone(),
1589                wasm: wasm.clone(),
1590                wasm_sha256,
1591            });
1592            accumulator.add_burn(bounds.update_burn)?;
1593            disposition = CanisterDisposition::Reinstall;
1594        }
1595    }
1596    if configured.kind != DesiredCanisterKind::Pool && live.status != CanisterRuntimeStatus::Running
1597    {
1598        actions.push(EnsureAction::Start {
1599            name: configured.name.clone(),
1600            principal: live.principal.clone(),
1601        });
1602        accumulator.add_burn(bounds.update_burn)?;
1603    }
1604    let mut actual_controllers = live.controllers.clone();
1605    actual_controllers.sort();
1606    let mut desired_controllers = resolved_controllers(configured, observation)?;
1607    desired_controllers.sort();
1608    if configured.kind != DesiredCanisterKind::Pool && actual_controllers != desired_controllers {
1609        actions.push(EnsureAction::SetControllers {
1610            controller_canisters: configured.controller_canisters.clone(),
1611            controllers: desired_controllers,
1612            name: configured.name.clone(),
1613            principal: live.principal.clone(),
1614        });
1615        accumulator.add_burn(bounds.update_burn)?;
1616    }
1617    append_target_funding(
1618        desired,
1619        configured,
1620        live,
1621        cycle_policy,
1622        bounds,
1623        created_at_time,
1624        active_pool_asset || retained_balance_evidence,
1625        &mut actions,
1626        accumulator,
1627    )?;
1628    Ok(CanisterPlan {
1629        actions,
1630        disposition,
1631        name: configured.name.clone(),
1632        observed_cycles: live.cycles,
1633        principal: Some(live.principal.clone()),
1634    })
1635}
1636
1637fn resolved_controllers(
1638    configured: &crate::fleet_ensure::model::DesiredCanister,
1639    observation: &FleetObservation,
1640) -> Result<Vec<String>, EnsurePolicyError> {
1641    let mut controllers = configured.controllers.clone();
1642    for controller in &configured.controller_canisters {
1643        let principal = observation
1644            .canisters
1645            .get(controller)
1646            .and_then(Option::as_ref)
1647            .map(|live| live.principal.clone())
1648            .ok_or_else(|| EnsurePolicyError::ControllerCanisterUnavailable {
1649                controller: controller.clone(),
1650                name: configured.name.clone(),
1651            })?;
1652        controllers.push(principal);
1653    }
1654    controllers.sort();
1655    controllers.dedup();
1656    Ok(controllers)
1657}
1658
1659#[expect(
1660    clippy::too_many_arguments,
1661    reason = "the funding step receives one complete target-local economic authority tuple"
1662)]
1663fn append_target_funding(
1664    desired: &DesiredFleet,
1665    configured: &crate::fleet_ensure::model::DesiredCanister,
1666    live: &LiveCanister,
1667    cycle_policy: CanisterCyclePolicy,
1668    bounds: CycleBounds,
1669    created_at_time: u64,
1670    funding_fenced: bool,
1671    actions: &mut Vec<EnsureAction>,
1672    accumulator: &mut PlanAccumulator,
1673) -> Result<(), EnsurePolicyError> {
1674    if funding_fenced || live.cycles >= cycle_policy.minimum_cycles {
1675        return Ok(());
1676    }
1677    let target_updates =
1678        u128::try_from(actions.len()).map_err(|_| EnsurePolicyError::ArithmeticOverflow {
1679            field: "target update count",
1680        })?;
1681    let update_margin = bounds.update_burn.checked_mul(target_updates).ok_or(
1682        EnsurePolicyError::ArithmeticOverflow {
1683            field: "target update margin",
1684        },
1685    )?;
1686    let target_margin = bounds.observation_burn.checked_add(update_margin).ok_or(
1687        EnsurePolicyError::ArithmeticOverflow {
1688            field: "target funding margin",
1689        },
1690    )?;
1691    let funding_deficit_cycles = cycle_policy.minimum_cycles - live.cycles;
1692    let amount = funding_deficit_cycles.checked_add(target_margin).ok_or(
1693        EnsurePolicyError::ArithmeticOverflow {
1694            field: "funding observation margin",
1695        },
1696    )?;
1697    let expected_post_cycles =
1698        live.cycles
1699            .checked_add(amount)
1700            .ok_or(EnsurePolicyError::ArithmeticOverflow {
1701                field: "funding post-balance",
1702            })?;
1703    actions.insert(
1704        0,
1705        EnsureAction::Fund {
1706            pool_funding: if configured.kind == DesiredCanisterKind::Pool {
1707                live.controllers
1708                    .first()
1709                    .cloned()
1710                    .map(|root| PoolFundingAuthority {
1711                        root,
1712                        lifecycle: EstatePoolAssetLifecycle::Ready,
1713                    })
1714            } else {
1715                None
1716            },
1717            amount,
1718            created_at_time,
1719            expected_post_cycles,
1720            funding_deficit_cycles,
1721            funding_margin_cycles: target_margin,
1722            ledger: desired.cycles_ledger.clone(),
1723            name: configured.name.clone(),
1724            principal: live.principal.clone(),
1725        },
1726    );
1727    accumulator.add_funding(amount)?;
1728    accumulator.add_fee(bounds.ledger_fee)
1729}
1730
1731fn reviewed_reinstall_canisters(
1732    desired: &DesiredFleet,
1733    observation: &FleetObservation,
1734) -> Result<BTreeSet<String>, EnsurePolicyError> {
1735    let mut reinstalls = BTreeSet::new();
1736    for configured in &desired.canisters {
1737        let required = observation
1738            .canisters
1739            .get(&configured.name)
1740            .and_then(Option::as_ref)
1741            .is_some_and(|live| live.reinstall_required);
1742        if !required {
1743            continue;
1744        }
1745        let exact_infrastructure = matches!(
1746            configured.kind,
1747            DesiredCanisterKind::Coordinator
1748                | DesiredCanisterKind::Root
1749                | DesiredCanisterKind::Store
1750        ) && configured.presence == DesiredPresence::Present;
1751        if !exact_infrastructure {
1752            return Err(EnsurePolicyError::InvalidInfrastructureReinstall {
1753                name: configured.name.clone(),
1754            });
1755        }
1756        reinstalls.insert(configured.name.clone());
1757    }
1758    Ok(reinstalls)
1759}
1760
1761fn retire_plan(
1762    desired: &DesiredFleet,
1763    artifacts: &DesiredFleetArtifacts,
1764    configured: &crate::fleet_ensure::model::DesiredCanister,
1765    live: &LiveCanister,
1766    bounds: CycleBounds,
1767    disposition: CanisterDisposition,
1768    accumulator: &mut PlanAccumulator,
1769) -> Result<CanisterPlan, EnsurePolicyError> {
1770    let mut actions = Vec::new();
1771    append_retirement_actions(
1772        desired,
1773        artifacts,
1774        configured,
1775        live,
1776        bounds,
1777        &mut actions,
1778        accumulator,
1779    )?;
1780    Ok(CanisterPlan {
1781        actions,
1782        disposition,
1783        name: configured.name.clone(),
1784        observed_cycles: live.cycles,
1785        principal: Some(live.principal.clone()),
1786    })
1787}
1788
1789fn append_retirement_actions(
1790    desired: &DesiredFleet,
1791    artifacts: &DesiredFleetArtifacts,
1792    configured: &crate::fleet_ensure::model::DesiredCanister,
1793    live: &LiveCanister,
1794    bounds: CycleBounds,
1795    actions: &mut Vec<EnsureAction>,
1796    accumulator: &mut PlanAccumulator,
1797) -> Result<(), EnsurePolicyError> {
1798    if live.cycles > bounds.material_threshold {
1799        let drain = configured
1800            .drain
1801            .as_ref()
1802            .ok_or_else(|| EnsurePolicyError::NoSafeDrain {
1803                name: configured.name.clone(),
1804                cycles: live.cycles,
1805            })?;
1806        if drain.destination != desired.treasury {
1807            return Err(EnsurePolicyError::WrongDrainDestination {
1808                name: configured.name.clone(),
1809            });
1810        }
1811        let maximum_execution_burn_cycles = parse_canister_cycles(
1812            &configured.name,
1813            "drain.maximum_execution_burn_cycles",
1814            &drain.maximum_execution_burn_cycles,
1815        )?;
1816        let reserved = checked_add(
1817            bounds.material_threshold,
1818            maximum_execution_burn_cycles,
1819            "retirement reserve",
1820        )?;
1821        let amount =
1822            live.cycles
1823                .checked_sub(reserved)
1824                .ok_or_else(|| EnsurePolicyError::NoSafeDrain {
1825                    name: configured.name.clone(),
1826                    cycles: live.cycles,
1827                })?;
1828        if amount == 0 {
1829            return Err(EnsurePolicyError::NoSafeDrain {
1830                name: configured.name.clone(),
1831                cycles: live.cycles,
1832            });
1833        }
1834        actions.push(EnsureAction::Transfer {
1835            amount,
1836            candid: drain.candid.clone(),
1837            candid_sha256: artifacts
1838                .drain_candid_sha256_by_canister
1839                .get(&configured.name)
1840                .cloned()
1841                .ok_or_else(|| EnsurePolicyError::MissingArtifactIdentity {
1842                    kind: "drain Candid",
1843                    name: configured.name.clone(),
1844                })?,
1845            destination: drain.destination.clone(),
1846            maximum_execution_burn_cycles,
1847            method: drain.method.clone(),
1848            name: configured.name.clone(),
1849            principal: live.principal.clone(),
1850        });
1851        accumulator.transfers = checked_add(accumulator.transfers, amount, "scheduled transfer")?;
1852        accumulator.add_burn(maximum_execution_burn_cycles)?;
1853    }
1854    actions.push(EnsureAction::Stop {
1855        name: configured.name.clone(),
1856        principal: live.principal.clone(),
1857    });
1858    actions.push(EnsureAction::Delete {
1859        maximum_remaining_cycles: bounds.material_threshold,
1860        name: configured.name.clone(),
1861        principal: live.principal.clone(),
1862    });
1863    accumulator.add_burn(bounds.material_threshold)?;
1864    accumulator.add_burn(bounds.update_burn)?;
1865    accumulator.add_burn(bounds.update_burn)?;
1866    Ok(())
1867}
1868
1869#[expect(
1870    clippy::too_many_lines,
1871    reason = "one pure validation boundary checks the complete desired Fleet authority"
1872)]
1873fn validate_authority(
1874    desired: &DesiredFleet,
1875    requested_fleet: &str,
1876) -> Result<(), EnsurePolicyError> {
1877    validate_path_identity(desired, requested_fleet)?;
1878    validate_funding_policy(desired)?;
1879    validate_terminal_pool_capacity(desired)?;
1880    if let Some(bootstrap) = &desired.bootstrap {
1881        for root in &bootstrap.roots {
1882            validate_bootstrap_root_pool_import_capacity(root)?;
1883        }
1884    }
1885    if desired.canisters.len() > MAX_FLEET_ENSURE_CANISTERS {
1886        return Err(EnsurePolicyError::TooManyCanisters {
1887            actual: desired.canisters.len(),
1888            maximum: MAX_FLEET_ENSURE_CANISTERS,
1889        });
1890    }
1891    if desired.protocol_steps.len() > MAX_FLEET_ENSURE_PROTOCOL_STEPS {
1892        return Err(EnsurePolicyError::TooManyProtocolSteps {
1893            actual: desired.protocol_steps.len(),
1894            maximum: MAX_FLEET_ENSURE_PROTOCOL_STEPS,
1895        });
1896    }
1897    if desired.maximum_stalled_observations == 0 {
1898        return Err(EnsurePolicyError::InvalidStallBound);
1899    }
1900    validate_principal("operator", &desired.operator)?;
1901    validate_principal("cycles_ledger", &desired.cycles_ledger)?;
1902    let mut names = BTreeSet::new();
1903    let mut principals = BTreeSet::new();
1904    for configured in &desired.canisters {
1905        if configured.canic_init.is_some()
1906            && (configured.init_arg.is_some() || configured.init_candid.is_some())
1907        {
1908            return Err(EnsurePolicyError::ConflictingCanicInitializer {
1909                name: configured.name.clone(),
1910            });
1911        }
1912        if configured.init_arg.is_some() != configured.init_candid.is_some() {
1913            return Err(EnsurePolicyError::IncompleteInitTemplate(
1914                configured.name.clone(),
1915            ));
1916        }
1917        if configured.name.is_empty()
1918            || configured
1919                .drain
1920                .as_ref()
1921                .is_some_and(|drain| drain.method.is_empty())
1922        {
1923            return Err(EnsurePolicyError::EmptyName);
1924        }
1925        validate_principal(
1926            &format!("canisters.{}.subnet", configured.name),
1927            &configured.subnet,
1928        )?;
1929        for controller in &configured.controllers {
1930            validate_principal(
1931                &format!("canisters.{}.controllers", configured.name),
1932                controller,
1933            )?;
1934        }
1935        for controller in &configured.controller_canisters {
1936            if controller.is_empty() {
1937                return Err(EnsurePolicyError::InvalidTopology {
1938                    name: configured.name.clone(),
1939                    reason: "controller canister name must not be empty",
1940                });
1941            }
1942        }
1943        if let Some(principal) = &configured.principal {
1944            validate_principal(
1945                &format!("canisters.{}.principal", configured.name),
1946                principal,
1947            )?;
1948        }
1949        let unique_name = names.insert(configured.name.as_str());
1950        let unique_principal = configured
1951            .principal
1952            .as_deref()
1953            .is_none_or(|principal| principals.insert(principal));
1954        if !unique_name || !unique_principal {
1955            return Err(EnsurePolicyError::DuplicateAuthority {
1956                name: configured.name.clone(),
1957            });
1958        }
1959        if configured.presence == DesiredPresence::Present
1960            && !configured.controllers.contains(&desired.operator)
1961            && configured.kind != DesiredCanisterKind::Pool
1962        {
1963            return Err(EnsurePolicyError::MissingOperatorController {
1964                name: configured.name.clone(),
1965            });
1966        }
1967        validate_canic_initializer(desired, configured)?;
1968        let fresh_pool = configured.kind == DesiredCanisterKind::Pool
1969            && desired
1970                .bootstrap
1971                .as_ref()
1972                .is_some_and(|bootstrap| bootstrap.fresh_estate)
1973            && configured.principal.is_none();
1974        if configured.kind == DesiredCanisterKind::Pool
1975            && (configured.presence != DesiredPresence::Present
1976                || (configured.principal.is_none() && !fresh_pool)
1977                || configured.replace
1978                || configured.wasm.is_some()
1979                || configured.drain.is_some()
1980                || configured.init_arg.is_some()
1981                || configured.init_candid.is_some()
1982                || configured.canic_init.is_some())
1983        {
1984            return Err(EnsurePolicyError::InvalidTopology {
1985                name: configured.name.clone(),
1986                reason: "pool assets must be exact present non-replaceable identities without independent runtime authority",
1987            });
1988        }
1989    }
1990    for (index, configured) in desired.canisters.iter().enumerate() {
1991        for controller in &configured.controller_canisters {
1992            let Some(controller_index) = desired
1993                .canisters
1994                .iter()
1995                .position(|candidate| candidate.name == *controller)
1996            else {
1997                return Err(EnsurePolicyError::ControllerCanisterUnavailable {
1998                    controller: controller.clone(),
1999                    name: configured.name.clone(),
2000                });
2001            };
2002            if controller_index >= index || controller == &configured.name {
2003                return Err(EnsurePolicyError::InvalidTopology {
2004                    name: configured.name.clone(),
2005                    reason: "controller canister must be a distinct earlier desired role",
2006                });
2007            }
2008        }
2009    }
2010    validate_typed_topology(desired)?;
2011    let mut protocol_names = BTreeSet::new();
2012    for step in &desired.protocol_steps {
2013        if step.name.is_empty()
2014            || step.command_method.is_empty()
2015            || step.status_method.is_empty()
2016            || !protocol_names.insert(step.name.as_str())
2017            || !desired.canisters.iter().any(|canister| {
2018                canister.name == step.canister && canister.presence == DesiredPresence::Present
2019            })
2020        {
2021            return Err(EnsurePolicyError::InvalidProtocolStep(step.name.clone()));
2022        }
2023    }
2024    if !desired.canisters.iter().any(|configured| {
2025        configured.presence == DesiredPresence::Present && configured.name == desired.treasury
2026    }) {
2027        return Err(EnsurePolicyError::MissingTreasury {
2028            treasury: desired.treasury.clone(),
2029        });
2030    }
2031    if desired
2032        .canisters
2033        .iter()
2034        .any(|configured| configured.name == desired.treasury && configured.replace)
2035    {
2036        return Err(EnsurePolicyError::TreasuryReplacement {
2037            treasury: desired.treasury.clone(),
2038        });
2039    }
2040    Ok(())
2041}
2042
2043/// Apply the runtime's protected funding invariants before reviewing any effects.
2044pub fn validate_funding_policy(desired: &DesiredFleet) -> Result<(), EnsurePolicyError> {
2045    let Some(bootstrap) = &desired.bootstrap else {
2046        return Ok(());
2047    };
2048    let ic_mainnet =
2049        bootstrap.canonical_network_id == canic_core::ids::CanonicalNetworkId::ic_mainnet();
2050    for root in &bootstrap.roots {
2051        validate_fleet_subnet_root_funding_authority(&root.funding, ic_mainnet)?;
2052    }
2053    if let Some(coordinator) = &bootstrap.root_funding {
2054        validate_coordinator_root_funding_policy(coordinator)?;
2055        validate_fleet_root_funding_capacity(
2056            coordinator,
2057            bootstrap.roots.iter().map(|root| &root.funding),
2058        )?;
2059    }
2060    Ok(())
2061}
2062
2063/// Prove terminal workload and reserve capacity before any external effect.
2064pub fn validate_terminal_pool_capacity(desired: &DesiredFleet) -> Result<(), EnsurePolicyError> {
2065    let Some(bootstrap) = &desired.bootstrap else {
2066        return Ok(());
2067    };
2068    if desired.protocol.is_none() {
2069        return Ok(());
2070    }
2071    for root in &bootstrap.roots {
2072        let workloads = desired_initial_root_workloads(desired, &root.root)?;
2073        let pool = &root.limits.canister_pool;
2074        terminal_pool_supply(&root.root, workloads, pool.minimum_size, pool.maximum_size)?;
2075    }
2076    Ok(())
2077}
2078
2079/// Require physical capacity for the complete Workload tree and the independent Ready reserve.
2080pub(super) fn terminal_pool_supply(
2081    root: &str,
2082    workloads: u32,
2083    ready_floor: u32,
2084    maximum_size: u32,
2085) -> Result<u32, EnsurePolicyError> {
2086    workloads
2087        .checked_add(ready_floor)
2088        .filter(|total| *total <= maximum_size)
2089        .ok_or_else(|| EnsurePolicyError::TerminalPoolCapacity {
2090            root: root.to_string(),
2091            workloads,
2092            ready_floor,
2093            maximum_size,
2094        })
2095}
2096
2097fn validate_bootstrap_root_pool_import_capacity(
2098    root: &crate::fleet_ensure::model::DesiredFleetBootstrapRoot,
2099) -> Result<(), EnsurePolicyError> {
2100    validate_root_pool_import_capacity(&RootPoolImportCapacityInput {
2101        import_count: root.canister_pool_imports.len(),
2102        maximum_size: root.limits.canister_pool.maximum_size,
2103        root: root.root.clone(),
2104    })?;
2105    Ok(())
2106}
2107
2108fn validate_canic_initializer(
2109    desired: &DesiredFleet,
2110    configured: &crate::fleet_ensure::model::DesiredCanister,
2111) -> Result<(), EnsurePolicyError> {
2112    use crate::fleet_ensure::model::DesiredCanisterInit;
2113
2114    let matches_kind = matches!(
2115        (&configured.kind, &configured.canic_init),
2116        (_, None)
2117            | (
2118                DesiredCanisterKind::Coordinator,
2119                Some(DesiredCanisterInit::Coordinator)
2120            )
2121            | (
2122                DesiredCanisterKind::Root,
2123                Some(DesiredCanisterInit::Root { .. })
2124            )
2125            | (
2126                DesiredCanisterKind::Store,
2127                Some(DesiredCanisterInit::Store { .. })
2128            )
2129    );
2130    let infrastructure_artifact = desired.protocol.is_some()
2131        && configured.wasm.is_some()
2132        && matches!(
2133            configured.kind,
2134            DesiredCanisterKind::Coordinator
2135                | DesiredCanisterKind::Root
2136                | DesiredCanisterKind::Store
2137        );
2138    if !matches_kind
2139        || (configured.canic_init.is_some() && desired.bootstrap.is_none())
2140        || (infrastructure_artifact && configured.canic_init.is_none())
2141    {
2142        return Err(EnsurePolicyError::MissingCanicInitializer {
2143            name: configured.name.clone(),
2144        });
2145    }
2146    if let (Some(bootstrap), Some(initializer)) = (&desired.bootstrap, &configured.canic_init) {
2147        let bound = match initializer {
2148            DesiredCanisterInit::Coordinator => configured.name == bootstrap.coordinator,
2149            DesiredCanisterInit::Root { root } => {
2150                root == &configured.name && bootstrap.roots.iter().any(|entry| entry.root == *root)
2151            }
2152            DesiredCanisterInit::Store { root } => bootstrap
2153                .roots
2154                .iter()
2155                .any(|entry| entry.root == *root && entry.store == configured.name),
2156        };
2157        if !bound {
2158            return Err(EnsurePolicyError::MissingCanicInitializer {
2159                name: configured.name.clone(),
2160            });
2161        }
2162    }
2163    Ok(())
2164}
2165
2166fn require_install_initializer(
2167    desired: &DesiredFleet,
2168    configured: &crate::fleet_ensure::model::DesiredCanister,
2169) -> Result<(), EnsurePolicyError> {
2170    if desired.protocol.is_some()
2171        && matches!(
2172            configured.kind,
2173            DesiredCanisterKind::Coordinator
2174                | DesiredCanisterKind::Root
2175                | DesiredCanisterKind::Store
2176        )
2177        && configured.canic_init.is_none()
2178    {
2179        return Err(EnsurePolicyError::MissingCanicInitializer {
2180            name: configured.name.clone(),
2181        });
2182    }
2183    Ok(())
2184}
2185
2186fn validate_typed_topology(desired: &DesiredFleet) -> Result<(), EnsurePolicyError> {
2187    let coordinators = desired
2188        .canisters
2189        .iter()
2190        .filter(|canister| {
2191            canister.presence == DesiredPresence::Present
2192                && canister.kind == DesiredCanisterKind::Coordinator
2193        })
2194        .collect::<Vec<_>>();
2195    let [coordinator] = coordinators.as_slice() else {
2196        return Err(EnsurePolicyError::InvalidTopology {
2197            name: desired.fleet.clone(),
2198            reason: "exactly one present Coordinator role is required",
2199        });
2200    };
2201    if coordinator.parent.is_some() {
2202        return Err(EnsurePolicyError::InvalidTopology {
2203            name: coordinator.name.clone(),
2204            reason: "Coordinator cannot have a topology parent",
2205        });
2206    }
2207    let has_managed_topology = desired.canisters.iter().any(|canister| {
2208        canister.presence == DesiredPresence::Present
2209            && matches!(
2210                canister.kind,
2211                DesiredCanisterKind::Root
2212                    | DesiredCanisterKind::Store
2213                    | DesiredCanisterKind::Component
2214            )
2215    });
2216    if has_managed_topology && desired.protocol.is_none() && desired.protocol_steps.is_empty() {
2217        return Err(EnsurePolicyError::InvalidTopology {
2218            name: desired.fleet.clone(),
2219            reason: "managed Fleet roles require complete typed protocol intent",
2220        });
2221    }
2222    for canister in desired
2223        .canisters
2224        .iter()
2225        .filter(|canister| canister.presence == DesiredPresence::Present)
2226    {
2227        let required_parent_kind = match canister.kind {
2228            DesiredCanisterKind::Root => Some(DesiredCanisterKind::Coordinator),
2229            DesiredCanisterKind::Store | DesiredCanisterKind::Component => {
2230                Some(DesiredCanisterKind::Root)
2231            }
2232            DesiredCanisterKind::Pool => Some(DesiredCanisterKind::Root),
2233            DesiredCanisterKind::Auxiliary | DesiredCanisterKind::Coordinator => None,
2234        };
2235        let Some(required_parent_kind) = required_parent_kind else {
2236            continue;
2237        };
2238        let Some(parent) = canister.parent.as_deref().and_then(|parent| {
2239            desired.canisters.iter().find(|candidate| {
2240                candidate.name == parent && candidate.presence == DesiredPresence::Present
2241            })
2242        }) else {
2243            return Err(EnsurePolicyError::InvalidTopology {
2244                name: canister.name.clone(),
2245                reason: "required present topology parent is missing",
2246            });
2247        };
2248        if parent.kind != required_parent_kind
2249            || (canister.kind == DesiredCanisterKind::Root && parent.name != coordinator.name)
2250        {
2251            return Err(EnsurePolicyError::InvalidTopology {
2252                name: canister.name.clone(),
2253                reason: "topology parent has the wrong Canic role",
2254            });
2255        }
2256    }
2257    Ok(())
2258}
2259
2260fn validate_principal(field: &str, value: &str) -> Result<(), EnsurePolicyError> {
2261    let principal =
2262        Principal::from_text(value).map_err(|_| EnsurePolicyError::InvalidPrincipal {
2263            field: field.to_string(),
2264            value: value.to_string(),
2265        })?;
2266    if principal == Principal::anonymous() {
2267        return Err(EnsurePolicyError::InvalidPrincipal {
2268            field: field.to_string(),
2269            value: value.to_string(),
2270        });
2271    }
2272    Ok(())
2273}
2274
2275fn validate_observation_authority(
2276    desired: &DesiredFleet,
2277    observation: &FleetObservation,
2278) -> Result<(), EnsurePolicyError> {
2279    let mut principals = BTreeSet::new();
2280    for configured in &desired.canisters {
2281        let Some(live) = observation
2282            .canisters
2283            .get(&configured.name)
2284            .ok_or_else(|| EnsurePolicyError::MissingObservation {
2285                name: configured.name.clone(),
2286            })?
2287            .as_ref()
2288        else {
2289            continue;
2290        };
2291        if !configured.replace
2292            && let Some(expected) = &configured.principal
2293            && expected != &live.principal
2294        {
2295            return Err(EnsurePolicyError::ObservationAuthorityMismatch {
2296                actual: live.principal.clone(),
2297                expected: expected.clone(),
2298                name: configured.name.clone(),
2299            });
2300        }
2301        if !principals.insert(live.principal.as_str()) {
2302            return Err(EnsurePolicyError::DuplicateAuthority {
2303                name: configured.name.clone(),
2304            });
2305        }
2306    }
2307    for principal in observation.additional_controlled_cycles.keys() {
2308        validate_principal("additional_controlled_cycles", principal)?;
2309        if !principals.insert(principal.as_str()) {
2310            return Err(EnsurePolicyError::DuplicateAuthority {
2311                name: principal.clone(),
2312            });
2313        }
2314    }
2315    Ok(())
2316}
2317
2318fn cycle_bounds(desired: &DesiredFleet) -> Result<CycleBounds, EnsurePolicyError> {
2319    Ok(CycleBounds {
2320        ledger_fee: parse_fleet_cycles("ledger_fee_cycles", &desired.ledger_fee_cycles)?,
2321        management_creation_fee: parse_fleet_cycles(
2322            "management_creation_fee_cycles",
2323            &desired.management_creation_fee_cycles,
2324        )?,
2325        material_threshold: parse_fleet_cycles(
2326            "material_cycle_threshold",
2327            &desired.material_cycle_threshold,
2328        )?,
2329        observation_burn: parse_fleet_cycles(
2330            "maximum_observation_burn_cycles",
2331            &desired.maximum_observation_burn_cycles,
2332        )?,
2333        update_burn: parse_fleet_cycles(
2334            "maximum_update_burn_cycles",
2335            &desired.maximum_update_burn_cycles,
2336        )?,
2337    })
2338}
2339
2340fn compile_estate_funding_domains(
2341    desired: &DesiredFleet,
2342    observation: &FleetObservation,
2343    bounds: CycleBounds,
2344    defer_pool_creation: bool,
2345) -> Result<Vec<EstateFundingDomainPlan>, EnsurePolicyError> {
2346    let Some(bootstrap) = desired.bootstrap.as_ref() else {
2347        if observation.estate_funding_domains.is_empty() {
2348            return Ok(Vec::new());
2349        }
2350        return Err(EnsurePolicyError::EstateFundingTopology {
2351            reason: "observed Root funding accounts without bootstrap authority".to_string(),
2352        });
2353    };
2354    if observation.estate_funding_domains.len() != bootstrap.roots.len() {
2355        return Err(EnsurePolicyError::EstateFundingTopology {
2356            reason: "observed Root funding-account set differs from bootstrap authority"
2357                .to_string(),
2358        });
2359    }
2360
2361    let mut domains = Vec::with_capacity(bootstrap.roots.len());
2362    for root in &bootstrap.roots {
2363        domains.push(compile_estate_funding_domain(
2364            desired,
2365            root,
2366            observation,
2367            bounds,
2368            defer_pool_creation,
2369        )?);
2370    }
2371    Ok(domains)
2372}
2373
2374fn compile_estate_funding_domain(
2375    desired: &DesiredFleet,
2376    root: &DesiredFleetBootstrapRoot,
2377    observation: &FleetObservation,
2378    bounds: CycleBounds,
2379    defer_pool_creation: bool,
2380) -> Result<EstateFundingDomainPlan, EnsurePolicyError> {
2381    let observed = observation
2382        .estate_funding_domains
2383        .get(&root.root)
2384        .ok_or_else(|| EnsurePolicyError::EstateFundingTopology {
2385            reason: format!("Root {} has no funding-account observation", root.root),
2386        })?;
2387    validate_estate_funding_owner(desired, observation, root, observed)?;
2388    let asset_cycles = root.limits.canister_pool.canister_cycles.to_u128();
2389    let pool_forecast = required_root_pool_creations(
2390        desired,
2391        &root.root,
2392        root.limits.canister_pool.minimum_size,
2393        asset_cycles,
2394        bounds.management_creation_fee,
2395        observation,
2396        defer_pool_creation,
2397    )?;
2398    let creation_execution_margin_cycles = root
2399        .limits
2400        .canister_pool
2401        .creation_execution_margin
2402        .to_u128();
2403    if creation_execution_margin_cycles == 0 {
2404        return Err(EnsurePolicyError::EstateFundingTopology {
2405            reason: format!(
2406                "Root {} has no autonomous-creation execution margin",
2407                root.root
2408            ),
2409        });
2410    }
2411    let creation_amount_cycles = asset_cycles
2412        .checked_add(creation_execution_margin_cycles)
2413        .ok_or(EnsurePolicyError::ArithmeticOverflow {
2414            field: "estate creation margin",
2415        })?
2416        .checked_add(bounds.management_creation_fee)
2417        .ok_or(EnsurePolicyError::ArithmeticOverflow {
2418            field: "estate creation amount",
2419        })?;
2420    let debit_per_creation = creation_amount_cycles
2421        .checked_add(bounds.ledger_fee)
2422        .ok_or(EnsurePolicyError::ArithmeticOverflow {
2423            field: "estate creation debit",
2424        })?;
2425    let fee_per_creation = bounds
2426        .management_creation_fee
2427        .checked_add(bounds.ledger_fee)
2428        .ok_or(EnsurePolicyError::ArithmeticOverflow {
2429            field: "estate creation fees",
2430        })?;
2431    let count = u128::from(pool_forecast.required_creation_count);
2432    let maximum_creation_debit_cycles =
2433        debit_per_creation
2434            .checked_mul(count)
2435            .ok_or(EnsurePolicyError::ArithmeticOverflow {
2436                field: "estate creation debit",
2437            })?;
2438    let maximum_creation_fee_cycles =
2439        fee_per_creation
2440            .checked_mul(count)
2441            .ok_or(EnsurePolicyError::ArithmeticOverflow {
2442                field: "estate creation fees",
2443            })?;
2444    let available_cycles = observed.balance_cycles;
2445    let shortfall_cycles =
2446        maximum_creation_debit_cycles.saturating_sub(available_cycles.unwrap_or_default());
2447    let initial_pool_assets = initial_pool_asset_authority(desired, root, observed);
2448    Ok(EstateFundingDomainPlan {
2449        allocated_workloads: pool_forecast.allocated_workloads,
2450        available_cycles,
2451        available_pool_slots: pool_forecast.available_slots,
2452        creation_amount_cycles,
2453        creation_execution_margin_cycles,
2454        readiness_floor_cycles: asset_cycles,
2455        cycles_ledger: observed.cycles_ledger.clone(),
2456        eligible_ready_pool_assets: pool_forecast.eligible_ready_assets,
2457        initial_pool_assets,
2458        ledger_fee_cycles: bounds.ledger_fee,
2459        management_creation_fee_cycles: bounds.management_creation_fee,
2460        maximum_creation_debit_cycles,
2461        maximum_creation_fee_cycles,
2462        maximum_funding_cycles: shortfall_cycles,
2463        occupied_pool_assets: pool_forecast.occupied_assets,
2464        pending_creation_count: pool_forecast.pending_creations,
2465        pending_creation: pool_forecast.pending_creation,
2466        planned_initial_workloads: pool_forecast.planned_workloads,
2467        pool_maximum_size: pool_forecast.maximum_size,
2468        required_creation_count: pool_forecast.required_creation_count,
2469        root: root.root.clone(),
2470        root_principal: observed.root_principal.clone(),
2471        shortfall_cycles,
2472    })
2473}
2474
2475fn initial_pool_asset_authority(
2476    desired: &DesiredFleet,
2477    root: &DesiredFleetBootstrapRoot,
2478    observed: &crate::fleet_ensure::model::EstateFundingDomainObservation,
2479) -> Vec<String> {
2480    let mut assets = desired
2481        .canisters
2482        .iter()
2483        .filter(|canister| {
2484            canister.kind == DesiredCanisterKind::Pool
2485                && canister.presence == DesiredPresence::Present
2486                && canister.parent.as_deref() == Some(&root.root)
2487        })
2488        .map(|canister| {
2489            canister
2490                .principal
2491                .clone()
2492                .unwrap_or_else(|| format!("created:{}", canister.name))
2493        })
2494        .collect::<BTreeSet<_>>();
2495    if let Some(pool) = observed.pool.as_ref() {
2496        assets.extend(pool.assets.iter().map(|asset| asset.principal.clone()));
2497        if let Some(created) = pool
2498            .pending_creation
2499            .as_ref()
2500            .and_then(|pending| pending.created_principal.clone())
2501        {
2502            assets.insert(created);
2503        }
2504    }
2505    assets.into_iter().collect()
2506}
2507
2508fn validate_estate_funding_owner(
2509    desired: &DesiredFleet,
2510    observation: &FleetObservation,
2511    root: &DesiredFleetBootstrapRoot,
2512    observed: &crate::fleet_ensure::model::EstateFundingDomainObservation,
2513) -> Result<(), EnsurePolicyError> {
2514    if observed.cycles_ledger != desired.cycles_ledger {
2515        return Err(EnsurePolicyError::EstateFundingTopology {
2516            reason: format!("Root {} funding-account Ledger differs", root.root),
2517        });
2518    }
2519    let Some(principal) = observed.root_principal.as_deref() else {
2520        return Ok(());
2521    };
2522    validate_principal("estate_funding_domains.root_principal", principal)?;
2523    let configured = desired
2524        .canisters
2525        .iter()
2526        .find(|canister| canister.name == root.root)
2527        .ok_or_else(|| EnsurePolicyError::EstateFundingTopology {
2528            reason: format!("Root {} is absent from desired canisters", root.root),
2529        })?;
2530    let live = observation
2531        .canisters
2532        .get(&root.root)
2533        .and_then(Option::as_ref);
2534    if live.is_some_and(|live| live.principal != principal)
2535        || configured
2536            .principal
2537            .as_deref()
2538            .is_some_and(|expected| expected != principal)
2539    {
2540        return Err(EnsurePolicyError::EstateFundingTopology {
2541            reason: format!("Root {} funding-account owner differs", root.root),
2542        });
2543    }
2544    Ok(())
2545}
2546
2547fn required_root_pool_creations(
2548    desired: &DesiredFleet,
2549    root: &str,
2550    ready_floor: u32,
2551    required_asset_cycles: u128,
2552    management_creation_fee: u128,
2553    observation: &FleetObservation,
2554    defer_pool_creation: bool,
2555) -> Result<RootPoolCreationForecast, EnsurePolicyError> {
2556    let workload_count = desired_initial_root_workloads(desired, root)?;
2557    let desired_pool = desired_root_pool_policy(desired, root)?;
2558    let domain = observation
2559        .estate_funding_domains
2560        .get(root)
2561        .ok_or_else(|| EnsurePolicyError::EstateFundingTopology {
2562            reason: format!("Root {root} has no funding-account observation"),
2563        })?;
2564    if let Some(pool) = domain.pool.as_ref() {
2565        if defer_pool_creation {
2566            let pending = pending_pool_creation_forecast(
2567                root,
2568                pool,
2569                desired_pool,
2570                required_asset_cycles,
2571                management_creation_fee,
2572            )?;
2573            let counts = observed_pool_counts(pool, pending.unmaterialized, required_asset_cycles)?;
2574            if counts.occupied_assets > desired_pool.maximum_size {
2575                return Err(EnsurePolicyError::EstatePoolCapacity {
2576                    allocated_workloads: counts.allocated_workloads,
2577                    available_slots: 0,
2578                    capacity_shortfall: counts.occupied_assets - desired_pool.maximum_size,
2579                    eligible_ready_assets: counts.eligible_ready_assets,
2580                    maximum_size: desired_pool.maximum_size,
2581                    occupied_assets: counts.occupied_assets,
2582                    pending_creations: pending.unmaterialized,
2583                    required_creation_count: 0,
2584                    root: root.to_string(),
2585                });
2586            }
2587            // Infrastructure and import reconciliation restore owned capacity first.
2588            // Observe real Ready receipts before reviewing creation or Ledger-account funding.
2589            return Ok(RootPoolCreationForecast {
2590                allocated_workloads: counts.allocated_workloads,
2591                available_slots: desired_pool.maximum_size - counts.occupied_assets,
2592                eligible_ready_assets: counts.eligible_ready_assets,
2593                maximum_size: desired_pool.maximum_size,
2594                occupied_assets: counts.occupied_assets,
2595                pending_creations: pending.unmaterialized,
2596                pending_creation: pool.pending_creation.clone(),
2597                planned_workloads: workload_count,
2598                required_creation_count: 0,
2599            });
2600        }
2601        return forecast_observed_root_pool(
2602            root,
2603            pool,
2604            desired_pool,
2605            workload_count,
2606            ready_floor,
2607            required_asset_cycles,
2608            management_creation_fee,
2609        );
2610    }
2611    forecast_configured_root_pool(
2612        desired,
2613        observation,
2614        root,
2615        workload_count,
2616        ready_floor,
2617        required_asset_cycles,
2618        desired_pool.maximum_size,
2619    )
2620}
2621
2622fn desired_root_pool_policy<'a>(
2623    desired: &'a DesiredFleet,
2624    root: &str,
2625) -> Result<&'a FleetSubnetCanisterPoolConfig, EnsurePolicyError> {
2626    desired
2627        .bootstrap
2628        .as_ref()
2629        .and_then(|bootstrap| {
2630            bootstrap
2631                .roots
2632                .iter()
2633                .find(|candidate| candidate.root == root)
2634        })
2635        .map(|root| &root.limits.canister_pool)
2636        .ok_or_else(|| EnsurePolicyError::EstateFundingTopology {
2637            reason: format!("Root {root} has no desired pool authority"),
2638        })
2639}
2640
2641fn forecast_configured_root_pool(
2642    desired: &DesiredFleet,
2643    observation: &FleetObservation,
2644    root: &str,
2645    workload_count: u32,
2646    _ready_floor: u32,
2647    required_asset_cycles: u128,
2648    maximum_size: u32,
2649) -> Result<RootPoolCreationForecast, EnsurePolicyError> {
2650    let (reusable_ready_assets, completed_workloads) = desired
2651        .canisters
2652        .iter()
2653        .filter(|canister| {
2654            canister.kind == DesiredCanisterKind::Pool
2655                && canister.parent.as_deref() == Some(root)
2656                && canister.presence == DesiredPresence::Present
2657        })
2658        .try_fold(
2659            (0_u32, 0_u32),
2660            |(ready, workload), canister| -> Result<(u32, u32), EnsurePolicyError> {
2661                let live = observation
2662                    .canisters
2663                    .get(&canister.name)
2664                    .and_then(Option::as_ref);
2665                match live.map(|live| (live.root_owned_lifecycle, live.cycles)) {
2666                    Some((Some(RootOwnedCanisterLifecycle::Idle), cycles))
2667                        if cycles >= required_asset_cycles =>
2668                    {
2669                        Ok((
2670                            ready
2671                                .checked_add(1)
2672                                .ok_or(EnsurePolicyError::ArithmeticOverflow {
2673                                    field: "reusable Ready-pool asset count",
2674                                })?,
2675                            workload,
2676                        ))
2677                    }
2678                    Some((
2679                        Some(
2680                            RootOwnedCanisterLifecycle::Claimed
2681                            | RootOwnedCanisterLifecycle::Workload,
2682                        ),
2683                        _,
2684                    )) => Ok((
2685                        ready,
2686                        workload
2687                            .checked_add(1)
2688                            .ok_or(EnsurePolicyError::ArithmeticOverflow {
2689                                field: "completed Root workload count",
2690                            })?,
2691                    )),
2692                    _ => Ok((ready, workload)),
2693                }
2694            },
2695        )?;
2696    // Infrastructure prerequisites cannot promise Root readiness. Replan from protected
2697    // inventory after installation and reviewed import reconciliation before estate funding.
2698    let required_creation_count = 0;
2699    let occupied_assets = reusable_ready_assets
2700        .checked_add(completed_workloads)
2701        .ok_or(EnsurePolicyError::ArithmeticOverflow {
2702            field: "configured Root pool asset count",
2703        })?;
2704    Ok(RootPoolCreationForecast {
2705        allocated_workloads: completed_workloads,
2706        available_slots: maximum_size.saturating_sub(occupied_assets),
2707        eligible_ready_assets: reusable_ready_assets,
2708        maximum_size,
2709        occupied_assets,
2710        pending_creations: 0,
2711        pending_creation: None,
2712        planned_workloads: workload_count,
2713        required_creation_count,
2714    })
2715}
2716
2717fn forecast_observed_root_pool(
2718    root: &str,
2719    pool: &crate::fleet_ensure::model::EstatePoolInventoryObservation,
2720    desired_pool: &FleetSubnetCanisterPoolConfig,
2721    workload_count: u32,
2722    ready_floor: u32,
2723    required_asset_cycles: u128,
2724    management_creation_fee: u128,
2725) -> Result<RootPoolCreationForecast, EnsurePolicyError> {
2726    let pending = pending_pool_creation_forecast(
2727        root,
2728        pool,
2729        desired_pool,
2730        required_asset_cycles,
2731        management_creation_fee,
2732    )?;
2733    let counts = observed_pool_counts(pool, pending.unmaterialized, required_asset_cycles)?;
2734    let available_supply = counts
2735        .eligible_ready_assets
2736        .checked_add(pending.unmaterialized)
2737        .ok_or(EnsurePolicyError::ArithmeticOverflow {
2738            field: "available Root pool supply",
2739        })?;
2740    let required_additional_creations = required_estate_creation_count(
2741        workload_count,
2742        ready_floor,
2743        available_supply,
2744        counts.allocated_workloads,
2745    )?;
2746    let required_creation_count = required_additional_creations
2747        .checked_add(pending.debit)
2748        .ok_or(EnsurePolicyError::ArithmeticOverflow {
2749            field: "required Root pool creation debit count",
2750        })?;
2751    let available_slots = desired_pool
2752        .maximum_size
2753        .saturating_sub(counts.occupied_assets);
2754    let inventory_exceeds_capacity = counts.occupied_assets > desired_pool.maximum_size;
2755    let demand_exceeds_capacity = required_additional_creations > available_slots;
2756    if inventory_exceeds_capacity || demand_exceeds_capacity {
2757        return Err(EnsurePolicyError::EstatePoolCapacity {
2758            allocated_workloads: counts.allocated_workloads,
2759            available_slots,
2760            capacity_shortfall: required_additional_creations.saturating_sub(available_slots),
2761            eligible_ready_assets: counts.eligible_ready_assets,
2762            maximum_size: desired_pool.maximum_size,
2763            occupied_assets: counts.occupied_assets,
2764            pending_creations: pending.unmaterialized,
2765            required_creation_count: required_additional_creations,
2766            root: root.to_string(),
2767        });
2768    }
2769    Ok(RootPoolCreationForecast {
2770        allocated_workloads: counts.allocated_workloads,
2771        available_slots,
2772        eligible_ready_assets: counts.eligible_ready_assets,
2773        maximum_size: desired_pool.maximum_size,
2774        occupied_assets: counts.occupied_assets,
2775        pending_creations: pending.unmaterialized,
2776        pending_creation: pool.pending_creation.clone(),
2777        planned_workloads: workload_count,
2778        required_creation_count,
2779    })
2780}
2781
2782#[derive(Clone, Copy)]
2783struct PendingPoolCreationForecast {
2784    debit: u32,
2785    unmaterialized: u32,
2786}
2787
2788fn pending_pool_creation_forecast(
2789    root: &str,
2790    pool: &crate::fleet_ensure::model::EstatePoolInventoryObservation,
2791    desired: &FleetSubnetCanisterPoolConfig,
2792    required_asset_cycles: u128,
2793    management_creation_fee: u128,
2794) -> Result<PendingPoolCreationForecast, EnsurePolicyError> {
2795    let Some(pending) = pool.pending_creation.as_ref() else {
2796        return Ok(PendingPoolCreationForecast {
2797            debit: 0,
2798            unmaterialized: 0,
2799        });
2800    };
2801    if pending.uncertain_result {
2802        return Err(EnsurePolicyError::EstateFundingTopology {
2803            reason: format!(
2804                "Root {root} has an unresolved creation response; reconcile that exact operation before funding"
2805            ),
2806        });
2807    }
2808    let expected_amount = required_asset_cycles
2809        .checked_add(desired.creation_execution_margin.to_u128())
2810        .and_then(|amount| amount.checked_add(management_creation_fee))
2811        .ok_or(EnsurePolicyError::ArithmeticOverflow {
2812            field: "pending estate creation amount",
2813        })?;
2814    if pending.creation_amount_cycles != expected_amount {
2815        return Err(EnsurePolicyError::EstateFundingTopology {
2816            reason: format!(
2817                "Root {root} pending creation amount {} differs from current authority {expected_amount}",
2818                pending.creation_amount_cycles
2819            ),
2820        });
2821    }
2822    let materialized = pending.created_principal.as_ref().is_some_and(|principal| {
2823        pool.assets
2824            .iter()
2825            .any(|asset| &asset.principal == principal)
2826    });
2827    Ok(PendingPoolCreationForecast {
2828        debit: u32::from(pending.created_principal.is_none()),
2829        unmaterialized: u32::from(!materialized),
2830    })
2831}
2832
2833#[derive(Clone, Copy)]
2834struct ObservedPoolCounts {
2835    allocated_workloads: u32,
2836    eligible_ready_assets: u32,
2837    occupied_assets: u32,
2838}
2839
2840fn observed_pool_counts(
2841    pool: &crate::fleet_ensure::model::EstatePoolInventoryObservation,
2842    pending_unmaterialized: u32,
2843    required_asset_cycles: u128,
2844) -> Result<ObservedPoolCounts, EnsurePolicyError> {
2845    let bounded_count = |count, field| {
2846        u32::try_from(count).map_err(|_| EnsurePolicyError::ArithmeticOverflow { field })
2847    };
2848    let asset_count = bounded_count(pool.assets.len(), "observed Root pool asset count")?;
2849    let occupied_assets = asset_count.checked_add(pending_unmaterialized).ok_or(
2850        EnsurePolicyError::ArithmeticOverflow {
2851            field: "observed Root pool capacity",
2852        },
2853    )?;
2854    let eligible_ready_assets = bounded_count(
2855        pool.assets
2856            .iter()
2857            .filter(|asset| {
2858                asset.lifecycle == EstatePoolAssetLifecycle::Ready
2859                    && asset.cycles >= required_asset_cycles
2860            })
2861            .count(),
2862        "eligible Ready-pool asset count",
2863    )?;
2864    let allocated_workloads = bounded_count(
2865        pool.assets
2866            .iter()
2867            .filter(|asset| {
2868                matches!(
2869                    asset.lifecycle,
2870                    EstatePoolAssetLifecycle::Claimed | EstatePoolAssetLifecycle::Workload
2871                )
2872            })
2873            .count(),
2874        "allocated Root workload count",
2875    )?;
2876    Ok(ObservedPoolCounts {
2877        allocated_workloads,
2878        eligible_ready_assets,
2879        occupied_assets,
2880    })
2881}
2882
2883#[derive(Clone, Debug, Eq, PartialEq)]
2884struct RootPoolCreationForecast {
2885    allocated_workloads: u32,
2886    available_slots: u32,
2887    eligible_ready_assets: u32,
2888    maximum_size: u32,
2889    occupied_assets: u32,
2890    pending_creations: u32,
2891    pending_creation: Option<crate::fleet_ensure::model::EstatePoolPendingCreationObservation>,
2892    planned_workloads: u32,
2893    required_creation_count: u32,
2894}
2895
2896fn desired_initial_root_workloads(
2897    desired: &DesiredFleet,
2898    root: &str,
2899) -> Result<u32, EnsurePolicyError> {
2900    let bootstrap = desired
2901        .bootstrap
2902        .as_ref()
2903        .expect("estate funding is compiled only with bootstrap authority");
2904    let protocol =
2905        desired
2906            .protocol
2907            .as_ref()
2908            .ok_or_else(|| EnsurePolicyError::EstateFundingTopology {
2909                reason: "estate workload forecast requires typed Fleet protocol".to_string(),
2910            })?;
2911    initial_workload_count(
2912        &bootstrap.component_deployment_configuration,
2913        protocol
2914            .component_group_placements
2915            .iter()
2916            .filter(|placement| placement.root == root)
2917            .map(|placement| placement.deployment.as_str()),
2918        root,
2919    )
2920}
2921
2922/// Count every initial top-level and descendant Workload from the shared compiled topology.
2923pub(super) fn initial_workload_count<'a>(
2924    configuration: &canic_core::control_plane_support::config::ComponentDeploymentConfiguration,
2925    mut deployments: impl Iterator<Item = &'a str>,
2926    root: &str,
2927) -> Result<u32, EnsurePolicyError> {
2928    deployments.try_fold(0_u32, |total, deployment_name| {
2929        let deployment = configuration
2930            .deployment_topology
2931            .component_group_deployments
2932            .iter()
2933            .find(|deployment| deployment.deployment.as_str() == deployment_name)
2934            .ok_or_else(|| EnsurePolicyError::EstateFundingTopology {
2935                reason: format!(
2936                    "Root {root} placement references unknown deployment {deployment_name}"
2937                ),
2938            })?;
2939        deployment.members.iter().try_fold(total, |total, member| {
2940            let spec = configuration
2941                .component_topology
2942                .component_specs
2943                .iter()
2944                .find(|spec| spec.component_spec == member.component_spec)
2945                .ok_or_else(|| EnsurePolicyError::EstateFundingTopology {
2946                    reason: format!(
2947                        "Root {root} deployment references unknown Component Spec {}",
2948                        member.component_spec
2949                    ),
2950                })?;
2951            total.checked_add(initial_role_tree_size(spec)?).ok_or(
2952                EnsurePolicyError::ArithmeticOverflow {
2953                    field: "estate initial workload count",
2954                },
2955            )
2956        })
2957    })
2958}
2959
2960fn required_estate_creation_count(
2961    workload_count: u32,
2962    ready_floor: u32,
2963    reusable_ready_assets: u32,
2964    completed_workloads: u32,
2965) -> Result<u32, EnsurePolicyError> {
2966    workload_count
2967        .saturating_sub(completed_workloads)
2968        .checked_add(ready_floor)
2969        .map(|required_assets| required_assets.saturating_sub(reusable_ready_assets))
2970        .ok_or(EnsurePolicyError::ArithmeticOverflow {
2971            field: "estate creation count",
2972        })
2973}
2974
2975fn initial_role_tree_size(
2976    spec: &canic_core::control_plane_support::config::ComponentSpec,
2977) -> Result<u32, EnsurePolicyError> {
2978    let mut instances = BTreeMap::from([(spec.component_role.clone(), 1_u32)]);
2979    let mut total = 0_u32;
2980    for _ in 0..=spec.spawn_grants.len() {
2981        let mut advanced = false;
2982        for grant in &spec.spawn_grants {
2983            if instances.contains_key(&grant.child_role) {
2984                continue;
2985            }
2986            let Some(parent_instances) = instances.get(&grant.parent_role).copied() else {
2987                continue;
2988            };
2989            let child_instances = parent_instances
2990                .checked_mul(grant.initial_instances_per_parent)
2991                .ok_or(EnsurePolicyError::ArithmeticOverflow {
2992                    field: "estate initial child count",
2993                })?;
2994            instances.insert(grant.child_role.clone(), child_instances);
2995            advanced = true;
2996        }
2997        if !advanced {
2998            total = instances.values().try_fold(0_u32, |sum, count| {
2999                sum.checked_add(*count)
3000                    .ok_or(EnsurePolicyError::ArithmeticOverflow {
3001                        field: "estate initial workload count",
3002                    })
3003            })?;
3004            break;
3005        }
3006    }
3007    if total == 0 {
3008        return Err(EnsurePolicyError::EstateFundingTopology {
3009            reason: format!(
3010                "Component Spec {} initial child graph did not converge",
3011                spec.component_spec
3012            ),
3013        });
3014    }
3015    Ok(total)
3016}
3017
3018fn tranche_protocol_actions(
3019    desired: &DesiredFleet,
3020    observation: &FleetObservation,
3021    bounds: CycleBounds,
3022    observed_estate_cycles: u128,
3023    accumulator: &mut PlanAccumulator,
3024    protocol_actions: &mut Vec<EnsureAction>,
3025) -> Result<(), EnsurePolicyError> {
3026    let candidates = std::mem::take(protocol_actions);
3027    let mut selected = Vec::with_capacity(candidates.len());
3028    for action in candidates {
3029        let execution_burn = accumulator.execution_burn;
3030        let transfers = accumulator.transfers;
3031
3032        account_protocol_action(accumulator, &action)?;
3033        selected.push(action);
3034
3035        let observation_count = maximum_observation_count(
3036            desired,
3037            &accumulator.canisters,
3038            &selected,
3039            observation.additional_controlled_cycles.len(),
3040        )?;
3041        let observation_burn = bounds
3042            .observation_burn
3043            .checked_mul(observation_count)
3044            .ok_or(EnsurePolicyError::ArithmeticOverflow {
3045                field: "observation burn",
3046            })?;
3047        let required = checked_add(
3048            accumulator.execution_burn,
3049            observation_burn,
3050            "cycle-conservation required burn",
3051        )?;
3052        let available = checked_add(
3053            observed_estate_cycles,
3054            accumulator.new_funding,
3055            "cycle-conservation available balance",
3056        )?;
3057        if required > available {
3058            let error =
3059                insufficient_cycle_conservation(accumulator, selected.len(), available, required);
3060            accumulator.execution_burn = execution_burn;
3061            accumulator.transfers = transfers;
3062            selected.pop();
3063            if selected.is_empty() {
3064                return Err(error);
3065            }
3066            break;
3067        }
3068    }
3069    *protocol_actions = selected;
3070    Ok(())
3071}
3072
3073fn account_protocol_action(
3074    accumulator: &mut PlanAccumulator,
3075    action: &EnsureAction,
3076) -> Result<(), EnsurePolicyError> {
3077    match action {
3078        EnsureAction::FleetProtocol {
3079            maximum_execution_burn_cycles,
3080            ..
3081        }
3082        | EnsureAction::Protocol {
3083            maximum_execution_burn_cycles,
3084            ..
3085        } => accumulator.add_burn(*maximum_execution_burn_cycles),
3086        action => Err(EnsurePolicyError::InvalidProtocolStep(
3087            action.name().to_string(),
3088        )),
3089    }
3090}
3091
3092fn insufficient_cycle_conservation(
3093    accumulator: &PlanAccumulator,
3094    protocol_action_count: usize,
3095    available: u128,
3096    required: u128,
3097) -> EnsurePolicyError {
3098    let canister_action_count = accumulator
3099        .canisters
3100        .iter()
3101        .map(|canister| canister.actions.len())
3102        .sum::<usize>();
3103    EnsurePolicyError::InsufficientCycleConservation {
3104        action_count: canister_action_count.saturating_add(protocol_action_count),
3105        available,
3106        required,
3107        shortfall: required.saturating_sub(available),
3108    }
3109}
3110
3111fn maximum_observation_count(
3112    desired: &DesiredFleet,
3113    canisters: &[CanisterPlan],
3114    protocol_actions: &[EnsureAction],
3115    additional_controlled_canisters: usize,
3116) -> Result<u128, EnsurePolicyError> {
3117    let initially_present = u128::try_from(
3118        canisters
3119            .iter()
3120            .filter(|canister| canister.principal.is_some())
3121            .count(),
3122    )
3123    .map_err(|_| EnsurePolicyError::ArithmeticOverflow {
3124        field: "initial observation count",
3125    })?;
3126    let terminal_present = u128::try_from(
3127        desired
3128            .canisters
3129            .iter()
3130            .filter(|canister| canister.presence == DesiredPresence::Present)
3131            .count(),
3132    )
3133    .map_err(|_| EnsurePolicyError::ArithmeticOverflow {
3134        field: "terminal observation count",
3135    })?;
3136    let action_observations = canisters
3137        .iter()
3138        .flat_map(|canister| &canister.actions)
3139        .try_fold(0_u128, |total, action| {
3140            let count = match action {
3141                EnsureAction::Create { .. } => 1,
3142                EnsureAction::Delete { .. } => 4,
3143                EnsureAction::Fund { .. }
3144                | EnsureAction::FundEstate { .. }
3145                | EnsureAction::Transfer { .. } => 2,
3146                EnsureAction::SealAuthority { .. }
3147                | EnsureAction::Install { .. }
3148                | EnsureAction::FleetProtocol { .. }
3149                | EnsureAction::Protocol { .. }
3150                | EnsureAction::SetControllers { .. }
3151                | EnsureAction::Start { .. }
3152                | EnsureAction::Stop { .. } => 3,
3153            };
3154            checked_add(total, count, "effect observation count")
3155        })?;
3156    let protocol_observations = u128::try_from(protocol_actions.len())
3157        .map_err(|_| EnsurePolicyError::ArithmeticOverflow {
3158            field: "protocol observation count",
3159        })?
3160        .checked_mul(3)
3161        .ok_or(EnsurePolicyError::ArithmeticOverflow {
3162            field: "protocol observation count",
3163        })?;
3164    let terminal_protocol_observations = terminal_protocol_observation_bound(
3165        desired,
3166        protocol_actions,
3167        additional_controlled_canisters,
3168    )?;
3169    let stalled_observations = canisters
3170        .iter()
3171        .flat_map(|canister| &canister.actions)
3172        .chain(protocol_actions.iter())
3173        .filter(|action| {
3174            matches!(action, EnsureAction::FleetProtocol { action, .. }
3175            if matches!(action.as_ref(), CurrentFleetProtocolAction::ProvisionComponents { .. }))
3176        })
3177        .try_fold(desired.maximum_stalled_observations, |bound, action| {
3178            effect_observation_policy(desired, action)
3179                .map(|policy| bound.max(policy.maximum_stalled_observations))
3180        })?;
3181    initially_present
3182        .checked_mul(2)
3183        .and_then(|value| value.checked_add(terminal_present))
3184        .and_then(|value| value.checked_add(action_observations))
3185        .and_then(|value| value.checked_add(protocol_observations))
3186        .and_then(|value| value.checked_add(terminal_protocol_observations))
3187        .and_then(|value| value.checked_add(u128::from(stalled_observations)))
3188        .ok_or(EnsurePolicyError::ArithmeticOverflow {
3189            field: "maximum observation count",
3190        })
3191}
3192
3193/// Exact conservative reservation for a protocol-only successor over fresh known identities.
3194pub(super) fn successor_phase_burn(
3195    desired: &DesiredFleet,
3196    plan: &FleetEnsurePlan,
3197) -> Result<u128, EnsurePolicyError> {
3198    let bounds = cycle_bounds(desired)?;
3199    let observations =
3200        maximum_observation_count(desired, &plan.canisters, &plan.protocol_actions, 0)?;
3201    let mut total = bounds.observation_burn.checked_mul(observations).ok_or(
3202        EnsurePolicyError::ArithmeticOverflow {
3203            field: "successor observation burn",
3204        },
3205    )?;
3206    for action in &plan.protocol_actions {
3207        let EnsureAction::FleetProtocol {
3208            maximum_execution_burn_cycles,
3209            ..
3210        } = action
3211        else {
3212            return Err(EnsurePolicyError::InvalidProtocolStep(
3213                action.name().to_string(),
3214            ));
3215        };
3216        total = checked_add(
3217            total,
3218            *maximum_execution_burn_cycles,
3219            "successor execution burn",
3220        )?;
3221    }
3222    Ok(total)
3223}
3224
3225fn terminal_protocol_observation_bound(
3226    desired: &DesiredFleet,
3227    protocol_actions: &[EnsureAction],
3228    additional_controlled_canisters: usize,
3229) -> Result<u128, EnsurePolicyError> {
3230    if desired.protocol.is_none() {
3231        return Ok(0);
3232    }
3233    let configured = u128::try_from(desired.canisters.len()).map_err(|_| {
3234        EnsurePolicyError::ArithmeticOverflow {
3235            field: "terminal protocol observation count",
3236        }
3237    })?;
3238    let additional = u128::try_from(additional_controlled_canisters).map_err(|_| {
3239        EnsurePolicyError::ArithmeticOverflow {
3240            field: "terminal protocol observation count",
3241        }
3242    })?;
3243    let observed_bound = configured
3244        .checked_mul(4)
3245        .and_then(|count| {
3246            additional
3247                .checked_mul(3)
3248                .and_then(|extra| count.checked_add(extra))
3249        })
3250        .and_then(|count| count.checked_add(4))
3251        .ok_or(EnsurePolicyError::ArithmeticOverflow {
3252            field: "terminal protocol observation count",
3253        })?;
3254    let planned_bound = protocol_actions.iter().try_fold(0_u128, |bound, action| {
3255        let EnsureAction::FleetProtocol {
3256            action: current, ..
3257        } = action
3258        else {
3259            return Ok(bound);
3260        };
3261        let crate::fleet_ensure::model::CurrentFleetProtocolAction::ProvisionComponents {
3262            request,
3263            ..
3264        } = current.as_ref()
3265        else {
3266            return Ok(bound);
3267        };
3268        component_provisioning_observation_bound(&request.plan.batches)
3269            .map(|current_bound| bound.max(current_bound))
3270    })?;
3271    Ok(observed_bound.max(planned_bound))
3272}
3273
3274/// Pure pending-observation policy for one exact reviewed effect.
3275pub(super) struct EffectObservationPolicy {
3276    pub maximum_stalled_observations: u32,
3277    pub paced: bool,
3278}
3279
3280/// Bound command-driven creation and lost-response reconciliation within one reviewed action.
3281pub(super) const fn pool_maintenance_update_bound(minimum_ready: u32) -> u32 {
3282    minimum_ready.saturating_mul(2).saturating_add(16)
3283}
3284
3285/// Keep ordinary effects on the configured bound while giving the one
3286/// long-running protocol operation a topology-derived, globally bounded lane.
3287pub(super) fn effect_observation_policy(
3288    desired: &DesiredFleet,
3289    action: &EnsureAction,
3290) -> Result<EffectObservationPolicy, EnsurePolicyError> {
3291    let EnsureAction::FleetProtocol {
3292        action: current, ..
3293    } = action
3294    else {
3295        return Ok(EffectObservationPolicy {
3296            maximum_stalled_observations: desired.maximum_stalled_observations,
3297            paced: false,
3298        });
3299    };
3300    if matches!(
3301        current.as_ref(),
3302        CurrentFleetProtocolAction::MaintainPoolReadiness { .. }
3303            | CurrentFleetProtocolAction::ObservePoolReadiness { .. }
3304    ) {
3305        return Ok(EffectObservationPolicy {
3306            maximum_stalled_observations: desired
3307                .maximum_stalled_observations
3308                .max(MAXIMUM_PACED_PROTOCOL_STALL_OBSERVATIONS),
3309            paced: true,
3310        });
3311    }
3312    let crate::fleet_ensure::model::CurrentFleetProtocolAction::ProvisionComponents {
3313        request, ..
3314    } = current.as_ref()
3315    else {
3316        return Ok(EffectObservationPolicy {
3317            maximum_stalled_observations: desired.maximum_stalled_observations,
3318            paced: false,
3319        });
3320    };
3321    let topology_bound = component_provisioning_stall_bound(desired, &request.plan.batches)?;
3322    Ok(EffectObservationPolicy {
3323        maximum_stalled_observations: paced_protocol_stall_limit(
3324            desired.maximum_stalled_observations,
3325            topology_bound,
3326        ),
3327        paced: true,
3328    })
3329}
3330
3331const MAXIMUM_PACED_PROTOCOL_STALL_OBSERVATIONS: u32 = 64;
3332
3333fn paced_protocol_stall_limit(configured: u32, topology_bound: u128) -> u32 {
3334    let topology_bound = u32::try_from(topology_bound)
3335        .unwrap_or(MAXIMUM_PACED_PROTOCOL_STALL_OBSERVATIONS)
3336        .min(MAXIMUM_PACED_PROTOCOL_STALL_OBSERVATIONS);
3337    configured.max(topology_bound)
3338}
3339
3340fn component_provisioning_stall_bound(
3341    desired: &DesiredFleet,
3342    batches: &[canic_core::dto::component_provisioning::FleetSubnetRootProvisioningBatch],
3343) -> Result<u128, EnsurePolicyError> {
3344    let base = component_provisioning_observation_bound(batches)?;
3345    if batches.iter().all(|batch| {
3346        batch
3347            .placements
3348            .iter()
3349            .all(|placement| placement.entries.is_empty())
3350    }) {
3351        return Ok(base);
3352    }
3353    let bootstrap =
3354        desired
3355            .bootstrap
3356            .as_ref()
3357            .ok_or_else(|| EnsurePolicyError::EstateFundingTopology {
3358                reason: "Component provisioning has no compiled bootstrap configuration"
3359                    .to_string(),
3360            })?;
3361    let specs = &bootstrap
3362        .component_deployment_configuration
3363        .component_topology
3364        .component_specs;
3365    let entries = batches
3366        .iter()
3367        .flat_map(|batch| &batch.placements)
3368        .flat_map(|placement| &placement.entries)
3369        .map(|entry| (&entry.component_spec, &entry.spec_hash));
3370    checked_add(
3371        base,
3372        initial_child_observation_bound(specs, entries)?,
3373        "Component observation count",
3374    )
3375}
3376
3377fn initial_child_observation_bound<'a>(
3378    specs: &[canic_core::control_plane_support::config::ComponentSpec],
3379    entries: impl IntoIterator<Item = (&'a canic_core::ids::ComponentSpecId, &'a [u8; 32])>,
3380) -> Result<u128, EnsurePolicyError> {
3381    entries.into_iter().try_fold(0, |bound, (id, hash)| {
3382        let spec = specs
3383            .iter()
3384            .find(|spec| &spec.component_spec == id && &spec.spec_hash == hash)
3385            .ok_or_else(|| EnsurePolicyError::EstateFundingTopology {
3386                reason: format!("provisioning Component Spec {id} differs from compiled authority"),
3387            })?;
3388        let children = initial_role_tree_size(spec)?.saturating_sub(1);
3389        checked_add(
3390            bound,
3391            u128::from(children) * 3,
3392            "initial child observation count",
3393        )
3394    })
3395}
3396
3397fn component_provisioning_observation_bound(
3398    batches: &[canic_core::dto::component_provisioning::FleetSubnetRootProvisioningBatch],
3399) -> Result<u128, EnsurePolicyError> {
3400    let root_count =
3401        u128::try_from(batches.len()).map_err(|_| EnsurePolicyError::ArithmeticOverflow {
3402            field: "terminal protocol observation count",
3403        })?;
3404    let component_count = batches.iter().try_fold(0_u128, |total, batch| {
3405        batch
3406            .placements
3407            .iter()
3408            .try_fold(total, |subtotal, placement| {
3409                u128::try_from(placement.entries.len())
3410                    .ok()
3411                    .and_then(|count| subtotal.checked_add(count))
3412                    .ok_or(EnsurePolicyError::ArithmeticOverflow {
3413                        field: "terminal protocol observation count",
3414                    })
3415            })
3416    })?;
3417    component_provisioning_observation_bound_from_counts(root_count, component_count)
3418}
3419
3420fn component_provisioning_observation_bound_from_counts(
3421    root_count: u128,
3422    component_count: u128,
3423) -> Result<u128, EnsurePolicyError> {
3424    let per_component = terminal_initial_component_observation_count(0);
3425    root_count
3426        .checked_mul(5)
3427        .and_then(|root_observations| {
3428            component_count
3429                .checked_mul(per_component)
3430                .and_then(|component_observations| {
3431                    root_observations.checked_add(component_observations)
3432                })
3433        })
3434        .and_then(|total| total.checked_add(1))
3435        .ok_or(EnsurePolicyError::ArithmeticOverflow {
3436            field: "terminal protocol observation count",
3437        })
3438}
3439
3440const fn terminal_initial_component_observation_count(_future_descendant_capacity: u32) -> u128 {
3441    // The fresh-estate terminal walk observes only the provisioned top-level
3442    // Component here. Future descendant capacity bounds later pagination; it
3443    // does not multiply the initial Component proof.
3444    3
3445}
3446
3447fn canister_cycle_policy(
3448    configured: &crate::fleet_ensure::model::DesiredCanister,
3449) -> Result<CanisterCyclePolicy, EnsurePolicyError> {
3450    let policy = CanisterCyclePolicy {
3451        initial_cycles: parse_canister_cycles(
3452            &configured.name,
3453            "initial_cycles",
3454            &configured.initial_cycles,
3455        )?,
3456        minimum_cycles: parse_canister_cycles(
3457            &configured.name,
3458            "minimum_cycles",
3459            &configured.minimum_cycles,
3460        )?,
3461    };
3462    if configured.presence == DesiredPresence::Present
3463        && policy.initial_cycles < policy.minimum_cycles
3464    {
3465        return Err(EnsurePolicyError::InitialCyclesBelowMinimum {
3466            name: configured.name.clone(),
3467        });
3468    }
3469    Ok(policy)
3470}
3471
3472fn parse_fleet_cycles(field: &'static str, value: &str) -> Result<u128, EnsurePolicyError> {
3473    value
3474        .parse::<Cycles>()
3475        .map(|cycles| cycles.to_u128())
3476        .map_err(|_| EnsurePolicyError::InvalidFleetCycles {
3477            field,
3478            value: value.to_string(),
3479        })
3480}
3481
3482fn parse_canister_cycles(
3483    name: &str,
3484    field: &'static str,
3485    value: &str,
3486) -> Result<u128, EnsurePolicyError> {
3487    value
3488        .parse::<Cycles>()
3489        .map(|cycles| cycles.to_u128())
3490        .map_err(|_| EnsurePolicyError::InvalidCanisterCycles {
3491            field,
3492            name: name.to_string(),
3493            value: value.to_string(),
3494        })
3495}
3496
3497fn checked_add(left: u128, right: u128, field: &'static str) -> Result<u128, EnsurePolicyError> {
3498    left.checked_add(right)
3499        .ok_or(EnsurePolicyError::ArithmeticOverflow { field })
3500}
3501
3502fn wasm_sha256(artifacts: &DesiredFleetArtifacts, name: &str) -> Result<String, EnsurePolicyError> {
3503    artifacts
3504        .wasm_sha256_by_canister
3505        .get(name)
3506        .cloned()
3507        .ok_or_else(|| EnsurePolicyError::MissingWasmIdentity {
3508            name: name.to_string(),
3509        })
3510}
3511
3512fn optional_init_arg_sha256(
3513    artifacts: &DesiredFleetArtifacts,
3514    configured: &crate::fleet_ensure::model::DesiredCanister,
3515) -> Result<Option<String>, EnsurePolicyError> {
3516    configured
3517        .init_arg
3518        .as_ref()
3519        .map(|_| {
3520            artifacts
3521                .init_arg_sha256_by_canister
3522                .get(&configured.name)
3523                .cloned()
3524                .ok_or_else(|| EnsurePolicyError::MissingArtifactIdentity {
3525                    kind: "init argument",
3526                    name: configured.name.clone(),
3527                })
3528        })
3529        .transpose()
3530}
3531
3532fn optional_init_candid_sha256(
3533    artifacts: &DesiredFleetArtifacts,
3534    configured: &crate::fleet_ensure::model::DesiredCanister,
3535) -> Result<Option<String>, EnsurePolicyError> {
3536    configured
3537        .init_candid
3538        .as_ref()
3539        .map(|_| {
3540            artifacts
3541                .init_candid_sha256_by_canister
3542                .get(&configured.name)
3543                .cloned()
3544                .ok_or_else(|| EnsurePolicyError::MissingArtifactIdentity {
3545                    kind: "init Candid",
3546                    name: configured.name.clone(),
3547                })
3548        })
3549        .transpose()
3550}
3551
3552pub(crate) fn operation_id(desired_sha256: &str, environment: &str, fleet: &str) -> String {
3553    let mut hasher = Sha256::new();
3554    hash_field(&mut hasher, b"canic:fleet-ensure:operation:v1");
3555    hash_field(&mut hasher, desired_sha256.as_bytes());
3556    hash_field(&mut hasher, environment.as_bytes());
3557    hash_field(&mut hasher, fleet.as_bytes());
3558    canic_core::cdk::utils::hash::hex_bytes(hasher.finalize())
3559}
3560
3561/// Recompute the canonical review identity of a current Fleet plan.
3562/// This does not persist or authorize the plan.
3563///
3564/// # Panics
3565/// Panics if a maintained plan field cannot be encoded by its canonical JSON serializer.
3566#[must_use]
3567pub fn expected_plan_sha256(plan: &FleetEnsurePlan) -> String {
3568    let mut canonical = plan.clone();
3569    canonical.plan_sha256.clear();
3570    let bytes = super::json::to_vec(&canonical).expect("Fleet ensure plan is JSON serializable");
3571    let mut hasher = Sha256::new();
3572    hash_field(&mut hasher, b"canic:fleet-ensure:plan:v1");
3573    hash_field(&mut hasher, &bytes);
3574    canic_core::cdk::utils::hash::hex_bytes(hasher.finalize())
3575}
3576
3577fn hash_field(hasher: &mut Sha256, value: &[u8]) {
3578    hasher.update(u64::try_from(value.len()).unwrap_or(u64::MAX).to_be_bytes());
3579    hasher.update(value);
3580}
3581
3582#[cfg(test)]
3583mod tests {
3584    use super::{
3585        CurrentFleetProtocolAction, CycleBounds, DesiredFleet, FLEET_ENSURE_SCHEMA_VERSION,
3586        FleetObservation, append_pool_reconciliation_funding,
3587    };
3588    use std::collections::BTreeMap;
3589
3590    use super::{
3591        EnsurePolicyError, PlanAccumulator, append_estate_funding_actions,
3592        component_provisioning_observation_bound_from_counts, forecast_observed_root_pool,
3593        initial_role_tree_size, paced_protocol_stall_limit, required_estate_creation_count,
3594        terminal_initial_component_observation_count, validate_bootstrap_root_pool_import_capacity,
3595    };
3596    use crate::fleet_ensure::model::{
3597        CanisterDisposition, CanisterPlan, DesiredFleetBootstrapRoot, EnsureAction,
3598        EstateFundingDomainPlan, EstatePoolAssetLifecycle, EstatePoolAssetObservation,
3599        EstatePoolAssetOrigin, EstatePoolInventoryObservation,
3600        EstatePoolPendingCreationObservation, PoolFundingAuthority,
3601    };
3602    use candid::Principal;
3603    use canic_core::{
3604        cdk::types::Cycles,
3605        control_plane_support::config::{ComponentLimits, ComponentSpawnGrant, ComponentSpec},
3606        ids::{
3607            CanisterRole, ComponentSpecId, ComponentTopologyDigest, CyclesFundingBudget,
3608            FleetSubnetCanisterPoolConfig, FleetSubnetRootLimits, SubnetId,
3609        },
3610    };
3611
3612    #[test]
3613    fn continuation_review_reserves_only_available_headroom_and_exposes_pending_discovery() {
3614        let observation = FleetObservation {
3615            additional_controlled_cycles: BTreeMap::new(),
3616            canisters: BTreeMap::new(),
3617            estate_funding_domains: BTreeMap::new(),
3618            ledger_fee_cycles: 0,
3619            operator_cycles: 0,
3620            protocol_ready: BTreeMap::new(),
3621        };
3622        let bounds = CycleBounds {
3623            observation_burn: 3,
3624            update_burn: 5,
3625            ledger_fee: 1,
3626            management_creation_fee: 0,
3627            material_threshold: 1,
3628        };
3629        let review = super::recovery::review(&observation, bounds, 32, 41, 369).unwrap();
3630        assert_eq!(review.base_execution_burn_cycles, 41);
3631        assert_eq!(review.whole_continuation_ceiling_cycles, 448);
3632        assert_eq!(review.continuation_reserve_cycles, 328);
3633        assert!(review.known_pool_funding.is_empty());
3634        assert_eq!(
3635            review.discovery,
3636            crate::fleet_ensure::model::RecoveryDiscovery::PendingCurrentProtocol
3637        );
3638        let no_headroom = super::recovery::review(&observation, bounds, 32, 41, 40).unwrap();
3639        assert_eq!(no_headroom.continuation_reserve_cycles, 0);
3640    }
3641
3642    #[test]
3643    fn estate_workload_forecast_includes_recursive_initial_children() {
3644        let mut spec = ComponentSpec {
3645            component_spec: ComponentSpecId::try_from(String::from("hub"))
3646                .expect("hub Component Spec ID"),
3647            spec_hash: [1; 32],
3648            component_role: CanisterRole::from("hub"),
3649            maximum_fleet_instances: 1,
3650            limits: ComponentLimits {
3651                maximum_descendants: 8,
3652                maximum_registry_bytes: 1,
3653                cycles_funding: CyclesFundingBudget {
3654                    window_secs: 1,
3655                    maximum_cycles: Cycles::new(1),
3656                },
3657            },
3658            children: Vec::new(),
3659            spawn_grants: vec![
3660                ComponentSpawnGrant {
3661                    parent_role: CanisterRole::from("hub"),
3662                    child_role: CanisterRole::from("shard"),
3663                    initial_instances_per_parent: 2,
3664                    maximum_instances_per_parent: 2,
3665                },
3666                ComponentSpawnGrant {
3667                    parent_role: CanisterRole::from("shard"),
3668                    child_role: CanisterRole::from("leaf"),
3669                    initial_instances_per_parent: 3,
3670                    maximum_instances_per_parent: 3,
3671                },
3672            ],
3673        };
3674
3675        assert_eq!(initial_role_tree_size(&spec), Ok(9));
3676        for capacity in [8, 10_000, u32::MAX] {
3677            spec.limits.maximum_descendants = capacity;
3678            let children = super::initial_child_observation_bound(
3679                std::slice::from_ref(&spec),
3680                [(&spec.component_spec, &spec.spec_hash); 2],
3681            )
3682            .unwrap();
3683            assert_eq!(children, 48);
3684            let base = component_provisioning_observation_bound_from_counts(1, 2).unwrap();
3685            assert_eq!(paced_protocol_stall_limit(8, base + children), 60);
3686        }
3687        assert!(matches!(
3688            super::initial_child_observation_bound(
3689                std::slice::from_ref(&spec),
3690                [(&spec.component_spec, &[2; 32])]
3691            ),
3692            Err(EnsurePolicyError::EstateFundingTopology { .. })
3693        ));
3694    }
3695
3696    #[test]
3697    fn estate_creation_forecast_retains_ready_floor_without_repeating_workloads() {
3698        assert_eq!(required_estate_creation_count(8, 10, 10, 0), Ok(8));
3699        assert_eq!(required_estate_creation_count(8, 10, 10, 8), Ok(0));
3700        assert_eq!(required_estate_creation_count(8, 2, 2, 0), Ok(8));
3701        assert_eq!(required_estate_creation_count(8, 2, 10, 0), Ok(0));
3702    }
3703
3704    #[test]
3705    fn estate_shortfall_compiles_one_exact_plan_owned_ledger_transfer() {
3706        let mut accumulator = PlanAccumulator::new();
3707        accumulator.canisters.push(CanisterPlan {
3708            actions: Vec::new(),
3709            disposition: CanisterDisposition::Reuse,
3710            name: "root-0".to_string(),
3711            observed_cycles: 30,
3712            principal: Some("rrkah-fqaaa-aaaaa-aaaaq-cai".to_string()),
3713        });
3714        let domain = EstateFundingDomainPlan {
3715            allocated_workloads: 1,
3716            available_cycles: Some(40),
3717            available_pool_slots: 1,
3718            creation_amount_cycles: 45,
3719            creation_execution_margin_cycles: 5,
3720            readiness_floor_cycles: 35,
3721            cycles_ledger: "aaaaa-aa".to_string(),
3722            eligible_ready_pool_assets: 1,
3723            initial_pool_assets: vec!["existing-pool".to_string()],
3724            ledger_fee_cycles: 5,
3725            management_creation_fee_cycles: 5,
3726            maximum_creation_debit_cycles: 100,
3727            maximum_creation_fee_cycles: 20,
3728            maximum_funding_cycles: 60,
3729            occupied_pool_assets: 1,
3730            pending_creation_count: 0,
3731            pending_creation: None,
3732            planned_initial_workloads: 2,
3733            pool_maximum_size: 2,
3734            required_creation_count: 2,
3735            root: "root-0".to_string(),
3736            root_principal: Some("rrkah-fqaaa-aaaaa-aaaaq-cai".to_string()),
3737            shortfall_cycles: 60,
3738        };
3739
3740        append_estate_funding_actions(&[domain], 100, 4, &mut accumulator)
3741            .expect("compile exact estate funding action");
3742
3743        assert_eq!(accumulator.new_funding, 60);
3744        assert_eq!(accumulator.fees, 5);
3745        assert!(matches!(
3746            accumulator.canisters[0].actions.as_slice(),
3747            [EnsureAction::FundEstate {
3748                amount: 60,
3749                created_at_time: 104,
3750                expected_post_cycles: 100,
3751                ledger,
3752                ledger_fee_cycles: 5,
3753                name,
3754                principal,
3755            }] if ledger == "aaaaa-aa"
3756                && name == "root-0"
3757                && principal == "rrkah-fqaaa-aaaaa-aaaaq-cai"
3758        ));
3759    }
3760
3761    #[test]
3762    fn complete_pool_inventory_owns_creation_capacity_forecast() {
3763        let policy = pool_policy(4, 8);
3764        let exhausted = observed_pool([
3765            EstatePoolAssetLifecycle::Workload,
3766            EstatePoolAssetLifecycle::Workload,
3767            EstatePoolAssetLifecycle::Workload,
3768            EstatePoolAssetLifecycle::Workload,
3769            EstatePoolAssetLifecycle::Failed,
3770            EstatePoolAssetLifecycle::Failed,
3771            EstatePoolAssetLifecycle::Failed,
3772            EstatePoolAssetLifecycle::Failed,
3773        ]);
3774        assert!(matches!(
3775            forecast_observed_root_pool("root", &exhausted, &policy, 4, 4, 1_900, 500),
3776            Err(EnsurePolicyError::EstatePoolCapacity {
3777                allocated_workloads: 4,
3778                available_slots: 0,
3779                capacity_shortfall: 4,
3780                eligible_ready_assets: 0,
3781                maximum_size: 8,
3782                occupied_assets: 8,
3783                pending_creations: 0,
3784                required_creation_count: 4,
3785                ..
3786            })
3787        ));
3788
3789        let recoverable = observed_pool([
3790            EstatePoolAssetLifecycle::Workload,
3791            EstatePoolAssetLifecycle::Workload,
3792            EstatePoolAssetLifecycle::Workload,
3793            EstatePoolAssetLifecycle::Workload,
3794            EstatePoolAssetLifecycle::Ready,
3795            EstatePoolAssetLifecycle::Ready,
3796        ]);
3797        let forecast = forecast_observed_root_pool("root", &recoverable, &policy, 4, 4, 1_900, 500)
3798            .expect("two free slots can restore the four-Ready floor");
3799        assert_eq!(forecast.occupied_assets, 6);
3800        assert_eq!(forecast.eligible_ready_assets, 2);
3801        assert_eq!(forecast.required_creation_count, 2);
3802        assert_eq!(forecast.available_slots, 2);
3803    }
3804
3805    #[test]
3806    fn every_retained_pool_lifecycle_and_pending_creation_consumes_capacity_once() {
3807        let policy = pool_policy(2, 10);
3808        let mut observed = observed_pool([
3809            EstatePoolAssetLifecycle::Claimed,
3810            EstatePoolAssetLifecycle::Failed,
3811            EstatePoolAssetLifecycle::HandingOff,
3812            EstatePoolAssetLifecycle::PendingReset,
3813            EstatePoolAssetLifecycle::Ready,
3814            EstatePoolAssetLifecycle::Recycling,
3815            EstatePoolAssetLifecycle::Workload,
3816        ]);
3817        observed.minimum_size = 2;
3818        observed.maximum_size = 10;
3819        observed.pending_creation = Some(EstatePoolPendingCreationObservation {
3820            attempt_count: 1,
3821            available_cycles: None,
3822            creation_amount_cycles: 2_500,
3823            created_principal: None,
3824            diagnostic: None,
3825            last_attempt_at_ns: Some(1),
3826            operation_id: "11".repeat(32),
3827            required_cycles: None,
3828            retry_at_ns: Some(2),
3829            shortfall_cycles: Some(2_500),
3830            uncertain_result: false,
3831        });
3832        let forecast = forecast_observed_root_pool("root", &observed, &policy, 2, 2, 1_900, 500)
3833            .expect("all bounded lifecycles fit with one free slot");
3834        assert_eq!(forecast.occupied_assets, 8);
3835        assert_eq!(forecast.allocated_workloads, 2);
3836        assert_eq!(forecast.eligible_ready_assets, 1);
3837        assert_eq!(forecast.pending_creations, 1);
3838        assert_eq!(forecast.required_creation_count, 1);
3839
3840        observed.maximum_size = 9;
3841        let drifted = forecast_observed_root_pool("root", &observed, &policy, 2, 2, 1_900, 500)
3842            .expect("observed policy drift retains exact assets under desired limits");
3843        assert_eq!(drifted, forecast);
3844    }
3845
3846    #[test]
3847    fn unresolved_pool_creation_rejects_before_funding_authority() {
3848        let policy = pool_policy(1, 2);
3849        let mut observed = observed_pool([]);
3850        observed.pending_creation = Some(EstatePoolPendingCreationObservation {
3851            attempt_count: 1,
3852            available_cycles: None,
3853            creation_amount_cycles: 2_500,
3854            created_principal: None,
3855            diagnostic: None,
3856            last_attempt_at_ns: Some(1),
3857            operation_id: "11".repeat(32),
3858            required_cycles: None,
3859            retry_at_ns: None,
3860            shortfall_cycles: None,
3861            uncertain_result: true,
3862        });
3863        assert!(matches!(
3864            forecast_observed_root_pool("root", &observed, &policy, 1, 1, 1_900, 500),
3865            Err(EnsurePolicyError::EstateFundingTopology { reason })
3866                if reason.contains("unresolved creation response")
3867        ));
3868    }
3869
3870    #[test]
3871    #[expect(
3872        clippy::too_many_lines,
3873        reason = "one complete inventory proof binds funding, retained identities and the no-creation outcome"
3874    )]
3875    fn full_failed_inventory_funds_exact_retained_assets_without_creating_replacements() {
3876        let root = Principal::from_slice(&[10]).to_text();
3877        let desired = DesiredFleet {
3878            bootstrap: None,
3879            canisters: Vec::new(),
3880            cycles_ledger: Principal::from_slice(&[11]).to_text(),
3881            environment: "local".into(),
3882            fleet: "repair".into(),
3883            ledger_fee_cycles: "5".into(),
3884            management_creation_fee_cycles: "500".into(),
3885            material_cycle_threshold: "1".into(),
3886            maximum_observation_burn_cycles: "10".into(),
3887            maximum_stalled_observations: 8,
3888            maximum_update_burn_cycles: "20".into(),
3889            operator: Principal::from_slice(&[12]).to_text(),
3890            protocol: None,
3891            protocol_steps: Vec::new(),
3892            schema_version: FLEET_ENSURE_SCHEMA_VERSION,
3893            treasury: "coordinator".into(),
3894        };
3895        let mut pool = observed_pool([
3896            EstatePoolAssetLifecycle::Workload,
3897            EstatePoolAssetLifecycle::Workload,
3898            EstatePoolAssetLifecycle::Workload,
3899            EstatePoolAssetLifecycle::Workload,
3900            EstatePoolAssetLifecycle::Failed,
3901            EstatePoolAssetLifecycle::Failed,
3902            EstatePoolAssetLifecycle::Failed,
3903            EstatePoolAssetLifecycle::Failed,
3904        ]);
3905        let mut actions = Vec::new();
3906        for (index, asset) in pool.assets.iter_mut().enumerate() {
3907            let canister_id =
3908                Principal::from_slice(&[u8::try_from(index + 20).expect("bounded asset")]);
3909            asset.principal = canister_id.to_text();
3910            if asset.lifecycle == EstatePoolAssetLifecycle::Failed {
3911                asset.cycles -= 1;
3912                actions.push(EnsureAction::FleetProtocol {
3913                    action: Box::new(CurrentFleetProtocolAction::ReconcilePoolAsset {
3914                        request: canic_core::dto::pool::PoolCanisterRequest { canister_id },
3915                        minimum_cycles: Cycles::new(pool.readiness_floor_cycles),
3916                    }),
3917                    candid: "root.did".into(),
3918                    candid_sha256: "42".repeat(32),
3919                    maximum_execution_burn_cycles: 20,
3920                    name: format!("pool-reconcile-{canister_id}"),
3921                    principal: root.clone(),
3922                });
3923            }
3924        }
3925        assert!(matches!(
3926            forecast_observed_root_pool("root", &pool, &pool_policy(4, 8), 4, 4, 1_900, 500),
3927            Err(EnsurePolicyError::EstatePoolCapacity {
3928                capacity_shortfall: 4,
3929                ..
3930            })
3931        ));
3932        let observation = FleetObservation {
3933            additional_controlled_cycles: pool
3934                .assets
3935                .iter()
3936                .map(|asset| (asset.principal.clone(), asset.cycles))
3937                .collect(),
3938            canisters: BTreeMap::new(),
3939            estate_funding_domains: BTreeMap::from([(
3940                "root".into(),
3941                crate::fleet_ensure::model::EstateFundingDomainObservation {
3942                    balance_cycles: Some(0),
3943                    cycles_ledger: desired.cycles_ledger.clone(),
3944                    pool: Some(pool.clone()),
3945                    root_principal: Some(root.clone()),
3946                },
3947            )]),
3948            ledger_fee_cycles: 5,
3949            operator_cycles: 10_000,
3950            protocol_ready: BTreeMap::new(),
3951        };
3952        let mut accumulator = PlanAccumulator::new();
3953        append_pool_reconciliation_funding(
3954            &desired,
3955            &observation,
3956            &actions,
3957            CycleBounds {
3958                ledger_fee: 5,
3959                management_creation_fee: 500,
3960                material_threshold: 1,
3961                observation_burn: 10,
3962                update_burn: 20,
3963            },
3964            100,
3965            &mut accumulator,
3966        )
3967        .expect("review recovery funding");
3968        let preview = super::recovery::review(
3969            &observation,
3970            CycleBounds {
3971                ledger_fee: 5,
3972                management_creation_fee: 500,
3973                material_threshold: 1,
3974                observation_burn: 10,
3975                update_burn: 20,
3976            },
3977            32,
3978            40,
3979            10_000,
3980        )
3981        .unwrap();
3982        assert_eq!(preview.known_pool_funding.len(), 4);
3983        assert_eq!(
3984            preview
3985                .known_pool_funding
3986                .iter()
3987                .map(|funding| funding.amount_cycles)
3988                .sum::<u128>(),
3989            accumulator.new_funding
3990        );
3991        assert_eq!(
3992            preview
3993                .known_pool_funding
3994                .iter()
3995                .map(|funding| funding.ledger_fee_cycles)
3996                .sum::<u128>(),
3997            accumulator.fees
3998        );
3999        assert_eq!(accumulator.new_funding, 4 * 131);
4000        assert_eq!(accumulator.fees, 4 * 5);
4001        for plan in &accumulator.canisters {
4002            assert!(matches!(plan.actions.as_slice(), [EnsureAction::Fund {
4003                pool_funding: Some(PoolFundingAuthority { root: bound_root, lifecycle: EstatePoolAssetLifecycle::Failed }), principal, amount: 131, expected_post_cycles: 2_030,
4004                funding_deficit_cycles: 101, funding_margin_cycles: 30, ..
4005            }] if bound_root == &root && Some(principal) == plan.principal.as_ref()));
4006        }
4007        for asset in &mut pool.assets {
4008            if asset.lifecycle == EstatePoolAssetLifecycle::Failed {
4009                asset.cycles = 2_000;
4010                asset.lifecycle = EstatePoolAssetLifecycle::Ready;
4011            }
4012        }
4013        let recovered =
4014            forecast_observed_root_pool("root", &pool, &pool_policy(4, 8), 4, 4, 1_900, 500)
4015                .expect("recovered full pool satisfies the same demand");
4016        assert_eq!(recovered.eligible_ready_assets, 4);
4017        assert_eq!(recovered.required_creation_count, 0);
4018    }
4019
4020    fn pool_policy(minimum_size: u32, maximum_size: u32) -> FleetSubnetCanisterPoolConfig {
4021        FleetSubnetCanisterPoolConfig {
4022            minimum_size,
4023            maximum_size,
4024            canister_cycles: Cycles::new(1_900),
4025            creation_execution_margin: Cycles::new(100),
4026        }
4027    }
4028
4029    fn observed_pool(
4030        lifecycles: impl IntoIterator<Item = EstatePoolAssetLifecycle>,
4031    ) -> EstatePoolInventoryObservation {
4032        EstatePoolInventoryObservation {
4033            assets: lifecycles
4034                .into_iter()
4035                .enumerate()
4036                .map(|(index, lifecycle)| EstatePoolAssetObservation {
4037                    creation_receipt: None,
4038                    cycles: 1_900,
4039                    lifecycle,
4040                    origin: EstatePoolAssetOrigin::Imported,
4041                    principal: format!("asset-{index}"),
4042                })
4043                .collect(),
4044            maximum_size: 8,
4045            minimum_size: 4,
4046            pending_creation: None,
4047            readiness_floor_cycles: 1_900,
4048            creation_execution_margin_cycles: 100,
4049        }
4050    }
4051
4052    #[test]
4053    fn fresh_fleet_descendant_capacity_does_not_multiply_terminal_proof() {
4054        for maximum_descendants in [0, 1, 10_000, u32::MAX] {
4055            assert_eq!(
4056                terminal_initial_component_observation_count(maximum_descendants),
4057                3,
4058            );
4059        }
4060    }
4061
4062    #[test]
4063    fn component_provisioning_stall_floor_scales_and_caps() {
4064        let topology_bound = component_provisioning_observation_bound_from_counts(1, 11)
4065            .expect("bounded one-Root eleven-Component topology");
4066        assert_eq!(paced_protocol_stall_limit(8, topology_bound), 39);
4067        assert_eq!(paced_protocol_stall_limit(8, 10_000), 64);
4068        assert_eq!(paced_protocol_stall_limit(80, topology_bound), 80);
4069    }
4070
4071    #[test]
4072    fn apply_policy_rejects_bootstrap_imports_above_the_root_maximum() {
4073        let root = DesiredFleetBootstrapRoot {
4074            canister_pool_imports: vec![
4075                "pool-0".to_string(),
4076                "pool-1".to_string(),
4077                "pool-2".to_string(),
4078            ],
4079            component_admissions: Vec::new(),
4080            component_topology_digest: ComponentTopologyDigest::from_bytes([1; 32]),
4081            funding: crate::test_support::fleet_subnet_root_funding_authority(),
4082            limits: FleetSubnetRootLimits {
4083                maximum_component_instances: 1,
4084                maximum_registry_bytes: 1,
4085                maximum_wasm_store_bytes: 1,
4086                canister_pool: FleetSubnetCanisterPoolConfig {
4087                    minimum_size: 2,
4088                    maximum_size: 2,
4089                    canister_cycles: Cycles::new(1),
4090                    creation_execution_margin: Cycles::new(1),
4091                },
4092                cycles_funding: CyclesFundingBudget {
4093                    window_secs: 1,
4094                    maximum_cycles: Cycles::new(1),
4095                },
4096                maximum_group_placements: 1,
4097            },
4098            placement_subnet: SubnetId::from_principal(Principal::from_slice(&[2])),
4099            root: "root-0".to_string(),
4100            store: "store-0".to_string(),
4101        };
4102
4103        assert!(matches!(
4104            validate_bootstrap_root_pool_import_capacity(&root),
4105            Err(EnsurePolicyError::PoolImportCapacity(error))
4106                if error.import_count == 3
4107                    && error.maximum_size == 2
4108                    && error.root == "root-0"
4109        ));
4110    }
4111}