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}