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 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 mut used = vec![false; request.selected_targets.len()];
128        let mut allocated = AllocatedAttemptsView {
129            mutations: 0,
130            observations: 0,
131        };
132        for (node, operation) in request.graph.nodes().iter().zip(&request.operations) {
133            if node.operation_sequence() != operation.operation_sequence() {
134                return Err(OperationPlanError::OperationGraphMismatch {
135                    expected: node.operation_sequence(),
136                    actual: operation.operation_sequence(),
137                });
138            }
139            let selected = request
140                .selected_targets
141                .binary_search_by(|target| target.as_str().cmp(operation.target()))
142                .map_err(|_| OperationPlanError::UnselectedTarget(operation.target().into()))?;
143            used[selected] = true;
144            allocated.mutations = allocated
145                .mutations
146                .checked_add(operation.budget().mutations())
147                .ok_or(OperationPlanError::BudgetTooLarge)?;
148            allocated.observations = allocated
149                .observations
150                .checked_add(operation.budget().observations())
151                .ok_or(OperationPlanError::BudgetTooLarge)?;
152        }
153        if !request.budget.admits(allocated) {
154            return Err(OperationPlanError::AssignedBudgetExceeded);
155        }
156        for (id, used) in request.selected_targets.iter().zip(used) {
157            if !used {
158                return Err(OperationPlanError::UnusedTarget(id.clone()));
159            }
160        }
161        Ok(Self {
162            version: 1,
163            context: request.context,
164            inventory: request.inventory,
165            selected_targets: request.selected_targets,
166            graph: request.graph,
167            operations: request.operations,
168            budget: request.budget,
169            allocated,
170        })
171    }
172    /// Read immutable declared context.
173    #[must_use]
174    pub const fn context(&self) -> &PlanContextRecord {
175        &self.context
176    }
177    /// Read the full original declared inventory.
178    #[must_use]
179    pub const fn inventory(&self) -> &InventoryRecord {
180        &self.inventory
181    }
182    /// Read canonical exact physical selection, without effect-order semantics.
183    #[must_use]
184    pub fn selected_targets(&self) -> &[String] {
185        &self.selected_targets
186    }
187    /// Read the bound original explicit dependency graph.
188    #[must_use]
189    pub const fn graph(&self) -> &EffectGraphRecord {
190        &self.graph
191    }
192    /// Read operation bindings in canonical sequence order.
193    #[must_use]
194    pub fn operations(&self) -> &[PlannedOperationRecord] {
195        &self.operations
196    }
197    // Exact singleton sequence admission only; callers retain payload and method checks.
198    pub(crate) fn singleton_operation(&self, sequence: u64) -> Option<&PlannedOperationRecord> {
199        match self.operations() {
200            [operation] if operation.operation_sequence() == sequence => Some(operation),
201            _ => None,
202        }
203    }
204
205    /// Read original aggregate attempt ceilings.
206    #[must_use]
207    pub const fn budget(&self) -> &PlanBudgetRecord {
208        &self.budget
209    }
210    /// Project assigned allowances only; this performs no observations or consumption.
211    #[must_use]
212    pub const fn allocated_attempts(&self) -> AllocatedAttemptsView {
213        self.allocated
214    }
215    /// Resolve an exact declared operation binding.
216    ///
217    /// # Errors
218    /// Rejects an operation absent from the exact original plan.
219    pub fn operation(&self, sequence: u64) -> Result<&PlannedOperationRecord, OperationPlanError> {
220        self.operations
221            .binary_search_by_key(&sequence, PlannedOperationRecord::operation_sequence)
222            .map(|index| &self.operations[index])
223            .map_err(|_| OperationPlanError::UnknownOperation(sequence))
224    }
225    /// Derive exact existing attempt authority under the full original canonical plan digest.
226    ///
227    /// Repeated derivation returns the same declaration and never creates/resets a journal.
228    /// Fresh authority, actual request bytes and dispatch admission stay caller-owned.
229    ///
230    /// # Errors
231    /// Rejects unknown operations or invalid derived identity admission.
232    pub fn attempt_authority(
233        &self,
234        sequence: u64,
235    ) -> Result<AttemptAuthorityRecord, OperationPlanError> {
236        let operation = self.operation(sequence)?;
237        self.authority_for_operation(operation, &self.digest())
238    }
239    /// Derive every original authority in canonical sequence order with one full plan hash.
240    ///
241    /// This avoids repeated full-plan encoding for bounded complete evidence scans.
242    /// It grants no new allowance, journal creation/reset or fresh dispatch permission.
243    /// # Errors
244    /// Rejects invalid derived identity admission through the same scalar authority owner.
245    pub fn attempt_authorities(&self) -> Result<Vec<AttemptAuthorityRecord>, OperationPlanError> {
246        let intent = self.digest();
247        self.operations
248            .iter()
249            .map(|operation| self.authority_for_operation(operation, &intent))
250            .collect()
251    }
252    fn authority_for_operation(
253        &self,
254        operation: &PlannedOperationRecord,
255        intent: &ArtifactChecksumRecord,
256    ) -> Result<AttemptAuthorityRecord, OperationPlanError> {
257        Ok(AttemptAuthorityRecord::new(
258            self.binding_for_operation(operation, intent)?,
259            operation.budget().clone(),
260        ))
261    }
262
263    pub(crate) fn operation_binding(
264        &self,
265        sequence: u64,
266    ) -> Result<OperationBindingRecord, OperationPlanError> {
267        self.binding_for_operation(self.operation(sequence)?, &self.digest())
268    }
269
270    fn binding_for_operation(
271        &self,
272        operation: &PlannedOperationRecord,
273        intent: &ArtifactChecksumRecord,
274    ) -> Result<OperationBindingRecord, OperationPlanError> {
275        Ok(OperationBindingRecord::new(&OperationBindingRequest {
276            intent: intent.hash().into(),
277            operation_sequence: operation.operation_sequence(),
278            network: self.context.network().into(),
279            caller: self.context.caller().into(),
280            target: operation.target().into(),
281            release: self.context.release().into(),
282            request: operation.request().into(),
283        })?)
284    }
285    // Borrow the same original fields used by authority construction; progress
286    // admission must not grow a separate definition of original authority.
287    pub(crate) fn matches_authority(
288        &self,
289        operation: &PlannedOperationRecord,
290        intent: &ArtifactChecksumRecord,
291        authority: &AttemptAuthorityRecord,
292    ) -> bool {
293        let binding = authority.binding();
294        let identity_matches = binding.intent() == intent.hash()
295            && binding.operation_sequence() == operation.operation_sequence()
296            && binding.target() == operation.target()
297            && binding.request() == operation.request();
298        let context_matches = self.context.binding_mismatch(binding).is_none();
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        bytes.bytes_u8(self.context.caller().as_bytes());
309        bytes.append(self.context.release().as_bytes());
310        bytes.checksum(&self.inventory.digest());
311        bytes.checksum(&self.graph.digest());
312        append_count(&mut bytes, self.selected_targets.len());
313        for target in &self.selected_targets {
314            bytes.bytes_u8(target.as_bytes());
315        }
316        bytes.u32_be(self.budget.mutations());
317        bytes.u32_be(self.budget.observations());
318        append_count(&mut bytes, self.operations.len());
319        for operation in &self.operations {
320            bytes.u64_be(operation.operation_sequence());
321            bytes.bytes_u8(operation.target().as_bytes());
322            bytes.append(operation.request().as_bytes());
323            bytes.u32_be(operation.budget().mutations());
324            bytes.u32_be(operation.budget().observations());
325        }
326        bytes.finish()
327    }
328}
329#[expect(
330    clippy::cast_possible_truncation,
331    reason = "validated selection/operation counts are at most 8192"
332)]
333fn append_count(bytes: &mut EvidenceDigest, count: usize) {
334    bytes.u32_be(count as u32);
335}
336fn bounded_selected<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<String>, D::Error> {
337    bounded::<D, String, MAX_INVENTORY_TARGETS>(deserializer)
338}
339fn bounded_operations<'de, D: Deserializer<'de>>(
340    deserializer: D,
341) -> Result<Vec<PlannedOperationRecord>, D::Error> {
342    bounded::<D, PlannedOperationRecord, MAX_EFFECT_OPERATIONS>(deserializer)
343}
344fn bounded<'de, D, T, const LIMIT: usize>(deserializer: D) -> Result<Vec<T>, D::Error>
345where
346    D: Deserializer<'de>,
347    T: Deserialize<'de>,
348{
349    bounded_sequence::deserialize::<_, _, LIMIT>(
350        deserializer,
351        format!("at most {LIMIT} plan entries"),
352        format!("plan list exceeds {LIMIT} entries"),
353    )
354}
355
356/// Typed original plan identity, structural binding or finite allowance rejection.
357#[derive(Debug, Error)]
358pub enum OperationPlanError {
359    /// Only protocol generation v1 is maintained.
360    #[error("unsupported operation plan version {0}")]
361    UnsupportedVersion(u16),
362    /// At least one exact physical selection is required.
363    #[error("operation plan has no selected targets")]
364    EmptySelection,
365    /// Exact selection exceeds the maintained inventory bound.
366    #[error("operation plan exceeds {MAX_INVENTORY_TARGETS} selected targets")]
367    TooManySelected,
368    /// Operation bindings exceed the maintained graph count.
369    #[error("operation plan exceeds {MAX_EFFECT_OPERATIONS} operations")]
370    TooManyOperations,
371    /// Principal text fails canonical admission at this named field.
372    #[error("invalid operation plan principal in {0}")]
373    InvalidPrincipal(&'static str),
374    /// Equivalent physical target was explicitly selected twice.
375    #[error("duplicate selected plan target {0}")]
376    DuplicateSelected(String),
377    /// Operation identity was bound more than once.
378    #[error("duplicate planned operation {0}")]
379    DuplicateOperation(u64),
380    /// Operation table does not have one binding per graph node.
381    #[error("operation table count differs from dependency graph")]
382    OperationCountMismatch,
383    /// Exact table identity differs from its corresponding canonical graph node.
384    #[error("operation table differs from graph: expected {expected}, actual {actual}")]
385    OperationGraphMismatch {
386        /// Exact graph operation identity.
387        expected: u64,
388        /// Rejected table identity.
389        actual: u64,
390    },
391    /// Exact operation target lies outside explicit selection.
392    #[error("operation targets unselected physical identity {0}")]
393    UnselectedTarget(String),
394    /// A selected physical target has no bound operation.
395    #[error("selected plan target has no operation {0}")]
396    UnusedTarget(String),
397    /// Requested exact operation is absent.
398    #[error("unknown planned operation {0}")]
399    UnknownOperation(u64),
400    /// Aggregate ceilings overflow or exceed the maintained maximum.
401    #[error("operation plan attempt ceiling exceeds {MAX_PLAN_ATTEMPTS}")]
402    BudgetTooLarge,
403    /// Assigned original allowances exceed a declared mutation/observation ceiling.
404    #[error("assigned operation attempts exceed original plan ceilings")]
405    AssignedBudgetExceeded,
406    /// Digest text fails canonical admission.
407    #[error(transparent)]
408    Checksum(#[from] ChecksumError),
409    /// Owning inventory rejects selected identity.
410    #[error(transparent)]
411    Inventory(#[from] InventoryRecordError),
412    /// Existing attempt authority boundary rejected derivation.
413    #[error(transparent)]
414    Attempt(#[from] AttemptJournalRecordError),
415}
416
417#[cfg(test)]
418mod tests;