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