Skip to main content

core_api/
delivery.rs

1//! Core -> Host delivery contract. Host executes a resolved destination; it never resolves
2//! recipients or chooses fallback routes. Completion describes evidence, not user receipt.
3use crate::MwsMessage;
4use serde::{Deserialize, Serialize};
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
7#[serde(rename_all = "camelCase", deny_unknown_fields)]
8pub struct HostDeliveryRequest {
9    /// Stable across retransmission of the same attempt. A new attempt needs a new ID.
10    pub delivery_id: String,
11    pub expires_at_unix_ms: i64,
12    pub destination: HostDeliveryDestination,
13}
14
15impl HostDeliveryRequest {
16    pub fn remaining(&self) -> std::time::Duration {
17        let now = std::time::SystemTime::now()
18            .duration_since(std::time::UNIX_EPOCH)
19            .map(|duration| duration.as_millis())
20            .unwrap_or(u128::MAX);
21        let remaining = (self.expires_at_unix_ms.max(0) as u128).saturating_sub(now);
22        std::time::Duration::from_millis(remaining.min(u64::MAX as u128) as u64)
23    }
24    pub fn is_expired(&self) -> bool {
25        let now = std::time::SystemTime::now()
26            .duration_since(std::time::UNIX_EPOCH)
27            .map(|duration| duration.as_millis())
28            .unwrap_or(u128::MAX);
29        self.expires_at_unix_ms <= 0 || now >= self.expires_at_unix_ms as u128
30    }
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
34#[serde(
35    tag = "kind",
36    rename_all = "camelCase",
37    rename_all_fields = "camelCase",
38    deny_unknown_fields
39)]
40pub enum HostDeliveryDestination {
41    AppConnection {
42        client_id: String,
43        message: MwsMessage,
44    },
45    NodeConnection {
46        connection_key: String,
47        message: MwsMessage,
48    },
49    Messaging {
50        request: crate::MessagingDeliveryRequest,
51    },
52}
53
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(tag = "status", rename_all = "camelCase", deny_unknown_fields)]
56pub enum DeliveryOutcome {
57    Accepted {
58        boundary: DeliveryAcceptance,
59    },
60    /// Intentionally handled without a provider/network send. Not proof of delivery.
61    Skipped {
62        reason: String,
63    },
64    Failed {
65        code: String,
66        message: String,
67    },
68    Unknown {
69        message: String,
70    },
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
74#[serde(rename_all = "camelCase")]
75pub enum DeliveryAcceptance {
76    /// The authenticated connection's writer accepted the frame, not the remote application.
77    ConnectionQueue,
78    /// The provider API accepted the operation, not a user-read acknowledgement.
79    Provider,
80}
81
82impl DeliveryOutcome {
83    pub fn is_accepted(&self) -> bool {
84        matches!(self, Self::Accepted { .. })
85    }
86
87    pub fn is_handled(&self) -> bool {
88        matches!(self, Self::Accepted { .. } | Self::Skipped { .. })
89    }
90
91    pub fn failed(code: impl Into<String>, message: impl Into<String>) -> Self {
92        Self::Failed {
93            code: code.into(),
94            message: message.into(),
95        }
96    }
97
98    pub fn connection(accepted: bool) -> Self {
99        if accepted {
100            Self::Accepted {
101                boundary: DeliveryAcceptance::ConnectionQueue,
102            }
103        } else {
104            Self::Failed {
105                code: "UNREACHABLE".into(),
106                message: "Connection did not accept delivery".into(),
107            }
108        }
109    }
110}
111
112/// Connection fencing is control, not delivery. The key must identify an exact session,
113/// so delayed control requests cannot close a replacement connection.
114#[derive(Debug, Clone, Serialize, Deserialize)]
115#[serde(
116    tag = "kind",
117    rename_all = "camelCase",
118    rename_all_fields = "camelCase",
119    deny_unknown_fields
120)]
121pub enum HostConnectionControl {
122    DisconnectNode { connection_key: String },
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    #[test]
130    fn acceptance_is_not_a_boolean_receipt() {
131        let result = DeliveryOutcome::connection(true);
132        assert_eq!(
133            serde_json::to_value(result).unwrap(),
134            serde_json::json!({
135                "status": "accepted", "boundary": "connectionQueue"
136            })
137        );
138        assert!(
139            serde_json::from_value::<DeliveryOutcome>(serde_json::json!({"delivered": true}))
140                .is_err()
141        );
142    }
143
144    #[test]
145    fn skipped_is_handled_but_never_accepted() {
146        let outcome = DeliveryOutcome::Skipped {
147            reason: "No provider message".into(),
148        };
149        assert!(outcome.is_handled());
150        assert!(!outcome.is_accepted());
151        let response = crate::MessagingDeliveryResponse {
152            outcome: outcome.clone(),
153        };
154        let json = serde_json::to_value(response).unwrap();
155        let decoded: crate::MessagingDeliveryResponse = serde_json::from_value(json).unwrap();
156        assert_eq!(decoded.outcome, outcome);
157        assert!(serde_json::from_value::<crate::MessagingDeliveryResponse>(
158            serde_json::json!({"delivered": true})
159        )
160        .is_err());
161    }
162
163    #[test]
164    fn obsolete_output_effects_are_rejected() {
165        assert!(serde_json::from_value::<crate::ServiceCoreOutput>(
166            serde_json::json!({"effects": []})
167        )
168        .is_err());
169    }
170
171    #[test]
172    fn unknown_is_not_failure_or_acceptance() {
173        let result = DeliveryOutcome::Unknown {
174            message: "Timed out after dispatch".into(),
175        };
176        assert!(!result.is_accepted());
177        let encoded = serde_json::to_value(&result).unwrap();
178        assert_eq!(
179            serde_json::from_value::<DeliveryOutcome>(encoded).unwrap(),
180            result
181        );
182    }
183}