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