frame-conv 0.2.0

Conversation patterns — request-response, subscription, pub/sub, and workflow over liminal
Documentation
//! The pattern envelope and its wire codec (design §2.1/§2.3).
//!
//! One envelope rides every liminal ordinary record frame-conv publishes:
//! `{ patternId, correlation, kind, payload }`. The wire form is JSON with a
//! fixed field order and no floating-point or map-keyed content in the
//! envelope grammar itself, so encoding is byte-deterministic and pinned by
//! committed golden vectors. Payloads are the pattern's typed Rust messages;
//! validation IS serde at both ends (F-3a R1 — the pinned schema ruling).
//! Receivers refuse non-envelope bytes typed (closed `CONV_*` slugs), never
//! silently.

use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};

use crate::error::{EnvelopeError, slug};
use crate::id::{CorrelationId, MessageKind, PatternId};

/// One decoded, validated conversation envelope.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Envelope {
    /// Nominal pattern contract, matched exactly.
    pub pattern: PatternId,
    /// Typed correlation identity, caller-unique per exchange.
    pub correlation: CorrelationId,
    /// Message kind within the pattern's closed set.
    pub kind: MessageKind,
    /// The pattern's typed message, as its JSON value.
    pub payload: serde_json::Value,
}

/// Raw wire shape; field order here IS the committed byte order.
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct WireEnvelope {
    #[serde(rename = "patternId")]
    pattern_id: String,
    correlation: String,
    kind: String,
    payload: serde_json::Value,
}

impl Envelope {
    /// Builds an envelope from a typed message, refusing schema-invalid
    /// values BEFORE the wire (F-3a R1).
    ///
    /// # Errors
    ///
    /// Returns [`slug::CONV_SCHEMA_INVALID`] when the typed value does not
    /// serialize, and [`slug::CONV_KIND_INVALID`] when `kind` is outside the
    /// pattern's closed kind set.
    pub fn from_typed<T: Serialize>(
        pattern: PatternId,
        correlation: CorrelationId,
        kind: MessageKind,
        body: &T,
    ) -> Result<Self, EnvelopeError> {
        if !pattern.allows(kind) {
            return Err(EnvelopeError {
                slug: slug::CONV_KIND_INVALID,
                detail: format!("kind {kind} is not in pattern {pattern}'s closed set"),
            });
        }
        // serde_json maps non-finite floats to null SILENTLY (observed at
        // the bytes — the committed-red vector run); the finite wall makes
        // that a typed refusal instead of a silent corruption.
        crate::finite::check(body).map_err(|refusal| EnvelopeError {
            slug: slug::CONV_SCHEMA_INVALID,
            detail: refusal.detail().to_owned(),
        })?;
        let payload = serde_json::to_value(body).map_err(|error| EnvelopeError {
            slug: slug::CONV_SCHEMA_INVALID,
            detail: format!("typed payload did not serialize: {error}"),
        })?;
        Ok(Self {
            pattern,
            correlation,
            kind,
            payload,
        })
    }

    /// Decodes this envelope's payload into the pattern's typed message.
    ///
    /// # Errors
    ///
    /// Returns [`slug::CONV_SCHEMA_INVALID`] when the payload is not a valid
    /// value of the typed message — surfaced typed, never dropped (F-3a R1).
    pub fn decode_payload<T: DeserializeOwned>(&self) -> Result<T, EnvelopeError> {
        serde_json::from_value(self.payload.clone()).map_err(|error| EnvelopeError {
            slug: slug::CONV_SCHEMA_INVALID,
            detail: format!("typed payload did not deserialize: {error}"),
        })
    }

    /// Encodes the envelope to its deterministic wire bytes.
    ///
    /// # Errors
    ///
    /// Returns [`slug::CONV_ENVELOPE_MALFORMED`] if JSON encoding fails —
    /// unreachable for the envelope grammar, surfaced typed rather than
    /// swallowed if the impossible happens.
    pub fn encode(&self) -> Result<Vec<u8>, EnvelopeError> {
        let wire = WireEnvelope {
            pattern_id: self.pattern.as_str().to_owned(),
            correlation: self.correlation.to_string(),
            kind: self.kind.as_str().to_owned(),
            payload: self.payload.clone(),
        };
        serde_json::to_vec(&wire).map_err(|error| EnvelopeError {
            slug: slug::CONV_ENVELOPE_MALFORMED,
            detail: format!("envelope did not encode: {error}"),
        })
    }

    /// Decodes and validates wire bytes into a typed envelope.
    ///
    /// # Errors
    ///
    /// Returns the closed refusal set: [`slug::CONV_ENVELOPE_MALFORMED`] for
    /// bytes that are not the wire shape (unknown fields refused — the
    /// refuse-non-canonical-receiver discipline),
    /// [`slug::CONV_PATTERN_UNKNOWN`] for a pattern outside the contract
    /// set, [`slug::CONV_CORRELATION_INVALID`] for a correlation that is not
    /// a typed correlation identity, and [`slug::CONV_KIND_INVALID`] for a
    /// kind outside the named pattern's closed set.
    pub fn decode(bytes: &[u8]) -> Result<Self, EnvelopeError> {
        let wire: WireEnvelope = serde_json::from_slice(bytes).map_err(|error| EnvelopeError {
            slug: slug::CONV_ENVELOPE_MALFORMED,
            detail: format!("bytes are not a conversation envelope: {error}"),
        })?;
        let pattern = PatternId::parse_exact(&wire.pattern_id).ok_or_else(|| EnvelopeError {
            slug: slug::CONV_PATTERN_UNKNOWN,
            detail: format!("unknown pattern contract: {}", wire.pattern_id),
        })?;
        let correlation = wire.correlation.parse().map_err(|error| EnvelopeError {
            slug: slug::CONV_CORRELATION_INVALID,
            detail: format!("correlation {:?} is not typed: {error}", wire.correlation),
        })?;
        let kind = MessageKind::parse_exact(&wire.kind).ok_or_else(|| EnvelopeError {
            slug: slug::CONV_KIND_INVALID,
            detail: format!("unknown kind: {}", wire.kind),
        })?;
        if !pattern.allows(kind) {
            return Err(EnvelopeError {
                slug: slug::CONV_KIND_INVALID,
                detail: format!("kind {kind} is not in pattern {pattern}'s closed set"),
            });
        }
        Ok(Self {
            pattern,
            correlation,
            kind,
            payload: wire.payload,
        })
    }
}