Skip to main content

assay_adapter_api/
lib.rs

1//! Stable contracts for protocol adapters that translate external protocol payloads
2//! into canonical Assay evidence events.
3
4mod canonical;
5mod shape;
6
7use assay_evidence::types::EvidenceEvent;
8use serde::{Deserialize, Serialize};
9use thiserror::Error;
10
11pub use canonical::{canonical_bytes, canonical_json_bytes, digest_canonical_json};
12pub use shape::validate_json_shape;
13
14/// Result type for adapter operations.
15pub type AdapterResult<T> = Result<T, AdapterError>;
16
17/// Stable protocol metadata exposed by each adapter.
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19pub struct ProtocolDescriptor {
20    /// Short protocol identifier such as `acp` or `a2a`.
21    pub name: String,
22    /// Supported specification version for the adapter implementation.
23    pub spec_version: String,
24    /// Optional schema identifier for payload validation.
25    pub schema_id: Option<String>,
26    /// Optional human-facing specification URL.
27    pub spec_url: Option<String>,
28}
29
30/// Stable adapter implementation metadata exposed by each adapter.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub struct AdapterDescriptor {
33    /// Stable adapter crate or implementation identifier.
34    pub adapter_id: &'static str,
35    /// Adapter build/version string.
36    pub adapter_version: &'static str,
37}
38
39/// Capabilities exposed by the adapter for review and routing.
40#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
41pub struct AdapterCapabilities {
42    /// Event types this adapter may emit.
43    pub supported_event_types: Vec<String>,
44    /// Supported upstream protocol versions or ranges.
45    pub supported_spec_versions: Vec<String>,
46    /// Whether strict conversion mode is implemented.
47    pub supports_strict: bool,
48    /// Whether lenient conversion mode is implemented.
49    pub supports_lenient: bool,
50}
51
52/// Conversion strictness for protocol translation.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
54#[serde(rename_all = "snake_case")]
55pub enum ConvertMode {
56    /// Fail on malformed or unmappable critical protocol data.
57    #[default]
58    Strict,
59    /// Emit evidence plus explicit lossiness metadata and raw payload reference.
60    Lenient,
61}
62
63/// Conversion options shared by all adapters.
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
65pub struct ConvertOptions {
66    /// Strictness mode for conversion.
67    pub mode: ConvertMode,
68    /// Optional payload size ceiling enforced before deep parsing.
69    pub max_payload_bytes: Option<u64>,
70    /// Optional maximum JSON nesting depth accepted during traversal.
71    pub max_json_depth: Option<u64>,
72    /// Optional maximum JSON array length accepted during traversal.
73    pub max_array_length: Option<u64>,
74}
75
76/// Raw protocol input supplied to an adapter.
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub struct AdapterInput<'a> {
79    /// Raw protocol payload bytes.
80    pub payload: &'a [u8],
81    /// Media type for the source payload.
82    pub media_type: &'a str,
83    /// Optional explicit protocol version observed at ingest time.
84    pub protocol_version: Option<&'a str>,
85}
86
87/// Digest-backed reference to a preserved raw payload.
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
89pub struct RawPayloadRef {
90    /// SHA-256 digest of the preserved payload.
91    pub sha256: String,
92    /// Size in bytes of the preserved payload.
93    pub size_bytes: u64,
94    /// Media type of the preserved payload.
95    pub media_type: String,
96}
97
98/// Lossiness classification for a conversion result.
99#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
100#[serde(rename_all = "snake_case")]
101pub enum LossinessLevel {
102    /// No known loss.
103    #[default]
104    None,
105    /// Minor field-level loss.
106    Low,
107    /// Material translation loss.
108    High,
109}
110
111/// Explicit accounting for translation loss.
112#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
113pub struct LossinessReport {
114    /// Overall lossiness level.
115    pub lossiness_level: LossinessLevel,
116    /// Number of unmapped fields encountered during translation.
117    pub unmapped_fields_count: u32,
118    /// Preserved raw payload reference, when available.
119    pub raw_payload_ref: Option<RawPayloadRef>,
120    /// Optional human-facing notes for diagnostics.
121    pub notes: Vec<String>,
122}
123
124/// Batch conversion result emitted by an adapter.
125#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
126pub struct AdapterBatch {
127    /// Canonical evidence events emitted by the adapter.
128    pub events: Vec<EvidenceEvent>,
129    /// Explicit lossiness metadata for the batch.
130    pub lossiness: LossinessReport,
131}
132
133/// Error category for adapter failures.
134#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
135#[serde(rename_all = "snake_case")]
136#[non_exhaustive]
137pub enum AdapterErrorKind {
138    /// Invalid adapter configuration.
139    Config,
140    /// Measurement or contract failure while parsing/validating input.
141    Measurement,
142    /// Host-side storage or attachment backend failure.
143    Infrastructure,
144    /// Upstream protocol version unsupported by this adapter.
145    UnsupportedProtocolVersion,
146    /// Strict mode rejected a lossy conversion.
147    StrictLossinessViolation,
148}
149
150/// Stable adapter error surface.
151#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Error)]
152#[error("{kind:?}: {message}")]
153pub struct AdapterError {
154    /// Error classification.
155    pub kind: AdapterErrorKind,
156    /// Human-readable failure message.
157    pub message: String,
158}
159
160impl AdapterError {
161    /// Create a new typed adapter error.
162    #[must_use]
163    pub fn new(kind: AdapterErrorKind, message: impl Into<String>) -> Self {
164        Self {
165            kind,
166            message: message.into(),
167        }
168    }
169}
170
171/// Host-provided interface for preserving raw protocol payloads.
172pub trait AttachmentWriter {
173    /// Persist a raw payload and return its digest-backed reference.
174    fn write_raw_payload(&self, payload: &[u8], media_type: &str) -> AdapterResult<RawPayloadRef>;
175}
176
177/// Stable contract implemented by protocol-specific adapters.
178pub trait ProtocolAdapter {
179    /// Return stable adapter implementation metadata.
180    fn adapter(&self) -> AdapterDescriptor;
181
182    /// Return stable protocol metadata.
183    fn protocol(&self) -> ProtocolDescriptor;
184
185    /// Return supported adapter capabilities.
186    fn capabilities(&self) -> AdapterCapabilities;
187
188    /// Convert a raw protocol payload into canonical evidence events.
189    fn convert(
190        &self,
191        input: AdapterInput<'_>,
192        options: &ConvertOptions,
193        attachments: &dyn AttachmentWriter,
194    ) -> AdapterResult<AdapterBatch>;
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200    use assay_evidence::types::EvidenceEvent;
201
202    struct StubWriter;
203
204    impl AttachmentWriter for StubWriter {
205        fn write_raw_payload(
206            &self,
207            payload: &[u8],
208            media_type: &str,
209        ) -> AdapterResult<RawPayloadRef> {
210            Ok(RawPayloadRef {
211                sha256: format!("sha256:{}", payload.len()),
212                size_bytes: payload.len() as u64,
213                media_type: media_type.to_string(),
214            })
215        }
216    }
217
218    struct StubAdapter;
219
220    impl ProtocolAdapter for StubAdapter {
221        fn adapter(&self) -> AdapterDescriptor {
222            AdapterDescriptor {
223                adapter_id: "assay-adapter-acp",
224                adapter_version: env!("CARGO_PKG_VERSION"),
225            }
226        }
227
228        fn protocol(&self) -> ProtocolDescriptor {
229            ProtocolDescriptor {
230                name: "acp".to_string(),
231                spec_version: "2.11.0".to_string(),
232                schema_id: Some("acp.packet".to_string()),
233                spec_url: Some("https://example.invalid/acp".to_string()),
234            }
235        }
236
237        fn capabilities(&self) -> AdapterCapabilities {
238            AdapterCapabilities {
239                supported_event_types: vec!["assay.adapter.acp.packet".to_string()],
240                supported_spec_versions: vec![">=2.11 <3.0".to_string()],
241                supports_strict: true,
242                supports_lenient: true,
243            }
244        }
245
246        fn convert(
247            &self,
248            input: AdapterInput<'_>,
249            options: &ConvertOptions,
250            attachments: &dyn AttachmentWriter,
251        ) -> AdapterResult<AdapterBatch> {
252            if matches!(options.mode, ConvertMode::Strict) && input.payload.is_empty() {
253                return Err(AdapterError::new(
254                    AdapterErrorKind::Measurement,
255                    "empty payload in strict mode",
256                ));
257            }
258
259            let raw_ref = attachments.write_raw_payload(input.payload, input.media_type)?;
260            let event = EvidenceEvent::new(
261                "assay.adapter.acp.packet",
262                "urn:assay:adapter:acp",
263                "run-1",
264                0,
265                serde_json::json!({"media_type": input.media_type}),
266            );
267
268            Ok(AdapterBatch {
269                events: vec![event],
270                lossiness: LossinessReport {
271                    lossiness_level: LossinessLevel::None,
272                    unmapped_fields_count: 0,
273                    raw_payload_ref: Some(raw_ref),
274                    notes: Vec::new(),
275                },
276            })
277        }
278    }
279
280    #[test]
281    fn strict_empty_payload_fails() {
282        let adapter = StubAdapter;
283        let writer = StubWriter;
284        let input = AdapterInput {
285            payload: &[],
286            media_type: "application/json",
287            protocol_version: Some("2.11.0"),
288        };
289        let err = adapter
290            .convert(input, &ConvertOptions::default(), &writer)
291            .expect_err("strict empty payload should fail");
292        assert_eq!(err.kind, AdapterErrorKind::Measurement);
293    }
294
295    #[test]
296    fn lenient_path_emits_event_and_raw_ref() {
297        let adapter = StubAdapter;
298        let writer = StubWriter;
299        let input = AdapterInput {
300            payload: br#"{"kind":"checkout"}"#,
301            media_type: "application/json",
302            protocol_version: Some("2.11.0"),
303        };
304        let batch = adapter
305            .convert(
306                input,
307                &ConvertOptions {
308                    mode: ConvertMode::Lenient,
309                    max_payload_bytes: Some(4096),
310                    max_json_depth: None,
311                    max_array_length: None,
312                },
313                &writer,
314            )
315            .expect("lenient conversion should succeed");
316        assert_eq!(batch.events.len(), 1);
317        assert_eq!(batch.lossiness.lossiness_level, LossinessLevel::None);
318        assert_eq!(
319            batch.lossiness.raw_payload_ref.expect("raw ref").size_bytes,
320            19
321        );
322    }
323
324    #[test]
325    fn adapter_descriptor_exposes_identity() {
326        let adapter = StubAdapter;
327        let descriptor = adapter.adapter();
328
329        assert_eq!(descriptor.adapter_id, "assay-adapter-acp");
330        assert!(!descriptor.adapter_version.is_empty());
331    }
332}