Skip to main content

appcore_update/
activation.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: activation.rs
4//    ##   ## ##   ##    P: AppCore-Runtime
5//         ## ##
6//                       C: 2026/09/25 00:00:00 by dnettoRaw
7//    ##   ## ##   ##    U: 2026/09/25 00:00:00 by dnettoRaw
8//      ###########      S: 1.0.3-rc
9// =============================================================================
10
11//! Generic host activation adapter contracts.
12
13use crate::{
14    ActivationReceiptV2, ArtifactDescriptor, RecoveryAction, RecoveryDecision, StagedArtifact,
15    UpdateError, UpdateResult,
16};
17
18const MAX_ATTEMPT_ID_BYTES: usize = 128;
19const MAX_HOST_BINDING_BYTES: usize = 512;
20
21/// Runtime-owned context passed to a host activation adapter.
22#[derive(Clone, PartialEq, Eq)]
23pub struct ActivationRequest {
24    /// Fencing identity for this activation attempt.
25    pub attempt_id: String,
26    /// Verified artifact being prepared.
27    pub descriptor: ArtifactDescriptor,
28    /// Previously active artifact, when one exists.
29    pub previous: Option<ArtifactDescriptor>,
30    /// Opaque host-owned binding, when required by the adapter.
31    pub host_binding: Option<String>,
32}
33
34impl ActivationRequest {
35    /// Validates the bounded Runtime-to-host activation context.
36    pub fn validate(&self) -> UpdateResult<()> {
37        validate_text("attempt_id", &self.attempt_id, MAX_ATTEMPT_ID_BYTES)?;
38        self.descriptor.validate()?;
39        if let Some(previous) = &self.previous {
40            previous.validate()?;
41        }
42        if let Some(binding) = &self.host_binding {
43            validate_text("host_binding", binding, MAX_HOST_BINDING_BYTES)?;
44        }
45        Ok(())
46    }
47}
48
49/// Host evidence returned after an adapter activates an artifact.
50#[derive(Clone, PartialEq, Eq)]
51pub struct ActivationEvidence {
52    /// Fencing identity repeated from the request.
53    pub attempt_id: String,
54    /// Artifact the host reports as active.
55    pub descriptor: ArtifactDescriptor,
56    /// Opaque host-owned binding observed during activation.
57    pub host_binding: Option<String>,
58}
59
60impl ActivationEvidence {
61    /// Validates bounded activation evidence before it enters a receipt.
62    pub fn validate(&self) -> UpdateResult<()> {
63        validate_text("attempt_id", &self.attempt_id, MAX_ATTEMPT_ID_BYTES)?;
64        self.descriptor.validate()?;
65        if let Some(binding) = &self.host_binding {
66            validate_text("host_binding", binding, MAX_HOST_BINDING_BYTES)?;
67        }
68        Ok(())
69    }
70
71    /// Converts host evidence into the durable V2 receipt representation.
72    pub fn into_receipt(
73        self,
74        previous: Option<ArtifactDescriptor>,
75        created_at_ms: u64,
76    ) -> UpdateResult<ActivationReceiptV2> {
77        self.validate()?;
78        if let Some(previous) = &previous {
79            previous.validate()?;
80        }
81        Ok(ActivationReceiptV2 {
82            format_version: crate::ACTIVATION_V2_FORMAT_VERSION,
83            attempt_id: self.attempt_id,
84            phase: crate::ActivationPhaseV2::ActivatedPendingHealth,
85            activated: self.descriptor,
86            previous,
87            created_at_ms,
88            updated_at_ms: created_at_ms,
89            host_binding: self.host_binding,
90            failure_reason: None,
91        })
92    }
93}
94
95/// Generic host adapter for prepare, activation, health, commit, rollback and recovery.
96/// When composed by `HostUpdateFlow`, every boundary must be bounded and
97/// idempotent for the same attempt/artifact identity. Inspect actual host state
98/// before changing an interrupted attempt; prepared intent does not prove that
99/// activation has not happened. Preserve previous artifacts through recovery.
100pub trait ActivationAdapter: Send + Sync {
101    /// Prepares host-owned state without making the artifact active.
102    fn prepare(&self, request: &ActivationRequest, staged: &StagedArtifact) -> UpdateResult<()>;
103    /// Makes the staged artifact active and returns host evidence.
104    fn activate(
105        &self,
106        request: &ActivationRequest,
107        staged: &StagedArtifact,
108    ) -> UpdateResult<ActivationEvidence>;
109    /// Probes the active artifact through the host-owned health boundary.
110    fn healthcheck(&self, evidence: &ActivationEvidence) -> UpdateResult<()>;
111    /// Commits host-owned activation after Runtime health evidence is durable.
112    fn commit(&self, receipt: &ActivationReceiptV2) -> UpdateResult<()>;
113    /// Restores the previous artifact described by the receipt.
114    fn rollback(&self, receipt: &ActivationReceiptV2) -> UpdateResult<()>;
115    /// Converts persisted recovery evidence into one explicit host action.
116    fn recover(&self, decision: &RecoveryDecision) -> UpdateResult<RecoveryAction>;
117}
118
119fn validate_text(name: &str, value: &str, max: usize) -> UpdateResult<()> {
120    if value.trim().is_empty() || value.len() > max || value.chars().any(char::is_control) {
121        return Err(UpdateError::Recovery(format!(
122            "activation {name} is empty, too long or contains control characters"
123        )));
124    }
125    Ok(())
126}
127
128impl std::fmt::Debug for ActivationRequest {
129    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
130        f.debug_struct("ActivationRequest")
131            .field("has_previous", &self.previous.is_some())
132            .field("has_host_binding", &self.host_binding.is_some())
133            .finish_non_exhaustive()
134    }
135}
136impl std::fmt::Debug for ActivationEvidence {
137    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
138        f.debug_struct("ActivationEvidence")
139            .field("has_host_binding", &self.host_binding.is_some())
140            .finish_non_exhaustive()
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147    use appcore_contracts::{ApplicationId, BuildId};
148
149    fn descriptor() -> ArtifactDescriptor {
150        ArtifactDescriptor::new(
151            ApplicationId::new("app-a").unwrap(),
152            "1.1.0",
153            BuildId::new("build-a").unwrap(),
154            "stable",
155            ">=0.6.0, <1.0.0",
156            "1",
157            "memory:build-a",
158            "00".repeat(32),
159            1,
160        )
161        .unwrap()
162    }
163
164    #[test]
165    fn activation_evidence_becomes_pending_v2_receipt() {
166        let evidence = ActivationEvidence {
167            attempt_id: "attempt-a".to_string(),
168            descriptor: descriptor(),
169            host_binding: Some("host-a".to_string()),
170        };
171        let receipt = evidence.into_receipt(None, 100).unwrap();
172        assert_eq!(
173            receipt.phase,
174            crate::ActivationPhaseV2::ActivatedPendingHealth
175        );
176        assert_eq!(receipt.attempt_id, "attempt-a");
177    }
178
179    #[test]
180    fn activation_context_rejects_unbounded_identifiers() {
181        let request = ActivationRequest {
182            attempt_id: "bad\nattempt".to_string(),
183            descriptor: descriptor(),
184            previous: None,
185            host_binding: None,
186        };
187        assert!(matches!(
188            request.validate(),
189            Err(UpdateError::Recovery(message)) if message.contains("attempt_id")
190        ));
191    }
192}