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 mut used = BTreeSet::new();
129        let mut allocated = AllocatedAttemptsView {
130            mutations: 0,
131            observations: 0,
132        };
133        for (node, operation) in request.graph.nodes().iter().zip(&request.operations) {
134            if node.operation_sequence() != operation.operation_sequence() {
135                return Err(OperationPlanError::OperationGraphMismatch {
136                    expected: node.operation_sequence(),
137                    actual: operation.operation_sequence(),
138                });
139            }
140            if request
141                .selected_targets
142                .binary_search_by(|target| target.as_str().cmp(operation.target()))
143                .is_err()
144            {
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        Ok(AttemptAuthorityRecord::new(
256            self.binding_for_operation(operation, intent)?,
257            operation.budget().clone(),
258        ))
259    }
260
261    pub(crate) fn operation_binding(
262        &self,
263        sequence: u64,
264    ) -> Result<OperationBindingRecord, OperationPlanError> {
265        self.binding_for_operation(self.operation(sequence)?, &self.digest())
266    }
267
268    fn binding_for_operation(
269        &self,
270        operation: &PlannedOperationRecord,
271        intent: &ArtifactChecksumRecord,
272    ) -> Result<OperationBindingRecord, OperationPlanError> {
273        Ok(OperationBindingRecord::new(&OperationBindingRequest {
274            intent: intent.hash().into(),
275            operation_sequence: operation.operation_sequence(),
276            network: self.context.network().into(),
277            caller: self.context.caller().into(),
278            target: operation.target().into(),
279            release: self.context.release().into(),
280            request: operation.request().into(),
281        })?)
282    }
283    // Borrow the same original fields used by authority construction; progress
284    // admission must not grow a separate definition of original authority.
285    pub(crate) fn matches_authority(
286        &self,
287        operation: &PlannedOperationRecord,
288        intent: &ArtifactChecksumRecord,
289        authority: &AttemptAuthorityRecord,
290    ) -> bool {
291        let binding = authority.binding();
292        let identity_matches = binding.intent() == intent.hash()
293            && binding.operation_sequence() == operation.operation_sequence()
294            && binding.target() == operation.target()
295            && binding.request() == operation.request();
296        let context_matches = binding.network() == self.context.network()
297            && binding.caller() == self.context.caller()
298            && binding.release() == self.context.release();
299        identity_matches && context_matches && authority.budget() == operation.budget()
300    }
301    /// Hash the entire canonical declaration using the maintained nonrecursive v1 encoding.
302    ///
303    /// Derived intent/authority, allocated views and local progress are excluded.
304    #[must_use]
305    pub fn digest(&self) -> ArtifactChecksumRecord {
306        let mut bytes = EvidenceDigest::new(b"ic-backup/operation-plan/v1\0");
307        bytes.append(self.context.network().as_bytes());
308        append_principal(&mut bytes, self.context.caller());
309        bytes.append(self.context.release().as_bytes());
310        bytes.append(self.inventory.digest().hash().as_bytes());
311        bytes.append(self.graph.digest().hash().as_bytes());
312        append_count(&mut bytes, self.selected_targets.len());
313        for target in &self.selected_targets {
314            append_principal(&mut bytes, target);
315        }
316        bytes.append(&self.budget.mutations().to_be_bytes());
317        bytes.append(&self.budget.observations().to_be_bytes());
318        append_count(&mut bytes, self.operations.len());
319        for operation in &self.operations {
320            bytes.append(&operation.operation_sequence().to_be_bytes());
321            append_principal(&mut bytes, operation.target());
322            bytes.append(operation.request().as_bytes());
323            bytes.append(&operation.budget().mutations().to_be_bytes());
324            bytes.append(&operation.budget().observations().to_be_bytes());
325        }
326        bytes.finish()
327    }
328}
329fn append_principal(bytes: &mut EvidenceDigest, text: &str) {
330    // Owning boundaries admit at most 63 canonical ASCII bytes, so the low byte is exact.
331    bytes.byte(text.len().to_le_bytes()[0]);
332    bytes.append(text.as_bytes());
333}
334#[expect(
335    clippy::cast_possible_truncation,
336    reason = "validated selection/operation counts are at most 8192"
337)]
338fn append_count(bytes: &mut EvidenceDigest, count: usize) {
339    bytes.append(&(count as u32).to_be_bytes());
340}
341fn bounded_selected<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<String>, D::Error> {
342    bounded::<D, String, MAX_INVENTORY_TARGETS>(deserializer)
343}
344fn bounded_operations<'de, D: Deserializer<'de>>(
345    deserializer: D,
346) -> Result<Vec<PlannedOperationRecord>, D::Error> {
347    bounded::<D, PlannedOperationRecord, MAX_EFFECT_OPERATIONS>(deserializer)
348}
349fn bounded<'de, D, T, const LIMIT: usize>(deserializer: D) -> Result<Vec<T>, D::Error>
350where
351    D: Deserializer<'de>,
352    T: Deserialize<'de>,
353{
354    bounded_sequence::deserialize::<_, _, LIMIT>(
355        deserializer,
356        format!("at most {LIMIT} plan entries"),
357        format!("plan list exceeds {LIMIT} entries"),
358    )
359}
360
361/// Typed original plan identity, structural binding or finite allowance rejection.
362#[derive(Debug, Error)]
363pub enum OperationPlanError {
364    /// Only protocol generation v1 is maintained.
365    #[error("unsupported operation plan version {0}")]
366    UnsupportedVersion(u16),
367    /// At least one exact physical selection is required.
368    #[error("operation plan has no selected targets")]
369    EmptySelection,
370    /// Exact selection exceeds the maintained inventory bound.
371    #[error("operation plan exceeds {MAX_INVENTORY_TARGETS} selected targets")]
372    TooManySelected,
373    /// Operation bindings exceed the maintained graph count.
374    #[error("operation plan exceeds {MAX_EFFECT_OPERATIONS} operations")]
375    TooManyOperations,
376    /// Principal text fails canonical admission at this named field.
377    #[error("invalid operation plan principal in {0}")]
378    InvalidPrincipal(&'static str),
379    /// Equivalent physical target was explicitly selected twice.
380    #[error("duplicate selected plan target {0}")]
381    DuplicateSelected(String),
382    /// Operation identity was bound more than once.
383    #[error("duplicate planned operation {0}")]
384    DuplicateOperation(u64),
385    /// Operation table does not have one binding per graph node.
386    #[error("operation table count differs from dependency graph")]
387    OperationCountMismatch,
388    /// Exact table identity differs from its corresponding canonical graph node.
389    #[error("operation table differs from graph: expected {expected}, actual {actual}")]
390    OperationGraphMismatch {
391        /// Exact graph operation identity.
392        expected: u64,
393        /// Rejected table identity.
394        actual: u64,
395    },
396    /// Exact operation target lies outside explicit selection.
397    #[error("operation targets unselected physical identity {0}")]
398    UnselectedTarget(String),
399    /// A selected physical target has no bound operation.
400    #[error("selected plan target has no operation {0}")]
401    UnusedTarget(String),
402    /// Requested exact operation is absent.
403    #[error("unknown planned operation {0}")]
404    UnknownOperation(u64),
405    /// Aggregate ceilings overflow or exceed the maintained maximum.
406    #[error("operation plan attempt ceiling exceeds {MAX_PLAN_ATTEMPTS}")]
407    BudgetTooLarge,
408    /// Assigned original allowances exceed a declared mutation/observation ceiling.
409    #[error("assigned operation attempts exceed original plan ceilings")]
410    AssignedBudgetExceeded,
411    /// Digest text fails canonical admission.
412    #[error(transparent)]
413    Checksum(#[from] ChecksumError),
414    /// Owning inventory rejects selected identity.
415    #[error(transparent)]
416    Inventory(#[from] InventoryRecordError),
417    /// Existing attempt authority boundary rejected derivation.
418    #[error(transparent)]
419    Attempt(#[from] AttemptJournalRecordError),
420}
421
422#[cfg(test)]
423pub(crate) mod tests;