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