1pub mod retirement;
7mod store;
8#[cfg(test)]
9mod tests;
10
11pub use store::CapacityImportJournalStore;
12pub(in crate::fleet_ensure::ops) use store::require_no_approved_import;
13
14use crate::fleet_ensure::{
15 model::{
16 EffectRecord, EffectState, EnsureAction,
17 capacity_import::{
18 CapacityImportHandoffRecord, CapacityImportHandoffRequestRecord,
19 CapacityImportJournalRecord, CapacityImportPlanRecord, CapacityImportReservationRecord,
20 },
21 },
22 ops::{
23 action_sha256,
24 capacity_import::{
25 CapacityImportReviewError,
26 transport::{CompletedHandoff, validate_request},
27 verify_review,
28 },
29 },
30 policy::capacity_import::{
31 CapacityImportPolicyError, admit_handoffs, admit_source_handoff, retained_source_debit,
32 source_total,
33 },
34 view::capacity_import::{CapacityImportDestinationView, CapacityImportSourceView},
35};
36use candid::Principal;
37use thiserror::Error;
38
39#[derive(Clone, Copy, Debug, Eq, PartialEq)]
41pub enum CapacityImportInventoryStage {
42 Decode,
43 Membership,
44 Page,
45 Policy,
46 Query,
47 Summary,
48}
49
50#[derive(Debug, Error)]
52pub enum CapacityImportJournalError {
53 #[error(
54 "no original capacity observation is retained for {canister}; keep the original review inputs when declaring additional funding, or review this source before declaring a credit"
55 )]
56 FundingBaselineMissing { canister: Principal },
57 #[error(
58 "capacity import requires a completed current Fleet or receipted bootstrap setup with exact installed infrastructure records"
59 )]
60 InfrastructureRequired,
61 #[error("capacity import infrastructure module, controllers, placement or Registry changed")]
62 InfrastructureChanged,
63 #[error(
64 "capacity import requires an exact operator disposition declaration for every candidate"
65 )]
66 DispositionInvalid,
67 #[error(
68 "capacity import inventory is incomplete, changed, duplicated or exceeds its observation bound"
69 )]
70 InventoryInvalid,
71 #[error("capacity import inventory observation of {canister} failed at {stage:?}")]
72 InventoryObservation {
73 canister: Principal,
74 stage: CapacityImportInventoryStage,
75 },
76 #[error(
77 "management observation failed for {canister}; verify operator controller access before retrying"
78 )]
79 ObservationUnavailable { canister: Principal },
80 #[error(transparent)]
81 Inspection(Box<crate::canister_protocol::CanisterProtocolError>),
82 #[error(transparent)]
83 Prerequisite(
84 #[from] crate::fleet_ensure::ops::capacity_import::CapacityImportPrerequisiteError,
85 ),
86 #[error(
87 "Coordinator {coordinator} is unavailable; verify its initialization, network and operator access"
88 )]
89 CoordinatorUnavailable { coordinator: Principal },
90 #[error("Coordinator rejected the import prerequisite query: {0}")]
91 CoordinatorRejected(canic_core::dto::error::Error),
92 #[error(transparent)]
93 Review(#[from] CapacityImportReviewError),
94 #[error(transparent)]
95 Policy(#[from] CapacityImportPolicyError),
96 #[error("capacity import journal does not match the reviewed handoff sequence")]
97 Integrity,
98 #[error("capacity import request differs from the exact reviewed controller handoff")]
99 RequestInvalid,
100 #[error("capacity import transport differs from the reviewed network or signer")]
101 ReaderMismatch,
102 #[error("Root rejected the capacity import request: {0}")]
103 RootRejected(canic_core::dto::error::Error),
104 #[error(
105 "capacity import budget cannot cover the complete operation: {paid_calls} calls, at least {minimum_calls} required; debit ceiling {maximum_debit_cycles}, required {required_debit_cycles}"
106 )]
107 InsufficientRootBudget {
108 paid_calls: u32,
109 minimum_calls: u32,
110 maximum_debit_cycles: u128,
111 required_debit_cycles: u128,
112 },
113 #[error(
114 "Root capacity import hit a capacity limit in phase {phase:?}: {reserved_debit_cycles}/{maximum_debit_cycles} cycles reserved, {observed_debit_cycles} observed debit, {paid_calls}/{maximum_paid_calls} calls; preserve issued operation authority"
115 )]
116 RootCapacityLimit {
117 phase: canic_core::dto::pool_import::PoolImportPhase,
118 reserved_debit_cycles: u128,
119 maximum_debit_cycles: u128,
120 observed_debit_cycles: u128,
121 paid_calls: u32,
122 maximum_paid_calls: u32,
123 },
124 #[error("Root capacity import response has an invalid current-contract encoding")]
125 RootResponseInvalid,
126 #[error("capacity import requires exact approval and a protected Root reservation")]
127 ReservationRequired,
128 #[error("capacity import effect outcome is unresolved; retain its original intent")]
129 Unresolved,
130
131 #[error(
132 "capacity import exhausted its reviewed {step} attempts; preserve the original journal"
133 )]
134 BudgetExhausted { step: String },
135 #[error("capacity import inventory inputs or publication evidence changed")]
136 PublicationConflict,
137 #[error("capacity import generator inputs are invalid: {0}")]
138 InventoryProjection(
139 Box<crate::fleet_ensure::generate::capacity_import::CapacityImportInventoryError>,
140 ),
141 #[error("another capacity import already retains approval or effect intent")]
142 Conflict,
143 #[error(transparent)]
144 State(#[from] crate::fleet_ensure::ops::EnsureStateError),
145 #[error("capacity import journal file operation failed")]
146 Io(#[from] std::io::Error),
147 #[error("capacity import journal cannot be decoded or encoded")]
148 Encoding(#[from] serde_json::Error),
149}
150
151impl From<crate::canister_protocol::CanisterProtocolError> for CapacityImportJournalError {
152 fn from(error: crate::canister_protocol::CanisterProtocolError) -> Self {
153 Self::Inspection(Box::new(error))
154 }
155}
156
157pub fn reviewed(
159 plan: CapacityImportPlanRecord,
160) -> Result<CapacityImportJournalRecord, CapacityImportJournalError> {
161 verify_review(&plan, plan.plan_sha256)?;
162 Ok(CapacityImportJournalRecord {
163 handoffs: plan
164 .sources
165 .iter()
166 .map(|source| CapacityImportHandoffRecord {
167 canister_id: source.binding.canister_id,
168 retirements: Vec::new(),
169 effect: None,
170 request: None,
171 before_reserved_cycles: None,
172 after_reserved_cycles: None,
173 })
174 .collect(),
175 plan,
176 operation: None,
177 approved: false,
178 reservation: None,
179 })
180}
181
182pub fn approve(
184 journal: &CapacityImportJournalRecord,
185 digest: [u8; 32],
186 destination: &CapacityImportDestinationView,
187 sources: &[CapacityImportSourceView],
188) -> Result<CapacityImportJournalRecord, CapacityImportJournalError> {
189 validate(journal)?;
190 verify_review(&journal.plan, digest)?;
191 if journal.approved {
192 return Ok(journal.clone());
193 }
194 admit_handoffs(&journal.plan, destination, sources)?;
195 let mut updated = journal.clone();
196 updated.approved = true;
197 Ok(updated)
198}
199
200pub fn reserve(
202 journal: &CapacityImportJournalRecord,
203 reservation: CapacityImportReservationRecord,
204) -> Result<CapacityImportJournalRecord, CapacityImportJournalError> {
205 validate(journal)?;
206 if !journal.approved || !reservation_matches(&journal.plan, &reservation) {
207 return Err(CapacityImportJournalError::ReservationRequired);
208 }
209 if let Some(retained) = &journal.reservation {
210 if *retained != reservation {
211 return Err(CapacityImportJournalError::Integrity);
212 }
213 return Ok(journal.clone());
214 }
215 let mut updated = journal.clone();
216 updated.reservation = Some(reservation);
217 Ok(updated)
218}
219
220pub fn prepare_handoff(
223 journal: &CapacityImportJournalRecord,
224 observed: &CapacityImportSourceView,
225 request: CapacityImportHandoffRequestRecord,
226) -> Result<CapacityImportJournalRecord, CapacityImportJournalError> {
227 validate(journal)?;
228 if !journal.approved || journal.reservation.is_none() {
229 return Err(CapacityImportJournalError::ReservationRequired);
230 }
231 let index = source_index(journal, observed.binding.canister_id)?;
232 if journal.handoffs[index].effect.is_some() {
233 return Ok(journal.clone());
234 }
235 let source = &journal.plan.sources[index];
236 if !crate::fleet_ensure::policy::capacity_import::requires_handoff(&journal.plan, source) {
237 return Err(CapacityImportJournalError::Integrity);
238 }
239 admit_source_handoff(source, observed)?;
240 validate_request(&journal.plan, observed.binding.canister_id, &request)?;
241 let mut updated = journal.clone();
242 updated.handoffs[index].effect = Some(handoff_intent(&journal.plan, index, observed.cycles));
243 updated.handoffs[index].before_reserved_cycles = Some(observed.reserved_cycles);
244 updated.handoffs[index].request = Some(request);
245 Ok(updated)
246}
247
248fn handoff_intent(
249 plan: &CapacityImportPlanRecord,
250 index: usize,
251 observed_cycles: u128,
252) -> EffectRecord {
253 EffectRecord {
254 maintenance_attempts: 0,
255 publication_attempts: 0,
256 action_sha256: action_sha256(&handoff_action(plan, index)),
257 created_principal: None,
258 destination_post_cycles: None,
259 destination_pre_cycles: None,
260 post_cycles: None,
261 pre_cycles: Some(observed_cycles),
262 pre_canister_version: Some(plan.sources[index].binding.canister_version),
263 progress_identity: None,
264 receipt: None,
265 state: EffectState::Intent,
266 }
267}
268
269pub fn issue_handoff(
271 journal: &CapacityImportJournalRecord,
272 canister: Principal,
273) -> Result<CapacityImportJournalRecord, CapacityImportJournalError> {
274 validate(journal)?;
275 let index = source_index(journal, canister)?;
276 let mut updated = journal.clone();
277 let effect = updated.handoffs[index]
278 .effect
279 .as_mut()
280 .ok_or(CapacityImportJournalError::Integrity)?;
281 if effect.state != EffectState::Intent {
282 return Err(CapacityImportJournalError::Unresolved);
283 }
284 effect.state = EffectState::Issued;
285 Ok(updated)
286}
287
288pub fn observe_handoff(
291 journal: &CapacityImportJournalRecord,
292 observed: &CapacityImportSourceView,
293 completion: &CompletedHandoff,
294) -> Result<CapacityImportJournalRecord, CapacityImportJournalError> {
295 validate(journal)?;
296 let index = source_index(journal, observed.binding.canister_id)?;
297 let source = &journal.plan.sources[index];
298 let request = journal.handoffs[index]
299 .request
300 .as_ref()
301 .ok_or(CapacityImportJournalError::Integrity)?;
302 if !completion.matches(&journal.plan, source.binding.canister_id, request) {
303 return Err(CapacityImportJournalError::Unresolved);
304 }
305 apply_observed_handoff(journal, observed)
306}
307
308fn apply_observed_handoff(
310 journal: &CapacityImportJournalRecord,
311 observed: &CapacityImportSourceView,
312) -> Result<CapacityImportJournalRecord, CapacityImportJournalError> {
313 validate(journal)?;
314 let index = source_index(journal, observed.binding.canister_id)?;
315 let effect = journal.handoffs[index]
316 .effect
317 .as_ref()
318 .ok_or(CapacityImportJournalError::Integrity)?;
319 if effect.state == EffectState::Applied {
320 return Ok(journal.clone());
321 }
322 if effect.state != EffectState::Issued {
323 return Err(CapacityImportJournalError::Integrity);
324 }
325 let source = &journal.plan.sources[index];
326 let mut expected = source.binding.clone();
327 expected
328 .controllers
329 .clone_from(&journal.plan.transitional_controllers);
330 expected.canister_version = expected
331 .canister_version
332 .checked_add(1)
333 .ok_or(CapacityImportJournalError::Integrity)?;
334 if observed.binding != expected {
335 return Err(CapacityImportJournalError::Unresolved);
336 }
337 retained_source_debit(source, observed.cycles, observed.reserved_cycles)?;
338 let before = source_total(
339 effect
340 .pre_cycles
341 .ok_or(CapacityImportJournalError::Integrity)?,
342 journal.handoffs[index]
343 .before_reserved_cycles
344 .ok_or(CapacityImportJournalError::Integrity)?,
345 )?;
346 if source_total(observed.cycles, observed.reserved_cycles)? > before {
347 return Err(CapacityImportJournalError::Unresolved);
348 }
349 let mut updated = journal.clone();
350 let effect = updated.handoffs[index]
351 .effect
352 .as_mut()
353 .ok_or(CapacityImportJournalError::Integrity)?;
354 effect.state = EffectState::Applied;
355 effect.post_cycles = Some(observed.cycles);
356 effect.receipt = Some(expected.canister_version.to_string());
358 updated.handoffs[index].after_reserved_cycles = Some(observed.reserved_cycles);
359 validate(&updated)?;
360 Ok(updated)
361}
362
363pub fn validate(journal: &CapacityImportJournalRecord) -> Result<(), CapacityImportJournalError> {
365 verify_review(&journal.plan, journal.plan.plan_sha256)?;
366 crate::fleet_ensure::ops::capacity_import::publication::validate(journal)?;
367 if journal.handoffs.len() != journal.plan.sources.len() {
368 return Err(CapacityImportJournalError::Integrity);
369 }
370 if let Some(reservation) = &journal.reservation
371 && (!journal.approved || !reservation_matches(&journal.plan, reservation))
372 {
373 return Err(CapacityImportJournalError::Integrity);
374 }
375 for (index, handoff) in journal.handoffs.iter().enumerate() {
376 let source = &journal.plan.sources[index];
377 retirement::validate(&journal.plan, handoff)?;
378 if handoff.canister_id != source.binding.canister_id {
379 return Err(CapacityImportJournalError::Integrity);
380 }
381 let Some(effect) = &handoff.effect else {
382 if !handoff.retirements.is_empty()
383 || handoff.request.is_some()
384 || handoff.before_reserved_cycles.is_some()
385 || handoff.after_reserved_cycles.is_some()
386 {
387 return Err(CapacityImportJournalError::Integrity);
388 }
389 continue;
390 };
391 if !crate::fleet_ensure::policy::capacity_import::requires_handoff(&journal.plan, source) {
392 return Err(CapacityImportJournalError::Integrity);
393 }
394 validate_request(
395 &journal.plan,
396 handoff.canister_id,
397 handoff
398 .request
399 .as_ref()
400 .ok_or(CapacityImportJournalError::Integrity)?,
401 )?;
402 let action_bound =
403 effect.action_sha256 == action_sha256(&handoff_action(&journal.plan, index));
404 let authority_bound = journal.approved
405 && journal.reservation.is_some()
406 && effect.pre_canister_version == Some(source.binding.canister_version);
407 let counters_empty = effect.maintenance_attempts == 0 && effect.publication_attempts == 0;
408 let unrelated_empty = effect.created_principal.is_none()
409 && effect.destination_pre_cycles.is_none()
410 && effect.destination_post_cycles.is_none()
411 && effect.progress_identity.is_none();
412 if !action_bound || !authority_bound || !counters_empty || !unrelated_empty {
413 return Err(CapacityImportJournalError::Integrity);
414 }
415 let before = effect
416 .pre_cycles
417 .ok_or(CapacityImportJournalError::Integrity)?;
418 let before_reserved = handoff
419 .before_reserved_cycles
420 .ok_or(CapacityImportJournalError::Integrity)?;
421 retained_source_debit(source, before, before_reserved)?;
422 match effect.state {
423 EffectState::Intent | EffectState::Issued => {
424 if effect.post_cycles.is_some()
425 || effect.receipt.is_some()
426 || handoff.after_reserved_cycles.is_some()
427 {
428 return Err(CapacityImportJournalError::Integrity);
429 }
430 }
431 EffectState::Applied => {
432 let after = effect
433 .post_cycles
434 .ok_or(CapacityImportJournalError::Integrity)?;
435 let version = source
436 .binding
437 .canister_version
438 .checked_add(1)
439 .ok_or(CapacityImportJournalError::Integrity)?;
440 let after_reserved = handoff
441 .after_reserved_cycles
442 .ok_or(CapacityImportJournalError::Integrity)?;
443 if source_total(after, after_reserved)? > source_total(before, before_reserved)?
444 || effect.receipt.as_deref() != Some(version.to_string().as_str())
445 {
446 return Err(CapacityImportJournalError::Integrity);
447 }
448 retained_source_debit(source, after, after_reserved)?;
449 }
450 }
451 }
452 Ok(())
453}
454
455#[must_use]
458pub fn custody_ready(journal: &CapacityImportJournalRecord, canister: Principal) -> bool {
459 let Some(index) = journal
460 .plan
461 .sources
462 .iter()
463 .position(|source| source.binding.canister_id == canister)
464 else {
465 return false;
466 };
467 let Some(handoff) = journal.handoffs.get(index) else {
468 return false;
469 };
470 if handoff.canister_id != canister {
471 return false;
472 }
473 if crate::fleet_ensure::policy::capacity_import::requires_handoff(
474 &journal.plan,
475 &journal.plan.sources[index],
476 ) {
477 handoff
478 .effect
479 .as_ref()
480 .is_some_and(|effect| effect.state == EffectState::Applied)
481 } else {
482 handoff.effect.is_none()
483 && handoff.request.is_none()
484 && handoff.retirements.is_empty()
485 && handoff.before_reserved_cycles.is_none()
486 && handoff.after_reserved_cycles.is_none()
487 }
488}
489
490#[must_use]
492pub fn all_custody_ready(journal: &CapacityImportJournalRecord) -> bool {
493 journal.handoffs.len() == journal.plan.sources.len()
494 && journal
495 .plan
496 .sources
497 .iter()
498 .all(|source| custody_ready(journal, source.binding.canister_id))
499}
500
501fn source_index(
502 journal: &CapacityImportJournalRecord,
503 canister: Principal,
504) -> Result<usize, CapacityImportJournalError> {
505 journal
506 .plan
507 .sources
508 .iter()
509 .position(|source| source.binding.canister_id == canister)
510 .ok_or(CapacityImportJournalError::Integrity)
511}
512
513fn handoff_action(plan: &CapacityImportPlanRecord, index: usize) -> EnsureAction {
514 let canister = plan.sources[index].binding.canister_id.to_text();
515 EnsureAction::SetControllers {
516 controller_canisters: Vec::new(),
517 controllers: plan
518 .transitional_controllers
519 .iter()
520 .map(Principal::to_text)
521 .collect(),
522 name: canister.clone(),
523 principal: canister,
524 }
525}
526
527fn reservation_matches(
528 plan: &CapacityImportPlanRecord,
529 reservation: &CapacityImportReservationRecord,
530) -> bool {
531 reservation.plan_sha256 == plan.plan_sha256
532 && reservation.authority == plan.authority
533 && reservation.sources
534 == plan
535 .sources
536 .iter()
537 .map(|source| source.binding.canister_id)
538 .collect::<Vec<_>>()
539}