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