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        let binding = OperationBindingRecord::new(&OperationBindingRequest {
235            intent: self.digest().hash().into(),
236            operation_sequence: sequence,
237            network: self.context.network().into(),
238            caller: self.context.caller().into(),
239            target: operation.target().into(),
240            release: self.context.release().into(),
241            request: operation.request().into(),
242        })?;
243        Ok(AttemptAuthorityRecord::new(
244            binding,
245            operation.budget().clone(),
246        ))
247    }
248    /// Hash the entire canonical declaration using the maintained nonrecursive v1 encoding.
249    ///
250    /// Derived intent/authority, allocated views and local progress are excluded.
251    #[must_use]
252    pub fn digest(&self) -> ArtifactChecksumRecord {
253        let mut bytes = b"ic-backup/operation-plan/v1\0".to_vec();
254        bytes.extend_from_slice(self.context.network().as_bytes());
255        append_principal(&mut bytes, self.context.caller());
256        bytes.extend_from_slice(self.context.release().as_bytes());
257        bytes.extend_from_slice(self.inventory.digest().hash().as_bytes());
258        bytes.extend_from_slice(self.graph.digest().hash().as_bytes());
259        append_count(&mut bytes, self.selected_targets.len());
260        for target in &self.selected_targets {
261            append_principal(&mut bytes, target);
262        }
263        bytes.extend_from_slice(&self.budget.mutations().to_be_bytes());
264        bytes.extend_from_slice(&self.budget.observations().to_be_bytes());
265        append_count(&mut bytes, self.operations.len());
266        for operation in &self.operations {
267            bytes.extend_from_slice(&operation.operation_sequence().to_be_bytes());
268            append_principal(&mut bytes, operation.target());
269            bytes.extend_from_slice(operation.request().as_bytes());
270            bytes.extend_from_slice(&operation.budget().mutations().to_be_bytes());
271            bytes.extend_from_slice(&operation.budget().observations().to_be_bytes());
272        }
273        ArtifactChecksumRecord::from_bytes(&bytes)
274    }
275}
276fn append_principal(bytes: &mut Vec<u8>, text: &str) {
277    // Owning boundaries admit at most 63 canonical ASCII bytes, so the low byte is exact.
278    bytes.push(text.len().to_le_bytes()[0]);
279    bytes.extend_from_slice(text.as_bytes());
280}
281#[expect(
282    clippy::cast_possible_truncation,
283    reason = "validated selection/operation counts are at most 8192"
284)]
285fn append_count(bytes: &mut Vec<u8>, count: usize) {
286    bytes.extend_from_slice(&(count as u32).to_be_bytes());
287}
288fn canonical_hash(text: &str) -> Result<String, OperationPlanError> {
289    Ok(ArtifactChecksumRecord::from_hash(text)?.hash().into())
290}
291
292fn bounded_selected<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<String>, D::Error> {
293    bounded::<D, String, MAX_INVENTORY_TARGETS>(deserializer)
294}
295fn bounded_operations<'de, D: Deserializer<'de>>(
296    deserializer: D,
297) -> Result<Vec<PlannedOperationRecord>, D::Error> {
298    bounded::<D, PlannedOperationRecord, MAX_EFFECT_OPERATIONS>(deserializer)
299}
300fn bounded<'de, D, T, const LIMIT: usize>(deserializer: D) -> Result<Vec<T>, D::Error>
301where
302    D: Deserializer<'de>,
303    T: Deserialize<'de>,
304{
305    struct Visitor<T, const LIMIT: usize>(std::marker::PhantomData<T>);
306    impl<'de, T: Deserialize<'de>, const LIMIT: usize> de::Visitor<'de> for Visitor<T, LIMIT> {
307        type Value = Vec<T>;
308        fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
309            write!(f, "at most {LIMIT} plan entries")
310        }
311        fn visit_seq<A: de::SeqAccess<'de>>(
312            self,
313            mut sequence: A,
314        ) -> Result<Self::Value, A::Error> {
315            let mut entries = Vec::new();
316            while entries.len() < LIMIT {
317                match sequence.next_element()? {
318                    Some(entry) => entries.push(entry),
319                    None => return Ok(entries),
320                }
321            }
322            if sequence.next_element::<de::IgnoredAny>()?.is_some() {
323                return Err(de::Error::custom(format!(
324                    "plan list exceeds {LIMIT} entries"
325                )));
326            }
327            Ok(entries)
328        }
329    }
330    deserializer.deserialize_seq(Visitor::<T, LIMIT>(std::marker::PhantomData))
331}
332
333/// Typed original plan identity, structural binding or finite allowance rejection.
334#[derive(Debug, Error)]
335pub enum OperationPlanError {
336    /// Only protocol generation v1 is maintained.
337    #[error("unsupported operation plan version {0}")]
338    UnsupportedVersion(u16),
339    /// At least one exact physical selection is required.
340    #[error("operation plan has no selected targets")]
341    EmptySelection,
342    /// Exact selection exceeds the maintained inventory bound.
343    #[error("operation plan exceeds {MAX_INVENTORY_TARGETS} selected targets")]
344    TooManySelected,
345    /// Operation bindings exceed the maintained graph count.
346    #[error("operation plan exceeds {MAX_EFFECT_OPERATIONS} operations")]
347    TooManyOperations,
348    /// Principal text fails canonical admission at this named field.
349    #[error("invalid operation plan principal in {0}")]
350    InvalidPrincipal(&'static str),
351    /// Equivalent physical target was explicitly selected twice.
352    #[error("duplicate selected plan target {0}")]
353    DuplicateSelected(String),
354    /// Operation identity was bound more than once.
355    #[error("duplicate planned operation {0}")]
356    DuplicateOperation(u64),
357    /// Operation table does not have one binding per graph node.
358    #[error("operation table count differs from dependency graph")]
359    OperationCountMismatch,
360    /// Exact table identity differs from its corresponding canonical graph node.
361    #[error("operation table differs from graph: expected {expected}, actual {actual}")]
362    OperationGraphMismatch {
363        /// Exact graph operation identity.
364        expected: u64,
365        /// Rejected table identity.
366        actual: u64,
367    },
368    /// Exact operation target lies outside explicit selection.
369    #[error("operation targets unselected physical identity {0}")]
370    UnselectedTarget(String),
371    /// A selected physical target has no bound operation.
372    #[error("selected plan target has no operation {0}")]
373    UnusedTarget(String),
374    /// Requested exact operation is absent.
375    #[error("unknown planned operation {0}")]
376    UnknownOperation(u64),
377    /// Aggregate ceilings overflow or exceed the maintained maximum.
378    #[error("operation plan attempt ceiling exceeds {MAX_PLAN_ATTEMPTS}")]
379    BudgetTooLarge,
380    /// Assigned original allowances exceed a declared mutation/observation ceiling.
381    #[error("assigned operation attempts exceed original plan ceilings")]
382    AssignedBudgetExceeded,
383    /// Digest text fails canonical admission.
384    #[error(transparent)]
385    Checksum(#[from] ChecksumError),
386    /// Owning inventory rejects selected identity.
387    #[error(transparent)]
388    Inventory(#[from] InventoryRecordError),
389    /// Existing attempt authority boundary rejected derivation.
390    #[error(transparent)]
391    Attempt(#[from] AttemptJournalRecordError),
392}
393
394#[cfg(test)]
395pub(crate) mod tests;