Skip to main content

a3s_code_core/research/
protocol.rs

1//! Versioned, language-neutral transport for native research contracts.
2//!
3//! Sibling modules own Rust-first research values. This module is the small
4//! wire boundary hosts use when projecting those values across a process or
5//! SDK. It carries no scientific rubric, publication decision, or Cloud
6//! business state.
7
8use super::{
9    ResearchCitationV1, ResearchClaimV1, ResearchContractError, ResearchEventV1,
10    ResearchEvidenceFactV1, ResearchEvidenceGraphV1, ResearchProvenanceReceiptV1,
11    ResearchReproducibilityManifestV1, ResearchRerunLineageV1, ResearchReviewBatchV1,
12    ResearchReviewFindingV1, ResearchRunV1, ResearchWorkflowPlanV1, ResearchWorkflowStepV1,
13};
14use serde::{de::DeserializeOwned, Deserialize, Serialize};
15use serde_json::Value;
16use thiserror::Error;
17
18/// Version carried by the research wire envelope.
19pub const RESEARCH_PROTOCOL_VERSION_V1: u16 = 1;
20
21/// Stable schema identifier for the cross-process research envelope.
22pub const RESEARCH_PROTOCOL_SCHEMA_V1: &str = "a3s.code.research-wire.v1";
23
24/// Maximum encoded envelope size accepted at a process boundary.
25pub const RESEARCH_PROTOCOL_MAX_MESSAGE_BYTES: usize = 32 * 1024 * 1024;
26
27macro_rules! define_research_wire_kinds_v1 {
28    ($( $variant:ident => $constant:ident = $wire_name:literal => $payload:ident ),+ $(,)?) => {
29        /// Closed top-level payload kinds in research wire version 1.
30        #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
31        #[serde(rename_all = "snake_case")]
32        pub enum ResearchWireKindV1 {
33            $( $variant, )+
34        }
35
36        /// Canonical string constants for the version-one wire catalog.
37        #[derive(Debug, Clone, Copy)]
38        pub struct ResearchWireTypeV1;
39
40        impl ResearchWireTypeV1 {
41            $( pub const $constant: &'static str = $wire_name; )+
42        }
43
44        /// One source-of-truth descriptor used by SDK artifact generation and
45        /// parity tests. The payload type is documentation metadata; Rust
46        /// validation still uses the concrete type in the match below.
47        #[derive(Debug, Clone, Copy, PartialEq, Eq)]
48        pub struct ResearchWireKindDescriptorV1 {
49            pub kind: ResearchWireKindV1,
50            pub wire_name: &'static str,
51            pub constant_name: &'static str,
52            pub payload_type: &'static str,
53        }
54
55        /// Ordered catalog known by research wire version 1.
56        pub const RESEARCH_WIRE_KIND_DESCRIPTORS_V1: &[ResearchWireKindDescriptorV1] = &[
57            $( ResearchWireKindDescriptorV1 {
58                kind: ResearchWireKindV1::$variant,
59                wire_name: $wire_name,
60                constant_name: stringify!($constant),
61                payload_type: stringify!($payload),
62            }, )+
63        ];
64
65        impl ResearchWireKindV1 {
66            /// Return the canonical wire spelling for this kind.
67            pub const fn wire_name(self) -> &'static str {
68                match self {
69                    $( Self::$variant => $wire_name, )+
70                }
71            }
72
73            /// Return the Rust payload type projected by this kind.
74            pub const fn payload_type(self) -> &'static str {
75                match self {
76                    $( Self::$variant => stringify!($payload), )+
77                }
78            }
79
80            /// Parse one canonical wire spelling without accepting aliases.
81            pub fn from_wire_name(value: &str) -> Option<Self> {
82                match value {
83                    $( $wire_name => Some(Self::$variant), )+
84                    _ => None,
85                }
86            }
87        }
88    };
89}
90
91// Keep this catalog intentionally boring and one-entry-per-line. The SDK
92// generator parses these lines, while Rust compiles the same list into the
93// enum, constants, and descriptors above.
94define_research_wire_kinds_v1! {
95    ResearchRun => RESEARCH_RUN = "research_run" => ResearchRunV1,
96    ResearchEvidenceFact => RESEARCH_EVIDENCE_FACT = "research_evidence_fact" => ResearchEvidenceFactV1,
97    ResearchClaim => RESEARCH_CLAIM = "research_claim" => ResearchClaimV1,
98    ResearchCitation => RESEARCH_CITATION = "research_citation" => ResearchCitationV1,
99    ResearchEvidenceGraph => RESEARCH_EVIDENCE_GRAPH = "research_evidence_graph" => ResearchEvidenceGraphV1,
100    ResearchWorkflowStep => RESEARCH_WORKFLOW_STEP = "research_workflow_step" => ResearchWorkflowStepV1,
101    ResearchWorkflowPlan => RESEARCH_WORKFLOW_PLAN = "research_workflow_plan" => ResearchWorkflowPlanV1,
102    ResearchRerunLineage => RESEARCH_RERUN_LINEAGE = "research_rerun_lineage" => ResearchRerunLineageV1,
103    ResearchProvenanceReceipt => RESEARCH_PROVENANCE_RECEIPT = "research_provenance_receipt" => ResearchProvenanceReceiptV1,
104    ResearchReproducibilityManifest => RESEARCH_REPRODUCIBILITY_MANIFEST = "research_reproducibility_manifest" => ResearchReproducibilityManifestV1,
105    ResearchReviewFinding => RESEARCH_REVIEW_FINDING = "research_review_finding" => ResearchReviewFindingV1,
106    ResearchReviewBatch => RESEARCH_REVIEW_BATCH = "research_review_batch" => ResearchReviewBatchV1,
107    ResearchEvent => RESEARCH_EVENT = "research_event" => ResearchEventV1,
108}
109
110/// Errors produced while decoding or validating a research wire message.
111#[derive(Debug, Clone, PartialEq, Eq, Error)]
112pub enum ResearchProtocolError {
113    #[error("research wire schema is unsupported")]
114    UnsupportedSchema,
115    #[error("research wire version {0} is unsupported")]
116    UnsupportedVersion(u16),
117    #[error("research wire kind is unknown")]
118    UnknownKind,
119    #[error("research wire field `{0}` is invalid")]
120    InvalidField(&'static str),
121    #[error("research wire payload is invalid: {0}")]
122    Payload(String),
123    #[error("research wire value exceeds its bounded encoding")]
124    Encoding,
125    #[error("research wire serialization failed: {0}")]
126    Serialization(String),
127}
128
129impl ResearchProtocolError {
130    /// Stable machine-readable error code for SDK and host boundaries.
131    pub const fn code(&self) -> &'static str {
132        match self {
133            Self::UnsupportedSchema => "a3s.code.research_protocol.unsupported_schema",
134            Self::UnsupportedVersion(_) => "a3s.code.research_protocol.unsupported_version",
135            Self::UnknownKind => "a3s.code.research_protocol.unknown_kind",
136            Self::InvalidField(_) => "a3s.code.research_protocol.invalid_field",
137            Self::Payload(_) => "a3s.code.research_protocol.payload",
138            Self::Encoding => "a3s.code.research_protocol.encoding",
139            Self::Serialization(_) => "a3s.code.research_protocol.serialization",
140        }
141    }
142}
143
144impl From<ResearchContractError> for ResearchProtocolError {
145    fn from(error: ResearchContractError) -> Self {
146        Self::Payload(error.to_string())
147    }
148}
149
150/// A strict, versioned envelope carrying one research contract value.
151///
152/// The envelope uses a JSON `Value` for the payload so the same transport can
153/// cross Node, Python, and Go without requiring those SDKs to instantiate Rust
154/// types. [`Self::validate`] immediately decodes the value into the closed
155/// payload type selected by `kind`, preserving Rust-side validation and
156/// rejecting unknown payload fields.
157#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
158#[serde(deny_unknown_fields)]
159pub struct ResearchWireEnvelopeV1 {
160    pub schema: String,
161    pub version: u16,
162    pub kind: ResearchWireKindV1,
163    pub payload: Value,
164}
165
166impl ResearchWireEnvelopeV1 {
167    /// Construct and validate an envelope from an arbitrary JSON payload.
168    pub fn new(kind: ResearchWireKindV1, payload: Value) -> Result<Self, ResearchProtocolError> {
169        let envelope = Self {
170            schema: RESEARCH_PROTOCOL_SCHEMA_V1.to_string(),
171            version: RESEARCH_PROTOCOL_VERSION_V1,
172            kind,
173            payload,
174        };
175        envelope.validate()?;
176        Ok(envelope)
177    }
178
179    /// Decode and validate a bounded JSON wire message.
180    pub fn from_slice(bytes: &[u8]) -> Result<Self, ResearchProtocolError> {
181        if bytes.len() > RESEARCH_PROTOCOL_MAX_MESSAGE_BYTES {
182            return Err(ResearchProtocolError::Encoding);
183        }
184        let value: Value = serde_json::from_slice(bytes)
185            .map_err(|error| ResearchProtocolError::Serialization(error.to_string()))?;
186        Self::from_value(value)
187    }
188
189    /// Decode and validate a JSON value at the process boundary.
190    pub fn from_value(value: Value) -> Result<Self, ResearchProtocolError> {
191        let encoded = serde_json::to_vec(&value)
192            .map_err(|error| ResearchProtocolError::Serialization(error.to_string()))?;
193        if encoded.len() > RESEARCH_PROTOCOL_MAX_MESSAGE_BYTES {
194            return Err(ResearchProtocolError::Encoding);
195        }
196        let kind = value
197            .get("kind")
198            .and_then(Value::as_str)
199            .ok_or(ResearchProtocolError::InvalidField("kind"))?;
200        if ResearchWireKindV1::from_wire_name(kind).is_none() {
201            return Err(ResearchProtocolError::UnknownKind);
202        }
203        let envelope: Self = serde_json::from_value(value)
204            .map_err(|error| ResearchProtocolError::Serialization(error.to_string()))?;
205        envelope.validate()?;
206        Ok(envelope)
207    }
208
209    /// Encode a validated envelope with the process-boundary size limit.
210    pub fn to_vec(&self) -> Result<Vec<u8>, ResearchProtocolError> {
211        self.validate()?;
212        let bytes = serde_json::to_vec(self)
213            .map_err(|error| ResearchProtocolError::Serialization(error.to_string()))?;
214        if bytes.len() > RESEARCH_PROTOCOL_MAX_MESSAGE_BYTES {
215            return Err(ResearchProtocolError::Encoding);
216        }
217        Ok(bytes)
218    }
219
220    /// Validate envelope identity, bounded encoding, and the selected payload.
221    pub fn validate(&self) -> Result<(), ResearchProtocolError> {
222        if self.schema != RESEARCH_PROTOCOL_SCHEMA_V1 {
223            return Err(ResearchProtocolError::UnsupportedSchema);
224        }
225        if self.version != RESEARCH_PROTOCOL_VERSION_V1 {
226            return Err(ResearchProtocolError::UnsupportedVersion(self.version));
227        }
228        let encoded = serde_json::to_vec(self)
229            .map_err(|error| ResearchProtocolError::Serialization(error.to_string()))?;
230        if encoded.len() > RESEARCH_PROTOCOL_MAX_MESSAGE_BYTES {
231            return Err(ResearchProtocolError::Encoding);
232        }
233
234        match self.kind {
235            ResearchWireKindV1::ResearchRun => {
236                let payload: ResearchRunV1 = self.decode_payload()?;
237                payload.validate()?;
238            }
239            ResearchWireKindV1::ResearchEvidenceFact => {
240                let payload: ResearchEvidenceFactV1 = self.decode_payload()?;
241                payload.validate()?;
242            }
243            ResearchWireKindV1::ResearchClaim => {
244                let payload: ResearchClaimV1 = self.decode_payload()?;
245                payload.validate()?;
246            }
247            ResearchWireKindV1::ResearchCitation => {
248                let payload: ResearchCitationV1 = self.decode_payload()?;
249                payload.validate()?;
250            }
251            ResearchWireKindV1::ResearchEvidenceGraph => {
252                let payload: ResearchEvidenceGraphV1 = self.decode_payload()?;
253                payload.validate()?;
254            }
255            ResearchWireKindV1::ResearchWorkflowStep => {
256                let payload: ResearchWorkflowStepV1 = self.decode_payload()?;
257                payload.validate()?;
258            }
259            ResearchWireKindV1::ResearchWorkflowPlan => {
260                let payload: ResearchWorkflowPlanV1 = self.decode_payload()?;
261                payload.validate()?;
262            }
263            ResearchWireKindV1::ResearchRerunLineage => {
264                let payload: ResearchRerunLineageV1 = self.decode_payload()?;
265                payload.validate()?;
266            }
267            ResearchWireKindV1::ResearchProvenanceReceipt => {
268                let payload: ResearchProvenanceReceiptV1 = self.decode_payload()?;
269                payload.validate()?;
270            }
271            ResearchWireKindV1::ResearchReproducibilityManifest => {
272                let payload: ResearchReproducibilityManifestV1 = self.decode_payload()?;
273                payload.validate()?;
274            }
275            ResearchWireKindV1::ResearchReviewFinding => {
276                let payload: ResearchReviewFindingV1 = self.decode_payload()?;
277                payload.validate()?;
278            }
279            ResearchWireKindV1::ResearchReviewBatch => {
280                let payload: ResearchReviewBatchV1 = self.decode_payload()?;
281                payload.validate()?;
282            }
283            ResearchWireKindV1::ResearchEvent => {
284                let payload: ResearchEventV1 = self.decode_payload()?;
285                payload.validate()?;
286            }
287        }
288        Ok(())
289    }
290
291    /// Return the payload kind without exposing a second string authority.
292    pub const fn kind(&self) -> ResearchWireKindV1 {
293        self.kind
294    }
295
296    /// Borrow the raw JSON payload for a host transport adapter.
297    pub fn payload(&self) -> &Value {
298        &self.payload
299    }
300
301    /// Construct a research-event envelope.
302    pub fn from_research_event(payload: ResearchEventV1) -> Result<Self, ResearchProtocolError> {
303        Self::from_typed(ResearchWireKindV1::ResearchEvent, payload)
304    }
305
306    /// Construct a reproducibility-manifest envelope.
307    pub fn from_reproducibility_manifest(
308        payload: ResearchReproducibilityManifestV1,
309    ) -> Result<Self, ResearchProtocolError> {
310        Self::from_typed(ResearchWireKindV1::ResearchReproducibilityManifest, payload)
311    }
312
313    /// Decode a typed payload after checking that the envelope kind matches.
314    pub fn payload_as<T>(&self, expected: ResearchWireKindV1) -> Result<T, ResearchProtocolError>
315    where
316        T: DeserializeOwned,
317    {
318        self.validate()?;
319        if self.kind != expected {
320            return Err(ResearchProtocolError::InvalidField("kind"));
321        }
322        self.decode_payload()
323    }
324
325    fn from_typed<T: Serialize>(
326        kind: ResearchWireKindV1,
327        payload: T,
328    ) -> Result<Self, ResearchProtocolError> {
329        let value = serde_json::to_value(payload)
330            .map_err(|error| ResearchProtocolError::Serialization(error.to_string()))?;
331        Self::new(kind, value)
332    }
333
334    fn decode_payload<T: DeserializeOwned>(&self) -> Result<T, ResearchProtocolError> {
335        serde_json::from_value(self.payload.clone())
336            .map_err(|error| ResearchProtocolError::Payload(error.to_string()))
337    }
338}
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343    use serde::Deserialize;
344
345    fn digest(ch: char) -> String {
346        format!("sha256:{}", ch.to_string().repeat(64))
347    }
348
349    fn fixture_event() -> ResearchEventV1 {
350        ResearchEventV1::new(
351            "project-1",
352            1,
353            Some("run-1".to_owned()),
354            1,
355            "research.run.admitted",
356            digest('1'),
357            1,
358        )
359        .unwrap()
360    }
361
362    #[test]
363    fn event_envelope_round_trips_and_rejects_unknown_kind() {
364        let event = fixture_event();
365        let envelope = ResearchWireEnvelopeV1::from_research_event(event.clone()).unwrap();
366        assert_eq!(envelope.kind(), ResearchWireKindV1::ResearchEvent);
367        let encoded = envelope.to_vec().unwrap();
368        let decoded = ResearchWireEnvelopeV1::from_slice(&encoded).unwrap();
369        let restored: ResearchEventV1 = decoded
370            .payload_as(ResearchWireKindV1::ResearchEvent)
371            .unwrap();
372        assert_eq!(restored, event);
373
374        let mut value = serde_json::to_value(&envelope).unwrap();
375        value["kind"] = Value::String("future_kind".to_owned());
376        assert_eq!(
377            ResearchWireEnvelopeV1::from_value(value),
378            Err(ResearchProtocolError::UnknownKind)
379        );
380    }
381
382    #[test]
383    fn envelope_rejects_unknown_top_level_and_version_drift() {
384        let envelope = ResearchWireEnvelopeV1::from_research_event(fixture_event()).unwrap();
385        let mut value = serde_json::to_value(&envelope).unwrap();
386        value["future_field"] = Value::Bool(true);
387        assert!(matches!(
388            ResearchWireEnvelopeV1::from_value(value.clone()),
389            Err(ResearchProtocolError::Serialization(_))
390        ));
391        value = serde_json::to_value(&envelope).unwrap();
392        value["version"] = Value::from(RESEARCH_PROTOCOL_VERSION_V1 + 1);
393        assert_eq!(
394            ResearchWireEnvelopeV1::from_value(value),
395            Err(ResearchProtocolError::UnsupportedVersion(
396                RESEARCH_PROTOCOL_VERSION_V1 + 1
397            ))
398        );
399    }
400
401    #[test]
402    fn generated_fixtures_accept_valid_and_reject_drift() {
403        #[derive(Deserialize)]
404        struct Fixtures {
405            valid: Value,
406            unknown_top_level_field: Value,
407            unknown_payload_field: Value,
408            unsupported_version: Value,
409        }
410        let fixtures: Fixtures = serde_json::from_str(include_str!(
411            "../../../sdk/research/research-wire-v1-fixtures.json"
412        ))
413        .expect("generated fixtures");
414        let valid = ResearchWireEnvelopeV1::from_value(fixtures.valid).unwrap();
415        assert_eq!(valid.kind(), ResearchWireKindV1::ResearchEvent);
416        assert!(matches!(
417            ResearchWireEnvelopeV1::from_value(fixtures.unknown_top_level_field),
418            Err(ResearchProtocolError::Serialization(_))
419        ));
420        assert!(ResearchWireEnvelopeV1::from_value(fixtures.unknown_payload_field).is_err());
421        assert_eq!(
422            ResearchWireEnvelopeV1::from_value(fixtures.unsupported_version),
423            Err(ResearchProtocolError::UnsupportedVersion(
424                RESEARCH_PROTOCOL_VERSION_V1 + 1
425            ))
426        );
427    }
428
429    #[test]
430    fn catalog_covers_every_research_contract_surface() {
431        assert_eq!(RESEARCH_WIRE_KIND_DESCRIPTORS_V1.len(), 13);
432        let names: Vec<_> = RESEARCH_WIRE_KIND_DESCRIPTORS_V1
433            .iter()
434            .map(|item| item.wire_name)
435            .collect();
436        assert!(names.contains(&"research_reproducibility_manifest"));
437        assert!(names.contains(&"research_event"));
438        assert_eq!(
439            names.len(),
440            names
441                .iter()
442                .collect::<std::collections::BTreeSet<_>>()
443                .len()
444        );
445    }
446}