Skip to main content

frame_conv/
envelope.rs

1//! The pattern envelope and its wire codec (design §2.1/§2.3).
2//!
3//! One envelope rides every liminal ordinary record frame-conv publishes:
4//! `{ patternId, correlation, kind, payload }`. The wire form is JSON with a
5//! fixed field order and no floating-point or map-keyed content in the
6//! envelope grammar itself, so encoding is byte-deterministic and pinned by
7//! committed golden vectors. Payloads are the pattern's typed Rust messages;
8//! validation IS serde at both ends (F-3a R1 — the pinned schema ruling).
9//! Receivers refuse non-envelope bytes typed (closed `CONV_*` slugs), never
10//! silently.
11
12use serde::de::DeserializeOwned;
13use serde::{Deserialize, Serialize};
14
15use crate::error::{EnvelopeError, slug};
16use crate::id::{CorrelationId, MessageKind, PatternId};
17
18/// One decoded, validated conversation envelope.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct Envelope {
21    /// Nominal pattern contract, matched exactly.
22    pub pattern: PatternId,
23    /// Typed correlation identity, caller-unique per exchange.
24    pub correlation: CorrelationId,
25    /// Message kind within the pattern's closed set.
26    pub kind: MessageKind,
27    /// The pattern's typed message, as its JSON value.
28    pub payload: serde_json::Value,
29}
30
31/// Raw wire shape; field order here IS the committed byte order.
32#[derive(Serialize, Deserialize)]
33#[serde(deny_unknown_fields)]
34struct WireEnvelope {
35    #[serde(rename = "patternId")]
36    pattern_id: String,
37    correlation: String,
38    kind: String,
39    payload: serde_json::Value,
40}
41
42impl Envelope {
43    /// Builds an envelope from a typed message, refusing schema-invalid
44    /// values BEFORE the wire (F-3a R1).
45    ///
46    /// # Errors
47    ///
48    /// Returns [`slug::CONV_SCHEMA_INVALID`] when the typed value does not
49    /// serialize, and [`slug::CONV_KIND_INVALID`] when `kind` is outside the
50    /// pattern's closed kind set.
51    pub fn from_typed<T: Serialize>(
52        pattern: PatternId,
53        correlation: CorrelationId,
54        kind: MessageKind,
55        body: &T,
56    ) -> Result<Self, EnvelopeError> {
57        if !pattern.allows(kind) {
58            return Err(EnvelopeError {
59                slug: slug::CONV_KIND_INVALID,
60                detail: format!("kind {kind} is not in pattern {pattern}'s closed set"),
61            });
62        }
63        // serde_json maps non-finite floats to null SILENTLY (observed at
64        // the bytes — the committed-red vector run); the finite wall makes
65        // that a typed refusal instead of a silent corruption.
66        crate::finite::check(body).map_err(|refusal| EnvelopeError {
67            slug: slug::CONV_SCHEMA_INVALID,
68            detail: refusal.detail().to_owned(),
69        })?;
70        let payload = serde_json::to_value(body).map_err(|error| EnvelopeError {
71            slug: slug::CONV_SCHEMA_INVALID,
72            detail: format!("typed payload did not serialize: {error}"),
73        })?;
74        Ok(Self {
75            pattern,
76            correlation,
77            kind,
78            payload,
79        })
80    }
81
82    /// Decodes this envelope's payload into the pattern's typed message.
83    ///
84    /// # Errors
85    ///
86    /// Returns [`slug::CONV_SCHEMA_INVALID`] when the payload is not a valid
87    /// value of the typed message — surfaced typed, never dropped (F-3a R1).
88    pub fn decode_payload<T: DeserializeOwned>(&self) -> Result<T, EnvelopeError> {
89        serde_json::from_value(self.payload.clone()).map_err(|error| EnvelopeError {
90            slug: slug::CONV_SCHEMA_INVALID,
91            detail: format!("typed payload did not deserialize: {error}"),
92        })
93    }
94
95    /// Encodes the envelope to its deterministic wire bytes.
96    ///
97    /// # Errors
98    ///
99    /// Returns [`slug::CONV_ENVELOPE_MALFORMED`] if JSON encoding fails —
100    /// unreachable for the envelope grammar, surfaced typed rather than
101    /// swallowed if the impossible happens.
102    pub fn encode(&self) -> Result<Vec<u8>, EnvelopeError> {
103        let wire = WireEnvelope {
104            pattern_id: self.pattern.as_str().to_owned(),
105            correlation: self.correlation.to_string(),
106            kind: self.kind.as_str().to_owned(),
107            payload: self.payload.clone(),
108        };
109        serde_json::to_vec(&wire).map_err(|error| EnvelopeError {
110            slug: slug::CONV_ENVELOPE_MALFORMED,
111            detail: format!("envelope did not encode: {error}"),
112        })
113    }
114
115    /// Decodes and validates wire bytes into a typed envelope.
116    ///
117    /// # Errors
118    ///
119    /// Returns the closed refusal set: [`slug::CONV_ENVELOPE_MALFORMED`] for
120    /// bytes that are not the wire shape (unknown fields refused — the
121    /// refuse-non-canonical-receiver discipline),
122    /// [`slug::CONV_PATTERN_UNKNOWN`] for a pattern outside the contract
123    /// set, [`slug::CONV_CORRELATION_INVALID`] for a correlation that is not
124    /// a typed correlation identity, and [`slug::CONV_KIND_INVALID`] for a
125    /// kind outside the named pattern's closed set.
126    pub fn decode(bytes: &[u8]) -> Result<Self, EnvelopeError> {
127        let wire: WireEnvelope = serde_json::from_slice(bytes).map_err(|error| EnvelopeError {
128            slug: slug::CONV_ENVELOPE_MALFORMED,
129            detail: format!("bytes are not a conversation envelope: {error}"),
130        })?;
131        let pattern = PatternId::parse_exact(&wire.pattern_id).ok_or_else(|| EnvelopeError {
132            slug: slug::CONV_PATTERN_UNKNOWN,
133            detail: format!("unknown pattern contract: {}", wire.pattern_id),
134        })?;
135        let correlation = wire.correlation.parse().map_err(|error| EnvelopeError {
136            slug: slug::CONV_CORRELATION_INVALID,
137            detail: format!("correlation {:?} is not typed: {error}", wire.correlation),
138        })?;
139        let kind = MessageKind::parse_exact(&wire.kind).ok_or_else(|| EnvelopeError {
140            slug: slug::CONV_KIND_INVALID,
141            detail: format!("unknown kind: {}", wire.kind),
142        })?;
143        if !pattern.allows(kind) {
144            return Err(EnvelopeError {
145                slug: slug::CONV_KIND_INVALID,
146                detail: format!("kind {kind} is not in pattern {pattern}'s closed set"),
147            });
148        }
149        Ok(Self {
150            pattern,
151            correlation,
152            kind,
153            payload: wire.payload,
154        })
155    }
156}