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    /// Read original aggregate attempt ceilings.
198    #[must_use]
199    pub const fn budget(&self) -> &PlanBudgetRecord {
200        &self.budget
201    }
202    /// Project assigned allowances only; this performs no observations or consumption.
203    #[must_use]
204    pub const fn allocated_attempts(&self) -> AllocatedAttemptsView {
205        self.allocated
206    }
207    /// Resolve an exact declared operation binding.
208    ///
209    /// # Errors
210    /// Rejects an operation absent from the exact original plan.
211    pub fn operation(&self, sequence: u64) -> Result<&PlannedOperationRecord, OperationPlanError> {
212        self.operations
213            .binary_search_by_key(&sequence, PlannedOperationRecord::operation_sequence)
214            .map(|index| &self.operations[index])
215            .map_err(|_| OperationPlanError::UnknownOperation(sequence))
216    }
217    /// Derive exact existing attempt authority under the full original canonical plan digest.
218    ///
219    /// Repeated derivation returns the same declaration and never creates/resets a journal.
220    /// Fresh authority, actual request bytes and dispatch admission stay caller-owned.
221    ///
222    /// # Errors
223    /// Rejects unknown operations or invalid derived identity admission.
224    pub fn attempt_authority(
225        &self,
226        sequence: u64,
227    ) -> Result<AttemptAuthorityRecord, OperationPlanError> {
228        let operation = self.operation(sequence)?;
229        self.authority_for_operation(operation, &self.digest())
230    }
231    /// Derive every original authority in canonical sequence order with one full plan hash.
232    ///
233    /// This avoids repeated full-plan encoding for bounded complete evidence scans.
234    /// It grants no new allowance, journal creation/reset or fresh dispatch permission.
235    /// # Errors
236    /// Rejects invalid derived identity admission through the same scalar authority owner.
237    pub fn attempt_authorities(&self) -> Result<Vec<AttemptAuthorityRecord>, OperationPlanError> {
238        let intent = self.digest();
239        self.operations
240            .iter()
241            .map(|operation| self.authority_for_operation(operation, &intent))
242            .collect()
243    }
244    fn authority_for_operation(
245        &self,
246        operation: &PlannedOperationRecord,
247        intent: &ArtifactChecksumRecord,
248    ) -> Result<AttemptAuthorityRecord, OperationPlanError> {
249        Ok(AttemptAuthorityRecord::new(
250            self.binding_for_operation(operation, intent)?,
251            operation.budget().clone(),
252        ))
253    }
254
255    pub(crate) fn operation_binding(
256        &self,
257        sequence: u64,
258    ) -> Result<OperationBindingRecord, OperationPlanError> {
259        self.binding_for_operation(self.operation(sequence)?, &self.digest())
260    }
261
262    fn binding_for_operation(
263        &self,
264        operation: &PlannedOperationRecord,
265        intent: &ArtifactChecksumRecord,
266    ) -> Result<OperationBindingRecord, OperationPlanError> {
267        Ok(OperationBindingRecord::new(&OperationBindingRequest {
268            intent: intent.hash().into(),
269            operation_sequence: operation.operation_sequence(),
270            network: self.context.network().into(),
271            caller: self.context.caller().into(),
272            target: operation.target().into(),
273            release: self.context.release().into(),
274            request: operation.request().into(),
275        })?)
276    }
277    // Borrow the same original fields used by authority construction; progress
278    // admission must not grow a separate definition of original authority.
279    pub(crate) fn matches_authority(
280        &self,
281        operation: &PlannedOperationRecord,
282        intent: &ArtifactChecksumRecord,
283        authority: &AttemptAuthorityRecord,
284    ) -> bool {
285        let binding = authority.binding();
286        let identity_matches = binding.intent() == intent.hash()
287            && binding.operation_sequence() == operation.operation_sequence()
288            && binding.target() == operation.target()
289            && binding.request() == operation.request();
290        let context_matches = self.context.binding_mismatch(binding).is_none();
291        identity_matches && context_matches && authority.budget() == operation.budget()
292    }
293    /// Hash the entire canonical declaration using the maintained nonrecursive v1 encoding.
294    ///
295    /// Derived intent/authority, allocated views and local progress are excluded.
296    #[must_use]
297    pub fn digest(&self) -> ArtifactChecksumRecord {
298        let mut bytes = EvidenceDigest::new(b"ic-backup/operation-plan/v1\0");
299        bytes.append(self.context.network().as_bytes());
300        append_principal(&mut bytes, self.context.caller());
301        bytes.append(self.context.release().as_bytes());
302        bytes.checksum(&self.inventory.digest());
303        bytes.checksum(&self.graph.digest());
304        append_count(&mut bytes, self.selected_targets.len());
305        for target in &self.selected_targets {
306            append_principal(&mut bytes, target);
307        }
308        bytes.u32_be(self.budget.mutations());
309        bytes.u32_be(self.budget.observations());
310        append_count(&mut bytes, self.operations.len());
311        for operation in &self.operations {
312            bytes.u64_be(operation.operation_sequence());
313            append_principal(&mut bytes, operation.target());
314            bytes.append(operation.request().as_bytes());
315            bytes.u32_be(operation.budget().mutations());
316            bytes.u32_be(operation.budget().observations());
317        }
318        bytes.finish()
319    }
320}
321fn append_principal(bytes: &mut EvidenceDigest, text: &str) {
322    // Owning boundaries admit at most 63 canonical ASCII bytes, so the low byte is exact.
323    bytes.byte(text.len().to_le_bytes()[0]);
324    bytes.append(text.as_bytes());
325}
326#[expect(
327    clippy::cast_possible_truncation,
328    reason = "validated selection/operation counts are at most 8192"
329)]
330fn append_count(bytes: &mut EvidenceDigest, count: usize) {
331    bytes.u32_be(count as u32);
332}
333fn bounded_selected<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<String>, D::Error> {
334    bounded::<D, String, MAX_INVENTORY_TARGETS>(deserializer)
335}
336fn bounded_operations<'de, D: Deserializer<'de>>(
337    deserializer: D,
338) -> Result<Vec<PlannedOperationRecord>, D::Error> {
339    bounded::<D, PlannedOperationRecord, MAX_EFFECT_OPERATIONS>(deserializer)
340}
341fn bounded<'de, D, T, const LIMIT: usize>(deserializer: D) -> Result<Vec<T>, D::Error>
342where
343    D: Deserializer<'de>,
344    T: Deserialize<'de>,
345{
346    bounded_sequence::deserialize::<_, _, LIMIT>(
347        deserializer,
348        format!("at most {LIMIT} plan entries"),
349        format!("plan list exceeds {LIMIT} entries"),
350    )
351}
352
353/// Typed original plan identity, structural binding or finite allowance rejection.
354#[derive(Debug, Error)]
355pub enum OperationPlanError {
356    /// Only protocol generation v1 is maintained.
357    #[error("unsupported operation plan version {0}")]
358    UnsupportedVersion(u16),
359    /// At least one exact physical selection is required.
360    #[error("operation plan has no selected targets")]
361    EmptySelection,
362    /// Exact selection exceeds the maintained inventory bound.
363    #[error("operation plan exceeds {MAX_INVENTORY_TARGETS} selected targets")]
364    TooManySelected,
365    /// Operation bindings exceed the maintained graph count.
366    #[error("operation plan exceeds {MAX_EFFECT_OPERATIONS} operations")]
367    TooManyOperations,
368    /// Principal text fails canonical admission at this named field.
369    #[error("invalid operation plan principal in {0}")]
370    InvalidPrincipal(&'static str),
371    /// Equivalent physical target was explicitly selected twice.
372    #[error("duplicate selected plan target {0}")]
373    DuplicateSelected(String),
374    /// Operation identity was bound more than once.
375    #[error("duplicate planned operation {0}")]
376    DuplicateOperation(u64),
377    /// Operation table does not have one binding per graph node.
378    #[error("operation table count differs from dependency graph")]
379    OperationCountMismatch,
380    /// Exact table identity differs from its corresponding canonical graph node.
381    #[error("operation table differs from graph: expected {expected}, actual {actual}")]
382    OperationGraphMismatch {
383        /// Exact graph operation identity.
384        expected: u64,
385        /// Rejected table identity.
386        actual: u64,
387    },
388    /// Exact operation target lies outside explicit selection.
389    #[error("operation targets unselected physical identity {0}")]
390    UnselectedTarget(String),
391    /// A selected physical target has no bound operation.
392    #[error("selected plan target has no operation {0}")]
393    UnusedTarget(String),
394    /// Requested exact operation is absent.
395    #[error("unknown planned operation {0}")]
396    UnknownOperation(u64),
397    /// Aggregate ceilings overflow or exceed the maintained maximum.
398    #[error("operation plan attempt ceiling exceeds {MAX_PLAN_ATTEMPTS}")]
399    BudgetTooLarge,
400    /// Assigned original allowances exceed a declared mutation/observation ceiling.
401    #[error("assigned operation attempts exceed original plan ceilings")]
402    AssignedBudgetExceeded,
403    /// Digest text fails canonical admission.
404    #[error(transparent)]
405    Checksum(#[from] ChecksumError),
406    /// Owning inventory rejects selected identity.
407    #[error(transparent)]
408    Inventory(#[from] InventoryRecordError),
409    /// Existing attempt authority boundary rejected derivation.
410    #[error(transparent)]
411    Attempt(#[from] AttemptJournalRecordError),
412}
413
414#[cfg(test)]
415mod tests;