Skip to main content

ic_backup/model/operation_plan/
mod.rs

1//! Immutable local binding of declarations to graph operations and original attempt authority.
2
3mod budget;
4mod context;
5mod operation;
6pub use budget::{AllocatedAttemptsView, MAX_PLAN_ATTEMPTS, PlanBudgetRecord};
7pub use context::{PlanContextRecord, PlanContextRequest};
8pub use operation::{PlannedOperationRecord, PlannedOperationRequest};
9
10use crate::model::{
11    artifacts::{ArtifactChecksumRecord, ChecksumError},
12    attempt_journal::{
13        AttemptAuthorityRecord, AttemptJournalRecordError, OperationBindingRecord,
14        OperationBindingRequest,
15    },
16    effect_graph::{EffectGraphRecord, MAX_EFFECT_OPERATIONS},
17    inventory::{InventoryRecord, InventoryRecordError, MAX_INVENTORY_TARGETS},
18};
19use serde::{Deserialize, Deserializer, Serialize, de};
20use std::{collections::BTreeSet, fmt};
21use thiserror::Error;
22
23/// Maximum encoded input and canonical output bytes admitted by plan persistence.
24pub const MAX_OPERATION_PLAN_BYTES: u64 = 1024 * 1024;
25
26/// Passive immutable-plan declaration assembled from validated owner records.
27#[derive(Clone, Debug)]
28pub struct OperationPlanRequest {
29    /// Exact declared network/caller/release shared by operations.
30    pub context: PlanContextRecord,
31    /// Full original declared physical inventory, including unselected parents.
32    pub inventory: InventoryRecord,
33    /// Nonempty explicit physical selection, without role or privileged-root semantics.
34    pub selected_targets: Vec<String>,
35    /// Exact declared dependencies over the operation table.
36    pub graph: EffectGraphRecord,
37    /// One exact target/request/original-budget binding per graph operation.
38    pub operations: Vec<PlannedOperationRecord>,
39    /// Original declared aggregate attempt ceilings.
40    pub budget: PlanBudgetRecord,
41}
42
43/// Canonical v1 operation plan binding declarations to exact finite attempt authority.
44///
45/// This is not an authenticated backup/restore plan, fresh preflight, qualified
46/// request payload, consistency/lifecycle contract or executable dispatch permit.
47#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
48#[serde(try_from = "PlanFields")]
49pub struct OperationPlanRecord {
50    version: u16,
51    context: PlanContextRecord,
52    inventory: InventoryRecord,
53    selected_targets: Vec<String>,
54    graph: EffectGraphRecord,
55    operations: Vec<PlannedOperationRecord>,
56    budget: PlanBudgetRecord,
57    #[serde(skip)]
58    allocated: AllocatedAttemptsView,
59}
60#[derive(Deserialize)]
61#[serde(deny_unknown_fields)]
62struct PlanFields {
63    version: u16,
64    context: PlanContextRecord,
65    inventory: InventoryRecord,
66    #[serde(deserialize_with = "bounded_selected")]
67    selected_targets: Vec<String>,
68    graph: EffectGraphRecord,
69    #[serde(deserialize_with = "bounded_operations")]
70    operations: Vec<PlannedOperationRecord>,
71    budget: PlanBudgetRecord,
72}
73impl TryFrom<PlanFields> for OperationPlanRecord {
74    type Error = OperationPlanError;
75    fn try_from(fields: PlanFields) -> Result<Self, Self::Error> {
76        if fields.version != 1 {
77            return Err(OperationPlanError::UnsupportedVersion(fields.version));
78        }
79        Self::new(OperationPlanRequest {
80            context: fields.context,
81            inventory: fields.inventory,
82            selected_targets: fields.selected_targets,
83            graph: fields.graph,
84            operations: fields.operations,
85            budget: fields.budget,
86        })
87    }
88}
89impl OperationPlanRecord {
90    /// Admit canonical exact graph/table/physical selection and aggregate attempt binding.
91    ///
92    /// # Errors
93    /// Rejects invalid selection, operation identity mismatch, unused targets and excessive allowances.
94    pub fn new(mut request: OperationPlanRequest) -> Result<Self, OperationPlanError> {
95        if request.selected_targets.is_empty() {
96            return Err(OperationPlanError::EmptySelection);
97        }
98        if request.selected_targets.len() > MAX_INVENTORY_TARGETS {
99            return Err(OperationPlanError::TooManySelected);
100        }
101        for id in &mut request.selected_targets {
102            *id = request.inventory.target(id)?.canister_id().into();
103        }
104        request.selected_targets.sort();
105        for pair in request.selected_targets.windows(2) {
106            if pair[0] == pair[1] {
107                return Err(OperationPlanError::DuplicateSelected(pair[0].clone()));
108            }
109        }
110        if request.operations.len() > MAX_EFFECT_OPERATIONS {
111            return Err(OperationPlanError::TooManyOperations);
112        }
113        request
114            .operations
115            .sort_by_key(PlannedOperationRecord::operation_sequence);
116        for pair in request.operations.windows(2) {
117            if pair[0].operation_sequence() == pair[1].operation_sequence() {
118                return Err(OperationPlanError::DuplicateOperation(
119                    pair[0].operation_sequence(),
120                ));
121            }
122        }
123        if request.operations.len() != request.graph.nodes().len() {
124            return Err(OperationPlanError::OperationCountMismatch);
125        }
126        let selected: BTreeSet<_> = request
127            .selected_targets
128            .iter()
129            .map(String::as_str)
130            .collect();
131        let mut used = BTreeSet::new();
132        let mut allocated = AllocatedAttemptsView {
133            mutations: 0,
134            observations: 0,
135        };
136        for (node, operation) in request.graph.nodes().iter().zip(&request.operations) {
137            if node.operation_sequence() != operation.operation_sequence() {
138                return Err(OperationPlanError::OperationGraphMismatch {
139                    expected: node.operation_sequence(),
140                    actual: operation.operation_sequence(),
141                });
142            }
143            if !selected.contains(operation.target()) {
144                return Err(OperationPlanError::UnselectedTarget(
145                    operation.target().into(),
146                ));
147            }
148            used.insert(operation.target());
149            allocated.mutations = allocated
150                .mutations
151                .checked_add(operation.budget().mutations())
152                .ok_or(OperationPlanError::BudgetTooLarge)?;
153            allocated.observations = allocated
154                .observations
155                .checked_add(operation.budget().observations())
156                .ok_or(OperationPlanError::BudgetTooLarge)?;
157        }
158        if !request.budget.admits(allocated) {
159            return Err(OperationPlanError::AssignedBudgetExceeded);
160        }
161        for id in &request.selected_targets {
162            if !used.contains(id.as_str()) {
163                return Err(OperationPlanError::UnusedTarget(id.clone()));
164            }
165        }
166        Ok(Self {
167            version: 1,
168            context: request.context,
169            inventory: request.inventory,
170            selected_targets: request.selected_targets,
171            graph: request.graph,
172            operations: request.operations,
173            budget: request.budget,
174            allocated,
175        })
176    }
177    /// Read immutable declared context.
178    #[must_use]
179    pub const fn context(&self) -> &PlanContextRecord {
180        &self.context
181    }
182    /// Read the full original declared inventory.
183    #[must_use]
184    pub const fn inventory(&self) -> &InventoryRecord {
185        &self.inventory
186    }
187    /// Read canonical exact physical selection, without effect-order semantics.
188    #[must_use]
189    pub fn selected_targets(&self) -> &[String] {
190        &self.selected_targets
191    }
192    /// Read the bound original explicit dependency graph.
193    #[must_use]
194    pub const fn graph(&self) -> &EffectGraphRecord {
195        &self.graph
196    }
197    /// Read operation bindings in canonical sequence order.
198    #[must_use]
199    pub fn operations(&self) -> &[PlannedOperationRecord] {
200        &self.operations
201    }
202    /// Read original aggregate attempt ceilings.
203    #[must_use]
204    pub const fn budget(&self) -> &PlanBudgetRecord {
205        &self.budget
206    }
207    /// Project assigned allowances only; this performs no observations or consumption.
208    #[must_use]
209    pub const fn allocated_attempts(&self) -> AllocatedAttemptsView {
210        self.allocated
211    }
212    /// Resolve an exact declared operation binding.
213    ///
214    /// # Errors
215    /// Rejects an operation absent from the exact original plan.
216    pub fn operation(&self, sequence: u64) -> Result<&PlannedOperationRecord, OperationPlanError> {
217        self.operations
218            .binary_search_by_key(&sequence, PlannedOperationRecord::operation_sequence)
219            .map(|index| &self.operations[index])
220            .map_err(|_| OperationPlanError::UnknownOperation(sequence))
221    }
222    /// Derive exact existing attempt authority under the full original canonical plan digest.
223    ///
224    /// Repeated derivation returns the same declaration and never creates/resets a journal.
225    /// Fresh authority, actual request bytes and dispatch admission stay caller-owned.
226    ///
227    /// # Errors
228    /// Rejects unknown operations or invalid derived identity admission.
229    pub fn attempt_authority(
230        &self,
231        sequence: u64,
232    ) -> Result<AttemptAuthorityRecord, OperationPlanError> {
233        let operation = self.operation(sequence)?;
234        self.authority_for_operation(operation, &self.digest())
235    }
236    /// Derive every original authority in canonical sequence order with one full plan hash.
237    ///
238    /// This avoids repeated full-plan encoding for bounded complete evidence scans.
239    /// It grants no new allowance, journal creation/reset or fresh dispatch permission.
240    /// # Errors
241    /// Rejects invalid derived identity admission through the same scalar authority owner.
242    pub fn attempt_authorities(&self) -> Result<Vec<AttemptAuthorityRecord>, OperationPlanError> {
243        let intent = self.digest();
244        self.operations
245            .iter()
246            .map(|operation| self.authority_for_operation(operation, &intent))
247            .collect()
248    }
249    fn authority_for_operation(
250        &self,
251        operation: &PlannedOperationRecord,
252        intent: &ArtifactChecksumRecord,
253    ) -> Result<AttemptAuthorityRecord, OperationPlanError> {
254        let binding = OperationBindingRecord::new(&OperationBindingRequest {
255            intent: intent.hash().into(),
256            operation_sequence: operation.operation_sequence(),
257            network: self.context.network().into(),
258            caller: self.context.caller().into(),
259            target: operation.target().into(),
260            release: self.context.release().into(),
261            request: operation.request().into(),
262        })?;
263        Ok(AttemptAuthorityRecord::new(
264            binding,
265            operation.budget().clone(),
266        ))
267    }
268    /// Hash the entire canonical declaration using the maintained nonrecursive v1 encoding.
269    ///
270    /// Derived intent/authority, allocated views and local progress are excluded.
271    #[must_use]
272    pub fn digest(&self) -> ArtifactChecksumRecord {
273        let mut bytes = b"ic-backup/operation-plan/v1\0".to_vec();
274        bytes.extend_from_slice(self.context.network().as_bytes());
275        append_principal(&mut bytes, self.context.caller());
276        bytes.extend_from_slice(self.context.release().as_bytes());
277        bytes.extend_from_slice(self.inventory.digest().hash().as_bytes());
278        bytes.extend_from_slice(self.graph.digest().hash().as_bytes());
279        append_count(&mut bytes, self.selected_targets.len());
280        for target in &self.selected_targets {
281            append_principal(&mut bytes, target);
282        }
283        bytes.extend_from_slice(&self.budget.mutations().to_be_bytes());
284        bytes.extend_from_slice(&self.budget.observations().to_be_bytes());
285        append_count(&mut bytes, self.operations.len());
286        for operation in &self.operations {
287            bytes.extend_from_slice(&operation.operation_sequence().to_be_bytes());
288            append_principal(&mut bytes, operation.target());
289            bytes.extend_from_slice(operation.request().as_bytes());
290            bytes.extend_from_slice(&operation.budget().mutations().to_be_bytes());
291            bytes.extend_from_slice(&operation.budget().observations().to_be_bytes());
292        }
293        ArtifactChecksumRecord::from_bytes(&bytes)
294    }
295}
296fn append_principal(bytes: &mut Vec<u8>, text: &str) {
297    // Owning boundaries admit at most 63 canonical ASCII bytes, so the low byte is exact.
298    bytes.push(text.len().to_le_bytes()[0]);
299    bytes.extend_from_slice(text.as_bytes());
300}
301#[expect(
302    clippy::cast_possible_truncation,
303    reason = "validated selection/operation counts are at most 8192"
304)]
305fn append_count(bytes: &mut Vec<u8>, count: usize) {
306    bytes.extend_from_slice(&(count as u32).to_be_bytes());
307}
308fn bounded_selected<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<String>, D::Error> {
309    bounded::<D, String, MAX_INVENTORY_TARGETS>(deserializer)
310}
311fn bounded_operations<'de, D: Deserializer<'de>>(
312    deserializer: D,
313) -> Result<Vec<PlannedOperationRecord>, D::Error> {
314    bounded::<D, PlannedOperationRecord, MAX_EFFECT_OPERATIONS>(deserializer)
315}
316fn bounded<'de, D, T, const LIMIT: usize>(deserializer: D) -> Result<Vec<T>, D::Error>
317where
318    D: Deserializer<'de>,
319    T: Deserialize<'de>,
320{
321    struct Visitor<T, const LIMIT: usize>(std::marker::PhantomData<T>);
322    impl<'de, T: Deserialize<'de>, const LIMIT: usize> de::Visitor<'de> for Visitor<T, LIMIT> {
323        type Value = Vec<T>;
324        fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
325            write!(f, "at most {LIMIT} plan entries")
326        }
327        fn visit_seq<A: de::SeqAccess<'de>>(
328            self,
329            mut sequence: A,
330        ) -> Result<Self::Value, A::Error> {
331            let mut entries = Vec::new();
332            while entries.len() < LIMIT {
333                match sequence.next_element()? {
334                    Some(entry) => entries.push(entry),
335                    None => return Ok(entries),
336                }
337            }
338            if sequence.next_element::<de::IgnoredAny>()?.is_some() {
339                return Err(de::Error::custom(format!(
340                    "plan list exceeds {LIMIT} entries"
341                )));
342            }
343            Ok(entries)
344        }
345    }
346    deserializer.deserialize_seq(Visitor::<T, LIMIT>(std::marker::PhantomData))
347}
348
349/// Typed original plan identity, structural binding or finite allowance rejection.
350#[derive(Debug, Error)]
351pub enum OperationPlanError {
352    /// Only protocol generation v1 is maintained.
353    #[error("unsupported operation plan version {0}")]
354    UnsupportedVersion(u16),
355    /// At least one exact physical selection is required.
356    #[error("operation plan has no selected targets")]
357    EmptySelection,
358    /// Exact selection exceeds the maintained inventory bound.
359    #[error("operation plan exceeds {MAX_INVENTORY_TARGETS} selected targets")]
360    TooManySelected,
361    /// Operation bindings exceed the maintained graph count.
362    #[error("operation plan exceeds {MAX_EFFECT_OPERATIONS} operations")]
363    TooManyOperations,
364    /// Principal text fails canonical admission at this named field.
365    #[error("invalid operation plan principal in {0}")]
366    InvalidPrincipal(&'static str),
367    /// Equivalent physical target was explicitly selected twice.
368    #[error("duplicate selected plan target {0}")]
369    DuplicateSelected(String),
370    /// Operation identity was bound more than once.
371    #[error("duplicate planned operation {0}")]
372    DuplicateOperation(u64),
373    /// Operation table does not have one binding per graph node.
374    #[error("operation table count differs from dependency graph")]
375    OperationCountMismatch,
376    /// Exact table identity differs from its corresponding canonical graph node.
377    #[error("operation table differs from graph: expected {expected}, actual {actual}")]
378    OperationGraphMismatch {
379        /// Exact graph operation identity.
380        expected: u64,
381        /// Rejected table identity.
382        actual: u64,
383    },
384    /// Exact operation target lies outside explicit selection.
385    #[error("operation targets unselected physical identity {0}")]
386    UnselectedTarget(String),
387    /// A selected physical target has no bound operation.
388    #[error("selected plan target has no operation {0}")]
389    UnusedTarget(String),
390    /// Requested exact operation is absent.
391    #[error("unknown planned operation {0}")]
392    UnknownOperation(u64),
393    /// Aggregate ceilings overflow or exceed the maintained maximum.
394    #[error("operation plan attempt ceiling exceeds {MAX_PLAN_ATTEMPTS}")]
395    BudgetTooLarge,
396    /// Assigned original allowances exceed a declared mutation/observation ceiling.
397    #[error("assigned operation attempts exceed original plan ceilings")]
398    AssignedBudgetExceeded,
399    /// Digest text fails canonical admission.
400    #[error(transparent)]
401    Checksum(#[from] ChecksumError),
402    /// Owning inventory rejects selected identity.
403    #[error(transparent)]
404    Inventory(#[from] InventoryRecordError),
405    /// Existing attempt authority boundary rejected derivation.
406    #[error(transparent)]
407    Attempt(#[from] AttemptJournalRecordError),
408}
409
410#[cfg(test)]
411pub(crate) mod tests;