Skip to main content

embassy_pack/
lib.rs

1// Copyright 2024-2026 Reflective Labs
2// SPDX-License-Identifier: MIT
3
4//! Shared contract surface for embassy ports.
5//!
6//! Every port in `embassy/*` emits provenanced observations and accepts a
7//! call context. These types are the cross-port contract — independent of
8//! any specific external service.
9
10pub mod macros;
11pub mod sanctions;
12pub use sanctions::{MatchType, SanctionsHit, SanctionsSubject, SubjectType};
13
14use serde::{Deserialize, Serialize};
15use std::collections::HashMap;
16
17#[derive(Debug, Clone, Default)]
18pub struct CallContext {
19    pub correlation_id: Option<String>,
20    pub metadata: HashMap<String, String>,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct Observation<T> {
25    pub observation_id: String,
26    pub request_hash: String,
27    pub vendor: String,
28    pub model: String,
29    pub latency_ms: u64,
30    pub cost_estimate: Option<f64>,
31    pub tokens: Option<u64>,
32    pub content: T,
33    pub raw_response: Option<String>,
34}
35
36pub fn content_hash(input: &str) -> String {
37    use sha2::{Digest, Sha256};
38    let mut hasher = Sha256::new();
39    hasher.update(input.as_bytes());
40    let bytes = hasher.finalize();
41    format!(
42        "{:016x}",
43        u64::from_be_bytes(bytes[..8].try_into().expect("slice is 8 bytes"))
44    )
45}
46
47#[cfg(test)]
48mod tests {
49    use super::*;
50
51    #[test]
52    fn content_hash_is_deterministic() {
53        assert_eq!(content_hash("hello"), content_hash("hello"));
54    }
55
56    #[test]
57    fn content_hash_differs_for_distinct_inputs() {
58        assert_ne!(content_hash("hello"), content_hash("world"));
59    }
60
61    #[test]
62    fn content_hash_is_sixteen_hex_chars() {
63        let h = content_hash("anything");
64        assert_eq!(h.len(), 16);
65        assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
66    }
67
68    #[test]
69    fn call_context_default_is_empty() {
70        let ctx = CallContext::default();
71        assert!(ctx.correlation_id.is_none());
72        assert!(ctx.metadata.is_empty());
73    }
74
75    #[test]
76    fn call_context_carries_metadata() {
77        let mut ctx = CallContext {
78            correlation_id: Some("trace-1".to_string()),
79            metadata: HashMap::new(),
80        };
81        ctx.metadata.insert("k".into(), "v".into());
82        assert_eq!(ctx.correlation_id.as_deref(), Some("trace-1"));
83        assert_eq!(ctx.metadata.get("k").map(String::as_str), Some("v"));
84    }
85
86    #[test]
87    fn observation_round_trips_through_json() {
88        let obs = Observation {
89            observation_id: "obs:1".to_string(),
90            request_hash: content_hash("req"),
91            vendor: "vendor".to_string(),
92            model: "model".to_string(),
93            latency_ms: 42,
94            cost_estimate: Some(0.5),
95            tokens: Some(7),
96            content: 99u32,
97            raw_response: Some("{}".to_string()),
98        };
99        let json = serde_json::to_string(&obs).expect("serialize");
100        let back: Observation<u32> = serde_json::from_str(&json).expect("deserialize");
101        assert_eq!(back.observation_id, obs.observation_id);
102        assert_eq!(back.request_hash, obs.request_hash);
103        assert_eq!(back.vendor, obs.vendor);
104        assert_eq!(back.model, obs.model);
105        assert_eq!(back.latency_ms, obs.latency_ms);
106        assert_eq!(back.cost_estimate, obs.cost_estimate);
107        assert_eq!(back.tokens, obs.tokens);
108        assert_eq!(back.content, obs.content);
109        assert_eq!(back.raw_response, obs.raw_response);
110    }
111}