Skip to main content

oris_evolution_network/
lib.rs

1//! Protocol contracts for the Oris Evolution Network (OEN).
2
3pub mod gossip;
4pub mod sync;
5
6pub use gossip::{
7    GossipConfig, GossipDigest, GossipDigestEntry, GossipSyncEngine as PushPullGossipSyncEngine,
8    GossipSyncReport, PeerAddress,
9};
10pub use sync::{
11    CapsuleDisposition, GossipSyncEngine, QuarantineEntry, QuarantineReason, QuarantineState,
12    QuarantineStore, RemoteCapsuleReceiver, SyncStats, PROMOTE_THRESHOLD,
13};
14
15use std::collections::BTreeSet;
16
17use chrono::Utc;
18use serde::{Deserialize, Serialize};
19use sha2::{Digest, Sha256};
20
21use oris_evolution::{Capsule, EvolutionEvent, Gene};
22
23#[derive(Clone, Debug, Serialize, Deserialize)]
24pub enum MessageType {
25    Publish,
26    Fetch,
27    Report,
28    Revoke,
29}
30
31#[derive(Clone, Debug, Serialize, Deserialize)]
32#[serde(tag = "kind", rename_all = "snake_case")]
33pub enum NetworkAsset {
34    Gene { gene: Gene },
35    Capsule { capsule: Capsule },
36    EvolutionEvent { event: EvolutionEvent },
37}
38
39#[derive(Clone, Debug, Serialize, Deserialize)]
40pub struct EvolutionEnvelope {
41    pub protocol: String,
42    pub protocol_version: String,
43    pub message_type: MessageType,
44    pub message_id: String,
45    pub sender_id: String,
46    pub timestamp: String,
47    pub assets: Vec<NetworkAsset>,
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub manifest: Option<EnvelopeManifest>,
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub signature: Option<String>,
52    pub content_hash: String,
53}
54
55#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
56pub struct EnvelopeManifest {
57    pub publisher: String,
58    pub sender_id: String,
59    pub asset_ids: Vec<String>,
60    pub asset_hash: String,
61}
62
63#[derive(Clone, Debug, Serialize, Deserialize)]
64pub struct PublishRequest {
65    pub sender_id: String,
66    pub assets: Vec<NetworkAsset>,
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub since_cursor: Option<String>,
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub resume_token: Option<String>,
71}
72
73#[derive(Clone, Debug, Serialize, Deserialize)]
74pub struct FetchQuery {
75    pub sender_id: String,
76    pub signals: Vec<String>,
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub since_cursor: Option<String>,
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub resume_token: Option<String>,
81}
82
83#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
84pub struct SyncAudit {
85    pub batch_id: String,
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub requested_cursor: Option<String>,
88    pub scanned_count: usize,
89    pub applied_count: usize,
90    pub skipped_count: usize,
91    pub failed_count: usize,
92    #[serde(default, skip_serializing_if = "Vec::is_empty")]
93    pub failure_reasons: Vec<String>,
94}
95
96#[derive(Clone, Debug, Serialize, Deserialize)]
97pub struct FetchResponse {
98    pub sender_id: String,
99    pub assets: Vec<NetworkAsset>,
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub next_cursor: Option<String>,
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub resume_token: Option<String>,
104    #[serde(default)]
105    pub sync_audit: SyncAudit,
106}
107
108#[derive(Clone, Debug, Serialize, Deserialize)]
109pub struct RevokeNotice {
110    pub sender_id: String,
111    pub asset_ids: Vec<String>,
112    pub reason: String,
113}
114
115impl EvolutionEnvelope {
116    pub fn publish(sender_id: impl Into<String>, assets: Vec<NetworkAsset>) -> Self {
117        let sender_id = sender_id.into();
118        let manifest = Some(Self::build_manifest(&sender_id, &assets));
119        let mut envelope = Self {
120            protocol: "oen".into(),
121            protocol_version: "0.1".into(),
122            message_type: MessageType::Publish,
123            message_id: format!(
124                "msg-{:x}",
125                Utc::now().timestamp_nanos_opt().unwrap_or_default()
126            ),
127            sender_id,
128            timestamp: Utc::now().to_rfc3339(),
129            assets,
130            manifest,
131            signature: None,
132            content_hash: String::new(),
133        };
134        envelope.content_hash = envelope.compute_content_hash();
135        envelope
136    }
137
138    fn build_manifest(sender_id: &str, assets: &[NetworkAsset]) -> EnvelopeManifest {
139        EnvelopeManifest {
140            publisher: sender_id.to_string(),
141            sender_id: sender_id.to_string(),
142            asset_ids: Self::manifest_asset_ids(assets),
143            asset_hash: Self::compute_assets_hash(assets),
144        }
145    }
146
147    fn normalize_manifest_ids(asset_ids: &[String]) -> Vec<String> {
148        let normalized = asset_ids
149            .iter()
150            .map(|value| value.trim())
151            .filter(|value| !value.is_empty())
152            .map(ToOwned::to_owned)
153            .collect::<BTreeSet<_>>();
154        normalized.into_iter().collect()
155    }
156
157    pub fn manifest_asset_ids(assets: &[NetworkAsset]) -> Vec<String> {
158        let ids = assets
159            .iter()
160            .map(Self::manifest_asset_id)
161            .collect::<BTreeSet<_>>();
162        ids.into_iter().collect()
163    }
164
165    fn manifest_asset_id(asset: &NetworkAsset) -> String {
166        match asset {
167            NetworkAsset::Gene { gene } => format!("gene:{}", gene.id),
168            NetworkAsset::Capsule { capsule } => format!("capsule:{}", capsule.id),
169            NetworkAsset::EvolutionEvent { event } => {
170                let payload = serde_json::to_vec(event).unwrap_or_default();
171                let mut hasher = Sha256::new();
172                hasher.update(payload);
173                format!("event:{}", hex::encode(hasher.finalize()))
174            }
175        }
176    }
177
178    pub fn compute_assets_hash(assets: &[NetworkAsset]) -> String {
179        let payload = serde_json::to_vec(assets).unwrap_or_default();
180        let mut hasher = Sha256::new();
181        hasher.update(payload);
182        hex::encode(hasher.finalize())
183    }
184
185    pub fn compute_content_hash(&self) -> String {
186        let payload = (
187            &self.protocol,
188            &self.protocol_version,
189            &self.message_type,
190            &self.message_id,
191            &self.sender_id,
192            &self.timestamp,
193            &self.assets,
194            &self.manifest,
195            &self.signature,
196        );
197        let json = serde_json::to_vec(&payload).unwrap_or_default();
198        let mut hasher = Sha256::new();
199        hasher.update(json);
200        hex::encode(hasher.finalize())
201    }
202
203    pub fn verify_content_hash(&self) -> bool {
204        self.compute_content_hash() == self.content_hash
205    }
206
207    pub fn verify_manifest(&self) -> Result<(), String> {
208        let Some(manifest) = self.manifest.as_ref() else {
209            return Err("missing manifest".into());
210        };
211        if manifest.publisher.trim().is_empty() {
212            return Err("missing manifest publisher".into());
213        }
214        if manifest.sender_id.trim().is_empty() {
215            return Err("missing manifest sender_id".into());
216        }
217        if manifest.sender_id.trim() != self.sender_id.trim() {
218            return Err("manifest sender_id mismatch".into());
219        }
220
221        let expected_asset_ids = Self::manifest_asset_ids(&self.assets);
222        let actual_asset_ids = Self::normalize_manifest_ids(&manifest.asset_ids);
223        if expected_asset_ids != actual_asset_ids {
224            return Err("manifest asset_ids mismatch".into());
225        }
226
227        let expected_hash = Self::compute_assets_hash(&self.assets);
228        if manifest.asset_hash != expected_hash {
229            return Err("manifest asset_hash mismatch".into());
230        }
231
232        Ok(())
233    }
234
235    pub fn verify_signature(&self) -> bool {
236        true
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243    use oris_evolution::{AssetState, Gene};
244
245    fn sample_gene(id: &str) -> Gene {
246        Gene {
247            id: id.to_string(),
248            signals: vec!["docs.fix".to_string()],
249            strategy: vec!["summary=docs fix".to_string()],
250            validation: vec!["cargo test".to_string()],
251            state: AssetState::Promoted,
252            task_class_id: None,
253        }
254    }
255
256    #[test]
257    fn publish_populates_manifest_and_verifies() {
258        let envelope = EvolutionEnvelope::publish(
259            "node-a",
260            vec![NetworkAsset::Gene {
261                gene: sample_gene("gene-a"),
262            }],
263        );
264        assert!(envelope.verify_content_hash());
265        assert!(envelope.verify_manifest().is_ok());
266        assert!(envelope.manifest.is_some());
267    }
268
269    #[test]
270    fn verify_manifest_detects_sender_mismatch() {
271        let mut envelope = EvolutionEnvelope::publish(
272            "node-a",
273            vec![NetworkAsset::Gene {
274                gene: sample_gene("gene-a"),
275            }],
276        );
277        envelope.sender_id = "node-b".to_string();
278        assert!(envelope.verify_manifest().is_err());
279    }
280
281    #[test]
282    fn verify_manifest_detects_asset_hash_drift() {
283        let mut envelope = EvolutionEnvelope::publish(
284            "node-a",
285            vec![NetworkAsset::Gene {
286                gene: sample_gene("gene-a"),
287            }],
288        );
289        if let Some(NetworkAsset::Gene { gene }) = envelope.assets.first_mut() {
290            gene.id = "gene-b".to_string();
291        }
292        assert!(envelope.verify_manifest().is_err());
293    }
294}