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 canonical_hash(text: &str) -> Result<String, OperationPlanError> {
309    Ok(ArtifactChecksumRecord::from_hash(text)?.hash().into())
310}
311
312fn bounded_selected<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<String>, D::Error> {
313    bounded::<D, String, MAX_INVENTORY_TARGETS>(deserializer)
314}
315fn bounded_operations<'de, D: Deserializer<'de>>(
316    deserializer: D,
317) -> Result<Vec<PlannedOperationRecord>, D::Error> {
318    bounded::<D, PlannedOperationRecord, MAX_EFFECT_OPERATIONS>(deserializer)
319}
320fn bounded<'de, D, T, const LIMIT: usize>(deserializer: D) -> Result<Vec<T>, D::Error>
321where
322    D: Deserializer<'de>,
323    T: Deserialize<'de>,
324{
325    struct Visitor<T, const LIMIT: usize>(std::marker::PhantomData<T>);
326    impl<'de, T: Deserialize<'de>, const LIMIT: usize> de::Visitor<'de> for Visitor<T, LIMIT> {
327        type Value = Vec<T>;
328        fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
329            write!(f, "at most {LIMIT} plan entries")
330        }
331        fn visit_seq<A: de::SeqAccess<'de>>(
332            self,
333            mut sequence: A,
334        ) -> Result<Self::Value, A::Error> {
335            let mut entries = Vec::new();
336            while entries.len() < LIMIT {
337                match sequence.next_element()? {
338                    Some(entry) => entries.push(entry),
339                    None => return Ok(entries),
340                }
341            }
342            if sequence.next_element::<de::IgnoredAny>()?.is_some() {
343                return Err(de::Error::custom(format!(
344                    "plan list exceeds {LIMIT} entries"
345                )));
346            }
347            Ok(entries)
348        }
349    }
350    deserializer.deserialize_seq(Visitor::<T, LIMIT>(std::marker::PhantomData))
351}
352
353/// Typed original plan identity, structural binding or finite allowance rejection.
354#[derive(Debug, Error)]
355pub enum OperationPlanError {
356    /// Only protocol generation v1 is maintained.
357    #[error("unsupported operation plan version {0}")]
358    UnsupportedVersion(u16),
359    /// At least one exact physical selection is required.
360    #[error("operation plan has no selected targets")]
361    EmptySelection,
362    /// Exact selection exceeds the maintained inventory bound.
363    #[error("operation plan exceeds {MAX_INVENTORY_TARGETS} selected targets")]
364    TooManySelected,
365    /// Operation bindings exceed the maintained graph count.
366    #[error("operation plan exceeds {MAX_EFFECT_OPERATIONS} operations")]
367    TooManyOperations,
368    /// Principal text fails canonical admission at this named field.
369    #[error("invalid operation plan principal in {0}")]
370    InvalidPrincipal(&'static str),
371    /// Equivalent physical target was explicitly selected twice.
372    #[error("duplicate selected plan target {0}")]
373    DuplicateSelected(String),
374    /// Operation identity was bound more than once.
375    #[error("duplicate planned operation {0}")]
376    DuplicateOperation(u64),
377    /// Operation table does not have one binding per graph node.
378    #[error("operation table count differs from dependency graph")]
379    OperationCountMismatch,
380    /// Exact table identity differs from its corresponding canonical graph node.
381    #[error("operation table differs from graph: expected {expected}, actual {actual}")]
382    OperationGraphMismatch {
383        /// Exact graph operation identity.
384        expected: u64,
385        /// Rejected table identity.
386        actual: u64,
387    },
388    /// Exact operation target lies outside explicit selection.
389    #[error("operation targets unselected physical identity {0}")]
390    UnselectedTarget(String),
391    /// A selected physical target has no bound operation.
392    #[error("selected plan target has no operation {0}")]
393    UnusedTarget(String),
394    /// Requested exact operation is absent.
395    #[error("unknown planned operation {0}")]
396    UnknownOperation(u64),
397    /// Aggregate ceilings overflow or exceed the maintained maximum.
398    #[error("operation plan attempt ceiling exceeds {MAX_PLAN_ATTEMPTS}")]
399    BudgetTooLarge,
400    /// Assigned original allowances exceed a declared mutation/observation ceiling.
401    #[error("assigned operation attempts exceed original plan ceilings")]
402    AssignedBudgetExceeded,
403    /// Digest text fails canonical admission.
404    #[error(transparent)]
405    Checksum(#[from] ChecksumError),
406    /// Owning inventory rejects selected identity.
407    #[error(transparent)]
408    Inventory(#[from] InventoryRecordError),
409    /// Existing attempt authority boundary rejected derivation.
410    #[error(transparent)]
411    Attempt(#[from] AttemptJournalRecordError),
412}
413
414#[cfg(test)]
415pub(crate) mod tests;