Skip to main content

appcore_update/
recovery.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: recovery.rs
4//    ##   ## ##   ##    P: AppCore-Runtime
5//         ## ##
6//                       C: 2026/09/24 00:00:00 by dnettoRaw
7//    ##   ## ##   ##    U: 2026/09/24 00:00:00 by dnettoRaw
8//      ###########      S: 1.0.3-rc
9// =============================================================================
10
11//! Versioned activation receipts and explicit recovery replay.
12
13use crate::store_io::{
14    atomic_write_json, read_json_bounded, remove_if_exists, JsonReadError,
15    MAX_UPDATE_METADATA_BYTES,
16};
17use crate::{ArtifactDescriptor, UpdateError, UpdateResult};
18use serde::{Deserialize, Serialize};
19use std::fs;
20use std::path::{Path, PathBuf};
21
22/// Version of the additive activation recovery journal.
23pub const ACTIVATION_V2_FORMAT_VERSION: u16 = 2;
24const MAX_ATTEMPT_ID_BYTES: usize = 128;
25const MAX_REASON_BYTES: usize = 512;
26const MAX_HOST_BINDING_BYTES: usize = 512;
27
28/// Persisted phase of one V2 activation attempt.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(rename_all = "snake_case")]
31pub enum ActivationPhaseV2 {
32    /// Artifact is prepared but not active.
33    Prepared,
34    /// Artifact is active and awaits host health evidence.
35    ActivatedPendingHealth,
36    /// Host confirmed the activated artifact.
37    Committed,
38    /// Activation failed and the host must explicitly restore the previous artifact.
39    RollbackRequired,
40    /// The host confirmed that the previous artifact was restored.
41    RolledBack,
42    /// A preparation was explicitly discarded before activation.
43    Aborted,
44    /// Evidence conflicts or is insufficient for an automatic action.
45    ManualReviewRequired,
46}
47
48/// Versioned V2 activation receipt kept separately from the V1 receipt.
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50pub struct ActivationReceiptV2 {
51    /// Persisted receipt format.
52    pub format_version: u16,
53    /// Unique attempt identity used for fencing and replay.
54    pub attempt_id: String,
55    /// Current activation phase.
56    pub phase: ActivationPhaseV2,
57    /// Artifact being activated.
58    pub activated: ArtifactDescriptor,
59    /// Artifact that the host may restore, when one exists.
60    pub previous: Option<ArtifactDescriptor>,
61    /// Creation timestamp supplied by the deployment clock.
62    pub created_at_ms: u64,
63    /// Last phase transition timestamp supplied by the deployment clock.
64    pub updated_at_ms: u64,
65    /// Optional opaque host binding, never interpreted by the Runtime.
66    pub host_binding: Option<String>,
67    /// Bounded failure detail for the recovery operator.
68    pub failure_reason: Option<String>,
69}
70
71impl ActivationReceiptV2 {
72    /// Validates the receipt without consulting external installation state.
73    pub fn validate(&self) -> UpdateResult<()> {
74        if self.format_version != ACTIVATION_V2_FORMAT_VERSION {
75            return Err(UpdateError::Recovery(
76                "NO MORE SUPPORTED PLEASE UPDATE".to_string(),
77            ));
78        }
79        validate_text("attempt_id", &self.attempt_id, MAX_ATTEMPT_ID_BYTES)?;
80        if self.updated_at_ms < self.created_at_ms {
81            return Err(UpdateError::Recovery(
82                "receipt transition timestamp precedes creation".to_string(),
83            ));
84        }
85        self.activated.validate()?;
86        if let Some(previous) = &self.previous {
87            previous.validate()?;
88        }
89        if let Some(binding) = &self.host_binding {
90            validate_text("host_binding", binding, MAX_HOST_BINDING_BYTES)?;
91        }
92        if let Some(reason) = &self.failure_reason {
93            validate_text("failure_reason", reason, MAX_REASON_BYTES)?;
94        }
95        Ok(())
96    }
97}
98
99/// Read-only conclusion from one recovery inspection.
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub enum RecoveryDecision {
102    /// No V2 receipt is present.
103    Idle,
104    /// A receipt requires an explicit host action.
105    PendingActivation {
106        /// Pending receipt.
107        receipt: ActivationReceiptV2,
108    },
109    /// The attempt is durably committed.
110    Committed {
111        /// Committed receipt.
112        receipt: ActivationReceiptV2,
113    },
114    /// The host must restore the previous artifact.
115    RollbackRequired {
116        /// Receipt requiring host rollback.
117        receipt: ActivationReceiptV2,
118    },
119    /// The previous artifact was restored and recorded.
120    RolledBack {
121        /// Receipt with externally confirmed rollback.
122        receipt: ActivationReceiptV2,
123    },
124    /// A preparation was explicitly discarded before activation.
125    Aborted {
126        /// Receipt explicitly discarded before activation.
127        receipt: ActivationReceiptV2,
128    },
129    /// Automatic replay is forbidden until an operator reviews the evidence.
130    ManualReviewRequired {
131        /// Bounded reason for manual review.
132        reason: String,
133    },
134}
135
136/// Explicit host action applied to a V2 recovery receipt.
137#[derive(Debug, Clone, PartialEq, Eq)]
138pub enum RecoveryAction {
139    /// Confirms health and commits the currently active artifact.
140    ConfirmHealthy {
141        /// Attempt being confirmed.
142        attempt_id: String,
143        /// Digest observed by the host as active.
144        activated_sha256: String,
145        /// Timestamp of the host evidence.
146        at_ms: u64,
147    },
148    /// Records that health failed and makes rollback required.
149    RequireRollback {
150        /// Attempt whose activation failed.
151        attempt_id: String,
152        /// Bounded operator-visible reason.
153        reason: String,
154        /// Timestamp of the host evidence.
155        at_ms: u64,
156    },
157    /// Records an externally completed rollback.
158    RecordRolledBack {
159        /// Attempt being finalized.
160        attempt_id: String,
161        /// Digest observed as active after rollback, if one exists.
162        active_sha256: Option<String>,
163        /// Timestamp of the host evidence.
164        at_ms: u64,
165    },
166    /// Discards a preparation that was never activated.
167    DiscardPrepared {
168        /// Attempt being discarded.
169        attempt_id: String,
170        /// Timestamp of the discard decision.
171        at_ms: u64,
172    },
173    /// Moves the receipt to manual review without guessing an outcome.
174    MarkManualReview {
175        /// Attempt being quarantined for review.
176        attempt_id: String,
177        /// Bounded operator-visible reason.
178        reason: String,
179        /// Timestamp of the decision.
180        at_ms: u64,
181    },
182}
183
184/// Result of an explicit recovery action.
185#[derive(Debug, Clone, PartialEq, Eq)]
186pub struct RecoveryResult {
187    /// Phase after the action.
188    pub phase: ActivationPhaseV2,
189    /// Whether the action observed an already-completed identical transition.
190    pub idempotent: bool,
191}
192
193/// Filesystem journal for the additive V2 recovery contract.
194#[derive(Debug, Clone)]
195pub struct FileRecoveryStore {
196    root: PathBuf,
197}
198
199impl FileRecoveryStore {
200    /// Opens or creates a V2 recovery journal root.
201    pub fn open(root: impl Into<PathBuf>) -> UpdateResult<Self> {
202        let store = Self { root: root.into() };
203        fs::create_dir_all(&store.root).map_err(store_error)?;
204        reject_directory(&store.root)?;
205        Ok(store)
206    }
207
208    /// Writes a new prepared receipt and rejects an existing attempt.
209    pub fn prepare(&self, receipt: ActivationReceiptV2) -> UpdateResult<()> {
210        receipt.validate()?;
211        if receipt.phase != ActivationPhaseV2::Prepared {
212            return Err(UpdateError::Recovery(
213                "new V2 receipts must start in prepared phase".to_string(),
214            ));
215        }
216        if self.read_receipt()?.is_some() {
217            return Err(UpdateError::Recovery(
218                "another V2 activation attempt is already pending".to_string(),
219            ));
220        }
221        atomic_write_json(&self.receipt_path(), &receipt)
222    }
223
224    /// Marks a prepared receipt as active and awaiting host health evidence.
225    pub fn mark_activated(&self, attempt_id: &str, at_ms: u64) -> UpdateResult<RecoveryResult> {
226        let receipt = self
227            .read_receipt()?
228            .ok_or_else(|| UpdateError::Recovery("recovery journal is idle".to_string()))?;
229        if receipt.attempt_id != attempt_id {
230            return Err(UpdateError::Recovery(
231                "recovery action belongs to another attempt".to_string(),
232            ));
233        }
234        if receipt.phase == ActivationPhaseV2::ActivatedPendingHealth {
235            return Ok(RecoveryResult {
236                phase: receipt.phase,
237                idempotent: true,
238            });
239        }
240        if receipt.phase != ActivationPhaseV2::Prepared {
241            return Err(UpdateError::Recovery(
242                "activation evidence conflicts with the persisted phase".to_string(),
243            ));
244        }
245        self.write_phase(receipt, ActivationPhaseV2::ActivatedPendingHealth, at_ms)
246    }
247
248    /// Inspects the journal without changing or removing any file.
249    pub fn inspect_recovery(&self) -> UpdateResult<RecoveryDecision> {
250        let Some(receipt) = self.read_receipt()? else {
251            return Ok(RecoveryDecision::Idle);
252        };
253        match receipt.phase {
254            ActivationPhaseV2::Prepared | ActivationPhaseV2::ActivatedPendingHealth => {
255                Ok(RecoveryDecision::PendingActivation { receipt })
256            }
257            ActivationPhaseV2::Committed => Ok(RecoveryDecision::Committed { receipt }),
258            ActivationPhaseV2::RollbackRequired => {
259                Ok(RecoveryDecision::RollbackRequired { receipt })
260            }
261            ActivationPhaseV2::RolledBack => Ok(RecoveryDecision::RolledBack { receipt }),
262            ActivationPhaseV2::Aborted => Ok(RecoveryDecision::Aborted { receipt }),
263            ActivationPhaseV2::ManualReviewRequired => Ok(RecoveryDecision::ManualReviewRequired {
264                reason: receipt
265                    .failure_reason
266                    .unwrap_or_else(|| "manual review required".to_string()),
267            }),
268        }
269    }
270
271    /// Applies one explicitly authorized and fenced recovery action.
272    pub fn replay(&self, action: RecoveryAction) -> UpdateResult<RecoveryResult> {
273        let Some(receipt) = self.read_receipt()? else {
274            return Err(UpdateError::Recovery(
275                "recovery journal is idle".to_string(),
276            ));
277        };
278        let (attempt_id, at_ms) = action_identity(&action);
279        if attempt_id != receipt.attempt_id {
280            return Err(UpdateError::Recovery(
281                "recovery action belongs to another attempt".to_string(),
282            ));
283        }
284        let previous = receipt.phase;
285        let receipt = apply_action(receipt, action)?;
286        let phase = receipt.phase;
287        let idempotent = previous == phase;
288        self.write_receipt(receipt, at_ms)?;
289        Ok(RecoveryResult { phase, idempotent })
290    }
291
292    /// Removes a V2 journal only when the caller explicitly discards it.
293    pub fn clear(&self) -> UpdateResult<()> {
294        remove_if_exists(&self.receipt_path())
295    }
296
297    fn write_phase(
298        &self,
299        mut receipt: ActivationReceiptV2,
300        phase: ActivationPhaseV2,
301        at_ms: u64,
302    ) -> UpdateResult<RecoveryResult> {
303        let previous = receipt.phase;
304        receipt.phase = phase;
305        self.write_receipt(receipt, at_ms)?;
306        Ok(RecoveryResult {
307            phase,
308            idempotent: previous == phase,
309        })
310    }
311
312    fn write_receipt(&self, mut receipt: ActivationReceiptV2, at_ms: u64) -> UpdateResult<()> {
313        receipt.updated_at_ms = at_ms;
314        receipt.validate()?;
315        atomic_write_json(&self.receipt_path(), &receipt)
316    }
317
318    fn read_receipt(&self) -> UpdateResult<Option<ActivationReceiptV2>> {
319        match read_json_bounded::<ActivationReceiptV2>(
320            &self.receipt_path(),
321            MAX_UPDATE_METADATA_BYTES,
322        ) {
323            Ok(receipt) => {
324                if let Some(receipt) = &receipt {
325                    receipt.validate()?;
326                }
327                Ok(receipt)
328            }
329            Err(JsonReadError::Io(error)) => Err(store_error(error)),
330            Err(JsonReadError::Decode(_)) => Err(UpdateError::Recovery(
331                "NO MORE SUPPORTED PLEASE UPDATE".to_string(),
332            )),
333        }
334    }
335
336    fn receipt_path(&self) -> PathBuf {
337        self.root.join("activation-v2.json")
338    }
339}
340
341fn apply_action(
342    mut receipt: ActivationReceiptV2,
343    action: RecoveryAction,
344) -> UpdateResult<ActivationReceiptV2> {
345    match action {
346        RecoveryAction::ConfirmHealthy {
347            activated_sha256, ..
348        } => {
349            if receipt.phase == ActivationPhaseV2::Committed {
350                if receipt.activated.sha256() != activated_sha256 {
351                    return conflict();
352                }
353                return Ok(receipt);
354            }
355            if receipt.phase != ActivationPhaseV2::ActivatedPendingHealth
356                || receipt.activated.sha256() != activated_sha256
357            {
358                return conflict();
359            }
360            receipt.phase = ActivationPhaseV2::Committed;
361            receipt.failure_reason = None;
362        }
363        RecoveryAction::RequireRollback { reason, .. } => {
364            validate_text("failure_reason", &reason, MAX_REASON_BYTES)?;
365            if receipt.phase == ActivationPhaseV2::RollbackRequired {
366                if receipt.failure_reason.as_deref() != Some(reason.as_str()) {
367                    return conflict();
368                }
369                return Ok(receipt);
370            }
371            if receipt.phase != ActivationPhaseV2::ActivatedPendingHealth {
372                return conflict();
373            }
374            receipt.phase = ActivationPhaseV2::RollbackRequired;
375            receipt.failure_reason = Some(reason);
376        }
377        RecoveryAction::RecordRolledBack { active_sha256, .. } => {
378            if receipt.phase == ActivationPhaseV2::RolledBack {
379                if receipt.previous.as_ref().map(|previous| previous.sha256())
380                    != active_sha256.as_deref()
381                {
382                    return conflict();
383                }
384                return Ok(receipt);
385            }
386            if receipt.phase != ActivationPhaseV2::RollbackRequired {
387                return conflict();
388            }
389            match (&receipt.previous, active_sha256) {
390                (None, None) => {}
391                (Some(previous), Some(active)) if previous.sha256() == active => {}
392                _ => return conflict(),
393            }
394            receipt.phase = ActivationPhaseV2::RolledBack;
395        }
396        RecoveryAction::DiscardPrepared { .. } => {
397            if receipt.phase != ActivationPhaseV2::Prepared {
398                return conflict();
399            }
400            receipt.phase = ActivationPhaseV2::Aborted;
401        }
402        RecoveryAction::MarkManualReview { reason, .. } => {
403            validate_text("failure_reason", &reason, MAX_REASON_BYTES)?;
404            if receipt.phase == ActivationPhaseV2::ManualReviewRequired {
405                return Ok(receipt);
406            }
407            receipt.phase = ActivationPhaseV2::ManualReviewRequired;
408            receipt.failure_reason = Some(reason);
409        }
410    }
411    Ok(receipt)
412}
413
414fn action_identity(action: &RecoveryAction) -> (&str, u64) {
415    match action {
416        RecoveryAction::ConfirmHealthy {
417            attempt_id, at_ms, ..
418        }
419        | RecoveryAction::RequireRollback {
420            attempt_id, at_ms, ..
421        }
422        | RecoveryAction::RecordRolledBack {
423            attempt_id, at_ms, ..
424        }
425        | RecoveryAction::DiscardPrepared { attempt_id, at_ms }
426        | RecoveryAction::MarkManualReview {
427            attempt_id, at_ms, ..
428        } => (attempt_id, *at_ms),
429    }
430}
431
432fn validate_text(name: &str, value: &str, max_bytes: usize) -> UpdateResult<()> {
433    if value.trim().is_empty() || value.len() > max_bytes || value.chars().any(char::is_control) {
434        return Err(UpdateError::Recovery(format!("{name} is invalid")));
435    }
436    Ok(())
437}
438
439fn conflict<T>() -> UpdateResult<T> {
440    Err(UpdateError::Recovery(
441        "recovery action conflicts with the persisted phase or evidence".to_string(),
442    ))
443}
444
445fn reject_directory(path: &Path) -> UpdateResult<()> {
446    let metadata = fs::symlink_metadata(path).map_err(store_error)?;
447    if metadata.file_type().is_symlink() || !metadata.is_dir() {
448        return Err(UpdateError::Store(
449            "recovery root is not a regular directory".to_string(),
450        ));
451    }
452    Ok(())
453}
454
455fn store_error(error: std::io::Error) -> UpdateError {
456    UpdateError::Store(error.to_string())
457}