1pub 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#[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#[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 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 }
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
832fn 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 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
989pub(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
1027pub(crate) fn compile_root_start_prerequisite_plan(
1029 input: RootStartPlanInput<'_>,
1030) -> Result<Option<FleetEnsurePlan>, EnsurePolicyError> {
1031 compile_root_start_plan(&input, None)
1032}
1033
1034pub(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
1330pub(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 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
2148pub 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
2168pub 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
2184pub(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 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 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
3030pub(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 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 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
3335pub(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
3416pub(super) struct EffectObservationPolicy {
3418 pub maximum_stalled_observations: u32,
3419 pub paced: bool,
3420}
3421
3422pub(super) const fn pool_maintenance_update_bound(minimum_ready: u32) -> u32 {
3424 minimum_ready.saturating_mul(2).saturating_add(16)
3425}
3426
3427pub(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 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#[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}