Skip to main content

ic_backup/model/operation_plan/
mod.rs

1//! Immutable local binding of declarations to dependency-bearing operations and original attempt authority.
2
3use crate::model::evidence_digest::EvidenceDigest;
4mod budget;
5mod context;
6mod dependencies;
7mod operation;
8pub use budget::{AllocatedAttemptsView, MAX_PLAN_ATTEMPTS, PlanBudgetRecord};
9pub use context::{PlanContextRecord, PlanContextRequest};
10pub use dependencies::{MAX_PLAN_DEPENDENCIES, MAX_PLAN_EDGES, MAX_PLAN_OPERATIONS};
11pub use operation::{PlannedOperationRecord, PlannedOperationRequest};
12
13use crate::model::{
14    artifacts::{ArtifactChecksumRecord, ChecksumError},
15    attempt_journal::{
16        AttemptAuthorityRecord, AttemptJournalRecord, AttemptJournalRecordError,
17        OperationBindingRecord, OperationBindingRequest,
18    },
19    bounded_sequence,
20    inventory::{InventoryRecord, InventoryRecordError, MAX_INVENTORY_TARGETS},
21};
22use serde::{Deserialize, Deserializer, Serialize};
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    /// One exact dependency/target/request/original-budget binding per 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    operations: Vec<PlannedOperationRecord>,
55    budget: PlanBudgetRecord,
56    #[serde(skip)]
57    allocated: AllocatedAttemptsView,
58    #[serde(skip)]
59    order: Vec<usize>,
60}
61#[derive(Deserialize)]
62#[serde(deny_unknown_fields)]
63struct PlanFields {
64    version: u16,
65    context: PlanContextRecord,
66    inventory: InventoryRecord,
67    #[serde(deserialize_with = "bounded_selected")]
68    selected_targets: Vec<String>,
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            operations: fields.operations,
84            budget: fields.budget,
85        })
86    }
87}
88impl OperationPlanRecord {
89    /// Admit canonical exact dependency rows/physical selection and aggregate attempt binding.
90    ///
91    /// # Errors
92    /// Rejects invalid selection, operation identity mismatch, unused targets and excessive allowances.
93    pub fn new(mut request: OperationPlanRequest) -> Result<Self, OperationPlanError> {
94        if request.selected_targets.is_empty() {
95            return Err(OperationPlanError::EmptySelection);
96        }
97        if request.selected_targets.len() > MAX_INVENTORY_TARGETS {
98            return Err(OperationPlanError::TooManySelected);
99        }
100        for id in &mut request.selected_targets {
101            *id = request.inventory.target(id)?.canister_id().into();
102        }
103        request.selected_targets.sort();
104        for pair in request.selected_targets.windows(2) {
105            if pair[0] == pair[1] {
106                return Err(OperationPlanError::DuplicateSelected(pair[0].clone()));
107            }
108        }
109        if request.operations.is_empty() {
110            return Err(OperationPlanError::EmptyOperations);
111        }
112        if request.operations.len() > MAX_PLAN_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        let order = dependencies::order(&request.operations)?;
126        let mut used = vec![false; request.selected_targets.len()];
127        let mut allocated = AllocatedAttemptsView {
128            mutations: 0,
129            observations: 0,
130        };
131        for operation in &request.operations {
132            let selected = request
133                .selected_targets
134                .binary_search_by(|target| target.as_str().cmp(operation.target()))
135                .map_err(|_| OperationPlanError::UnselectedTarget(operation.target().into()))?;
136            used[selected] = true;
137            allocated.mutations = allocated
138                .mutations
139                .checked_add(operation.budget().mutations())
140                .ok_or(OperationPlanError::BudgetTooLarge)?;
141            allocated.observations = allocated
142                .observations
143                .checked_add(operation.budget().observations())
144                .ok_or(OperationPlanError::BudgetTooLarge)?;
145        }
146        if !request.budget.admits(allocated) {
147            return Err(OperationPlanError::AssignedBudgetExceeded);
148        }
149        for (id, used) in request.selected_targets.iter().zip(used) {
150            if !used {
151                return Err(OperationPlanError::UnusedTarget(id.clone()));
152            }
153        }
154        Ok(Self {
155            version: 1,
156            context: request.context,
157            inventory: request.inventory,
158            selected_targets: request.selected_targets,
159            order,
160            operations: request.operations,
161            budget: request.budget,
162            allocated,
163        })
164    }
165    /// Read immutable declared context.
166    #[must_use]
167    pub const fn context(&self) -> &PlanContextRecord {
168        &self.context
169    }
170    /// Read the full original declared inventory.
171    #[must_use]
172    pub const fn inventory(&self) -> &InventoryRecord {
173        &self.inventory
174    }
175    /// Read canonical exact physical selection, without effect-order semantics.
176    #[must_use]
177    pub fn selected_targets(&self) -> &[String] {
178        &self.selected_targets
179    }
180    /// Traverse dependency-bearing operations in deterministic topological order.
181    /// Smallest currently ready sequence wins ties; this grants no effect authority.
182    pub fn ordered_operations(
183        &self,
184    ) -> impl ExactSizeIterator<Item = &PlannedOperationRecord> + DoubleEndedIterator + '_ {
185        self.order.iter().map(|index| &self.operations[*index])
186    }
187    /// Read operation bindings in canonical sequence order.
188    #[must_use]
189    pub fn operations(&self) -> &[PlannedOperationRecord] {
190        &self.operations
191    }
192    // Exact singleton sequence admission only; callers retain payload and method checks.
193    pub(crate) fn singleton_operation(&self, sequence: u64) -> Option<&PlannedOperationRecord> {
194        match self.operations() {
195            [operation] if operation.operation_sequence() == sequence => Some(operation),
196            _ => None,
197        }
198    }
199
200    /// Read original aggregate attempt ceilings.
201    #[must_use]
202    pub const fn budget(&self) -> &PlanBudgetRecord {
203        &self.budget
204    }
205    /// Project assigned allowances only; this performs no observations or consumption.
206    #[must_use]
207    pub const fn allocated_attempts(&self) -> AllocatedAttemptsView {
208        self.allocated
209    }
210    /// Resolve an exact declared operation binding.
211    ///
212    /// # Errors
213    /// Rejects an operation absent from the exact original plan.
214    pub fn operation(&self, sequence: u64) -> Result<&PlannedOperationRecord, OperationPlanError> {
215        self.operations
216            .binary_search_by_key(&sequence, PlannedOperationRecord::operation_sequence)
217            .map(|index| &self.operations[index])
218            .map_err(|_| OperationPlanError::UnknownOperation(sequence))
219    }
220    /// Derive exact existing attempt authority under the full original canonical plan digest.
221    ///
222    /// Repeated derivation returns the same declaration and never creates/resets a journal.
223    /// Fresh authority, actual request bytes and dispatch admission stay caller-owned.
224    ///
225    /// # Errors
226    /// Rejects unknown operations or invalid derived identity admission.
227    pub fn attempt_authority(
228        &self,
229        sequence: u64,
230    ) -> Result<AttemptAuthorityRecord, OperationPlanError> {
231        let operation = self.operation(sequence)?;
232        self.authority_for_operation(operation, &self.digest())
233    }
234    // Match the original journal before payload/reservation admission. Callers retain
235    // their public error identity and choose when this ordered check occurs.
236    pub(crate) fn attempt_authority_for_journal<E: From<OperationPlanError>>(
237        &self,
238        sequence: u64,
239        journal: &AttemptJournalRecord,
240        mismatch: E,
241    ) -> Result<AttemptAuthorityRecord, E> {
242        let authority = self.attempt_authority(sequence)?;
243        if journal.authority() != &authority {
244            return Err(mismatch);
245        }
246        Ok(authority)
247    }
248
249    /// Derive every original authority in canonical sequence order with one full plan hash.
250    ///
251    /// This avoids repeated full-plan encoding for bounded complete evidence scans.
252    /// It grants no new allowance, journal creation/reset or fresh dispatch permission.
253    /// # Errors
254    /// Rejects invalid derived identity admission through the same scalar authority owner.
255    pub fn attempt_authorities(&self) -> Result<Vec<AttemptAuthorityRecord>, OperationPlanError> {
256        let intent = self.digest();
257        self.operations
258            .iter()
259            .map(|operation| self.authority_for_operation(operation, &intent))
260            .collect()
261    }
262    fn authority_for_operation(
263        &self,
264        operation: &PlannedOperationRecord,
265        intent: &ArtifactChecksumRecord,
266    ) -> Result<AttemptAuthorityRecord, OperationPlanError> {
267        Ok(AttemptAuthorityRecord::new(
268            self.binding_for_operation(operation, intent)?,
269            operation.budget().clone(),
270        ))
271    }
272
273    pub(crate) fn operation_binding(
274        &self,
275        sequence: u64,
276    ) -> Result<OperationBindingRecord, OperationPlanError> {
277        self.binding_for_operation(self.operation(sequence)?, &self.digest())
278    }
279
280    fn binding_for_operation(
281        &self,
282        operation: &PlannedOperationRecord,
283        intent: &ArtifactChecksumRecord,
284    ) -> Result<OperationBindingRecord, OperationPlanError> {
285        Ok(OperationBindingRecord::new(&OperationBindingRequest {
286            intent: intent.hash().into(),
287            operation_sequence: operation.operation_sequence(),
288            network: self.context.network().into(),
289            caller: self.context.caller().into(),
290            target: operation.target().into(),
291            release: self.context.release().into(),
292            request: operation.request().into(),
293        })?)
294    }
295    // Borrow the same original fields used by authority construction; progress
296    // admission must not grow a separate definition of original authority.
297    pub(crate) fn matches_authority(
298        &self,
299        operation: &PlannedOperationRecord,
300        intent: &ArtifactChecksumRecord,
301        authority: &AttemptAuthorityRecord,
302    ) -> bool {
303        let binding = authority.binding();
304        let identity_matches = binding.intent() == intent.hash()
305            && binding.operation_sequence() == operation.operation_sequence()
306            && binding.target() == operation.target()
307            && binding.request() == operation.request();
308        let context_matches = self.context.binding_mismatch(binding).is_none();
309        identity_matches && context_matches && authority.budget() == operation.budget()
310    }
311    /// Hash the entire canonical declaration using the maintained nonrecursive v1 encoding.
312    ///
313    /// Derived intent/authority, allocated views and local progress are excluded.
314    #[must_use]
315    pub fn digest(&self) -> ArtifactChecksumRecord {
316        let mut bytes = EvidenceDigest::new(b"ic-backup/operation-plan/dependency-rows/v1\0");
317        bytes.append(self.context.network().as_bytes());
318        bytes.bytes_u8(self.context.caller().as_bytes());
319        bytes.append(self.context.release().as_bytes());
320        bytes.checksum(&self.inventory.digest());
321        bytes.count_u32(self.selected_targets.len());
322        for target in &self.selected_targets {
323            bytes.bytes_u8(target.as_bytes());
324        }
325        bytes.u32_be(self.budget.mutations());
326        bytes.u32_be(self.budget.observations());
327        bytes.count_u32(self.operations.len());
328        for operation in &self.operations {
329            bytes.u64_be(operation.operation_sequence());
330            bytes.count_u32(operation.depends_on().len());
331            for dependency in operation.depends_on() {
332                bytes.u64_be(*dependency);
333            }
334            bytes.bytes_u8(operation.target().as_bytes());
335            bytes.append(operation.request().as_bytes());
336            bytes.u32_be(operation.budget().mutations());
337            bytes.u32_be(operation.budget().observations());
338        }
339        bytes.finish()
340    }
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    let mut edges = 0;
349    bounded_sequence::deserialize_checked::<D, PlannedOperationRecord, _, _, MAX_PLAN_OPERATIONS>(
350        deserializer,
351        "bounded dependency-bearing operations",
352        OperationPlanError::TooManyOperations,
353        |operation| {
354            edges = dependencies::add_edges(edges, operation.depends_on().len())?;
355            Ok::<(), OperationPlanError>(())
356        },
357    )
358}
359fn bounded<'de, D, T, const LIMIT: usize>(deserializer: D) -> Result<Vec<T>, D::Error>
360where
361    D: Deserializer<'de>,
362    T: Deserialize<'de>,
363{
364    bounded_sequence::deserialize::<_, _, LIMIT>(
365        deserializer,
366        format!("at most {LIMIT} plan entries"),
367        format!("plan list exceeds {LIMIT} entries"),
368    )
369}
370
371/// Typed original plan identity, structural binding or finite allowance rejection.
372#[derive(Debug, Error)]
373pub enum OperationPlanError {
374    /// Only protocol generation v1 is maintained.
375    #[error("unsupported operation plan version {0}")]
376    UnsupportedVersion(u16),
377    /// At least one exact physical selection is required.
378    #[error("operation plan has no selected targets")]
379    EmptySelection,
380    /// Exact selection exceeds the maintained inventory bound.
381    #[error("operation plan exceeds {MAX_INVENTORY_TARGETS} selected targets")]
382    TooManySelected,
383    /// Operation bindings exceed the maintained count.
384    #[error("operation plan exceeds {MAX_PLAN_OPERATIONS} operations")]
385    TooManyOperations,
386    /// Principal text fails canonical admission at this named field.
387    #[error("invalid operation plan principal in {0}")]
388    InvalidPrincipal(&'static str),
389    /// Equivalent physical target was explicitly selected twice.
390    #[error("duplicate selected plan target {0}")]
391    DuplicateSelected(String),
392    /// Operation identity was bound more than once.
393    #[error("duplicate planned operation {0}")]
394    DuplicateOperation(u64),
395    /// A plan must declare at least one operation.
396    #[error("operation plan contains no operations")]
397    EmptyOperations,
398    /// One operation exceeds the direct dependency bound.
399    #[error("operation exceeds {MAX_PLAN_DEPENDENCIES} dependencies")]
400    TooManyDependencies,
401    /// The plan exceeds its total dependency bound.
402    #[error("operation plan exceeds {MAX_PLAN_EDGES} edges")]
403    TooManyEdges,
404    /// A prerequisite was declared twice in one operation.
405    #[error("operation {operation_sequence} repeats dependency {dependency}")]
406    DuplicateDependency {
407        /// Original operation sequence.
408        operation_sequence: u64,
409        /// Repeated prerequisite sequence.
410        dependency: u64,
411    },
412    /// A prerequisite does not exist in the original operation rows.
413    #[error("operation {operation_sequence} has absent dependency {dependency}")]
414    MissingDependency {
415        /// Original operation sequence.
416        operation_sequence: u64,
417        /// Absent prerequisite sequence.
418        dependency: u64,
419    },
420    /// Dependencies contain a self-loop or longer cycle.
421    #[error("operation plan contains a dependency cycle")]
422    Cycle,
423    /// Exact operation target lies outside explicit selection.
424    #[error("operation targets unselected physical identity {0}")]
425    UnselectedTarget(String),
426    /// A selected physical target has no bound operation.
427    #[error("selected plan target has no operation {0}")]
428    UnusedTarget(String),
429    /// Requested exact operation is absent.
430    #[error("unknown planned operation {0}")]
431    UnknownOperation(u64),
432    /// Aggregate ceilings overflow or exceed the maintained maximum.
433    #[error("operation plan attempt ceiling exceeds {MAX_PLAN_ATTEMPTS}")]
434    BudgetTooLarge,
435    /// Assigned original allowances exceed a declared mutation/observation ceiling.
436    #[error("assigned operation attempts exceed original plan ceilings")]
437    AssignedBudgetExceeded,
438    /// Digest text fails canonical admission.
439    #[error(transparent)]
440    Checksum(#[from] ChecksumError),
441    /// Owning inventory rejects selected identity.
442    #[error(transparent)]
443    Inventory(#[from] InventoryRecordError),
444    /// Existing attempt authority boundary rejected derivation.
445    #[error(transparent)]
446    Attempt(#[from] AttemptJournalRecordError),
447}
448
449#[cfg(test)]
450mod tests;