Skip to main content

appcore_update/
host_flow.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: host_flow.rs
4//    ##   ## ##   ##    P: AppCore-Runtime
5//         ## ##
6//                       C: 2026/10/12 00:00:00 by dnettoRaw
7//    ##   ## ##   ##    U: 2026/10/12 00:00:00 by dnettoRaw
8//      ###########      S: 1.0.2-rc
9// =============================================================================
10
11//! Explicit host composition over existing V2 receipts, without owning a process.
12use crate::{
13    ActivationAdapter, ActivationEvidence, ActivationPhaseV2, ActivationReceiptV2,
14    ActivationRequest, FileRecoveryStore, RecoveryAction, RecoveryDecision, StagedArtifact,
15    UpdateError, UpdateResult,
16};
17
18/// Host-owned write exclusion and data recovery preparation.
19/// Implementations must impose deadlines, persist installation-bound backups,
20/// and keep normal writes blocked on every ambiguous result, including restart.
21pub trait UpdateRecoveryGate: Send + Sync {
22    /// Idempotently blocks/drains writes and verifies a recoverable data snapshot.
23    fn drain_and_snapshot(&self, request: &ActivationRequest) -> UpdateResult<()>;
24    /// Seals normal write admission before host installation may begin.
25    fn seal(&self, request: &ActivationRequest) -> UpdateResult<()>;
26}
27
28/// One deployment-owned adapter composition. Use a single external process lock
29/// for the journal and host operation. This type never acquires a hidden lock,
30/// starts a process, selects providers or performs a domain migration.
31pub struct HostUpdateFlow<'a> {
32    journal: &'a FileRecoveryStore,
33    adapter: &'a dyn ActivationAdapter,
34    gate: &'a dyn UpdateRecoveryGate,
35}
36impl<'a> HostUpdateFlow<'a> {
37    /// Composes an explicitly locked journal, host adapter and recovery gate.
38    pub fn new(
39        journal: &'a FileRecoveryStore,
40        adapter: &'a dyn ActivationAdapter,
41        gate: &'a dyn UpdateRecoveryGate,
42    ) -> Self {
43        Self {
44            journal,
45            adapter,
46            gate,
47        }
48    }
49    /// Persists intent before drain, snapshot and adapter preparation. On any
50    /// failure the prepared receipt remains inspectable; recovery must inspect
51    /// host reality rather than assuming that an interrupted callback did nothing.
52    /// Supply only bytes already authenticated/verified/staged by Update.
53    pub fn prepare(
54        &self,
55        request: &ActivationRequest,
56        staged: &StagedArtifact,
57        at_ms: u64,
58    ) -> UpdateResult<()> {
59        request.validate()?;
60        if staged.descriptor != request.descriptor {
61            return conflict();
62        }
63        validate_upgrade(request)?;
64        match self.journal.inspect_recovery()? {
65            RecoveryDecision::Idle => self.journal.prepare(ActivationReceiptV2 {
66                format_version: crate::ACTIVATION_V2_FORMAT_VERSION,
67                attempt_id: request.attempt_id.clone(),
68                phase: ActivationPhaseV2::Prepared,
69                activated: request.descriptor.clone(),
70                previous: request.previous.clone(),
71                created_at_ms: at_ms,
72                updated_at_ms: at_ms,
73                host_binding: request.host_binding.clone(),
74                failure_reason: None,
75            })?,
76            RecoveryDecision::PendingActivation { receipt }
77            | RecoveryDecision::Committed { receipt } => {
78                check_request(&receipt, request)?;
79                if receipt.phase != ActivationPhaseV2::Prepared {
80                    return Ok(());
81                }
82            }
83            _ => return conflict(),
84        }
85        self.gate.drain_and_snapshot(request)?;
86        self.adapter.prepare(request, staged)
87    }
88    /// Repeats the idempotent preparation boundary before activation so a
89    /// restarted caller cannot skip a previously failed drain/snapshot callback.
90    /// A failed activation leaves prepared intent; inspect host reality on restart.
91    pub fn activate(
92        &self,
93        request: &ActivationRequest,
94        staged: &StagedArtifact,
95        at_ms: u64,
96    ) -> UpdateResult<ActivationEvidence> {
97        let receipt = self.activation_receipt()?;
98        if staged.descriptor != request.descriptor {
99            return conflict();
100        }
101        check_request(&receipt, request)?;
102        if matches!(
103            receipt.phase,
104            ActivationPhaseV2::ActivatedPendingHealth | ActivationPhaseV2::Committed
105        ) {
106            return Ok(ActivationEvidence {
107                attempt_id: receipt.attempt_id,
108                descriptor: receipt.activated,
109                host_binding: receipt.host_binding,
110            });
111        }
112        check_timestamp(&receipt, at_ms)?;
113        self.gate.drain_and_snapshot(request)?;
114        self.adapter.prepare(request, staged)?;
115        self.gate.seal(request)?;
116        let evidence = self.adapter.activate(request, staged)?;
117        check_evidence(&receipt, &evidence)?;
118        self.journal.mark_activated(&receipt.attempt_id, at_ms)?;
119        Ok(evidence)
120    }
121    /// Confirms host health, commits the adapter, then records completion.
122    /// Backend and renderer readiness must both be included in a desktop adapter.
123    /// Failed health durably requests rollback. Failed/ambiguous commit retains
124    /// pending intent and never reports success or guesses a rollback.
125    pub fn confirm(&self, evidence: &ActivationEvidence, at_ms: u64) -> UpdateResult<()> {
126        let receipt = self.activation_receipt()?;
127        check_evidence(&receipt, evidence)?;
128        if receipt.phase == ActivationPhaseV2::Committed {
129            return Ok(());
130        }
131        if receipt.phase != ActivationPhaseV2::ActivatedPendingHealth {
132            return conflict();
133        }
134        check_timestamp(&receipt, at_ms)?;
135        if self.adapter.healthcheck(evidence).is_err() {
136            self.journal.replay(RecoveryAction::RequireRollback {
137                attempt_id: receipt.attempt_id,
138                reason: "host health gate failed".into(),
139                at_ms,
140            })?;
141            return Err(UpdateError::Health("host health gate failed".into()));
142        }
143        self.adapter.commit(&receipt)?;
144        self.journal.replay(RecoveryAction::ConfirmHealthy {
145            attempt_id: receipt.attempt_id,
146            activated_sha256: evidence.descriptor.sha256().into(),
147            at_ms,
148        })?;
149        Ok(())
150    }
151    /// Runs host rollback but does not record completion. The host must re-probe
152    /// the actual restored identity and supply `RecordRolledBack` to the journal.
153    pub fn rollback(&self) -> UpdateResult<()> {
154        match self.journal.inspect_recovery()? {
155            RecoveryDecision::RollbackRequired { receipt } => self.adapter.rollback(&receipt),
156            RecoveryDecision::RolledBack { .. } => Ok(()),
157            _ => conflict(),
158        }
159    }
160    fn activation_receipt(&self) -> UpdateResult<ActivationReceiptV2> {
161        match self.journal.inspect_recovery()? {
162            RecoveryDecision::PendingActivation { receipt }
163            | RecoveryDecision::Committed { receipt } => Ok(receipt),
164            _ => conflict(),
165        }
166    }
167}
168fn check_request(receipt: &ActivationReceiptV2, request: &ActivationRequest) -> UpdateResult<()> {
169    request.validate()?;
170    if receipt.attempt_id != request.attempt_id
171        || receipt.activated != request.descriptor
172        || receipt.previous != request.previous
173        || receipt.host_binding != request.host_binding
174    {
175        return conflict();
176    }
177    Ok(())
178}
179fn check_evidence(
180    receipt: &ActivationReceiptV2,
181    evidence: &ActivationEvidence,
182) -> UpdateResult<()> {
183    evidence.validate()?;
184    if receipt.attempt_id != evidence.attempt_id
185        || receipt.activated != evidence.descriptor
186        || receipt.host_binding != evidence.host_binding
187    {
188        return conflict();
189    }
190    Ok(())
191}
192fn validate_upgrade(request: &ActivationRequest) -> UpdateResult<()> {
193    if let Some(previous) = &request.previous {
194        if previous.application_id() != request.descriptor.application_id()
195            || previous.build_id() == request.descriptor.build_id()
196            || previous.target() != request.descriptor.target()
197            || semver::Version::parse(previous.application_version())
198                .map_err(|_| UpdateError::Incompatible("invalid previous version".into()))?
199                >= semver::Version::parse(request.descriptor.application_version())
200                    .map_err(|_| UpdateError::Incompatible("invalid candidate version".into()))?
201        {
202            return Err(UpdateError::Incompatible(
203                "candidate must advance the same application and target".into(),
204            ));
205        }
206    }
207    Ok(())
208}
209fn conflict<T>() -> UpdateResult<T> {
210    Err(UpdateError::Recovery(
211        "host operation conflicts with activation evidence".into(),
212    ))
213}
214
215fn check_timestamp(receipt: &ActivationReceiptV2, at_ms: u64) -> UpdateResult<()> {
216    if at_ms < receipt.updated_at_ms {
217        return conflict();
218    }
219    Ok(())
220}