Skip to main content

canic_backup/plan/preflight/
receipts.rs

1//! Module: plan::preflight::receipts
2//!
3//! Responsibility: validate execution preflight receipt bundles.
4//! Does not own: authority probing, topology observation, or quiescence enforcement.
5//! Boundary: gates backup mutation on accepted topology, authority, and quiescence receipts.
6
7use crate::plan::{
8    BackupExecutionPreflightReceipts, BackupPlan, BackupPlanError, QuiescencePreflightReceipt,
9    TopologyPreflightReceipt,
10    validation::{
11        validate_nonempty, validate_optional_nonempty, validate_preflight_id,
12        validate_preflight_timestamp, validate_preflight_window, validate_required_hash,
13    },
14};
15
16impl BackupPlan {
17    /// Validate execution-only preflight receipts before mutation starts.
18    pub fn validate_execution_preflight_receipts(
19        &self,
20        topology_receipt: &TopologyPreflightReceipt,
21        quiescence_receipt: &QuiescencePreflightReceipt,
22        preflight_id: &str,
23        as_of: &str,
24    ) -> Result<(), BackupPlanError> {
25        self.validate_for_execution()?;
26        validate_preflight_id(preflight_id)?;
27        validate_preflight_timestamp("preflight.as_of", as_of)?;
28        validate_topology_preflight_receipt(self, topology_receipt, preflight_id, as_of)?;
29        validate_quiescence_preflight_receipt(self, quiescence_receipt, preflight_id, as_of)
30    }
31
32    /// Apply and validate the full execution preflight receipt bundle.
33    pub fn apply_execution_preflight_receipts(
34        &mut self,
35        receipts: &BackupExecutionPreflightReceipts,
36        as_of: &str,
37    ) -> Result<(), BackupPlanError> {
38        validate_execution_preflight_bundle(self, receipts, as_of)?;
39        self.apply_authority_preflight_receipts(
40            &receipts.preflight_id,
41            &receipts.control_authority,
42            &receipts.snapshot_read_authority,
43            as_of,
44        )?;
45        self.validate_execution_preflight_receipts(
46            &receipts.topology,
47            &receipts.quiescence,
48            &receipts.preflight_id,
49            as_of,
50        )
51    }
52}
53
54fn validate_execution_preflight_bundle(
55    plan: &BackupPlan,
56    receipts: &BackupExecutionPreflightReceipts,
57    as_of: &str,
58) -> Result<(), BackupPlanError> {
59    validate_nonempty("preflight_receipts.plan_id", &receipts.plan_id)?;
60    validate_preflight_id(&receipts.preflight_id)?;
61    validate_preflight_timestamp("preflight_receipts.as_of", as_of)?;
62    validate_preflight_window(
63        &receipts.preflight_id,
64        &receipts.validated_at,
65        &receipts.expires_at,
66        as_of,
67    )?;
68
69    if receipts.plan_id != plan.plan_id {
70        return Err(BackupPlanError::PreflightReceiptPlanMismatch {
71            expected: plan.plan_id.clone(),
72            actual: receipts.plan_id.clone(),
73        });
74    }
75
76    Ok(())
77}
78
79fn validate_topology_preflight_receipt(
80    plan: &BackupPlan,
81    receipt: &TopologyPreflightReceipt,
82    preflight_id: &str,
83    as_of: &str,
84) -> Result<(), BackupPlanError> {
85    validate_nonempty("topology_receipt.plan_id", &receipt.plan_id)?;
86    validate_preflight_id(&receipt.preflight_id)?;
87    validate_required_hash(
88        "topology_receipt.topology_hash_before_quiesce",
89        &receipt.topology_hash_before_quiesce,
90    )?;
91    validate_required_hash(
92        "topology_receipt.topology_hash_at_preflight",
93        &receipt.topology_hash_at_preflight,
94    )?;
95    validate_optional_nonempty("topology_receipt.message", receipt.message.as_deref())?;
96    validate_preflight_window(
97        &receipt.preflight_id,
98        &receipt.validated_at,
99        &receipt.expires_at,
100        as_of,
101    )?;
102
103    if receipt.plan_id != plan.plan_id {
104        return Err(BackupPlanError::PreflightReceiptPlanMismatch {
105            expected: plan.plan_id.clone(),
106            actual: receipt.plan_id.clone(),
107        });
108    }
109    if receipt.preflight_id != preflight_id {
110        return Err(BackupPlanError::PreflightReceiptIdMismatch {
111            expected: preflight_id.to_string(),
112            actual: receipt.preflight_id.clone(),
113        });
114    }
115    if receipt.topology_hash_before_quiesce != plan.topology_hash_before_quiesce {
116        return Err(BackupPlanError::TopologyPreflightHashMismatch {
117            expected: plan.topology_hash_before_quiesce.clone(),
118            actual: receipt.topology_hash_before_quiesce.clone(),
119        });
120    }
121    if receipt.topology_hash_at_preflight != plan.topology_hash_before_quiesce {
122        return Err(BackupPlanError::TopologyPreflightHashMismatch {
123            expected: plan.topology_hash_before_quiesce.clone(),
124            actual: receipt.topology_hash_at_preflight.clone(),
125        });
126    }
127    if receipt.targets != plan.topology_preflight_request().targets {
128        return Err(BackupPlanError::TopologyPreflightTargetsMismatch);
129    }
130
131    Ok(())
132}
133
134fn validate_quiescence_preflight_receipt(
135    plan: &BackupPlan,
136    receipt: &QuiescencePreflightReceipt,
137    preflight_id: &str,
138    as_of: &str,
139) -> Result<(), BackupPlanError> {
140    validate_nonempty("quiescence_receipt.plan_id", &receipt.plan_id)?;
141    validate_preflight_id(&receipt.preflight_id)?;
142    validate_optional_nonempty("quiescence_receipt.message", receipt.message.as_deref())?;
143    validate_preflight_window(
144        &receipt.preflight_id,
145        &receipt.validated_at,
146        &receipt.expires_at,
147        as_of,
148    )?;
149
150    if receipt.plan_id != plan.plan_id {
151        return Err(BackupPlanError::PreflightReceiptPlanMismatch {
152            expected: plan.plan_id.clone(),
153            actual: receipt.plan_id.clone(),
154        });
155    }
156    if receipt.preflight_id != preflight_id {
157        return Err(BackupPlanError::PreflightReceiptIdMismatch {
158            expected: preflight_id.to_string(),
159            actual: receipt.preflight_id.clone(),
160        });
161    }
162    if receipt.quiescence_policy != plan.quiescence_policy {
163        return Err(BackupPlanError::QuiescencePolicyMismatch);
164    }
165    if !receipt.accepted {
166        return Err(BackupPlanError::QuiescencePreflightRejected);
167    }
168    if receipt.targets != plan.quiescence_preflight_request().targets {
169        return Err(BackupPlanError::QuiescencePreflightTargetsMismatch);
170    }
171
172    Ok(())
173}